diff --git a/.betterer.results b/.betterer.results index 82da11f2cc6..cc04defd879 100644 --- a/.betterer.results +++ b/.betterer.results @@ -702,9 +702,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -720,15 +718,11 @@ exports[`better eslint`] = { ], "packages/grafana-ui/src/components/Table/TableNG/utils.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "packages/grafana-ui/src/components/Table/TableNG/utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "packages/grafana-ui/src/components/Table/TableRT/Filter.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] diff --git a/.citools/src/golangci-lint/go.mod b/.citools/src/golangci-lint/go.mod index bdc3e9bba79..9fcd1645750 100644 --- a/.citools/src/golangci-lint/go.mod +++ b/.citools/src/golangci-lint/go.mod @@ -65,7 +65,7 @@ require ( github.com/go-toolsmith/astp v1.1.0 // indirect github.com/go-toolsmith/strparse v1.1.0 // indirect github.com/go-toolsmith/typep v1.1.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gofrs/flock v0.12.1 // indirect diff --git a/.citools/src/golangci-lint/go.sum b/.citools/src/golangci-lint/go.sum index 6d1048df76f..6f217936896 100644 --- a/.citools/src/golangci-lint/go.sum +++ b/.citools/src/golangci-lint/go.sum @@ -142,8 +142,8 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= diff --git a/.citools/src/lefthook/go.mod b/.citools/src/lefthook/go.mod index 85e4a8e5c14..ba6572071f8 100644 --- a/.citools/src/lefthook/go.mod +++ b/.citools/src/lefthook/go.mod @@ -18,7 +18,7 @@ require ( github.com/evilmartians/lefthook v1.4.8 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/.citools/src/lefthook/go.sum b/.citools/src/lefthook/go.sum index b6db57ac497..346af51fc19 100644 --- a/.citools/src/lefthook/go.sum +++ b/.citools/src/lefthook/go.sum @@ -29,8 +29,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/.citools/src/swagger/go.mod b/.citools/src/swagger/go.mod index 33ca8159a8f..344f0c95653 100644 --- a/.citools/src/swagger/go.mod +++ b/.citools/src/swagger/go.mod @@ -24,7 +24,7 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect github.com/go-swagger/go-swagger v0.30.6-0.20240310114303-db51e79a0e37 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect diff --git a/.citools/src/swagger/go.sum b/.citools/src/swagger/go.sum index dd473c80d87..82e705386e1 100644 --- a/.citools/src/swagger/go.sum +++ b/.citools/src/swagger/go.sum @@ -41,8 +41,8 @@ github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3Bum github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-swagger/go-swagger v0.30.6-0.20240310114303-db51e79a0e37 h1:KFcZmKdZmapAog2+eL1buervAYrYolBZk7fMecPPDmo= github.com/go-swagger/go-swagger v0.30.6-0.20240310114303-db51e79a0e37/go.mod h1:i1/E+d8iPNReSE7y04FaVu5OPKB3il5cn+T1Egogg3I= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e71c286f32b..5c5de6c7246 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -773,6 +773,7 @@ embed.go @grafana/grafana-as-code /.github/actions/setup-enterprise/action.yml @grafana/grafana-backend-group /.github/actions/setup-grafana-bench/ @Proximyst /.github/actions/build-package @grafana/grafana-developer-enablement-squad +/.github/actions/change-detection @grafana/grafana-developer-enablement-squad /.github/workflows/actionlint-format.txt @grafana/grafana-developer-enablement-squad /.github/workflows/actionlint.yml @grafana/grafana-developer-enablement-squad /.github/workflows/add-to-whats-new.yml @grafana/docs-tooling diff --git a/.github/actions/change-detection/action.yml b/.github/actions/change-detection/action.yml new file mode 100644 index 00000000000..b8864e985e2 --- /dev/null +++ b/.github/actions/change-detection/action.yml @@ -0,0 +1,141 @@ +name: Detect changed files +description: Detects whether any matching files have changed in the current PR +inputs: + self: + description: The path to the calling workflow (e.g. .github/workflows/backend-unit-tests.yml). It is regarded as any category. + required: true +outputs: + self: + description: Whether the calling workflow has changed in any way + value: ${{ steps.changed-files.outputs.self_any_changed || 'true' }} + backend: + description: Whether the backend or self have changed in any way + value: ${{ steps.changed-files.outputs.backend_any_changed || 'true' }} + frontend: + description: Whether the frontend or self has changed in any way + value: ${{ steps.changed-files.outputs.frontend_any_changed || 'true' }} + e2e: + description: Whether the e2e tests or self have changed in any way + value: ${{ steps.changed-files.outputs.e2e_any_changed == 'true' || + steps.changed-files.outputs.backend_any_changed == 'true' || + steps.changed-files.outputs.frontend_any_changed == 'true' || 'true' }} + dev-tooling: + description: Whether the dev tooling or self have changed in any way + value: ${{ steps.changed-files.outputs.dev_tooling_any_changed || 'true' }} + docs: + description: Whether the docs or self have changed in any way + value: ${{ steps.changed-files.outputs.docs_any_changed || 'true' }} +runs: + using: composite + steps: + # Assumption: We've done a checkout with the actions/checkout action. + # It must persist credentials to allow the changed-files action to get more history. + - name: Detect changes + id: changed-files + if: github.event_name == 'pull_request' + uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46 + with: + files_yaml: | + self: + - '.github/actions/change-detection/**' + - '${{ inputs.self }}' + backend: + - '!*.md' + - '!docs/**' + - '!.github/**' + - '.github/actions/setup-enterprise/**' + - '.github/actions/checkout/**' + - '**/go.mod' + - '**/go.sum' + - '**.go' + - 'pkg/**' + - '!pkg/**.md' + - 'apps/**' + - '!apps/**.md' + - 'build.sh' + - '.github/actions/change-detection/**' + - '**.cue' + - 'devenv/docker/blocks/*_tests/**' + - 'kindsv2/**' + - '${{ inputs.self }}' + frontend: + - '.github/actions/setup-enterprise/**' + - '.github/actions/checkout/**' + - 'public/**' + - '**.js' + - '**.jsx' + - '**.ts' + - '**.tsx' + - '**.css' + - '**.mjs' + - 'yarn.lock' + - 'package.json' + - '!**.md' + - '.github/actions/change-detection/**' + - '**.cue' + - '.prettier*' + - '.betterer*' + - '.yarnrc.yml' + - 'eslint.config.js' + - 'jest.config.js' + - 'nx.json' + - 'tsconfig.json' + - '.yarn/**' + - '${{ inputs.self }}' + e2e: + - 'e2e/**' + - '.github/actions/setup-enterprise/**' + - '.github/actions/checkout/**' + - 'emails/**' + - 'pkg/**' + - 'proto/**' + - '**/Makefile' + - 'scripts/**' + - '!scripts/drone/**' + - '!**.md' + - '.github/actions/change-detection/**' + - '**.cue' + - 'conf/**' + - 'cypress.config.js' + - '${{ inputs.self }}' + dev_tooling: + - '.github/actions/setup-enterprise/**' + - '.github/actions/checkout/**' + - '**.sh' + - '.trivyignore' + - '.prettierrc.js' + - '**/Makefile' + - 'proto/**.yaml' + - 'pkg/build/**' + - 'pkg/wire/**' + - 'scripts/**' + - '!**.md' + - '.citools/**' + - '.bingo/**' + - '.github/actions/change-detection/**' + - '${{ inputs.self }}' + docs: + - 'contribute/**' + - 'docs/**' + - '**.md' + - 'LICENSE' + - '.vale.ini' + - '.github/actions/change-detection/**' + - '${{ inputs.self }}' + - name: Print all change groups + shell: bash + run: | + echo "Self: ${{ steps.changed-files.outputs.self_any_changed || 'true' }}" + echo " --> ${{ steps.changed-files.outputs.self_all_changed_files }}" + echo "Backend: ${{ steps.changed-files.outputs.backend_any_changed || 'true' }}" + echo " --> ${{ steps.changed-files.outputs.backend_all_changed_files }}" + echo "Frontend: ${{ steps.changed-files.outputs.frontend_any_changed || 'true' }}" + echo " --> ${{ steps.changed-files.outputs.frontend_all_changed_files }}" + echo "E2E: ${{ steps.changed-files.outputs.e2e_any_changed || 'true' }}" + echo " --> ${{ steps.changed-files.outputs.e2e_all_changed_files }}" + echo " --> ${{ steps.changed-files.outputs.backend_all_changed_files }}" + echo " --> ${{ steps.changed-files.outputs.frontend_all_changed_files }}" + echo "Dev Tooling: ${{ steps.changed-files.outputs.dev_tooling_any_changed || 'true' }}" + echo " --> ${{ steps.changed-files.outputs.dev_tooling_all_changed_files }}" + echo "Docs: ${{ steps.changed-files.outputs.docs_any_changed || 'true' }}" + echo " --> ${{ steps.changed-files.outputs.docs_all_changed_files }}" diff --git a/.github/actions/setup-enterprise/action.yml b/.github/actions/setup-enterprise/action.yml index 37c09911fc8..134a7e7f004 100644 --- a/.github/actions/setup-enterprise/action.yml +++ b/.github/actions/setup-enterprise/action.yml @@ -34,9 +34,9 @@ runs: GH_TOKEN: ${{ steps.generate_token.outputs.token }} run: | git clone https://x-access-token:${GH_TOKEN}@github.com/grafana/grafana-enterprise.git ../grafana-enterprise; - + cd ../grafana-enterprise - + if git checkout ${GITHUB_HEAD_REF}; then echo "checked out ${GITHUB_HEAD_REF}" elif git checkout ${GITHUB_BASE_REF}; then @@ -44,5 +44,5 @@ runs: else git checkout main fi - - ./build.sh + + QUIET=1 ./build.sh diff --git a/.github/workflows/backend-unit-tests.yml b/.github/workflows/backend-unit-tests.yml index 59943c8b99f..07663eb8631 100644 --- a/.github/workflows/backend-unit-tests.yml +++ b/.github/workflows/backend-unit-tests.yml @@ -14,10 +14,29 @@ concurrency: permissions: {} jobs: + detect-changes: + name: Detect whether code changed + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + changed: ${{ steps.detect-changes.outputs.backend }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true # required to get more history in the changed-files action + fetch-depth: 2 + - name: Detect changes + id: detect-changes + uses: ./.github/actions/change-detection + with: + self: .github/workflows/backend-unit-tests.yml + grafana: # Run this workflow only for PRs from forks # the `pr-backend-unit-tests-enterprise` workflow will run instead - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true + needs: detect-changes + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && needs.detect-changes.outputs.changed == 'true' strategy: matrix: shard: [ @@ -53,8 +72,8 @@ jobs: grafana-enterprise: # Run this workflow for non-PR events (like pushes to `main` or `release-*`) OR for internal PRs (PRs not from forks) - # If it gets merged into `main` or `release-*`, then a junit test result is uploaded to GCS. - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false + needs: detect-changes + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false && needs.detect-changes.outputs.changed == 'true' strategy: matrix: shard: [ diff --git a/.github/workflows/frontend-lint.yml b/.github/workflows/frontend-lint.yml index b9949fc74f6..e5ceb117eea 100644 --- a/.github/workflows/frontend-lint.yml +++ b/.github/workflows/frontend-lint.yml @@ -9,13 +9,32 @@ on: permissions: {} jobs: + detect-changes: + name: Detect whether code changed + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + changed: ${{ steps.detect-changes.outputs.frontend }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true # required to get more history in the changed-files action + fetch-depth: 2 + - name: Detect changes + id: detect-changes + uses: ./.github/actions/change-detection + with: + self: .github/workflows/frontend-lint.yml + lint-frontend-prettier: + needs: detect-changes permissions: contents: read id-token: write # Run this workflow only for PRs from forks; if it gets merged into `main` or `release-*`, # the `lint-frontend-prettier-enterprise` workflow will run instead - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && needs.detect-changes.outputs.changed == 'true' name: Lint runs-on: ubuntu-latest steps: @@ -31,11 +50,12 @@ jobs: - run: yarn run prettier:check - run: yarn run lint lint-frontend-prettier-enterprise: + needs: detect-changes permissions: contents: read id-token: write # Run this workflow for non-PR events (like pushes to `main` or `release-*`) OR for internal PRs (PRs not from forks) - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false && needs.detect-changes.outputs.changed == 'true' name: Lint runs-on: ubuntu-latest steps: @@ -55,12 +75,13 @@ jobs: - run: yarn run prettier:check - run: yarn run lint lint-frontend-typecheck: + needs: detect-changes permissions: contents: read id-token: write # Run this workflow only for PRs from forks; if it gets merged into `main` or `release-*`, # the `lint-frontend-typecheck-enterprise` workflow will run instead - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && needs.detect-changes.outputs.changed == 'true' name: Typecheck runs-on: ubuntu-latest steps: @@ -75,11 +96,12 @@ jobs: - run: yarn install --immutable --check-cache - run: yarn run typecheck lint-frontend-typecheck-enterprise: + needs: detect-changes permissions: contents: read id-token: write # Run this workflow for non-PR events (like pushes to `main` or `release-*`) OR for internal PRs (PRs not from forks) - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false && needs.detect-changes.outputs.changed == 'true' name: Typecheck runs-on: ubuntu-latest steps: @@ -98,9 +120,11 @@ jobs: - run: yarn install --immutable --check-cache - run: yarn run typecheck lint-frontend-betterer: + needs: detect-changes permissions: contents: read id-token: write + if: needs.detect-changes.outputs.changed == 'true' name: Betterer runs-on: ubuntu-latest steps: diff --git a/.github/workflows/pr-e2e-tests.yml b/.github/workflows/pr-e2e-tests.yml index bf663324f57..ec74716f7e3 100644 --- a/.github/workflows/pr-e2e-tests.yml +++ b/.github/workflows/pr-e2e-tests.yml @@ -14,7 +14,27 @@ concurrency: permissions: {} jobs: + detect-changes: + name: Detect whether code changed + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + changed: ${{ steps.detect-changes.outputs.e2e }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true # required to get more history in the changed-files action + fetch-depth: 2 + - name: Detect changes + id: detect-changes + uses: ./.github/actions/change-detection + with: + self: .github/workflows/pr-e2e-tests.yml + build-grafana: + needs: detect-changes + if: needs.detect-changes.outputs.changed == 'true' name: Build & Package Grafana runs-on: ubuntu-latest-16-cores permissions: @@ -41,6 +61,8 @@ jobs: path: grafana.tar.gz build-e2e-runner: + needs: detect-changes + if: needs.detect-changes.outputs.changed == 'true' name: Build E2E test runner runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/pr-frontend-unit-tests.yml b/.github/workflows/pr-frontend-unit-tests.yml index 59eb9c794ea..9e217c6ee46 100644 --- a/.github/workflows/pr-frontend-unit-tests.yml +++ b/.github/workflows/pr-frontend-unit-tests.yml @@ -9,13 +9,32 @@ on: permissions: {} jobs: + detect-changes: + name: Detect whether code changed + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + changed: ${{ steps.detect-changes.outputs.frontend }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true # required to get more history in the changed-files action + fetch-depth: 2 + - name: Detect changes + id: detect-changes + uses: ./.github/actions/change-detection + with: + self: .github/workflows/pr-frontend-unit-tests.yml + frontend-unit-tests: permissions: contents: read id-token: write # Run this workflow only for PRs from forks; if it gets merged into `main` or `release-*`, # the `frontend-unit-tests-enterprise` workflow will run instead - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true + needs: detect-changes + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && needs.detect-changes.outputs.changed == 'true' runs-on: ubuntu-latest-8-cores name: "Unit tests (${{ matrix.chunk }} / 8)" strategy: @@ -43,7 +62,8 @@ jobs: contents: read id-token: write # Run this workflow for non-PR events (like pushes to `main` or `release-*`) OR for internal PRs (PRs not from forks) - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false + needs: detect-changes + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false && needs.detect-changes.outputs.changed == 'true' runs-on: ubuntu-latest-8-cores name: "Unit tests (${{ matrix.chunk }} / 8)" strategy: diff --git a/Makefile b/Makefile index 97d6da04774..7973fd5f679 100644 --- a/Makefile +++ b/Makefile @@ -90,6 +90,7 @@ swagger-enterprise-gen: ## Generate API Swagger specification -x "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options" \ -x "github.com/prometheus/alertmanager" \ -i pkg/api/swagger_tags.json \ + -t enterprise \ --exclude-tag=alpha \ --include-tag=enterprise endif @@ -189,10 +190,18 @@ gen-feature-toggles: go test -v ./pkg/services/featuremgmt/...; \ fi -.PHONY: gen-go -gen-go: - @echo "generate go files" - $(GO) run $(GO_RACE_FLAG) ./pkg/build/wire/cmd/wire/main.go gen -tags $(WIRE_TAGS) ./pkg/server +.PHONY: gen-go gen-enterprise-go +ifeq ("$(wildcard $(ENTERPRISE_EXT_FILE))","") ## if enterprise is not enabled +gen-enterprise-go: + @echo "skipping re-generating Wire graph for enterprise: not enabled" +else +gen-enterprise-go: ## Generate Wire graph (Enterprise) + @echo "re-generating Wire graph for enterprise" + $(GO) run ./pkg/build/wire/cmd/wire/main.go gen -tags "enterprise" -gen_tags "(enterprise || pro)" -output_file_prefix="enterprise_" ./pkg/server +endif +gen-go: gen-enterprise-go ## Generate Wire graph + @echo "generatng Wire graph" + $(GO) run ./pkg/build/wire/cmd/wire/main.go gen -tags "oss" -gen_tags "(!enterprise && !pro)" ./pkg/server .PHONY: fix-cue fix-cue: diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 059176c28dd..45a7ed0b51e 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -24,6 +24,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.9 // indirect @@ -32,7 +33,7 @@ require ( github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb // indirect - github.com/grafana/grafana-app-sdk/logging v0.35.0 // indirect + github.com/grafana/grafana-app-sdk/logging v0.38.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -49,32 +50,32 @@ require ( github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/common v0.63.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/sdk v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/sdk v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect go.opentelemetry.io/proto/otlp v1.6.0 // indirect - golang.org/x/crypto v0.38.0 // indirect - golang.org/x/net v0.40.0 // indirect - golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sync v0.14.0 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/oauth2 v0.29.0 // indirect + golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.9.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect - google.golang.org/grpc v1.72.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect + google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 164b5efcca3..e7629867703 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -36,6 +36,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -60,10 +62,12 @@ github.com/grafana/grafana-app-sdk v0.30.0 h1:Hqn2pETu2mQ4RpWkZYEQfu01P7xd1Z1Gj+ github.com/grafana/grafana-app-sdk v0.30.0/go.mod h1:jhfqNIovb+Mes2vdMf9iMCWQkp1GTNtyNuExONtiNuk= github.com/grafana/grafana-app-sdk v0.31.0/go.mod h1:Xw00NL7qpRLo5r3Gn48Bl1Xn2n4eUDI5pYf/wMufKWs= github.com/grafana/grafana-app-sdk v0.35.1/go.mod h1:Zx5MkVppYK+ElSDUAR6+fjzOVo6I/cIgk+ty+LmNOxI= +github.com/grafana/grafana-app-sdk v0.39.0/go.mod h1:xRyBQOttgWTc3tGe9pI0upnpEPVhzALf7Mh/61O4zyY= github.com/grafana/grafana-app-sdk/logging v0.29.0 h1:mgbXaAf33aFwqwGVeaX30l8rkeAJH0iACgX5Rn6YkN4= github.com/grafana/grafana-app-sdk/logging v0.29.0/go.mod h1:xy6ZyVXl50Z3DBDLybvBPphbykPhuVNed/VNmen9DQM= github.com/grafana/grafana-app-sdk/logging v0.30.0/go.mod h1:xy6ZyVXl50Z3DBDLybvBPphbykPhuVNed/VNmen9DQM= github.com/grafana/grafana-app-sdk/logging v0.35.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk= +github.com/grafana/grafana-app-sdk/logging v0.38.2/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M= @@ -125,6 +129,7 @@ github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQy github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ= github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= @@ -152,32 +157,39 @@ go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0/go.mod h1:179AK5aar5R3eS9FucPy6rggvU0g52cvKId8pv4+v0c= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= @@ -188,6 +200,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -202,9 +215,11 @@ golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -213,6 +228,7 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -233,6 +249,7 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -253,16 +270,19 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d/go. google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o= google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34/go.mod h1:0awUlEkap+Pb1UMeJwJQQAdJQrt3moU7J2moTy69irI= +google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw= google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= @@ -281,18 +301,22 @@ k8s.io/api v0.32.0 h1:OL9JpbvAU5ny9ga2fb24X8H6xQlVp+aJMFlgtQjR9CE= k8s.io/api v0.32.0/go.mod h1:4LEwHZEf6Q/cG96F3dqR965sYOfmPM7rq81BLgsE0p0= k8s.io/api v0.32.1/go.mod h1:/Yi/BqkuueW1BgpoePYBRdDYfjPF5sgTr5+YqDZra5k= k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= +k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw= k8s.io/apiextensions-apiserver v0.32.0 h1:S0Xlqt51qzzqjKPxfgX1xh4HBZE+p8KKBq+k2SWNOE0= k8s.io/apiextensions-apiserver v0.32.0/go.mod h1:86hblMvN5yxMvZrZFX2OhIHAuFIMJIZ19bTvzkP+Fmw= k8s.io/apiextensions-apiserver v0.32.1/go.mod h1:sxWIGuGiYov7Io1fAS2X06NjMIk5CbRHc2StSmbaQto= k8s.io/apiextensions-apiserver v0.32.3/go.mod h1:8YwcvVRMVzw0r1Stc7XfGAzB/SIVLunqApySV5V7Dss= +k8s.io/apiextensions-apiserver v0.33.1/go.mod h1:uNQ52z1A1Gu75QSa+pFK5bcXc4hq7lpOXbweZgi4dqA= k8s.io/apimachinery v0.32.0 h1:cFSE7N3rmEEtv4ei5X6DaJPHHX0C+upp+v5lVPiEwpg= k8s.io/apimachinery v0.32.0/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= k8s.io/apimachinery v0.32.1/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= k8s.io/client-go v0.32.0 h1:DimtMcnN/JIKZcrSrstiwvvZvLjG0aSxy8PxN8IChp8= k8s.io/client-go v0.32.0/go.mod h1:boDWvdM1Drk4NJj/VddSLnx59X3OPgwrOo0vGbtq9+8= k8s.io/client-go v0.32.1/go.mod h1:aTTKZY7MdxUaJ/KiUs8D+GssR9zJZi77ZqtzcGXIiDg= k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= +k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/types.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/types.go index c12ae17cbf8..1589fc62c8a 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/types.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/types.go @@ -82,9 +82,12 @@ type LibraryPanelSpec struct { // The panel type PluginVersion string `json:"pluginVersion,omitempty"` - // The panel title + // The title of the library panel Title string `json:"title,omitempty"` + // The title of the panel when displayed in the dashboard + PanelTitle string `json:"panelTitle,omitempty"` + // Library panel description Description string `json:"description,omitempty"` @@ -97,11 +100,28 @@ type LibraryPanelSpec struct { // The default datasource type Datasource *data.DataSourceRef `json:"datasource,omitempty"` + // The grid position + GridPos GridPos `json:"gridPos,omitempty"` + + // Whether the panel is transparent + Transparent bool `json:"transparent,omitempty"` + + // The links for the panel + Links []common.Unstructured `json:"links,omitempty"` + // The datasource queries // +listType=set Targets []data.DataQuery `json:"targets,omitempty"` } +// +k8s:deepcopy-gen=true +type GridPos struct { + W int `json:"w"` + H int `json:"h"` + X int `json:"x"` + Y int `json:"y"` +} + // +k8s:deepcopy-gen=true type LibraryPanelStatus struct { // Translation warnings (mostly things that were in SQL columns but not found in the saved body) diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go index 8d851c4f6c2..99a44dde4e3 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go @@ -9,6 +9,7 @@ package v0alpha1 import ( datav0alpha1 "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -190,6 +191,22 @@ func (in *FacetResult) DeepCopy() *FacetResult { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GridPos) DeepCopyInto(out *GridPos) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GridPos. +func (in *GridPos) DeepCopy() *GridPos { + if in == nil { + return nil + } + out := new(GridPos) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LibraryPanel) DeepCopyInto(out *LibraryPanel) { *out = *in @@ -265,6 +282,14 @@ func (in *LibraryPanelSpec) DeepCopyInto(out *LibraryPanelSpec) { *out = new(datav0alpha1.DataSourceRef) **out = **in } + out.GridPos = in.GridPos + if in.Links != nil { + in, out := &in.Links, &out.Links + *out = make([]commonv0alpha1.Unstructured, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.Targets != nil { in, out := &in.Targets, &out.Targets *out = make([]datav0alpha1.DataQuery, len(*in)) diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go index 1b818580c45..13faf44445d 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go @@ -29,6 +29,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.DashboardVersionList": schema_pkg_apis_dashboard_v0alpha1_DashboardVersionList(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.DashboardWithAccessInfo": schema_pkg_apis_dashboard_v0alpha1_DashboardWithAccessInfo(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.FacetResult": schema_pkg_apis_dashboard_v0alpha1_FacetResult(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.GridPos": schema_pkg_apis_dashboard_v0alpha1_GridPos(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.LibraryPanel": schema_pkg_apis_dashboard_v0alpha1_LibraryPanel(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.LibraryPanelList": schema_pkg_apis_dashboard_v0alpha1_LibraryPanelList(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.LibraryPanelSpec": schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref), @@ -713,6 +714,47 @@ func schema_pkg_apis_dashboard_v0alpha1_FacetResult(ref common.ReferenceCallback } } +func schema_pkg_apis_dashboard_v0alpha1_GridPos(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "w": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int32", + }, + }, + "h": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int32", + }, + }, + "x": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int32", + }, + }, + "y": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int32", + }, + }, + }, + Required: []string{"w", "h", "x", "y"}, + }, + }, + } +} + func schema_pkg_apis_dashboard_v0alpha1_LibraryPanel(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -833,7 +875,14 @@ func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref common.ReferenceCal }, "title": { SchemaProps: spec.SchemaProps{ - Description: "The panel title", + Description: "The title of the library panel", + Type: []string{"string"}, + Format: "", + }, + }, + "panelTitle": { + SchemaProps: spec.SchemaProps{ + Description: "The title of the panel when displayed in the dashboard", Type: []string{"string"}, Format: "", }, @@ -863,6 +912,33 @@ func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref common.ReferenceCal Ref: ref("github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataSourceRef"), }, }, + "gridPos": { + SchemaProps: spec.SchemaProps{ + Description: "The grid position", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.GridPos"), + }, + }, + "transparent": { + SchemaProps: spec.SchemaProps{ + Description: "Whether the panel is transparent", + Type: []string{"boolean"}, + Format: "", + }, + }, + "links": { + SchemaProps: spec.SchemaProps{ + Description: "The links for the panel", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), + }, + }, + }, + }, + }, "targets": { VendorExtensible: spec.VendorExtensible{ Extensions: spec.Extensions{ @@ -886,7 +962,7 @@ func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref common.ReferenceCal }, }, Dependencies: []string{ - "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery", "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataSourceRef", "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"}, + "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery", "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataSourceRef", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.GridPos", "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"}, } } diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi_violation_exceptions.list b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi_violation_exceptions.list index 5003217ce4e..528e029553f 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -1,6 +1,7 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,DashboardHit,Tags API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,DashboardMetadata,Finalizers API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,FacetResult,Terms +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,LibraryPanelSpec,Links API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,LibraryPanelStatus,Warnings API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,SearchResults,Hits API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1,SortableFields,Fields diff --git a/apps/investigations/go.mod b/apps/investigations/go.mod index fe752f7ab09..f37ff9db9e4 100644 --- a/apps/investigations/go.mod +++ b/apps/investigations/go.mod @@ -24,14 +24,15 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.38.2 // indirect github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb // indirect + github.com/grafana/grafana-app-sdk/logging v0.38.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -58,27 +59,27 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/sdk v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/sdk v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect go.opentelemetry.io/proto/otlp v1.6.0 // indirect - golang.org/x/crypto v0.38.0 // indirect - golang.org/x/net v0.40.0 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/net v0.41.0 // indirect golang.org/x/oauth2 v0.29.0 // indirect - golang.org/x/sync v0.14.0 // indirect + golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.33.0 // indirect + golang.org/x/tools v0.34.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect - google.golang.org/grpc v1.72.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect + google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/apps/investigations/go.sum b/apps/investigations/go.sum index 08f6c1c3849..fbf07786493 100644 --- a/apps/investigations/go.sum +++ b/apps/investigations/go.sum @@ -35,6 +35,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -55,8 +57,10 @@ github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb h1:oTl2j6/4miQUYmX github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb/go.mod h1:PBtQaXwkFu4BAt2aXsR7w8p8NVpdjV5aJYhqRDei9Us= github.com/grafana/grafana-app-sdk v0.35.1 h1:zEXubzsQrxGBOzXJJMBwhEClC/tvPi0sfK7NGmlX3RI= github.com/grafana/grafana-app-sdk v0.35.1/go.mod h1:Zx5MkVppYK+ElSDUAR6+fjzOVo6I/cIgk+ty+LmNOxI= +github.com/grafana/grafana-app-sdk v0.39.0/go.mod h1:xRyBQOttgWTc3tGe9pI0upnpEPVhzALf7Mh/61O4zyY= github.com/grafana/grafana-app-sdk/logging v0.35.1 h1:taVpl+RoixTYl0JBJGhH+fPVmwA9wvdwdzJTZsv9buM= github.com/grafana/grafana-app-sdk/logging v0.35.1/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk= +github.com/grafana/grafana-app-sdk/logging v0.38.2/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -139,20 +143,27 @@ go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJyS go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0/go.mod h1:179AK5aar5R3eS9FucPy6rggvU0g52cvKId8pv4+v0c= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= @@ -163,6 +174,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -173,6 +185,7 @@ golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -181,6 +194,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -195,6 +209,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -204,6 +219,7 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 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= @@ -213,13 +229,16 @@ gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuB google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM= google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8= google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34/go.mod h1:0awUlEkap+Pb1UMeJwJQQAdJQrt3moU7J2moTy69irI= +google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -233,12 +252,16 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= +k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw= k8s.io/apiextensions-apiserver v0.32.3 h1:4D8vy+9GWerlErCwVIbcQjsWunF9SUGNu7O7hiQTyPY= k8s.io/apiextensions-apiserver v0.32.3/go.mod h1:8YwcvVRMVzw0r1Stc7XfGAzB/SIVLunqApySV5V7Dss= +k8s.io/apiextensions-apiserver v0.33.1/go.mod h1:uNQ52z1A1Gu75QSa+pFK5bcXc4hq7lpOXbweZgi4dqA= k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= +k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= diff --git a/devenv/docker/blocks/prometheus/docker-compose.yaml b/devenv/docker/blocks/prometheus/docker-compose.yaml index f42734771f6..0cea2677bd3 100644 --- a/devenv/docker/blocks/prometheus/docker-compose.yaml +++ b/devenv/docker/blocks/prometheus/docker-compose.yaml @@ -12,6 +12,7 @@ --web.console.libraries=/usr/share/prometheus/console_libraries --web.console.templates=/usr/share/prometheus/consoles --web.config.file=/etc/prometheus/web.yml + --web.enable-remote-write-receiver node_exporter: image: prom/node-exporter @@ -26,7 +27,7 @@ FD_DATASOURCE: prom alertmanager: - image: prom/alertmanager + image: prom/alertmanager volumes: - ${PWD}/docker/blocks/prometheus/alertmanager.yml:/etc/alertmanager/alertmanager.yml command: > diff --git a/docs/sources/alerting/set-up/performance-limitations/index.md b/docs/sources/alerting/set-up/performance-limitations/index.md index 9b716efbaac..72a6515e6cc 100644 --- a/docs/sources/alerting/set-up/performance-limitations/index.md +++ b/docs/sources/alerting/set-up/performance-limitations/index.md @@ -59,18 +59,22 @@ For more information, refer to [this GitHub issue](https://github.com/grafana/gr ## High load on database caused by a high number of alert instances If you have a high number of alert instances, it can happen that the load on the database gets very high, as each state -transition of an alert instance is saved in the database. +transition of an alert instance is saved in the database after every evaluation. ### Compressed alert state -When the `alertingSaveStateCompressed` feature toggle is enabled, Grafana saves the alert rule state in a compressed form, reducing database overhead for alerts with many instances. +When the `alertingSaveStateCompressed` feature toggle is enabled, Grafana saves the alert rule state in a compressed form. Instead of performing an individual SQL update for each alert instance, Grafana performs a single SQL update per alert rule, updating all alert instances belonging to that rule. + +This can significantly reduce database overhead for alert rules with many alert instances. ### Save state periodically -High load can be also prevented by writing to the database periodically. For this the feature flag `alertingSaveStatePeriodic` needs -to be enabled. By default, it saves the states every 5 minutes to the database and on each shutdown. The periodic interval -can also be configured using the `state_periodic_save_interval` configuration flag. During this process, Grafana deletes all existing alert instances from the -database and then writes the entire current set of instances back in batches in a single transacton. +High load can be also prevented by writing to the database periodically, instead of after every evaluation. + +To save state periodically, enable the `alertingSaveStatePeriodic` feature toggle. + +By default, it saves the states every 5 minutes to the database and on each shutdown. The periodic interval +can also be configured using the `state_periodic_save_interval` configuration flag. During this process, Grafana deletes all existing alert instances from the database and then writes the entire current set of instances back in batches in a single transaction. Configure the size of each batch using the `state_periodic_save_batch_size` configuration option. The time it takes to write to the database periodically can be monitored using the `state_full_sync_duration_seconds` metric diff --git a/docs/sources/dashboards/build-dashboards/create-dynamic-dashboard/index.md b/docs/sources/dashboards/build-dashboards/create-dynamic-dashboard/index.md index 6c12e68e724..65a72a97d16 100644 --- a/docs/sources/dashboards/build-dashboards/create-dynamic-dashboard/index.md +++ b/docs/sources/dashboards/build-dashboards/create-dynamic-dashboard/index.md @@ -325,6 +325,24 @@ To edit dashboards, follow these steps: 1. Click **Save**. 1. Toggle off the edit mode switch. +### Undo and redo + +When a dashboard is in edit mode, you can undo and redo changes you've made using the buttons on the toolbar: + +{{< figure src="/media/docs/grafana/dashboards/screenshot-undo-redo-icons-v12.0.png" max-width="500px" alt="Undo and redo buttons" >}} + +When you've made a change and hover the cursor over the buttons, the tooltip displays the change you're about to undo or redo. +Also, you can continue undoing or redoing as many changes as you need: + +{{< video-embed src="/media/docs/grafana/dashboards/screenrecord-undo-redo-v12.0.mp4" >}} + +The undo and redo buttons are only available at the dashboard level and only apply to changes made there, such as dashboard layout and grouping and high-level dashboard or panel updates. +They aren't visible and don't apply when you're configuring a panel or making changes in the dashboard settings. + +{{< admonition type="note" >}} +Not all dashboard edit actions can be undone or redone yet. +{{< /admonition >}} + ## Move or resize a panel diff --git a/docs/sources/panels-visualizations/visualizations/bar-chart/index.md b/docs/sources/panels-visualizations/visualizations/bar-chart/index.md index 6de1af95513..44d0922b454 100644 --- a/docs/sources/panels-visualizations/visualizations/bar-chart/index.md +++ b/docs/sources/panels-visualizations/visualizations/bar-chart/index.md @@ -17,11 +17,6 @@ labels: title: Bar chart weight: 100 refs: - standard-calculations: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/query-transform-data/calculation-types/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/calculation-types/ standard-options-definitions: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-standard-options/#max @@ -32,11 +27,6 @@ refs: destination: /docs/grafana//panels-visualizations/configure-overrides/#add-a-field-override - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-overrides/#add-a-field-override - configure-data-links: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-data-links/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-data-links/ time-series: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/visualizations/time-series/ diff --git a/docs/sources/panels-visualizations/visualizations/candlestick/index.md b/docs/sources/panels-visualizations/visualizations/candlestick/index.md index f9a7c9a4597..dc0df1d1e9f 100644 --- a/docs/sources/panels-visualizations/visualizations/candlestick/index.md +++ b/docs/sources/panels-visualizations/visualizations/candlestick/index.md @@ -23,6 +23,21 @@ refs: destination: /docs/grafana//panels-visualizations/visualizations/time-series/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/time-series/ + color-scheme: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/configure-standard-options/#color-scheme + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/#color-scheme + configure-field-overrides: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/configure-overrides/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-overrides/ + add-a-field-override: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/configure-overrides/#add-a-field-override + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-overrides/#add-a-field-override --- # Candlestick @@ -162,6 +177,7 @@ When you set the **Tooltip mode** to **All**, the **Values sort order** option i #### Hover proximity Set the hover proximity (in pixels) to control how close the cursor must be to a data point to trigger the tooltip to display. +The following screen recording shows this option in a time series visualization: ![Adding a hover proximity limit for tooltips](/media/docs/grafana/gif-grafana-10-4-hover-proximity.gif) @@ -173,7 +189,122 @@ Set the hover proximity (in pixels) to control how close the cursor must be to a The options under the **Graph styles** section let you control the general appearance of [additional fields](#additional-fields) in the visualization, excluding [color](#standard-options). -{{< docs/shared lookup="visualizations/graph-styles-options.md" source="grafana" version="" leveloffset="+1" >}} + + +| Option | Description | +| ------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| [Style](#style) | Choose whether to display your time-series data as **Lines**, **Bars**, or **Points**. | +| [Line interpolation](#line-interpolation) | Choose how the graph interpolates the series line. | +| Line width | Set the thickness of the series lines or the outline for bars using the **Line width** slider. | +| Fill opacity | Set the series area fill color using the **Fill opacity** slider. | +| [Gradient mode](#gradient-mode) | Choose a gradient mode to control the gradient fill, which is based on the series color. | +| [Line style](#line-style) | Choose a solid, dashed, or dotted line style. | +| [Connect null values](#connect-null-values) | Choose how null values, which are gaps in the data, appear on the graph. | +| [Disconnect values](#disconnect-values) | Choose whether to set a threshold above which values in the data should be disconnected. | +| [Show points](#show-points) | Set whether to show data points to lines or bars. | +| Point size | Set the size of the points, from 1 to 40 pixels in diameter. | +| [Stack series](#stack-series) | Set whether Grafana displays series on top of each other. | +| [Bar alignment](#bar-alignment) | Set the position of the bar relative to a data point. | +| Bar width factor | Set the width of the bar relative to minimum space between data points. A factor of 0.5 means that the bars take up half of the available space between data points. A factor of 1.0 means that the bars take up all available space. | + + + +#### Style + +Choose whether to display your time-series data as **Lines**, **Bars**, or **Points**. +You can use overrides to combine multiple styles in the same graph. Choose from the following: + +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-candle-style-v12.0.png" max-width="750px" alt="Graph styles" >}} + +#### Line interpolation + +Choose how the graph interpolates the series line: + +- **Linear** - Points are joined by straight lines. +- **Smooth** - Points are joined by curved lines that smooths transitions between points. +- **Step before** - The line is displayed as steps between points. Points are rendered at the end of the step. +- **Step after** - The line is displayed as steps between points. Points are rendered at the beginning of the step. + +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-candle-interpolation-v12.0.png" max-width="750px" alt="Line interpolation styles" >}} + +#### Gradient mode + +Choose a gradient mode to control the gradient fill, which is based on the series color. To change the color, use the standard color scheme field option. For more information, refer to [Color scheme](ref:color-scheme). + +- **None** - No gradient fill. This is the default setting. +- **Opacity** - An opacity gradient where the opacity of the fill increases as y-axis values increase. +- **Hue** - A subtle gradient that's based on the hue of the series color. +- **Scheme** - A color gradient defined by your [Color scheme](ref:color-scheme). This setting is used for the fill area and line. For more information about scheme, refer to [Scheme gradient mode](#scheme-gradient-mode). + +Gradient appearance is influenced by the **Fill opacity** setting. The following image shows the **Fill opacity** set to 50. + +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-candle-gradient-v12.0.png" max-width="750px" alt="Gradient modes" >}} + +##### Scheme gradient mode + +In **Scheme** gradient mode, the line or bar receives a gradient color defined from the selected **Color scheme** option in the visualization's **Standard** options. + +The following image shows a line chart with the **Green-Yellow-Red (by value)** color scheme option selected: + +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-candle-scheme-grad-1-v12.0.png" max-width="600px" alt="Gradient color scheme" >}} + +If the **Color scheme** is set to **From thresholds (by value)** and **Gradient mode** is set to **Scheme**, then the line or bar color changes as it crosses the defined thresholds: + +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-candle-scheme-grad-2-v12.0.png" max-width="600px" alt="Gradient color scheme with thresholds" >}} + +#### Line style + +Choose a solid, dashed, or dotted line style: + +- **Solid** - Display a solid line. This is the default setting. +- **Dash** - Display a dashed line. When you choose this option, a list appears for you to select the length and gap (length, gap) for the line dashes. Dash spacing is 10, 10 by default. +- **Dots** - Display dotted lines. When you choose this option, a list appears for you to select the gap (length = 0, gap) for the dot spacing. Dot spacing is 0, 10 by default. + +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-candle-line-style-v12.0.png" max-width="750px" alt="Line styles" >}} + +{{< docs/shared lookup="visualizations/connect-null-values.md" source="grafana" version="" leveloffset="+1" >}} + +{{< docs/shared lookup="visualizations/disconnect-values.md" source="grafana" version="" leveloffset="+1" >}} + +To change the color, use the standard [color scheme](ref:color-scheme) field option. + +#### Show points + +Set whether to show data points as lines or bars. Choose from the following: + +- **Auto** - Grafana determines a point's visibility based on the density of the data. If the density is low, then points appear. +- **Always** - Show the points regardless of how dense the dataset is. +- **Never** - Don't show points. + +#### Stack series + +Set whether Grafana stacks or displays series on top of each other. Be cautious when using stacking because it can create misleading graphs. To read more about why stacking might not be the best approach, refer to [The issue with stacking](https://www.data-to-viz.com/caveat/stacking.html). Choose from the following: + +- **Off** - Turns off series stacking. When **Off**, all series share the same space in the visualization. +- **Normal** - Stacks series on top of each other. +- **100%** - Stack by percentage where all series add up to 100%. + +##### Stack series in groups + +The stacking group option is only available as an override. For more information about creating an override, refer to [Configure field overrides](ref:configure-field-overrides). + +1. Edit the panel and click **Overrides**. +1. Create a field override for the **Stack series** option. +1. In stacking mode, click **Normal**. +1. Name the stacking group in which you want the series to appear. + + The stacking group name option is only available when you create an override. + +#### Bar alignment + +Set the position of the bar relative to a data point. In the examples below, **Show points** is set to **Always** which makes it easier to see the difference this setting makes. The points don't change, but the bars change in relationship to the points. Choose from the following: + +- **Before** ![Bar alignment before icon](/static/img/docs/time-series-panel/bar-alignment-before.png) + The bar is drawn before the point. The point is placed on the trailing corner of the bar. +- **Center** ![Bar alignment center icon](/static/img/docs/time-series-panel/bar-alignment-center.png) + The bar is drawn around the point. The point is placed in the center of the bar. This is the default. +- **After** ![Bar alignment after icon](/static/img/docs/time-series-panel/bar-alignment-after.png) + The bar is drawn after the point. The point is placed on the leading corner of the bar. ### Axis options diff --git a/docs/sources/panels-visualizations/visualizations/canvas/index.md b/docs/sources/panels-visualizations/visualizations/canvas/index.md index 06ad9adf49a..cef103b2424 100644 --- a/docs/sources/panels-visualizations/visualizations/canvas/index.md +++ b/docs/sources/panels-visualizations/visualizations/canvas/index.md @@ -21,11 +21,6 @@ refs: destination: /docs/grafana//panels-visualizations/configure-data-links/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-data-links/ - add-field-from-calculation-transform: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/query-transform-data/transform-data/#add-field-from-calculation - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/transform-data/#add-field-from-calculation --- # Canvas diff --git a/docs/sources/panels-visualizations/visualizations/histogram/index.md b/docs/sources/panels-visualizations/visualizations/histogram/index.md index 26f9d89008f..de10e1548f8 100644 --- a/docs/sources/panels-visualizations/visualizations/histogram/index.md +++ b/docs/sources/panels-visualizations/visualizations/histogram/index.md @@ -18,11 +18,6 @@ labels: title: Histogram weight: 100 refs: - standard-calculations: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/query-transform-data/calculation-types/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/calculation-types/ color-scheme: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-standard-options/#color-scheme diff --git a/docs/sources/panels-visualizations/visualizations/state-timeline/index.md b/docs/sources/panels-visualizations/visualizations/state-timeline/index.md index 82544f3699e..2bf81512885 100644 --- a/docs/sources/panels-visualizations/visualizations/state-timeline/index.md +++ b/docs/sources/panels-visualizations/visualizations/state-timeline/index.md @@ -15,17 +15,6 @@ labels: - oss title: State timeline weight: 100 -refs: - color-scheme: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-standard-options/#color-scheme - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/#color-scheme - value-mappings: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-value-mappings/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-value-mappings/ --- # State timeline diff --git a/docs/sources/panels-visualizations/visualizations/status-history/index.md b/docs/sources/panels-visualizations/visualizations/status-history/index.md index 9407c0ebf2f..6c7fdb4fd5e 100644 --- a/docs/sources/panels-visualizations/visualizations/status-history/index.md +++ b/docs/sources/panels-visualizations/visualizations/status-history/index.md @@ -15,17 +15,6 @@ labels: - oss title: Status history weight: 100 -refs: - color-scheme: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-standard-options/#color-scheme - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/#color-scheme - value-mappings: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-value-mappings/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-value-mappings/ --- # Status history diff --git a/docs/sources/panels-visualizations/visualizations/table/index.md b/docs/sources/panels-visualizations/visualizations/table/index.md index e99622019d8..eb47c849ca1 100644 --- a/docs/sources/panels-visualizations/visualizations/table/index.md +++ b/docs/sources/panels-visualizations/visualizations/table/index.md @@ -44,11 +44,6 @@ refs: destination: /docs/grafana//panels-visualizations/configure-standard-options/#color-scheme - pattern: /docs/grafana-cloud destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/#color-scheme - configuration-file: - - pattern: /docs/grafana/ - destination: /docs/grafana//setup-grafana/configure-grafana/#configuration-file-location - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//setup-grafana/configure-grafana/#configuration-file-location field-override: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-overrides/ diff --git a/docs/sources/panels-visualizations/visualizations/text/index.md b/docs/sources/panels-visualizations/visualizations/text/index.md index b3fa00b2ad6..fefcc32d251 100644 --- a/docs/sources/panels-visualizations/visualizations/text/index.md +++ b/docs/sources/panels-visualizations/visualizations/text/index.md @@ -18,7 +18,7 @@ description: Configure options for Grafana's text visualization title: Text weight: 100 refs: - disable_sanitize_html: + disable-sanitize-html: - pattern: /docs/grafana/ destination: /docs/grafana//setup-grafana/configure-grafana/#disable_sanitize_html - pattern: /docs/grafana-cloud/ @@ -63,7 +63,7 @@ Use the following options to refine your text visualization. | Option | Description | | ------ | ----------- | -| Mode | Determines how embedded content appears. Choose from:
  • **Markdown** - Formats the content as [markdown](https://en.wikipedia.org/wiki/Markdown).
  • **HTML** - Renders the content as [sanitized](https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/text/sanitize.ts) HTML. If you require more direct control over the output, you can set the [disable_sanitize_html](ref:disable_sanitize_html) flag which enables you to directly enter HTML.
  • **Code** - Renders content inside a read-only code editor. [Variables](ref:variables) in the content are expanded for display.

To allow embedding of iframes and other websites, you need set `allow_embedding = true` in your Grafana `config.ini` or environment variables (depending on your employment).

| +| Mode | Determines how embedded content appears. Choose from:
  • **Markdown** - Formats the content as [markdown](https://en.wikipedia.org/wiki/Markdown).
  • **HTML** - Renders the content as [sanitized](https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/text/sanitize.ts) HTML. If you require more direct control over the output, you can set the [disable_sanitize_html](ref:disable-sanitize-html) flag which enables you to directly enter HTML.
  • **Code** - Renders content inside a read-only code editor. [Variables](ref:variables) in the content are expanded for display.

To allow embedding of iframes and other websites, you need set `allow_embedding = true` in your Grafana `config.ini` or environment variables (depending on your employment).

| | Language | When you choose **Code** as your text mode, select an appropriate language to apply syntax highlighting to the embedded text. | | Show line numbers | Displays line numbers in the panel preview when you choose **Code** as your text mode. | | Show mini map | Displays a small outline of the embedded text in the panel preview when you choose **Code** as your text mode. | diff --git a/docs/sources/panels-visualizations/visualizations/time-series/index.md b/docs/sources/panels-visualizations/visualizations/time-series/index.md index 91eb19fdba6..ad1b84f8a27 100644 --- a/docs/sources/panels-visualizations/visualizations/time-series/index.md +++ b/docs/sources/panels-visualizations/visualizations/time-series/index.md @@ -34,37 +34,12 @@ title: Time series menuTitle: Time series weight: 10 refs: - configure-standard-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-standard-options/#max - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/#max - color-scheme: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-standard-options/#color-scheme - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/#color-scheme - add-a-field-override: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-overrides/#add-a-field-override - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-overrides/#add-a-field-override - configure-field-overrides: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-overrides/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-overrides/ link-alert: - pattern: /docs/grafana/ destination: /docs/grafana//alerting/alerting-rules/create-grafana-managed-rule/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-grafana-managed-rule/ - panel-editor-alerts: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/panel-editor-overview/#data-section - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/panel-editor-overview/#data-section - data-transformation: + panel-data-section: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/panel-editor-overview/#data-section - pattern: /docs/grafana-cloud/ @@ -75,7 +50,7 @@ refs: Time series visualizations are the default way to show the variations of a set of data values over time. Each data point is matched to a timestamp and this _time series_ is displayed as a graph. The visualization can render series as lines, points, or bars and it's versatile enough to display almost any type of [time-series data](https://grafana.com/docs/grafana//fundamentals/timeseries/). -{{< figure src="/static/img/docs/time-series-panel/time_series_small_example.png" max-width="1200px" alt="Time series" >}} +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-time-series-v12.0.png" max-width="750px" alt="Time series visualization" >}} {{< admonition type="note" >}} You can migrate from the legacy Graph visualization to the time series visualization. To migrate, open the panel and click the **Migrate** button in the side pane. @@ -116,7 +91,7 @@ In the following example, there are three numeric fields represented by three li ![Time series line chart with multiple numeric fields](/media/docs/grafana/panels-visualizations/screenshot-grafana-11.1-timeseries-example1v2.png 'Time series line chart with multiple numeric fields') -If the time field isn't automatically detected, you might need to convert the data to a time format using a [data transformation](ref:data-transformation). +If the time field isn't automatically detected, you might need to convert the data to a time format using a [data transformation](ref:panel-data-section). ### Example 2 @@ -168,7 +143,7 @@ When you add the offset, the resulting visualization makes the datasets appear t ## Alert rules -You can [link alert rules](ref:link-alert) to time series visualizations in the form of annotations to observe when alerts fire and are resolved. In addition, you can create alert rules from the **Alert** tab within the [panel editor](ref:panel-editor-alerts). +You can [link alert rules](ref:link-alert) to time series visualizations in the form of annotations to observe when alerts fire and are resolved. In addition, you can create alert rules from the **Alert** tab within the [panel editor](ref:panel-data-section). ## Special overrides @@ -187,7 +162,7 @@ The **Graph styles > Fill below to** [override property](#field-overrides) fills The following example shows three series: Min, Max, and Value. The Min and Max series have **Line width** set to 0. Max has a **Fill below to** override set to Min, which fills the area between Max and Min with the Max line color. -{{< figure src="/static/img/docs/time-series-panel/fill-below-to-7-4.png" max-width="600px" alt="Fill below to example" >}} +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-fill-below-to-v12.0.png" max-width="600px" alt="Fill below to example" >}} {{< docs/shared lookup="visualizations/multiple-y-axes.md" source="grafana" version="" leveloffset="+2" >}} diff --git a/docs/sources/panels-visualizations/visualizations/xy-chart/index.md b/docs/sources/panels-visualizations/visualizations/xy-chart/index.md index a639761b82f..72d59e961cc 100644 --- a/docs/sources/panels-visualizations/visualizations/xy-chart/index.md +++ b/docs/sources/panels-visualizations/visualizations/xy-chart/index.md @@ -16,40 +16,16 @@ description: Configure options for Grafana's xy chart title: XY chart weight: 100 refs: - panel-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-panel-options/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-panel-options/ - data-links: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-data-links/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-data-links/ configure-standard-options: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-standard-options/#max - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/#max - standard-options: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-standard-options/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/ color-scheme: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-standard-options/#color-scheme - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/#color-scheme - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-standard-options/#color-scheme - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-standard-options/#color-scheme - add-a-field-override: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/configure-overrides/#add-a-field-override - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/configure-overrides/#add-a-field-override configure-field-overrides: - pattern: /docs/grafana/ destination: /docs/grafana//panels-visualizations/configure-overrides/ diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 5c5fb21173b..48a2ca08a92 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -45,7 +45,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes | | `formatString` | Enable format string transformer | Yes | | `kubernetesClientDashboardsFolders` | Route the folder and dashboard service requests to k8s | Yes | -| `lokiStructuredMetadata` | Enables the loki data source to request structured metadata from the Loki server | Yes | | `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | Yes | | `annotationPermissionUpdate` | Change the way annotation permissions work by scoping them to folders and dashboards. | Yes | | `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes | diff --git a/docs/sources/setup-grafana/installation/debian/index.md b/docs/sources/setup-grafana/installation/debian/index.md index 36fc5451ef5..067bcb29f6a 100644 --- a/docs/sources/setup-grafana/installation/debian/index.md +++ b/docs/sources/setup-grafana/installation/debian/index.md @@ -163,7 +163,7 @@ Complete the following steps to install Grafana using the standalone binaries: 1. Use the binary to manually start the Grafana server: ```shell - /usr/local/grafana/bin/grafana-server --homepath /usr/local/grafana + /usr/local/grafana/bin/grafana server --homepath /usr/local/grafana ``` {{< admonition type="note" >}} diff --git a/docs/sources/shared/visualizations/axis-options-1.md b/docs/sources/shared/visualizations/axis-options-1.md index a579a254d32..e4b5e0394fc 100644 --- a/docs/sources/shared/visualizations/axis-options-1.md +++ b/docs/sources/shared/visualizations/axis-options-1.md @@ -33,7 +33,7 @@ Select the placement of the y-axis. Choose from the following: - **Auto** - Automatically assigns the y-axis to the series. When there are two or more series with different units, Grafana assigns the left axis to the first unit and the right axis to the units that follow. - **Left** - Display all y-axes on the left side. - **Right** - Display all y-axes on the right side. -- **Hidden** - Hide all axes. To selectively hide axes, [Add a field override](ref:add-a-field-override) that targets specific fields. +- **Hidden** - Hide all axes. To selectively hide axes, [Add a field override](https://grafana.com/docs/grafana//panels-visualizations/configure-overrides/#add-a-field-override) that targets specific fields. #### Scale @@ -49,6 +49,6 @@ Set a **Soft min** or **soft max** option for better control of y-axis limits. B **Soft min** and **soft max** settings can prevent small variations in the data from being magnified when it's mostly flat. In contrast, hard min and max values help prevent obscuring useful detail in the data by clipping intermittent spikes past a specific point. -To define hard limits of the y-axis, set standard min/max options. For more information, refer to [Configure standard options](ref:configure-standard-options). +To define hard limits of the y-axis, set standard min/max options. For more information, refer to [Configure standard options](https://grafana.com/docs/grafana//panels-visualizations/configure-standard-options/#max). -![Label example](/static/img/docs/time-series-panel/axis-soft-min-max-7-4.png) +![Label example](/media/docs/grafana/panels-visualizations/screenshot-soft-min-max-v12.0.png) diff --git a/docs/sources/shared/visualizations/axis-options-2.md b/docs/sources/shared/visualizations/axis-options-2.md index 98875324e91..06de2666445 100644 --- a/docs/sources/shared/visualizations/axis-options-2.md +++ b/docs/sources/shared/visualizations/axis-options-2.md @@ -32,7 +32,7 @@ Select the placement of the y-axis. Choose from the following: - **Auto** - Automatically assigns the y-axis to the series. When there are two or more series with different units, Grafana assigns the left axis to the first unit and the right axis to the units that follow. - **Left** - Display all y-axes on the left side. - **Right** - Display all y-axes on the right side. -- **Hidden** - Hide all axes. To selectively hide axes, [Add a field override](ref:add-a-field-override) that targets specific fields. +- **Hidden** - Hide all axes. To selectively hide axes, [Add a field override](https://grafana.com/docs/grafana//panels-visualizations/configure-overrides/#add-a-field-override) that targets specific fields. #### Scale @@ -50,8 +50,7 @@ Set a **Soft min** or **soft max** option for better control of y-axis limits. B **Soft min** and **soft max** settings can prevent small variations in the data from being magnified when it's mostly flat. In contrast, hard min and max values help prevent obscuring useful detail in the data by clipping intermittent spikes past a specific point. -To define hard limits of the y-axis, set standard min/max options. For more information, refer to [Configure standard options](ref:configure-standard-options). +To define hard limits of the y-axis, set standard min/max options. For more information, refer to [Configure standard options](https://grafana.com/docs/grafana//panels-visualizations/configure-standard-options/#max). +The following examples shows how this option works in a time series visualization: - +![Label example](/media/docs/grafana/panels-visualizations/screenshot-soft-min-max-v12.0.png) diff --git a/docs/sources/shared/visualizations/change-axis-link.md b/docs/sources/shared/visualizations/change-axis-link.md index 00256bc6967..c785e84d2a9 100644 --- a/docs/sources/shared/visualizations/change-axis-link.md +++ b/docs/sources/shared/visualizations/change-axis-link.md @@ -4,4 +4,4 @@ title: Stack series link ## Axis -For full instructions, refer to [Change axis display](/docs/grafana//visualizations/time-series/change-axis-display/). +For full instructions, refer to [Change axis display](https://grafana.com/docs/grafana//visualizations/time-series/change-axis-display/). diff --git a/docs/sources/shared/visualizations/config-options-intro.md b/docs/sources/shared/visualizations/config-options-intro.md index 94173717f11..80c8e4e8549 100644 --- a/docs/sources/shared/visualizations/config-options-intro.md +++ b/docs/sources/shared/visualizations/config-options-intro.md @@ -1,7 +1,7 @@ --- title: Configuration options intro text comments: | - This file is used in the following in all visualizations except: alert list, annotiations list, logs, news, text + This file is used in the following in all visualizations except: alert list, annotations list, logs, news, text --- The following section describes the configuration options available in the panel editor pane for this visualization. These options are, as much as possible, ordered as they appear in Grafana. diff --git a/docs/sources/shared/visualizations/datalink-options-1.md b/docs/sources/shared/visualizations/datalink-options-1.md index 8ee5051089e..6609fe37c72 100644 --- a/docs/sources/shared/visualizations/datalink-options-1.md +++ b/docs/sources/shared/visualizations/datalink-options-1.md @@ -8,7 +8,7 @@ comments: | Data links allow you to link to other panels, dashboards, and external resources while maintaining the context of the source panel. You can create links that include the series name or even the value under the cursor. -To learn more, refer to [Configure data links and actions](../../configure-data-links/). +To learn more, refer to [Configure data links and actions](https://grafana.com/docs/grafana//panels-visualizations/configure-data-links/). {{< admonition type="note" >}} Actions are not supported for this visualization. diff --git a/docs/sources/shared/visualizations/datalink-options-2.md b/docs/sources/shared/visualizations/datalink-options-2.md index 278f7033216..c34cb13a228 100644 --- a/docs/sources/shared/visualizations/datalink-options-2.md +++ b/docs/sources/shared/visualizations/datalink-options-2.md @@ -33,4 +33,4 @@ For each action, define the following API call settings: -To learn more, refer to [Configure data links and actions](../../configure-data-links/). +To learn more, refer to [Configure data links and actions](https://grafana.com/docs/grafana//panels-visualizations/configure-data-links/). diff --git a/docs/sources/shared/visualizations/datalink-options-3.md b/docs/sources/shared/visualizations/datalink-options-3.md index 5618a80f306..bc68c9c45fb 100644 --- a/docs/sources/shared/visualizations/datalink-options-3.md +++ b/docs/sources/shared/visualizations/datalink-options-3.md @@ -35,4 +35,4 @@ For each action, define the following API call settings: -To learn more, refer to [Configure data links and actions](../../configure-data-links/). +To learn more, refer to [Configure data links and actions](https://grafana.com/docs/grafana//panels-visualizations/configure-data-links/). diff --git a/docs/sources/shared/visualizations/datalink-options.md b/docs/sources/shared/visualizations/datalink-options.md index 28c6afad0e3..b1e7df86a7f 100644 --- a/docs/sources/shared/visualizations/datalink-options.md +++ b/docs/sources/shared/visualizations/datalink-options.md @@ -19,4 +19,4 @@ For each data link, set the following options: - **URL** - **Open in new tab** -To learn more, refer to [Configure data links and actions](../../configure-data-links/). +To learn more, refer to [Configure data links and actions](https://grafana.com/docs/grafana//panels-visualizations/configure-data-links/). diff --git a/docs/sources/shared/visualizations/graph-styles-options.md b/docs/sources/shared/visualizations/graph-styles-options.md index 2d966e0b4a1..62d88d4feb5 100644 --- a/docs/sources/shared/visualizations/graph-styles-options.md +++ b/docs/sources/shared/visualizations/graph-styles-options.md @@ -1,34 +1,34 @@ --- title: Graph styles options comments: | - This file is used in the following visualizations: candlestick, time series. + This file is used in the following visualizations: time series. --- - + -| Option | Description | -| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Style](#style) | Choose whether to display your time-series data as **Lines**, **Bars**, or **Points**. | -| [Line interpolation](#line-interpolation) | Choose how the graph interpolates the series line. | -| Line width | Set the thickness of the series lines or the outline for bars using the **Line width** slider. | -| [Fill opacity](#fill-opacity) | Set the series area fill color using the **Fill opacity** slider. | -| [Gradient mode](#gradient-mode) | Choose a gradient mode to control the gradient fill, which is based on the series color. | -| [Line style](#line-style) | Choose a solid, dashed, or dotted line style. | -| [Connect null values](#connect-null-values) | Choose how null values, which are gaps in the data, appear on the graph. | -| [Disconnect values](#disconnect-values) | Choose whether to set a threshold above which values in the data should be disconnected. | -| [Show points](#show-points) | Set whether to show data points to lines or bars. | -| Point size | Set the size of the points, from 1 to 40 pixels in diameter. | -| [Stack series](#stack-series) | Set whether Grafana displays series on top of each other. | -| [Bar alignment](#bar-alignment) | Set the position of the bar relative to a data point. | +| Option | Description | +| ------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| [Style](#style) | Choose whether to display your time-series data as **Lines**, **Bars**, or **Points**. | +| [Line interpolation](#line-interpolation) | Choose how the graph interpolates the series line. | +| Line width | Set the thickness of the series lines or the outline for bars using the **Line width** slider. | +| Fill opacity | Set the series area fill color using the **Fill opacity** slider. | +| [Gradient mode](#gradient-mode) | Choose a gradient mode to control the gradient fill, which is based on the series color. | +| [Line style](#line-style) | Choose a solid, dashed, or dotted line style. | +| [Connect null values](#connect-null-values) | Choose how null values, which are gaps in the data, appear on the graph. | +| [Disconnect values](#disconnect-values) | Choose whether to set a threshold above which values in the data should be disconnected. | +| [Show points](#show-points) | Set whether to show data points to lines or bars. | +| Point size | Set the size of the points, from 1 to 40 pixels in diameter. | +| [Stack series](#stack-series) | Set whether Grafana displays series on top of each other. | +| [Bar alignment](#bar-alignment) | Set the position of the bar relative to a data point. | | Bar width factor | Set the width of the bar relative to minimum space between data points. A factor of 0.5 means that the bars take up half of the available space between data points. A factor of 1.0 means that the bars take up all available space. | - + #### Style Choose whether to display your time-series data as **Lines**, **Bars**, or **Points**. You can use overrides to combine multiple styles in the same graph. Choose from the following: -![Style modes](/static/img/docs/time-series-panel/style-modes-v9.png) +![Graph style examples](/media/docs/grafana/panels-visualizations/screenshot-time-style-v12.0.png) #### Line interpolation @@ -39,44 +39,32 @@ Choose how the graph interpolates the series line: - **Step before** - The line is displayed as steps between points. Points are rendered at the end of the step. - **Step after** - The line is displayed as steps between points. Points are rendered at the beginning of the step. -#### Line width - -Set the thickness of the series lines or the outline for bars using the **Line width** slider. - -#### Fill opacity - -Set the series area fill color using the **Fill opacity** slider. - -![Fill opacity examples](/static/img/docs/time-series-panel/fill-opacity.png) +![Line interpolation examples](/media/docs/grafana/panels-visualizations/screenshot-time-interpolation-v12.0.png) #### Gradient mode -Choose a gradient mode to control the gradient fill, which is based on the series color. To change the color, use the standard color scheme field option. For more information, refer to [Color scheme](ref:color-scheme). +Choose a gradient mode to control the gradient fill, which is based on the series color. To change the color, use the standard color scheme field option. For more information, refer to [Color scheme](https://grafana.com/docs/grafana//panels-visualizations/configure-standard-options/#color-scheme). - **None** - No gradient fill. This is the default setting. - **Opacity** - An opacity gradient where the opacity of the fill increases as y-axis values increase. - **Hue** - A subtle gradient that's based on the hue of the series color. -- **Scheme** - A color gradient defined by your [Color scheme](ref:color-scheme). This setting is used for the fill area and line. For more information about scheme, refer to [Scheme gradient mode](#scheme-gradient-mode). +- **Scheme** - A color gradient defined by your [Color scheme](https://grafana.com/docs/grafana//panels-visualizations/configure-standard-options/#color-scheme). This setting is used for the fill area and line. For more information about scheme, refer to [Scheme gradient mode](#scheme-gradient-mode). Gradient appearance is influenced by the **Fill opacity** setting. The following image shows the **Fill opacity** set to 50. -![Gradient mode examples](/static/img/docs/time-series-panel/gradient-modes-v9.png) +![Gradient mode examples](/media/docs/grafana/panels-visualizations/screenshot-time-gradient-v12.0.png) ##### Scheme gradient mode -The **Gradient mode** option located under the **Graph styles** section has a mode called **Scheme**. When you enable **Scheme**, the line or bar receives a gradient color defined from the selected **Color scheme**. +In **Scheme** gradient mode, the line or bar receives a gradient color defined from the selected **Color scheme** option in the visualization's **Standard** options. -###### From thresholds +The following image shows a line chart with the **Green-Yellow-Red (by value)** color scheme option selected: -If the **Color scheme** is set to **From thresholds (by value)** and **Gradient mode** is set to **Scheme**, then the line or bar color changes as it crosses the defined thresholds. +{{< figure src="/static/img/docs/time-series-panel/gradient_mode_scheme_line.png" max-width="600px" alt="Color scheme: Green-Yellow-Red" >}} -{{< figure src="/static/img/docs/time-series-panel/gradient_mode_scheme_thresholds_line.png" max-width="1200px" alt="Colors scheme: From thresholds" >}} +If the **Color scheme** is set to **From thresholds (by value)** and **Gradient mode** is set to **Scheme**, then the line or bar color changes as it crosses the defined thresholds: -###### Gradient color schemes - -The following image shows a line chart with the **Green-Yellow-Red (by value)** color scheme option selected. - -{{< figure src="/static/img/docs/time-series-panel/gradient_mode_scheme_line.png" max-width="1200px" alt="Color scheme: Green-Yellow-Red" >}} +{{< figure src="/static/img/docs/time-series-panel/gradient_mode_scheme_thresholds_line.png" max-width="600px" alt="Colors scheme: From thresholds" >}} #### Line style @@ -86,13 +74,13 @@ Choose a solid, dashed, or dotted line style: - **Dash** - Display a dashed line. When you choose this option, a list appears for you to select the length and gap (length, gap) for the line dashes. Dash spacing is 10, 10 by default. - **Dots** - Display dotted lines. When you choose this option, a list appears for you to select the gap (length = 0, gap) for the dot spacing. Dot spacing is 0, 10 by default. -![Line styles examples](/static/img/docs/time-series-panel/line-styles-examples-v9.png) +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-time-line-style-v12.0.png" max-width="550px" alt="Line style examples" >}} {{< docs/shared lookup="visualizations/connect-null-values.md" source="grafana" version="" leveloffset="+1" >}} {{< docs/shared lookup="visualizations/disconnect-values.md" source="grafana" version="" leveloffset="+1" >}} -To change the color, use the standard [color scheme](ref:color-scheme) field option. +To change the color, use the standard [color scheme](https://grafana.com/docs/grafana//panels-visualizations/configure-standard-options/#color-scheme) field option. #### Show points @@ -112,7 +100,7 @@ Set whether Grafana stacks or displays series on top of each other. Be cautious ##### Stack series in groups -The stacking group option is only available as an override. For more information about creating an override, refer to [Configure field overrides](ref:configure-field-overrides). +The stacking group option is only available as an override. For more information about creating an override, refer to [Configure field overrides](https://grafana.com/docs/grafana//panels-visualizations/configure-overrides/). 1. Edit the panel and click **Overrides**. 1. Create a field override for the **Stack series** option. diff --git a/docs/sources/shared/visualizations/legend-options-1.md b/docs/sources/shared/visualizations/legend-options-1.md index a81d1aa45fc..89a433ce1cc 100644 --- a/docs/sources/shared/visualizations/legend-options-1.md +++ b/docs/sources/shared/visualizations/legend-options-1.md @@ -6,12 +6,12 @@ comments: | This file is used in the following visualizations: bar chart, candlestick, histogram, time series, trend, xy chart --- -Legend options control the series names and statistics that appear under or to the right of the graph. For more information about the legend, refer to [Configure a legend](../configure-legend/). +Legend options control the series names and statistics that appear under or to the right of the graph. For more information about the legend, refer to [Configure a legend](https://grafana.com/docs/grafana//panels-visualizations/configure-legend/). -| Option | Description | -| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Visibility | Toggle the switch to turn the legend on or off. | -| Mode | Use these settings to define how the legend appears in your visualization. **List** displays the legend as a list. This is a default display mode of the legend. **Table** displays the legend as a table. | -| Placement | Choose where to display the legend. **Bottom** places the legend below the graph. **Right** places the legend to the right of the graph. | -| Width | Control how wide the legend is when placed on the right side of the visualization. This option is only displayed if you set the legend placement to **Right**. | -| Values | Choose which of the [standard calculations](../../query-transform-data/calculation-types/) to show in the legend. You can have more than one. | +| Option | Description | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Visibility | Toggle the switch to turn the legend on or off. | +| Mode | Use these settings to define how the legend appears in your visualization. **List** displays the legend as a list. This is a default display mode of the legend. **Table** displays the legend as a table. | +| Placement | Choose where to display the legend. **Bottom** places the legend below the graph. **Right** places the legend to the right of the graph. | +| Width | Control how wide the legend is when placed on the right side of the visualization. This option is only displayed if you set the legend placement to **Right**. | +| Values | Choose which of the [standard calculations](https://grafana.com/docs/grafana//panels-visualizations/query-transform-data/calculation-types/) to show in the legend. You can have more than one. | diff --git a/docs/sources/shared/visualizations/legend-options-2.md b/docs/sources/shared/visualizations/legend-options-2.md index 3091b358b84..9620e79c5d0 100644 --- a/docs/sources/shared/visualizations/legend-options-2.md +++ b/docs/sources/shared/visualizations/legend-options-2.md @@ -6,9 +6,9 @@ comments: | This file is used in the following visualizations: state timeline, status history --- -When the legend option is enabled it can show either the value mappings or the threshold brackets. To show the value mappings in the legend, it's important that the **Color scheme** as referenced in [Color scheme](../../configure-standard-options#color-scheme) is set to **Single color** or **Classic palette**. To see the threshold brackets in the legend set the **Color scheme** to **From thresholds**. +When the legend option is enabled it can show either the value mappings or the threshold brackets. To show the value mappings in the legend, it's important that the **Color scheme** as referenced in [Color scheme](https://grafana.com/docs/grafana//panels-visualizations/configure-standard-options/#color-scheme) is set to **Single color** or **Classic palette**. To see the threshold brackets in the legend set the **Color scheme** to **From thresholds**. -For more information about the legend, refer to [Configure a legend](../configure-legend/). +For more information about the legend, refer to [Configure a legend](https://grafana.com/docs/grafana//panels-visualizations/configure-legend/). ### Visibility diff --git a/docs/sources/shared/visualizations/multiple-y-axes.md b/docs/sources/shared/visualizations/multiple-y-axes.md index 4d74d3b9501..63a62126cad 100644 --- a/docs/sources/shared/visualizations/multiple-y-axes.md +++ b/docs/sources/shared/visualizations/multiple-y-axes.md @@ -6,4 +6,4 @@ title: Multiple y-axes In some cases, you might want to display multiple y-axes. For example, if you have a dataset showing both temperature and humidity over time, you might want to show two y-axes with different units for the two series. -You can configure multiple y-axes and control where they're displayed in the visualization by adding field overrides. [This example of a dataset that includes temperature and humidity](../../configure-overrides/#example-2-format-temperature-and-humidity) describes how you can configure that. Repeat the steps for every y-axis you wish to display. +You can configure multiple y-axes and control where they're displayed in the visualization by adding field overrides. [This example of a dataset that includes temperature and humidity](https://grafana.com/docs/grafana//panels-visualizations/configure-overrides/#example-2-format-temperature-and-humidity) describes how you can configure that. Repeat the steps for every y-axis you wish to display. diff --git a/docs/sources/shared/visualizations/overrides-options.md b/docs/sources/shared/visualizations/overrides-options.md index 21b910af4cb..48de9faef98 100644 --- a/docs/sources/shared/visualizations/overrides-options.md +++ b/docs/sources/shared/visualizations/overrides-options.md @@ -16,4 +16,4 @@ Choose from the following override options: | Fields returned by query | Select all fields returned by a specific query, such as A, B, or C. | | Fields with values | Select all fields returned by your defined reducer condition, such as **Min**, **Max**, **Count**, **Total**. | -To learn more, refer to [Configure field overrides](../../configure-overrides/). +To learn more, refer to [Configure field overrides](https://grafana.com/docs/grafana//panels-visualizations/configure-overrides/). diff --git a/docs/sources/shared/visualizations/panel-options.md b/docs/sources/shared/visualizations/panel-options.md index c013457d191..de91390f6bc 100644 --- a/docs/sources/shared/visualizations/panel-options.md +++ b/docs/sources/shared/visualizations/panel-options.md @@ -4,4 +4,4 @@ comments: | This file is used in all visualizations pages --- -In the **Panel options** section of the panel editor pane, set basic options like panel title and description, as well as panel links. To learn more, refer to [Configure panel options](../../configure-panel-options/). +In the **Panel options** section of the panel editor pane, set basic options like panel title and description, as well as panel links. To learn more, refer to [Configure panel options](https://grafana.com/docs/grafana//panels-visualizations/configure-panel-options/). diff --git a/docs/sources/shared/visualizations/stack-series-link.md b/docs/sources/shared/visualizations/stack-series-link.md index c8c92e11fe0..f92b3f67b9d 100644 --- a/docs/sources/shared/visualizations/stack-series-link.md +++ b/docs/sources/shared/visualizations/stack-series-link.md @@ -4,4 +4,4 @@ title: Stack series link ### Stack series -For full instructions, refer to [Graph stacked time series](/docs/grafana//visualizations/time-series/graph-time-series-stacking/). +For full instructions, refer to [Graph stacked time series](https://grafana.com/docs/grafana//visualizations/time-series/graph-time-series-stacking/). diff --git a/docs/sources/shared/visualizations/standard-options.md b/docs/sources/shared/visualizations/standard-options.md index db489bd09e3..f39762e683e 100644 --- a/docs/sources/shared/visualizations/standard-options.md +++ b/docs/sources/shared/visualizations/standard-options.md @@ -4,7 +4,7 @@ comments: | This file is used in the following visualizations: bar chart, bar gauge, candlestick, canvas, gauge, geomap, histogram, pie chart, stat, state timeline, status history, table, time series, trend --- -**Standard options** in the panel editor pane let you change how field data is displayed in your visualizations. When you set a standard option, the change is applied to all fields or series. For more granular control over the display of fields, refer to [Configure overrides](../../configure-overrides/). +**Standard options** in the panel editor pane let you change how field data is displayed in your visualizations. When you set a standard option, the change is applied to all fields or series. For more granular control over the display of fields, refer to [Configure overrides](https://grafana.com/docs/grafana//panels-visualizations/configure-overrides/). | Option | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -16,4 +16,4 @@ comments: | | Color scheme | Set single or multiple colors for your entire visualization. | | No value | Enter what Grafana should display if the field value is empty or null. The default value is a hyphen (-). | -To learn more, refer to [Configure standard options](../../configure-standard-options/). +To learn more, refer to [Configure standard options](https://grafana.com/docs/grafana//panels-visualizations/configure-standard-options/). diff --git a/docs/sources/shared/visualizations/thresholds-options-1.md b/docs/sources/shared/visualizations/thresholds-options-1.md index 3b2fc6b8f6b..15157a35585 100644 --- a/docs/sources/shared/visualizations/thresholds-options-1.md +++ b/docs/sources/shared/visualizations/thresholds-options-1.md @@ -16,4 +16,4 @@ For each threshold, set the following options: | Thresholds mode | Choose from **Absolute** and **Percentage**. | | Show thresholds | Choose from a variety of display options including not displaying thresholds at all. | -To learn more, refer to [Configure thresholds](../../configure-thresholds/). +To learn more, refer to [Configure thresholds](https://grafana.com/docs/grafana//panels-visualizations/configure-thresholds/). diff --git a/docs/sources/shared/visualizations/thresholds-options-2.md b/docs/sources/shared/visualizations/thresholds-options-2.md index f6defc3928e..41a9b13292d 100644 --- a/docs/sources/shared/visualizations/thresholds-options-2.md +++ b/docs/sources/shared/visualizations/thresholds-options-2.md @@ -15,4 +15,4 @@ For each threshold, set the following options: | Value | Set the value for each threshold. | | Thresholds mode | Choose from **Absolute** and **Percentage**. | -To learn more, refer to [Configure thresholds](../../configure-thresholds/). +To learn more, refer to [Configure thresholds](https://grafana.com/docs/grafana//panels-visualizations/configure-thresholds/). diff --git a/docs/sources/shared/visualizations/tooltip-options-2.md b/docs/sources/shared/visualizations/tooltip-options-2.md index 2512022e08d..0a189eb1178 100644 --- a/docs/sources/shared/visualizations/tooltip-options-2.md +++ b/docs/sources/shared/visualizations/tooltip-options-2.md @@ -38,5 +38,6 @@ When you set the **Tooltip mode** to **All**, the **Values sort order** option i ### Hover proximity Set the hover proximity (in pixels) to control how close the cursor must be to a data point to trigger the tooltip to display. +The following screen recording shows this option in a time series visualization: ![Adding a hover proximity limit for tooltips](/media/docs/grafana/gif-grafana-10-4-hover-proximity.gif) diff --git a/docs/sources/shared/visualizations/value-mappings-options.md b/docs/sources/shared/visualizations/value-mappings-options.md index 937801addec..eef91fc6208 100644 --- a/docs/sources/shared/visualizations/value-mappings-options.md +++ b/docs/sources/shared/visualizations/value-mappings-options.md @@ -17,4 +17,4 @@ For each value mapping, set the following options: - **Color** (Optional) - **Icon** (Canvas only) -To learn more, refer to [Configure value mappings](../../configure-value-mappings/). +To learn more, refer to [Configure value mappings](https://grafana.com/docs/grafana//panels-visualizations/configure-value-mappings/). diff --git a/e2e/pa11yci.conf.js b/e2e/pa11yci.conf.js index 63e274e16bf..b4ed68a5412 100644 --- a/e2e/pa11yci.conf.js +++ b/e2e/pa11yci.conf.js @@ -91,7 +91,7 @@ const config = { }, { url: '${HOST}/org/teams', - threshold: 0, + threshold: 1, }, { url: '${HOST}/plugins', diff --git a/go.mod b/go.mod index edc79e42259..02f5e8dc389 100644 --- a/go.mod +++ b/go.mod @@ -382,7 +382,7 @@ require ( github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gogo/googleapis v1.4.1 // indirect diff --git a/go.sum b/go.sum index 42063a751f2..bd4682f69ba 100644 --- a/go.sum +++ b/go.sum @@ -1358,8 +1358,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:9wScpmSP5A3Bk8V3XHWUcJmYTh+ZnlHVyc+A4oZYS3Y= github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:56xuuqnHyryaerycW3BfssRdxQstACi0Epw/yC5E2xM= github.com/go-zookeeper/zk v1.0.4 h1:DPzxraQx7OrPyXq2phlGlNSIyWEsAox0RJmjTseMV6I= diff --git a/go.work.sum b/go.work.sum index 9bbec9aacc2..cf90c70f3b1 100644 --- a/go.work.sum +++ b/go.work.sum @@ -754,6 +754,7 @@ github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9 h github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/apache/arrow-go/v18 v18.0.1-0.20241212180703-82be143d7c30/go.mod h1:RNuWDIiGjq5nndL2PyQrndUy9nMLwheA3uWaAV7fe4U= +github.com/apache/arrow-go/v18 v18.2.0/go.mod h1:Ic/01WSwGJWRrdAZcxjBZ5hbApNJ28K96jGYaxzzGUc= github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 h1:q4dksr6ICHXqG5hm0ZW5IHyeEJXoIJSOZeBLmWPNeIQ= github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40/go.mod h1:Q7yQnSMnLvcXlZ8RV+jwz/6y1rQTqbX6C82SndT52Zs= github.com/apache/arrow/go/v10 v10.0.1 h1:n9dERvixoC/1JjDmBcs9FPaEryoANa2sCgVFo6ez9cI= @@ -885,6 +886,7 @@ github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpV github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U= +github.com/chromedp/cdproto v0.0.0-20240810084448-b931b754e476/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw= github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= @@ -1335,6 +1337,7 @@ github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeU github.com/grafana/grafana-plugin-sdk-go v0.267.0/go.mod h1:OuwS4c/JYgn0rr/w5zhJBpLo4gKm/vw15RsfpYAvK9Q= github.com/grafana/grafana-plugin-sdk-go v0.269.1/go.mod h1:yv2KbO4mlr9WuDK2f+2gHAMTwwLmLuqaEnrPXTRU+OI= github.com/grafana/grafana-plugin-sdk-go v0.275.0/go.mod h1:mO9LJqdXDh5JpO/xIdPAeg5LdThgQ06Y/SLpXDWKw2c= +github.com/grafana/grafana-plugin-sdk-go v0.277.0/go.mod h1:mAUWg68w5+1f5TLDqagIr8sWr1RT9h7ufJl5NMcWJAU= github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA= github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c= github.com/grafana/grafana/apps/advisor v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:q+h3HbmqU/PposW6lq8cMle1v8vuyX1LCMrGzbabHxc= @@ -1366,6 +1369,8 @@ github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e7 github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:xrKQcxQxz+IUF90ybtfENFeEXtlj9nAsX/3Fw0KEIeQ= github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed h1:59JF1WhHLT+lNX89Tm1OzOEySMVMASAhaPbsRjtp8Kc= github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed/go.mod h1:OIAAKNgG5fpuJQRNO1lUSj9nc18Xl3O7M8fjIlBO1cI= +github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5 h1:MAQ2B0cu0V1S91ZjVa7NomNZFjaR2SmdtvdwhqBtyhU= +github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5/go.mod h1:tN93IZUaAmnSWgL0IgnKdLv6DNeIhTJGvl1wvQMrWco= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3 h1:6D2gGAwyQBElSrp3E+9lSr7k8gLuP3Aiy20rweLWeBw= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3/go.mod h1:YeND+6FDA7OuFgDzYODN8kfPhXLCehcpxe4T9mdnpCY= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 h1:4/BZkGObFWZf4cLbE2Vqg/1VTz67Q0AJ7LHspWLKJoQ= @@ -1380,6 +1385,7 @@ github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJr github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/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.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0/go.mod h1:zrT2dxOAjNFPRGjTUe2Xmb4q4YdUwVvQFV6xiCSf+z0= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= @@ -2329,6 +2335,7 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.5 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.58.0/go.mod h1:uosvgpqTcTXtcPQORTbEkZNDQTCDOgTz1fe6aLSyqrQ= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.59.0/go.mod h1:54CaSNqYEXvpzDh8KPjiMVoWm60t5R0dZRt0leEPgAs= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0/go.mod h1:CosX/aS4eHnG9D7nESYpV753l4j9q5j3SL/PUYd2lR8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= @@ -2341,6 +2348,7 @@ go.opentelemetry.io/contrib/propagators/b3 v1.27.0 h1:IjgxbomVrV9za6bRi8fWCNXENs go.opentelemetry.io/contrib/propagators/b3 v1.27.0/go.mod h1:Dv9obQz25lCisDvvs4dy28UPh974CxkahRDUPsY7y9E= go.opentelemetry.io/contrib/propagators/b3 v1.35.0 h1:DpwKW04LkdFRFCIgM3sqwTJA/QREHMeMHYPWP1WeaPQ= go.opentelemetry.io/contrib/propagators/b3 v1.35.0/go.mod h1:9+SNxwqvCWo1qQwUpACBY5YKNVxFJn5mlbXg/4+uKBg= +go.opentelemetry.io/contrib/propagators/jaeger v1.35.0/go.mod h1:0ciyFyYZxE6JqRAQvIgGRabKWDUmNdW3GAQb6y/RlFU= go.opentelemetry.io/contrib/samplers/jaegerremote v0.28.0/go.mod h1:iWS+NvC948FyfnJbVfPN9h/8+vr8CR2FPn6XsLRkvH8= go.opentelemetry.io/contrib/samplers/jaegerremote v0.29.0/go.mod h1:XAJmM2MWhiIoTO4LCLBVeE8w009TmsYk6hq1UNdXs5A= go.opentelemetry.io/contrib/zpages v0.60.0 h1:wOM9ie1Hz4H88L9KE6GrGbKJhfm+8F1NfW/Y3q9Xt+8= diff --git a/packages/grafana-data/src/transformations/transformers/convertFieldType.test.ts b/packages/grafana-data/src/transformations/transformers/convertFieldType.test.ts index b0e29ea9c8b..4a1c4133450 100644 --- a/packages/grafana-data/src/transformations/transformers/convertFieldType.test.ts +++ b/packages/grafana-data/src/transformations/transformers/convertFieldType.test.ts @@ -423,6 +423,35 @@ describe('field convert types transformer', () => { ]); }); + it('will support custom join separators for field type string, correctly handling undefined and null', () => { + const options = { + conversions: [{ targetField: 'mixed_values', destinationType: FieldType.string, joinWith: '&' }], + }; + + const mixedValues = toDataFrame({ + fields: [ + { + name: 'mixed_values', + type: FieldType.string, + values: [['a', 'b', 'c'], ['d', undefined, 'f'], undefined, 'regular string', null], + }, + ], + }); + + const stringified = convertFieldTypes(options, [mixedValues]); + expect( + stringified[0].fields.map(({ type, values }) => ({ + type, + values, + })) + ).toEqual([ + { + type: FieldType.string, + values: ['a&b&c', 'd&&f', undefined, '"regular string"', 'null'], + }, + ]); + }); + it('will convert time fields to strings', () => { const options = { conversions: [{ targetField: 'time', destinationType: FieldType.string, dateFormat: 'YYYY-MM' }], diff --git a/packages/grafana-data/src/transformations/transformers/convertFieldType.ts b/packages/grafana-data/src/transformations/transformers/convertFieldType.ts index 4e90e67a84c..e5fe425d7aa 100644 --- a/packages/grafana-data/src/transformations/transformers/convertFieldType.ts +++ b/packages/grafana-data/src/transformations/transformers/convertFieldType.ts @@ -218,7 +218,7 @@ export function fieldToStringField( if (joinWith?.length && Array.isArray(v)) { return v.join(joinWith); } - return field.type === FieldType.other ? JSON.stringify(v) : `${v}`; + return JSON.stringify(v); }); break; diff --git a/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts index 2737732a383..a778302ba26 100644 --- a/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts +++ b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.test.ts @@ -46,7 +46,7 @@ describe('GroupToSubframe transformer', () => { values: ['one', 'two', 'three'], }, { - name: 'Nested frames', + name: '__nestedFrames', type: FieldType.nestedFrames, config: {}, values: [ @@ -153,7 +153,7 @@ describe('GroupToSubframe transformer', () => { }, { config: {}, - name: 'Nested frames', + name: '__nestedFrames', type: FieldType.nestedFrames, values: [ [ diff --git a/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts index 3f6843afeb1..943c3d842c0 100644 --- a/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts +++ b/packages/grafana-data/src/transformations/transformers/groupToNestedTable.ts @@ -124,7 +124,7 @@ export const groupToNestedTable: DataTransformerInfo ({ + return result.map((x) => ({ label: x, value: x, })); diff --git a/packages/grafana-prometheus/src/querybuilder/parsing.test.ts b/packages/grafana-prometheus/src/querybuilder/parsing.test.ts index d17a2568f08..53db5b12b95 100644 --- a/packages/grafana-prometheus/src/querybuilder/parsing.test.ts +++ b/packages/grafana-prometheus/src/querybuilder/parsing.test.ts @@ -948,6 +948,25 @@ describe('buildVisualQueryFromString', () => { }) ); }); + + it('parses query with custom variable', () => { + expect(buildVisualQueryFromString('topk($custom, rate(metric_name[$__rate_interval]))')).toEqual( + noErrors({ + metric: 'metric_name', + labels: [], + operations: [ + { + id: 'rate', + params: ['$__rate_interval'], + }, + { + id: 'topk', + params: ['$custom'], + }, + ], + }) + ); + }); }); function noErrors(query: PromVisualQuery) { diff --git a/packages/grafana-prometheus/src/querybuilder/parsing.ts b/packages/grafana-prometheus/src/querybuilder/parsing.ts index c7dd3db31a0..e18c6071bab 100644 --- a/packages/grafana-prometheus/src/querybuilder/parsing.ts +++ b/packages/grafana-prometheus/src/querybuilder/parsing.ts @@ -49,9 +49,9 @@ import { PromVisualQuery, PromVisualQueryBinary } from './types'; * It traverses the tree and uses sort of state machine to update the query model. * The query model is modified during the traversal and sent to each handler as context. */ -export function buildVisualQueryFromString(expr: string): Context { +export function buildVisualQueryFromString(expr: string): Omit { expr = replaceBuiltInVariable(expr); - const replacedExpr = replaceVariables(expr); + const { replacedExpr, replacedVariables } = replaceVariables(expr); const tree = parser.parse(replacedExpr); const node = tree.topNode; @@ -64,6 +64,7 @@ export function buildVisualQueryFromString(expr: string): Context { const context: Context = { query: visQuery, errors: [], + replacements: replacedVariables, }; try { @@ -83,6 +84,9 @@ export function buildVisualQueryFromString(expr: string): Context { context.errors = []; } + // No need to return replaced variables + delete context.replacements; + return context; } @@ -96,6 +100,7 @@ interface ParsingError { interface Context { query: PromVisualQuery; errors: ParsingError[]; + replacements?: Record; } /** @@ -359,6 +364,19 @@ function updateFunctionArgs(expr: string, node: SyntaxNode | null, context: Cont break; } + case VectorSelector: { + // When we replace a custom variable to prevent errors during parsing we receive VectorSelector and Identifier in it. + // But this is also a normal case for a normal function body. i.e. topk(5, http_requests_total{}) + // In such cases we got identifier as http_requests_total. So we shouldn't push this as param. + // So we check whether the given VectorSelector is something we replaced earlier. + if (context.replacements?.[expr.substring(node.from, node.to)]) { + const identifierNode = node.getChild(Identifier); + const customVarName = getString(expr, identifierNode); + op.params.push(customVarName); + break; + } + } + default: { // Means we get to something that does not seem like simple function arg and is probably nested query so jump // back to main context @@ -429,6 +447,7 @@ function handleBinary(expr: string, node: SyntaxNode, context: Context) { handleExpression(expr, right, { query: binQuery.query, errors: context.errors, + replacements: context.replacements, }); } } diff --git a/packages/grafana-prometheus/src/querybuilder/parsingUtils.test.ts b/packages/grafana-prometheus/src/querybuilder/parsingUtils.test.ts index b5f9a55a434..c96a7877e8f 100644 --- a/packages/grafana-prometheus/src/querybuilder/parsingUtils.test.ts +++ b/packages/grafana-prometheus/src/querybuilder/parsingUtils.test.ts @@ -20,9 +20,15 @@ describe('getLeftMostChild', () => { describe('replaceVariables', () => { it('should replace variables', () => { - expect(replaceVariables('sum_over_time([[metric_var]]{bar="${app}"}[$__interval])')).toBe( - 'sum_over_time(__V_1__metric_var__V__{bar="__V_2__app__V__"}[__V_0____interval__V__])' + const { replacedExpr, replacedVariables } = replaceVariables( + 'sum_over_time([[metric_var]]{bar="${app}"}[$__interval])' ); + expect(replacedExpr).toBe('sum_over_time(__V_1__metric_var__V__{bar="__V_2__app__V__"}[__V_0____interval__V__])'); + expect(replacedVariables).toEqual({ + __V_1__metric_var__V__: '[[metric_var]]', + __V_2__app__V__: '${app}', + __V_0____interval__V__: '$__interval', + }); }); }); @@ -42,9 +48,14 @@ describe('getString', () => { it('is symmetrical with replaceVariables', () => { const expr = 'sum_over_time([[metric_var]]{bar="${app}"}[$__interval])'; - const replaced = replaceVariables(expr); - const tree = parser.parse(replaced); - expect(getString(replaced, tree.topNode)).toBe(expr); + const { replacedExpr, replacedVariables } = replaceVariables(expr); + const tree = parser.parse(replacedExpr); + expect(getString(replacedExpr, tree.topNode)).toBe(expr); + expect(replacedVariables).toEqual({ + __V_1__metric_var__V__: '[[metric_var]]', + __V_2__app__V__: '${app}', + __V_0____interval__V__: '$__interval', + }); }); }); diff --git a/packages/grafana-prometheus/src/querybuilder/parsingUtils.ts b/packages/grafana-prometheus/src/querybuilder/parsingUtils.ts index 15136e51005..0346042ca02 100644 --- a/packages/grafana-prometheus/src/querybuilder/parsingUtils.ts +++ b/packages/grafana-prometheus/src/querybuilder/parsingUtils.ts @@ -36,7 +36,8 @@ const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\ * parsable and at the same time we can get the variable and its format back from it. */ export function replaceVariables(expr: string) { - return expr.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { + const replacedVariables: Record = {}; + const replacedExpr = expr.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { const fmt = fmt2 || fmt3; let variable = var1; let varType = '0'; @@ -51,8 +52,12 @@ export function replaceVariables(expr: string) { varType = '2'; } - return `__V_${varType}__` + variable + '__V__' + (fmt ? '__F__' + fmt + '__F__' : ''); + const replacement = `__V_${varType}__` + variable + '__V__' + (fmt ? '__F__' + fmt + '__F__' : ''); + replacedVariables[replacement] = match; + return replacement; }); + + return { replacedExpr, replacedVariables }; } const varTypeFunc = [ diff --git a/packages/grafana-ui/.storybook/preview.ts b/packages/grafana-ui/.storybook/preview.ts index f66b4b8422a..bff4cd763b7 100644 --- a/packages/grafana-ui/.storybook/preview.ts +++ b/packages/grafana-ui/.storybook/preview.ts @@ -49,7 +49,9 @@ if (process.env.NODE_ENV === 'development') { * See https://github.com/mswjs/msw-storybook-addon#configuring-msw * to learn how to customize it */ -initialize(); +initialize({ + onUnhandledRequest: 'bypass', +}); const preview: Preview = { decorators: [withTheme(handleThemeChange), withTimeZone()], diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 8925fa97536..443b8cc01ae 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -107,7 +107,7 @@ "react-calendar": "^5.1.0", "react-colorful": "5.6.1", "react-custom-scrollbars-2": "4.5.0", - "react-data-grid": "grafana/react-data-grid#3420f7f2a9e0d707d3313ec5b143a6be53f720b5", + "react-data-grid": "grafana/react-data-grid#de920f0105cb2b7d774444e7443a675f3b568ad6", "react-dropzone": "14.3.8", "react-highlight-words": "0.21.0", "react-hook-form": "^7.49.2", diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index 6fd2074e56b..271410e32ea 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -688,7 +688,7 @@ function getValueStyles( const styles: CSSProperties = { color, height: `${height}px`, - width: `${width}px`, + maxWidth: `${width}px`, display: 'flex', alignItems: 'center', textWrap: 'nowrap', diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx index 1a1f8ec37ea..b65fa0e0297 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx @@ -3,7 +3,8 @@ import userEvent from '@testing-library/user-event'; import { Field, FieldType, LinkModel } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { TableCellDisplayMode } from '@grafana/schema'; + +import { TableCellDisplayMode } from '../../types'; import AutoCell from './AutoCell'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx index 91b2c8e0da2..a3bcdad4db0 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx @@ -4,10 +4,10 @@ import { useState } from 'react'; import { GrafanaTheme2, formattedValueToString } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { TableCellDisplayMode, TableCellOptions } from '@grafana/schema'; import { useStyles2 } from '../../../../themes/ThemeContext'; import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; +import { TableCellOptions, TableCellDisplayMode } from '../../types'; import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils'; import { AutoCellProps } from '../types'; import { getCellLinks } from '../utils'; @@ -60,7 +60,6 @@ const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent cell: css({ display: 'flex', justifyContent: justifyContent, - a: { color: 'inherit', }, diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.test.tsx index ca68fbeb189..62a1d11055e 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.test.tsx @@ -1,7 +1,8 @@ import { render, screen } from '@testing-library/react'; import { Field, FieldType, LinkModel } from '@grafana/data'; -import { TableCellDisplayMode } from '@grafana/schema'; + +import { TableCellDisplayMode } from '../../types'; import { DataLinksCell } from './DataLinksCell'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx index 6604629564b..c5f7b79400f 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx @@ -1,6 +1,5 @@ import { css } from '@emotion/css'; -import { Property } from 'csstype'; -import React, { useLayoutEffect, useRef, useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { Column, SortDirection } from 'react-data-grid'; import { Field, GrafanaTheme2 } from '@grafana/data'; @@ -9,22 +8,18 @@ import { useStyles2 } from '../../../../themes/ThemeContext'; import { getFieldTypeIcon } from '../../../../types/icon'; import { Icon } from '../../../Icon/Icon'; import { Filter } from '../Filter/Filter'; -import { TableColumnResizeActionCallback, FilterType, TableRow, TableSummaryRow } from '../types'; +import { FilterType, TableRow, TableSummaryRow } from '../types'; import { getDisplayName } from '../utils'; interface HeaderCellProps { column: Column; rows: TableRow[]; field: Field; - onSort: (columnKey: string, direction: SortDirection, isMultiSort: boolean) => void; direction?: SortDirection; - justifyContent: Property.JustifyContent; filter: FilterType; setFilter: React.Dispatch>; - onColumnResize?: TableColumnResizeActionCallback; - headerCellRefs: React.MutableRefObject>; - crossFilterOrder: React.MutableRefObject; - crossFilterRows: React.MutableRefObject<{ [key: string]: TableRow[] }>; + crossFilterOrder: string[]; + crossFilterRows: { [key: string]: TableRow[] }; showTypeIcons?: boolean; } @@ -32,113 +27,54 @@ const HeaderCell: React.FC = ({ column, rows, field, - onSort, direction, - justifyContent, filter, setFilter, - onColumnResize, - headerCellRefs, crossFilterOrder, crossFilterRows, showTypeIcons, }) => { - const styles = useStyles2(getStyles, justifyContent); - const headerRef = useRef(null); + const styles = useStyles2(getStyles); + const displayName = useMemo(() => getDisplayName(field), [field]); + const filterable = useMemo(() => field.config.custom?.filterable ?? false, [field]); - const filterable = field.config?.custom?.filterable ?? false; - const displayName = getDisplayName(field); - - let isColumnFilterable = filterable; - if (field.config.custom?.filterable !== filterable) { - isColumnFilterable = field.config.custom?.filterable || false; - } // we have to remove/reset the filter if the column is not filterable - if (!isColumnFilterable && filter[displayName]) { - setFilter((filter: FilterType) => { - const newFilter = { ...filter }; - delete newFilter[displayName]; - return newFilter; - }); - } - - const handleSort = (event: React.MouseEvent) => { - const isMultiSort = event.shiftKey; - onSort(column.key, direction === 'ASC' ? 'DESC' : 'ASC', isMultiSort); - }; - - // collecting header cell refs to handle manual column resize - useLayoutEffect(() => { - if (headerRef.current) { - headerCellRefs.current[column.key] = headerRef.current; - } - }, [headerRef, column.key]); // eslint-disable-line react-hooks/exhaustive-deps - - // TODO: this is a workaround to handle manual column resize; useEffect(() => { - const headerCellParent = headerRef.current?.parentElement; - if (headerCellParent) { - // `lastElement` is an HTML element added by react-data-grid for resizing columns. - // We add event listeners to `lastElement` to handle the resize operation. - const lastElement = headerCellParent.lastElementChild; - if (lastElement) { - const handleMouseUp = () => { - let newWidth = headerCellParent.clientWidth; - onColumnResize?.(column.key, newWidth); - }; - - lastElement.addEventListener('click', handleMouseUp); - - return () => { - lastElement.removeEventListener('click', handleMouseUp); - }; - } + if (!filterable && filter[displayName]) { + setFilter((filter: FilterType) => { + const newFilter = { ...filter }; + delete newFilter[displayName]; + return newFilter; + }); } - // to handle "Not all code paths return a value." error - return; - }, [column]); // eslint-disable-line react-hooks/exhaustive-deps + }, [filterable, displayName, filter, setFilter]); return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions -
{ - if (event.key === ' ') { - event.stopPropagation(); - } - }} - > - + - {isColumnFilterable && ( + {filterable && ( )} -
+ ); }; -const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => ({ - headerCell: css({ - display: 'flex', - gap: theme.spacing(0.5), - justifyContent, - }), +const getStyles = (theme: GrafanaTheme2) => ({ headerCellLabel: css({ border: 'none', padding: 0, diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx index 13c4727e4e7..348d0aa168c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx @@ -3,10 +3,10 @@ import { Property } from 'csstype'; import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { TableCellDisplayMode } from '@grafana/schema'; import { useStyles2 } from '../../../../themes/ThemeContext'; import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; +import { TableCellDisplayMode } from '../../types'; import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils'; import { ImageCellProps } from '../types'; import { getCellLinks } from '../utils'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx index 95e29ce17f3..019afb7e243 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx @@ -12,7 +12,7 @@ export function RowExpander({ height, onCellExpand, isExpanded }: RowExpanderNGP function handleKeyDown(e: React.KeyboardEvent) { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); - onCellExpand(); + onCellExpand(e); } } return ( diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx index 190de5b85bd..225790cb0bb 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx @@ -82,7 +82,7 @@ export const SparklineCell = (props: SparklineCellProps) => { }, }; - const hideValue = field.config.custom?.cellOptions?.hideValue; + const hideValue = cellOptions.hideValue; let valueWidth = 0; let valueElement: React.ReactNode = null; if (!hideValue) { diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx new file mode 100644 index 00000000000..eb67a369cb2 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx @@ -0,0 +1,82 @@ +import { WKT } from 'ol/format'; +import { Geometry } from 'ol/geom'; + +import { FieldType } from '@grafana/data'; +import { t } from '@grafana/i18n'; + +import { IconButton } from '../../../IconButton/IconButton'; +import { TableCellInspectorMode } from '../../TableCellInspector'; +import { TableCellDisplayMode } from '../../types'; +import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, TableCellActionsProps } from '../types'; + +export function TableCellActions(props: TableCellActionsProps) { + const { + field, + value, + cellOptions, + displayName, + setIsInspecting, + setContextMenuProps, + onCellFilterAdded, + className, + cellInspect, + showFilters, + } = props; + + return ( +
+ {cellInspect && ( + { + let inspectValue = value; + let mode = TableCellInspectorMode.text; + + if (field.type === FieldType.geo && value instanceof Geometry) { + inspectValue = new WKT().writeGeometry(value, { + featureProjection: 'EPSG:3857', + dataProjection: 'EPSG:4326', + }); + mode = TableCellInspectorMode.code; + } else if ('cellType' in cellOptions && cellOptions.cellType === TableCellDisplayMode.JSONView) { + mode = TableCellInspectorMode.code; + } + + setContextMenuProps({ + value: String(inspectValue ?? ''), + mode, + }); + setIsInspecting(true); + }} + /> + )} + {showFilters && ( + <> + { + onCellFilterAdded?.({ + key: displayName, + operator: FILTER_FOR_OPERATOR, + value: String(value ?? ''), + }); + }} + /> + { + onCellFilterAdded?.({ + key: displayName, + operator: FILTER_OUT_OPERATOR, + value: String(value ?? ''), + }); + }} + /> + + )} +
+ ); +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx deleted file mode 100644 index a3a57a5564c..00000000000 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { css } from '@emotion/css'; -import { WKT } from 'ol/format'; -import { Geometry } from 'ol/geom'; -import { ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; - -import { FieldType, GrafanaTheme2, isDataFrame, isTimeSeriesFrame } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { TableAutoCellOptions, TableCellDisplayMode } from '@grafana/schema'; - -import { useStyles2 } from '../../../../themes/ThemeContext'; -import { IconButton } from '../../../IconButton/IconButton'; -// import { GeoCell } from '../../Cells/GeoCell'; -import { TableCellInspectorMode } from '../../TableCellInspector'; -import { - CellColors, - CustomCellRendererProps, - FILTER_FOR_OPERATOR, - FILTER_OUT_OPERATOR, - TableCellNGProps, -} from '../types'; -import { getCellColors, getDisplayName, getTextAlign } from '../utils'; - -import { ActionsCell } from './ActionsCell'; -import AutoCell from './AutoCell'; -import { BarGaugeCell } from './BarGaugeCell'; -import { DataLinksCell } from './DataLinksCell'; -import { GeoCell } from './GeoCell'; -import { ImageCell } from './ImageCell'; -import { JSONCell } from './JSONCell'; -import { SparklineCell } from './SparklineCell'; - -export function TableCellNG(props: TableCellNGProps) { - const { - field, - frame, - value, - theme, - timeRange, - height, - rowIdx, - justifyContent, - shouldTextOverflow, - setIsInspecting, - setContextMenuProps, - getActions, - rowBg, - onCellFilterAdded, - replaceVariables, - } = props; - - const cellInspect = field.config?.custom?.inspect ?? false; - const displayName = getDisplayName(field); - - const { config: fieldConfig } = field; - const defaultCellOptions: TableAutoCellOptions = { type: TableCellDisplayMode.Auto }; - const cellOptions = fieldConfig.custom?.cellOptions ?? defaultCellOptions; - const { type: cellType } = cellOptions; - - const showFilters = field.config.filterable && onCellFilterAdded; - - const isRightAligned = getTextAlign(field) === 'flex-end'; - const displayValue = field.display!(value); - let colors: CellColors = { bgColor: '', textColor: '', bgHoverColor: '' }; - if (rowBg) { - colors = rowBg(rowIdx); - } else { - colors = useMemo(() => getCellColors(theme, cellOptions, displayValue), [theme, cellOptions, displayValue]); - } - const styles = useStyles2(getStyles, isRightAligned, colors); - - // TODO - // TableNG provides either an overridden cell width or 'auto' as the cell width value. - // While the overridden value gives the exact cell width, 'auto' does not. - // Therefore, we need to determine the actual cell width from the DOM. - const divWidthRef = useRef(null); - const [divWidth, setDivWidth] = useState(0); - const [isHovered, setIsHovered] = useState(false); - - const actions = useMemo( - () => (getActions ? getActions(frame, field, rowIdx, replaceVariables) : []), - [getActions, frame, field, rowIdx, replaceVariables] - ); - - useLayoutEffect(() => { - if (divWidthRef.current && divWidthRef.current.clientWidth !== 0) { - setDivWidth(divWidthRef.current.clientWidth); - } - }, [divWidthRef.current]); // eslint-disable-line react-hooks/exhaustive-deps - - // Common props for all cells - const commonProps = useMemo( - () => ({ - value, - field, - rowIdx, - justifyContent, - }), - [value, field, rowIdx, justifyContent] - ); - - // Get the correct cell type - const renderedCell = useMemo(() => { - let cell: ReactNode = null; - switch (cellType) { - case TableCellDisplayMode.Sparkline: - cell = ; - break; - case TableCellDisplayMode.Gauge: - case TableCellDisplayMode.BasicGauge: - case TableCellDisplayMode.GradientGauge: - case TableCellDisplayMode.LcdGauge: - cell = ( - - ); - break; - case TableCellDisplayMode.Image: - cell = ; - break; - case TableCellDisplayMode.JSONView: - cell = ; - break; - case TableCellDisplayMode.DataLinks: - cell = ; - break; - case TableCellDisplayMode.Actions: - cell = ; - break; - case TableCellDisplayMode.Custom: - const CustomCellComponent: React.ComponentType = cellOptions.cellComponent; - cell = ; - break; - case TableCellDisplayMode.Auto: - default: - // Handle auto cell type detection - if (field.type === FieldType.geo) { - cell = ; - } else if (field.type === FieldType.frame) { - const firstValue = field.values[0]; - if (isDataFrame(firstValue) && isTimeSeriesFrame(firstValue)) { - cell = ; - } else { - cell = ; - } - } else if (field.type === FieldType.other) { - cell = ; - } else { - cell = ; - } - break; - } - return cell; - }, [cellType, commonProps, theme, timeRange, divWidth, height, cellOptions, field, rowIdx, actions, value, frame]); - - const handleMouseEnter = () => { - setIsHovered(true); - if (shouldTextOverflow()) { - // TODO: The table cell styles in TableNG do not update dynamically even if we change the state - const div = divWidthRef.current; - const tableCellDiv = div?.parentElement; - tableCellDiv?.style.setProperty('z-index', String(theme.zIndex.tooltip)); - tableCellDiv?.style.setProperty('white-space', 'pre-line'); - tableCellDiv?.style.setProperty('min-height', `100%`); - tableCellDiv?.style.setProperty('height', `fit-content`); - tableCellDiv?.style.setProperty('background', colors.bgHoverColor || 'none'); - tableCellDiv?.style.setProperty('min-width', 'min-content'); - } - }; - - const handleMouseLeave = () => { - setIsHovered(false); - if (shouldTextOverflow()) { - // TODO: The table cell styles in TableNG do not update dynamically even if we change the state - const div = divWidthRef.current; - const tableCellDiv = div?.parentElement; - tableCellDiv?.style.removeProperty('z-index'); - tableCellDiv?.style.removeProperty('white-space'); - tableCellDiv?.style.removeProperty('min-height'); - tableCellDiv?.style.removeProperty('height'); - tableCellDiv?.style.removeProperty('background'); - tableCellDiv?.style.removeProperty('min-width'); - } - }; - - const onFilterFor = useCallback(() => { - if (onCellFilterAdded) { - onCellFilterAdded({ - key: displayName, - operator: FILTER_FOR_OPERATOR, - value: String(value ?? ''), - }); - } - }, [displayName, onCellFilterAdded, value]); - - const onFilterOut = useCallback(() => { - if (onCellFilterAdded) { - onCellFilterAdded({ - key: displayName, - operator: FILTER_OUT_OPERATOR, - value: String(value ?? ''), - }); - } - }, [displayName, onCellFilterAdded, value]); - - return ( -
- {renderedCell} - {isHovered && (cellInspect || showFilters) && ( -
- {cellInspect && ( - { - let inspectValue = value; - let mode = TableCellInspectorMode.text; - - if (field.type === FieldType.geo && value instanceof Geometry) { - inspectValue = new WKT().writeGeometry(value, { - featureProjection: 'EPSG:3857', - dataProjection: 'EPSG:4326', - }); - mode = TableCellInspectorMode.code; - } else if (cellType === TableCellDisplayMode.JSONView) { - mode = TableCellInspectorMode.code; - } - - setContextMenuProps({ - value: String(inspectValue ?? ''), - mode, - }); - setIsInspecting(true); - }} - /> - )} - {showFilters && ( - <> - - - - )} -
- )} -
- ); -} - -const getStyles = (theme: GrafanaTheme2, isRightAligned: boolean, color: CellColors) => ({ - cell: css({ - height: '100%', - alignContent: 'center', - paddingInline: '8px', - // TODO: follow-up on this: change styles on hover on table row level - background: color.bgColor || 'none', - color: color.textColor, - '&:hover': { background: color.bgHoverColor }, - }), - cellActions: css({ - display: 'flex', - position: 'absolute', - top: '1px', - left: isRightAligned ? 0 : undefined, - right: isRightAligned ? undefined : 0, - margin: 'auto', - height: '100%', - background: theme.colors.background.secondary, - color: theme.colors.text.primary, - padding: '4px 0px 4px 4px', - }), -}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx new file mode 100644 index 00000000000..b27c050445d --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx @@ -0,0 +1,343 @@ +import { render } from '@testing-library/react'; + +import { createDataFrame, createTheme, Field, FieldType } from '@grafana/data'; + +import { TableCellOptions, TableCellDisplayMode, TableCustomCellOptions } from '../../types'; + +import { getCellRenderer } from './renderers'; + +// Performance testing utilities +const measurePerformance = (fn: () => void, iterations = 100) => { + const start = performance.now(); + for (let i = 0; i < iterations; i++) { + fn(); + } + const end = performance.now(); + return (end - start) / iterations; // Average time per iteration +}; + +const createLargeTimeSeriesFrame = () => { + const timeValues = Array.from({ length: 100 }, (_, i) => Date.now() + i * 1000); + const valueValues = Array.from({ length: 100 }, (_, i) => Math.random() * 100); + + return createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: timeValues }, + { name: 'value', type: FieldType.number, values: valueValues }, + ], + }); +}; + +const createLargeJSONData = () => { + return { + id: 1, + name: 'Test Object', + metadata: { + tags: Array.from({ length: 50 }, (_, i) => `tag-${i}`), + properties: Array.from({ length: 100 }, (_, i) => ({ key: `prop-${i}`, value: `value-${i}` })), + nested: { + level1: { + level2: { + level3: { + data: Array.from({ length: 20 }, (_, i) => ({ id: i, value: Math.random() })), + }, + }, + }, + }, + }, + array: Array.from({ length: 200 }, (_, i) => ({ id: i, value: Math.random() * 1000 })), + }; +}; + +describe('TableNG Cells renderers', () => { + describe('getCellRenderer', () => { + // Helper function to create a basic field + function createField(type: FieldType, values: V[] = []): Field { + return { + name: 'test', + type, + values, + config: {}, + state: {}, + display: jest.fn(() => ({ text: 'black', color: 'white', numeric: 0 })), + }; + } + + // Helper function to render a cell and get the test ID + const renderCell = (field: Field, cellOptions: TableCellOptions) => + render( + getCellRenderer( + field, + cellOptions + )({ + field, + value: 'test-value', + rowIdx: 0, + frame: createDataFrame({ fields: [field] }), + height: 100, + width: 100, + theme: createTheme(), + cellOptions, + cellInspect: false, + showFilters: false, + justifyContent: 'flex-start', + }) + ); + + // Performance test helper + const benchmarkCellPerformance = (field: Field, cellOptions: TableCellOptions, iterations = 100) => { + // eslint-disable-next-line testing-library/render-result-naming-convention + const r = getCellRenderer(field, cellOptions); + return measurePerformance(() => { + render( + r({ + field, + value: 'test-value', + rowIdx: 0, + frame: createDataFrame({ fields: [field] }), + height: 100, + width: 100, + theme: createTheme(), + cellOptions, + cellInspect: false, + showFilters: false, + justifyContent: 'flex-start', + }) + ); + }, iterations); + }; + + describe('explicit cell type cases', () => { + it.each([ + { type: TableCellDisplayMode.Sparkline, fieldType: FieldType.number }, + { type: TableCellDisplayMode.Gauge, fieldType: FieldType.number }, + { type: TableCellDisplayMode.JSONView, fieldType: FieldType.string }, + { type: TableCellDisplayMode.Image, fieldType: FieldType.string }, + { type: TableCellDisplayMode.DataLinks, fieldType: FieldType.string }, + { type: TableCellDisplayMode.Actions, fieldType: FieldType.string }, + { type: TableCellDisplayMode.ColorText, fieldType: FieldType.string }, + { type: TableCellDisplayMode.ColorBackground, fieldType: FieldType.string }, + { type: TableCellDisplayMode.Auto, fieldType: FieldType.string }, + ] as const)('should render $type cell into the document', ({ type, fieldType }) => { + const field = createField(fieldType); + const { container } = renderCell(field, { type }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + describe('invalid config cases', () => { + it('should return AutoCell when cellOptions.type is undefined', () => { + const field = createField(FieldType.string); + + const { container } = renderCell(field, { type: undefined } as unknown as TableCellOptions); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return AutoCell when cellOptions is undefined', () => { + const field = createField(FieldType.string); + + const { container } = renderCell(field, undefined as unknown as TableCellOptions); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + }); + }); + + describe('auto mode field type cases', () => { + it('should return GeoCell for geo field type', () => { + const field = createField(FieldType.geo); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return SparklineCell for frame field type with time series', () => { + const timeSeriesFrame = createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1, 2, 3] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + const field = createField(FieldType.frame, [timeSeriesFrame]); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return JSONCell for frame field type with non-time series', () => { + const regularFrame = createDataFrame({ + fields: [ + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + const field = createField(FieldType.frame, [regularFrame]); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return JSONCell for other field type', () => { + const field = createField(FieldType.other); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return AutoCell for string field type', () => { + const field = createField(FieldType.string); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return AutoCell for number field type', () => { + const field = createField(FieldType.number); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return AutoCell for boolean field type', () => { + const field = createField(FieldType.boolean); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should return AutoCell for time field type', () => { + const field = createField(FieldType.time); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + }); + + describe('custom cell renderer cases', () => { + it('should return custom cell component for Custom type with valid cellComponent', () => { + const CustomComponent = () =>
CustomCell
; + const field = createField(FieldType.string); + + const { container } = renderCell(field, { + type: TableCellDisplayMode.Custom, + cellComponent: CustomComponent, + }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('(invalid) should return null for Custom type without cellComponent', () => { + const field = createField(FieldType.string); + + const { container } = renderCell(field, { + type: TableCellDisplayMode.Custom, + cellComponent: undefined, + } as unknown as TableCustomCellOptions); + + expect(container.childNodes).toHaveLength(0); + }); + }); + + describe('edge cases', () => { + it('should handle empty field values array', () => { + const field = createField(FieldType.frame, []); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should handle field with null values', () => { + const field = createField(FieldType.frame, [null]); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + + it('should handle field with undefined values', () => { + const field = createField(FieldType.frame, [undefined]); + + const { container } = renderCell(field, { type: TableCellDisplayMode.Auto }); + expect(container).toBeInTheDocument(); + expect(container.childNodes).toHaveLength(1); + }); + }); + + describe.skip('performance benchmarks', () => { + // Performance thresholds (in milliseconds) + // these thresholds are tweaked based on performance on CI, not on a typical dev machine. + const PERFORMANCE_THRESHOLDS = { + FAST: 1, // Should render in under 1ms + MEDIUM: 2.5, // Should render in under 2.5ms + SLOW: 5, // Should render in under 5ms + }; + + describe('explicit cell type performance', () => { + it.each([ + { type: TableCellDisplayMode.Sparkline, threshold: PERFORMANCE_THRESHOLDS.MEDIUM }, + { type: TableCellDisplayMode.Gauge, threshold: PERFORMANCE_THRESHOLDS.SLOW }, + { type: TableCellDisplayMode.JSONView, threshold: PERFORMANCE_THRESHOLDS.FAST }, + { type: TableCellDisplayMode.Image, threshold: PERFORMANCE_THRESHOLDS.FAST }, + { type: TableCellDisplayMode.DataLinks, threshold: PERFORMANCE_THRESHOLDS.FAST }, + { type: TableCellDisplayMode.Actions, threshold: PERFORMANCE_THRESHOLDS.FAST }, + { type: TableCellDisplayMode.ColorText, threshold: PERFORMANCE_THRESHOLDS.FAST }, + { type: TableCellDisplayMode.ColorBackground, threshold: PERFORMANCE_THRESHOLDS.FAST }, + { type: TableCellDisplayMode.Auto, threshold: PERFORMANCE_THRESHOLDS.FAST }, + ] as const)('should render $type within performance threshold', ({ type, threshold }) => { + const field = createField(FieldType.number); + const avgTime = benchmarkCellPerformance(field, { type }, 100); + expect(avgTime).toBeLessThan(threshold); + }); + }); + + describe('custom cell renderer performance', () => { + it('should render custom cell component within performance threshold', () => { + const CustomComponent = () =>
CustomCell
; + const field = createField(FieldType.string); + const avgTime = benchmarkCellPerformance( + field, + { + type: TableCellDisplayMode.Custom, + cellComponent: CustomComponent, + }, + 50 + ); + expect(avgTime).toBeLessThan(PERFORMANCE_THRESHOLDS.FAST); + }); + }); + + describe('large data performance', () => { + it('should render JSONCell with large JSON data within performance threshold', () => { + const largeJSON = createLargeJSONData(); + const field = createField(FieldType.string, [JSON.stringify(largeJSON)]); + const avgTime = benchmarkCellPerformance(field, { type: TableCellDisplayMode.JSONView }, 20); + expect(avgTime).toBeLessThan(PERFORMANCE_THRESHOLDS.SLOW); + }); + + it('should render SparklineCell with large time series within performance threshold', () => { + const largeTimeSeriesFrame = createLargeTimeSeriesFrame(); + const field = createField(FieldType.frame, [largeTimeSeriesFrame]); + const avgTime = benchmarkCellPerformance(field, { type: TableCellDisplayMode.Sparkline }, 10); + expect(avgTime).toBeLessThan(PERFORMANCE_THRESHOLDS.MEDIUM); + }); + + it('should render AutoCell with large string data within performance threshold', () => { + const largeString = 'x'.repeat(10000); // 10KB string + const field = createField(FieldType.string, [largeString]); + const avgTime = benchmarkCellPerformance(field, { type: TableCellDisplayMode.Auto }, 30); + expect(avgTime).toBeLessThan(PERFORMANCE_THRESHOLDS.MEDIUM); + }); + }); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx new file mode 100644 index 00000000000..7bb4b39f2cb --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx @@ -0,0 +1,135 @@ +import { ReactNode } from 'react'; + +import { Field, FieldType, isDataFrame, isTimeSeriesFrame } from '@grafana/data'; + +import { TableCellDisplayMode, TableCellOptions, TableCustomCellOptions } from '../../types'; +import { TableCellRendererProps } from '../types'; + +import { ActionsCell } from './ActionsCell'; +import AutoCell from './AutoCell'; +import { BarGaugeCell } from './BarGaugeCell'; +import { DataLinksCell } from './DataLinksCell'; +import { GeoCell } from './GeoCell'; +import { ImageCell } from './ImageCell'; +import { JSONCell } from './JSONCell'; +import { SparklineCell } from './SparklineCell'; + +export type TableCellRenderer = (props: TableCellRendererProps) => ReactNode; + +const GAUGE_RENDERER: TableCellRenderer = (props) => ( + +); + +const AUTO_RENDERER: TableCellRenderer = (props) => ( + +); + +const SPARKLINE_RENDERER: TableCellRenderer = (props) => ( + +); + +const JSON_RENDERER: TableCellRenderer = (props) => ( + +); + +const GEO_RENDERER: TableCellRenderer = (props) => ( + +); + +const IMAGE_RENDERER: TableCellRenderer = (props) => ( + +); + +const DATA_LINKS_RENDERER: TableCellRenderer = (props) => ; + +const ACTIONS_RENDERER: TableCellRenderer = (props) => ; + +function isCustomCellOptions(options: TableCellOptions): options is TableCustomCellOptions { + return options.type === TableCellDisplayMode.Custom; +} + +const CUSTOM_RENDERER: TableCellRenderer = (props) => { + if (!isCustomCellOptions(props.cellOptions) || !props.cellOptions.cellComponent) { + return null; // nonsensical case, but better to typeguard it than throw. + } + const CustomCellComponent = props.cellOptions.cellComponent; + return ; +}; + +const CELL_RENDERERS: Record = { + [TableCellDisplayMode.Sparkline]: SPARKLINE_RENDERER, + [TableCellDisplayMode.Gauge]: GAUGE_RENDERER, + [TableCellDisplayMode.JSONView]: JSON_RENDERER, + [TableCellDisplayMode.Image]: IMAGE_RENDERER, + [TableCellDisplayMode.DataLinks]: DATA_LINKS_RENDERER, + [TableCellDisplayMode.Actions]: ACTIONS_RENDERER, + [TableCellDisplayMode.Custom]: CUSTOM_RENDERER, + [TableCellDisplayMode.ColorText]: AUTO_RENDERER, + [TableCellDisplayMode.ColorBackground]: AUTO_RENDERER, + [TableCellDisplayMode.Auto]: AUTO_RENDERER, +}; + +/** @internal */ +export function getCellRenderer(field: Field, cellOptions: TableCellOptions): TableCellRenderer { + const cellType = cellOptions?.type ?? TableCellDisplayMode.Auto; + if (cellType === TableCellDisplayMode.Auto) { + return getAutoRendererResult(field); + } + return CELL_RENDERERS[cellType]; +} + +/** @internal */ +export function getAutoRendererResult(field: Field): TableCellRenderer { + if (field.type === FieldType.geo) { + return GEO_RENDERER; + } + if (field.type === FieldType.frame) { + const firstValue = field.values[0]; + if (isDataFrame(firstValue) && isTimeSeriesFrame(firstValue)) { + return SPARKLINE_RENDERER; + } else { + return JSON_RENDERER; + } + } + if (field.type === FieldType.other) { + return JSON_RENDERER; + } + return AUTO_RENDERER; +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx b/packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx index 120cbc985a7..acde579e401 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx @@ -1,12 +1,12 @@ import { css, cx } from '@emotion/css'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Field, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { useStyles2 } from '../../../../themes/ThemeContext'; import { Icon } from '../../../Icon/Icon'; import { Popover } from '../../../Tooltip/Popover'; -import { TableRow } from '../types'; +import { FilterType, TableRow } from '../types'; import { REGEX_OPERATOR } from './FilterList'; import { FilterPopup } from './FilterPopup'; @@ -14,8 +14,8 @@ import { FilterPopup } from './FilterPopup'; interface Props { name: string; rows: any[]; - filter: any; - setFilter: (value: any) => void; + filter: FilterType; + setFilter: (value: FilterType) => void; field?: Field; crossFilterOrder: string[]; crossFilterRows: { [key: string]: TableRow[] }; @@ -43,8 +43,6 @@ export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder, const [isPopoverVisible, setPopoverVisible] = useState(false); const styles = useStyles2(getStyles); const filterEnabled = useMemo(() => Boolean(filterValue), [filterValue]); - const onShowPopover = useCallback(() => setPopoverVisible(true), [setPopoverVisible]); - const onClosePopover = useCallback(() => setPopoverVisible(false), [setPopoverVisible]); const [searchFilter, setSearchFilter] = useState(filter[name]?.searchFilter || ''); const [operator, setOperator] = useState>(filter[name]?.operator || REGEX_OPERATOR); @@ -53,7 +51,10 @@ export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder, className={cx(styles.headerFilter, filterEnabled ? styles.filterIconEnabled : styles.filterIconDisabled)} ref={ref} type="button" - onClick={onShowPopover} + onClick={(ev) => { + setPopoverVisible(true); + ev.stopPropagation(); + }} > {isPopoverVisible && ref.current && ( @@ -65,13 +66,18 @@ export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder, filterValue={filterValue} setFilter={setFilter} field={field} - onClose={onClosePopover} + onClose={() => setPopoverVisible(false)} searchFilter={searchFilter} setSearchFilter={setSearchFilter} operator={operator} setOperator={setOperator} /> } + onKeyDown={(event) => { + if (event.key === ' ') { + event.stopPropagation(); + } + }} placement="bottom-start" referenceElement={ref.current} show diff --git a/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx b/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx index d11d66cf2e0..9893504f297 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx @@ -12,6 +12,7 @@ import { FilterInput } from '../../../FilterInput/FilterInput'; import { Label } from '../../../Forms/Label'; import { Stack } from '../../../Layout/Stack/Stack'; import { FilterType } from '../types'; +import { getDisplayName } from '../utils'; import { FilterList } from './FilterList'; import { calculateUniqueFieldValues, getFilteredOptions, valuesToOptions } from './utils'; @@ -110,7 +111,7 @@ export const FilterPopup = ({
- {field && } + {field && } { @@ -141,7 +142,7 @@ const createNestedDataFrame = (): DataFrame => { config: { custom: { hidden: true } }, }, { - name: 'Nested frames', + name: '__nestedFrames', type: FieldType.nestedFrames, values: [[processedNestedFrame], [processedNestedFrame]], config: { custom: {} }, @@ -251,7 +252,6 @@ const createSortingTestDataFrame = (): DataFrame => { })[0]; }; -// Create a data frame with time field for testing crosshair sharing functionality const createTimeDataFrame = (): DataFrame => { const frame = toDataFrame({ name: 'TimeTestData', @@ -324,7 +324,7 @@ describe('TableNG', () => { }); describe('Basic TableNG rendering', () => { - it('renders a simple table with columns and rows', () => { + it('renders a simple table with columns and rows', async () => { const { container } = render( ); @@ -649,9 +649,6 @@ describe('TableNG', () => { describe('Sorting', () => { it('allows sorting when clicking on column headers', async () => { - // Mock scrollIntoView - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - const { container } = render( ); @@ -661,50 +658,49 @@ describe('TableNG', () => { expect(columnHeader).toBeInTheDocument(); // Find the sort button within the first header - if (columnHeader) { - // Store the initial state of the header - const initialSortAttribute = columnHeader.getAttribute('aria-sort'); + if (!columnHeader) { + throw new Error('No column header found'); + } - // Look for a button inside the header - const sortButton = columnHeader.querySelector('button') || columnHeader; + // Store the initial state of the header + const initialSortAttribute = columnHeader.getAttribute('aria-sort'); - // Click the sort button - await user.click(sortButton); + // Look for a button inside the header + const sortButton = columnHeader.querySelector('button') || columnHeader; - // After clicking, the header should have an aria-sort attribute - const newSortAttribute = columnHeader.getAttribute('aria-sort'); + // Click the sort button + await user.click(sortButton); - // The sort attribute should have changed - expect(newSortAttribute).not.toBe(initialSortAttribute); + // After clicking, the header should have an aria-sort attribute + const newSortAttribute = columnHeader.getAttribute('aria-sort'); - // The sort attribute should be either 'ascending' or 'descending' - expect(['ascending', 'descending']).toContain(newSortAttribute); + // The sort attribute should have changed + expect(newSortAttribute).not.toBe(initialSortAttribute); - // Also verify the data is sorted by checking cell values - const cells = container.querySelectorAll('[role="gridcell"]'); - const firstColumnCells = Array.from(cells).filter((_, index) => index % 2 === 0); + // The sort attribute should be either 'ascending' or 'descending' + expect(['ascending', 'descending']).toContain(newSortAttribute); - // Get the text content of the first column cells - const cellValues = firstColumnCells.map((cell) => cell.textContent); + // Also verify the data is sorted by checking cell values + const cells = container.querySelectorAll('[role="gridcell"]'); + const firstColumnCells = Array.from(cells).filter((_, index) => index % 2 === 0); - // Verify we have values to check - expect(cellValues.length).toBeGreaterThan(0); + // Get the text content of the first column cells + const cellValues = firstColumnCells.map((cell) => cell.textContent); - // Verify the values are in sorted order based on the aria-sort attribute - const sortedValues = [...cellValues].sort(); + // Verify we have values to check + expect(cellValues.length).toBeGreaterThan(0); - if (newSortAttribute === 'ascending') { - expect(JSON.stringify(cellValues)).toBe(JSON.stringify(sortedValues)); - } else if (newSortAttribute === 'descending') { - expect(JSON.stringify(cellValues)).toBe(JSON.stringify([...sortedValues].reverse())); - } + // Verify the values are in sorted order based on the aria-sort attribute + const sortedValues = [...cellValues].sort(); + + if (newSortAttribute === 'ascending') { + expect(JSON.stringify(cellValues)).toBe(JSON.stringify(sortedValues)); + } else if (newSortAttribute === 'descending') { + expect(JSON.stringify(cellValues)).toBe(JSON.stringify([...sortedValues].reverse())); } }); it('cycles through ascending, descending, and no sort states', async () => { - // Mock scrollIntoView - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - const { container } = render( ); @@ -733,10 +729,7 @@ describe('TableNG', () => { } }); - it('supports multi-column sorting with shift key', async () => { - // Mock scrollIntoView - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - + it('supports multi-column sorting with cmd or ctrl key', async () => { const { container } = render( ); @@ -831,7 +824,7 @@ describe('TableNG', () => { expect(categoryBValues).toContain('4'); // 2. Now add second sort column (Value) with shift key - await user.keyboard('{Shift>}'); + await user.keyboard('{Control>}'); await user.click(valueColumnButton); // Check data is sorted by Category and then by Value @@ -866,7 +859,6 @@ describe('TableNG', () => { expect(multiSortedRows[4][2]).toBe('Alice'); // 3. Change Value sort direction to descending - await user.keyboard('{Shift>}'); await user.click(valueColumnButton); // Check data is sorted by Category (asc) and then by Value (desc) @@ -901,7 +893,6 @@ describe('TableNG', () => { expect(multiSortedRowsDesc[4][2]).toBe('Jane'); // 4. Test removing the secondary sort by clicking a third time - await user.keyboard('{Shift>}'); await user.click(valueColumnButton); // The data should still be sorted by Category only @@ -915,6 +906,56 @@ describe('TableNG', () => { // Last 2 rows should still be 'B' category expect(singleSortRows[3][0]).toBe('B'); expect(singleSortRows[4][0]).toBe('B'); + + // finally release control and prove that we exit multi-sort mode + await user.keyboard('{/Control}'); + await user.click(categoryColumnButton); + + const nonMultiSortCategoryRows = getCellTextContent(); + + expect(nonMultiSortCategoryRows[0][0]).toBe('B'); + expect(nonMultiSortCategoryRows[0][1]).toBe('3'); + expect(nonMultiSortCategoryRows[0][2]).toBe('Jane'); + + expect(nonMultiSortCategoryRows[1][0]).toBe('B'); + expect(nonMultiSortCategoryRows[1][1]).toBe('4'); + expect(nonMultiSortCategoryRows[1][2]).toBe('Alice'); + + expect(nonMultiSortCategoryRows[2][0]).toBe('A'); + expect(nonMultiSortCategoryRows[2][1]).toBe('5'); + expect(nonMultiSortCategoryRows[2][2]).toBe('John'); + + expect(nonMultiSortCategoryRows[3][0]).toBe('A'); + expect(nonMultiSortCategoryRows[3][1]).toBe('1'); + expect(nonMultiSortCategoryRows[3][2]).toBe('Bob'); + + expect(nonMultiSortCategoryRows[4][0]).toBe('A'); + expect(nonMultiSortCategoryRows[4][1]).toBe('2'); + expect(nonMultiSortCategoryRows[4][2]).toBe('Charlie'); + + await user.click(valueColumnButton); + + const nonMultiSortValueRows = getCellTextContent(); + + expect(nonMultiSortValueRows[0][0]).toBe('A'); + expect(nonMultiSortValueRows[0][1]).toBe('1'); + expect(nonMultiSortValueRows[0][2]).toBe('Bob'); + + expect(nonMultiSortValueRows[1][0]).toBe('A'); + expect(nonMultiSortValueRows[1][1]).toBe('2'); + expect(nonMultiSortValueRows[1][2]).toBe('Charlie'); + + expect(nonMultiSortValueRows[2][0]).toBe('B'); + expect(nonMultiSortValueRows[2][1]).toBe('3'); + expect(nonMultiSortValueRows[2][2]).toBe('Jane'); + + expect(nonMultiSortValueRows[3][0]).toBe('B'); + expect(nonMultiSortValueRows[3][1]).toBe('4'); + expect(nonMultiSortValueRows[3][2]).toBe('Alice'); + + expect(nonMultiSortValueRows[4][0]).toBe('A'); + expect(nonMultiSortValueRows[4][1]).toBe('5'); + expect(nonMultiSortValueRows[4][2]).toBe('John'); }); it('correctly sorts different data types', async () => { @@ -978,6 +1019,38 @@ describe('TableNG', () => { // Verify number values are sorted numerically expect(numberValues).toEqual(['1', '2', '3']); }); + + it('triggers the onSortByChange callback', async () => { + const onSortByChange = jest.fn(); + + const { container } = render( + + ); + + // Ensure there are column headers + const columnHeader = container.querySelector('[role="columnheader"]'); + expect(columnHeader).toBeInTheDocument(); + + // Find the sort button within the first header + if (!columnHeader) { + throw new Error('No column header found'); + } + + // Look for a button inside the header + const sortButton = columnHeader.querySelector('button') || columnHeader; + + // Click the sort button + await user.click(sortButton); + + // After clicking, the header should have an aria-sort attribute + expect(onSortByChange).toHaveBeenCalledTimes(1); + }); }); describe('Filtering', () => { @@ -1159,8 +1232,28 @@ describe('TableNG', () => { }); }); - describe('Resizing', () => { - it('calls onColumnResize when column is resized', () => { + // TODO we need to test this with an e2e rather than a unit test, because the element dimensions calcs + // don't work in unit tests (no clientWidth/Height) + describe.skip('Resizing', () => { + beforeEach(() => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + window.HTMLElement.prototype.setPointerCapture = jest.fn(); + window.HTMLElement.prototype.hasPointerCapture = jest.fn(); + window.HTMLElement.prototype.releasePointerCapture = jest.fn(); + window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ + width: 100, + height: 20, + top: 0, + left: 0, + bottom: 0, + right: 0, + x: 0, + y: 0, + toJSON: jest.fn(() => ''), + })); + }); + + it('calls onColumnResize when column is resized', async () => { const onColumnResize = jest.fn(); const { container } = render( @@ -1174,22 +1267,19 @@ describe('TableNG', () => { ); // Find resize handle - const resizeHandles = container.querySelectorAll('.rdg-header-row > [role="columnheader"] .rdg-resizer'); + const resizeHandles = container.querySelectorAll('.rdg-header-row > [role="columnheader"] > div:last-child'); + const handle = resizeHandles[0]; - // TODO: This `if` doesn't even trigger - the test is evergreen. - // We should work out a reliable way to actually find and trigger the resize methods - // The querySelector doesn't return anything! - if (resizeHandles.length > 0) { - // Simulate resize by triggering mousedown, mousemove, mouseup - /* eslint-disable testing-library/prefer-user-event */ - fireEvent.mouseDown(resizeHandles[0]); - fireEvent.mouseMove(resizeHandles[0], { clientX: 250 }); - fireEvent.mouseUp(resizeHandles[0]); - /* eslint-enable testing-library/prefer-user-event */ - - // Check that onColumnResize was called - expect(onColumnResize).toHaveBeenCalled(); + if (!handle) { + throw new Error('Resize handle not found'); } + + // simulate a click, then drag, then release. + await userEvent.pointer({ keys: '[MouseLeft>]', coords: { x: 0, y: 0 }, target: handle }); + await userEvent.pointer({ coords: { x: 250, y: 0 }, target: handle }); + await userEvent.pointer({ keys: '[/MouseLeft]', coords: { x: 250, y: 0 }, target: handle }); + + await waitFor(() => expect(onColumnResize).toHaveBeenCalled()); }); }); @@ -1201,7 +1291,7 @@ describe('TableNG', () => { const cells = container.querySelectorAll('[role="gridcell"]'); const cellStyles = window.getComputedStyle(cells[0]); - expect(cellStyles.getPropertyValue('white-space')).toBe('nowrap'); + expect(cellStyles.getPropertyValue('white-space')).not.toBe('pre-line'); }); it('applies text wrapping styles when wrapText is true', () => { @@ -1265,6 +1355,10 @@ describe('TableNG', () => { // Check for the Inspect value menu item const menuItem = await screen.findByText('Inspect value'); expect(menuItem).toBeInTheDocument(); + + // close the menu + await userEvent.click(container); + expect(menuItem).not.toBeInTheDocument(); }); }); @@ -1371,8 +1465,8 @@ describe('TableNG', () => { expect(cells.length).toBeGreaterThan(0); // Check the first div inside the cell for style attributes - const div = cells[0].querySelectorAll('div')[0]; - const styleAttr = window.getComputedStyle(div); + const cell = cells[0]; + const styleAttr = window.getComputedStyle(cell); // Expected color is red expect(styleAttr.background).toBe('rgb(255, 0, 0)'); @@ -1408,11 +1502,50 @@ describe('TableNG', () => { expect(cells.length).toBeGreaterThan(0); // Check the first div inside the cell for style attributes - const div = cells[0].querySelectorAll('div')[0]; - const computedStyle = window.getComputedStyle(div); + const cell = cells[0]; + const computedStyle = window.getComputedStyle(cell); // Expected color is red expect(computedStyle.color).toBe('rgb(255, 0, 0)'); + + // doesn't accidentally applyToRow + const otherCell = cells[1]; + expect(window.getComputedStyle(otherCell).color).not.toBe('rgb(255, 0, 0)'); + }); + + it("renders the background color correclty when using 'ColorBackground' display mode and applyToRow is true", () => { + // Create a frame with color background cells and applyToRow set to true + const frame = createBasicDataFrame(); + frame.fields[0].config.custom = { + ...frame.fields[0].config.custom, + cellOptions: { + type: TableCellDisplayMode.ColorBackground, + applyToRow: true, + mode: TableCellBackgroundDisplayMode.Basic, + }, + }; + + // Add color to the display values + const originalDisplay = frame.fields[0].display; + const expectedColor = '#ff0000'; // Red color + frame.fields[0].display = (value: unknown) => { + const displayValue = originalDisplay ? originalDisplay(value) : { text: String(value), numeric: 0 }; + return { + ...displayValue, + color: expectedColor, + }; + }; + + const { container } = render(); + + // Find rows in the table + const rows = container.querySelectorAll('[role="row"]'); + const cells = rows[1].querySelectorAll('[role="gridcell"]'); // Skip header row + for (const cell of cells) { + const cellStyle = window.getComputedStyle(cell); + // Ensure each cell has the same background color + expect(cellStyle.backgroundColor).toBe('rgb(255, 0, 0)'); + } }); }); @@ -1451,44 +1584,77 @@ describe('TableNG', () => { jest.clearAllMocks(); }); - it('should publish DataHoverEvent when hovering over a row with time field', () => { - const frame = createTimeDataFrame(); - const idx = 1; - - onRowHover(idx, mockPanelContext, frame, true); - - expect(mockEventBus.publish).toHaveBeenCalledWith( - expect.objectContaining({ - payload: { - point: { - time: new Date('2024-03-20T10:01:00Z').getTime(), - }, - }, - type: 'data-hover', - }) + it('should publish DataHoverEvent when hovering over a row with time field', async () => { + const data = createTimeDataFrame(); + render( + + + ); + + await userEvent.hover(screen.getAllByRole('row')[1]); + + expect(mockEventBus.publish).toHaveBeenCalledWith({ + payload: { + point: { + time: data.fields[0].values[0], + }, + }, + type: 'data-hover', + }); }); - it('should not publish DataHoverEvent when enableSharedCrosshair is false', () => { - const frame = createTimeDataFrame(); - const idx = 1; + it('should not publish DataHoverEvent when enableSharedCrosshair is false', async () => { + render( + + + + ); - onRowHover(idx, mockPanelContext, frame, false); + await userEvent.hover(screen.getAllByRole('row')[1]); expect(mockEventBus.publish).not.toHaveBeenCalled(); }); - it('should not publish DataHoverEvent when time field is not present', () => { - const frame = createBasicDataFrame(); - const idx = 1; + it('should not publish DataHoverEvent when time field is not present', async () => { + render( + + + + ); - onRowHover(idx, mockPanelContext, frame, true); + await userEvent.hover(screen.getAllByRole('row')[1]); expect(mockEventBus.publish).not.toHaveBeenCalled(); }); - it('should publish DataHoverClearEvent when leaving a row', () => { - onRowLeave(mockPanelContext, true); + it('should publish DataHoverClearEvent when leaving a row', async () => { + render( + + + + ); + + await userEvent.hover(screen.getAllByRole('row')[1]); + await userEvent.unhover(screen.getAllByRole('row')[1]); expect(mockEventBus.publish).toHaveBeenCalledWith( expect.objectContaining({ @@ -1497,50 +1663,23 @@ describe('TableNG', () => { ); }); - it('should not publish DataHoverClearEvent when enableSharedCrosshair is false', () => { - onRowLeave(mockPanelContext, false); + it('should not publish DataHoverClearEvent when enableSharedCrosshair is false', async () => { + render( + + + + ); + + await userEvent.hover(screen.getAllByRole('row')[1]); + await userEvent.unhover(screen.getAllByRole('row')[1]); expect(mockEventBus.publish).not.toHaveBeenCalled(); }); }); - describe('scroll position persistence', () => { - it('should persist scroll position after revId change', () => { - const data = createBasicDataFrame(); - const { rerender } = render(); - - // Find the DataGrid element - const dataGrid = screen.getByRole('grid'); - - // Simulate scrolling - - fireEvent.scroll(dataGrid, { - target: { - scrollLeft: 100, - scrollTop: 50, - }, - }); - - // Rerender with the same data but different fieldConfig to trigger revId change - rerender( - - ); - - // Verify scroll position was restored - expect(dataGrid.scrollLeft).toBe(100); - expect(dataGrid.scrollTop).toBe(50); - }); - }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 22ed8d6105c..d2e38be4f58 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -1,23 +1,20 @@ import 'react-data-grid/lib/styles.css'; -import { css } from '@emotion/css'; -import { useMemo, useState, useLayoutEffect, useCallback, useRef, useEffect } from 'react'; -import { DataGrid, RenderCellProps, RenderRowProps, Row, SortColumn, DataGridHandle } from 'react-data-grid'; -import { useMeasure } from 'react-use'; - +import { css, cx } from '@emotion/css'; +import { Property } from 'csstype'; +import { Key, useLayoutEffect, useMemo, useState } from 'react'; import { - DataFrame, - DataHoverClearEvent, - DataHoverEvent, - Field, - fieldReducers, - FieldType, - formattedValueToString, - getDefaultTimeRange, - GrafanaTheme2, - ReducerID, -} from '@grafana/data'; + Cell, + CellRendererProps, + DataGrid, + DataGridProps, + RenderCellProps, + RenderRowProps, + Row, + SortColumn, +} from 'react-data-grid'; + +import { DataHoverClearEvent, DataHoverEvent, Field, FieldType, GrafanaTheme2, ReducerID } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { TableCellDisplayMode } from '@grafana/schema'; import { useStyles2, useTheme2 } from '../../../themes/ThemeContext'; import { ContextMenu } from '../../ContextMenu/ContextMenu'; @@ -25,137 +22,87 @@ import { MenuItem } from '../../Menu/MenuItem'; import { Pagination } from '../../Pagination/Pagination'; import { PanelContext, usePanelContext } from '../../PanelChrome'; import { TableCellInspector, TableCellInspectorMode } from '../TableCellInspector'; +import { CellColors } from '../types'; import { HeaderCell } from './Cells/HeaderCell'; import { RowExpander } from './Cells/RowExpander'; -import { TableCellNG } from './Cells/TableCellNG'; +import { TableCellActions } from './Cells/TableCellActions'; +import { getCellRenderer } from './Cells/renderers'; import { COLUMN, TABLE } from './constants'; import { - TableNGProps, - FilterType, - TableRow, - TableSummaryRow, - ColumnTypes, - TableColumnResizeActionCallback, - TableColumn, - TableFieldOptionsType, - ScrollPosition, - CellColors, -} from './types'; + useColumnResize, + useFilteredRows, + useFooterCalcs, + usePaginatedRows, + useRowHeight, + useSortedRows, + useTextWraps, +} from './hooks'; +import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps } from './types'; import { frameToRecords, - getCellColors, - getCellHeightCalculator, - getComparator, getDefaultRowHeight, getDisplayName, - getFooterItemNG, - getFooterStyles, getIsNestedTable, - getRowHeight, getTextAlign, - handleSort, - MapFrameToGridOptions, - processNestedTableRows, + getVisibleFields, shouldTextOverflow, + getApplyToRowBgFn, + getColumnTypes, + computeColWidths, + applySort, + getCellColors, + getCellOptions, } from './utils'; export function TableNG(props: TableNGProps) { const { cellHeight, + data, enablePagination, - enableVirtualization = true, - fieldConfig, + enableSharedCrosshair = false, + enableVirtualization, footerOptions, + getActions, height, initialSortBy, noHeader, onCellFilterAdded, onColumnResize, onSortByChange, - width, - data, - enableSharedCrosshair, - showTypeIcons, replaceVariables, + showTypeIcons, + structureRev, + width, } = props; - const initialSortColumns = useMemo(() => { - const initialSort = initialSortBy?.map(({ displayName, desc }) => { - const matchingField = data.fields.find(({ state }) => state?.displayName === displayName); - const columnKey = matchingField?.name || displayName; - - return { - columnKey, - direction: desc ? ('DESC' as const) : ('ASC' as const), - }; - }); - return initialSort ?? []; - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - /* ------------------------------- Local state ------------------------------ */ - const [revId, setRevId] = useState(0); - const [contextMenuProps, setContextMenuProps] = useState<{ - rowIdx?: number; - value: string; - mode?: TableCellInspectorMode.code | TableCellInspectorMode.text; - top?: number; - left?: number; - } | null>(null); - const [isInspecting, setIsInspecting] = useState(false); - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); - const [filter, setFilter] = useState({}); - const [page, setPage] = useState(0); - // This state will trigger re-render for recalculating row heights - const [, setResizeTrigger] = useState(0); - const [, setReadyForRowHeightCalc] = useState(false); - const [sortColumns, setSortColumns] = useState(initialSortColumns); - const [expandedRows, setExpandedRows] = useState([]); - const [isNestedTable, setIsNestedTable] = useState(false); - const scrollPositionRef = useRef({ x: 0, y: 0 }); - const [hasScroll, setHasScroll] = useState(false); - - /* ------------------------------- Local refs ------------------------------- */ - const crossFilterOrder = useRef([]); - const crossFilterRows = useRef>({}); - const headerCellRefs = useRef>({}); - // TODO: This ref persists sortColumns between renders. setSortColumns is still used to trigger re-render - const sortColumnsRef = useRef(initialSortColumns); - const prevProps = useRef(props); - const calcsRef = useRef([]); - const [paginationWrapperRef, { height: paginationHeight }] = useMeasure(); - const theme = useTheme2(); + const styles = useStyles2(getGridStyles, { + enablePagination, + noHeader, + }); const panelContext = usePanelContext(); - const isFooterVisible = Boolean(footerOptions?.show && footerOptions.reducer?.length); + const hasHeader = !noHeader; + const hasFooter = Boolean(footerOptions?.show && footerOptions.reducer?.length); const isCountRowsSet = Boolean( footerOptions?.countRows && footerOptions.reducer && footerOptions.reducer.length && footerOptions.reducer[0] === ReducerID.count ); - const tableRef = useRef(null); - /* --------------------------------- Effects -------------------------------- */ - useEffect(() => { - // TODO: there is a use case when adding a new column to the table doesn't update the table - if ( - prevProps.current.data.fields.length !== props.data.fields.length || - prevProps.current.fieldConfig?.overrides !== fieldConfig?.overrides || - prevProps.current.fieldConfig?.defaults !== fieldConfig?.defaults - ) { - setRevId(revId + 1); - } - prevProps.current = props; - }, [props, revId, fieldConfig?.overrides, fieldConfig?.defaults]); // eslint-disable-line react-hooks/exhaustive-deps + const [contextMenuProps, setContextMenuProps] = useState(null); + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + + const resizeHandler = useColumnResize(onColumnResize); useLayoutEffect(() => { if (!isContextMenuOpen) { return; } - function onClick(event: MouseEvent) { + function onClick(_event: MouseEvent) { setIsContextMenuOpen(false); } @@ -166,427 +113,90 @@ export function TableNG(props: TableNGProps) { }; }, [isContextMenuOpen]); - useEffect(() => { - const hasNestedFrames = getIsNestedTable(props.data); - setIsNestedTable(hasNestedFrames); - }, [props.data]); + const rows = useMemo(() => frameToRecords(data), [data]); + const columnTypes = useMemo(() => getColumnTypes(data.fields), [data.fields]); + const hasNestedFrames = useMemo(() => getIsNestedTable(data.fields), [data]); - useEffect(() => { - const el = tableRef.current; - if (el) { - const gridElement = el?.element; - if (gridElement) { - setHasScroll( - gridElement.scrollHeight > gridElement.clientHeight || gridElement.scrollWidth > gridElement.clientWidth - ); - } - } - }, []); + const { + rows: filteredRows, + filter, + setFilter, + crossFilterOrder, + crossFilterRows, + } = useFilteredRows(rows, data.fields, { hasNestedFrames }); - // TODO: this is a hack to force the column width to update when the fieldConfig changes - const columnWidth = useMemo(() => { - setRevId(revId + 1); - return fieldConfig?.defaults?.custom?.width || 'auto'; - }, [fieldConfig]); // eslint-disable-line react-hooks/exhaustive-deps + const { + rows: sortedRows, + sortColumns, + setSortColumns, + } = useSortedRows(filteredRows, data.fields, { columnTypes, hasNestedFrames, initialSortBy }); - const defaultRowHeight = getDefaultRowHeight(theme, cellHeight); - const defaultLineHeight = theme.typography.body.lineHeight * theme.typography.fontSize; - const panelPaddingHeight = theme.components.panel.padding * theme.spacing.gridSize * 2; + const defaultRowHeight = useMemo(() => getDefaultRowHeight(theme, cellHeight), [theme, cellHeight]); + const [isInspecting, setIsInspecting] = useState(false); + const [expandedRows, setExpandedRows] = useState>({}); - /* ------------------------------ Rows & Columns ----------------------------- */ - const rows = useMemo(() => frameToRecords(props.data), [frameToRecords, props.data]); // eslint-disable-line react-hooks/exhaustive-deps - - // Create a map of column key to column type - const columnTypes = useMemo( - () => props.data.fields.reduce((acc, field) => ({ ...acc, [getDisplayName(field)]: field.type }), {}), - [props.data.fields] + // vt scrollbar accounting for column auto-sizing + const visibleFields = useMemo(() => getVisibleFields(data.fields), [data.fields]); + const visibleFieldsByDisplayName: Record = useMemo( + () => visibleFields.reduce((acc, f) => ({ ...acc, [getDisplayName(f)]: f }), {}), + [visibleFields] ); + const availableWidth = useMemo( + () => (hasNestedFrames ? width - COLUMN.EXPANDER_WIDTH : width), + [width, hasNestedFrames] + ); + const widths = useMemo(() => computeColWidths(visibleFields, availableWidth), [visibleFields, availableWidth]); + const rowHeight = useRowHeight(widths, visibleFields, hasNestedFrames, defaultRowHeight, expandedRows); + + const { + rows: paginatedRows, + page, + setPage, + numPages, + pageRangeStart, + pageRangeEnd, + smallPagination, + } = usePaginatedRows(sortedRows, { + enabled: enablePagination, + width: availableWidth, + height, + hasHeader, + hasFooter, + rowHeight, + }); // Create a map of column key to text wrap - const textWraps = useMemo( + const textWraps = useTextWraps(data.fields); + const footerCalcs = useFooterCalcs(sortedRows, data.fields, { enabled: hasFooter, footerOptions, isCountRowsSet }); + const applyToRowBgFn = useMemo(() => getApplyToRowBgFn(data.fields, theme) ?? undefined, [data.fields, theme]); + + const renderRow = useMemo( + () => renderRowFactory(data.fields, panelContext, expandedRows, enableSharedCrosshair), + [data, enableSharedCrosshair, expandedRows, panelContext] + ); + + const renderCell = useMemo( + () => renderCellFactory(columnTypes, applyToRowBgFn, rowHeight, textWraps, theme, visibleFieldsByDisplayName), + [columnTypes, applyToRowBgFn, rowHeight, textWraps, theme, visibleFieldsByDisplayName] + ); + + const commonDataGridProps = useMemo( () => - props.data.fields.reduce<{ [key: string]: boolean }>( - (acc, field) => ({ - ...acc, - [getDisplayName(field)]: field.config?.custom?.cellOptions?.wrapText ?? false, - }), - {} - ), - [props.data.fields] - ); - - const textWrap = useMemo(() => Object.values(textWraps).some(Boolean), [textWraps]); - const styles = useStyles2(getStyles); - - // Create a function to get column widths for text wrapping calculations - const getColumnWidths = useCallback(() => { - const widths: Record = {}; - - // Set default widths from field config if they exist - props.data.fields.forEach((field) => { - const displayName = getDisplayName(field); - const configWidth = field.config?.custom?.width; - const totalWidth = typeof configWidth === 'number' ? configWidth : COLUMN.DEFAULT_WIDTH; - // subtract out padding and 1px right border - const contentWidth = totalWidth - 2 * TABLE.CELL_PADDING - 1; - widths[displayName] = contentWidth; - }); - - // Measure actual widths if available - Object.keys(headerCellRefs.current).forEach((key) => { - const headerCell = headerCellRefs.current[key]; - - if (headerCell.offsetWidth > 0) { - widths[key] = headerCell.offsetWidth; - } - }); - - return widths; - }, [props.data.fields]); - - const headersLength = useMemo(() => { - return props.data.fields.length; - }, [props.data.fields]); - - const fieldDisplayType = useMemo(() => { - return props.data.fields.reduce>((acc, field) => { - if (field.config?.custom?.cellOptions?.type) { - acc[getDisplayName(field)] = field.config.custom.cellOptions.type; - } - return acc; - }, {}); - }, [props.data.fields]); - - // Clean up fieldsData to simplify - const fieldsData = useMemo( - () => ({ - headersLength, - textWraps, - columnTypes, - fieldDisplayType, - columnWidths: getColumnWidths(), - }), - [textWraps, columnTypes, getColumnWidths, headersLength, fieldDisplayType] - ); - - // Filter rows - const filteredRows = useMemo(() => { - const filterValues = Object.entries(filter); - if (filterValues.length === 0) { - // reset cross filter order - crossFilterOrder.current = []; - return rows; - } - - // Helper function to get displayed value - const getDisplayedValue = (row: TableRow, key: string) => { - const field = props.data.fields.find((field) => getDisplayName(field) === key); - if (!field || !field.display) { - return ''; - } - const displayedValue = formattedValueToString(field.display(row[key])); - return displayedValue; - }; - - // Update crossFilterOrder - const filterKeys = new Set(filterValues.map(([key]) => key)); - filterKeys.forEach((key) => { - if (!crossFilterOrder.current.includes(key)) { - // Each time a filter is added or removed, it is always a single filter. - // When adding a new filter, it is always appended to the end, maintaining the order. - crossFilterOrder.current.push(key); - } - }); - // Remove keys from crossFilterOrder that are no longer present in the current filter values - crossFilterOrder.current = crossFilterOrder.current.filter((key) => filterKeys.has(key)); - - // reset crossFilterRows - crossFilterRows.current = {}; - - // For nested tables, only filter parent rows and keep their children - if (isNestedTable) { - return processNestedTableRows(rows, (parents) => - parents.filter((row) => { - for (const [key, value] of filterValues) { - const displayedValue = getDisplayedValue(row, key); - if (!value.filteredSet.has(displayedValue)) { - return false; - } - // collect rows for crossFilter - if (!crossFilterRows.current[key]) { - crossFilterRows.current[key] = [row]; - } else { - crossFilterRows.current[key].push(row); - } - } - return true; - }) - ); - } - - // Regular filtering for non-nested tables - return rows.filter((row) => { - for (const [key, value] of filterValues) { - const displayedValue = getDisplayedValue(row, key); - if (!value.filteredSet.has(displayedValue)) { - return false; - } - // collect rows for crossFilter - if (!crossFilterRows.current[key]) { - crossFilterRows.current[key] = [row]; - } else { - crossFilterRows.current[key].push(row); - } - } - return true; - }); - }, [rows, filter, isNestedTable, props.data.fields]); - - // Sort rows - const sortedRows = useMemo(() => { - if (sortColumns.length === 0) { - return filteredRows; - } - - // Common sort comparator function - const compareRows = (a: TableRow, b: TableRow): number => { - let result = 0; - for (let i = 0; i < sortColumns.length; i++) { - const { columnKey, direction } = sortColumns[i]; - const compare = getComparator(columnTypes[columnKey]); - const sortDir = direction === 'ASC' ? 1 : -1; - - result = sortDir * compare(a[columnKey], b[columnKey]); - if (result !== 0) { - break; - } - } - return result; - }; - - // Handle nested tables - if (isNestedTable) { - return processNestedTableRows(filteredRows, (parents) => [...parents].sort(compareRows)); - } - - // Regular sort for tables without nesting - return filteredRows.slice().sort((a, b) => compareRows(a, b)); - }, [filteredRows, sortColumns, columnTypes, isNestedTable]); - - // Paginated rows - // TODO consolidate calculations into pagination wrapper component and only use when needed - const numRows = sortedRows.length; - // calculate number of rowsPerPage based on height stack - let headerCellHeight = TABLE.MAX_CELL_HEIGHT; - if (noHeader) { - headerCellHeight = 0; - } else if (!noHeader && Object.keys(headerCellRefs.current).length > 0) { - headerCellHeight = headerCellRefs.current[Object.keys(headerCellRefs.current)[0]].getBoundingClientRect().height; - } - let rowsPerPage = Math.floor( - (height - headerCellHeight - TABLE.SCROLL_BAR_WIDTH - paginationHeight - panelPaddingHeight) / defaultRowHeight - ); - // if footer calcs are on, remove one row per page - if (isFooterVisible) { - rowsPerPage -= 1; - } - if (rowsPerPage < 1) { - // avoid 0 or negative rowsPerPage - rowsPerPage = 1; - } - const numberOfPages = Math.ceil(numRows / rowsPerPage); - if (page > numberOfPages) { - // resets pagination to end - setPage(numberOfPages - 1); - } - // calculate row range for pagination summary display - const itemsRangeStart = page * rowsPerPage + 1; - let displayedEnd = itemsRangeStart + rowsPerPage - 1; - if (displayedEnd > numRows) { - displayedEnd = numRows; - } - const smallPagination = width < TABLE.PAGINATION_LIMIT; - - const paginatedRows = useMemo(() => { - const pageOffset = page * rowsPerPage; - return sortedRows.slice(pageOffset, pageOffset + rowsPerPage); - }, [rows, sortedRows, page, rowsPerPage]); // eslint-disable-line react-hooks/exhaustive-deps - - useMemo(() => { - calcsRef.current = props.data.fields.map((field, index) => { - if (field.state?.calcs) { - delete field.state?.calcs; - } - if (isCountRowsSet) { - return index === 0 ? `${sortedRows.length}` : ''; - } - if (index === 0) { - const footerCalcReducer = footerOptions?.reducer?.[0]; - return footerCalcReducer ? fieldReducers.get(footerCalcReducer).name : ''; - } - return getFooterItemNG(sortedRows, field, footerOptions); - }); - }, [sortedRows, props.data.fields, footerOptions, isCountRowsSet]); // eslint-disable-line react-hooks/exhaustive-deps - - const onCellExpand = (rowIdx: number) => { - if (!expandedRows.includes(rowIdx)) { - setExpandedRows([...expandedRows, rowIdx]); - } else { - setExpandedRows(expandedRows.filter((id) => id !== rowIdx)); - } - setResizeTrigger((prev) => prev + 1); - }; - - const { ctx, avgCharWidth } = useMemo(() => { - const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`; - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d')!; - // set in grafana/data in createTypography.ts - const letterSpacing = 0.15; - - ctx.letterSpacing = `${letterSpacing}px`; - ctx.font = font; - let txt = - "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s"; - const txtWidth = ctx.measureText(txt).width; - const avgCharWidth = txtWidth / txt.length + letterSpacing; - - return { - ctx, - font, - avgCharWidth, - }; - }, [theme.typography.fontSize, theme.typography.fontFamily]); - - const columns = useMemo( - () => - mapFrameToDataGrid({ - frame: props.data, - calcsRef, - options: { - columnTypes, - textWraps, - columnWidth, - crossFilterOrder, - crossFilterRows, - defaultLineHeight, - defaultRowHeight, - expandedRows, - filter, - headerCellRefs, - isCountRowsSet, - onCellFilterAdded, - ctx, - onSortByChange, - rows, - setContextMenuProps, - setFilter, - setIsInspecting, - setSortColumns, - sortColumnsRef, - styles, - theme, - showTypeIcons, - replaceVariables, - ...props, - }, - handlers: { - onCellExpand, - onColumnResize: onColumnResize!, - }, - // Adjust table width to account for the scroll bar width - availableWidth: width - (hasScroll ? TABLE.SCROLL_BAR_WIDTH + TABLE.SCROLL_BAR_MARGIN : 0), - }), - [props.data, calcsRef, filter, expandedRows, expandedRows.length, footerOptions, width, hasScroll, sortedRows] // eslint-disable-line react-hooks/exhaustive-deps - ); - - // This effect needed to set header cells refs before row height calculation - useLayoutEffect(() => { - setReadyForRowHeightCalc(Object.keys(headerCellRefs.current).length > 0); - }, [columns]); - - const renderMenuItems = () => { - return ( - <> - { - setIsInspecting(true); - }} - className={styles.menuItem} - /> - - ); - }; - - const cellHeightCalc = useMemo(() => { - return getCellHeightCalculator(ctx, defaultLineHeight, defaultRowHeight, TABLE.CELL_PADDING); - }, [ctx, defaultLineHeight, defaultRowHeight]); - - const calculateRowHeight = useCallback( - (row: TableRow) => { - // Logic for sub-tables - if (Number(row.__depth) === 1 && !expandedRows.includes(Number(row.__index))) { - return 0; - } else if (Number(row.__depth) === 1 && expandedRows.includes(Number(row.__index))) { - const headerCount = row?.data?.meta?.custom?.noHeader ? 0 : 1; - - // Ensure we have a minimum height for the nested table even if data is empty - const rowCount = row.data?.length ?? 0; - return Math.max(defaultRowHeight, defaultRowHeight * (rowCount + headerCount)); - } - return getRowHeight(row, cellHeightCalc, avgCharWidth, defaultRowHeight, fieldsData); - }, - [expandedRows, avgCharWidth, defaultRowHeight, fieldsData, cellHeightCalc] - ); - - const handleScroll = (event: React.UIEvent) => { - const target = event.currentTarget; - scrollPositionRef.current = { - x: target.scrollLeft, - y: target.scrollTop, - }; - }; - - // Reset sortColumns when initialSortBy changes - useEffect(() => { - if (initialSortColumns.length > 0) { - setSortColumns(initialSortColumns); - } - }, [initialSortColumns]); - - // Restore scroll position after re-renders - useEffect(() => { - if (tableRef.current?.element) { - tableRef.current.element.scrollLeft = scrollPositionRef.current.x; - tableRef.current.element.scrollTop = scrollPositionRef.current.y; - } - }, [revId]); - - return ( - <> - - ref={tableRef} - className={styles.dataGrid} - // Default to true, overridden to false for testing - enableVirtualization={enableVirtualization} - key={`DataGrid${revId}`} - rows={enablePagination ? paginatedRows : sortedRows} - columns={columns} - headerRowHeight={noHeader ? 0 : undefined} - defaultColumnOptions={{ - sortable: true, + ({ + enableVirtualization, + defaultColumnOptions: { + minWidth: 50, resizable: true, - }} - rowHeight={textWrap || isNestedTable ? calculateRowHeight : defaultRowHeight} - // TODO: This doesn't follow current table behavior - style={{ width, height: height - (enablePagination ? paginationHeight : 0) }} - renderers={{ - renderRow: (key, props) => - myRowRenderer(key, props, expandedRows, panelContext, data, enableSharedCrosshair ?? false), - }} - onScroll={handleScroll} - onCellContextMenu={({ row, column }, event) => { + sortable: true, + // draggable: true, + }, + onCellContextMenu: ({ row, column }, event) => { + // in nested tables, it's possible for this event to trigger in a column header + // when holding Ctrl for multi-row sort. + if (column.key === 'expanded') { + return; + } + event.preventGridDefault(); // Do not show the default context menu event.preventDefault(); @@ -598,30 +208,278 @@ export function TableNG(props: TableNGProps) { top: event.clientY, left: event.clientX, }); + setIsContextMenuOpen(true); - }} - // sorting - sortColumns={sortColumns} - // footer - // TODO figure out exactly how this works - some array needs to be here for it to render regardless of renderSummaryCell() - bottomSummaryRows={isFooterVisible ? [{}] : undefined} - onColumnResize={() => { - // NOTE: This method is called continuously during the column resize drag operation, - // providing the current column width. There is no separate event for the end of the drag operation. - if (textWrap) { - // This is needed only when textWrap is enabled - // TODO: this is a hack to force rowHeight re-calculation - setResizeTrigger((prev) => prev + 1); - } - }} + }, + onColumnResize: resizeHandler, + onSortColumnsChange: (newSortColumns: SortColumn[]) => { + setSortColumns(newSortColumns); + onSortByChange?.( + newSortColumns.map(({ columnKey, direction }) => ({ + displayName: columnKey, + desc: direction === 'DESC', + })) + ); + }, + sortColumns, + rowHeight, + headerRowClass: styles.headerRow, + headerRowHeight: noHeader ? 0 : TABLE.HEADER_ROW_HEIGHT, + bottomSummaryRows: hasFooter ? [{}] : undefined, + }) satisfies Partial>, + [ + enableVirtualization, + resizeHandler, + sortColumns, + rowHeight, + styles.headerRow, + noHeader, + hasFooter, + setSortColumns, + onSortByChange, + ] + ); + + const columns = useMemo((): TableColumn[] => { + const columnsFromFields = (f: Field[], w: number[]): TableColumn[] => + f.map((field, i): TableColumn => { + const justifyContent = getTextAlign(field); + const footerStyles = getFooterStyles(justifyContent); + const displayName = getDisplayName(field); + const headerCellClass = getHeaderCellStyles(theme, justifyContent).headerCell; + const cellOptions = getCellOptions(field); + const renderFieldCell = getCellRenderer(field, cellOptions); + + const cellInspect = Boolean(field.config.custom?.inspect); + const showFilters = Boolean(field.config.filterable && onCellFilterAdded != null); + const showActions = cellInspect || showFilters; + const width = w[i]; + const frame = data; + + // helps us avoid string cx and emotion per-cell + const cellActionClassName = showActions + ? cx( + 'table-cell-actions', + styles.cellActions, + justifyContent === 'flex-end' ? styles.cellActionsEnd : styles.cellActionsStart + ) + : undefined; + + return { + field, + key: displayName, + name: displayName, + width, + headerCellClass, + renderCell: (props: RenderCellProps): JSX.Element => { + // TODO: once per row + const height = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight; + // TODO: defer until click? + const actions = getActions?.(frame, field, props.row.__index, replaceVariables); + + const rowIdx = props.row.__index; + const value = props.row[displayName]; + + return ( + <> + {renderFieldCell({ + actions, + cellOptions, + frame, + field, + height, + justifyContent, + rowIdx, + theme, + value, + width, + cellInspect, + showFilters, + })} + {showActions && ( + + )} + + ); + }, + renderHeaderCell: ({ column, sortDirection }): JSX.Element => ( + + ), + renderSummaryCell: () => { + if (isCountRowsSet && i === 0) { + return ( +
+ + Count + + {footerCalcs[i]} +
+ ); + } + return
{footerCalcs[i]}
; + }, + }; + }); + + const result: TableColumn[] = columnsFromFields(visibleFields, widths); + + // handle nested frames rendering from here. + if (!hasNestedFrames) { + return result; + } + + // pre-calculate renderRow and expandedColumns based on the first nested frame's fields. + const firstNestedData = rows.find((r) => r.data)?.data; + if (!firstNestedData) { + return result; + } + + const renderRow = renderRowFactory(firstNestedData.fields, panelContext, expandedRows, enableSharedCrosshair); + const expandedColumns = columnsFromFields( + firstNestedData.fields, + computeColWidths(firstNestedData.fields, availableWidth) + ); + + // If we have nested frames, we need to add a column for the row expansion + result.unshift({ + key: 'expanded', + name: '', + field: { + name: '', + type: FieldType.other, + config: {}, + values: [], + }, + cellClass(row) { + if (Number(row.__depth) !== 0) { + return styles.cellNested; + } + return; + }, + colSpan(args) { + return args.type === 'ROW' && Number(args.row.__depth) === 1 ? data.fields.length : 1; + }, + renderCell: ({ row }) => { + if (Number(row.__depth) === 0) { + return ( + { + setExpandedRows({ ...expandedRows, [row.__index]: !expandedRows[row.__index] }); + }} + /> + ); + } + + // Type guard to check if data exists as it's optional + const nestedData = row.data; + if (!nestedData) { + return null; + } + + const expandedRecords = applySort(frameToRecords(nestedData), nestedData.fields, sortColumns); + + return ( + + {...commonDataGridProps} + className={cx(styles.grid, styles.gridNested)} + columns={expandedColumns} + rows={expandedRecords} + renderers={{ renderRow, renderCell }} + /> + ); + }, + width: COLUMN.EXPANDER_WIDTH, + minWidth: COLUMN.EXPANDER_WIDTH, + }); + + return result; + }, [ + availableWidth, + commonDataGridProps, + crossFilterOrder, + crossFilterRows, + data, + defaultRowHeight, + enableSharedCrosshair, + expandedRows, + filter, + footerCalcs, + getActions, + hasNestedFrames, + isCountRowsSet, + onCellFilterAdded, + panelContext, + replaceVariables, + renderCell, + rows, + rowHeight, + setFilter, + showTypeIcons, + sortColumns, + styles, + theme, + visibleFields, + widths, + ]); + + // invalidate columns on every structureRev change. this supports width editing in the fieldConfig. + // eslint-disable-next-line react-hooks/exhaustive-deps + const structureRevColumns = useMemo(() => columns, [columns, structureRev]); + + // we need to have variables with these exact names for the localization to work properly + const itemsRangeStart = pageRangeStart; + const displayedEnd = pageRangeEnd; + const numRows = sortedRows.length; + + return ( + <> + + {...commonDataGridProps} + className={styles.grid} + columns={structureRevColumns} + rows={paginatedRows} + onCellKeyDown={ + hasNestedFrames + ? (_, event) => { + if (event.isDefaultPrevented()) { + // skip parent grid keyboard navigation if nested grid handled it + event.preventGridDefault(); + } + } + : null + } + renderers={{ renderRow, renderCell }} /> {enablePagination && ( -
+
{ setPage(toPage - 1); @@ -629,6 +487,8 @@ export function TableNG(props: TableNGProps) { /> {!smallPagination && (
+ {/* TODO: once TableRT is deprecated, we can update the localiziation + string with the more consistent variable names */} {{ itemsRangeStart }} - {{ displayedEnd }} of {{ numRows }} rows @@ -641,7 +501,13 @@ export function TableNG(props: TableNGProps) { ( + setIsInspecting(true)} + className={styles.menuItem} + /> + )} focusOnOpen={false} /> )} @@ -660,415 +526,172 @@ export function TableNG(props: TableNGProps) { ); } -export function mapFrameToDataGrid({ - frame, - calcsRef, - options, - handlers, - availableWidth, -}: { - frame: DataFrame; - calcsRef: React.MutableRefObject; - options: MapFrameToGridOptions; - handlers: { onCellExpand: (rowIdx: number) => void; onColumnResize: TableColumnResizeActionCallback }; - availableWidth: number; -}): TableColumn[] { - const { - columnTypes, - textWraps, - crossFilterOrder, - crossFilterRows, - defaultLineHeight, - defaultRowHeight, - expandedRows, - filter, - headerCellRefs, - isCountRowsSet, - onCellFilterAdded, - ctx, - onSortByChange, - rows, - setContextMenuProps, - setFilter, - setIsInspecting, - setSortColumns, - sortColumnsRef, - styles, - theme, - timeRange, - getActions, - showTypeIcons, - replaceVariables, - } = options; - const { onCellExpand, onColumnResize } = handlers; +/** + * this is passed to the top-level `renderRow` prop on DataGrid. applies aria attributes and custom event handlers. + */ +const renderRowFactory = + ( + fields: Field[], + panelContext: PanelContext, + expandedRows: Record, + enableSharedCrosshair: boolean + ) => + (key: React.Key, props: RenderRowProps): React.ReactNode => { + const { row } = props; + const rowIdx = Number(row.__index); + const isExpanded = !!expandedRows[rowIdx]; - const columns: TableColumn[] = []; - const hasNestedFrames = getIsNestedTable(frame); + // Don't render non expanded child rows + if (Number(row.__depth) === 1 && !isExpanded) { + return null; + } - // If nested frames, add expansion control column - if (hasNestedFrames) { - const expanderField: Field = { - name: '', - type: FieldType.other, - config: {}, - values: [], - }; - columns.push({ - key: 'expanded', - name: '', - field: expanderField, - cellClass: styles.cell, - colSpan(args) { - return args.type === 'ROW' && Number(args.row.__depth) === 1 ? frame.fields.length : 1; - }, - renderCell: ({ row }) => { - // TODO add TableRow type extension to include row depth and optional data - if (Number(row.__depth) === 0) { - const rowIdx = Number(row.__index); - return ( - onCellExpand(rowIdx)} - isExpanded={expandedRows.includes(rowIdx)} - /> + // Add aria-expanded to parent rows that have nested data + if (row.data) { + return ; + } + + const handlers: Partial = {}; + if (enableSharedCrosshair) { + const timeField = fields.find((f) => f.type === FieldType.time); + if (timeField) { + handlers.onMouseEnter = () => { + panelContext.eventBus.publish( + new DataHoverEvent({ + point: { + time: timeField?.values[rowIdx], + }, + }) ); - } - // If it's a child, render entire DataGrid at first column position - let expandedColumns: TableColumn[] = []; - let expandedRecords: TableRow[] = []; - - // Type guard to check if data exists as it's optional - if (row.data) { - expandedColumns = mapFrameToDataGrid({ - frame: row.data, - calcsRef, - options: { ...options }, - handlers: { onCellExpand, onColumnResize }, - availableWidth, - }); - expandedRecords = frameToRecords(row.data); - } - - // TODO add renderHeaderCell HeaderCell's here and handle all features - return ( - - rows={expandedRecords} - columns={expandedColumns} - rowHeight={defaultRowHeight} - className={styles.dataGrid} - style={{ height: '100%', overflow: 'visible', marginLeft: COLUMN.EXPANDER_WIDTH - 1 }} - headerRowHeight={row.data?.meta?.custom?.noHeader ? 0 : undefined} - /> - ); - }, - width: COLUMN.EXPANDER_WIDTH, - minWidth: COLUMN.EXPANDER_WIDTH, - }); - - availableWidth -= COLUMN.EXPANDER_WIDTH; - } - - // Row background color function - let rowBg: Function | undefined = undefined; - for (const field of frame.fields) { - const fieldOptions = field.config.custom; - const cellOptionsExist = fieldOptions !== undefined && fieldOptions.cellOptions !== undefined; - - if ( - cellOptionsExist && - fieldOptions.cellOptions.type === TableCellDisplayMode.ColorBackground && - fieldOptions.cellOptions.applyToRow - ) { - rowBg = (rowIndex: number): CellColors => { - const display = field.display!(field.values[rowIndex]); - const colors = getCellColors(theme, fieldOptions.cellOptions, display); - return colors; - }; - } - } - - let fieldCountWithoutWidth = 0; - frame.fields.map((field, fieldIndex) => { - if (field.type === FieldType.nestedFrames || field.config.custom?.hidden) { - // Don't render nestedFrames type field - return; - } - const fieldTableOptions: TableFieldOptionsType = field.config.custom || {}; - const key = getDisplayName(field); - const justifyColumnContent = getTextAlign(field); - const footerStyles = getFooterStyles(justifyColumnContent); - - // current/old table width logic calculations - if (fieldTableOptions.width) { - availableWidth -= fieldTableOptions.width; - } else { - fieldCountWithoutWidth++; - } - - // Add a column for each field - columns.push({ - key, - name: field.name, - field, - cellClass: textWraps[getDisplayName(field)] ? styles.cellWrapped : styles.cell, - renderCell: (props: RenderCellProps): JSX.Element => { - const { row } = props; - const cellType = field.config?.custom?.cellOptions?.type ?? TableCellDisplayMode.Auto; - const value = row[key]; - // Cell level rendering here - return ( - - shouldTextOverflow( - key, - row, - columnTypes, - headerCellRefs, - ctx, - defaultLineHeight, - defaultRowHeight, - TABLE.CELL_PADDING, - textWraps[getDisplayName(field)], - field, - cellType - ) - } - setIsInspecting={setIsInspecting} - setContextMenuProps={setContextMenuProps} - getActions={getActions} - rowBg={rowBg} - onCellFilterAdded={onCellFilterAdded} - replaceVariables={replaceVariables} - /> - ); - }, - renderSummaryCell: () => { - if (isCountRowsSet && fieldIndex === 0) { - return ( -
- - Count - - {calcsRef.current[fieldIndex]} -
- ); - } - return
{calcsRef.current[fieldIndex]}
; - }, - renderHeaderCell: ({ column, sortDirection }): JSX.Element => ( - { - handleSort(columnKey, direction, isMultiSort, setSortColumns, sortColumnsRef); - - // Update panel context with the new sort order - if (onSortByChange) { - const sortByFields = sortColumnsRef.current.map(({ columnKey, direction }) => ({ - displayName: columnKey, - desc: direction === 'DESC', - })); - onSortByChange(sortByFields); - } - }} - direction={sortDirection} - justifyContent={justifyColumnContent} - filter={filter} - setFilter={setFilter} - onColumnResize={onColumnResize} - headerCellRefs={headerCellRefs} - crossFilterOrder={crossFilterOrder} - crossFilterRows={crossFilterRows} - showTypeIcons={showTypeIcons} - /> - ), - width: fieldTableOptions.width, - minWidth: fieldTableOptions.minWidth || COLUMN.DEFAULT_WIDTH, - }); - }); - - // set columns that are at minimum width - let sharedWidth = availableWidth / fieldCountWithoutWidth; - for (let i = fieldCountWithoutWidth; i > 0; i--) { - for (const column of columns) { - if (!column.width && column.minWidth! > sharedWidth) { - column.width = column.minWidth; - availableWidth -= column.width!; - fieldCountWithoutWidth -= 1; - sharedWidth = availableWidth / fieldCountWithoutWidth; + }; + handlers.onMouseLeave = () => { + panelContext.eventBus.publish(new DataHoverClearEvent()); + }; } } - } - // divide up the rest of the space - for (const column of columns) { - if (!column.width) { - column.width = sharedWidth; + return ; + }; + +/** + * passed to the top-level `renderCell` prop on DataGrid. This applies all per-cell styles. + */ +const renderCellFactory = + ( + columnTypes: Record, + applyToRowBgFn: ((rowIdx: number) => CellColors) | undefined, + rowHeight: number | ((row: TableRow) => number), + textWraps: Record, + theme: GrafanaTheme2, + visibleFieldsByDisplayName: Record + ) => + (key: Key, props: CellRendererProps) => { + const displayName = props.column.key; + const field = visibleFieldsByDisplayName[displayName]; + + // exit early if we fail to look up the field from the column key. + if (!field) { + return ; } - column.minWidth = COLUMN.MIN_WIDTH; - } - return columns; -} + const cellOptions = getCellOptions(field); + const cellType = cellOptions.type; + const value = props.row[props.column.key]; -export function myRowRenderer( - key: React.Key, - props: RenderRowProps, - expandedRows: number[], - panelContext: PanelContext, - data: DataFrame, - enableSharedCrosshair: boolean -): React.ReactNode { - // Let's render row level things here! - // i.e. we can look at row styles and such here - const { row } = props; - const rowIdx = Number(row.__index); - const isExpanded = expandedRows.includes(rowIdx); + const colors: CellColors = (() => { + if (applyToRowBgFn) { + return applyToRowBgFn(props.rowIdx); + } + const displayValue = field.display?.(value); + if (displayValue && cellOptions) { + return getCellColors(theme, cellOptions, displayValue); + } + return {}; + })(); - // Don't render non expanded child rows - if (Number(row.__depth) === 1 && !isExpanded) { - return null; - } + const rh = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight; + const shouldOverflow = shouldTextOverflow( + displayName, + columnTypes, + textWraps[getDisplayName(field)], + field, + cellType + ); + const shouldWrap = textWraps[displayName] ?? false; + const cellStyle = getCellStyles(theme, field, rh, shouldWrap, shouldOverflow, colors); - // Add aria-expanded to parent rows that have nested data - if (row.data) { - return ; - } + return ( + + ); + }; - return ( - onRowHover(rowIdx, panelContext, data, enableSharedCrosshair)} - onMouseLeave={() => onRowLeave(panelContext, enableSharedCrosshair)} - /> - ); -} - -export function onRowHover(idx: number, panelContext: PanelContext, frame: DataFrame, enableSharedCrosshair: boolean) { - if (!enableSharedCrosshair) { - return; - } - - const timeField: Field = frame!.fields.find((f) => f.type === FieldType.time)!; - - if (!timeField) { - return; - } - - panelContext.eventBus.publish( - new DataHoverEvent({ - point: { - time: timeField.values[idx], - }, - }) - ); -} - -export function onRowLeave(panelContext: PanelContext, enableSharedCrosshair: boolean) { - if (!enableSharedCrosshair) { - return; - } - - panelContext.eventBus.publish(new DataHoverClearEvent()); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - dataGrid: css({ +const getGridStyles = ( + theme: GrafanaTheme2, + { enablePagination, noHeader }: { enablePagination?: boolean; noHeader?: boolean } +) => ({ + grid: css({ '--rdg-background-color': theme.colors.background.primary, '--rdg-header-background-color': theme.colors.background.primary, - '--rdg-border-color': 'transparent', + '--rdg-border-color': theme.isDark ? '#282b30' : '#ebebec', '--rdg-color': theme.colors.text.primary, - '&:hover': { - '--rdg-row-hover-background-color': theme.colors.emphasize(theme.colors.action.hover, 0.6), - }, - // If we rely solely on borderInlineEnd which is added from data grid, we - // get a small gap where the gridCell borders meet the column header borders. - // To avoid this, we can unset borderInlineEnd and set borderRight instead. - '.rdg-cell': { - borderInlineEnd: 'unset', - borderRight: `1px solid ${theme.colors.border.medium}`, + // note: this cannot have any transparency since default cells that + // overlay/overflow on hover inherit this background and need to occlude cells below + '--rdg-row-hover-background-color': theme.isDark ? '#212428' : '#f4f5f5', - '&:last-child': { - borderRight: 'none', - }, - }, + // TODO: magic 32px number is unfortunate. it would be better to have the content + // flow using flexbox rather than hard-coding this size via a calc + blockSize: enablePagination ? 'calc(100% - 32px)' : '100%', + scrollbarWidth: 'thin', + scrollbarColor: theme.isDark ? '#fff5 #fff1' : '#0005 #0001', + + border: 'none', '.rdg-summary-row': { - backgroundColor: theme.colors.background.primary, - '--rdg-summary-border-color': theme.colors.border.medium, - '.rdg-cell': { - // Prevent collisions with custom cell components - zIndex: 2, - borderRight: 'none', + zIndex: theme.zIndex.tooltip - 1, + paddingInline: TABLE.CELL_PADDING, + paddingBlock: TABLE.CELL_PADDING, }, }, - - // Due to stylistic choices, we do not want borders on the column headers - // other than the bottom border. - 'div[role=columnheader]': { - borderBottom: `1px solid ${theme.colors.border.medium}`, - borderInlineEnd: 'unset', - - '.r1y6ywlx7-0-0-beta-46': { - '&:hover': { - borderRight: `3px solid ${theme.colors.text.link}`, - }, - }, - }, - - '::-webkit-scrollbar': { - width: TABLE.SCROLL_BAR_WIDTH, - height: TABLE.SCROLL_BAR_WIDTH, - }, - '::-webkit-scrollbar-thumb': { - backgroundColor: 'rgba(204, 204, 220, 0.16)', - // eslint-disable-next-line @grafana/no-border-radius-literal - borderRadius: '4px', - }, - '::-webkit-scrollbar-track': { - background: 'transparent', - }, - '::-webkit-scrollbar-corner': { - backgroundColor: 'transparent', + }), + gridNested: css({ + height: '100%', + width: `calc(100% - ${COLUMN.EXPANDER_WIDTH - 1}px)`, + overflow: 'visible', + marginLeft: COLUMN.EXPANDER_WIDTH - 1, + }), + cellNested: css({ + '&[aria-selected=true]': { + outline: 'none', }, }), - menuItem: css({ - maxWidth: '200px', + cellActions: css({ + display: 'none', + position: 'absolute', + top: 0, + margin: 'auto', + height: '100%', + color: theme.colors.text.primary, + background: theme.isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(255, 255, 255, 0.7)', + padding: theme.spacing.x0_5, + paddingInlineStart: theme.spacing.x1, }), - cell: css({ - '--rdg-border-color': theme.colors.border.medium, - borderLeft: 'none', - whiteSpace: 'nowrap', - wordWrap: 'break-word', - overflow: 'hidden', - textOverflow: 'ellipsis', - - // Reset default cell styles for custom cell component styling - paddingInline: '0', + cellActionsEnd: css({ + left: 0, }), - cellWrapped: css({ - '--rdg-border-color': theme.colors.border.medium, - borderLeft: 'none', - whiteSpace: 'pre-line', - wordWrap: 'break-word', - overflow: 'hidden', - textOverflow: 'ellipsis', - - // Reset default cell styles for custom cell component styling - paddingInline: '0', + cellActionsStart: css({ + right: 0, + }), + headerRow: css({ + paddingBlockStart: 0, + fontWeight: 'normal', + ...(noHeader && { display: 'none' }), }), paginationContainer: css({ alignItems: 'center', @@ -1084,4 +707,65 @@ const getStyles = (theme: GrafanaTheme2) => ({ justifyContent: 'flex-end', padding: theme.spacing(0, 1, 0, 2), }), + menuItem: css({ + maxWidth: '200px', + }), +}); + +const getFooterStyles = (justifyContent: Property.JustifyContent) => ({ + footerCellCountRows: css({ + display: 'flex', + justifyContent: 'space-between', + }), + footerCell: css({ + display: 'flex', + justifyContent: justifyContent || 'space-between', + }), +}); + +const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => ({ + headerCell: css({ + display: 'flex', + gap: theme.spacing(0.5), + zIndex: theme.zIndex.tooltip - 1, + paddingInline: TABLE.CELL_PADDING, + paddingBlock: TABLE.CELL_PADDING, + borderInlineEnd: 'none', + justifyContent, + }), +}); + +const getCellStyles = ( + theme: GrafanaTheme2, + field: Field, + rowHeight: number, + shouldWrap: boolean, + shouldOverflow: boolean, + colors: CellColors +) => ({ + cell: css({ + textOverflow: 'initial', + background: colors.bgColor ?? 'inherit', + alignContent: 'center', + justifyContent: getTextAlign(field), + paddingInline: TABLE.CELL_PADDING, + height: '100%', + minHeight: rowHeight, // min height interacts with the fit-content property on the overflow container + ...(shouldWrap && { whiteSpace: 'pre-line' }), + '&:last-child': { + borderInlineEnd: 'none', + }, + '&:hover': { + background: colors.bgHoverColor, + '.table-cell-actions': { + display: 'flex', + }, + ...(shouldOverflow && { + zIndex: theme.zIndex.tooltip - 2, + whiteSpace: 'pre-line', + height: 'fit-content', + minWidth: 'fit-content', + }), + }, + }), }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/constants.ts b/packages/grafana-ui/src/components/Table/TableNG/constants.ts index 04e6acbdfb4..34490833395 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/constants.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/constants.ts @@ -8,7 +8,8 @@ export const COLUMN = { /** Table layout and display constants */ export const TABLE = { - CELL_PADDING: 8, + CELL_PADDING: 6, + HEADER_ROW_HEIGHT: 28, MAX_CELL_HEIGHT: 48, PAGINATION_LIMIT: 750, SCROLL_BAR_WIDTH: 8, diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts new file mode 100644 index 00000000000..51c702e5d60 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts @@ -0,0 +1,335 @@ +import { act, renderHook } from '@testing-library/react'; + +import { Field, FieldType } from '@grafana/data'; + +import { useFilteredRows, usePaginatedRows, useSortedRows, useFooterCalcs } from './hooks'; +import { getColumnTypes } from './utils'; + +describe('TableNG hooks', () => { + function setupData() { + // Mock data for testing + const fields: Field[] = [ + { + name: 'name', + type: FieldType.string, + display: (v) => ({ text: v as string, numeric: NaN }), + config: {}, + values: [], + }, + { + name: 'age', + type: FieldType.number, + display: (v) => ({ text: (v as number).toString(), numeric: v as number }), + config: {}, + values: [], + }, + { + name: 'active', + type: FieldType.boolean, + display: (v) => ({ text: (v as boolean).toString(), numeric: NaN }), + config: {}, + values: [], + }, + ]; + + const rows = [ + { name: 'Alice', age: 30, active: true, __depth: 0, __index: 0 }, + { name: 'Bob', age: 25, active: false, __depth: 0, __index: 1 }, + { name: 'Charlie', age: 35, active: true, __depth: 0, __index: 2 }, + ]; + + return { fields, rows }; + } + + describe('useFilteredRows', () => { + it('should correctly initialize with provided fields and rows', () => { + const { fields, rows } = setupData(); + const { result } = renderHook(() => useFilteredRows(rows, fields, { hasNestedFrames: false })); + expect(result.current.rows[0].name).toBe('Alice'); + }); + + it('should apply filters correctly', () => { + const { fields, rows } = setupData(); + const { result } = renderHook(() => useFilteredRows(rows, fields, { hasNestedFrames: false })); + + act(() => { + result.current.setFilter({ + name: { filteredSet: new Set(['Alice']) }, + }); + }); + + expect(result.current.rows.length).toBe(1); + expect(result.current.rows[0].name).toBe('Alice'); + }); + + it('should clear filters correctly', () => { + const { fields, rows } = setupData(); + const { result } = renderHook(() => useFilteredRows(rows, fields, { hasNestedFrames: false })); + + act(() => { + result.current.setFilter({ + name: { filteredSet: new Set(['Alice']) }, + }); + }); + + expect(result.current.rows.length).toBe(1); + + act(() => { + result.current.setFilter({}); + }); + + expect(result.current.rows.length).toBe(3); + }); + + it.todo('should handle nested frames'); + }); + + describe('useSortedRows', () => { + it('should correctly set up the table with an initial sort', () => { + const { fields, rows } = setupData(); + const columnTypes = getColumnTypes(fields); + const { result } = renderHook(() => + useSortedRows(rows, fields, { + columnTypes, + hasNestedFrames: false, + initialSortBy: [{ displayName: 'age', desc: false }], + }) + ); + + // Initial state checks + expect(result.current.sortColumns).toEqual([{ columnKey: 'age', direction: 'ASC' }]); + expect(result.current.rows[0].name).toBe('Bob'); + }); + + it('should change the sort on setSortColumns', () => { + const { fields, rows } = setupData(); + const columnTypes = getColumnTypes(fields); + const { result } = renderHook(() => + useSortedRows(rows, fields, { + columnTypes, + hasNestedFrames: false, + initialSortBy: [{ displayName: 'age', desc: false }], + }) + ); + + expect(result.current.rows[0].name).toBe('Bob'); + + act(() => { + result.current.setSortColumns([{ columnKey: 'age', direction: 'DESC' }]); + }); + + expect(result.current.rows[0].name).toBe('Charlie'); + + act(() => { + result.current.setSortColumns([{ columnKey: 'name', direction: 'ASC' }]); + }); + + expect(result.current.rows[0].name).toBe('Alice'); + }); + + it.todo('should handle nested frames'); + }); + + describe('usePaginatedRows', () => { + it('should return defaults for pagination values when pagination is disabled', () => { + const { rows } = setupData(); + const { result } = renderHook(() => + usePaginatedRows(rows, { rowHeight: 30, height: 300, width: 800, enabled: false }) + ); + + expect(result.current.page).toBe(-1); + expect(result.current.rowsPerPage).toBe(0); + expect(result.current.pageRangeStart).toBe(1); + expect(result.current.pageRangeEnd).toBe(3); + expect(result.current.rows.length).toBe(3); + }); + + it('should handle pagination correctly', () => { + // with the numbers provided here, we have 3 rows, with 2 rows per page, over 2 pages total. + const { rows } = setupData(); + const { result } = renderHook(() => + usePaginatedRows(rows, { + enabled: true, + height: 60, + width: 800, + rowHeight: 10, + }) + ); + + expect(result.current.page).toBe(0); + expect(result.current.rowsPerPage).toBe(2); + expect(result.current.pageRangeStart).toBe(1); + expect(result.current.pageRangeEnd).toBe(2); + expect(result.current.rows.length).toBe(2); + + act(() => { + result.current.setPage(1); + }); + + expect(result.current.page).toBe(1); + expect(result.current.rowsPerPage).toBe(2); + expect(result.current.pageRangeStart).toBe(3); + expect(result.current.pageRangeEnd).toBe(3); + expect(result.current.rows.length).toBe(1); + }); + }); + + describe('useFooterCalcs', () => { + const rows = [ + { Field1: 1, Text: 'a', __depth: 0, __index: 0 }, + { Field1: 2, Text: 'b', __depth: 0, __index: 1 }, + { Field1: 3, Text: 'c', __depth: 0, __index: 2 }, + { Field2: 3, Text: 'd', __depth: 0, __index: 3 }, + { Field2: 10, Text: 'e', __depth: 0, __index: 4 }, + ]; + + const numericField: Field = { + name: 'Field1', + type: FieldType.number, + values: [1, 2, 3], + config: { + custom: {}, + }, + display: (value: unknown) => ({ + text: String(value), + numeric: Number(value), + color: undefined, + prefix: undefined, + suffix: undefined, + }), + state: {}, + getLinks: undefined, + }; + + const numericField2: Field = { + name: 'Field2', + type: FieldType.number, + values: [3, 10], + config: { custom: {} }, + display: (value: unknown) => ({ + text: String(value), + numeric: Number(value), + color: undefined, + prefix: undefined, + suffix: undefined, + }), + state: {}, + getLinks: undefined, + }; + + const textField: Field = { + name: 'Text', + type: FieldType.string, + values: ['a', 'b', 'c'], + config: { custom: {} }, + display: (value: unknown) => ({ + text: String(value), + numeric: 0, + color: undefined, + prefix: undefined, + suffix: undefined, + }), + state: {}, + getLinks: undefined, + }; + + it('should calculate sum for numeric fields', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, numericField], { + enabled: true, + footerOptions: { show: true, reducer: ['sum'] }, + }) + ); + + expect(result.current).toEqual(['Total', '6']); // 1 + 2 + 3 + }); + + it('should calculate mean for numeric fields', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, numericField], { + enabled: true, + footerOptions: { show: true, reducer: ['mean'] }, + }) + ); + + expect(result.current).toEqual(['Mean', '2']); // (1 + 2 + 3) / 3 + }); + + it('should return an empty string for non-numeric fields', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, textField], { + enabled: true, + footerOptions: { show: true, reducer: ['sum'] }, + }) + ); + + expect(result.current).toEqual(['Total', '']); + }); + + it('should return empty array if no footerOptions are provided', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, textField], { + enabled: true, + footerOptions: undefined, + }) + ); + + expect(result.current).toEqual([]); + }); + + it('should return empty array when footer is disabled', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, textField], { + enabled: false, + footerOptions: { show: true, reducer: ['sum'] }, + }) + ); + + expect(result.current).toEqual([]); + }); + + it('should return empty array when reducer is undefined', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, textField], { + enabled: true, + footerOptions: { show: true, reducer: undefined }, + }) + ); + + expect(result.current).toEqual([]); + }); + + it('should return empty array when reducer is empty', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, textField], { + enabled: true, + footerOptions: { show: true, reducer: [] }, + }) + ); + + expect(result.current).toEqual([]); + }); + + it('should return empty string if fields array doesnt include this field', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, numericField, numericField2], { + enabled: true, + footerOptions: { show: true, reducer: ['sum'], fields: ['Field2', 'Field3'] }, + }) + ); + + expect(result.current).toEqual(['Total', '', '13']); + }); + + it('should return the calculation if fields array includes this field', () => { + const { result } = renderHook(() => + useFooterCalcs(rows, [textField, numericField, numericField2], { + enabled: true, + footerOptions: { show: true, reducer: ['sum'], fields: ['Field1', 'Field2', 'Field3'] }, + }) + ); + + expect(result.current).toEqual(['Total', '6', '13']); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts new file mode 100644 index 00000000000..865a4d01c67 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts @@ -0,0 +1,506 @@ +import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect } from 'react'; +import { Column, DataGridProps, SortColumn } from 'react-data-grid'; + +import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data'; + +import { useTheme2 } from '../../../themes/ThemeContext'; +import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types'; + +import { TABLE } from './constants'; +import { ColumnTypes, FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow } from './types'; +import { getDisplayName, processNestedTableRows, getCellHeightCalculator, applySort, getCellOptions } from './utils'; + +// Helper function to get displayed value +const getDisplayedValue = (row: TableRow, key: string, fields: Field[]) => { + const field = fields.find((field) => getDisplayName(field) === key); + if (!field || !field.display) { + return ''; + } + const displayedValue = formattedValueToString(field.display(row[key])); + return displayedValue; +}; + +export interface FilteredRowsResult { + rows: TableRow[]; + filter: FilterType; + setFilter: React.Dispatch>; + crossFilterOrder: string[]; + crossFilterRows: Record; +} + +export interface FilteredRowsOptions { + hasNestedFrames: boolean; +} + +export function useFilteredRows( + rows: TableRow[], + fields: Field[], + { hasNestedFrames }: FilteredRowsOptions +): FilteredRowsResult { + // TODO: allow persisted filter selection via url + const [filter, setFilter] = useState({}); + const filterValues = useMemo(() => Object.entries(filter), [filter]); + + const crossFilterOrder: FilteredRowsResult['crossFilterOrder'] = useMemo( + () => Array.from(new Set(filterValues.map(([key]) => key))), + [filterValues] + ); + + const [filteredRows, crossFilterRows] = useMemo(() => { + const crossFilterRows: FilteredRowsResult['crossFilterRows'] = {}; + + const filterRows = (row: TableRow): boolean => { + for (const [key, value] of filterValues) { + const displayedValue = getDisplayedValue(row, key, fields); + if (!value.filteredSet.has(displayedValue)) { + return false; + } + // collect rows for crossFilter + crossFilterRows[key] = crossFilterRows[key] ?? []; + crossFilterRows[key].push(row); + } + return true; + }; + + const filteredRows = hasNestedFrames + ? processNestedTableRows(rows, (parents) => parents.filter(filterRows)) + : rows.filter(filterRows); + + return [filteredRows, crossFilterRows]; + }, [filterValues, rows, fields, hasNestedFrames]); + + return { + rows: filteredRows, + filter, + setFilter, + crossFilterOrder, + crossFilterRows, + }; +} + +export interface SortedRowsOptions { + columnTypes: ColumnTypes; + hasNestedFrames: boolean; + initialSortBy?: TableSortByFieldState[]; +} + +export interface SortedRowsResult { + rows: TableRow[]; + sortColumns: SortColumn[]; + setSortColumns: React.Dispatch>; +} + +export function useSortedRows( + rows: TableRow[], + fields: Field[], + { initialSortBy, columnTypes, hasNestedFrames }: SortedRowsOptions +): SortedRowsResult { + const initialSortColumns = useMemo( + () => + initialSortBy?.flatMap(({ displayName, desc }) => { + if (!fields.some((f) => getDisplayName(f) === displayName)) { + return []; + } + return [ + { + columnKey: displayName, + direction: desc ? ('DESC' as const) : ('ASC' as const), + }, + ]; + }) ?? [], + [] // eslint-disable-line react-hooks/exhaustive-deps + ); + const [sortColumns, setSortColumns] = useState(initialSortColumns); + + const sortedRows = useMemo( + () => applySort(rows, fields, sortColumns, columnTypes, hasNestedFrames), + [rows, fields, sortColumns, hasNestedFrames, columnTypes] + ); + + return { + rows: sortedRows, + sortColumns, + setSortColumns, + }; +} + +export interface PaginatedRowsOptions { + height: number; + width: number; + rowHeight: number | ((row: TableRow) => number); + hasHeader?: boolean; + hasFooter?: boolean; + paginationHeight?: number; + enabled?: boolean; +} + +export interface PaginatedRowsResult { + rows: TableRow[]; + page: number; + setPage: React.Dispatch>; + numPages: number; + rowsPerPage: number; + pageRangeStart: number; + pageRangeEnd: number; + smallPagination: boolean; +} + +// hand-measured. pagination height is 30px, plus 8px top margin +const PAGINATION_HEIGHT = 38; + +export function usePaginatedRows( + rows: TableRow[], + { height, width, hasHeader, hasFooter, rowHeight, enabled }: PaginatedRowsOptions +): PaginatedRowsResult { + // TODO: allow persisted page selection via url + const [page, setPage] = useState(0); + const numRows = rows.length; + + // calculate average row height if row height is variable. + const avgRowHeight = useMemo(() => { + if (typeof rowHeight === 'number') { + return rowHeight; + } + return rows.reduce((avg, row, _, { length }) => avg + rowHeight(row) / length, 0); + }, [rows, rowHeight]); + + // using dimensions of the panel, calculate pagination parameters + const { numPages, rowsPerPage, pageRangeStart, pageRangeEnd, smallPagination } = useMemo((): { + numPages: number; + rowsPerPage: number; + pageRangeStart: number; + pageRangeEnd: number; + smallPagination: boolean; + } => { + if (!enabled) { + return { numPages: 0, rowsPerPage: 0, pageRangeStart: 1, pageRangeEnd: numRows, smallPagination: false }; + } + + // calculate number of rowsPerPage based on height stack + const rowAreaHeight = + height - (hasHeader ? TABLE.HEADER_ROW_HEIGHT : 0) - (hasFooter ? avgRowHeight : 0) - PAGINATION_HEIGHT; + const heightPerRow = Math.floor(rowAreaHeight / (avgRowHeight || 1)); + // ensure at least one row per page is displayed + let rowsPerPage = heightPerRow > 1 ? heightPerRow : 1; + + // calculate row range for pagination summary display + const pageRangeStart = page * rowsPerPage + 1; + let pageRangeEnd = pageRangeStart + rowsPerPage - 1; + if (pageRangeEnd > numRows) { + pageRangeEnd = numRows; + } + const smallPagination = width < TABLE.PAGINATION_LIMIT; + const numPages = Math.ceil(numRows / rowsPerPage); + return { + numPages, + rowsPerPage, + pageRangeStart, + pageRangeEnd, + smallPagination, + }; + }, [width, height, hasHeader, hasFooter, avgRowHeight, enabled, numRows, page]); + + // safeguard against page overflow on panel resize or other factors + useEffect(() => { + if (!enabled) { + return; + } + + if (page > numPages) { + // resets pagination to end + setPage(numPages - 1); + } + }, [numPages, enabled, page, setPage]); + + // apply pagination to the sorted rows + const paginatedRows = useMemo(() => { + if (!enabled) { + return rows; + } + const pageOffset = page * rowsPerPage; + return rows.slice(pageOffset, pageOffset + rowsPerPage); + }, [page, rowsPerPage, rows, enabled]); + + return { + rows: paginatedRows, + page: enabled ? page : -1, + setPage, + numPages, + rowsPerPage, + pageRangeStart, + pageRangeEnd, + smallPagination, + }; +} + +export interface FooterCalcsOptions { + enabled?: boolean; + isCountRowsSet?: boolean; + footerOptions?: TableFooterCalc; +} + +export function useFooterCalcs( + rows: TableRow[], + fields: Field[], + { enabled, footerOptions, isCountRowsSet }: FooterCalcsOptions +): string[] { + return useMemo(() => { + const footerReducers = footerOptions?.reducer; + + if (!enabled || !footerOptions || !Array.isArray(footerReducers) || !footerReducers.length) { + return []; + } + + return fields.map((field, index) => { + if (field.state?.calcs) { + delete field.state?.calcs; + } + + if (isCountRowsSet) { + return index === 0 ? `${rows.length}` : ''; + } + + if (index === 0) { + const footerCalcReducer = footerReducers[0]; + return footerCalcReducer ? fieldReducers.get(footerCalcReducer).name : ''; + } + + if (field.type !== FieldType.number) { + return ''; + } + + // if field.display is undefined, don't throw + const displayFn = field.display; + if (!displayFn) { + return ''; + } + + // If fields array is specified, only show footer for fields included in that array + if (footerOptions.fields?.length && !footerOptions.fields?.includes(getDisplayName(field))) { + return ''; + } + + const calc = footerReducers[0]; + const value = reduceField({ + field: { + ...field, + values: rows.map((row) => row[getDisplayName(field)]), + }, + reducers: footerReducers, + })[calc]; + + return formattedValueToString(displayFn(value)); + }); + }, [fields, enabled, footerOptions, isCountRowsSet, rows]); +} + +export function useTextWraps(fields: Field[]): Record { + return useMemo( + () => + fields.reduce<{ [key: string]: boolean }>((acc, field) => { + const cellOptions = getCellOptions(field); + const displayName = getDisplayName(field); + const wrapText = 'wrapText' in cellOptions && cellOptions.wrapText; + return { ...acc, [displayName]: !!wrapText }; + }, {}), + [fields] + ); +} + +export function useTypographyCtx() { + const theme = useTheme2(); + const { ctx, font, avgCharWidth } = useMemo(() => { + const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + // set in grafana/data in createTypography.ts + const letterSpacing = 0.15; + + ctx.letterSpacing = `${letterSpacing}px`; + ctx.font = font; + const txt = + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s"; + const txtWidth = ctx.measureText(txt).width; + const avgCharWidth = txtWidth / txt.length + letterSpacing; + + return { + ctx, + font, + avgCharWidth, + }; + }, [theme.typography.fontSize, theme.typography.fontFamily]); + return { ctx, font, avgCharWidth }; +} + +export function useRowHeight( + columnWidths: number[], + fields: Field[], + hasNestedFrames: boolean, + defaultRowHeight: number, + expandedRows: Record +): number | ((row: TableRow) => number) { + const [wrappedColIdxs, hasWrappedCols] = useMemo(() => { + let hasWrappedCols = false; + return [ + fields.map((field) => { + if (field.type !== FieldType.string) { + return false; + } + + const cellOptions = getCellOptions(field); + const wrapText = 'wrapText' in cellOptions && cellOptions.wrapText; + const type = cellOptions.type; + const result = !!wrapText && type !== TableCellDisplayMode.Image; + if (result === true) { + hasWrappedCols = true; + } + return result; + }), + hasWrappedCols, + ]; + }, [fields]); + + const { ctx, avgCharWidth } = useTypographyCtx(); + + const rowHeight = useMemo(() => { + // row height is only complicated when there are nested frames or wrapped columns. + if (!hasNestedFrames && !hasWrappedCols) { + return defaultRowHeight; + } + + const HPADDING = TABLE.CELL_PADDING; + const VPADDING = TABLE.CELL_PADDING; + const BORDER_RIGHT = 0.666667; + const LINE_HEIGHT = 22; + + const wrapWidths = columnWidths.map((c) => c - 2 * HPADDING - BORDER_RIGHT); + const calc = getCellHeightCalculator(ctx, LINE_HEIGHT, defaultRowHeight, VPADDING); + + return (row: TableRow) => { + // nested rows + if (Number(row.__depth) > 0) { + // if unexpanded, height === 0 + if (!expandedRows[row.__index]) { + return 0; + } + + // Ensure we have a minimum height (defaultRowHeight) for the nested table even if data is empty + const headerCount = row?.data?.meta?.custom?.noHeader ? 0 : 1; + const rowCount = row.data?.length ?? 0; + return Math.max(defaultRowHeight, defaultRowHeight * (rowCount + headerCount)); + } + + // regular rows + let maxLines = 1; + let maxLinesIdx = -1; + let maxLinesText = ''; + + for (let i = 0; i < columnWidths.length; i++) { + if (wrappedColIdxs[i]) { + const cellTextRaw = fields[i].values[row.__index]; + if (cellTextRaw != null) { + const cellText = String(cellTextRaw); + const charsPerLine = wrapWidths[i] / avgCharWidth; + const approxLines = cellText.length / charsPerLine; + + if (approxLines > maxLines) { + maxLines = approxLines; + maxLinesIdx = i; + maxLinesText = cellText; + } + } + } + } + + if (maxLinesIdx === -1) { + return defaultRowHeight; + } + + return calc(maxLinesText, wrapWidths[maxLinesIdx]); + }; + }, [ + avgCharWidth, + columnWidths, + ctx, + defaultRowHeight, + expandedRows, + fields, + hasNestedFrames, + hasWrappedCols, + wrappedColIdxs, + ]); + + return rowHeight; +} + +/** + * react-data-grid is a little unwieldy when it comes to column resize events. + * we want to detect a few different column resize signals: + * - dragging the handle (only want to dispatch when handle is released) + * - double-clicking the handle (sets the column to the minimum width to fit content) + * `onColumnResize` dispatches events throughout a dragged resize, and `onColumnWidthsChanged` doesn't + * emit an event when double-click resizing occurs, so we have to build something custom on top of these + * behaviors in order to get everything working. + */ +interface UseColumnResizeState { + columnKey: string | undefined; + width: number; +} + +const INITIAL_COL_RESIZE_STATE = Object.freeze({ columnKey: undefined, width: 0 }) satisfies UseColumnResizeState; + +export function useColumnResize( + onColumnResize: TableColumnResizeActionCallback = () => {} +): DataGridProps['onColumnResize'] { + // these must be refs. if we used setState, we would run into race conditions with these event listeners + const colResizeState = useRef({ ...INITIAL_COL_RESIZE_STATE }); + const pointerIsDown = useRef(false); + + // to detect whether we got a double-click resize, we track whether the pointer is currently down + useLayoutEffect(() => { + function pointerDown(_event: PointerEvent) { + pointerIsDown.current = true; + } + + function pointerUp(_event: PointerEvent) { + pointerIsDown.current = false; + } + + window.addEventListener('pointerdown', pointerDown); + window.addEventListener('pointerup', pointerUp); + + return () => { + window.removeEventListener('pointerdown', pointerDown); + window.removeEventListener('pointerup', pointerUp); + }; + }); + + const dispatchEvent = useCallback(() => { + if (colResizeState.current.columnKey) { + onColumnResize(colResizeState.current.columnKey, Math.floor(colResizeState.current.width)); + colResizeState.current = { ...INITIAL_COL_RESIZE_STATE }; + } + window.removeEventListener('click', dispatchEvent, { capture: true }); + }, [onColumnResize]); + + // this is the callback that gets passed to react-data-grid + const dataGridResizeHandler = useCallback( + (column: Column, width: number) => { + if (!colResizeState.current.columnKey) { + window.addEventListener('click', dispatchEvent, { capture: true }); + } + + colResizeState.current.columnKey = column.key; + colResizeState.current.width = width; + + // when double clicking to resize, this handler will fire, but the pointer will not be down, + // meaning that we should immediately flush the new width + if (!pointerIsDown.current) { + dispatchEvent(); + } + }, + [dispatchEvent] + ); + + return dataGridResizeHandler; +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 140c91f9e40..2946923db8f 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -1,4 +1,5 @@ import { Property } from 'csstype'; +import { SyntheticEvent } from 'react'; import { Column } from 'react-data-grid'; import { @@ -12,10 +13,12 @@ import { InterpolateFunction, FieldType, DataFrameWithValue, + SelectableValue, } from '@grafana/data'; -import { TableCellOptions, TableCellHeight, TableFieldOptions } from '@grafana/schema'; +import { TableCellHeight, TableFieldOptions } from '@grafana/schema'; import { TableCellInspectorMode } from '../TableCellInspector'; +import { TableCellOptions } from '../types'; export const FILTER_FOR_OPERATOR = '='; export const FILTER_OUT_OPERATOR = '!='; @@ -39,11 +42,15 @@ export type TableFieldOptionsType = Omit & { headerComponent?: React.ComponentType; }; -export type FilterType = { - [key: string]: { +export type FilterType = Record< + string, + { filteredSet: Set; - }; -}; + filtered?: Array>; + searchFilter?: string; + operator?: SelectableValue; + } +>; /* ----------------------------- Table specific types ----------------------------- */ export interface TableSummaryRow { @@ -51,12 +58,9 @@ export interface TableSummaryRow { } export interface TableColumn extends Column { - key: string; // Unique identifier used by DataGrid - name: string; // Display name in header field: Field; // Grafana field data/config width?: number | string; // Column width minWidth?: number; // Min width constraint - cellClass?: string; // CSS styling } // Possible values for table cells based on field types @@ -77,7 +81,8 @@ export interface TableRow { // Nested table properties data?: DataFrame; - 'Nested frames'?: DataFrame[]; + __nestedFrames?: DataFrame[]; + __expanded?: boolean; // For row expansion state // Generic typing for column values [columnName: string]: TableCellValue; @@ -104,7 +109,7 @@ export interface TableSortByFieldState { export interface TableFooterCalc { show: boolean; - reducer?: string[]; // Make this optional + reducer?: string[]; fields?: string[]; enablePagination?: boolean; countRows?: boolean; @@ -129,6 +134,7 @@ export interface BaseTableProps { footerValues?: FooterItem[]; enablePagination?: boolean; cellHeight?: TableCellHeight; + structureRev?: number; /** @alpha Used by SparklineCell when provided */ timeRange?: TimeRange; enableSharedCrosshair?: boolean; @@ -144,28 +150,48 @@ export interface BaseTableProps { /* ---------------------------- Table cell props ---------------------------- */ export interface TableNGProps extends BaseTableProps {} -export interface TableCellNGProps { - field: Field; - frame: DataFrame; - getActions?: GetActionsFunction; - height: number; - justifyContent: Property.JustifyContent; +export interface TableCellRendererProps { + actions?: ActionModel[]; rowIdx: number; - setContextMenuProps: (props: { value: string; top?: number; left?: number; mode?: TableCellInspectorMode }) => void; - setIsInspecting: (isInspecting: boolean) => void; - shouldTextOverflow: () => boolean; - theme: GrafanaTheme2; - timeRange: TimeRange; + frame: DataFrame; + timeRange?: TimeRange; value: TableCellValue; - rowBg: Function | undefined; + height: number; + // flags that are static per column + field: Field; + cellOptions: TableCellOptions; + width: number; + theme: GrafanaTheme2; + cellInspect: boolean; + showFilters: boolean; + justifyContent: Property.JustifyContent; +} + +export type ContextMenuProps = { + rowIdx?: number; + value: string; + mode?: TableCellInspectorMode.code | TableCellInspectorMode.text; + top?: number; + left?: number; +}; + +export interface TableCellActionsProps { + field: Field; + value: TableCellValue; + cellOptions: TableCellOptions; + displayName: string; + cellInspect: boolean; + showFilters: boolean; + setIsInspecting: React.Dispatch>; + setContextMenuProps: React.Dispatch>; + className?: string; onCellFilterAdded?: TableFilterActionCallback; - replaceVariables?: InterpolateFunction; } /* ------------------------- Specialized Cell Props ------------------------- */ export interface RowExpanderNGProps { height: number; - onCellExpand: () => void; + onCellExpand: (e: SyntheticEvent) => void; isExpanded?: boolean; } @@ -174,7 +200,7 @@ export interface SparklineCellProps { justifyContent: Property.JustifyContent; rowIdx: number; theme: GrafanaTheme2; - timeRange: TimeRange; + timeRange?: TimeRange; value: TableCellValue; width: number; } @@ -186,7 +212,6 @@ export interface BarGaugeCellProps extends ActionCellProps { theme: GrafanaTheme2; value: TableCellValue; width: number; - timeRange: TimeRange; } export interface ImageCellProps extends ActionCellProps { diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts index dccddd89bd8..74a9f39d224 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts @@ -1,10 +1,8 @@ -import { SortColumn } from 'react-data-grid'; - import { createDataFrame, createTheme, DataFrame, - DisplayProcessor, + DataFrameWithValue, DisplayValue, Field, FieldType, @@ -12,21 +10,12 @@ import { LinkModel, ValueLinkConfig, } from '@grafana/data'; -import { Trans } from '@grafana/i18n'; -import { - BarGaugeDisplayMode, - TableCellBackgroundDisplayMode, - TableCellDisplayMode, - TableCellHeight, -} from '@grafana/schema'; +import { BarGaugeDisplayMode, TableCellBackgroundDisplayMode, TableCellHeight } from '@grafana/schema'; -import { PanelContext } from '../../PanelChrome'; +import { TableCellDisplayMode } from '../types'; -import { mapFrameToDataGrid, myRowRenderer } from './TableNG'; -import { COLUMN, TABLE } from './constants'; -import { TableColumn } from './types'; +import { TABLE } from './constants'; import { - convertRGBAToHex, extractPixelValue, frameToRecords, getAlignmentFactor, @@ -35,490 +24,13 @@ import { getCellOptions, getComparator, getDefaultRowHeight, - getFooterItemNG, - getFooterStyles, getIsNestedTable, getTextAlign, - handleSort, - isTextCell, migrateTableDisplayModeToCellOptions, + getColumnTypes, } from './utils'; -const data = createDataFrame({ - fields: [ - { - name: 'Time', - type: FieldType.time, - values: [], - config: { - custom: { - width: undefined, // For width distribution testing - displayMode: 'auto', - }, - }, - }, - { - name: 'Value', - type: FieldType.number, - values: [], - display: ((v: unknown) => ({ - text: String(v), - numeric: v, - color: undefined, - prefix: undefined, - suffix: undefined, - })) as DisplayProcessor, - config: { - custom: { - width: 100, - displayMode: 'basic', - }, - }, - }, - { - name: 'Message', - type: FieldType.string, - values: [], - config: { - custom: { - align: 'center', - }, - }, - }, - ], - meta: { - custom: { - noHeader: false, // For header rendering tests - }, - }, -}); - -const calcsRef = { current: [] }; -const headerCellRefs = { current: {} }; -const crossFilterOrder = { current: [] }; -const crossFilterRows = { current: {} }; -const sortColumnsRef = { current: [] }; - -const mockOptions = { - ctx: null as unknown as CanvasRenderingContext2D, - textWraps: {}, - rows: [], - sortedRows: [], - setContextMenuProps: () => {}, - setFilter: () => {}, - setIsInspecting: () => {}, - data, - width: 800, - height: 600, - fieldConfig: { - defaults: { - custom: { - width: 'auto', - minWidth: COLUMN.MIN_WIDTH, - cellOptions: { - wrapText: false, - }, - }, - }, - overrides: [ - { - matcher: { id: 'byName', options: 'Value' }, - properties: [{ id: 'width', value: 100 }], - }, - ], - }, - columnTypes: {}, - columnWidth: 'auto', - defaultLineHeight: 40, - defaultRowHeight: 40, - expandedRows: [], - filter: {}, - headerCellRefs, - crossFilterOrder, - crossFilterRows, - isCountRowsSet: false, - styles: { cell: '', cellWrapped: '', dataGrid: '' }, - theme: createTheme(), - setSortColumns: () => {}, - sortColumnsRef, - textWrap: false, -}; - describe('TableNG utils', () => { - describe('mapFrameToDataGrid', () => { - it('take data frame and return array of columns', () => { - const columns = mapFrameToDataGrid({ - frame: data, - calcsRef, - options: mockOptions, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - // Test column structure - expect(columns).toHaveLength(3); - - // Test Time column - expect(columns[0]).toMatchObject({ - key: 'Time', - name: 'Time', - field: expect.objectContaining({ - name: 'Time', - type: FieldType.time, - }), - }); - - // Test Value column with custom width - expect(columns[1]).toMatchObject({ - key: 'Value', - name: 'Value', - // TODO: fix this - // width: 100, - field: expect.objectContaining({ - name: 'Value', - type: FieldType.number, - }), - }); - - // Test Message column alignment - expect(columns[2]).toMatchObject({ - key: 'Message', - name: 'Message', - field: expect.objectContaining({ - name: 'Message', - type: FieldType.string, - config: expect.objectContaining({ - custom: expect.objectContaining({ - align: 'center', - }), - }), - }), - }); - }); - }); - - describe('column building', () => { - it('should build basic column structure', () => { - const columns = mapFrameToDataGrid({ - frame: data, - calcsRef, - options: mockOptions, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - expect(columns).toHaveLength(3); - columns.forEach((column: TableColumn) => { - expect(column).toHaveProperty('key'); - expect(column).toHaveProperty('name'); - expect(column).toHaveProperty('field'); - expect(column).toHaveProperty('cellClass'); - expect(column).toHaveProperty('renderCell'); - expect(column).toHaveProperty('renderHeaderCell'); - }); - }); - - it.skip('should handle column width configurations', () => { - const columns = mapFrameToDataGrid({ - frame: data, - calcsRef, - options: mockOptions, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - // Default width - expect(columns[0].width).toBe(350); - // Explicit width from field config - expect(columns[1].width).toBe(100); - // Default width with min width - expect(columns[2].minWidth).toBe(COLUMN.MIN_WIDTH); - }); - - it('should handle cell alignment', () => { - const columns = mapFrameToDataGrid({ - frame: data, - calcsRef, - options: mockOptions, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - const messageColumn = columns[2]; - expect(messageColumn.field.config.custom.align).toBe('center'); - }); - - it('should handle footer/summary rows', () => { - const options = { - ...mockOptions, - isCountRowsSet: true, - }; - - const columns = mapFrameToDataGrid({ - frame: data, - calcsRef: { current: ['3', '', ''] }, - options, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - // First column should show count - const firstCell = columns[0].renderSummaryCell?.({ - row: { __depth: 0, __index: 0 }, - column: { - ...columns[0], - frozen: false, - idx: 0, - parent: undefined, - level: 0, - sortable: true, - minWidth: 100, - draggable: true, - renderCell: () => null, - renderHeaderCell: () => null, - resizable: true, - width: 100, - maxWidth: undefined, - headerCellClass: undefined, - summaryCellClass: undefined, - }, - tabIndex: 0, - }); - - expect(firstCell).toBeDefined(); - - // Check the div structure and content - const divElement = firstCell as JSX.Element; - expect(divElement.props.style).toEqual({ display: 'flex', justifyContent: 'space-between' }); - - // Check that we have two spans with correct content - const [countSpan, valueSpan] = divElement.props.children; - expect(countSpan.type).toBe('span'); - expect(countSpan.props.children.type).toBe(Trans); - expect(countSpan.props.children.props.i18nKey).toBe('grafana-ui.table.count'); - expect(valueSpan.props.children).toBe('3'); - }); - }); - - describe('nested frames', () => { - const nestedData = createDataFrame({ - fields: [ - { name: 'Time', type: FieldType.time, values: [1, 2] }, - { name: 'Value', type: FieldType.number, values: [10, 20] }, - { - name: 'Nested frames', - type: FieldType.nestedFrames, - values: [ - [ - createDataFrame({ - fields: [ - { name: 'Nested Time', type: FieldType.time, values: [3] }, - { name: 'Nested Value', type: FieldType.number, values: [30] }, - ], - }), - ], - ], - }, - ], - }); - - it('should add expander column for nested frames', () => { - const columns = mapFrameToDataGrid({ - frame: nestedData, - calcsRef, - options: mockOptions, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - // First column should be expander - expect(columns[0]).toMatchObject({ - key: 'expanded', - name: '', - width: COLUMN.EXPANDER_WIDTH, - minWidth: COLUMN.EXPANDER_WIDTH, - }); - }); - - it('should not render nested frame type fields', () => { - const columns = mapFrameToDataGrid({ - frame: nestedData, - calcsRef, - options: mockOptions, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - // Should only have expander + Time + Value (not Nested frames column) - expect(columns).toHaveLength(3); - - // No column should be of type nestedFrames - const hasNestedFrameColumn = columns.some((col: TableColumn) => col.field.type === FieldType.nestedFrames); - expect(hasNestedFrameColumn).toBe(false); - }); - - it('should render nested frame data when expanded', () => { - const expandedRows = [0]; - const columns = mapFrameToDataGrid({ - frame: nestedData, - calcsRef, - options: { ...mockOptions, expandedRows }, - handlers: { onCellExpand: () => {}, onColumnResize: () => {} }, - availableWidth: mockOptions.width, - }); - - // Get the rendered content of first row's expander cell - const expanderCell = columns[0].renderCell?.({ - row: { - __depth: 1, - __index: 0, - data: nestedData.fields[2].values[0][0], - }, - rowIdx: 0, - column: { - ...columns[0], - frozen: false, - idx: 0, - parent: undefined, - level: 0, - sortable: true, - minWidth: 100, - draggable: true, - renderCell: () => null, - renderHeaderCell: () => null, - resizable: true, - width: 100, - maxWidth: undefined, - headerCellClass: undefined, - summaryCellClass: undefined, - }, - isCellEditable: false, - tabIndex: 0, - onRowChange: () => {}, - }); - - expect(expanderCell).toBeDefined(); - }); - }); - - describe('getFooterItemNG', () => { - const rows = [ - { Field1: 1, Text: 'a', __depth: 0, __index: 0 }, - { Field1: 2, Text: 'b', __depth: 0, __index: 1 }, - { Field1: 3, Text: 'c', __depth: 0, __index: 2 }, - { Field2: 3, Text: 'd', __depth: 0, __index: 3 }, - { Field2: 10, Text: 'e', __depth: 0, __index: 4 }, - ]; - - const numericField: Field = { - name: 'Field1', - type: FieldType.number, - values: [1, 2, 3], - config: { - custom: {}, - }, - display: (value: unknown) => ({ - text: String(value), - numeric: Number(value), - color: undefined, - prefix: undefined, - suffix: undefined, - }), - state: {}, - getLinks: undefined, - }; - - const numericField2: Field = { - name: 'Field2', - type: FieldType.number, - values: [3, 10], - config: { custom: {} }, - display: (value: unknown) => ({ - text: String(value), - numeric: Number(value), - color: undefined, - prefix: undefined, - suffix: undefined, - }), - state: {}, - getLinks: undefined, - }; - - const textField: Field = { - name: 'Text', - type: FieldType.string, - values: ['a', 'b', 'c'], - config: { custom: {} }, - display: (value: unknown) => ({ - text: String(value), - numeric: 0, - color: undefined, - prefix: undefined, - suffix: undefined, - }), - state: {}, - getLinks: undefined, - }; - - it('should calculate sum for numeric fields', () => { - const result = getFooterItemNG(rows, numericField, { - show: true, - reducer: ['sum'], - }); - - expect(result).toBe('6'); // 1 + 2 + 3 - }); - - it('should calculate mean for numeric fields', () => { - const result = getFooterItemNG(rows, numericField, { - show: true, - reducer: ['mean'], - }); - - expect(result).toBe('2'); // (1 + 2 + 3) / 3 - }); - - it('should return empty string for non-numeric fields', () => { - const result = getFooterItemNG(rows, textField, { - show: true, - reducer: ['sum'], - }); - - expect(result).toBe(''); - }); - - it('should return empty string when footer not shown', () => { - const result = getFooterItemNG(rows, numericField, undefined); - - expect(result).toBe(''); - }); - - it('should return empty string when reducer is undefined', () => { - const result = getFooterItemNG(rows, numericField, { - show: true, - reducer: undefined, - }); - expect(result).toBe(''); - }); - - it('should correctly calculate sum for numeric fields based on selected fields', () => { - const numericField1Result = getFooterItemNG(rows, numericField, { - show: true, - reducer: ['sum'], - fields: ['Field1'], - }); - - const numericField2Result = getFooterItemNG(rows, numericField2, { - show: true, - reducer: ['sum'], - fields: ['Field2'], - }); - - expect(numericField1Result).toBe('6'); // 1 + 2 + 3 - expect(numericField2Result).toBe('13'); // 3 + 10 - }); - }); - describe('text alignment', () => { it('should map alignment options to flex values', () => { // Test 'left' alignment @@ -717,56 +229,6 @@ describe('TableNG utils', () => { }); }); - describe('handleSort', () => { - const setSortColumns = jest.fn(); - const sortColumnsRef: { current: SortColumn[] } = { current: [] }; - - beforeEach(() => { - setSortColumns.mockClear(); - sortColumnsRef.current = []; - }); - - it('should set initial sort', () => { - handleSort('Value', 'ASC', false, setSortColumns, sortColumnsRef); - - expect(setSortColumns).toHaveBeenCalledWith([{ columnKey: 'Value', direction: 'ASC' }]); - }); - - it('should toggle sort direction on same column', () => { - // Initial sort - sortColumnsRef.current = [{ columnKey: 'Value', direction: 'ASC' }] as const; - - handleSort('Value', 'DESC', false, setSortColumns, sortColumnsRef); - - expect(setSortColumns).toHaveBeenCalledWith([{ columnKey: 'Value', direction: 'DESC' }]); - }); - - it('should handle multi-sort with shift key', () => { - // Initial sort - sortColumnsRef.current = [{ columnKey: 'Time', direction: 'ASC' }] as const; - - handleSort('Value', 'ASC', true, setSortColumns, sortColumnsRef); - - expect(setSortColumns).toHaveBeenCalledWith([ - { columnKey: 'Time', direction: 'ASC' }, - { columnKey: 'Value', direction: 'ASC' }, - ]); - }); - - it('should remove sort when toggling through all states', () => { - // Initial ASC sort - sortColumnsRef.current = [{ columnKey: 'Value', direction: 'ASC' }] as const; - - // Toggle to DESC - handleSort('Value', 'DESC', false, setSortColumns, sortColumnsRef); - expect(setSortColumns).toHaveBeenCalledWith([{ columnKey: 'Value', direction: 'DESC' }]); - - // Toggle to no sort - handleSort('Value', 'DESC', false, setSortColumns, sortColumnsRef); - expect(setSortColumns).toHaveBeenCalledWith([]); - }); - }); - describe('getAlignmentFactor', () => { it('should create a new alignment factor when none exists', () => { // Create a field with no existing alignment factor @@ -940,6 +402,102 @@ describe('TableNG utils', () => { }) ); }); + + it.todo('alignmentFactor.text = displayValue.text;'); + }); + + describe('getColumnTypes', () => { + it('builds the expected record with column types', () => { + const fields: Field[] = [ + { + name: 'name', + type: FieldType.string, + display: (v) => ({ text: v as string, numeric: NaN }), + config: {}, + values: [], + }, + { + name: 'age', + type: FieldType.number, + display: (v) => ({ text: (v as number).toString(), numeric: v as number }), + config: {}, + values: [], + }, + { + name: 'active', + type: FieldType.boolean, + display: (v) => ({ text: (v as boolean).toString(), numeric: NaN }), + config: {}, + values: [], + }, + ]; + const result = getColumnTypes(fields); + + expect(result).toEqual({ + name: FieldType.string, + age: FieldType.number, + active: FieldType.boolean, + }); + }); + + it('should recursively build column types when nested fields are present', () => { + const frame: DataFrame = { + fields: [ + { type: FieldType.string, name: 'stringCol', config: {}, values: [] }, + { + type: FieldType.nestedFrames, + name: 'nestedCol', + config: {}, + values: [ + [ + createDataFrame({ + fields: [ + { name: 'time', values: [1, 2] }, + { name: 'value', values: [10, 20] }, + ], + }), + ], + [ + createDataFrame({ + fields: [ + { name: 'time', values: [3, 4] }, + { name: 'value', values: [30, 40] }, + ], + }), + ], + ], + }, + ], + length: 0, + name: 'test', + }; + + expect(getColumnTypes(frame.fields)).toEqual({ + stringCol: FieldType.string, + time: FieldType.time, + value: FieldType.number, + }); + }); + + it('does not throw if nestedFrames has no values', () => { + const frame: DataFrame = { + fields: [ + { type: FieldType.string, name: 'stringCol', config: {}, values: [] }, + { + type: FieldType.nestedFrames, + name: 'nestedCol', + config: {}, + values: [], + }, + ], + length: 0, + name: 'test', + }; + + expect(getColumnTypes(frame.fields)).toEqual({ + stringCol: FieldType.string, + }); + }); }); describe('getIsNestedTable', () => { @@ -952,7 +510,7 @@ describe('TableNG utils', () => { length: 0, name: 'test', }; - expect(getIsNestedTable(frame)).toBe(true); + expect(getIsNestedTable(frame.fields)).toBe(true); }); it('should return false for regular frames', () => { @@ -964,7 +522,7 @@ describe('TableNG utils', () => { length: 0, name: 'test', }; - expect(getIsNestedTable(frame)).toBe(false); + expect(getIsNestedTable(frame.fields)).toBe(false); }); }); @@ -1006,241 +564,28 @@ describe('TableNG utils', () => { expect(comparator(true, false)).toBeGreaterThan(0); expect(comparator(true, true)).toBe(0); }); - }); - /* - describe('shouldTextOverflow', () => { - const mockContext = { - font: '', - measureText: (text: string) => ({ - // Each character is 8px wide in our mock context - width: text.length * 8, - }), - }; - const ctx = mockContext as unknown as CanvasRenderingContext2D; + it('should compare frame values', () => { + const comparator = getComparator(FieldType.frame); - const headerCellRefs = { - current: { - column1: { - getBoundingClientRect: () => ({ width: 100 }), - offsetWidth: 100, - }, - } as unknown as Record, - }; - - it('should return true when text exceeds cell width', () => { - const row = { - __depth: 0, - __index: 0, - // 43*8 = 344px wide cell, it should overflow as it's greater than 100px - column1: 'This is a very long text that should overflow', + // simulate using `first`. + const frame1: DataFrameWithValue = { + value: 1, + ...createDataFrame({ fields: [{ name: 'a', values: [1, 2, 3, 4] }] }), }; - const columnTypes = { column1: FieldType.string }; - - const result = shouldTextOverflow( - 'column1', - row, - columnTypes, - headerCellRefs, - ctx, - 20, // lineHeight - 40, // defaultRowHeight - 8, // padding - false, // textWrap - { - config: { - custom: { - inspect: false, - }, - }, - } as Field, - TableCellDisplayMode.Auto // cellType - ); - - expect(result).toBe(true); - }); - - it('should return false when text fits cell width', () => { - const row = { - __depth: 0, - __index: 0, - // 9*8 = 72px wide cell, it should fit as it's less than 100px - column1: 'Short text', + const frame2: DataFrameWithValue = { + value: 4, + ...createDataFrame({ fields: [{ name: 'a', values: [4, 3, 2, 1] }] }), }; - const columnTypes = { column1: FieldType.string }; - - const result = shouldTextOverflow( - 'column1', - row, - columnTypes, - headerCellRefs, - ctx, - 20, // lineHeight - 40, // defaultRowHeight - 8, // padding - false, // textWrap - { - config: { - custom: { - inspect: false, - }, - }, - } as Field, - TableCellDisplayMode.Auto // cellType - ); - - expect(result).toBe(false); - }); - - it('should return false when text wrapping is enabled', () => { - const row = { - __depth: 0, - __index: 0, - column1: 'This is a very long text that should wrap instead of overflow', - }; - const columnTypes = { column1: FieldType.string }; - - const result = shouldTextOverflow( - 'column1', - row, - columnTypes, - headerCellRefs, - ctx, - 20, // lineHeight - 40, // defaultRowHeight - 8, // padding - true, // textWrap ENABLED - { - config: { - custom: { - inspect: true, - }, - }, - } as Field, - TableCellDisplayMode.Auto // cellType - ); - - expect(result).toBe(false); - }); - - it('should return false when cell inspection is enabled', () => { - const row = { - __depth: 0, - __index: 0, - column1: 'This is a very long text', - }; - const columnTypes = { column1: FieldType.string }; - - const result = shouldTextOverflow( - 'column1', - row, - columnTypes, - headerCellRefs, - ctx, - 20, // lineHeight - 40, // defaultRowHeight - 8, // padding - false, // textWrap - { - config: { - custom: { - inspect: true, - }, - }, - } as Field, - TableCellDisplayMode.Auto // cellType - ); - - expect(result).toBe(false); - }); - }); - - describe.skip('getRowHeight', () => { - const ctx = { - font: '14px Inter, sans-serif', - letterSpacing: '0.15px', - measureText: (text: string) => ({ - width: text.length * 8, - }), - } as unknown as CanvasRenderingContext2D; - - const calc = uWrap(ctx); - - const headerCellRefs = { - current: { - stringCol: { offsetWidth: 100 }, - numberCol: { offsetWidth: 100 }, - } as unknown as Record, - }; - - it('should return default height when no text cells present', () => { - const row = { - __depth: 0, - __index: 0, - numberCol: 123, - }; - const columnTypes = { numberCol: FieldType.number }; - - const height = getRowHeight( - row, - calc, - 8, - headerCellRefs, - 20, // lineHeight - 40, // defaultRowHeight - 8 // padding - ); - - expect(height).toBe(40); - }); - - it('should calculate height based on longest text cell', () => { - const row = { - __depth: 0, - __index: 0, - stringCol: 'This is a very long text that should wrap', - numberCol: 123, - }; - const columnTypes = { - stringCol: FieldType.string, - numberCol: FieldType.number, + const frame3: DataFrameWithValue = { + value: 4, + ...createDataFrame({ fields: [{ name: 'a', values: [4, 5, 6, 7] }] }), }; - const height = getRowHeight(row, columnTypes, headerCellRefs, ctx, 20, 40, 8); - - expect(height).toBeGreaterThan(40); - expect(height).toBe(112); - }); - - it('should handle empty header cell refs', () => { - const row = { - __depth: 0, - __index: 0, - stringCol: 'Some text', - }; - const columnTypes = { stringCol: FieldType.string }; - const emptyRefs = { current: {} } as unknown as React.MutableRefObject>; - - const height = getRowHeight(row, columnTypes, emptyRefs, ctx, 20, 40, 8); - - expect(height).toBe(40); - }); - }); -*/ - - describe('isTextCell', () => { - it('should return true for string fields', () => { - expect(isTextCell('column', { column: FieldType.string })).toBe(true); - }); - - it('should return false for non-string fields', () => { - expect(isTextCell('column', { column: FieldType.number })).toBe(false); - expect(isTextCell('column', { column: FieldType.time })).toBe(false); - expect(isTextCell('column', { column: FieldType.boolean })).toBe(false); - }); - - it('should handle unknown fields', () => { - expect(isTextCell('unknown', { column: FieldType.string })).toBe(false); + expect(comparator(frame1, frame2)).toBeLessThan(0); + expect(comparator(frame2, frame1)).toBeGreaterThan(0); + expect(comparator(frame2, frame2)).toBe(0); + expect(comparator(frame2, frame3)).toBe(0); // equivalent start values }); }); @@ -1500,133 +845,81 @@ describe('TableNG utils', () => { expect(links?.find((link) => link.onClick !== undefined)).toBeDefined(); expect(links?.find((link) => link.href === 'http://example.com/full')).toBeDefined(); }); - }); - /* - describe.skip('getCellHeight', () => { - // Create a mock CanvasRenderingContext2D - const createMockContext = () => { - return { - measureText: jest.fn((text) => { - // Simple mock that returns width based on text length - // This is a simplification - real browser would be more complex - return { width: text.length * 8 }; // Assume 8px per character - }), - } as unknown as CanvasRenderingContext2D; - }; + it('should bind the onClick handlers', () => { + const onClickHandler = jest.fn(); + // Create links with different valid configurations + const mockLinks: LinkModel[] = [ + // Internal link with onClick handler + { + title: 'Internal Link', + href: '', // Empty href for internal links + onClick: onClickHandler, + target: '_self', + origin: { datasourceUid: 'test' }, + }, + ]; - it('should return default row height when ctx is null', () => { - const defaultRowHeight = 40; - const height = getCellHeight('Some text', 100, null, 20, defaultRowHeight); - expect(height).toBe(defaultRowHeight); + const field: Field = { + name: 'test', + type: FieldType.string, + config: {}, + values: ['value1'], + getLinks: () => mockLinks, + }; + + const links = getCellLinks(field, 0); + + const link = links?.[0]; + const event = new MouseEvent('click', { bubbles: true }); + jest.spyOn(event, 'preventDefault'); + + link?.onClick?.(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(onClickHandler).toHaveBeenCalledWith(event, { field, rowIndex: 0 }); }); - it('should return default row height for text that fits in one line', () => { - const mockContext = createMockContext(); - const defaultRowHeight = 40; - const cellWidth = 100; // 100px width - const text = 'Short'; // 5 chars * 8px = 40px, fits in cellWidth + it.each([ + { keyName: 'metaKey', eventOverride: { metaKey: true } }, + { keyName: 'ctrlKey', eventOverride: { ctrlKey: true } }, + { keyName: 'shiftKey', eventOverride: { shiftKey: true } }, + ])( + 'should allow open a link in a new tab when $keyName clicked instead of using the handler', + ({ eventOverride }) => { + const onClickHandler = jest.fn(); + // Create links with different valid configurations + const mockLinks: LinkModel[] = [ + // Internal link with onClick handler + { + title: 'Internal Link', + href: '', // Empty href for internal links + onClick: onClickHandler, + target: '_self', + origin: { datasourceUid: 'test' }, + }, + ]; - const height = getCellHeight(text, cellWidth, mockContext, 20, defaultRowHeight); + const field: Field = { + name: 'test', + type: FieldType.string, + config: {}, + values: ['value1'], + getLinks: () => mockLinks, + }; - // Since text fits in one line, should return default height - expect(height).toBe(defaultRowHeight); - expect(mockContext.measureText).toHaveBeenCalled(); - }); + const links = getCellLinks(field, 0); - it('should calculate height for text that wraps to multiple lines', () => { - const mockContext = createMockContext(); - const defaultRowHeight = 40; - const lineHeight = 20; - const cellWidth = 100; // 100px width - // This text is long enough to wrap to multiple lines - const text = 'This is a very long text that will definitely need to wrap to multiple lines in our table cell'; + const link = links?.[0]; + const event = new MouseEvent('click', { bubbles: true, ...eventOverride }); + jest.spyOn(event, 'preventDefault'); - const height = getCellHeight(text, cellWidth, mockContext, lineHeight, defaultRowHeight); + link?.onClick?.(event); - // Should be greater than default height since text wraps - expect(height).toBeGreaterThan(defaultRowHeight); - expect(height).toBe(180); - // Height should be a multiple of line height plus padding - expect(height % lineHeight).toBe(0); - expect(mockContext.measureText).toHaveBeenCalled(); - }); - - it('should account for padding when calculating height', () => { - const mockContext = createMockContext(); - const defaultRowHeight = 40; - const lineHeight = 20; - const cellWidth = 100; - const padding = 10; - const text = 'This is a very long text that will wrap to multiple lines'; - - const heightWithoutPadding = getCellHeight(text, cellWidth, mockContext, lineHeight, defaultRowHeight); - const heightWithPadding = getCellHeight(text, cellWidth, mockContext, lineHeight, defaultRowHeight, padding); - - // Height with padding should be greater than without padding - expect(heightWithPadding).toBeGreaterThan(heightWithoutPadding); - // The difference should be related to the padding (padding is applied twice in the function) - expect(heightWithPadding - heightWithoutPadding).toBe(padding * 2 * 2); - }); - - it('should handle empty text', () => { - const mockContext = createMockContext(); - const defaultRowHeight = 40; - - const height = getCellHeight('', 100, mockContext, 20, defaultRowHeight); - - // Empty text should return default height - expect(height).toBe(defaultRowHeight); - }); - }); -*/ - - describe('getFooterStyles', () => { - it('should create an emotion css class', () => { - const styles = getFooterStyles('flex-start'); - - // Check that the footerCell style has been created - expect(styles.footerCell).toBeDefined(); - - // Get the CSS string representation - const cssString = styles.footerCell.toString(); - - // Verify it's an Emotion CSS class - expect(cssString).toContain('css-'); - }); - - it('should use the provided justification value', () => { - const styles = getFooterStyles('center'); - - // Create a DOM element and apply the CSS class - document.body.innerHTML = `
Test
`; - const element = document.querySelector('div'); - - // Get the computed style - const computedStyle = window.getComputedStyle(element!); - - // Check the CSS property - expect(computedStyle.justifyContent).toBe('center'); - }); - - it('should default to space-between when no justification is provided', () => { - const styles = getFooterStyles(undefined as any); - - // Create a DOM element and apply the CSS class - document.body.innerHTML = `
Test
`; - const element = document.querySelector('div'); - - // Get the computed style - const computedStyle = window.getComputedStyle(element!); - - // Check the CSS property - expect(computedStyle.justifyContent).toBe('space-between'); - }); - - // Clean up after all tests - afterAll(() => { - document.body.innerHTML = ''; - }); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(onClickHandler).not.toHaveBeenCalled(); + } + ); }); describe('extractPixelValue', () => { @@ -1681,12 +974,9 @@ describe('TableNG utils', () => { }); }); - describe('convertRGBAToHex', () => { - it('should convert RGBA format to hex with alpha', () => { - expect(convertRGBAToHex('#181b1f', 'rgba(255, 0, 0, 1)')).toBe('#ff0000'); - expect(convertRGBAToHex('#181b1f', 'rgba(0, 255, 0, 0.5)')).toBe('#0c8d10'); - expect(convertRGBAToHex('#181b1f', 'rgba(0, 0, 255, 0)')).toBe('#181b1f'); - }); + describe('getCellHeightCalculator', () => { + it.todo('returns a cell height calculator'); + it.todo('returns a minimum height of the default row height'); }); describe('getDefaultRowHeight', () => { @@ -1716,110 +1006,4 @@ describe('TableNG utils', () => { expect(result).toBe(expected); }); }); - - describe('myRowRenderer', () => { - // Create mock props for testing - const createMockProps = (depth: number, hasData: boolean, index: number) => { - return { - row: { - __depth: depth, - __index: index, - data: hasData ? { length: 2 } : undefined, - }, - viewportColumns: [], - rowIdx: 0, - isRowSelected: false, - onRowClick: jest.fn(), - onRowDoubleClick: jest.fn(), - rowClass: '', - top: 0, - height: 40, - 'aria-rowindex': 1, - 'aria-selected': false, - gridRowStart: 1, - isLastRow: false, - selectedCellIdx: undefined, - selectCell: jest.fn(), - lastFrozenColumnIndex: -1, - copiedCellIdx: undefined, - draggedOverCellIdx: undefined, - setDraggedOverRowIdx: jest.fn(), - onRowChange: jest.fn(), - rowArray: [], - selectedPosition: { idx: 0, rowIdx: 0, mode: 'SELECT' }, - } as any; - }; - - const mockPanelContext = { - id: 1, - title: 'Test Panel', - description: 'Test Description', - width: 800, - height: 600, - timeRange: { from: 'now-6h', to: 'now' }, - timeZone: 'browser', - onTimeRangeChange: jest.fn(), - onOptionsChange: jest.fn(), - onFieldConfigChange: jest.fn(), - onInstanceStateChange: jest.fn(), - replaceVariables: jest.fn(), - eventBus: { - publish: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - }, - } as unknown as PanelContext; - - const mockData = createDataFrame({ - fields: [ - { name: 'Time', type: FieldType.time, values: [] }, - { name: 'Value', type: FieldType.number, values: [] }, - ], - }); - - it('returns null for non-expanded child rows', () => { - const props = createMockProps(1, false, 0); - const expandedRows: number[] = []; // No expanded rows - - const view = myRowRenderer('key-0', props, expandedRows, mockPanelContext, mockData, false); - - expect(view).toBeNull(); - }); - - it('renders child rows when parent is expanded', () => { - const props = createMockProps(1, false, 0); - const expandedRows: number[] = [0]; // Row 0 is expanded - - const view = myRowRenderer('key-0', props, expandedRows, mockPanelContext, mockData, false); - - expect(view).not.toBeNull(); - }); - - it('adds aria-expanded attribute to parent rows with nested data', () => { - const props = createMockProps(0, true, 0); - const expandedRows: number[] = [0]; // Row 0 is expanded - - const result = myRowRenderer('key-0', props, expandedRows, mockPanelContext, mockData, false) as JSX.Element; - - expect(result.props['aria-expanded']).toBe(true); - }); - - it('sets aria-expanded to false when parent row is not expanded', () => { - const props = createMockProps(0, true, 0); - const expandedRows: number[] = []; // No expanded rows - - const result = myRowRenderer('key-0', props, expandedRows, mockPanelContext, mockData, false) as JSX.Element; - - expect(result.props['aria-expanded']).toBe(false); - }); - - it('renders regular rows without aria-expanded attribute', () => { - const props = createMockProps(0, false, 0); - const expandedRows: number[] = []; - - const result = myRowRenderer('key-0', props, expandedRows, mockPanelContext, mockData, false) as JSX.Element; - - expect(result.props['aria-expanded']).toBeUndefined(); - }); - }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 3827d2adf34..d65b1cd9ff5 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -1,7 +1,5 @@ -import { css } from '@emotion/css'; import { Property } from 'csstype'; -import React from 'react'; -import { SortColumn, SortDirection } from 'react-data-grid'; +import { SortColumn } from 'react-data-grid'; import tinycolor from 'tinycolor2'; import { varPreLine } from 'uwrap'; @@ -9,7 +7,6 @@ import { FieldType, Field, formattedValueToString, - reduceField, GrafanaTheme2, DisplayValue, LinkModel, @@ -18,89 +15,24 @@ import { } from '@grafana/data'; import { BarGaugeDisplayMode, - TableAutoCellOptions, TableCellBackgroundDisplayMode, TableCellDisplayMode, TableCellHeight, - TableCellOptions, - TableSortByFieldState, } from '@grafana/schema'; import { getTextColorForAlphaBackground } from '../../../utils/colors'; -import { TableCellInspectorMode } from '../TableCellInspector'; +import { TableCellOptions } from '../types'; -import { TABLE } from './constants'; -import { - CellColors, - TableRow, - TableFieldOptionsType, - ColumnTypes, - FilterType, - FrameToRowsConverter, - TableNGProps, - Comparator, - TableFooterCalc, -} from './types'; +import { COLUMN, TABLE } from './constants'; +import { CellColors, TableRow, TableFieldOptionsType, ColumnTypes, FrameToRowsConverter, Comparator } from './types'; /* ---------------------------- Cell calculations --------------------------- */ -export function getCellHeight( - text: string, - cellWidth: number, // width of the cell without padding - ctx: CanvasRenderingContext2D, - lineHeight: number, - defaultRowHeight: number, - padding = 0 -) { - const PADDING = padding * 2; - - if (typeof text === 'string') { - const words = text.split(/\s/); - const lines = []; - let currentLine = ''; - - // Let's just wrap the lines and see how well the measurement works - for (let i = 0; i < words.length; i++) { - const currentWord = words[i]; - // TODO: this method is not accurate - let lineWidth = ctx.measureText(currentLine + ' ' + currentWord).width; - - // if line width is less than the cell width, add the word to the current line and continue - // else add the current line to the lines array and start a new line with the current word - if (lineWidth < cellWidth) { - currentLine += ' ' + currentWord; - } else { - lines.push({ - width: lineWidth, - line: currentLine, - }); - - currentLine = currentWord; - } - - // if we are at the last word, add the current line to the lines array - if (i === words.length - 1) { - lines.push({ - width: lineWidth, - line: currentLine, - }); - } - } - - if (lines.length === 1) { - return defaultRowHeight; - } - - // TODO: double padding to adjust osContext.measureText() results - const height = lines.length * lineHeight + PADDING * 2; - - return height; - } - - return defaultRowHeight; -} - export type CellHeightCalculator = (text: string, cellWidth: number) => number; +/** + * @internal + * Returns a function that calculates the height of a cell based on its text content and width. + */ export function getCellHeightCalculator( // should be pre-configured with font and letterSpacing ctx: CanvasRenderingContext2D, @@ -111,15 +43,17 @@ export function getCellHeightCalculator( const { count } = varPreLine(ctx); return (text: string, cellWidth: number) => { - const effectiveCellWidth = Math.max(cellWidth, 20); // Minimum width to work with - const TOTAL_PADDING = padding * 2; - const numLines = count(text, effectiveCellWidth); - const totalHeight = numLines * lineHeight + TOTAL_PADDING; + const numLines = count(text, cellWidth); + const totalHeight = numLines * lineHeight + 2 * padding; return Math.max(totalHeight, defaultRowHeight); }; } -export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight: TableCellHeight | undefined): number { +/** + * @internal + * Returns the default row height based on the theme and cell height setting. + */ +export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight?: TableCellHeight): number { const bodyFontSize = theme.typography.fontSize; const lineHeight = theme.typography.body.lineHeight; @@ -136,61 +70,12 @@ export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight: TableCellH } /** - * getRowHeight determines cell height based on cell width + text length. Used - * for when textWrap is enabled. + * @internal + * Returns true if text overflow handling should be applied to the cell. */ -export function getRowHeight( - row: TableRow, - calc: CellHeightCalculator, - avgCharWidth: number, - defaultRowHeight: number, - fieldsData: { - headersLength: number; - textWraps: { [key: string]: boolean }; - columnTypes: ColumnTypes; - columnWidths: Record; - fieldDisplayType: Record; - } -): number { - let maxLines = 1; - let maxLinesCol = ''; - - for (const key in row) { - if ( - fieldsData.columnTypes[key] === FieldType.string && - fieldsData.textWraps[key] && - fieldsData.fieldDisplayType[key] !== TableCellDisplayMode.Image - ) { - const cellText = row[key] as string; - - if (cellText != null) { - const charsPerLine = fieldsData.columnWidths[key] / avgCharWidth; - const approxLines = cellText.length / charsPerLine; - - if (approxLines > maxLines) { - maxLines = approxLines; - maxLinesCol = key; - } - } - } - } - - return maxLinesCol === '' ? defaultRowHeight : calc(row[maxLinesCol] as string, fieldsData.columnWidths[maxLinesCol]); -} - -export function isTextCell(key: string, columnTypes: Record): boolean { - return columnTypes[key] === FieldType.string; -} - export function shouldTextOverflow( key: string, - row: TableRow, columnTypes: ColumnTypes, - headerCellRefs: React.MutableRefObject>, - ctx: CanvasRenderingContext2D, - lineHeight: number, - defaultRowHeight: number, - padding: number, textWrap: boolean, field: Field, cellType: TableCellDisplayMode @@ -199,13 +84,17 @@ export function shouldTextOverflow( // Tech debt: Technically image cells are of type string, which is misleading (kinda?) // so we need to ensure we don't apply overflow hover states fo type image - if (textWrap || cellInspect || cellType === TableCellDisplayMode.Image || !isTextCell(key, columnTypes)) { + if (textWrap || cellInspect || cellType === TableCellDisplayMode.Image || columnTypes[key] !== FieldType.string) { return false; } return true; } +/** + * @internal + * Returns the text alignment for a field based on its type and configuration. + */ export function getTextAlign(field?: Field): Property.JustifyContent { if (!field) { return 'flex-start'; @@ -231,21 +120,22 @@ export function getTextAlign(field?: Field): Property.JustifyContent { return 'flex-start'; } -const defaultCellOptions: TableAutoCellOptions = { type: TableCellDisplayMode.Auto }; +const DEFAULT_CELL_OPTIONS = { type: TableCellDisplayMode.Auto } as const; +/** + * @internal + * Returns the cell options for a field, migrating from legacy displayMode if necessary. + */ export function getCellOptions(field: Field): TableCellOptions { if (field.config.custom?.displayMode) { return migrateTableDisplayModeToCellOptions(field.config.custom?.displayMode); } - if (!field.config.custom?.cellOptions) { - return defaultCellOptions; - } - - return field.config.custom.cellOptions; + return field.config.custom?.cellOptions ?? DEFAULT_CELL_OPTIONS; } /** + * @internal * Getting gauge or sparkline values to align is very tricky without looking at all values and passing them through display processor. * For very large tables that could pretty expensive. So this is kind of a compromise. We look at the first 1000 rows and cache the longest value. * If we have a cached value we just check if the current value is longer and update the alignmentFactor. This can obviously still lead to @@ -271,7 +161,7 @@ export function getAlignmentFactor( const maxIndex = Math.min(field.values.length, rowIndex + 1000); for (let i = rowIndex + 1; i < maxIndex; i++) { - const nextDisplayValue = field.display!(field.values[i]); + const nextDisplayValue = field.display?.(field.values[i]) ?? field.values[i]; if (formattedValueToString(alignmentFactor).length > formattedValueToString(nextDisplayValue).length) { alignmentFactor.text = displayValue.text; } @@ -287,69 +177,27 @@ export function getAlignmentFactor( } } -/* ------------------------------ Footer calculations ------------------------------ */ -export function getFooterItemNG(rows: TableRow[], field: Field, options: TableFooterCalc | undefined): string { - if (options === undefined) { - return ''; - } - - if (field.type !== FieldType.number) { - return ''; - } - - // Check if reducer array exists and has at least one element - if (!options.reducer || !options.reducer.length) { - return ''; - } - - // If fields array is specified, only show footer for fields included in that array - if (options.fields && options.fields.length > 0) { - if (!options.fields.includes(field.name)) { - return ''; - } - } - - const calc = options.reducer[0]; - const value = reduceField({ - field: { - ...field, - values: rows.map((row) => row[getDisplayName(field)]), - }, - reducers: options.reducer, - })[calc]; - - const formattedValue = formattedValueToString(field.display!(value)); - - return formattedValue; -} - -export const getFooterStyles = (justifyContent: Property.JustifyContent) => ({ - footerCell: css({ - display: 'flex', - justifyContent: justifyContent || 'space-between', - }), -}); - /* ------------------------- Cell color calculation ------------------------- */ const CELL_COLOR_DARKENING_MULTIPLIER = 10; const CELL_GRADIENT_DARKENING_MULTIPLIER = 15; const CELL_GRADIENT_HUE_ROTATION_DEGREES = 5; +/** + * @internal + * Returns the text and background colors for a table cell based on its options and display value. + */ export function getCellColors( theme: GrafanaTheme2, cellOptions: TableCellOptions, displayValue: DisplayValue ): CellColors { - // Convert RGBA hover color to hex to prevent transparency issues on cell hover - const autoCellBackgroundHoverColor = convertRGBAToHex(theme.colors.background.primary, theme.colors.action.hover); - // How much to darken elements depends upon if we're in dark mode const darkeningFactor = theme.isDark ? 1 : -0.7; // Setup color variables let textColor: string | undefined = undefined; let bgColor: string | undefined = undefined; - let bgHoverColor: string = autoCellBackgroundHoverColor; + let bgHoverColor: string | undefined = undefined; if (cellOptions.type === TableCellDisplayMode.ColorText) { textColor = displayValue.color; @@ -378,18 +226,14 @@ export function getCellColors( return { textColor, bgColor, bgHoverColor }; } -/** Extracts numeric pixel value from theme spacing */ +/** + * @internal + * Extracts numeric pixel value from theme spacing + */ export const extractPixelValue = (spacing: string | number): number => { return typeof spacing === 'number' ? spacing : parseFloat(spacing) || 0; }; -/** Converts an RGBA color to hex by blending it with a background color */ -export const convertRGBAToHex = (backgroundColor: string, rgbaColor: string): string => { - const bg = tinycolor(backgroundColor); - const rgba = tinycolor(rgbaColor); - return tinycolor.mix(bg, rgba, rgba.getAlpha() * 100).toHexString(); -}; - /* ------------------------------- Data links ------------------------------- */ /** * @internal @@ -410,7 +254,7 @@ export const getCellLinks = (field: Field, rowIdx: number) => { if (links[i].onClick) { const origOnClick = links[i].onClick; - links[i].onClick = (event) => { + links[i].onClick = (event: MouseEvent) => { // Allow opening in new tab if (!(event.ctrlKey || event.metaKey || event.shiftKey)) { event.preventDefault(); @@ -427,40 +271,48 @@ export const getCellLinks = (field: Field, rowIdx: number) => { }; /* ----------------------------- Data grid sorting ---------------------------- */ -export const handleSort = ( - columnKey: string, - direction: SortDirection, - isMultiSort: boolean, - setSortColumns: React.Dispatch>, - sortColumnsRef: React.MutableRefObject -) => { - let currentSortColumn: SortColumn | undefined; - - const updatedSortColumns = sortColumnsRef.current.filter((column) => { - const isCurrentColumn = column.columnKey === columnKey; - if (isCurrentColumn) { - currentSortColumn = column; - } - return !isCurrentColumn; - }); - - // sorted column exists and is descending -> remove it to reset sorting - if (currentSortColumn && currentSortColumn.direction === 'DESC') { - setSortColumns(updatedSortColumns); - sortColumnsRef.current = updatedSortColumns; - } else { - // new sort column or changed direction - if (isMultiSort) { - setSortColumns([...updatedSortColumns, { columnKey, direction }]); - sortColumnsRef.current = [...updatedSortColumns, { columnKey, direction }]; - } else { - setSortColumns([{ columnKey, direction }]); - sortColumnsRef.current = [{ columnKey, direction }]; - } +/** + * @internal + */ +export function applySort( + rows: TableRow[], + fields: Field[], + sortColumns: SortColumn[], + columnTypes: ColumnTypes = getColumnTypes(fields), + hasNestedFrames: boolean = getIsNestedTable(fields) +): TableRow[] { + if (sortColumns.length === 0) { + return rows; } -}; + + const compareRows = (a: TableRow, b: TableRow): number => { + let result = 0; + for (let i = 0; i < sortColumns.length; i++) { + const { columnKey, direction } = sortColumns[i]; + const compare = getComparator(columnTypes[columnKey]); + const sortDir = direction === 'ASC' ? 1 : -1; + + result = sortDir * compare(a[columnKey], b[columnKey]); + if (result !== 0) { + break; + } + } + return result; + }; + + // Handle nested tables + if (hasNestedFrames) { + return processNestedTableRows(rows, (parents) => [...parents].sort(compareRows)); + } + + // Regular sort for tables without nesting + return [...rows].sort(compareRows); +} /* ----------------------------- Data grid mapping ---------------------------- */ +/** + * @internal + */ export const frameToRecords = (frame: DataFrame): TableRow[] => { const fnBody = ` const rows = Array(frame.length); @@ -473,8 +325,8 @@ export const frameToRecords = (frame: DataFrame): TableRow[] => { ${frame.fields.map((field, fieldIdx) => `${JSON.stringify(getDisplayName(field))}: values[${fieldIdx}][i]`).join(',')} }; rowCount += 1; - if (rows[rowCount-1]['Nested frames']){ - const childFrame = rows[rowCount-1]['Nested frames']; + if (rows[rowCount-1]['__nestedFrames']){ + const childFrame = rows[rowCount-1]['__nestedFrames']; rows[rowCount] = {__depth: 1, __index: i, data: childFrame[0]} rowCount += 1; } @@ -488,65 +340,68 @@ export const frameToRecords = (frame: DataFrame): TableRow[] => { return convert(frame); }; -export interface MapFrameToGridOptions extends TableNGProps { - columnTypes: ColumnTypes; - columnWidth: number | string; - crossFilterOrder: React.MutableRefObject; - crossFilterRows: React.MutableRefObject<{ [key: string]: TableRow[] }>; - defaultLineHeight: number; - defaultRowHeight: number; - expandedRows: number[]; - filter: FilterType; - headerCellRefs: React.MutableRefObject>; - isCountRowsSet: boolean; - ctx: CanvasRenderingContext2D; - onSortByChange?: (sortBy: TableSortByFieldState[]) => void; - rows: TableRow[]; - setContextMenuProps: (props: { value: string; top?: number; left?: number; mode?: TableCellInspectorMode }) => void; - setFilter: React.Dispatch>; - setIsInspecting: (isInspecting: boolean) => void; - setSortColumns: React.Dispatch>; - sortColumnsRef: React.MutableRefObject; - styles: { cell: string; cellWrapped: string; dataGrid: string }; - textWraps: Record; - theme: GrafanaTheme2; - showTypeIcons?: boolean; -} - /* ----------------------------- Data grid comparator ---------------------------- */ // The numeric: true option is used to sort numbers as strings correctly. It recognizes numeric sequences // within strings and sorts numerically instead of lexicographically. const compare = new Intl.Collator('en', { sensitivity: 'base', numeric: true }).compare; +const strCompare: Comparator = (a, b) => compare(String(a ?? ''), String(b ?? '')); +const numCompare: Comparator = (a, b) => { + if (a === b) { + return 0; + } + if (a == null) { + return -1; + } + if (b == null) { + return 1; + } + return Number(a) - Number(b); +}; +const frameCompare: Comparator = (a, b) => { + // @ts-ignore The compared vals are DataFrameWithValue. the value is the rendered stat (first, last, etc.) + return (a?.value ?? 0) - (b?.value ?? 0); +}; + +/** + * @internal + */ export function getComparator(sortColumnType: FieldType): Comparator { switch (sortColumnType) { // Handle sorting for frame type fields (sparklines) case FieldType.frame: - return (a, b) => { - // @ts-ignore The values are DataFrameWithValue - return (a?.value ?? 0) - (b?.value ?? 0); - }; + return frameCompare; case FieldType.time: case FieldType.number: case FieldType.boolean: - return (a, b) => { - if (a === b) { - return 0; - } - if (a == null) { - return -1; - } - if (b == null) { - return 1; - } - return Number(a) - Number(b); - }; + return numCompare; case FieldType.string: case FieldType.enum: default: - return (a, b) => compare(String(a ?? ''), String(b ?? '')); + return strCompare; } } +type TableCellGaugeDisplayModes = + | TableCellDisplayMode.BasicGauge + | TableCellDisplayMode.GradientGauge + | TableCellDisplayMode.LcdGauge; +const TABLE_CELL_GAUGE_DISPLAY_MODES_TO_DISPLAY_MODES: Record = { + [TableCellDisplayMode.BasicGauge]: BarGaugeDisplayMode.Basic, + [TableCellDisplayMode.GradientGauge]: BarGaugeDisplayMode.Gradient, + [TableCellDisplayMode.LcdGauge]: BarGaugeDisplayMode.Lcd, +}; + +type TableCellColorBackgroundDisplayModes = + | TableCellDisplayMode.ColorBackground + | TableCellDisplayMode.ColorBackgroundSolid; +const TABLE_CELL_COLOR_BACKGROUND_DISPLAY_MODES_TO_DISPLAY_MODES: Record< + TableCellColorBackgroundDisplayModes, + TableCellBackgroundDisplayMode +> = { + [TableCellDisplayMode.ColorBackground]: TableCellBackgroundDisplayMode.Gradient, + [TableCellDisplayMode.ColorBackgroundSolid]: TableCellBackgroundDisplayMode.Basic, +}; + /* ---------------------------- Miscellaneous ---------------------------- */ /** * Migrates table cell display mode to new object format. @@ -558,49 +413,44 @@ export function getComparator(sortColumnType: FieldType): Comparator { export function migrateTableDisplayModeToCellOptions(displayMode: TableCellDisplayMode): TableCellOptions { switch (displayMode) { // In the case of the gauge we move to a different option - case 'basic': - case 'gradient-gauge': - case 'lcd-gauge': - let gaugeMode = BarGaugeDisplayMode.Basic; - - if (displayMode === 'gradient-gauge') { - gaugeMode = BarGaugeDisplayMode.Gradient; - } else if (displayMode === 'lcd-gauge') { - gaugeMode = BarGaugeDisplayMode.Lcd; - } - + case TableCellDisplayMode.BasicGauge: + case TableCellDisplayMode.GradientGauge: + case TableCellDisplayMode.LcdGauge: return { type: TableCellDisplayMode.Gauge, - mode: gaugeMode, + mode: TABLE_CELL_GAUGE_DISPLAY_MODES_TO_DISPLAY_MODES[displayMode], }; // Also true in the case of the color background - case 'color-background': - case 'color-background-solid': - let mode = TableCellBackgroundDisplayMode.Basic; - - // Set the new mode field, somewhat confusingly the - // color-background mode is for gradient display - if (displayMode === 'color-background') { - mode = TableCellBackgroundDisplayMode.Gradient; - } - + case TableCellDisplayMode.ColorBackground: + case TableCellDisplayMode.ColorBackgroundSolid: return { type: TableCellDisplayMode.ColorBackground, - mode: mode, + mode: TABLE_CELL_COLOR_BACKGROUND_DISPLAY_MODES_TO_DISPLAY_MODES[displayMode], + }; + // catching a nonsense case: `displayMode`: 'custom' should pre-date the CustomCell. + // if it doesn't, we need to just nope out and return an auto cell. + case TableCellDisplayMode.Custom: + return { + type: TableCellDisplayMode.Auto, }; default: return { - // @ts-ignore type: displayMode, }; } } -/** Returns true if the DataFrame contains nested frames */ -export const getIsNestedTable = (dataFrame: DataFrame): boolean => - dataFrame.fields.some(({ type }) => type === FieldType.nestedFrames); +/** + * @internal + * Returns true if the DataFrame contains nested frames + */ +export const getIsNestedTable = (fields: Field[]): boolean => + fields.some(({ type }) => type === FieldType.nestedFrames); -/** Processes nested table rows */ +/** + * @internal + * Processes nested table rows + */ export const processNestedTableRows = ( rows: TableRow[], processParents: (parents: TableRow[]) => TableRow[] @@ -611,13 +461,13 @@ export const processNestedTableRows = ( const parentRows: TableRow[] = []; const childRows: Map = new Map(); - rows.forEach((row) => { + for (const row of rows) { if (Number(row.__depth) === 0) { parentRows.push(row); } else { childRows.set(Number(row.__index), row); } - }); + } // Process parent rows (filter or sort) const processedParents = processParents(parentRows); @@ -635,6 +485,84 @@ export const processNestedTableRows = ( return result; }; +/** + * @internal + * returns the display name of a field + */ export const getDisplayName = (field: Field): string => { return field.state?.displayName ?? field.name; }; + +/** + * @internal + * returns only fields that are not nested tables and not explicitly hidden + */ +export function getVisibleFields(fields: Field[]): Field[] { + return fields.filter((field) => field.type !== FieldType.nestedFrames && field.config.custom?.hidden !== true); +} + +/** + * @internal + * returns a map of column types by display name + */ +export function getColumnTypes(fields: Field[]): ColumnTypes { + return fields.reduce((acc, field) => { + switch (field.type) { + case FieldType.nestedFrames: + return { ...acc, ...getColumnTypes(field.values[0]?.[0]?.fields ?? []) }; + default: + return { ...acc, [getDisplayName(field)]: field.type }; + } + }, {}); +} + +/** + * @internal + * calculates the width of each field, with the following logic: + * 1. manual sizing minWidth is hard-coded to 50px, we set this in RDG since it enforces the hard limit correctly + * 2. if minWidth is configured in fieldConfig (or defaults to 150), it serves as the bottom of the auto-size clamp + */ +export function computeColWidths(fields: Field[], availWidth: number) { + let autoCount = 0; + let definedWidth = 0; + + return ( + fields + // first pass to add up how many fields have pre-defined widths and what that width totals to. + .map((field) => { + const width: number = field.config.custom?.width ?? 0; + + if (width === 0) { + autoCount++; + } else { + definedWidth += width; + } + + return width; + }) + // second pass once `autoCount` and `definedWidth` are known. + .map( + (width, i) => + width || + Math.max(fields[i].config.custom?.minWidth ?? COLUMN.DEFAULT_WIDTH, (availWidth - definedWidth) / autoCount) + ) + ); +} + +/** + * @internal + * if applyToRow is true in any field, return a function that gets the row background color + */ +export function getApplyToRowBgFn(fields: Field[], theme: GrafanaTheme2): ((rowIndex: number) => CellColors) | void { + for (const field of fields) { + const cellOptions = getCellOptions(field); + const fieldDisplay = field.display; + if ( + fieldDisplay !== undefined && + cellOptions.type === TableCellDisplayMode.ColorBackground && + cellOptions.applyToRow === true + ) { + return (rowIndex: number) => getCellColors(theme, cellOptions, fieldDisplay(field.values[rowIndex])); + } + } +} diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 7dad46032c4..8d535c2f127 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -418,7 +418,7 @@ func TestHTTPServer_GetDashboardVersions_AccessControl(t *testing.T) { }) } -func TestDashboardAPIEndpoint(t *testing.T) { +func TestIntegrationDashboardAPIEndpoint(t *testing.T) { t.Run("Given two dashboards with the same title in different folders", func(t *testing.T) { dashOne := dashboards.NewDashboard("dash") dashOne.ID = 2 diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index f7f2219a14e..8c2daa46414 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -113,7 +113,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F return m, hs } -func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) { +func TestIntegrationHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) { type buildInfo struct { Version string `json:"version"` Commit string `json:"commit"` @@ -182,7 +182,7 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) { } } -func TestHTTPServer_GetFrontendSettings_pluginsCDNBaseURL(t *testing.T) { +func TestIntegrationHTTPServer_GetFrontendSettings_pluginsCDNBaseURL(t *testing.T) { type settings struct { PluginsCDNBaseURL string `json:"pluginsCDNBaseURL"` } @@ -232,7 +232,7 @@ func TestHTTPServer_GetFrontendSettings_pluginsCDNBaseURL(t *testing.T) { } } -func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { +func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) { type settings struct { Apps map[string]*plugins.AppDTO `json:"apps"` } @@ -462,7 +462,7 @@ func newAppSettings(id string, enabled bool) map[string]*pluginsettings.DTO { } } -func TestHTTPServer_GetFrontendSettings_translations(t *testing.T) { +func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) { type settings struct { Datasources map[string]plugins.DataSourceDTO `json:"datasources"` Panels map[string]*plugins.PanelDTO `json:"panels"` diff --git a/pkg/api/org.go b/pkg/api/org.go index c649afe4b12..2e15640c278 100644 --- a/pkg/api/org.go +++ b/pkg/api/org.go @@ -292,16 +292,16 @@ func (hs *HTTPServer) DeleteOrgByID(c *contextmodel.ReqContext) response.Respons if err != nil { return response.Error(http.StatusBadRequest, "orgId is invalid", err) } - // before deleting an org, check if user does not belong to the current org + // before deleting an org, check if user is not active in the org if c.GetOrgID() == orgID { - return response.Error(http.StatusBadRequest, "Can not delete org for current user", nil) + return response.Error(http.StatusBadRequest, "Cannot delete your active organization. Please switch to a different organization first.", nil) } if err := hs.orgDeletionService.Delete(c.Req.Context(), &org.DeleteOrgCommand{ID: orgID}); err != nil { if errors.Is(err, org.ErrOrgNotFound) { return response.Error(http.StatusNotFound, "Failed to delete organization. ID not found", nil) } - return response.Error(http.StatusInternalServerError, "Failed to update organization", err) + return response.Error(http.StatusInternalServerError, "Failed to delete organization", err) } return response.Success("Organization deleted") } diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 59bb47330d6..bc7afa52e10 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -64,7 +64,7 @@ func setUpGetOrgUsersDB(t *testing.T, sqlStore db.DB, cfg *setting.Cfg) { require.NoError(t, err) } -func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { +func TestIntegrationOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { hs := setupSimpleHTTPServer(featuremgmt.WithFeatures()) settings := hs.Cfg diff --git a/pkg/api/plugin_resource_test.go b/pkg/api/plugin_resource_test.go index 98091d4407f..e3bfda93121 100644 --- a/pkg/api/plugin_resource_test.go +++ b/pkg/api/plugin_resource_test.go @@ -41,7 +41,7 @@ import ( "github.com/grafana/grafana/pkg/web/webtest" ) -func TestCallResource(t *testing.T) { +func TestIntegrationCallResource(t *testing.T) { staticRootPath, err := filepath.Abs("../../public/") require.NoError(t, err) diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 6b7a113cd12..cff5c630000 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -60,7 +60,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestDataSourceProxy_routeRule(t *testing.T) { +func TestIntegrationDataSourceProxy_routeRule(t *testing.T) { cfg := &setting.Cfg{} t.Run("Plugin with routes", func(t *testing.T) { diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 6dc94f4468c..3ad1e389c1f 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -57,7 +57,7 @@ import ( const newEmail = "newemail@localhost" -func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { +func TestIntegrationUserAPIEndpoint_userLoggedIn(t *testing.T) { settings := setting.NewCfg() sqlStore := db.InitTestDB(t, sqlstore.InitTestDBOpt{Cfg: settings}) hs := &HTTPServer{ @@ -404,7 +404,7 @@ func Test_GetUserByID(t *testing.T) { } } -func TestHTTPServer_UpdateUser(t *testing.T) { +func TestIntegrationHTTPServer_UpdateUser(t *testing.T) { settings := setting.NewCfg() sqlStore := db.InitTestDB(t) @@ -478,7 +478,7 @@ func setupUpdateEmailTests(t *testing.T, cfg *setting.Cfg) (*user.User, *HTTPSer return usr, hs, nsMock } -func TestUser_UpdateEmail(t *testing.T) { +func TestIntegrationUser_UpdateEmail(t *testing.T) { cases := []struct { Name string Field user.UpdateEmailActionType @@ -1153,7 +1153,7 @@ func updateUserScenario(t *testing.T, ctx updateUserContext, hs *HTTPServer) { }) } -func TestHTTPServer_UpdateSignedInUser(t *testing.T) { +func TestIntegrationHTTPServer_UpdateSignedInUser(t *testing.T) { settings := setting.NewCfg() sqlStore := db.InitTestDB(t) diff --git a/pkg/build/wire/cmd/wire/main.go b/pkg/build/wire/cmd/wire/main.go index af79d29115c..4d4a2a52c36 100644 --- a/pkg/build/wire/cmd/wire/main.go +++ b/pkg/build/wire/cmd/wire/main.go @@ -101,6 +101,7 @@ type genCmd struct { headerFile string prefixFileName string tags string + genTags string } func (*genCmd) Name() string { return "gen" } @@ -119,6 +120,7 @@ func (cmd *genCmd) SetFlags(f *flag.FlagSet) { f.StringVar(&cmd.headerFile, "header_file", "", "path to file to insert as a header in wire_gen.go") f.StringVar(&cmd.prefixFileName, "output_file_prefix", "", "string to prepend to output file names.") f.StringVar(&cmd.tags, "tags", "", "append build tags to the default wirebuild") + f.StringVar(&cmd.genTags, "gen_tags", "", "append build tags to the generated file") } func (cmd *genCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) subcommands.ExitStatus { @@ -135,6 +137,7 @@ func (cmd *genCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interfa opts.PrefixOutputFile = cmd.prefixFileName opts.Tags = cmd.tags + opts.GenTags = cmd.genTags outs, errs := wire.Generate(ctx, wd, os.Environ(), packages(f), opts) if len(errs) > 0 { diff --git a/pkg/build/wire/internal/wire/testdata/BindInjectorArg/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/BindInjectorArg/want/wire_gen.go index 5f5b21068ef..de72b70617a 100644 --- a/pkg/build/wire/internal/wire/testdata/BindInjectorArg/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/BindInjectorArg/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/BindInjectorArgPointer/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/BindInjectorArgPointer/want/wire_gen.go index 15d6f0e335b..789f9b0c160 100644 --- a/pkg/build/wire/internal/wire/testdata/BindInjectorArgPointer/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/BindInjectorArgPointer/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/BindInterfaceWithValue/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/BindInterfaceWithValue/want/wire_gen.go index 58d82bf4006..60be1f544ab 100644 --- a/pkg/build/wire/internal/wire/testdata/BindInterfaceWithValue/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/BindInterfaceWithValue/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/BuildTagsAllPackages/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/BuildTagsAllPackages/want/wire_gen.go index 16c0df257c2..3d20e9cbad3 100644 --- a/pkg/build/wire/internal/wire/testdata/BuildTagsAllPackages/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/BuildTagsAllPackages/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/Chain/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/Chain/want/wire_gen.go index cf9e9eeaaea..c444e0ca0c6 100644 --- a/pkg/build/wire/internal/wire/testdata/Chain/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/Chain/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/Cleanup/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/Cleanup/want/wire_gen.go index 1519c104c52..825f159cd4e 100644 --- a/pkg/build/wire/internal/wire/testdata/Cleanup/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/Cleanup/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/CopyOtherDecls/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/CopyOtherDecls/want/wire_gen.go index c654540b4ed..1eeb76d01f6 100644 --- a/pkg/build/wire/internal/wire/testdata/CopyOtherDecls/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/CopyOtherDecls/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/DocComment/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/DocComment/want/wire_gen.go index 3efb22ac5de..b80fe5928ae 100644 --- a/pkg/build/wire/internal/wire/testdata/DocComment/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/DocComment/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/ExampleWithMocks/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/ExampleWithMocks/want/wire_gen.go index 9cb7b01cb12..944deb1f122 100644 --- a/pkg/build/wire/internal/wire/testdata/ExampleWithMocks/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/ExampleWithMocks/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/ExportedValue/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/ExportedValue/want/wire_gen.go index 7563ca26af5..0032f485fc2 100644 --- a/pkg/build/wire/internal/wire/testdata/ExportedValue/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/ExportedValue/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/ExportedValueDifferentPackage/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/ExportedValueDifferentPackage/want/wire_gen.go index d8c74d4cdb0..32f05faf112 100644 --- a/pkg/build/wire/internal/wire/testdata/ExportedValueDifferentPackage/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/ExportedValueDifferentPackage/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/FieldsOfImportedStruct/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/FieldsOfImportedStruct/want/wire_gen.go index 8467ee37d89..56548972de5 100644 --- a/pkg/build/wire/internal/wire/testdata/FieldsOfImportedStruct/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/FieldsOfImportedStruct/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/FieldsOfStruct/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/FieldsOfStruct/want/wire_gen.go index 5b8ca2f3c07..8ec5f813262 100644 --- a/pkg/build/wire/internal/wire/testdata/FieldsOfStruct/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/FieldsOfStruct/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/FieldsOfStructPointer/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/FieldsOfStructPointer/want/wire_gen.go index c9fe7955491..6b35f5ebb86 100644 --- a/pkg/build/wire/internal/wire/testdata/FieldsOfStructPointer/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/FieldsOfStructPointer/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/FieldsOfValueStruct/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/FieldsOfValueStruct/want/wire_gen.go index 793c04d6b21..336157bcb82 100644 --- a/pkg/build/wire/internal/wire/testdata/FieldsOfValueStruct/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/FieldsOfValueStruct/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/Header/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/Header/want/wire_gen.go index 2f8c5c622f4..40c1238683c 100644 --- a/pkg/build/wire/internal/wire/testdata/Header/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/Header/want/wire_gen.go @@ -2,9 +2,8 @@ // // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/ImportedInterfaceBinding/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/ImportedInterfaceBinding/want/wire_gen.go index b68a1dbf2b4..f86bc80f1a6 100644 --- a/pkg/build/wire/internal/wire/testdata/ImportedInterfaceBinding/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/ImportedInterfaceBinding/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/InjectInput/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/InjectInput/want/wire_gen.go index 9471cf6f381..8ca98d6d298 100644 --- a/pkg/build/wire/internal/wire/testdata/InjectInput/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/InjectInput/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/InjectWithPanic/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/InjectWithPanic/want/wire_gen.go index 8b124e562d8..17838512386 100644 --- a/pkg/build/wire/internal/wire/testdata/InjectWithPanic/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/InjectWithPanic/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/InterfaceBinding/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/InterfaceBinding/want/wire_gen.go index 2c1a67b261e..75d53fa51b2 100644 --- a/pkg/build/wire/internal/wire/testdata/InterfaceBinding/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/InterfaceBinding/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/InterfaceBindingReuse/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/InterfaceBindingReuse/want/wire_gen.go index 1c437713599..2f72588bf80 100644 --- a/pkg/build/wire/internal/wire/testdata/InterfaceBindingReuse/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/InterfaceBindingReuse/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/InterfaceValue/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/InterfaceValue/want/wire_gen.go index 9868a9023f5..f8381bf16fd 100644 --- a/pkg/build/wire/internal/wire/testdata/InterfaceValue/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/InterfaceValue/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/MultipleSimilarPackages/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/MultipleSimilarPackages/want/wire_gen.go index e4068205704..a45cbce8510 100644 --- a/pkg/build/wire/internal/wire/testdata/MultipleSimilarPackages/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/MultipleSimilarPackages/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/NamingWorstCase/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/NamingWorstCase/want/wire_gen.go index e32329d325e..d7f216318d9 100644 --- a/pkg/build/wire/internal/wire/testdata/NamingWorstCase/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/NamingWorstCase/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/NamingWorstCaseAllInOne/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/NamingWorstCaseAllInOne/want/wire_gen.go index c0815204bd1..5c0fe8a4d70 100644 --- a/pkg/build/wire/internal/wire/testdata/NamingWorstCaseAllInOne/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/NamingWorstCaseAllInOne/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/NiladicIdentity/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/NiladicIdentity/want/wire_gen.go index 8b124e562d8..17838512386 100644 --- a/pkg/build/wire/internal/wire/testdata/NiladicIdentity/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/NiladicIdentity/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/NiladicValue/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/NiladicValue/want/wire_gen.go index 16c0df257c2..3d20e9cbad3 100644 --- a/pkg/build/wire/internal/wire/testdata/NiladicValue/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/NiladicValue/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/NoInjectParamNames/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/NoInjectParamNames/want/wire_gen.go index b8760db4b81..4d3e22afdd6 100644 --- a/pkg/build/wire/internal/wire/testdata/NoInjectParamNames/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/NoInjectParamNames/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/PartialCleanup/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/PartialCleanup/want/wire_gen.go index 6cf2640186d..4c5908ff5b6 100644 --- a/pkg/build/wire/internal/wire/testdata/PartialCleanup/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/PartialCleanup/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/PkgImport/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/PkgImport/want/wire_gen.go index 5f3e127faff..7823a84cec9 100644 --- a/pkg/build/wire/internal/wire/testdata/PkgImport/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/PkgImport/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/RelativePkg/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/RelativePkg/want/wire_gen.go index 8b124e562d8..17838512386 100644 --- a/pkg/build/wire/internal/wire/testdata/RelativePkg/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/RelativePkg/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/ReservedKeywords/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/ReservedKeywords/want/wire_gen.go index 7ff31104195..ad8b29cc856 100644 --- a/pkg/build/wire/internal/wire/testdata/ReservedKeywords/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/ReservedKeywords/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/ReturnArgumentAsInterface/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/ReturnArgumentAsInterface/want/wire_gen.go index 9d1a331d258..894eb8fda0f 100644 --- a/pkg/build/wire/internal/wire/testdata/ReturnArgumentAsInterface/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/ReturnArgumentAsInterface/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/ReturnError/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/ReturnError/want/wire_gen.go index 5d9746675db..4202fe7a2a6 100644 --- a/pkg/build/wire/internal/wire/testdata/ReturnError/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/ReturnError/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/Struct/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/Struct/want/wire_gen.go index e4bf0c727d1..3fbcbf6d5bb 100644 --- a/pkg/build/wire/internal/wire/testdata/Struct/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/Struct/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/StructPointer/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/StructPointer/want/wire_gen.go index e96e8cdd606..edac319bd96 100644 --- a/pkg/build/wire/internal/wire/testdata/StructPointer/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/StructPointer/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/TwoDeps/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/TwoDeps/want/wire_gen.go index bfc52d7f244..79e8af9aee9 100644 --- a/pkg/build/wire/internal/wire/testdata/TwoDeps/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/TwoDeps/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/ValueChain/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/ValueChain/want/wire_gen.go index da2cccb3f06..c1281b4c307 100644 --- a/pkg/build/wire/internal/wire/testdata/ValueChain/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/ValueChain/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/ValueConversion/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/ValueConversion/want/wire_gen.go index b663861d920..b2ac25e684b 100644 --- a/pkg/build/wire/internal/wire/testdata/ValueConversion/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/ValueConversion/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/ValueIsStruct/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/ValueIsStruct/want/wire_gen.go index f41ed12200f..f7c9156b72d 100644 --- a/pkg/build/wire/internal/wire/testdata/ValueIsStruct/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/ValueIsStruct/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/VarValue/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/VarValue/want/wire_gen.go index 07e4f01993b..5ee82a4a260 100644 --- a/pkg/build/wire/internal/wire/testdata/VarValue/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/VarValue/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/testdata/Varargs/want/wire_gen.go b/pkg/build/wire/internal/wire/testdata/Varargs/want/wire_gen.go index 703a549132b..9bffed4399e 100644 --- a/pkg/build/wire/internal/wire/testdata/Varargs/want/wire_gen.go +++ b/pkg/build/wire/internal/wire/testdata/Varargs/want/wire_gen.go @@ -1,8 +1,7 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:generate go run ./pkg/build/wire/cmd/wire/main.go //go:build !wireinject -// +build !wireinject package main diff --git a/pkg/build/wire/internal/wire/wire.go b/pkg/build/wire/internal/wire/wire.go index 5cedeb1ab3b..be60f05c08b 100644 --- a/pkg/build/wire/internal/wire/wire.go +++ b/pkg/build/wire/internal/wire/wire.go @@ -66,6 +66,7 @@ type GenerateOptions struct { Header []byte PrefixOutputFile string Tags string + GenTags string } // Generate performs dependency injection for the packages that match the given @@ -104,7 +105,7 @@ func Generate(ctx context.Context, wd string, env []string, patterns []string, o continue } copyNonInjectorDecls(g, injectorFiles, pkg.TypesInfo) - goSrc := g.frame(opts.Tags) + goSrc := g.frame(opts.Tags, opts.GenTags) if len(opts.Header) > 0 { goSrc = append(opts.Header, goSrc...) } @@ -258,7 +259,7 @@ func newGen(pkg *packages.Package) *gen { } // frame bakes the built up source body into an unformatted Go source file. -func (g *gen) frame(tags string) []byte { +func (g *gen) frame(tags, genTags string) []byte { if g.buf.Len() == 0 { return nil } @@ -267,8 +268,12 @@ func (g *gen) frame(tags string) []byte { tags = fmt.Sprintf(" gen -tags \"%s\"", tags) } buf.WriteString("// Code generated by Wire. DO NOT EDIT.\n\n") - buf.WriteString("//go:generate go run -mod=mod github.com/google/wire/cmd/wire" + tags + "\n") - buf.WriteString("//+build !wireinject\n\n") + buf.WriteString("//go:generate go run ./pkg/build/wire/cmd/wire/main.go" + tags + "\n") + buildTags := "!wireinject" + if len(genTags) > 0 { + buildTags += " && " + genTags + } + buf.WriteString("//go:build " + buildTags + "\n\n") buf.WriteString("package ") buf.WriteString(g.pkg.Name) buf.WriteString("\n\n") diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go index d24e0782cab..8eb9158629a 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go @@ -20,7 +20,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestPasswordMigrationCommand(t *testing.T) { +func TestIntegrationPasswordMigrationCommand(t *testing.T) { // setup datasources with password, basic_auth and none store := db.InitTestDB(t) err := store.WithDbSession(context.Background(), func(sess *db.Session) error { diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go index 8b4176e9e09..b2fac806a45 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go @@ -22,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/setting" @@ -75,6 +76,7 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err provisioning, nil, // no librarypanels.Service sort.ProvideService(), + acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), ) client, err := newUnifiedClient(cfg, sqlStore) diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage_test.go b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage_test.go index 1fdbfc7b026..510908c82e8 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage_test.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage_test.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" ) -func TestUnifiedStorageCommand(t *testing.T) { +func TestIntegrationUnifiedStorageCommand(t *testing.T) { // setup datasources with password, basic_auth and none store := db.InitTestDB(t) err := store.WithDbSession(context.Background(), func(sess *db.Session) error { diff --git a/pkg/infra/kvstore/kvstore_test.go b/pkg/infra/kvstore/kvstore_test.go index f94fd7dc0d2..5023edb6b4a 100644 --- a/pkg/infra/kvstore/kvstore_test.go +++ b/pkg/infra/kvstore/kvstore_test.go @@ -248,7 +248,7 @@ func TestIntegrationKVStore(t *testing.T) { }) } -func TestGetItems(t *testing.T) { +func TestIntegrationGetItems(t *testing.T) { kv := createTestableKVStore(t) ctx := context.Background() diff --git a/pkg/infra/remotecache/database_storage_test.go b/pkg/infra/remotecache/database_storage_test.go index 6c44aca3777..c0b9cd9ed4a 100644 --- a/pkg/infra/remotecache/database_storage_test.go +++ b/pkg/infra/remotecache/database_storage_test.go @@ -11,7 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" ) -func TestDatabaseStorageGarbageCollection(t *testing.T) { +func TestIntegrationDatabaseStorageGarbageCollection(t *testing.T) { sqlstore := db.InitTestDB(t) db := &databaseCache{ @@ -58,7 +58,7 @@ func TestDatabaseStorageGarbageCollection(t *testing.T) { assert.Equal(t, err, nil) } -func TestSecondSet(t *testing.T) { +func TestIntegrationSecondSet(t *testing.T) { var err error sqlstore := db.InitTestDB(t) diff --git a/pkg/infra/remotecache/remotecache_test.go b/pkg/infra/remotecache/remotecache_test.go index 239fc7b304c..71cacf3acf2 100644 --- a/pkg/infra/remotecache/remotecache_test.go +++ b/pkg/infra/remotecache/remotecache_test.go @@ -33,7 +33,7 @@ func createTestClient(t *testing.T, opts *setting.RemoteCacheSettings, sqlstore return dc } -func TestCachedBasedOnConfig(t *testing.T) { +func TestIntegrationCachedBasedOnConfig(t *testing.T) { db, cfg := sqlstore.InitTestDB(t) err := cfg.Load(setting.CommandLineArgs{ HomePath: "../../../", diff --git a/pkg/infra/serverlock/serverlock_test.go b/pkg/infra/serverlock/serverlock_test.go index 0205bce3908..f13b9bfa141 100644 --- a/pkg/infra/serverlock/serverlock_test.go +++ b/pkg/infra/serverlock/serverlock_test.go @@ -30,7 +30,7 @@ func createTestableServerLock(t *testing.T) *ServerLockService { } } -func TestServerLock(t *testing.T) { +func TestIntegrationServerLock(t *testing.T) { sl := createTestableServerLock(t) operationUID := "test-operation" @@ -66,7 +66,7 @@ func TestServerLock(t *testing.T) { }) } -func TestLockAndRelease(t *testing.T) { +func TestIntegrationLockAndRelease(t *testing.T) { operationUID := "test-operation-release" t.Run("create lock and then release it", func(t *testing.T) { diff --git a/pkg/infra/usagestats/service/usage_stats_test.go b/pkg/infra/usagestats/service/usage_stats_test.go index 2667144d5a4..6a186e93b84 100644 --- a/pkg/infra/usagestats/service/usage_stats_test.go +++ b/pkg/infra/usagestats/service/usage_stats_test.go @@ -155,7 +155,7 @@ func TestMetrics(t *testing.T) { }) } -func TestGetUsageReport_IncludesMetrics(t *testing.T) { +func TestIntegrationGetUsageReport_IncludesMetrics(t *testing.T) { sqlStore := dbtest.NewFakeDB() uss := createService(t, sqlStore, true) metricName := "stats.test_metric.count" diff --git a/pkg/infra/usagestats/statscollector/concurrent_users_test.go b/pkg/infra/usagestats/statscollector/concurrent_users_test.go index dbe348c4c8c..6c089a5e712 100644 --- a/pkg/infra/usagestats/statscollector/concurrent_users_test.go +++ b/pkg/infra/usagestats/statscollector/concurrent_users_test.go @@ -27,7 +27,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestConcurrentUsersMetrics(t *testing.T) { +func TestIntegrationConcurrentUsersMetrics(t *testing.T) { sqlStore, cfg := db.InitTestDBWithCfg(t) statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore, &dashboards.FakeDashboardService{}, &foldertest.FakeService{}, &orgtest.FakeOrgService{}, featuremgmt.WithFeatures()) s := createService(t, cfg, sqlStore, statsService) @@ -45,7 +45,7 @@ func TestConcurrentUsersMetrics(t *testing.T) { assert.Equal(t, int32(6), stats["stats.auth_token_per_user_le_inf"]) } -func TestConcurrentUsersStats(t *testing.T) { +func TestIntegrationConcurrentUsersStats(t *testing.T) { sqlStore, cfg := db.InitTestDBWithCfg(t) statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore, &dashboards.FakeDashboardService{}, &foldertest.FakeService{}, &orgtest.FakeOrgService{}, featuremgmt.WithFeatures()) s := createService(t, cfg, sqlStore, statsService) diff --git a/pkg/login/social/socialimpl/service_test.go b/pkg/login/social/socialimpl/service_test.go index 776fc077d8a..e188fbb53d9 100644 --- a/pkg/login/social/socialimpl/service_test.go +++ b/pkg/login/social/socialimpl/service_test.go @@ -27,7 +27,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestSocialService_ProvideService(t *testing.T) { +func TestIntegrationSocialService_ProvideService(t *testing.T) { type testEnv struct { features featuremgmt.FeatureToggles } @@ -138,7 +138,7 @@ func TestSocialService_ProvideService(t *testing.T) { } } -func TestSocialService_ProvideService_GrafanaComGrafanaNet(t *testing.T) { +func TestIntegrationSocialService_ProvideService_GrafanaComGrafanaNet(t *testing.T) { testCases := []struct { name string rawIniContent string diff --git a/pkg/plugins/manager/client/client_test.go b/pkg/plugins/manager/client/client_test.go index 0369365c269..cf4c7521bd0 100644 --- a/pkg/plugins/manager/client/client_test.go +++ b/pkg/plugins/manager/client/client_test.go @@ -156,7 +156,7 @@ func TestCheckHealth(t *testing.T) { }) } -func TestCallResource(t *testing.T) { +func TestIntegrationCallResource(t *testing.T) { registry := fakes.NewFakePluginRegistry() p := &plugins.Plugin{ JSONData: plugins.JSONData{ diff --git a/pkg/registry/apis/dashboard/authorizer.go b/pkg/registry/apis/dashboard/authorizer.go index fffb01da544..4c6351e2189 100644 --- a/pkg/registry/apis/dashboard/authorizer.go +++ b/pkg/registry/apis/dashboard/authorizer.go @@ -6,10 +6,12 @@ import ( "k8s.io/apiserver/pkg/authorization/authorizer" "github.com/grafana/authlib/types" + dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/libraryelements" ) func GetAuthorizer(ac accesscontrol.AccessControl, l log.Logger) authorizer.Authorizer { @@ -42,38 +44,87 @@ func GetAuthorizer(ac accesscontrol.AccessControl, l log.Logger) authorizer.Auth return authorizer.DecisionDeny, "org mismatch", dashboards.ErrUserIsNotSignedInToOrg } - switch attr.GetVerb() { - case "list", "search": - // Detailed read permissions are handled by authz, this just checks whether the user can ready *any* dashboard - ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead)) - if !ok || err != nil { - return authorizer.DecisionDeny, "can not read any dashboards", err - } - case "create": - // Detailed create permissions are handled by authz, this just checks whether the user can create *any* dashboard - ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate)) - if !ok || err != nil { - return authorizer.DecisionDeny, "can not create any dashboards", err - } - case "get": - ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName()))) - if !ok || err != nil { - return authorizer.DecisionDeny, "can not view dashboard", err - } - case "update", "patch": - ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName()))) - if !ok || err != nil { - return authorizer.DecisionDeny, "can not edit dashboard", err - } - case "delete": - ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName()))) - if !ok || err != nil { - return authorizer.DecisionDeny, "can not delete dashboard", err - } - default: - l.Info("unknown verb", "verb", attr.GetVerb()) - return authorizer.DecisionDeny, "unsupported verb", nil // Unknown verb + // Determine if this is a library panel or dashboard resource + resource := attr.GetResource() + isLibraryPanel := resource == dashv0.LIBRARY_PANEL_RESOURCE + + if isLibraryPanel { + return authorizeLibraryPanel(ctx, ac, user, attr) + } else { + return authorizeDashboard(ctx, ac, user, attr) } - return authorizer.DecisionAllow, "", nil }) } + +func authorizeLibraryPanel(ctx context.Context, ac accesscontrol.AccessControl, user identity.Requester, attr authorizer.Attributes) (authorizer.Decision, string, error) { + switch attr.GetVerb() { + case "list", "search": + // Detailed read permissions are handled by authz, this just checks whether the user can ready *any* library panel + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsRead)) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not read any library panels", err + } + case "create": + // TODO: uncomment this when we implement create :) + // + // Detailed create permissions are handled by authz, this just checks whether the user can create *any* library panel + // ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsCreate)) + // if !ok || err != nil { + // return authorizer.DecisionDeny, "can not create any library panels", err + // } + return authorizer.DecisionDeny, "can not create any library panels", nil + case "get": + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsRead, libraryelements.ScopeLibraryPanelsProvider.GetResourceScopeUID(attr.GetName()))) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not view library panel", err + } + case "update", "patch": + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsWrite, libraryelements.ScopeLibraryPanelsProvider.GetResourceScopeUID(attr.GetName()))) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not edit library panel", err + } + case "delete": + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(libraryelements.ActionLibraryPanelsDelete, libraryelements.ScopeLibraryPanelsProvider.GetResourceScopeUID(attr.GetName()))) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not delete library panel", err + } + default: + return authorizer.DecisionDeny, "unsupported verb for library panels", nil + } + return authorizer.DecisionAllow, "", nil +} + +func authorizeDashboard(ctx context.Context, ac accesscontrol.AccessControl, user identity.Requester, attr authorizer.Attributes) (authorizer.Decision, string, error) { + switch attr.GetVerb() { + case "list", "search": + // Detailed read permissions are handled by authz, this just checks whether the user can ready *any* dashboard + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead)) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not read any dashboards", err + } + case "create": + // Detailed create permissions are handled by authz, this just checks whether the user can create *any* dashboard + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate)) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not create any dashboards", err + } + case "get": + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName()))) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not view dashboard", err + } + case "update", "patch": + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName()))) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not edit dashboard", err + } + case "delete": + ok, err := ac.Evaluate(ctx, user, accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(attr.GetName()))) + if !ok || err != nil { + return authorizer.DecisionDeny, "can not delete dashboard", err + } + default: + return authorizer.DecisionDeny, "unsupported verb for dashboards", nil + } + return authorizer.DecisionAllow, "", nil +} diff --git a/pkg/registry/apis/dashboard/legacy/migrate.go b/pkg/registry/apis/dashboard/legacy/migrate.go index 8cbaeafaf0e..5692b490e95 100644 --- a/pkg/registry/apis/dashboard/legacy/migrate.go +++ b/pkg/registry/apis/dashboard/legacy/migrate.go @@ -15,6 +15,7 @@ import ( folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/search/sort" @@ -48,9 +49,10 @@ func ProvideLegacyMigrator( sql db.DB, // direct access to tables provisioning provisioning.ProvisioningService, // only needed for dashboard settings libraryPanelSvc librarypanels.Service, + accessControl accesscontrol.AccessControl, ) LegacyMigrator { dbp := legacysql.NewDatabaseProvider(sql) - return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, libraryPanelSvc, sort.ProvideService()) + return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, libraryPanelSvc, sort.ProvideService(), accessControl) } type BlobStoreInfo struct { diff --git a/pkg/registry/apis/dashboard/legacy/query_panels.sql b/pkg/registry/apis/dashboard/legacy/query_panels.sql index d106b17b6bf..ede44614ce8 100644 --- a/pkg/registry/apis/dashboard/legacy/query_panels.sql +++ b/pkg/registry/apis/dashboard/legacy/query_panels.sql @@ -1,7 +1,7 @@ SELECT p.id, p.uid, p.folder_uid, p.created, created_user.uid as created_by, p.updated, updated_user.uid as updated_by, - p.name, p.type, p.description, p.model + p.name, p.type, p.description, p.model, p.version FROM {{ .Ident .LibraryElementTable }} as p LEFT OUTER JOIN {{ .Ident .UserTable }} AS created_user ON p.created_by = created_user.id LEFT OUTER JOIN {{ .Ident .UserTable }} AS updated_user ON p.updated_by = updated_user.id diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 6f04fe12947..21c83690c8a 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -25,9 +25,11 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/search/sort" @@ -64,6 +66,7 @@ type dashboardSqlAccess struct { dashStore dashboards.Store dashboardSearchClient legacysearcher.DashboardSearchClient + accessControl accesscontrol.AccessControl libraryPanelSvc librarypanels.Service // Typically one... the server wrapper @@ -78,6 +81,7 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, provisioning provisioning.ProvisioningService, libraryPanelSvc librarypanels.Service, sorter sort.Service, + accessControl accesscontrol.AccessControl, ) DashboardAccess { dashboardSearchClient := legacysearcher.NewDashboardSearchClient(dashStore, sorter) return &dashboardSqlAccess{ @@ -87,6 +91,7 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, provisioning: provisioning, dashboardSearchClient: *dashboardSearchClient, libraryPanelSvc: libraryPanelSvc, + accessControl: accessControl, log: log.New("dashboard.legacysql"), } } @@ -478,6 +483,25 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das return dash, created, err } +type panel struct { + ID int64 + UID string + FolderUID sql.NullString + + Created time.Time + CreatedBy string + + Updated time.Time + UpdatedBy string + + Version int64 + + Name string + Type string + Description string + Model []byte +} + func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query LibraryPanelQuery) (*dashboardV0.LibraryPanelList, error) { limit := int(query.Limit) query.Limit += 1 // for continue @@ -485,6 +509,11 @@ func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query Library return nil, fmt.Errorf("expected non zero orgID") } + user, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + sqlx, err := a.sql(ctx) if err != nil { return nil, err @@ -508,93 +537,30 @@ func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query Library return nil, err } - type panel struct { - ID int64 - UID string - FolderUID sql.NullString - - Created time.Time - CreatedBy string - - Updated time.Time - UpdatedBy string - - Name string - Type string - Description string - Model []byte - } - var lastID int64 for rows.Next() { p := panel{} err = rows.Scan(&p.ID, &p.UID, &p.FolderUID, &p.Created, &p.CreatedBy, &p.Updated, &p.UpdatedBy, - &p.Name, &p.Type, &p.Description, &p.Model, + &p.Name, &p.Type, &p.Description, &p.Model, &p.Version, ) if err != nil { return res, err } lastID = p.ID - item := dashboardV0.LibraryPanel{ - TypeMeta: metav1.TypeMeta{ - APIVersion: dashboardV0.APIVERSION, - Kind: "LibraryPanel", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: p.UID, - CreationTimestamp: metav1.NewTime(p.Created), - ResourceVersion: strconv.FormatInt(p.Updated.UnixMicro(), 10), - }, - Spec: dashboardV0.LibraryPanelSpec{}, - } - - status := &dashboardV0.LibraryPanelStatus{ - Missing: v0alpha1.Unstructured{}, - } - err = json.Unmarshal(p.Model, &item.Spec) + item, err := parseLibraryPanelRow(p) if err != nil { - return nil, err - } - err = json.Unmarshal(p.Model, &status.Missing.Object) - if err != nil { - return nil, err + return res, err } - if item.Spec.Title != p.Name { - status.Warnings = append(status.Warnings, fmt.Sprintf("title mismatch (expected: %s)", p.Name)) - } - if item.Spec.Description != p.Description { - status.Warnings = append(status.Warnings, fmt.Sprintf("description mismatch (expected: %s)", p.Description)) - } - if item.Spec.Type != p.Type { - status.Warnings = append(status.Warnings, fmt.Sprintf("type mismatch (expected: %s)", p.Type)) - } - item.Status = status - - // Remove the properties we are already showing - for _, k := range []string{"type", "pluginVersion", "title", "description", "options", "fieldConfig", "datasource", "targets", "libraryPanel"} { - delete(status.Missing.Object, k) - } - - meta, err := utils.MetaAccessor(&item) - if err != nil { - return nil, err - } - if p.FolderUID.Valid { - meta.SetFolder(p.FolderUID.String) - } - meta.SetCreatedBy(p.CreatedBy) - meta.SetGeneration(1) - meta.SetDeprecatedInternalID(p.ID) //nolint:staticcheck - - // Only set updated metadata if it is different - if p.UpdatedBy != p.CreatedBy || p.Updated.Sub(p.Created) > time.Second { - meta.SetUpdatedBy(p.UpdatedBy) - meta.SetUpdatedTimestamp(&p.Updated) - meta.SetGeneration(2) + ok, err := a.accessControl.Evaluate(ctx, user, accesscontrol.EvalPermission( + libraryelements.ActionLibraryPanelsRead, + libraryelements.ScopeLibraryPanelsProvider.GetResourceScopeUID(item.Name), + )) + if err != nil || !ok { + continue } res.Items = append(res.Items, item) @@ -611,3 +577,71 @@ func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query Library } return res, err } + +func parseLibraryPanelRow(p panel) (dashboardV0.LibraryPanel, error) { + item := dashboardV0.LibraryPanel{ + TypeMeta: metav1.TypeMeta{ + APIVersion: dashboardV0.APIVERSION, + Kind: "LibraryPanel", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: p.UID, + CreationTimestamp: metav1.NewTime(p.Created), + ResourceVersion: strconv.FormatInt(p.Updated.UnixMicro(), 10), + }, + Spec: dashboardV0.LibraryPanelSpec{}, + } + + status := &dashboardV0.LibraryPanelStatus{ + Missing: v0alpha1.Unstructured{}, + } + err := json.Unmarshal(p.Model, &item.Spec) + if err != nil { + return item, err + } + err = json.Unmarshal(p.Model, &status.Missing.Object) + if err != nil { + return item, err + } + + // the panel title used in dashboards and title of the library panel can differ + // in the old model blob, the panel title is specified as "title", and the library panel title is + // in "libraryPanel.name", or as the column in the db. + item.Spec.PanelTitle = item.Spec.Title + item.Spec.Title = p.Name + + if item.Spec.Title != p.Name { + status.Warnings = append(status.Warnings, fmt.Sprintf("title mismatch (expected: %s)", p.Name)) + } + if item.Spec.Description != p.Description { + status.Warnings = append(status.Warnings, fmt.Sprintf("description mismatch (expected: %s)", p.Description)) + } + if item.Spec.Type != p.Type { + status.Warnings = append(status.Warnings, fmt.Sprintf("type mismatch (expected: %s)", p.Type)) + } + item.Status = status + + // Remove the properties we are already showing + for _, k := range []string{"type", "pluginVersion", "title", "description", "options", "fieldConfig", "datasource", "targets", "libraryPanel", "id", "gridPos"} { + delete(status.Missing.Object, k) + } + + meta, err := utils.MetaAccessor(&item) + if err != nil { + return item, err + } + if p.FolderUID.Valid { + meta.SetFolder(p.FolderUID.String) + } + meta.SetCreatedBy(p.CreatedBy) + meta.SetGeneration(p.Version) + meta.SetDeprecatedInternalID(p.ID) //nolint:staticcheck + + // Only set updated metadata if it is different + if p.UpdatedBy != p.CreatedBy || p.Updated.Sub(p.Created) > time.Second { + meta.SetUpdatedBy(p.UpdatedBy) + meta.SetUpdatedTimestamp(&p.Updated) + } + + return item, nil +} diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go index 6c19aa9ccf6..3c96a843467 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go @@ -2,6 +2,8 @@ package legacy import ( "context" + "database/sql" + "encoding/json" "testing" "time" @@ -221,3 +223,81 @@ func TestBuildSaveDashboardCommand(t *testing.T) { }) } } + +func TestParseLibraryPanelRow(t *testing.T) { + basePanel := panel{ + ID: 1, + UID: "panel-uid", + FolderUID: sql.NullString{String: "folder-uid", Valid: true}, + Created: time.Now(), + CreatedBy: "creator", + Updated: time.Now(), + UpdatedBy: "updator", + Version: 2, + Type: "graph", + Description: "desc from db", + } + + t.Run("type mismatch triggers warning", func(t *testing.T) { + p := basePanel + p.Name = "Test Panel" + p.Type = "graph" + model := map[string]interface{}{ + "title": "Test Panel", + "type": "table", + "description": "desc from db", + } + modelBytes, err := json.Marshal(model) + require.NoError(t, err) + p.Model = modelBytes + + item, err := parseLibraryPanelRow(p) + require.NoError(t, err) + require.Equal(t, "table", item.Spec.Type) + require.NotEmpty(t, item.Status.Warnings) + require.Contains(t, item.Status.Warnings[0], "type mismatch") + }) + + t.Run("metadata is set correctly", func(t *testing.T) { + p := basePanel + p.Name = "Test Panel" + model := map[string]interface{}{ + "title": "Test Panel", + "type": "graph", + "description": "desc from db", + } + modelBytes, err := json.Marshal(model) + require.NoError(t, err) + p.Model = modelBytes + + item, err := parseLibraryPanelRow(p) + require.NoError(t, err) + + meta, err := utils.MetaAccessor(&item) + require.NoError(t, err) + require.Equal(t, p.ID, meta.GetDeprecatedInternalID()) // nolint:staticcheck + require.Equal(t, p.Version, meta.GetGeneration()) + require.Equal(t, p.FolderUID.String, meta.GetFolder()) + require.Equal(t, p.CreatedBy, meta.GetCreatedBy()) + require.Equal(t, p.UpdatedBy, meta.GetUpdatedBy()) + }) + + t.Run("panel title in dashboard vs library panel title is set correctly", func(t *testing.T) { + p := basePanel + p.Name = "Database Name" + model := map[string]interface{}{ + "title": "Model Title", + "type": "graph", + "description": "desc from db", + } + modelBytes, err := json.Marshal(model) + require.NoError(t, err) + p.Model = modelBytes + + item, err := parseLibraryPanelRow(p) + require.NoError(t, err) + + require.Equal(t, "Model Title", item.Spec.PanelTitle) + require.Equal(t, "Database Name", item.Spec.Title) + }) +} diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-get_uid.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-get_uid.sql index bfe4b6b037c..ba0ee511bfc 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-get_uid.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-get_uid.sql @@ -1,7 +1,7 @@ SELECT p.id, p.uid, p.folder_uid, p.created, created_user.uid as created_by, p.updated, updated_user.uid as updated_by, - p.name, p.type, p.description, p.model + p.name, p.type, p.description, p.model, p.version FROM `grafana`.`library_element` as p LEFT OUTER JOIN `grafana`.`user` AS created_user ON p.created_by = created_user.id LEFT OUTER JOIN `grafana`.`user` AS updated_user ON p.updated_by = updated_user.id diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list.sql index 94bd1a4d9e6..0d32399a66a 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list.sql @@ -1,7 +1,7 @@ SELECT p.id, p.uid, p.folder_uid, p.created, created_user.uid as created_by, p.updated, updated_user.uid as updated_by, - p.name, p.type, p.description, p.model + p.name, p.type, p.description, p.model, p.version FROM `grafana`.`library_element` as p LEFT OUTER JOIN `grafana`.`user` AS created_user ON p.created_by = created_user.id LEFT OUTER JOIN `grafana`.`user` AS updated_user ON p.updated_by = updated_user.id diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list_page_two.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list_page_two.sql index 18bbf4db6b6..91088bec273 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list_page_two.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list_page_two.sql @@ -1,7 +1,7 @@ SELECT p.id, p.uid, p.folder_uid, p.created, created_user.uid as created_by, p.updated, updated_user.uid as updated_by, - p.name, p.type, p.description, p.model + p.name, p.type, p.description, p.model, p.version FROM `grafana`.`library_element` as p LEFT OUTER JOIN `grafana`.`user` AS created_user ON p.created_by = created_user.id LEFT OUTER JOIN `grafana`.`user` AS updated_user ON p.updated_by = updated_user.id diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-get_uid.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-get_uid.sql index d20e1917b8e..cb1ba26e303 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-get_uid.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-get_uid.sql @@ -1,7 +1,7 @@ SELECT p.id, p.uid, p.folder_uid, p.created, created_user.uid as created_by, p.updated, updated_user.uid as updated_by, - p.name, p.type, p.description, p.model + p.name, p.type, p.description, p.model, p.version FROM "grafana"."library_element" as p LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list.sql index 714c3ade2e6..16b08992219 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list.sql @@ -1,7 +1,7 @@ SELECT p.id, p.uid, p.folder_uid, p.created, created_user.uid as created_by, p.updated, updated_user.uid as updated_by, - p.name, p.type, p.description, p.model + p.name, p.type, p.description, p.model, p.version FROM "grafana"."library_element" as p LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list_page_two.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list_page_two.sql index 8b00556968c..ad227e7feb4 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list_page_two.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list_page_two.sql @@ -1,7 +1,7 @@ SELECT p.id, p.uid, p.folder_uid, p.created, created_user.uid as created_by, p.updated, updated_user.uid as updated_by, - p.name, p.type, p.description, p.model + p.name, p.type, p.description, p.model, p.version FROM "grafana"."library_element" as p LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-get_uid.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-get_uid.sql index d20e1917b8e..cb1ba26e303 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-get_uid.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-get_uid.sql @@ -1,7 +1,7 @@ SELECT p.id, p.uid, p.folder_uid, p.created, created_user.uid as created_by, p.updated, updated_user.uid as updated_by, - p.name, p.type, p.description, p.model + p.name, p.type, p.description, p.model, p.version FROM "grafana"."library_element" as p LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list.sql index 714c3ade2e6..16b08992219 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list.sql @@ -1,7 +1,7 @@ SELECT p.id, p.uid, p.folder_uid, p.created, created_user.uid as created_by, p.updated, updated_user.uid as updated_by, - p.name, p.type, p.description, p.model + p.name, p.type, p.description, p.model, p.version FROM "grafana"."library_element" as p LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list_page_two.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list_page_two.sql index 8b00556968c..ad227e7feb4 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list_page_two.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list_page_two.sql @@ -1,7 +1,7 @@ SELECT p.id, p.uid, p.folder_uid, p.created, created_user.uid as created_by, p.updated, updated_user.uid as updated_by, - p.name, p.type, p.description, p.model + p.name, p.type, p.description, p.model, p.version FROM "grafana"."library_element" as p LEFT OUTER JOIN "grafana"."user" AS created_user ON p.created_by = created_user.id LEFT OUTER JOIN "grafana"."user" AS updated_user ON p.updated_by = updated_user.id diff --git a/pkg/registry/apis/dashboard/libary_panel.go b/pkg/registry/apis/dashboard/libary_panel.go index 9e560117c33..c22594ff590 100644 --- a/pkg/registry/apis/dashboard/libary_panel.go +++ b/pkg/registry/apis/dashboard/libary_panel.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" apierrors "k8s.io/apimachinery/pkg/api/errors" ) @@ -21,12 +22,16 @@ var ( _ rest.SingularNameProvider = (*LibraryPanelStore)(nil) _ rest.Getter = (*LibraryPanelStore)(nil) _ rest.Lister = (*LibraryPanelStore)(nil) + _ rest.Creater = (*LibraryPanelStore)(nil) + _ rest.GracefulDeleter = (*LibraryPanelStore)(nil) + _ rest.Updater = (*LibraryPanelStore)(nil) _ rest.Storage = (*LibraryPanelStore)(nil) ) type LibraryPanelStore struct { - Access legacy.DashboardAccess - ResourceInfo utils.ResourceInfo + Access legacy.DashboardAccess + ResourceInfo utils.ResourceInfo + AccessControl accesscontrol.AccessControl } func (s *LibraryPanelStore) New() runtime.Object { @@ -51,6 +56,22 @@ func (s *LibraryPanelStore) ConvertToTable(ctx context.Context, object runtime.O return s.ResourceInfo.TableConverter().ConvertToTable(ctx, object, tableOptions) } +func (s *LibraryPanelStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + return nil, fmt.Errorf("method not yet implemented") +} + +func (s *LibraryPanelStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + return nil, false, fmt.Errorf("method not yet implemented") +} + +func (s *LibraryPanelStore) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + return nil, false, fmt.Errorf("method not yet implemented") +} + +func (s *LibraryPanelStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + return nil, apierrors.NewMethodNotSupported(s.ResourceInfo.GroupResource(), "deletecollection") +} + func (s *LibraryPanelStore) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { if options.ResourceVersion != "" { return nil, apierrors.NewBadRequest("List with explicit resourceVersion is not supported with this storage backend") diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index b468d435feb..6ca9b87fbc4 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -142,7 +142,7 @@ func RegisterAPIService( folderClient: folderClient, legacy: &DashboardStorage{ - Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter), + Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, accessControl), DashboardService: dashboardService, }, reg: reg, @@ -519,9 +519,21 @@ func (b *DashboardsAPIBuilder) storageForVersion( // Expose read only library panels if libraryPanels != nil { - storage[libraryPanels.StoragePath()] = &LibraryPanelStore{ - Access: b.legacy.Access, - ResourceInfo: *libraryPanels, + legacyLibraryStore := &LibraryPanelStore{ + Access: b.legacy.Access, + ResourceInfo: *libraryPanels, + AccessControl: b.accessControl, + } + + unifiedLibraryStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, *libraryPanels, opts.OptsGetter) + if err != nil { + return err + } + + libraryGr := libraryPanels.GroupResource() + storage[libraryPanels.StoragePath()], err = opts.DualWriteBuilder(libraryGr, legacyLibraryStore, unifiedLibraryStore) + if err != nil { + return err } } diff --git a/pkg/registry/apis/secret/contracts/data_key.go b/pkg/registry/apis/secret/contracts/data_key.go new file mode 100644 index 00000000000..bac21460d18 --- /dev/null +++ b/pkg/registry/apis/secret/contracts/data_key.go @@ -0,0 +1,35 @@ +package contracts + +import ( + "context" + "errors" + "time" + + "github.com/grafana/grafana/pkg/registry/apis/secret/encryption" +) + +var ( + ErrDataKeyNotFound = errors.New("data key not found") +) + +// SecretDataKey does not have a mirrored K8s resource +type SecretDataKey struct { + UID string + Active bool + Namespace string + Label string + Provider encryption.ProviderID + EncryptedData []byte + Created time.Time + Updated time.Time +} + +// DataKeyStorage is the interface for wiring and dependency injection. +type DataKeyStorage interface { + CreateDataKey(ctx context.Context, dataKey *SecretDataKey) error + GetDataKey(ctx context.Context, namespace, uid string) (*SecretDataKey, error) + GetCurrentDataKey(ctx context.Context, namespace, label string) (*SecretDataKey, error) + GetAllDataKeys(ctx context.Context, namespace string) ([]*SecretDataKey, error) + DisableDataKeys(ctx context.Context, namespace string) error + DeleteDataKey(ctx context.Context, namespace, uid string) error +} diff --git a/pkg/registry/apis/secret/encryption/secrets.go b/pkg/registry/apis/secret/encryption/secrets.go index d8f89151a75..d1cfd87286a 100644 --- a/pkg/registry/apis/secret/encryption/secrets.go +++ b/pkg/registry/apis/secret/encryption/secrets.go @@ -1,3 +1,26 @@ package encryption +import ( + "fmt" + "strings" + "time" +) + const UsageInsightsPrefix = "secrets_manager" + +type ProviderID string + +func (id ProviderID) Kind() (string, error) { + idStr := string(id) + + parts := strings.SplitN(idStr, ".", 2) + if len(parts) != 2 { + return "", fmt.Errorf("malformatted provider identifier %s: expected format .", idStr) + } + + return parts[0], nil +} + +func KeyLabel(providerID ProviderID) string { + return fmt.Sprintf("%s@%s", time.Now().Format("2006-01-02"), providerID) +} diff --git a/pkg/server/distributor_test.go b/pkg/server/distributor_test.go new file mode 100644 index 00000000000..6468d4f0f3e --- /dev/null +++ b/pkg/server/distributor_test.go @@ -0,0 +1,415 @@ +package server + +import ( + "context" + "errors" + "fmt" + "math/rand" + "net" + "net/http" + "strconv" + "sync" + "testing" + "time" + + claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/api" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/modules" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/storage/unified/search" + "github.com/grafana/grafana/pkg/storage/unified/sql" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace/noop" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/metadata" + "k8s.io/component-base/metrics/legacyregistry" +) + +var ( + testIndexFileThreshold = 200 // just needs to be bigger than max playlist number, so the indexer don't use the filesystem + namespaceCount = 250 // how many stacks we're simulating + maxPlaylistPerNamespace = 50 // upper bound on how many playlists we will seed to each stack. +) + +//nolint:gocyclo +func TestIntegrationDistributor(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dbType := sqlutil.GetTestDBType() + if dbType != "mysql" { + t.Skip() + } + + // this next line is to avoid double registration when registering sprinkles metrics + legacyregistry.Registerer = func() prometheus.Registerer { return prometheus.NewRegistry() } + + db, err := sqlutil.GetTestDB(dbType) + require.NoError(t, err) + + testNamespaces := make([]string, 0, namespaceCount) + for i := range namespaceCount { + testNamespaces = append(testNamespaces, "stacks-"+strconv.Itoa(i)) + } + + baselineServer := createBaselineServer(t, dbType, db.ConnStr, testNamespaces) + + testServers := make([]testModuleServer, 0, 2) + memberlistPort := getRandomPort() + distributorServer := initDistributorServerForTest(t, memberlistPort) + testServers = append(testServers, createStorageServerApi(t, 1, dbType, db.ConnStr, memberlistPort)) + testServers = append(testServers, createStorageServerApi(t, 2, dbType, db.ConnStr, memberlistPort)) + + startAndWaitHealthy(t, distributorServer) + + for _, testServer := range testServers { + startAndWaitHealthy(t, testServer) + } + + t.Run("should expose ring endpoint", func(t *testing.T) { + client := http.Client{} + res, err := client.Get(fmt.Sprintf("http://localhost:%s/ring", distributorServer.httpPort)) + require.NoError(t, err) + + require.Equal(t, res.StatusCode, http.StatusOK) + _ = res.Body.Close() + }) + + t.Run("should expose memberlist endpoint", func(t *testing.T) { + client := http.Client{} + res, err := client.Get(fmt.Sprintf("http://localhost:%s/memberlist", distributorServer.httpPort)) + require.NoError(t, err) + + require.Equal(t, res.StatusCode, http.StatusOK) + _ = res.Body.Close() + }) + + t.Run("GetStats", func(t *testing.T) { + instanceResponseCount := make(map[string]int) + + for _, ns := range testNamespaces { + req := &resourcepb.ResourceStatsRequest{ + Namespace: ns, + } + baselineRes := getBaselineResponse(t, req, baselineServer.GetStats) + distributorRes := getDistributorResponse(t, req, distributorServer.resourceClient.GetStats, instanceResponseCount) + require.Equal(t, baselineRes.String(), distributorRes.String()) + } + + for instance, count := range instanceResponseCount { + require.GreaterOrEqual(t, count, 1, "instance did not get any traffic: "+instance) + } + }) + + t.Run("CountManagedObjects", func(t *testing.T) { + instanceResponseCount := make(map[string]int) + + for _, ns := range testNamespaces { + req := &resourcepb.CountManagedObjectsRequest{ + Namespace: ns, + } + baselineRes := getBaselineResponse(t, req, baselineServer.CountManagedObjects) + distributorRes := getDistributorResponse(t, req, distributorServer.resourceClient.CountManagedObjects, instanceResponseCount) + require.Equal(t, baselineRes.String(), distributorRes.String()) + } + + for instance, count := range instanceResponseCount { + require.GreaterOrEqual(t, count, 1, "instance did not get any traffic: "+instance) + } + }) + + t.Run("ListManagedObjects", func(t *testing.T) { + instanceResponseCount := make(map[string]int) + + for _, ns := range testNamespaces { + req := &resourcepb.ListManagedObjectsRequest{ + Namespace: ns, + } + baselineRes := getBaselineResponse(t, req, baselineServer.ListManagedObjects) + distributorRes := getDistributorResponse(t, req, distributorServer.resourceClient.ListManagedObjects, instanceResponseCount) + require.Equal(t, baselineRes.String(), distributorRes.String()) + } + + for instance, count := range instanceResponseCount { + require.GreaterOrEqual(t, count, 1, "instance did not get any traffic: "+instance) + } + }) + + t.Run("Search", func(t *testing.T) { + instanceResponseCount := make(map[string]int) + + for _, ns := range testNamespaces { + req := &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "aoeuaeou", + Namespace: ns, + }, + }, + } + baselineRes := getBaselineResponse(t, req, baselineServer.Search) + distributorRes := getDistributorResponse(t, req, distributorServer.resourceClient.Search, instanceResponseCount) + // sometimes the querycost is different between the two. Happens randomly and we don't have control over it + // as it comes from bleve. Since we are not testing search functionality we hard-set this to 0 to avoid + // flaky tests + distributorRes.QueryCost = 0 + baselineRes.QueryCost = 0 + require.Equal(t, baselineRes.String(), distributorRes.String()) + } + + for instance, count := range instanceResponseCount { + require.GreaterOrEqual(t, count, 1, "instance did not get any traffic: "+instance) + } + }) + + var wg sync.WaitGroup + for _, testServer := range testServers { + wg.Add(1) + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := testServer.server.Shutdown(ctx, "tests are done"); err != nil { + require.NoError(t, err) + } + }() + } + wg.Wait() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := distributorServer.server.Shutdown(ctx, "tests are done"); err != nil { + require.NoError(t, err) + } +} + +func getBaselineResponse[Req any, Resp any](t *testing.T, req *Req, fn func(ctx context.Context, req *Req) (*Resp, error)) *Resp { + ctx := context.Background() + baselineRes, err := fn(ctx, req) + require.NoError(t, err) + return baselineRes +} + +func getDistributorResponse[Req any, Resp any](t *testing.T, req *Req, fn func(ctx context.Context, req *Req, opts ...grpc.CallOption) (*Resp, error), instanceResponseCount map[string]int) *Resp { + ctx := identity.WithServiceIdentityContext(context.Background(), 1) + var header metadata.MD + res, err := fn(ctx, req, grpc.Header(&header)) + require.NoError(t, err) + + instance := header.Get("proxied-instance-id") + if len(instance) != 1 { + t.Fatal("received invalid proxied-instance-id header", instance) + } + + instanceResponseCount[instance[0]] += 1 + return res +} + +func startAndWaitHealthy(t *testing.T, testServer testModuleServer) { + go func() { + // this next line is to avoid double registration, as both InitializeDocumentBuilders as well as ProvideUnifiedStorageGrpcService + // are hard-coded to use prometheus.DefaultRegisterer + // the alternative would be to get the registry from wire, in which case the tests would receive a new + // registry automatically, but that _may_ change metric names + // We can remove this once that's fixed + prometheus.DefaultRegisterer = prometheus.NewRegistry() + if err := testServer.server.Run(); err != nil && !errors.Is(err, context.Canceled) { + require.NoError(t, err) + } + }() + + deadline := time.Now().Add(20 * time.Second) + for { + conn, err := net.DialTimeout("tcp", testServer.grpcAddress, 1*time.Second) + if err == nil { + _ = conn.Close() + break + } + + if time.Now().After(deadline) { + t.Fatal("server failed to become ready: ", testServer.id) + } + + time.Sleep(1 * time.Second) + } + + res, err := testServer.healthClient.Check(context.Background(), &grpc_health_v1.HealthCheckRequest{}) + require.NoError(t, err) + require.Equal(t, res.Status, grpc_health_v1.HealthCheckResponse_SERVING) +} + +type testModuleServer struct { + server *ModuleServer + healthClient grpc_health_v1.HealthClient + resourceClient resource.ResourceClient + id string + grpcAddress string + httpPort string +} + +func getRandomPort() int { + ln, _ := net.Listen("tcp", "127.0.0.1:0") + _ = ln.Close() + return ln.Addr().(*net.TCPAddr).Port +} + +func initDistributorServerForTest(t *testing.T, memberlistPort int) testModuleServer { + cfg := setting.NewCfg() + cfg.HTTPPort = strconv.Itoa(getRandomPort()) + cfg.GRPCServer.Network = "tcp" + cfg.GRPCServer.Address = "127.0.0.1:" + strconv.Itoa(getRandomPort()) + cfg.EnableSharding = true + cfg.MemberlistBindAddr = "127.0.0.1" + cfg.MemberlistJoinMember = "127.0.0.1:" + strconv.Itoa(memberlistPort) + cfg.MemberlistAdvertiseAddr = "127.0.0.1" + cfg.MemberlistAdvertisePort = memberlistPort + cfg.Target = []string{modules.Distributor} + cfg.InstanceID = "distributor" // does nothing for the distributor but may be useful to debug tests + + conn, err := grpc.NewClient(cfg.GRPCServer.Address, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + client := resource.NewLegacyResourceClient(conn, conn) + + server := initModuleServerForTest(t, cfg, Options{}, api.ServerOptions{}) + + server.resourceClient = client + + return server +} + +func createStorageServerApi(t *testing.T, instanceId int, dbType, dbConnStr string, memberlistPort int) testModuleServer { + cfg := setting.NewCfg() + section, err := cfg.Raw.NewSection("database") + require.NoError(t, err) + + _, err = section.NewKey("type", dbType) + require.NoError(t, err) + _, err = section.NewKey("connection_string", dbConnStr) + require.NoError(t, err) + + cfg.HTTPPort = strconv.Itoa(getRandomPort()) + cfg.GRPCServer.Network = "tcp" + cfg.GRPCServer.Address = "127.0.0.1:" + strconv.Itoa(getRandomPort()) + cfg.EnableSharding = true + cfg.MemberlistBindAddr = "127.0.0.1" + cfg.MemberlistJoinMember = "127.0.0.1:" + strconv.Itoa(memberlistPort) + cfg.MemberlistAdvertiseAddr = "127.0.0.1" + cfg.MemberlistAdvertisePort = getRandomPort() + cfg.InstanceID = "instance-" + strconv.Itoa(instanceId) + cfg.IndexPath = t.TempDir() + cfg.InstanceID + cfg.IndexFileThreshold = testIndexFileThreshold + cfg.Target = []string{modules.StorageServer} + + return initModuleServerForTest(t, cfg, Options{}, api.ServerOptions{}) +} + +func initModuleServerForTest( + t *testing.T, + cfg *setting.Cfg, + opts Options, + apiOpts api.ServerOptions, +) testModuleServer { + ms, err := NewModule(opts, apiOpts, featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearch), cfg, nil, nil, prometheus.NewRegistry(), prometheus.DefaultGatherer, nil) + require.NoError(t, err) + + conn, err := grpc.NewClient(cfg.GRPCServer.Address, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + healthClient := grpc_health_v1.NewHealthClient(conn) + + return testModuleServer{server: ms, grpcAddress: cfg.GRPCServer.Address, httpPort: cfg.HTTPPort, healthClient: healthClient, id: cfg.InstanceID} +} + +func createBaselineServer(t *testing.T, dbType, dbConnStr string, testNamespaces []string) resource.ResourceServer { + cfg := setting.NewCfg() + section, err := cfg.Raw.NewSection("database") + require.NoError(t, err) + + _, err = section.NewKey("type", dbType) + require.NoError(t, err) + _, err = section.NewKey("connection_string", dbConnStr) + require.NoError(t, err) + cfg.IndexPath = t.TempDir() + cfg.IndexFileThreshold = testIndexFileThreshold + features := featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearch) + docBuilders, err := InitializeDocumentBuilders(cfg) + require.NoError(t, err) + tracer := noop.NewTracerProvider().Tracer("test-tracer") + require.NoError(t, err) + searchOpts, err := search.NewSearchOptions(features, cfg, tracer, docBuilders, nil) + require.NoError(t, err) + server, err := sql.NewResourceServer(nil, cfg, tracer, nil, nil, searchOpts, nil, nil, features) + require.NoError(t, err) + + testUserA := &identity.StaticRequester{ + Type: claims.TypeUser, + Login: "testuser", + UserID: 123, + UserUID: "u123", + OrgRole: identity.RoleAdmin, + IsGrafanaAdmin: true, // can do anything + } + ctx := claims.WithAuthInfo(context.Background(), testUserA) + + for _, ns := range testNamespaces { + for range rand.Intn(maxPlaylistPerNamespace) + 1 { + _, err = server.Create(ctx, generatePlaylistPayload(ns)) + require.NoError(t, err) + } + } + + return server +} + +var counter int + +func generatePlaylistPayload(ns string) *resourcepb.CreateRequest { + name := "playlist" + strconv.Itoa(counter) + counter += 1 + return &resourcepb.CreateRequest{ + Value: []byte(fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "%s", + "uid": "xyz", + "namespace": "%s", + "annotations": { + "grafana.app/repoName": "elsewhere", + "grafana.app/repoPath": "path/to/item", + "grafana.app/repoTimestamp": "2024-02-02T00:00:00Z" + } + }, + "spec": { + "title": "hello", + "interval": "5m", + "items": [ + { + "type": "dashboard_by_uid", + "value": "vmie2cmWz" + } + ] + } + }`, name, ns)), + Key: &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "aoeuaeou", + Namespace: ns, + Name: name, + }, + } +} diff --git a/pkg/server/memberlist.go b/pkg/server/memberlist.go index 8f1eb6bdb5c..e17d9bba267 100644 --- a/pkg/server/memberlist.go +++ b/pkg/server/memberlist.go @@ -51,6 +51,8 @@ func toMemberlistConfig(cfg *setting.Cfg) *memberlist.KVConfig { if cfg.MemberlistAdvertiseAddr != "" { memberlistKVcfg.AdvertiseAddr = cfg.MemberlistAdvertiseAddr } + memberlistKVcfg.AdvertisePort = cfg.MemberlistAdvertisePort + memberlistKVcfg.TCPTransport.BindPort = cfg.MemberlistAdvertisePort memberlistKVcfg.JoinMembers = []string{cfg.MemberlistJoinMember} return memberlistKVcfg diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 57c5c475717..2b8f7352f7e 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -423,6 +423,7 @@ var wireBasicSet = wire.NewSet( secretmetadata.ProvideSecureValueMetadataStorage, secretmetadata.ProvideKeeperMetadataStorage, secretmetadata.ProvideOutboxQueue, + secretencryption.ProvideDataKeyStorage, secretencryption.ProvideEncryptedValueStorage, secretmigrator.NewWithEngine, secretdatabase.ProvideDatabase, diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index 435123ebd91..e73a87f1d4f 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -49,7 +49,7 @@ func setupTestEnv(t testing.TB) *Service { return ac } -func TestUsageMetrics(t *testing.T) { +func TestIntegrationUsageMetrics(t *testing.T) { tests := []struct { name string expectedValue int @@ -80,7 +80,7 @@ func TestUsageMetrics(t *testing.T) { } } -func TestService_DeclareFixedRoles(t *testing.T) { +func TestIntegrationService_DeclareFixedRoles(t *testing.T) { tests := []struct { name string registrations []accesscontrol.RoleRegistration @@ -165,7 +165,7 @@ func TestService_DeclareFixedRoles(t *testing.T) { } } -func TestService_DeclarePluginRoles(t *testing.T) { +func TestIntegrationService_DeclarePluginRoles(t *testing.T) { tests := []struct { name string pluginID string @@ -278,7 +278,7 @@ func TestService_DeclarePluginRoles(t *testing.T) { } } -func TestService_RegisterFixedRoles(t *testing.T) { +func TestIntegrationService_RegisterFixedRoles(t *testing.T) { tests := []struct { name string token licensing.Licensing @@ -380,7 +380,7 @@ func TestService_RegisterFixedRoles(t *testing.T) { } } -func TestService_SearchUsersPermissions(t *testing.T) { +func TestIntegrationService_SearchUsersPermissions(t *testing.T) { searchOption := accesscontrol.SearchOptions{ActionPrefix: "teams"} ctx := context.Background() listAllPerms := map[string][]string{accesscontrol.ActionUsersPermissionsRead: {"users:*"}} @@ -601,7 +601,7 @@ func TestService_SearchUsersPermissions(t *testing.T) { } } -func TestService_SearchUserPermissions(t *testing.T) { +func TestIntegrationService_SearchUserPermissions(t *testing.T) { ctx := context.Background() tests := []struct { name string @@ -832,7 +832,7 @@ func TestService_SearchUserPermissions(t *testing.T) { } } -func TestService_SaveExternalServiceRole(t *testing.T) { +func TestIntegrationService_SaveExternalServiceRole(t *testing.T) { type run struct { cmd accesscontrol.SaveExternalServiceRoleCommand wantErr bool @@ -918,7 +918,7 @@ func TestService_SaveExternalServiceRole(t *testing.T) { } } -func TestService_DeleteExternalServiceRole(t *testing.T) { +func TestIntegrationService_DeleteExternalServiceRole(t *testing.T) { tests := []struct { name string initCmd *accesscontrol.SaveExternalServiceRoleCommand @@ -971,7 +971,7 @@ func TestService_DeleteExternalServiceRole(t *testing.T) { } } -func TestService_GetRoleByName(t *testing.T) { +func TestIntegrationService_GetRoleByName(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/pkg/services/accesscontrol/database/database_test.go b/pkg/services/accesscontrol/database/database_test.go index 37731ea8aee..f8321d492f9 100644 --- a/pkg/services/accesscontrol/database/database_test.go +++ b/pkg/services/accesscontrol/database/database_test.go @@ -44,7 +44,7 @@ type getUserPermissionsTestCase struct { policyCount int } -func TestAccessControlStore_GetUserPermissions(t *testing.T) { +func TestIntegrationAccessControlStore_GetUserPermissions(t *testing.T) { tests := []getUserPermissionsTestCase{ { desc: "should successfully get user, team and builtin permissions", @@ -158,7 +158,7 @@ type getTeamsPermissionsTestCase struct { expected int } -func TestAccessControlStore_GetTeamsPermissions(t *testing.T) { +func TestIntegrationAccessControlStore_GetTeamsPermissions(t *testing.T) { tests := []getTeamsPermissionsTestCase{ { desc: "should successfully get team permissions", @@ -230,7 +230,7 @@ func TestAccessControlStore_GetTeamsPermissions(t *testing.T) { } } -func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { +func TestIntegrationAccessControlStore_DeleteUserPermissions(t *testing.T) { t.Run("expect permissions in all orgs to be deleted", func(t *testing.T) { store, permissionsStore, usrSvc, teamSvc, _, sql := setupTestEnv(t) user, _ := createUserAndTeam(t, sql, usrSvc, teamSvc, 1) @@ -312,7 +312,7 @@ func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { }) } -func TestAccessControlStore_DeleteTeamPermissions(t *testing.T) { +func TestIntegrationAccessControlStore_DeleteTeamPermissions(t *testing.T) { t.Run("expect permissions related to team to be deleted", func(t *testing.T) { store, permissionsStore, usrSvc, teamSvc, _, sql := setupTestEnv(t) user, team := createUserAndTeam(t, sql, usrSvc, teamSvc, 1) @@ -767,7 +767,7 @@ func TestIntegrationAccessControlStore_SearchUsersPermissions(t *testing.T) { } } -func TestAccessControlStore_GetUsersBasicRoles(t *testing.T) { +func TestIntegrationAccessControlStore_GetUsersBasicRoles(t *testing.T) { ctx := context.Background() tests := []struct { name string diff --git a/pkg/services/accesscontrol/database/externalservices_test.go b/pkg/services/accesscontrol/database/externalservices_test.go index e346f3be1d8..4f3f505bcb3 100644 --- a/pkg/services/accesscontrol/database/externalservices_test.go +++ b/pkg/services/accesscontrol/database/externalservices_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestAccessControlStore_SaveExternalServiceRole(t *testing.T) { +func TestIntegrationAccessControlStore_SaveExternalServiceRole(t *testing.T) { type run struct { cmd accesscontrol.SaveExternalServiceRoleCommand wantErr bool @@ -152,7 +152,7 @@ func TestAccessControlStore_SaveExternalServiceRole(t *testing.T) { } } -func TestAccessControlStore_DeleteExternalServiceRole(t *testing.T) { +func TestIntegrationAccessControlStore_DeleteExternalServiceRole(t *testing.T) { extID := "app1" tests := []struct { name string diff --git a/pkg/services/accesscontrol/filter_test.go b/pkg/services/accesscontrol/filter_test.go index 480bee31345..d1e2fc452f6 100644 --- a/pkg/services/accesscontrol/filter_test.go +++ b/pkg/services/accesscontrol/filter_test.go @@ -32,7 +32,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestFilter_Datasources(t *testing.T) { +func TestIntegrationFilter_Datasources(t *testing.T) { tests := []filterDatasourcesTestCase{ { desc: "expect all data sources to be returned", diff --git a/pkg/services/accesscontrol/resourcepermissions/api_test.go b/pkg/services/accesscontrol/resourcepermissions/api_test.go index 65adcc5ecc4..a80dfc0afb4 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/api_test.go @@ -30,7 +30,7 @@ type getDescriptionTestCase struct { expectedStatus int } -func TestApi_getDescription(t *testing.T) { +func TestIntegrationApi_getDescription(t *testing.T) { tests := []getDescriptionTestCase{ { desc: "should return description", @@ -135,7 +135,7 @@ type getPermissionsTestCase struct { expectedStatus int } -func TestApi_getPermissions(t *testing.T) { +func TestIntegrationApi_getPermissions(t *testing.T) { tests := []getPermissionsTestCase{ { desc: "expect permissions for resource with id 1", @@ -181,7 +181,7 @@ type setBuiltinPermissionTestCase struct { permissions []accesscontrol.Permission } -func TestApi_setBuiltinRolePermission(t *testing.T) { +func TestIntegrationApi_setBuiltinRolePermission(t *testing.T) { tests := []setBuiltinPermissionTestCase{ { desc: "should set Edit permission for Viewer", @@ -260,7 +260,7 @@ type setTeamPermissionTestCase struct { byUID bool } -func TestApi_setTeamPermission(t *testing.T) { +func TestIntegrationApi_setTeamPermission(t *testing.T) { tests := []setTeamPermissionTestCase{ { desc: "should set Edit permission for team 1", @@ -367,7 +367,7 @@ type setUserPermissionTestCase struct { permissions []accesscontrol.Permission } -func TestApi_setUserPermission(t *testing.T) { +func TestIntegrationApi_setUserPermission(t *testing.T) { tests := []setUserPermissionTestCase{ { desc: "should set Edit permission for user 1", @@ -442,7 +442,7 @@ func TestApi_setUserPermission(t *testing.T) { } } -func TestApi_setUserPermissionForTeams(t *testing.T) { +func TestIntegrationApi_setUserPermissionForTeams(t *testing.T) { type setUserPermissionForTeamsTestCase struct { setUserPermissionTestCase teamCmd *team.CreateTeamCommand diff --git a/pkg/services/accesscontrol/resourcepermissions/service_test.go b/pkg/services/accesscontrol/resourcepermissions/service_test.go index c8fbc0532e3..677ebde6095 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/service_test.go @@ -31,7 +31,7 @@ type setUserPermissionTest struct { callHook bool } -func TestService_SetUserPermission(t *testing.T) { +func TestIntegrationService_SetUserPermission(t *testing.T) { tests := []setUserPermissionTest{ { desc: "should call hook when updating user permissions", @@ -75,7 +75,7 @@ type setTeamPermissionTest struct { callHook bool } -func TestService_SetTeamPermission(t *testing.T) { +func TestIntegrationService_SetTeamPermission(t *testing.T) { tests := []setTeamPermissionTest{ { desc: "should call hook when updating user permissions", @@ -124,7 +124,7 @@ type setBuiltInRolePermissionTest struct { callHook bool } -func TestService_SetBuiltInRolePermission(t *testing.T) { +func TestIntegrationService_SetBuiltInRolePermission(t *testing.T) { tests := []setBuiltInRolePermissionTest{ { desc: "should call hook when updating user permissions", @@ -166,7 +166,7 @@ type setPermissionsTest struct { expectErr bool } -func TestService_SetPermissions(t *testing.T) { +func TestIntegrationService_SetPermissions(t *testing.T) { tests := []setPermissionsTest{ { desc: "should set all permissions", @@ -235,7 +235,7 @@ func TestService_SetPermissions(t *testing.T) { } } -func TestService_RegisterActionSets(t *testing.T) { +func TestIntegrationService_RegisterActionSets(t *testing.T) { type registerActionSetsTest struct { desc string options Options diff --git a/pkg/services/auth/authimpl/auth_token_test.go b/pkg/services/auth/authimpl/auth_token_test.go index df585feeeb5..e4e183ceae7 100644 --- a/pkg/services/auth/authimpl/auth_token_test.go +++ b/pkg/services/auth/authimpl/auth_token_test.go @@ -823,7 +823,7 @@ func TestIntegrationTokenCount(t *testing.T) { require.Equal(t, int64(0), count) } -func TestRevokeAllUserTokens(t *testing.T) { +func TestIntegrationRevokeAllUserTokens(t *testing.T) { t.Run("should not fail if the external sessions could not be removed", func(t *testing.T) { ctx := createTestContext(t) usr := &user.User{ID: int64(10)} @@ -856,7 +856,7 @@ func TestRevokeAllUserTokens(t *testing.T) { }) } -func TestRevokeToken(t *testing.T) { +func TestIntegrationRevokeToken(t *testing.T) { t.Run("should not fail if the external sessions could not be removed", func(t *testing.T) { ctx := createTestContext(t) usr := &user.User{ID: int64(10)} @@ -887,7 +887,7 @@ func TestRevokeToken(t *testing.T) { }) } -func TestBatchRevokeAllUserTokens(t *testing.T) { +func TestIntegrationBatchRevokeAllUserTokens(t *testing.T) { t.Run("should not fail if the external sessions could not be removed", func(t *testing.T) { ctx := createTestContext(t) userIds := []int64{1, 2, 3} diff --git a/pkg/services/auth/jwt/auth_test.go b/pkg/services/auth/jwt/auth_test.go index 73d2106b72d..712232cd9d1 100644 --- a/pkg/services/auth/jwt/auth_test.go +++ b/pkg/services/auth/jwt/auth_test.go @@ -44,7 +44,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestVerifyUsingPKIXPublicKeyFile(t *testing.T) { +func TestIntegrationVerifyUsingPKIXPublicKeyFile(t *testing.T) { key := rsaKeys[0] unknownKey := rsaKeys[1] @@ -79,7 +79,7 @@ func TestVerifyUsingPKIXPublicKeyFile(t *testing.T) { }) } -func TestVerifyUsingJWKSetFile(t *testing.T) { +func TestIntegrationVerifyUsingJWKSetFile(t *testing.T) { configure := func(t *testing.T, cfg *setting.Cfg) { t.Helper() @@ -118,7 +118,7 @@ func TestVerifyUsingJWKSetFile(t *testing.T) { }, configure) } -func TestVerifyUsingJWKSetURL(t *testing.T) { +func TestIntegrationVerifyUsingJWKSetURL(t *testing.T) { t.Run("should refuse to start with non-https URL", func(t *testing.T) { var err error @@ -160,7 +160,7 @@ func TestVerifyUsingJWKSetURL(t *testing.T) { }) } -func TestCachingJWKHTTPResponse(t *testing.T) { +func TestIntegrationCachingJWKHTTPResponse(t *testing.T) { jwkCachingScenario(t, "caches the jwk response", func(t *testing.T, sc cachingScenarioContext) { for i := 0; i < 5; i++ { token := sign(t, &jwKeys[0], jwt.Claims{Subject: subject}, nil) @@ -200,7 +200,7 @@ func TestCachingJWKHTTPResponse(t *testing.T) { }) } -func TestSignatureWithNoneAlgorithm(t *testing.T) { +func TestIntegrationSignatureWithNoneAlgorithm(t *testing.T) { scenario(t, "rejects a token signed with \"none\" algorithm", func(t *testing.T, sc scenarioContext) { token := signNone(t, jwt.Claims{Subject: "foo"}) _, err := sc.authJWTSvc.Verify(sc.ctx, token) @@ -208,7 +208,7 @@ func TestSignatureWithNoneAlgorithm(t *testing.T) { }, configurePKIXPublicKeyFile) } -func TestClaimValidation(t *testing.T) { +func TestIntegrationClaimValidation(t *testing.T) { key := rsaKeys[0] scenario(t, "validates iss field for equality", func(t *testing.T, sc scenarioContext) { @@ -368,7 +368,7 @@ func jwkCachingScenario(t *testing.T, desc string, fn cachingScenarioFunc, cbs . }) } -func TestBase64Paddings(t *testing.T) { +func TestIntegrationBase64Paddings(t *testing.T) { key := rsaKeys[0] scenario(t, "verifies a token with base64 padding (non compliant rfc7515#section-2 but accepted)", func(t *testing.T, sc scenarioContext) { diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index c85c4c03648..0767ecf6734 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -2643,7 +2643,7 @@ func TestCleanUpDashboard(t *testing.T) { } } -func TestK8sDashboardCleanupJob(t *testing.T) { +func TestIntegrationK8sDashboardCleanupJob(t *testing.T) { tests := []struct { name string featureEnabled bool diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go index bdfc8b0d8d8..ac364c36acb 100644 --- a/pkg/services/dashboardsnapshots/service/service_test.go +++ b/pkg/services/dashboardsnapshots/service/service_test.go @@ -38,7 +38,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestDashboardSnapshotsService(t *testing.T) { +func TestIntegrationDashboardSnapshotsService(t *testing.T) { sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() dsStore := dashsnapdb.ProvideStore(sqlStore, cfg) @@ -97,7 +97,7 @@ func TestDashboardSnapshotsService(t *testing.T) { }) } -func TestValidateDashboardExists(t *testing.T) { +func TestIntegrationValidateDashboardExists(t *testing.T) { sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() dsStore := dashsnapdb.ProvideStore(sqlStore, cfg) diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index 319f039814e..138b88bfa7b 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -63,7 +63,7 @@ func (d *dataSourceMockRetriever) GetDataSource(ctx context.Context, query *data return nil, datasources.ErrDataSourceNotFound } -func TestService_AddDataSource(t *testing.T) { +func TestIntegrationService_AddDataSource(t *testing.T) { t.Run("should not fail if the plugin is not installed", func(t *testing.T) { dsService := initDSService(t) dsService.pluginStore = &pluginstore.FakePluginStore{ @@ -354,7 +354,7 @@ func TestService_getAvailableName(t *testing.T) { } } -func TestService_UpdateDataSource(t *testing.T) { +func TestIntegrationService_UpdateDataSource(t *testing.T) { t.Run("should return not found error if datasource not found", func(t *testing.T) { dsService := initDSService(t) @@ -761,7 +761,7 @@ func TestService_UpdateDataSource(t *testing.T) { }) } -func TestService_DeleteDataSource(t *testing.T) { +func TestIntegrationService_DeleteDataSource(t *testing.T) { t.Run("should not return an error if data source doesn't exist", func(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) @@ -1038,7 +1038,7 @@ func TestService_awsServiceNamespace(t *testing.T) { } //nolint:goconst -func TestService_GetHttpTransport(t *testing.T) { +func TestIntegrationService_GetHttpTransport(t *testing.T) { cfg := &setting.Cfg{} t.Run("Should use cached proxy", func(t *testing.T) { @@ -1450,7 +1450,7 @@ func TestService_GetHttpTransport(t *testing.T) { }) } -func TestService_getProxySettings(t *testing.T) { +func TestIntegrationService_getProxySettings(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) @@ -1528,7 +1528,7 @@ func TestService_getProxySettings(t *testing.T) { }) } -func TestService_getTimeout(t *testing.T) { +func TestIntegrationService_getTimeout(t *testing.T) { cfg := &setting.Cfg{} originalTimeout := sdkhttpclient.DefaultTimeoutOptions.Timeout sdkhttpclient.DefaultTimeoutOptions.Timeout = time.Minute @@ -1562,7 +1562,7 @@ func TestService_getTimeout(t *testing.T) { } } -func TestService_GetDecryptedValues(t *testing.T) { +func TestIntegrationService_GetDecryptedValues(t *testing.T) { t.Run("should migrate and retrieve values from secure json data", func(t *testing.T) { ds := &datasources.DataSource{ ID: 1, @@ -1621,7 +1621,7 @@ func TestService_GetDecryptedValues(t *testing.T) { }) } -func TestDataSource_CustomHeaders(t *testing.T) { +func TestIntegrationDataSource_CustomHeaders(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d0b1c6f3c0b..85fc598fad4 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -163,13 +163,6 @@ var ( Expression: "true", // turned on by default AllowSelfServe: true, }, - { - Name: "lokiQuerySplittingConfig", - Description: "Give users the option to configure split durations for Loki queries", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaObservabilityLogsSquad, - }, { Name: "individualCookiePreferences", Description: "Support overriding cookie preferences per user", @@ -262,13 +255,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaFrontendPlatformSquad, }, - { - Name: "lokiPredefinedOperations", - Description: "Adds predefined query operations to Loki query editor", - FrontendOnly: true, - Stage: FeatureStageExperimental, - Owner: grafanaObservabilityLogsSquad, - }, { Name: "pluginsFrontendSandbox", Description: "Enables the plugins frontend sandbox", @@ -570,14 +556,6 @@ var ( Stage: FeatureStagePublicPreview, Owner: awsDatasourcesSquad, }, - { - Name: "lokiStructuredMetadata", - Description: "Enables the loki data source to request structured metadata from the Loki server", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: false, - Owner: grafanaObservabilityLogsSquad, - Expression: "true", - }, { Name: "cachingOptimizeSerializationMemoryUsage", Description: "If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses.", @@ -1771,6 +1749,16 @@ var ( Owner: grafanaPartnerPluginsSquad, Expression: "false", }, + { + Name: "enableAppChromeExtensions", + Description: "Set this to true to enable all app chrome extensions registered by plugins.", + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + HideFromAdminPage: true, + HideFromDocs: true, + FrontendOnly: true, + Expression: "false", // extensions will be disabled by default + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 771c9e751e6..2278cb1fb50 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -19,7 +19,6 @@ editPanelCSVDragAndDrop,experimental,@grafana/dataviz-squad,false,false,true logsContextDatasourceUi,GA,@grafana/observability-logs,false,false,true lokiShardSplitting,experimental,@grafana/observability-logs,false,false,true lokiQuerySplitting,GA,@grafana/observability-logs,false,false,true -lokiQuerySplittingConfig,experimental,@grafana/observability-logs,false,false,true individualCookiePreferences,experimental,@grafana/grafana-backend-group,false,false,false influxdbBackendMigration,GA,@grafana/partner-datasources,false,false,true influxqlStreamingParser,experimental,@grafana/partner-datasources,false,false,false @@ -33,7 +32,6 @@ refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true enableDatagridEditing,preview,@grafana/dataviz-squad,false,false,true extraThemes,experimental,@grafana/grafana-frontend-platform,false,false,true -lokiPredefinedOperations,experimental,@grafana/observability-logs,false,false,true pluginsFrontendSandbox,privatePreview,@grafana/plugins-platform-backend,false,false,false pluginsDetailsRightPanel,GA,@grafana/plugins-platform-backend,false,false,true sqlDatasourceDatabaseSelection,preview,@grafana/oss-big-tent,false,false,true @@ -75,7 +73,6 @@ queryServiceRewrite,experimental,@grafana/grafana-datasources-core-services,fals queryServiceFromUI,experimental,@grafana/grafana-datasources-core-services,false,false,true queryServiceFromExplore,experimental,@grafana/grafana-datasources-core-services,false,false,true cloudWatchBatchQueries,preview,@grafana/aws-datasources,false,false,false -lokiStructuredMetadata,GA,@grafana/observability-logs,false,false,false cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-experience-squad,false,false,false prometheusCodeModeMetricNamesSearch,experimental,@grafana/oss-big-tent,false,false,true addFieldFromCalculationStatFunctions,GA,@grafana/dataviz-squad,false,false,true @@ -231,3 +228,4 @@ alertingImportAlertmanagerAPI,experimental,@grafana/alerting-squad,false,false,f preferLibraryPanelTitle,privatePreview,@grafana/dashboards-squad,false,false,false tabularNumbers,GA,@grafana/grafana-frontend-platform,false,false,false newInfluxDSConfigPageDesign,privatePreview,@grafana/partner-datasources,false,false,false +enableAppChromeExtensions,experimental,@grafana/plugins-platform-backend,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index f86bb736c95..fbd6e7ab88a 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -87,10 +87,6 @@ const ( // Split large interval queries into subqueries with smaller time intervals FlagLokiQuerySplitting = "lokiQuerySplitting" - // FlagLokiQuerySplittingConfig - // Give users the option to configure split durations for Loki queries - FlagLokiQuerySplittingConfig = "lokiQuerySplittingConfig" - // FlagIndividualCookiePreferences // Support overriding cookie preferences per user FlagIndividualCookiePreferences = "individualCookiePreferences" @@ -143,10 +139,6 @@ const ( // Enables extra themes FlagExtraThemes = "extraThemes" - // FlagLokiPredefinedOperations - // Adds predefined query operations to Loki query editor - FlagLokiPredefinedOperations = "lokiPredefinedOperations" - // FlagPluginsFrontendSandbox // Enables the plugins frontend sandbox FlagPluginsFrontendSandbox = "pluginsFrontendSandbox" @@ -311,10 +303,6 @@ const ( // Runs CloudWatch metrics queries as separate batches FlagCloudWatchBatchQueries = "cloudWatchBatchQueries" - // FlagLokiStructuredMetadata - // Enables the loki data source to request structured metadata from the Loki server - FlagLokiStructuredMetadata = "lokiStructuredMetadata" - // FlagCachingOptimizeSerializationMemoryUsage // If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses. FlagCachingOptimizeSerializationMemoryUsage = "cachingOptimizeSerializationMemoryUsage" @@ -934,4 +922,8 @@ const ( // FlagNewInfluxDSConfigPageDesign // Enables new design for the InfluxDB data source configuration page FlagNewInfluxDSConfigPageDesign = "newInfluxDSConfigPageDesign" + + // FlagEnableAppChromeExtensions + // Set this to true to enable all app chrome extensions registered by plugins. + FlagEnableAppChromeExtensions = "enableAppChromeExtensions" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index a9d27431e6b..dda8e2005c4 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -943,6 +943,22 @@ "codeowner": "@grafana/aws-datasources" } }, + { + "metadata": { + "name": "enableAppChromeExtensions", + "resourceVersion": "1750235111726", + "creationTimestamp": "2025-06-18T08:25:11Z" + }, + "spec": { + "description": "Set this to true to enable all app chrome extensions registered by plugins.", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true, + "hideFromAdminPage": true, + "hideFromDocs": true, + "expression": "false" + } + }, { "metadata": { "name": "enableDatagridEditing", @@ -1830,7 +1846,8 @@ "metadata": { "name": "lokiPredefinedOperations", "resourceVersion": "1750434297879", - "creationTimestamp": "2023-06-02T10:52:36Z" + "creationTimestamp": "2023-06-02T10:52:36Z", + "deletionTimestamp": "2025-06-27T08:08:44Z" }, "spec": { "description": "Adds predefined query operations to Loki query editor", @@ -1872,7 +1889,8 @@ "metadata": { "name": "lokiQuerySplittingConfig", "resourceVersion": "1750434297879", - "creationTimestamp": "2023-03-20T15:51:36Z" + "creationTimestamp": "2023-03-20T15:51:36Z", + "deletionTimestamp": "2025-06-27T09:32:43Z" }, "spec": { "description": "Give users the option to configure split durations for Loki queries", @@ -1910,7 +1928,8 @@ "metadata": { "name": "lokiStructuredMetadata", "resourceVersion": "1750434297879", - "creationTimestamp": "2023-11-16T16:06:14Z" + "creationTimestamp": "2023-11-16T16:06:14Z", + "deletionTimestamp": "2025-06-27T09:00:53Z" }, "spec": { "description": "Enables the loki data source to request structured metadata from the Loki server", diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 6aca687b14f..efe3c5914df 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -358,7 +358,7 @@ func TestIntegrationFolderService(t *testing.T) { }) } -func TestIntegrationNestedFolderService(t *testing.T) { +func TestIntegrationNestedFolderServiceBasicOperations(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } @@ -438,7 +438,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, dashSrv, ac, b) require.NoError(t, err) - elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOn, featuresFlagOn, ac, dashSrv) + elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOn, featuresFlagOn, ac, dashSrv, nil, nil) lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOn) require.NoError(t, err) @@ -517,7 +517,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, dashSrv, ac, b) require.NoError(t, err) - elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOff, featuresFlagOff, ac, dashSrv) + elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOff, featuresFlagOff, ac, dashSrv, nil, nil) lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOff) require.NoError(t, err) @@ -655,7 +655,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { require.NoError(t, err) dashSrv.RegisterDashboardPermissions(dashboardPermissions) - elementService := libraryelements.ProvideService(cfg, db, routeRegister, tc.service, tc.featuresFlag, ac, dashSrv) + elementService := libraryelements.ProvideService(cfg, db, routeRegister, tc.service, tc.featuresFlag, ac, dashSrv, nil, nil) lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, tc.service) require.NoError(t, err) @@ -726,7 +726,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { }) } -func TestNestedFolderServiceFeatureToggle(t *testing.T) { +func TestIntegrationNestedFolderServiceFeatureToggle(t *testing.T) { nestedFolderStore := folder.NewFakeStore() dashStore := dashboards.FakeDashboardStore{} @@ -757,7 +757,7 @@ func TestNestedFolderServiceFeatureToggle(t *testing.T) { }) } -func TestFolderServiceDualWrite(t *testing.T) { +func TestIntegrationFolderServiceDualWrite(t *testing.T) { db, _ := sqlstore.InitTestDB(t) cfg := setting.NewCfg() features := featuremgmt.WithFeatures() @@ -816,7 +816,7 @@ func TestFolderServiceDualWrite(t *testing.T) { }) } -func TestNestedFolderService(t *testing.T) { +func TestIntegrationNestedFolderService(t *testing.T) { t.Run("with feature flag unset", func(t *testing.T) { t.Run("Should create a folder in both dashboard and folders tables", func(t *testing.T) { // dash is needed here because folderSvc.Create expects SaveDashboard to return it @@ -1690,7 +1690,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { }) } -func TestFolderServiceGetFolder(t *testing.T) { +func TestIntegrationFolderServiceGetFolder(t *testing.T) { db, _ := sqlstore.InitTestDB(t) signedInAdminUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ @@ -1801,7 +1801,7 @@ func TestFolderServiceGetFolder(t *testing.T) { } } -func TestFolderServiceGetFolders(t *testing.T) { +func TestIntegrationFolderServiceGetFolders(t *testing.T) { db, cfg := sqlstore.InitTestDB(t) folderStore := ProvideDashboardFolderStore(db) @@ -1872,7 +1872,7 @@ func TestFolderServiceGetFolders(t *testing.T) { // TODO replace it with an API test under /pkg/tests/api/folders // whenever the golang client with get updated to allow filtering child folders by permission -func TestGetChildrenFilterByPermission(t *testing.T) { +func TestIntegrationGetChildrenFilterByPermission(t *testing.T) { db, cfg := sqlstore.InitTestDB(t) signedInAdminUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go index 57838cd1691..738e1e98f56 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go @@ -847,7 +847,7 @@ func TestGetFoldersFromApiServer(t *testing.T) { }) } -func TestDeleteFoldersFromApiServer(t *testing.T) { +func TestIntegrationDeleteFoldersFromApiServer(t *testing.T) { fakeK8sClient := new(client.MockK8sHandler) fakeK8sClient.On("GetNamespace", mock.Anything, mock.Anything).Return("default") dashboardK8sclient := new(client.MockK8sHandler) diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index 21f705ac353..bfa8e46f704 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -1,23 +1,38 @@ package libraryelements import ( + "encoding/json" "errors" "fmt" "hash/fnv" "net/http" "strings" + dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/kinds/librarypanel" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/errhttp" "github.com/grafana/grafana/pkg/web" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" ) func (l *LibraryElementService) registerAPIEndpoints() { @@ -25,13 +40,13 @@ func (l *LibraryElementService) registerAPIEndpoints() { l.RouteRegister.Group("/api/library-elements", func(entities routing.RouteRegister) { uidScope := ScopeLibraryPanelsProvider.GetResourceScopeUID(ac.Parameter(":uid")) - entities.Post("/", authorize(ac.EvalPermission(ActionLibraryPanelsCreate)), routing.Wrap(l.createHandler)) - entities.Delete("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsDelete, uidScope)), routing.Wrap(l.deleteHandler)) - entities.Get("/", authorize(ac.EvalPermission(ActionLibraryPanelsRead)), routing.Wrap(l.getAllHandler)) + entities.Post("/", authorize(ac.EvalPermission(ActionLibraryPanelsCreate)), routing.Wrap(l.createHandler)) // TODO: add wrapper for k8s + entities.Delete("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsDelete, uidScope)), routing.Wrap(l.deleteHandler)) // TODO: add wrapper for k8s + entities.Get("/", authorize(ac.EvalPermission(ActionLibraryPanelsRead)), routing.Wrap(l.getAllHandler)) // TODO: add wrapper for k8s - requires search entities.Get("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsRead)), routing.Wrap(l.getHandler)) entities.Get("/:uid/connections/", authorize(ac.EvalPermission(ActionLibraryPanelsRead, uidScope)), routing.Wrap(l.getConnectionsHandler)) - entities.Get("/name/:name", routing.Wrap(l.getByNameHandler)) - entities.Patch("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsWrite, uidScope)), routing.Wrap(l.patchHandler)) + entities.Get("/name/:name", routing.Wrap(l.getByNameHandler)) // TODO: add wrapper for k8s - requires search + entities.Patch("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsWrite, uidScope)), routing.Wrap(l.patchHandler)) // TODO: add wrapper for k8s }) } @@ -133,6 +148,11 @@ func (l *LibraryElementService) deleteHandler(c *contextmodel.ReqContext) respon // 404: notFoundError // 500: internalServerError func (l *LibraryElementService) getHandler(c *contextmodel.ReqContext) response.Response { + if l.features.IsEnabled(c.Req.Context(), featuremgmt.FlagKubernetesLibraryPanels) { + l.k8sHandler.getK8sLibraryElement(c) + return nil // already handled in the k8s handler + } + ctx := c.Req.Context() element, err := l.getLibraryElementByUid(ctx, c.SignedInUser, model.GetLibraryElementCommand{ @@ -529,3 +549,205 @@ type GetLibraryElementConnectionsResponse struct { // in: body Body model.LibraryElementConnectionsResponse `json:"body"` } + +//----------------------------------------------------------------------------------------- +// Library Elements k8s wrapper functions +//----------------------------------------------------------------------------------------- + +type libraryElementsK8sHandler struct { + cfg *setting.Cfg + namespacer request.NamespaceMapper + gvr schema.GroupVersionResource + clientConfigProvider grafanaapiserver.DirectRestConfigProvider + folderService folder.Service + dashboardsService dashboards.DashboardService + userService user.Service +} + +func newLibraryElementsK8sHandler(cfg *setting.Cfg, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, folderService folder.Service, userService user.Service, dashboardsService dashboards.DashboardService) *libraryElementsK8sHandler { + gvr := schema.GroupVersionResource{ + Group: dashboardV0.APIGroup, + Version: dashboardV0.APIVersion, + Resource: dashboardV0.LIBRARY_PANEL_RESOURCE, + } + return &libraryElementsK8sHandler{ + cfg: cfg, + gvr: gvr, + namespacer: request.GetNamespaceMapper(cfg), + clientConfigProvider: clientConfigProvider, + folderService: folderService, + dashboardsService: dashboardsService, + userService: userService, + } +} + +func (lk8s *libraryElementsK8sHandler) getK8sLibraryElement(c *contextmodel.ReqContext) { + client, ok := lk8s.getClient(c) + if !ok { + return + } + uid := web.Params(c.Req)[":uid"] + out, err := client.Get(c.Req.Context(), uid, v1.GetOptions{}) + if err != nil { + lk8s.writeError(c, err) + return + } + + dto, err := lk8s.unstructuredToLegacyLibraryPanelDTO(c, *out) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "conversion error", err) + return + } + c.JSON(http.StatusOK, model.LibraryElementResponse{Result: *dto}) +} + +func (lk8s *libraryElementsK8sHandler) unstructuredToLegacyLibraryPanelDTO(c *contextmodel.ReqContext, item unstructured.Unstructured) (*model.LibraryElementDTO, error) { + spec, exists := item.Object["spec"].(map[string]interface{}) + if !exists { + return nil, fmt.Errorf("spec not found in unstructured object") + } + + id := int64(0) + folderUID := "" + meta, err := utils.MetaAccessor(&item) + if err == nil { + id = meta.GetDeprecatedInternalID() // nolint:staticcheck + folderUID = meta.GetFolder() + } + + var libraryPanelSpec dashboardV0.LibraryPanelSpec + specJSON, err := json.Marshal(spec) + if err != nil { + return nil, fmt.Errorf("failed to marshal spec: %w", err) + } + + err = json.Unmarshal(specJSON, &libraryPanelSpec) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal spec into LibraryPanelSpec: %w", err) + } + + // need to reconstruct this section from what we have in the k8s object + legacyModel := map[string]any{} + legacyModel["datasource"] = libraryPanelSpec.Datasource + legacyModel["description"] = libraryPanelSpec.Description + legacyModel["fieldConfig"] = libraryPanelSpec.FieldConfig.Object + legacyModel["gridPos"] = libraryPanelSpec.GridPos + legacyModel["id"] = id + legacyModel["options"] = libraryPanelSpec.Options.Object + legacyModel["pluginVersion"] = libraryPanelSpec.PluginVersion + legacyModel["type"] = libraryPanelSpec.Type + legacyModel["title"] = libraryPanelSpec.PanelTitle // this is the title of the panel when displayed in the dashboard + legacyModel["libraryPanel"] = map[string]string{ + "name": libraryPanelSpec.Title, // this is the title of the actual library panel, when displayed in the library panel list + "uid": item.GetName(), + } + if len(libraryPanelSpec.Links) > 0 { + legacyModel["links"] = libraryPanelSpec.Links + } + if len(libraryPanelSpec.Targets) > 0 { + legacyModel["targets"] = libraryPanelSpec.Targets + } + if libraryPanelSpec.Transparent { + legacyModel["transparent"] = libraryPanelSpec.Transparent + } + finalModel, err := json.Marshal(legacyModel) + if err != nil { + return nil, fmt.Errorf("failed to marshal model: %w", err) + } + + dto := &model.LibraryElementDTO{ + ID: id, + OrgID: c.OrgID, + FolderUID: folderUID, + UID: item.GetName(), + Name: libraryPanelSpec.Title, + Kind: int64(model.PanelElement), + Type: libraryPanelSpec.Type, + Description: libraryPanelSpec.Description, + Model: finalModel, + Version: item.GetGeneration(), + Meta: model.LibraryElementDTOMeta{ + FolderUID: folderUID, + Created: meta.GetCreationTimestamp().Time, + }, + } + + if folderUID != "" { + folder, err := lk8s.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{ + OrgID: c.OrgID, + UID: &folderUID, + SignedInUser: c.SignedInUser, + }) + if err != nil { + return nil, err + } + + dto.Meta.FolderName = folder.Title + dto.FolderID = folder.ID // nolint:staticcheck + } + + dashboards, err := lk8s.dashboardsService.GetDashboardsByLibraryPanelUID(c.Req.Context(), item.GetName(), c.OrgID) + if err != nil { + return nil, err + } + dto.Meta.ConnectedDashboards = int64(len(dashboards)) + + createdBy := meta.GetCreatedBy() + updatedBy := createdBy // the old /api returns the same user for updated if it was never updated + userUIDs := []string{meta.GetCreatedBy()} + if timestamp, err := meta.GetUpdatedTimestamp(); err == nil && timestamp != nil { + dto.Meta.Updated = *timestamp + updatedBy = meta.GetUpdatedBy() + userUIDs = append(userUIDs, updatedBy) + } else { + // if never updated, the old /api returns the same timestamp for updated as for created + dto.Meta.Updated = dto.Meta.Created + } + + users, err := lk8s.userService.ListByIdOrUID(c.Req.Context(), userUIDs, []int64{c.OrgID}) + if err != nil { + return nil, err + } + for _, user := range users { + if user.UID == createdBy { + dto.Meta.CreatedBy = librarypanel.LibraryElementDTOMetaUser{ + Id: user.ID, + Name: user.Login, + AvatarUrl: dtos.GetGravatarUrl(lk8s.cfg, user.Email), + } + } + // not else because /api returns the same user for updated if it was never updated + if user.UID == updatedBy { + dto.Meta.UpdatedBy = librarypanel.LibraryElementDTOMetaUser{ + Id: user.ID, + Name: user.Login, + AvatarUrl: dtos.GetGravatarUrl(lk8s.cfg, user.Email), + } + } + } + + return dto, nil +} + +//----------------------------------------------------------------------------------------- +// Utility functions +//----------------------------------------------------------------------------------------- + +func (lk8s *libraryElementsK8sHandler) getClient(c *contextmodel.ReqContext) (dynamic.ResourceInterface, bool) { + dyn, err := dynamic.NewForConfig(lk8s.clientConfigProvider.GetDirectRestConfig(c)) + if err != nil { + c.JsonApiErr(500, "client", err) + return nil, false + } + return dyn.Resource(lk8s.gvr).Namespace(lk8s.namespacer(c.OrgID)), true +} + +func (lk8s *libraryElementsK8sHandler) writeError(c *contextmodel.ReqContext, err error) { + //nolint:errorlint + statusError, ok := err.(*k8serrors.StatusError) + if ok { + c.JsonApiErr(int(statusError.Status().Code), statusError.Status().Message, err) + return + } + errhttp.Write(c.Req.Context(), err, c.Resp) +} diff --git a/pkg/services/libraryelements/k8s_conversion_test.go b/pkg/services/libraryelements/k8s_conversion_test.go new file mode 100644 index 00000000000..0319e0ae76a --- /dev/null +++ b/pkg/services/libraryelements/k8s_conversion_test.go @@ -0,0 +1,180 @@ +package libraryelements + +import ( + "context" + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/apimachinery/utils" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/libraryelements/model" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/usertest" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestUnstructuredToLegacyLibraryPanelDTO(t *testing.T) { + cfg := setting.NewCfg() + userSvc := &usertest.FakeUserService{} + testUser := &user.User{ + ID: 1, + UID: "test-user-uid", + Login: "testuser", + Email: "test@example.com", + } + userSvc.ExpectedListUsersByIdOrUid = []*user.User{testUser} + + testFolder := &folder.Folder{ + ID: 1, + UID: "test-folder-uid", + Title: "Test Folder", + } + folderSvc := &foldertest.FakeService{ + ExpectedFolder: testFolder, + } + + dashboardsSvc := &dashboards.FakeDashboardService{} + testDashboard := &dashboards.DashboardRef{ + ID: 1, + UID: "test-dashboard-uid", + FolderUID: testFolder.UID, + } + dashboardsSvc.On("GetDashboardsByLibraryPanelUID", mock.Anything, "test-panel-uid", int64(1)).Return([]*dashboards.DashboardRef{testDashboard}, nil) + + handler := &libraryElementsK8sHandler{ + cfg: cfg, + folderService: folderSvc, + dashboardsService: dashboardsSvc, + userService: userSvc, + } + + unstructuredObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v0alpha1", + "kind": "LibraryPanel", + "metadata": map[string]any{ + "name": "test-panel-uid", + }, + "spec": map[string]any{ + "type": "text", + "pluginVersion": "1.0.0", + "title": "Test Library Panel", + "panelTitle": "Test Panel Title", + "description": "Test description", + "options": map[string]interface{}{ + "content": "Test content", + }, + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "color": map[string]interface{}{ + "mode": "palette-classic", + }, + }, + }, + "gridPos": map[string]interface{}{ + "h": 8, + "w": 12, + "x": 0, + "y": 0, + }, + "datasource": map[string]interface{}{ + "type": "testdata", + "uid": "test-datasource", + }, + "transparent": true, + "links": []interface{}{ + map[string]interface{}{ + "title": "Test Link", + "url": "https://example.com", + }, + }, + "targets": []interface{}{ + map[string]interface{}{ + "refId": "A", + "expr": "test_query", + }, + }, + }, + }, + } + + meta, err := utils.MetaAccessor(unstructuredObj) + require.NoError(t, err) + meta.SetFolder(testFolder.UID) + meta.SetGeneration(2) + creationTimestamp := metav1.NewTime(time.Now()) + meta.SetCreationTimestamp(creationTimestamp) + meta.SetCreatedBy(testUser.UID) + meta.SetDeprecatedInternalID(123) // nolint:staticcheck + + reqContext := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: httptest.NewRequest("GET", "/", nil).WithContext(context.Background()), + }, + SignedInUser: &user.SignedInUser{ + UserID: 1, + OrgID: 1, + OrgRole: org.RoleAdmin, + }, + } + result, err := handler.unstructuredToLegacyLibraryPanelDTO(reqContext, *unstructuredObj) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(123), result.ID) + require.Equal(t, int64(1), result.OrgID) + require.Equal(t, testFolder.UID, result.FolderUID) + require.Equal(t, "test-panel-uid", result.UID) + require.Equal(t, "Test Library Panel", result.Name) + require.Equal(t, int64(model.PanelElement), result.Kind) + require.Equal(t, "text", result.Type) + require.Equal(t, "Test description", result.Description) + require.Equal(t, int64(2), result.Version) + require.Equal(t, testFolder.UID, result.Meta.FolderUID) + require.Equal(t, testFolder.Title, result.Meta.FolderName) + require.Equal(t, int64(1), result.Meta.ConnectedDashboards) + require.Equal(t, int64(1), result.FolderID) // nolint:staticcheck + require.Equal(t, creationTimestamp.Format(time.RFC3339), result.Meta.Created.Format(time.RFC3339)) + require.Equal(t, testUser.ID, result.Meta.CreatedBy.Id) + require.Equal(t, testUser.Login, result.Meta.CreatedBy.Name) + require.Equal(t, dtos.GetGravatarUrl(cfg, testUser.Email), result.Meta.CreatedBy.AvatarUrl) + require.Equal(t, creationTimestamp.Format(time.RFC3339), result.Meta.Updated.Format(time.RFC3339)) + require.Equal(t, testUser.ID, result.Meta.UpdatedBy.Id) + require.Equal(t, testUser.Login, result.Meta.UpdatedBy.Name) + require.Equal(t, dtos.GetGravatarUrl(cfg, testUser.Email), result.Meta.UpdatedBy.AvatarUrl) + + var modelMap map[string]interface{} + err = json.Unmarshal(result.Model, &modelMap) + require.NoError(t, err) + require.Equal(t, "testdata", modelMap["datasource"].(map[string]interface{})["type"]) + require.Equal(t, "test-datasource", modelMap["datasource"].(map[string]interface{})["uid"]) + require.Equal(t, "Test description", modelMap["description"]) + require.Equal(t, float64(123), modelMap["id"]) + require.Equal(t, "text", modelMap["type"]) + require.Equal(t, "Test Panel Title", modelMap["title"]) + require.Equal(t, "Test content", modelMap["options"].(map[string]interface{})["content"]) + require.Equal(t, true, modelMap["transparent"]) + require.Equal(t, "Test Library Panel", modelMap["libraryPanel"].(map[string]interface{})["name"]) + require.Equal(t, "test-panel-uid", modelMap["libraryPanel"].(map[string]interface{})["uid"]) + links := modelMap["links"].([]interface{}) + require.Len(t, links, 1) + require.Equal(t, "Test Link", links[0].(map[string]interface{})["title"]) + + targets := modelMap["targets"].([]interface{}) + require.Len(t, targets, 1) + require.Equal(t, "A", targets[0].(map[string]interface{})["refId"]) + + dashboardsSvc.AssertExpectations(t) +} diff --git a/pkg/services/libraryelements/libraryelements.go b/pkg/services/libraryelements/libraryelements.go index 14fdf8c9b0f..fe31dfcfc70 100644 --- a/pkg/services/libraryelements/libraryelements.go +++ b/pkg/services/libraryelements/libraryelements.go @@ -9,14 +9,16 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" + grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/libraryelements/model" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) -func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, folderService folder.Service, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, dashboardsService dashboards.DashboardService) *LibraryElementService { +func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, folderService folder.Service, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, dashboardsService dashboards.DashboardService, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, userService user.Service) *LibraryElementService { l := &LibraryElementService{ Cfg: cfg, SQLStore: sqlStore, @@ -26,6 +28,7 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.Rout log: log.New("library-elements"), features: features, AccessControl: ac, + k8sHandler: newLibraryElementsK8sHandler(cfg, clientConfigProvider, folderService, userService, dashboardsService), } l.registerAPIEndpoints() @@ -55,6 +58,7 @@ type LibraryElementService struct { log log.Logger features featuremgmt.FeatureToggles AccessControl accesscontrol.AccessControl + k8sHandler *libraryElementsK8sHandler } var _ Service = (*LibraryElementService)(nil) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 6feeec293d9..fd63d239131 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -531,6 +531,7 @@ func setupTestScenario(t *testing.T) scenarioContext { features := featuremgmt.WithFeatures() tracer := tracing.InitializeTracerForTest() sqlStore, cfg := db.InitTestDBWithCfg(t) + t.Cleanup(db.CleanupTestDB) quotaService := quotatest.New(false, nil) dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore)) require.NoError(t, err) diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 58b45f305fe..da8d4057f63 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -55,7 +55,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestConnectLibraryPanelsForDashboard(t *testing.T) { +func TestIntegrationConnectLibraryPanelsForDashboard(t *testing.T) { scenarioWithLibraryPanel(t, "When an admin tries to store a dashboard with a library panel, it should connect the two", func(t *testing.T, sc scenarioContext) { dashJSON := map[string]any{ @@ -348,7 +348,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { }) } -func TestImportLibraryPanelsForDashboard(t *testing.T) { +func TestIntegrationImportLibraryPanelsForDashboard(t *testing.T) { testScenario(t, "When an admin tries to import a dashboard with a library panel that does not exist, it should import the library panel", func(t *testing.T, sc scenarioContext) { var missingUID = "jL6MrxCMz" @@ -850,7 +850,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, nil, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, features, ac, dashService) + elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, features, ac, dashService, nil, nil) service := LibraryPanelService{ Cfg: cfg, SQLStore: sqlStore, diff --git a/pkg/services/live/live_test.go b/pkg/services/live/live_test.go index c002ec7c705..7ae91b968f5 100644 --- a/pkg/services/live/live_test.go +++ b/pkg/services/live/live_test.go @@ -24,7 +24,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func Test_provideLiveService_RedisUnavailable(t *testing.T) { +func TestIntegration_provideLiveService_RedisUnavailable(t *testing.T) { cfg := setting.NewCfg() cfg.LiveHAEngine = "testredisunavailable" diff --git a/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go b/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go index 643601adcb5..1e220035a7f 100644 --- a/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go +++ b/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go @@ -86,7 +86,7 @@ func TestService_Validate(t *testing.T) { } } -func TestUserLoginAttempts(t *testing.T) { +func TestIntegrationUserLoginAttempts(t *testing.T) { ctx := context.Background() cfg := setting.NewCfg() cfg.DisableBruteForceLoginProtection = false @@ -184,7 +184,7 @@ func TestService_ValidateIPAddress(t *testing.T) { } } -func TestIPLoginAttempts(t *testing.T) { +func TestIntegrationIPLoginAttempts(t *testing.T) { ctx := context.Background() cfg := setting.NewCfg() cfg.DisableIPAddressLoginProtection = false diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index 6790e804b44..1cdacebed4e 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -61,7 +61,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestProvisioningApi(t *testing.T) { +func TestIntegrationProvisioningApi(t *testing.T) { t.Run("policies", func(t *testing.T) { t.Run("successful GET returns 200", func(t *testing.T) { sut := createProvisioningSrvSut(t) @@ -1619,7 +1619,7 @@ func TestProvisioningApi(t *testing.T) { }) } -func TestProvisioningApiContactPointExport(t *testing.T) { +func TestIntegrationProvisioningApiContactPointExport(t *testing.T) { createTestEnv := func(t *testing.T, testConfig string) testEnvironment { env := createTestEnv(t, testConfig) env.ac = &recordingAccessControlFake{ diff --git a/pkg/services/ngalert/notifier/alertmanager_test.go b/pkg/services/ngalert/notifier/alertmanager_test.go index 09d163871ba..fe00b4f8331 100644 --- a/pkg/services/ngalert/notifier/alertmanager_test.go +++ b/pkg/services/ngalert/notifier/alertmanager_test.go @@ -63,7 +63,7 @@ func setupAMTest(t *testing.T) *alertmanager { return am } -func TestAlertmanager_newAlertmanager(t *testing.T) { +func TestIntegrationAlertmanager_newAlertmanager(t *testing.T) { am := setupAMTest(t) require.False(t, am.Ready()) } diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go index 31f888276f8..7cab4c03442 100644 --- a/pkg/services/ngalert/notifier/receiver_svc_test.go +++ b/pkg/services/ngalert/notifier/receiver_svc_test.go @@ -33,7 +33,7 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func TestReceiverService_GetReceiver(t *testing.T) { +func TestIntegrationReceiverService_GetReceiver(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) @@ -61,7 +61,7 @@ func TestReceiverService_GetReceiver(t *testing.T) { }) } -func TestReceiverService_GetReceivers(t *testing.T) { +func TestIntegrationReceiverService_GetReceivers(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) @@ -91,7 +91,7 @@ func TestReceiverService_GetReceivers(t *testing.T) { }) } -func TestReceiverService_DecryptRedact(t *testing.T) { +func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) diff --git a/pkg/services/ngalert/notifier/templates_test.go b/pkg/services/ngalert/notifier/templates_test.go index 1b1da5a2890..9c6000bfad1 100644 --- a/pkg/services/ngalert/notifier/templates_test.go +++ b/pkg/services/ngalert/notifier/templates_test.go @@ -57,7 +57,7 @@ var ( } ) -func TestTemplateDefaultData(t *testing.T) { +func TestIntegrationTemplateDefaultData(t *testing.T) { am := setupAMTest(t) tests := []struct { diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index 69225d65904..253010be25e 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -41,7 +41,7 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -func TestAlertRuleService(t *testing.T) { +func TestIntegrationAlertRuleService(t *testing.T) { ruleService := createAlertRuleService(t, nil) var orgID int64 = 1 u := &user.SignedInUser{ @@ -754,7 +754,7 @@ func TestAlertRuleService(t *testing.T) { }) } -func TestCreateAlertRule(t *testing.T) { +func TestIntegrationCreateAlertRule(t *testing.T) { orgID := rand.Int63() u := &user.SignedInUser{OrgID: orgID, UserUID: util.GenerateShortUID()} groupKey := models.GenerateGroupKey(orgID) @@ -1983,7 +1983,7 @@ func TestDeleteRuleGroups(t *testing.T) { }) } -func TestProvisiongWithFullpath(t *testing.T) { +func TestIntegrationProvisiongWithFullpath(t *testing.T) { tracer := tracing.InitializeTracerForTest() inProcBus := bus.ProvideBus(tracer) sqlStore, cfg := db.InitTestDBWithCfg(t) diff --git a/pkg/services/ngalert/provisioning/contactpoints_test.go b/pkg/services/ngalert/provisioning/contactpoints_test.go index dc2a0fd239c..e042272a063 100644 --- a/pkg/services/ngalert/provisioning/contactpoints_test.go +++ b/pkg/services/ngalert/provisioning/contactpoints_test.go @@ -35,7 +35,7 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func TestContactPointService(t *testing.T) { +func TestIntegrationContactPointService(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) @@ -360,7 +360,7 @@ func TestContactPointService(t *testing.T) { }) } -func TestContactPointServiceDecryptRedact(t *testing.T) { +func TestIntegrationContactPointServiceDecryptRedact(t *testing.T) { secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t))) redactedUser := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go index c6a3a06b4d7..8ebb5de8826 100644 --- a/pkg/services/ngalert/remote/alertmanager_test.go +++ b/pkg/services/ngalert/remote/alertmanager_test.go @@ -128,7 +128,7 @@ func TestNewAlertmanager(t *testing.T) { } } -func TestApplyConfig(t *testing.T) { +func TestIntegrationApplyConfig(t *testing.T) { const tenantID = "test" // errorHandler returns an error response for the readiness check and state sync. errorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/services/ngalert/sender/notifier_test.go b/pkg/services/ngalert/sender/notifier_test.go index c36ec85e482..6b70f91e17b 100644 --- a/pkg/services/ngalert/sender/notifier_test.go +++ b/pkg/services/ngalert/sender/notifier_test.go @@ -1066,7 +1066,7 @@ func TestStop_DrainingEnabled(t *testing.T) { require.Equal(t, int64(2), alertsReceived.Load()) } -func TestApplyConfig(t *testing.T) { +func TestIntegrationApplyConfig(t *testing.T) { targetURL := "alertmanager:9093" targetGroup := &targetgroup.Group{ Targets: []model.LabelSet{ diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 9e00269b16e..c42bb5f6435 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -46,7 +46,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestWarmStateCache(t *testing.T) { +func TestIntegrationWarmStateCache(t *testing.T) { evaluationTime, err := time.Parse("2006-01-02", "2021-03-25") require.NoError(t, err) ctx := context.Background() @@ -271,7 +271,7 @@ func TestWarmStateCache(t *testing.T) { }) } -func TestDashboardAnnotations(t *testing.T) { +func TestIntegrationDashboardAnnotations(t *testing.T) { evaluationTime, err := time.Parse("2006-01-02", "2022-01-01") require.NoError(t, err) @@ -1450,7 +1450,7 @@ func printAllAnnotations(annos map[int64]annotations.Item) string { return b.String() } -func TestStaleResultsHandler(t *testing.T) { +func TestIntegrationStaleResultsHandler(t *testing.T) { evaluationTime := time.Now().Truncate(time.Second).UTC() // Truncate to the second since we don't store sub-second precision. interval := time.Minute @@ -1737,7 +1737,7 @@ func TestStaleResults(t *testing.T) { }) } -func TestDeleteStateByRuleUID(t *testing.T) { +func TestIntegrationDeleteStateByRuleUID(t *testing.T) { interval := time.Minute ctx := context.Background() ng, dbstore := tests.SetupTestEnv(t, 1) @@ -1883,7 +1883,7 @@ func TestDeleteStateByRuleUID(t *testing.T) { } } -func TestResetStateByRuleUID(t *testing.T) { +func TestIntegrationResetStateByRuleUID(t *testing.T) { interval := time.Minute ctx := context.Background() ng, dbstore := tests.SetupTestEnv(t, 1) diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index ba6dd1ebbde..97c6002b853 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -1447,7 +1447,7 @@ func TestIntegrationRuleGroupsCaseSensitive(t *testing.T) { }) } -func TestIncreaseVersionForAllRulesInNamespaces(t *testing.T) { +func TestIntegrationIncreaseVersionForAllRulesInNamespaces(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } @@ -1496,7 +1496,7 @@ func TestIncreaseVersionForAllRulesInNamespaces(t *testing.T) { }) } -func TestGetRuleVersions(t *testing.T) { +func TestIntegrationGetRuleVersions(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } diff --git a/pkg/services/ngalert/store/org_test.go b/pkg/services/ngalert/store/org_test.go index da73f255972..6b16004fc23 100644 --- a/pkg/services/ngalert/store/org_test.go +++ b/pkg/services/ngalert/store/org_test.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -func TestFetchOrgIds(t *testing.T) { +func TestIntegrationFetchOrgIds(t *testing.T) { ctx := context.Background() t.Run("returns empty result when no orgs exist", func(t *testing.T) { diff --git a/pkg/services/ngalert/store/range_to_instant_test.go b/pkg/services/ngalert/store/range_to_instant_test.go index d2852eebe31..c5fa4883a76 100644 --- a/pkg/services/ngalert/store/range_to_instant_test.go +++ b/pkg/services/ngalert/store/range_to_instant_test.go @@ -145,7 +145,7 @@ func TestCanBeInstant(t *testing.T) { } } -func TestMigrateLokiQueryToInstant(t *testing.T) { +func TestIntegrationMigrateLokiQueryToInstant(t *testing.T) { original := createMigrateableLokiRule(t) migrated := createMigrateableLokiRule(t, func(r *models.AlertRule) { r.Data[0] = lokiQuery(t, "A", "instant", "grafanacloud-logs") @@ -168,7 +168,7 @@ func TestMigrateLokiQueryToInstant(t *testing.T) { require.False(t, canBeOptimized) } -func TestMigrateMultiLokiQueryToInstant(t *testing.T) { +func TestIntegrationMigrateMultiLokiQueryToInstant(t *testing.T) { original := createMultiQueryMigratableLokiRule(t) migrated := createMultiQueryMigratableLokiRule(t, func(r *models.AlertRule) { r.Data[0] = lokiQuery(t, "TotalRequests", "instant", "grafanacloud-logs") @@ -205,7 +205,7 @@ func TestMigrateMultiLokiQueryToInstant(t *testing.T) { require.False(t, canBeOptimized) } -func TestMigratePromQueryToInstant(t *testing.T) { +func TestIntegrationMigratePromQueryToInstant(t *testing.T) { original := createMigratablePromRule(t) migrated := createMigratablePromRule(t, func(r *models.AlertRule) { r.Data[0] = prometheusQuery(t, "A", promExternalDS, promIsInstant) @@ -226,7 +226,7 @@ func TestMigratePromQueryToInstant(t *testing.T) { require.False(t, canBeOptimized) } -func TestMigrateMultiPromQueryToInstant(t *testing.T) { +func TestIntegrationMigrateMultiPromQueryToInstant(t *testing.T) { original := createMultiQueryMigratablePromRule(t) migrated := createMultiQueryMigratablePromRule(t, func(r *models.AlertRule) { r.Data[0] = prometheusQuery(t, "TotalRequests", promExternalDS, promIsInstant) diff --git a/pkg/services/org/orgimpl/org_delete_svc.go b/pkg/services/org/orgimpl/org_delete_svc.go index 4ea185aaef5..97676e786c4 100644 --- a/pkg/services/org/orgimpl/org_delete_svc.go +++ b/pkg/services/org/orgimpl/org_delete_svc.go @@ -3,6 +3,7 @@ package orgimpl import ( "context" "errors" + "fmt" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" @@ -59,7 +60,7 @@ func (s *DeletionService) Delete(ctx context.Context, cmd *org.DeleteOrgCommand) ctx, _ = identity.WithServiceIdentity(ctx, cmd.ID) err = s.dashSvc.DeleteAllDashboards(ctx, cmd.ID) if err != nil { - return err + return fmt.Errorf("failed to delete dashboards for org %d: %w", cmd.ID, err) } return s.store.Delete(ctx, cmd) diff --git a/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware.go index ea7237734d8..b8bd1f2c9d9 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware.go +++ b/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware.go @@ -2,8 +2,13 @@ package clientmiddleware import ( "context" + "fmt" + "strings" + "unicode/utf8" "github.com/grafana/grafana-plugin-sdk-go/backend" + "golang.org/x/text/encoding/charmap" + "golang.org/x/text/transform" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/query" @@ -50,6 +55,9 @@ func (m *TracingHeaderMiddleware) applyHeaders(ctx context.Context, req backend. if gotVal == "" { continue } + if !utf8.ValidString(gotVal) { + gotVal = sanitizeHTTPHeaderValueForGRPC(gotVal) + } req.SetHTTPHeader(headerName, gotVal) } } @@ -102,3 +110,28 @@ func (m *TracingHeaderMiddleware) RunStream(ctx context.Context, req *backend.Ru m.applyHeaders(ctx, req) return m.BaseHandler.RunStream(ctx, req, sender) } + +// sanitizeHTTPHeaderValueForGRPC sanitizes header values according to HTTP/2 gRPC specification. +// The spec defines that header values must consist of printable ASCII characters 0x20 (space) - 0x7E(tilde) inclusive. +// First attempts to decode any percent-encoded characters, then encodes invalid characters. +func sanitizeHTTPHeaderValueForGRPC(value string) string { + // First try to decode characters that were encoded by the frontend + decoder := charmap.ISO8859_1.NewDecoder() + decoded, _, err := transform.Bytes(decoder, []byte(value)) + // If decoding fails, work with the original value + if err != nil { + decoded = []byte(value) + } + var sanitized strings.Builder + sanitized.Grow(len(decoded)) // Pre-allocate reasonable capacity + // Then encode invalid characters + for _, b := range decoded { + if b >= 0x20 && b <= 0x7E { + sanitized.WriteByte(b) + } else { + sanitized.WriteString(fmt.Sprintf("%%%02X", b)) + } + } + + return sanitized.String() +} diff --git a/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware_test.go b/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware_test.go index 60435b2b557..3c3b799a358 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware_test.go +++ b/pkg/services/pluginsintegration/clientmiddleware/tracing_header_middleware_test.go @@ -238,5 +238,115 @@ func TestTracingHeaderMiddleware(t *testing.T) { require.Equal(t, `d26e337d-cb53-481a-9212-0112537b3c1a`, cdt.RunStreamReq.GetHTTPHeader(`X-Query-Group-Id`)) require.Equal(t, `true`, cdt.RunStreamReq.GetHTTPHeader(`X-Grafana-From-Expr`)) }) + + t.Run("sanitizes grpc header values for invalid utf-8", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/some/thing", nil) + require.NoError(t, err) + + // Create invalid UTF-8 strings + invalidUTF8Dashboard := string([]byte{'d', 'a', 's', 'h', 0xFF, 0xFE, 'u', 'i', 'd'}) + invalidUTF8Panel := string([]byte{'p', 'a', 'n', 'e', 'l', 0x80, 'i', 'd'}) + + // Set headers with various characters that need to be sanitization + req.Header[`X-Dashboard-Title`] = []string{invalidUTF8Dashboard} // invalid UTF-8 + req.Header[`X-Panel-Title`] = []string{invalidUTF8Panel} // invalid UTF-8 + + // Set headers that don't need sanitization + req.Header[`X-Dashboard-Uid`] = []string{"dashboard\x00uid"} // control character + req.Header[`X-Datasource-Uid`] = []string{"datasource\tuid"} // tab character + req.Header[`X-Query-Group-Id`] = []string{"valid-text-123"} // valid characters + req.Header[`X-Grafana-From-Expr`] = []string{"café résumé"} // extended characters + + pluginCtx := backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + } + + cdt := handlertest.NewHandlerMiddlewareTest(t, + WithReqContext(req, &user.SignedInUser{ + IsAnonymous: true, + Login: "anonymous"}, + ), + handlertest.WithMiddlewares(NewTracingHeaderMiddleware()), + ) + + _, err = cdt.MiddlewareHandler.QueryData(req.Context(), &backend.QueryDataRequest{ + PluginContext: pluginCtx, + Headers: map[string]string{}, + }) + require.NoError(t, err) + + // Invalid UTF-8 should be sanitized + require.Equal(t, "dash%C3%BF%C3%BEuid", cdt.QueryDataReq.GetHTTPHeader(`X-Dashboard-Title`)) + require.Equal(t, "panel%C2%80id", cdt.QueryDataReq.GetHTTPHeader(`X-Panel-Title`)) + + // Valid characters should remain unchanged + require.Equal(t, "valid-text-123", cdt.QueryDataReq.GetHTTPHeader(`X-Query-Group-Id`)) + require.Equal(t, "café résumé", cdt.QueryDataReq.GetHTTPHeader(`X-Grafana-From-Expr`)) + require.Equal(t, "dashboard\x00uid", cdt.QueryDataReq.GetHTTPHeader(`X-Dashboard-Uid`)) + require.Equal(t, "datasource\tuid", cdt.QueryDataReq.GetHTTPHeader(`X-Datasource-Uid`)) + }) }) } + +func TestSanitizeHTTPHeaderValueForGRPC(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "Valid printable ASCII characters remain unchanged", + input: "Hello World! 123 @#$%^&*()", + expected: "Hello World! 123 @#$%^&*()", + }, + { + name: "Extended characters remain unchanged", + // %C3%A9 is encoded é + input: "naiv%C3%A9", + expected: "naiv%C3%A9", + }, + { + name: "naivé coming in as an iso8859-1 string", + input: string([]byte{110, 97, 105, 118, 233}), + expected: "naiv%C3%A9", + }, + { + name: "Control characters are percent-encoded", + input: "hello\x00\x01\x1Fworld", + expected: "hello%00%01%1Fworld", + }, + { + name: "Tab character is percent-encoded", + input: "hello\tworld", + expected: "hello%09world", + }, + { + name: "Newline character is percent-encoded", + input: "hello\nworld", + expected: "hello%0Aworld", + }, + { + name: "Carriage return is percent-encoded", + input: "hello\rworld", + expected: "hello%0Dworld", + }, + { + name: "Mixed valid and invalid characters", + // %F0%9F%9A%80 is encoded 🚀 + input: "Valid text\x00invalid\x1Fmore valid %F0%9F%9A%80", + expected: "Valid text%00invalid%1Fmore valid %F0%9F%9A%80", + }, + { + name: "Empty string remains empty", + input: "", + expected: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := sanitizeHTTPHeaderValueForGRPC(tc.input) + require.Equal(t, tc.expected, result) + }) + } +} diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go index 0209fa7ef67..80f985a71bb 100644 --- a/pkg/services/provisioning/dashboards/file_reader_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_test.go @@ -108,7 +108,7 @@ func TestCreatingNewDashboardFileReader(t *testing.T) { }) } -func TestDashboardFileReader(t *testing.T) { +func TestIntegrationDashboardFileReader(t *testing.T) { logger := log.New("test-logger") cfg := &config{} diff --git a/pkg/services/provisioning/dashboards/validator_test.go b/pkg/services/provisioning/dashboards/validator_test.go index b2819a3c4f4..f5affa902e0 100644 --- a/pkg/services/provisioning/dashboards/validator_test.go +++ b/pkg/services/provisioning/dashboards/validator_test.go @@ -31,7 +31,7 @@ const ( twoDashboardsWithUID = "testdata/test-dashboards/two-dashboards-with-uid" ) -func TestDuplicatesValidator(t *testing.T) { +func TestIntegrationDuplicatesValidator(t *testing.T) { fakeService := &dashboards.FakeDashboardProvisioning{} defer fakeService.AssertExpectations(t) diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index 156a225a019..5104206c46a 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -682,7 +682,7 @@ func TestIntegrationDelete(t *testing.T) { }) } -func TestDeleteByDashboardUIDs(t *testing.T) { +func TestIntegrationDeleteByDashboardUIDs(t *testing.T) { var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store @@ -732,7 +732,7 @@ func TestDeleteByDashboardUIDs(t *testing.T) { }) } -func TestGetMetrics(t *testing.T) { +func TestIntegrationGetMetrics(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index 0db1dfd08b0..a0c5bcf7367 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -307,7 +307,7 @@ const ( }` ) -func TestGetQueryDataResponse(t *testing.T) { +func TestIntegrationGetQueryDataResponse(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, _ := newPublicDashboardServiceImpl(t, nil, nil, nil, fakeDashboardService, nil) fakeQueryService := &query.FakeQueryService{} @@ -361,7 +361,7 @@ func TestGetQueryDataResponse(t *testing.T) { }) } -func TestFindAnnotations(t *testing.T) { +func TestIntegrationFindAnnotations(t *testing.T) { color := "red" name := "annoName" t.Run("service identity has correct permissions to get annotations dashboards and query datasources", func(t *testing.T) { @@ -717,7 +717,7 @@ func TestFindAnnotations(t *testing.T) { }) } -func TestGetMetricRequest(t *testing.T) { +func TestIntegrationGetMetricRequest(t *testing.T) { service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, nil, nil, nil, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore)) require.NoError(t, err) @@ -756,7 +756,7 @@ func TestGetMetricRequest(t *testing.T) { }) } -func TestBuildMetricRequest(t *testing.T) { +func TestIntegrationBuildMetricRequest(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, nil, nil, fakeDashboardService, nil) diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index 9ae9d27bd16..5ddc7c8f2d9 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -60,7 +60,7 @@ func TestLogPrefix(t *testing.T) { assert.Equal(t, LogPrefix, "publicdashboards.service") } -func TestGetPublicDashboardForView(t *testing.T) { +func TestIntegrationGetPublicDashboardForView(t *testing.T) { type storeResp struct { pd *PublicDashboard d *dashboards.Dashboard @@ -452,7 +452,7 @@ func TestGetPublicDashboardForView(t *testing.T) { } } -func TestGetPublicDashboard(t *testing.T) { +func TestIntegrationGetPublicDashboard(t *testing.T) { type storeResp struct { pd *PublicDashboard d *dashboards.Dashboard @@ -529,7 +529,7 @@ func TestGetPublicDashboard(t *testing.T) { } } -func TestGetEnabledPublicDashboard(t *testing.T) { +func TestIntegrationGetEnabledPublicDashboard(t *testing.T) { type storeResp struct { pd *PublicDashboard d *dashboards.Dashboard @@ -594,7 +594,7 @@ func TestGetEnabledPublicDashboard(t *testing.T) { // We're using sqlite here because testing all of the behaviors with mocks in // the correct order is convoluted. -func TestCreatePublicDashboard(t *testing.T) { +func TestIntegrationCreatePublicDashboard(t *testing.T) { t.Run("Create public dashboard", func(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, nil, nil, fakeDashboardService, nil) @@ -975,7 +975,7 @@ func assertFalseIfNull(t *testing.T, expectedValue bool, nullableValue *bool) { } } -func TestUpdatePublicDashboard(t *testing.T) { +func TestIntegrationUpdatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, nil, nil, fakeDashboardService, nil) @@ -1219,7 +1219,7 @@ func assertOldValueIfNull(t *testing.T, expectedValue bool, oldValue bool, nulla } } -func TestDeletePublicDashboard(t *testing.T) { +func TestIntegrationDeletePublicDashboard(t *testing.T) { pubdash := &PublicDashboard{Uid: "2", OrgId: 1, DashboardUid: "uid"} type mockFindResponse struct { @@ -1389,7 +1389,7 @@ func TestDashboardEnabledChanged(t *testing.T) { }) } -func TestPublicDashboardServiceImpl_ListPublicDashboards(t *testing.T) { +func TestIntegrationPublicDashboardServiceImpl_ListPublicDashboards(t *testing.T) { features := featuremgmt.WithFeatures() testDB, cfg := db.InitTestDBWithCfg(t) dashStore, err := dashboardsDB.ProvideDashboardStore(testDB, cfg, features, tagimpl.ProvideService(testDB)) diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index 5597ae34e42..e98331326f6 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -47,7 +47,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestParseMetricRequest(t *testing.T) { +func TestIntegrationParseMetricRequest(t *testing.T) { t.Run("Test a simple single datasource query", func(t *testing.T) { tc := setup(t) mr := metricRequestWithQueries(t, `{ @@ -268,7 +268,7 @@ func TestParseMetricRequest(t *testing.T) { }) } -func TestQueryDataMultipleSources(t *testing.T) { +func TestIntegrationQueryDataMultipleSources(t *testing.T) { t.Run("can query multiple datasources", func(t *testing.T) { tc := setup(t) query1, err := simplejson.NewJson([]byte(` diff --git a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go index f7a8429114f..8c79e91cc6c 100644 --- a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go +++ b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go @@ -44,7 +44,7 @@ func SetupTestDataSourceSecretMigrationService(t *testing.T, sqlStore db.DB, kvS return migService } -func TestMigrate(t *testing.T) { +func TestIntegrationMigrate(t *testing.T) { t.Run("should migrate from legacy to unified with compatibility", func(t *testing.T) { sqlStore := db.InitTestDB(t) kvStore := kvstore.ProvideService(sqlStore) diff --git a/pkg/services/secrets/kvstore/sql_test.go b/pkg/services/secrets/kvstore/sql_test.go index eeee727b9f0..ad37b7a9b1c 100644 --- a/pkg/services/secrets/kvstore/sql_test.go +++ b/pkg/services/secrets/kvstore/sql_test.go @@ -30,7 +30,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestSecretsKVStoreSQL(t *testing.T) { +func TestIntegrationSecretsKVStoreSQL(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := manager.SetupTestService(t, fakes.NewFakeSecretsStore()) kv := NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) diff --git a/pkg/services/secrets/manager/manager_test.go b/pkg/services/secrets/manager/manager_test.go index 3efaef05e51..1f37c31cf8c 100644 --- a/pkg/services/secrets/manager/manager_test.go +++ b/pkg/services/secrets/manager/manager_test.go @@ -29,7 +29,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestSecretsService_EnvelopeEncryption(t *testing.T) { +func TestIntegrationSecretsService_EnvelopeEncryption(t *testing.T) { testDB := db.InitTestDB(t) store := database.ProvideSecretsStore(testDB) svc := SetupTestService(t, store) @@ -90,7 +90,7 @@ func TestSecretsService_EnvelopeEncryption(t *testing.T) { }) } -func TestSecretsService_DataKeys(t *testing.T) { +func TestIntegrationSecretsService_DataKeys(t *testing.T) { testDB := db.InitTestDB(t) store := database.ProvideSecretsStore(testDB) ctx := context.Background() @@ -168,7 +168,7 @@ func TestSecretsService_DataKeys(t *testing.T) { }) } -func TestSecretsService_UseCurrentProvider(t *testing.T) { +func TestIntegrationSecretsService_UseCurrentProvider(t *testing.T) { t.Run("When encryption_provider is not specified explicitly, should use 'secretKey' as a current provider", func(t *testing.T) { testDB := db.InitTestDB(t) svc := SetupTestService(t, database.ProvideSecretsStore(testDB)) @@ -273,7 +273,7 @@ func (f *fakeKMS) Provide() (map[secrets.ProviderID]secrets.Provider, error) { return providers, nil } -func TestSecretsService_Run(t *testing.T) { +func TestIntegrationSecretsService_Run(t *testing.T) { ctx := context.Background() testDB := db.InitTestDB(t) store := database.ProvideSecretsStore(testDB) @@ -323,7 +323,7 @@ func TestSecretsService_Run(t *testing.T) { }) } -func TestSecretsService_ReEncryptDataKeys(t *testing.T) { +func TestIntegrationSecretsService_ReEncryptDataKeys(t *testing.T) { ctx := context.Background() testDB := db.InitTestDB(t) store := database.ProvideSecretsStore(testDB) @@ -370,7 +370,7 @@ func TestSecretsService_ReEncryptDataKeys(t *testing.T) { }) } -func TestSecretsService_Decrypt(t *testing.T) { +func TestIntegrationSecretsService_Decrypt(t *testing.T) { ctx := context.Background() testDB := db.InitTestDB(t) store := database.ProvideSecretsStore(testDB) diff --git a/pkg/services/serviceaccounts/manager/service_test.go b/pkg/services/serviceaccounts/manager/service_test.go index 9cfdcf62777..2a0b5219c1b 100644 --- a/pkg/services/serviceaccounts/manager/service_test.go +++ b/pkg/services/serviceaccounts/manager/service_test.go @@ -117,7 +117,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestProvideServiceAccount_DeleteServiceAccount(t *testing.T) { +func TestIntegrationProvideServiceAccount_DeleteServiceAccount(t *testing.T) { storeMock := newServiceAccountStoreFake() acSvc := actest.FakeService{} pSvc := &actest.FakePermissionsService{} diff --git a/pkg/services/shorturls/shorturlimpl/shorturl_test.go b/pkg/services/shorturls/shorturlimpl/shorturl_test.go index e192fed067a..4db93f1ad7f 100644 --- a/pkg/services/shorturls/shorturlimpl/shorturl_test.go +++ b/pkg/services/shorturls/shorturlimpl/shorturl_test.go @@ -17,7 +17,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestShortURLService(t *testing.T) { +func TestIntegrationShortURLService(t *testing.T) { user := &user.SignedInUser{UserID: 1} store := db.InitTestDB(t) diff --git a/pkg/services/sqlstore/migrations/login_attempt_mig.go b/pkg/services/sqlstore/migrations/login_attempt_mig.go index fc5c00dd382..46b59f7b386 100644 --- a/pkg/services/sqlstore/migrations/login_attempt_mig.go +++ b/pkg/services/sqlstore/migrations/login_attempt_mig.go @@ -44,4 +44,9 @@ func addLoginAttemptMigrations(mg *Migrator) { mg.AddMigration("increase login_attempt.ip_address column length for IPv6 support", NewRawSQLMigration(""). Postgres("ALTER TABLE login_attempt ALTER COLUMN ip_address TYPE VARCHAR(50);"). Mysql("ALTER TABLE login_attempt MODIFY ip_address VARCHAR(50);")) + + // Use bigint for "created" column to store timestamps (INT is too small) + mg.AddMigration("alter table login_attempt alter column created type to bigint", NewRawSQLMigration(""). + Mysql("ALTER TABLE login_attempt MODIFY created BIGINT;"). + Postgres("ALTER TABLE login_attempt ALTER COLUMN created TYPE BIGINT;")) } diff --git a/pkg/services/sqlstore/migrations/ualert/tables.go b/pkg/services/sqlstore/migrations/ualert/tables.go index 1e7b058e0a2..d6d98ebfec9 100644 --- a/pkg/services/sqlstore/migrations/ualert/tables.go +++ b/pkg/services/sqlstore/migrations/ualert/tables.go @@ -300,6 +300,10 @@ func addAlertRuleMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64) mg.AddMigration("fix is_paused column for alert_rule table", migrator.NewRawSQLMigration(""). Postgres(`ALTER TABLE alert_rule ALTER COLUMN is_paused SET DEFAULT false; UPDATE alert_rule SET is_paused = false;`)) + + mg.AddMigration("alter table alert_rule alter column rule_group_idx type to bigint", migrator.NewRawSQLMigration(""). + Mysql("ALTER TABLE alert_rule MODIFY rule_group_idx BIGINT;"). + Postgres("ALTER TABLE alert_rule ALTER COLUMN rule_group_idx TYPE BIGINT;")) } var alertRuleVersionUDX_OrgIdRuleUIDVersion = &migrator.Index{Cols: []string{"rule_org_id", "rule_uid", "version"}, Type: migrator.UniqueIndex} @@ -371,6 +375,10 @@ func addAlertRuleVersionMigrations(mg *migrator.Migrator) { mg.AddMigration("fix is_paused column for alert_rule_version table", migrator.NewRawSQLMigration(""). Postgres(`ALTER TABLE alert_rule_version ALTER COLUMN is_paused SET DEFAULT false; UPDATE alert_rule_version SET is_paused = false;`)) + + mg.AddMigration("alter table alert_rule_version alter column rule_group_idx type to bigint", migrator.NewRawSQLMigration(""). + Mysql("ALTER TABLE alert_rule_version MODIFY rule_group_idx BIGINT;"). + Postgres("ALTER TABLE alert_rule_version ALTER COLUMN rule_group_idx TYPE BIGINT;")) } func addAlertmanagerConfigMigrations(mg *migrator.Migrator) { diff --git a/pkg/services/sqlstore/searchstore/filters_test.go b/pkg/services/sqlstore/searchstore/filters_test.go index 186eee1db7d..c13fd7dbc56 100644 --- a/pkg/services/sqlstore/searchstore/filters_test.go +++ b/pkg/services/sqlstore/searchstore/filters_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestFolderUIDFilter(t *testing.T) { +func TestIntegrationFolderUIDFilter(t *testing.T) { testCases := []struct { description string uids []string diff --git a/pkg/services/sqlstore/searchstore/search_test.go b/pkg/services/sqlstore/searchstore/search_test.go index e9eadd5fa22..d365d053a32 100644 --- a/pkg/services/sqlstore/searchstore/search_test.go +++ b/pkg/services/sqlstore/searchstore/search_test.go @@ -31,7 +31,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestBuilder_EqualResults_Basic(t *testing.T) { +func TestIntegrationBuilder_EqualResults_Basic(t *testing.T) { user := &user.SignedInUser{ UserID: 1, OrgID: 1, @@ -75,7 +75,7 @@ func TestBuilder_EqualResults_Basic(t *testing.T) { }, res) } -func TestBuilder_Pagination(t *testing.T) { +func TestIntegrationBuilder_Pagination(t *testing.T) { user := &user.SignedInUser{ UserID: 1, OrgID: 1, @@ -122,7 +122,7 @@ func TestBuilder_Pagination(t *testing.T) { assert.Equal(t, "P", resPg2[0].Title, "page 2 should start with the 16th dashboard") } -func TestBuilder_RBAC(t *testing.T) { +func TestIntegrationBuilder_RBAC(t *testing.T) { testsCases := []struct { desc string userPermissions []accesscontrol.Permission diff --git a/pkg/services/store/service_test.go b/pkg/services/store/service_test.go index bd3cc0ac964..7d9ecaa7884 100644 --- a/pkg/services/store/service_test.go +++ b/pkg/services/store/service_test.go @@ -73,7 +73,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestListFiles(t *testing.T) { +func TestIntegrationListFiles(t *testing.T) { roots := []storageRuntime{publicStaticFilesStorage} store := newStandardStorageService(db.InitTestDB(t), roots, func(orgId int64) []storageRuntime { @@ -94,7 +94,7 @@ func TestListFiles(t *testing.T) { experimental.CheckGoldenJSONFrame(t, "testdata", "public_testdata_js_libraries.golden", testDsFrame, true) } -func TestListFilesWithoutPermissions(t *testing.T) { +func TestIntegrationListFilesWithoutPermissions(t *testing.T) { roots := []storageRuntime{publicStaticFilesStorage} store := newStandardStorageService(db.InitTestDB(t), roots, func(orgId int64) []storageRuntime { @@ -128,7 +128,7 @@ func setupUploadStore(t *testing.T, authService storageAuthService) (StorageServ return store, mockStorage, storageName } -func TestShouldUploadWhenNoFileAlreadyExists(t *testing.T) { +func TestIntegrationShouldUploadWhenNoFileAlreadyExists(t *testing.T) { service, mockStorage, storageName := setupUploadStore(t, nil) fileName := "/myFile.jpg" @@ -147,7 +147,7 @@ func TestShouldUploadWhenNoFileAlreadyExists(t *testing.T) { require.NoError(t, err) } -func TestShouldFailUploadWithoutAccess(t *testing.T) { +func TestIntegrationShouldFailUploadWithoutAccess(t *testing.T) { service, _, storageName := setupUploadStore(t, denyAllAuthService) err := service.Upload(context.Background(), dummyUser, &UploadRequest{ @@ -158,7 +158,7 @@ func TestShouldFailUploadWithoutAccess(t *testing.T) { require.ErrorIs(t, err, ErrAccessDenied) } -func TestShouldFailUploadWhenFileAlreadyExists(t *testing.T) { +func TestIntegrationShouldFailUploadWhenFileAlreadyExists(t *testing.T) { service, mockStorage, storageName := setupUploadStore(t, nil) mockStorage.On("Get", mock.Anything, "/myFile.jpg", &filestorage.GetFileOptions{WithContents: false}).Return(&filestorage.File{Contents: make([]byte, 0)}, true, nil) @@ -171,7 +171,7 @@ func TestShouldFailUploadWhenFileAlreadyExists(t *testing.T) { require.ErrorIs(t, err, ErrFileAlreadyExists) } -func TestShouldDelegateFileDeletion(t *testing.T) { +func TestIntegrationShouldDelegateFileDeletion(t *testing.T) { service, mockStorage, storageName := setupUploadStore(t, nil) mockStorage.On("Delete", mock.Anything, "/myFile.jpg").Return(nil) @@ -180,7 +180,7 @@ func TestShouldDelegateFileDeletion(t *testing.T) { require.NoError(t, err) } -func TestShouldDelegateFolderCreation(t *testing.T) { +func TestIntegrationShouldDelegateFolderCreation(t *testing.T) { service, mockStorage, storageName := setupUploadStore(t, nil) mockStorage.On("CreateFolder", mock.Anything, "/nestedFolder/mostNestedFolder").Return(nil) @@ -189,7 +189,7 @@ func TestShouldDelegateFolderCreation(t *testing.T) { require.NoError(t, err) } -func TestShouldDelegateFolderDeletion(t *testing.T) { +func TestIntegrationShouldDelegateFolderDeletion(t *testing.T) { service, mockStorage, storageName := setupUploadStore(t, nil) cmds := []*DeleteFolderCmd{ { @@ -213,7 +213,7 @@ func TestShouldDelegateFolderDeletion(t *testing.T) { } } -func TestShouldUploadSvg(t *testing.T) { +func TestIntegrationShouldUploadSvg(t *testing.T) { service, mockStorage, storageName := setupUploadStore(t, nil) fileName := "/myFile.svg" @@ -232,7 +232,7 @@ func TestShouldUploadSvg(t *testing.T) { require.NoError(t, err) } -func TestShouldNotUploadHtmlDisguisedAsSvg(t *testing.T) { +func TestIntegrationShouldNotUploadHtmlDisguisedAsSvg(t *testing.T) { service, mockStorage, storageName := setupUploadStore(t, nil) fileName := "/myFile.svg" @@ -246,7 +246,7 @@ func TestShouldNotUploadHtmlDisguisedAsSvg(t *testing.T) { require.ErrorIs(t, err, ErrValidationFailed) } -func TestShouldNotUploadJpgDisguisedAsSvg(t *testing.T) { +func TestIntegrationShouldNotUploadJpgDisguisedAsSvg(t *testing.T) { service, mockStorage, storageName := setupUploadStore(t, nil) fileName := "/myFile.svg" @@ -260,7 +260,7 @@ func TestShouldNotUploadJpgDisguisedAsSvg(t *testing.T) { require.ErrorIs(t, err, ErrValidationFailed) } -func TestSetupWithNonUniqueStoragePrefixes(t *testing.T) { +func TestIntegrationSetupWithNonUniqueStoragePrefixes(t *testing.T) { prefix := "resources" sqlStorage := newSQLStorage(RootStorageMeta{}, prefix, "Testing upload", "dummy descr", &StorageSQLConfig{}, db.InitTestDB(t), 1, false) sqlStorage2 := newSQLStorage(RootStorageMeta{}, prefix, "Testing upload", "dummy descr", &StorageSQLConfig{}, db.InitTestDB(t), 1, false) @@ -276,7 +276,7 @@ func TestSetupWithNonUniqueStoragePrefixes(t *testing.T) { }, allowAllAuthService, cfg, nil) } -func TestContentRootWithNestedStorage(t *testing.T) { +func TestIntegrationContentRootWithNestedStorage(t *testing.T) { globalOrgID := int64(accesscontrol.GlobalOrgID) testDB := db.InitTestDB(t) orgedUser := &user.SignedInUser{OrgID: 1} @@ -522,7 +522,7 @@ func TestContentRootWithNestedStorage(t *testing.T) { } } -func TestShadowingExistingFolderByNestedContentRoot(t *testing.T) { +func TestIntegrationShadowingExistingFolderByNestedContentRoot(t *testing.T) { db := db.InitTestDB(t) ctx := context.Background() nestedStorage := newSQLStorage(RootStorageMeta{}, "nested", "Testing upload", "dummy descr", &StorageSQLConfig{}, db, accesscontrol.GlobalOrgID, true) diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index 6e2377052a9..b60bd4ac306 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -15,6 +15,7 @@ func (f *HelpFlags1) AddFlag(flag HelpFlags1) { *f |= flag } const ( HelpFlagGettingStartedPanelDismissed HelpFlags1 = 1 << iota HelpFlagDashboardHelp1 + HelpFlagEnterpriseAuth1 ) type UpdateEmailActionType string diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index e8d65d5f820..ccbef1138f8 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -274,7 +274,10 @@ func (ss *sqlStore) Update(ctx context.Context, cmd *user.UpdateUserCommand) err q = q.UseBool("is_admin") usr.IsAdmin = v }) - setOptional(cmd.HelpFlags1, func(v user.HelpFlags1) { usr.HelpFlags1 = *cmd.HelpFlags1 }) + setOptional(cmd.HelpFlags1, func(v user.HelpFlags1) { + q = q.MustCols("help_flags1") + usr.HelpFlags1 = *cmd.HelpFlags1 + }) if _, err := q.Update(&usr); err != nil { return err @@ -420,12 +423,17 @@ func (ss *sqlStore) Count(ctx context.Context) (int64, error) { func (ss *sqlStore) CountUserAccountsWithEmptyRole(ctx context.Context) (int64, error) { sb := &db.SQLBuilder{} - sb.Write("SELECT ") - sb.Write(`(SELECT COUNT (*) from ` + ss.dialect.Quote("org_user") + ` AS ou ` + - `LEFT JOIN ` + ss.dialect.Quote("user") + ` AS u ON u.id = ou.user_id ` + - `WHERE ou.role =? ` + - `AND u.is_service_account = ` + ss.dialect.BooleanStr(false) + ` ` + - `AND u.is_disabled = ` + ss.dialect.BooleanStr(false) + `) AS user_accounts_with_no_role`) + sb.Write(` + SELECT sub.user_accounts_with_no_role + FROM ( + SELECT COUNT(*) AS user_accounts_with_no_role + FROM ` + ss.dialect.Quote("org_user") + ` AS ou + LEFT JOIN ` + ss.dialect.Quote("user") + ` AS u ON u.id = ou.user_id + WHERE ou.role = ? + AND u.is_service_account = ` + ss.dialect.BooleanStr(false) + ` + AND u.is_disabled = ` + ss.dialect.BooleanStr(false) + ` + ) AS sub + `) sb.AddParams("None") var countStats int64 diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index ade6366a895..a3dfe791296 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -1021,7 +1021,7 @@ func createFiveTestUsers(t *testing.T, svc user.Service, fn func(i int) *user.Cr return users } -func TestMetricsUsage(t *testing.T) { +func TestIntegrationMetricsUsage(t *testing.T) { ss, cfg := db.InitTestDBWithCfg(t) userStore := ProvideStore(ss, setting.NewCfg()) quotaService := quotaimpl.ProvideService(ss, cfg) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 2e5d4bd1e10..c43d27664be 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -569,6 +569,7 @@ type Cfg struct { EnableSharding bool MemberlistBindAddr string MemberlistAdvertiseAddr string + MemberlistAdvertisePort int MemberlistJoinMember string MemberlistClusterLabel string MemberlistClusterLabelVerificationDisabled bool diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 23b08c1541b..2484d5c1a33 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -58,6 +58,7 @@ func (cfg *Cfg) setUnifiedStorageConfig() { cfg.EnableSharding = section.Key("enable_sharding").MustBool(false) cfg.MemberlistBindAddr = section.Key("memberlist_bind_addr").String() cfg.MemberlistAdvertiseAddr = section.Key("memberlist_advertise_addr").String() + cfg.MemberlistAdvertisePort = section.Key("memberlist_advertise_port").MustInt(7946) cfg.MemberlistJoinMember = section.Key("memberlist_join_member").String() cfg.MemberlistClusterLabel = section.Key("memberlist_cluster_label").String() cfg.MemberlistClusterLabelVerificationDisabled = section.Key("memberlist_cluster_label_verification_disabled").MustBool(false) diff --git a/pkg/setting/settings_zanzana.go b/pkg/setting/settings_zanzana.go index 410c62f01d9..3b4fff83b78 100644 --- a/pkg/setting/settings_zanzana.go +++ b/pkg/setting/settings_zanzana.go @@ -52,7 +52,7 @@ type ZanzanaServerSettings struct { func (cfg *Cfg) readZanzanaSettings() { zc := ZanzanaClientSettings{} - clientSec := cfg.Raw.Section("zanzana.client") + clientSec := cfg.SectionWithEnvOverrides("zanzana.client") zc.Mode = ZanzanaMode(clientSec.Key("mode").MustString("embedded")) validModes := []ZanzanaMode{ZanzanaModeEmbedded, ZanzanaModeClient} @@ -70,7 +70,7 @@ func (cfg *Cfg) readZanzanaSettings() { cfg.ZanzanaClient = zc zs := ZanzanaServerSettings{} - serverSec := cfg.Raw.Section("zanzana.server") + serverSec := cfg.SectionWithEnvOverrides("zanzana.server") zs.OpenFGAHttpAddr = serverSec.Key("http_addr").MustString("127.0.0.1:8080") zs.CheckQueryCache = serverSec.Key("check_query_cache").MustBool(true) diff --git a/pkg/storage/legacysql/dualwrite/dualwriter.go b/pkg/storage/legacysql/dualwrite/dualwriter.go index 3eb01bea499..b46472be075 100644 --- a/pkg/storage/legacysql/dualwrite/dualwriter.go +++ b/pkg/storage/legacysql/dualwrite/dualwriter.go @@ -100,7 +100,7 @@ func (d *dualWriter) List(ctx context.Context, options *metainternalversion.List // This can happen, as unified storage iteration is doing paging not only based on the provided limit, // but also based on the response size. This check prevents starting the new iteration again. if options.Continue != "" && legacyToken == "" { - return nil, nil + return d.NewList(), nil } // In some cases, where the stores are not in sync yet, the unified storage continue token might already diff --git a/pkg/storage/secret/encryption/data/data_key_create.sql b/pkg/storage/secret/encryption/data/data_key_create.sql new file mode 100644 index 00000000000..4db1ed9667a --- /dev/null +++ b/pkg/storage/secret/encryption/data/data_key_create.sql @@ -0,0 +1,19 @@ +INSERT INTO {{ .Ident "secret_data_key" }} ( + {{ .Ident "uid" }}, + {{ .Ident "namespace" }}, + {{ .Ident "label" }}, + {{ .Ident "provider" }}, + {{ .Ident "encrypted_data" }}, + {{ .Ident "active" }}, + {{ .Ident "created" }}, + {{ .Ident "updated" }} +) VALUES ( + {{ .Arg .Row.UID }}, + {{ .Arg .Row.Namespace }}, + {{ .Arg .Row.Label }}, + {{ .Arg .Row.Provider }}, + {{ .Arg .Row.EncryptedData }}, + {{ .Arg .Row.Active }}, + {{ .Arg .Row.Created }}, + {{ .Arg .Row.Updated }} +); diff --git a/pkg/storage/secret/encryption/data/data_key_delete.sql b/pkg/storage/secret/encryption/data/data_key_delete.sql new file mode 100644 index 00000000000..70a98264050 --- /dev/null +++ b/pkg/storage/secret/encryption/data/data_key_delete.sql @@ -0,0 +1,4 @@ +DELETE FROM {{ .Ident "secret_data_key" }} +WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND + {{ .Ident "uid" }} = {{ .Arg .UID }} +; diff --git a/pkg/storage/secret/encryption/data/data_key_disable.sql b/pkg/storage/secret/encryption/data/data_key_disable.sql new file mode 100644 index 00000000000..d11311ae0fd --- /dev/null +++ b/pkg/storage/secret/encryption/data/data_key_disable.sql @@ -0,0 +1,8 @@ +UPDATE + {{ .Ident "secret_data_key" }} +SET + {{ .Ident "active" }} = false, + {{ .Ident "updated" }} = {{ .Arg .Updated }} +WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND + {{ .Ident "active" }} = true +; diff --git a/pkg/storage/secret/encryption/data/data_key_list.sql b/pkg/storage/secret/encryption/data/data_key_list.sql new file mode 100644 index 00000000000..b12a4483f2a --- /dev/null +++ b/pkg/storage/secret/encryption/data/data_key_list.sql @@ -0,0 +1,13 @@ +SELECT + {{ .Ident "uid" }}, + {{ .Ident "namespace" }}, + {{ .Ident "label" }}, + {{ .Ident "provider" }}, + {{ .Ident "encrypted_data" }}, + {{ .Ident "active" }}, + {{ .Ident "created" }}, + {{ .Ident "updated" }} +FROM + {{ .Ident "secret_data_key" }} +WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} +; diff --git a/pkg/storage/secret/encryption/data/data_key_read.sql b/pkg/storage/secret/encryption/data/data_key_read.sql new file mode 100644 index 00000000000..7b49dd32a19 --- /dev/null +++ b/pkg/storage/secret/encryption/data/data_key_read.sql @@ -0,0 +1,14 @@ +SELECT + {{ .Ident "uid" }}, + {{ .Ident "namespace" }}, + {{ .Ident "label" }}, + {{ .Ident "provider" }}, + {{ .Ident "encrypted_data" }}, + {{ .Ident "active" }}, + {{ .Ident "created" }}, + {{ .Ident "updated" }} +FROM + {{ .Ident "secret_data_key" }} +WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND + {{ .Ident "uid" }} = {{ .Arg .UID }} +; diff --git a/pkg/storage/secret/encryption/data/data_key_read_current.sql b/pkg/storage/secret/encryption/data/data_key_read_current.sql new file mode 100644 index 00000000000..e55751ca044 --- /dev/null +++ b/pkg/storage/secret/encryption/data/data_key_read_current.sql @@ -0,0 +1,15 @@ +SELECT + {{ .Ident "uid" }}, + {{ .Ident "namespace" }}, + {{ .Ident "label" }}, + {{ .Ident "provider" }}, + {{ .Ident "encrypted_data" }}, + {{ .Ident "active" }}, + {{ .Ident "created" }}, + {{ .Ident "updated" }} +FROM + {{ .Ident "secret_data_key" }} +WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND + {{ .Ident "label" }} = {{ .Arg .Label }} AND + {{ .Ident "active" }} = true +; diff --git a/pkg/storage/secret/encryption/data_key_model.go b/pkg/storage/secret/encryption/data_key_model.go new file mode 100644 index 00000000000..3153e3cd25f --- /dev/null +++ b/pkg/storage/secret/encryption/data_key_model.go @@ -0,0 +1,19 @@ +package encryption + +import ( + "time" + + "github.com/grafana/grafana/pkg/registry/apis/secret/encryption" +) + +// SecretDataKey does not have a mirrored K8s resource +type SecretDataKey struct { + UID string + Active bool + Namespace string + Label string + Provider encryption.ProviderID + EncryptedData []byte + Created time.Time + Updated time.Time +} diff --git a/pkg/storage/secret/encryption/data_key_store.go b/pkg/storage/secret/encryption/data_key_store.go new file mode 100644 index 00000000000..15b7dfe2f4d --- /dev/null +++ b/pkg/storage/secret/encryption/data_key_store.go @@ -0,0 +1,332 @@ +package encryption + +import ( + "context" + "fmt" + "time" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// encryptionStoreImpl is the actual implementation of the data key storage. +type encryptionStoreImpl struct { + db contracts.Database + dialect sqltemplate.Dialect + tracer trace.Tracer + log log.Logger +} + +func ProvideDataKeyStorage( + db contracts.Database, + tracer trace.Tracer, + features featuremgmt.FeatureToggles, + registerer prometheus.Registerer, +) (contracts.DataKeyStorage, error) { + if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) || + !features.IsEnabledGlobally(featuremgmt.FlagSecretsManagementAppPlatform) { + return &encryptionStoreImpl{}, nil + } + + store := &encryptionStoreImpl{ + db: db, + dialect: sqltemplate.DialectForDriver(db.DriverName()), + tracer: tracer, + log: log.New("encryption.store"), + } + + return store, nil +} + +func (ss *encryptionStoreImpl) GetDataKey(ctx context.Context, namespace, uid string) (*contracts.SecretDataKey, error) { + ctx, span := ss.tracer.Start(ctx, "DataKeyStorage.GetDataKey", trace.WithAttributes( + attribute.String("namespace", namespace), + attribute.String("uid", uid), + )) + defer span.End() + + req := readDataKey{ + SQLTemplate: sqltemplate.New(ss.dialect), + Namespace: namespace, + UID: uid, + } + + query, err := sqltemplate.Execute(sqlDataKeyRead, req) + if err != nil { + return nil, fmt.Errorf("execute template %q: %w", sqlDataKeyRead.Name(), err) + } + + res, err := ss.db.QueryContext(ctx, query, req.GetArgs()...) + if err != nil { + return nil, fmt.Errorf("getting data key row: %w", err) + } + defer func() { _ = res.Close() }() + + if !res.Next() { + return nil, contracts.ErrDataKeyNotFound + } + + var dataKey SecretDataKey + err = res.Scan( + &dataKey.UID, + &dataKey.Namespace, + &dataKey.Label, + &dataKey.Provider, + &dataKey.EncryptedData, + &dataKey.Active, + &dataKey.Created, + &dataKey.Updated, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan data key row: %w", err) + } + if err := res.Err(); err != nil { + return nil, fmt.Errorf("read rows error: %w", err) + } + + return &contracts.SecretDataKey{ + UID: dataKey.UID, + Namespace: dataKey.Namespace, + Label: dataKey.Label, + Provider: dataKey.Provider, + EncryptedData: dataKey.EncryptedData, + Active: dataKey.Active, + Created: dataKey.Created, + Updated: dataKey.Updated, + }, nil +} + +func (ss *encryptionStoreImpl) GetCurrentDataKey(ctx context.Context, namespace, label string) (*contracts.SecretDataKey, error) { + ctx, span := ss.tracer.Start(ctx, "DataKeyStorage.GetCurrentDataKey", trace.WithAttributes( + attribute.String("namespace", namespace), + attribute.String("label", label), + )) + defer span.End() + + req := readCurrentDataKey{ + SQLTemplate: sqltemplate.New(ss.dialect), + Namespace: namespace, + Label: label, + } + + query, err := sqltemplate.Execute(sqlDataKeyReadCurrent, req) + if err != nil { + return nil, fmt.Errorf("execute template %q: %w", sqlDataKeyReadCurrent.Name(), err) + } + + res, err := ss.db.QueryContext(ctx, query, req.GetArgs()...) + if err != nil { + return nil, fmt.Errorf("getting current data key row: %w", err) + } + defer func() { _ = res.Close() }() + + if !res.Next() { + return nil, contracts.ErrDataKeyNotFound + } + + var dataKey SecretDataKey + err = res.Scan( + &dataKey.UID, + &dataKey.Namespace, + &dataKey.Label, + &dataKey.Provider, + &dataKey.EncryptedData, + &dataKey.Active, + &dataKey.Created, + &dataKey.Updated, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan data key row: %w", err) + } + if err := res.Err(); err != nil { + return nil, fmt.Errorf("read rows error: %w", err) + } + + return &contracts.SecretDataKey{ + UID: dataKey.UID, + Namespace: dataKey.Namespace, + Label: dataKey.Label, + Provider: dataKey.Provider, + EncryptedData: dataKey.EncryptedData, + Active: dataKey.Active, + Created: dataKey.Created, + Updated: dataKey.Updated, + }, nil +} + +func (ss *encryptionStoreImpl) GetAllDataKeys(ctx context.Context, namespace string) ([]*contracts.SecretDataKey, error) { + ctx, span := ss.tracer.Start(ctx, "DataKeyStorage.GetAllDataKeys", trace.WithAttributes( + attribute.String("namespace", namespace), + )) + defer span.End() + + req := listDataKeys{ + SQLTemplate: sqltemplate.New(ss.dialect), + Namespace: namespace, + } + + query, err := sqltemplate.Execute(sqlDataKeyList, req) + if err != nil { + return nil, fmt.Errorf("execute template %q: %w", sqlDataKeyList.Name(), err) + } + + rows, err := ss.db.QueryContext(ctx, query, req.GetArgs()...) + if err != nil { + return nil, fmt.Errorf("listing data keys %q: %w", sqlDataKeyList.Name(), err) + } + defer func() { _ = rows.Close() }() + + dataKeys := make([]*contracts.SecretDataKey, 0) + for rows.Next() { + var row SecretDataKey + err = rows.Scan( + &row.UID, + &row.Namespace, + &row.Label, + &row.Provider, + &row.EncryptedData, + &row.Active, + &row.Created, + &row.Updated, + ) + if err != nil { + return nil, fmt.Errorf("error reading data key row: %w", err) + } + + dataKeys = append(dataKeys, &contracts.SecretDataKey{ + UID: row.UID, + Namespace: row.Namespace, + Label: row.Label, + Provider: row.Provider, + EncryptedData: row.EncryptedData, + Active: row.Active, + Created: row.Created, + Updated: row.Updated, + }) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read rows error: %w", err) + } + + return dataKeys, nil +} + +func (ss *encryptionStoreImpl) CreateDataKey(ctx context.Context, dataKey *contracts.SecretDataKey) error { + ctx, span := ss.tracer.Start(ctx, "DataKeyStorage.CreateDataKey", trace.WithAttributes( + attribute.String("uid", dataKey.UID), + attribute.String("namespace", dataKey.Namespace), + attribute.Bool("active", dataKey.Active), + )) + defer span.End() + + if !dataKey.Active { + return fmt.Errorf("cannot insert deactivated data keys") + } + + dataKey.Created = time.Now() + dataKey.Updated = dataKey.Created + + req := createDataKey{ + SQLTemplate: sqltemplate.New(ss.dialect), + Row: dataKey, + } + + query, err := sqltemplate.Execute(sqlDataKeyCreate, req) + if err != nil { + return fmt.Errorf("execute template %q: %w", sqlDataKeyCreate.Name(), err) + } + + result, err := ss.db.ExecContext(ctx, query, req.GetArgs()...) + if err != nil { + return fmt.Errorf("inserting data key row: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("getting rows affected: %w", err) + } + + if rowsAffected != 1 { + return fmt.Errorf("expected 1 row affected, but affected %d", rowsAffected) + } + + return nil +} + +func (ss *encryptionStoreImpl) DisableDataKeys(ctx context.Context, namespace string) error { + ctx, span := ss.tracer.Start(ctx, "DataKeyStorage.DisableDataKeys", trace.WithAttributes( + attribute.String("namespace", namespace), + )) + defer span.End() + + req := disableDataKeys{ + SQLTemplate: sqltemplate.New(ss.dialect), + Namespace: namespace, + Updated: time.Now(), + } + + query, err := sqltemplate.Execute(sqlDataKeyDisable, req) + if err != nil { + return fmt.Errorf("execute template %q: %w", sqlDataKeyDisable.Name(), err) + } + + result, err := ss.db.ExecContext(ctx, query, req.GetArgs()...) + if err != nil { + return fmt.Errorf("updating data key row: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("getting rows affected: %w", err) + } + + if rowsAffected != 1 { + return fmt.Errorf("expected 1 row affected, but affected %d", rowsAffected) + } + + return nil +} + +func (ss *encryptionStoreImpl) DeleteDataKey(ctx context.Context, namespace, uid string) error { + ctx, span := ss.tracer.Start(ctx, "DataKeyStorage.DeleteDataKey", trace.WithAttributes( + attribute.String("uid", uid), + attribute.String("namespace", namespace), + )) + defer span.End() + + if len(uid) == 0 { + return fmt.Errorf("data key id is missing") + } + + req := deleteDataKey{ + SQLTemplate: sqltemplate.New(ss.dialect), + Namespace: namespace, + UID: uid, + } + + query, err := sqltemplate.Execute(sqlDataKeyDelete, req) + if err != nil { + return fmt.Errorf("execute template %q: %w", sqlDataKeyDelete.Name(), err) + } + + result, err := ss.db.ExecContext(ctx, query, req.GetArgs()...) + if err != nil { + return fmt.Errorf("deleting data key is %s in namespace %s: %w", uid, namespace, err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("getting rows affected: %w", err) + } + + if rowsAffected != 1 { + return fmt.Errorf("bug: deleted more than one row from the data key table, should delete only one at a time: deleted=%v", rowsAffected) + } + + return nil +} diff --git a/pkg/storage/secret/encryption/data_key_store_test.go b/pkg/storage/secret/encryption/data_key_store_test.go new file mode 100644 index 00000000000..2bbbecbf5a6 --- /dev/null +++ b/pkg/storage/secret/encryption/data_key_store_test.go @@ -0,0 +1,128 @@ +package encryption + +import ( + "context" + "encoding/base64" + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace/noop" + + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/encryption" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/storage/secret/database" + "github.com/grafana/grafana/pkg/storage/secret/migrator" + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +const ( + passThroughProvider encryption.ProviderID = "PASS_THROUGH_PROVIDER" + base64Provider encryption.ProviderID = "BASE64_PROVIDER" +) + +func TestEncryptionStoreImpl_DataKeyLifecycle(t *testing.T) { + // Initialize data key storage with a fake db + testDB := sqlstore.NewTestStore(t, sqlstore.WithMigrator(migrator.New())) + tracer := noop.NewTracerProvider().Tracer("test") + features := featuremgmt.WithFeatures(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, featuremgmt.FlagSecretsManagementAppPlatform) + store, err := ProvideDataKeyStorage(database.ProvideDatabase(testDB), tracer, features, nil) + require.NoError(t, err) + + ctx := context.Background() + + // Define a data key whose lifecycle we will test + dataKey := &contracts.SecretDataKey{ + UID: "test-uid", + Namespace: "test-namespace", + Label: "test-label", + Active: true, + EncryptedData: []byte("test-data"), + Provider: passThroughProvider, // so that the Decrypt() method just gets us the input test data + } + + // Define a second data key in a different namespace that should remain undisturbed + unchangingDataKey := &contracts.SecretDataKey{ + UID: "static-uid", + Namespace: "static-namespace", + Label: "static-label", + Active: true, + EncryptedData: []byte("static-data"), + } + + // Test CreateDataKey + err = store.CreateDataKey(ctx, dataKey) + require.NoError(t, err) + err = store.CreateDataKey(ctx, unchangingDataKey) + require.NoError(t, err) + + // Test GetDataKey + retrievedKey, err := store.GetDataKey(ctx, "test-namespace", "test-uid") + require.NoError(t, err) + require.Equal(t, dataKey.UID, retrievedKey.UID) + require.Equal(t, dataKey.Namespace, retrievedKey.Namespace) + + // Test GetCurrentDataKey + currentKey, err := store.GetCurrentDataKey(ctx, "test-namespace", "test-label") + require.NoError(t, err) + require.Equal(t, dataKey.UID, currentKey.UID) + require.Equal(t, dataKey.Namespace, currentKey.Namespace) + + // Test GetAllDataKeys + allKeys, err := store.GetAllDataKeys(ctx, "test-namespace") + require.NoError(t, err) + require.Len(t, allKeys, 1) + require.Equal(t, dataKey.UID, allKeys[0].UID) + + // Test DisableDataKeys + err = store.DisableDataKeys(ctx, "test-namespace") + require.NoError(t, err) + + // Verify that the data key is disabled + disabledKey, err := store.GetDataKey(ctx, "test-namespace", "test-uid") + require.NoError(t, err) + require.False(t, disabledKey.Active) + + // Test DeleteDataKey + err = store.DeleteDataKey(ctx, "test-namespace", "test-uid") + require.NoError(t, err) + + // Verify that the data key is deleted + _, err = store.GetDataKey(ctx, "test-namespace", "test-uid") + require.Error(t, err) + require.Equal(t, contracts.ErrDataKeyNotFound, err) + + // Verify that the unchanging data key still exists and is active + staticKey, err := store.GetDataKey(ctx, "static-namespace", "static-uid") + require.NoError(t, err) + require.Equal(t, unchangingDataKey.UID, staticKey.UID) + require.Equal(t, unchangingDataKey.Namespace, staticKey.Namespace) + require.True(t, staticKey.Active) +} + +type PassThroughEncryptionProvider struct{} + +func (d *PassThroughEncryptionProvider) Encrypt(ctx context.Context, blob []byte) ([]byte, error) { + return blob, nil +} + +func (d *PassThroughEncryptionProvider) Decrypt(ctx context.Context, blob []byte) ([]byte, error) { + return blob, nil +} + +type Base64EncryptionProvider struct{} + +func (d *Base64EncryptionProvider) Encrypt(ctx context.Context, blob []byte) ([]byte, error) { + r := base64.RawStdEncoding.EncodeToString(blob) + return []byte(r), nil +} + +func (d *Base64EncryptionProvider) Decrypt(ctx context.Context, blob []byte) ([]byte, error) { + r, err := base64.RawStdEncoding.DecodeString(string(blob)) + return r, err +} diff --git a/pkg/storage/secret/encryption/query.go b/pkg/storage/secret/encryption/query.go index 5aa98d229cb..1e6a8b13609 100644 --- a/pkg/storage/secret/encryption/query.go +++ b/pkg/storage/secret/encryption/query.go @@ -4,7 +4,9 @@ import ( "embed" "fmt" "text/template" + "time" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) @@ -19,6 +21,13 @@ var ( sqlEncryptedValueRead = mustTemplate("encrypted_value_read.sql") sqlEncryptedValueUpdate = mustTemplate("encrypted_value_update.sql") sqlEncryptedValueDelete = mustTemplate("encrypted_value_delete.sql") + + sqlDataKeyCreate = mustTemplate("data_key_create.sql") + sqlDataKeyRead = mustTemplate("data_key_read.sql") + sqlDataKeyReadCurrent = mustTemplate("data_key_read_current.sql") + sqlDataKeyList = mustTemplate("data_key_list.sql") + sqlDataKeyDisable = mustTemplate("data_key_disable.sql") + sqlDataKeyDelete = mustTemplate("data_key_delete.sql") ) // TODO: Move this to a common place so that all stores can use @@ -79,3 +88,52 @@ type deleteEncryptedValue struct { func (r deleteEncryptedValue) Validate() error { return nil // TODO } + +/*************************************/ +/**-- Data Key Queries --**/ +/*************************************/ +type createDataKey struct { + sqltemplate.SQLTemplate + Row *contracts.SecretDataKey +} + +func (r createDataKey) Validate() error { return nil } + +type readDataKey struct { + sqltemplate.SQLTemplate + Namespace string + UID string +} + +func (r readDataKey) Validate() error { return nil } + +type readCurrentDataKey struct { + sqltemplate.SQLTemplate + Namespace string + Label string +} + +func (r readCurrentDataKey) Validate() error { return nil } + +type listDataKeys struct { + sqltemplate.SQLTemplate + Namespace string +} + +func (r listDataKeys) Validate() error { return nil } + +type disableDataKeys struct { + sqltemplate.SQLTemplate + Namespace string + Updated time.Time +} + +func (r disableDataKeys) Validate() error { return nil } + +type deleteDataKey struct { + sqltemplate.SQLTemplate + Namespace string + UID string +} + +func (r deleteDataKey) Validate() error { return nil } diff --git a/pkg/storage/secret/encryption/query_test.go b/pkg/storage/secret/encryption/query_test.go index d102268aabc..371d5bfc887 100644 --- a/pkg/storage/secret/encryption/query_test.go +++ b/pkg/storage/secret/encryption/query_test.go @@ -3,7 +3,9 @@ package encryption import ( "testing" "text/template" + "time" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate/mocks" ) @@ -61,3 +63,98 @@ func TestEncryptedValueQueries(t *testing.T) { }, }) } + +func TestDataKeyQueries(t *testing.T) { + mocks.CheckQuerySnapshots(t, mocks.TemplateTestSetup{ + RootDir: "testdata", + Templates: map[*template.Template][]mocks.TemplateTestCase{ + sqlDataKeyCreate: { + { + Name: "create", + Data: &createDataKey{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Row: &contracts.SecretDataKey{ + UID: "abc123", + Active: true, + Namespace: "ns", + Label: "label", + Provider: "provider", + EncryptedData: []byte("secret"), + }, + }, + }, + { + Name: "create-not-active", + Data: &createDataKey{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Row: &contracts.SecretDataKey{ + UID: "abc123", + Active: false, + Namespace: "ns", + Label: "label", + Provider: "provider", + EncryptedData: []byte("secret"), + }, + }, + }, + }, + sqlDataKeyRead: { + { + Name: "read", + Data: &readDataKey{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Namespace: "ns", + UID: "abc123", + }, + }, + }, + sqlDataKeyReadCurrent: { + { + Name: "read_current", + Data: &readCurrentDataKey{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Namespace: "ns", + Label: "label", + }, + }, + }, + sqlDataKeyList: { + { + Name: "list", + Data: &listDataKeys{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Namespace: "ns", + }, + }, + }, + sqlDataKeyDisable: { + { + Name: "disable", + Data: &disableDataKeys{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Namespace: "ns", + Updated: time.Unix(1735689600, 0).UTC(), + }, + }, + }, + sqlDataKeyDelete: { + { + Name: "delete", + Data: &deleteDataKey{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Namespace: "ns", + UID: "abc123", + }, + }, + { + Name: "delete-no-uid", + Data: &deleteDataKey{ + SQLTemplate: mocks.NewTestingSQLTemplate(), + Namespace: "ns", + UID: "", + }, + }, + }, + }, + }) +} diff --git a/pkg/storage/secret/encryption/testdata/mysql--data_key_create-create-not-active.sql b/pkg/storage/secret/encryption/testdata/mysql--data_key_create-create-not-active.sql new file mode 100755 index 00000000000..8b6d4639625 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/mysql--data_key_create-create-not-active.sql @@ -0,0 +1,19 @@ +INSERT INTO `secret_data_key` ( + `uid`, + `namespace`, + `label`, + `provider`, + `encrypted_data`, + `active`, + `created`, + `updated` +) VALUES ( + 'abc123', + 'ns', + 'label', + 'provider', + '[115 101 99 114 101 116]', + FALSE, + '0001-01-01 00:00:00 +0000 UTC', + '0001-01-01 00:00:00 +0000 UTC' +); diff --git a/pkg/storage/secret/encryption/testdata/mysql--data_key_create-create.sql b/pkg/storage/secret/encryption/testdata/mysql--data_key_create-create.sql new file mode 100755 index 00000000000..7527493dd18 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/mysql--data_key_create-create.sql @@ -0,0 +1,19 @@ +INSERT INTO `secret_data_key` ( + `uid`, + `namespace`, + `label`, + `provider`, + `encrypted_data`, + `active`, + `created`, + `updated` +) VALUES ( + 'abc123', + 'ns', + 'label', + 'provider', + '[115 101 99 114 101 116]', + TRUE, + '0001-01-01 00:00:00 +0000 UTC', + '0001-01-01 00:00:00 +0000 UTC' +); diff --git a/pkg/storage/secret/encryption/testdata/mysql--data_key_delete-delete-no-uid.sql b/pkg/storage/secret/encryption/testdata/mysql--data_key_delete-delete-no-uid.sql new file mode 100755 index 00000000000..17f149e7e4c --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/mysql--data_key_delete-delete-no-uid.sql @@ -0,0 +1,4 @@ +DELETE FROM `secret_data_key` +WHERE `namespace` = 'ns' AND + `uid` = '' +; diff --git a/pkg/storage/secret/encryption/testdata/mysql--data_key_delete-delete.sql b/pkg/storage/secret/encryption/testdata/mysql--data_key_delete-delete.sql new file mode 100755 index 00000000000..a548c739dc6 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/mysql--data_key_delete-delete.sql @@ -0,0 +1,4 @@ +DELETE FROM `secret_data_key` +WHERE `namespace` = 'ns' AND + `uid` = 'abc123' +; diff --git a/pkg/storage/secret/encryption/testdata/mysql--data_key_disable-disable.sql b/pkg/storage/secret/encryption/testdata/mysql--data_key_disable-disable.sql new file mode 100755 index 00000000000..bfed5dcb7ed --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/mysql--data_key_disable-disable.sql @@ -0,0 +1,8 @@ +UPDATE + `secret_data_key` +SET + `active` = false, + `updated` = '2025-01-01 00:00:00 +0000 UTC' +WHERE `namespace` = 'ns' AND + `active` = true +; diff --git a/pkg/storage/secret/encryption/testdata/mysql--data_key_list-list.sql b/pkg/storage/secret/encryption/testdata/mysql--data_key_list-list.sql new file mode 100755 index 00000000000..373d1fe08b5 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/mysql--data_key_list-list.sql @@ -0,0 +1,13 @@ +SELECT + `uid`, + `namespace`, + `label`, + `provider`, + `encrypted_data`, + `active`, + `created`, + `updated` +FROM + `secret_data_key` +WHERE `namespace` = 'ns' +; diff --git a/pkg/storage/secret/encryption/testdata/mysql--data_key_read-read.sql b/pkg/storage/secret/encryption/testdata/mysql--data_key_read-read.sql new file mode 100755 index 00000000000..44411b7b1bc --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/mysql--data_key_read-read.sql @@ -0,0 +1,14 @@ +SELECT + `uid`, + `namespace`, + `label`, + `provider`, + `encrypted_data`, + `active`, + `created`, + `updated` +FROM + `secret_data_key` +WHERE `namespace` = 'ns' AND + `uid` = 'abc123' +; diff --git a/pkg/storage/secret/encryption/testdata/mysql--data_key_read_current-read_current.sql b/pkg/storage/secret/encryption/testdata/mysql--data_key_read_current-read_current.sql new file mode 100755 index 00000000000..ea2da33f0f2 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/mysql--data_key_read_current-read_current.sql @@ -0,0 +1,15 @@ +SELECT + `uid`, + `namespace`, + `label`, + `provider`, + `encrypted_data`, + `active`, + `created`, + `updated` +FROM + `secret_data_key` +WHERE `namespace` = 'ns' AND + `label` = 'label' AND + `active` = true +; diff --git a/pkg/storage/secret/encryption/testdata/postgres--data_key_create-create-not-active.sql b/pkg/storage/secret/encryption/testdata/postgres--data_key_create-create-not-active.sql new file mode 100755 index 00000000000..184c22e5858 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/postgres--data_key_create-create-not-active.sql @@ -0,0 +1,19 @@ +INSERT INTO "secret_data_key" ( + "uid", + "namespace", + "label", + "provider", + "encrypted_data", + "active", + "created", + "updated" +) VALUES ( + 'abc123', + 'ns', + 'label', + 'provider', + '[115 101 99 114 101 116]', + FALSE, + '0001-01-01 00:00:00 +0000 UTC', + '0001-01-01 00:00:00 +0000 UTC' +); diff --git a/pkg/storage/secret/encryption/testdata/postgres--data_key_create-create.sql b/pkg/storage/secret/encryption/testdata/postgres--data_key_create-create.sql new file mode 100755 index 00000000000..b100d299a7e --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/postgres--data_key_create-create.sql @@ -0,0 +1,19 @@ +INSERT INTO "secret_data_key" ( + "uid", + "namespace", + "label", + "provider", + "encrypted_data", + "active", + "created", + "updated" +) VALUES ( + 'abc123', + 'ns', + 'label', + 'provider', + '[115 101 99 114 101 116]', + TRUE, + '0001-01-01 00:00:00 +0000 UTC', + '0001-01-01 00:00:00 +0000 UTC' +); diff --git a/pkg/storage/secret/encryption/testdata/postgres--data_key_delete-delete-no-uid.sql b/pkg/storage/secret/encryption/testdata/postgres--data_key_delete-delete-no-uid.sql new file mode 100755 index 00000000000..13bf38a568e --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/postgres--data_key_delete-delete-no-uid.sql @@ -0,0 +1,4 @@ +DELETE FROM "secret_data_key" +WHERE "namespace" = 'ns' AND + "uid" = '' +; diff --git a/pkg/storage/secret/encryption/testdata/postgres--data_key_delete-delete.sql b/pkg/storage/secret/encryption/testdata/postgres--data_key_delete-delete.sql new file mode 100755 index 00000000000..679f26f2f10 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/postgres--data_key_delete-delete.sql @@ -0,0 +1,4 @@ +DELETE FROM "secret_data_key" +WHERE "namespace" = 'ns' AND + "uid" = 'abc123' +; diff --git a/pkg/storage/secret/encryption/testdata/postgres--data_key_disable-disable.sql b/pkg/storage/secret/encryption/testdata/postgres--data_key_disable-disable.sql new file mode 100755 index 00000000000..bfb4ee0eb1d --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/postgres--data_key_disable-disable.sql @@ -0,0 +1,8 @@ +UPDATE + "secret_data_key" +SET + "active" = false, + "updated" = '2025-01-01 00:00:00 +0000 UTC' +WHERE "namespace" = 'ns' AND + "active" = true +; diff --git a/pkg/storage/secret/encryption/testdata/postgres--data_key_list-list.sql b/pkg/storage/secret/encryption/testdata/postgres--data_key_list-list.sql new file mode 100755 index 00000000000..2233ed1cd20 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/postgres--data_key_list-list.sql @@ -0,0 +1,13 @@ +SELECT + "uid", + "namespace", + "label", + "provider", + "encrypted_data", + "active", + "created", + "updated" +FROM + "secret_data_key" +WHERE "namespace" = 'ns' +; diff --git a/pkg/storage/secret/encryption/testdata/postgres--data_key_read-read.sql b/pkg/storage/secret/encryption/testdata/postgres--data_key_read-read.sql new file mode 100755 index 00000000000..94bf46eb2c8 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/postgres--data_key_read-read.sql @@ -0,0 +1,14 @@ +SELECT + "uid", + "namespace", + "label", + "provider", + "encrypted_data", + "active", + "created", + "updated" +FROM + "secret_data_key" +WHERE "namespace" = 'ns' AND + "uid" = 'abc123' +; diff --git a/pkg/storage/secret/encryption/testdata/postgres--data_key_read_current-read_current.sql b/pkg/storage/secret/encryption/testdata/postgres--data_key_read_current-read_current.sql new file mode 100755 index 00000000000..cd9438a7fb0 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/postgres--data_key_read_current-read_current.sql @@ -0,0 +1,15 @@ +SELECT + "uid", + "namespace", + "label", + "provider", + "encrypted_data", + "active", + "created", + "updated" +FROM + "secret_data_key" +WHERE "namespace" = 'ns' AND + "label" = 'label' AND + "active" = true +; diff --git a/pkg/storage/secret/encryption/testdata/sqlite--data_key_create-create-not-active.sql b/pkg/storage/secret/encryption/testdata/sqlite--data_key_create-create-not-active.sql new file mode 100755 index 00000000000..184c22e5858 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/sqlite--data_key_create-create-not-active.sql @@ -0,0 +1,19 @@ +INSERT INTO "secret_data_key" ( + "uid", + "namespace", + "label", + "provider", + "encrypted_data", + "active", + "created", + "updated" +) VALUES ( + 'abc123', + 'ns', + 'label', + 'provider', + '[115 101 99 114 101 116]', + FALSE, + '0001-01-01 00:00:00 +0000 UTC', + '0001-01-01 00:00:00 +0000 UTC' +); diff --git a/pkg/storage/secret/encryption/testdata/sqlite--data_key_create-create.sql b/pkg/storage/secret/encryption/testdata/sqlite--data_key_create-create.sql new file mode 100755 index 00000000000..b100d299a7e --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/sqlite--data_key_create-create.sql @@ -0,0 +1,19 @@ +INSERT INTO "secret_data_key" ( + "uid", + "namespace", + "label", + "provider", + "encrypted_data", + "active", + "created", + "updated" +) VALUES ( + 'abc123', + 'ns', + 'label', + 'provider', + '[115 101 99 114 101 116]', + TRUE, + '0001-01-01 00:00:00 +0000 UTC', + '0001-01-01 00:00:00 +0000 UTC' +); diff --git a/pkg/storage/secret/encryption/testdata/sqlite--data_key_delete-delete-no-uid.sql b/pkg/storage/secret/encryption/testdata/sqlite--data_key_delete-delete-no-uid.sql new file mode 100755 index 00000000000..13bf38a568e --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/sqlite--data_key_delete-delete-no-uid.sql @@ -0,0 +1,4 @@ +DELETE FROM "secret_data_key" +WHERE "namespace" = 'ns' AND + "uid" = '' +; diff --git a/pkg/storage/secret/encryption/testdata/sqlite--data_key_delete-delete.sql b/pkg/storage/secret/encryption/testdata/sqlite--data_key_delete-delete.sql new file mode 100755 index 00000000000..679f26f2f10 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/sqlite--data_key_delete-delete.sql @@ -0,0 +1,4 @@ +DELETE FROM "secret_data_key" +WHERE "namespace" = 'ns' AND + "uid" = 'abc123' +; diff --git a/pkg/storage/secret/encryption/testdata/sqlite--data_key_disable-disable.sql b/pkg/storage/secret/encryption/testdata/sqlite--data_key_disable-disable.sql new file mode 100755 index 00000000000..bfb4ee0eb1d --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/sqlite--data_key_disable-disable.sql @@ -0,0 +1,8 @@ +UPDATE + "secret_data_key" +SET + "active" = false, + "updated" = '2025-01-01 00:00:00 +0000 UTC' +WHERE "namespace" = 'ns' AND + "active" = true +; diff --git a/pkg/storage/secret/encryption/testdata/sqlite--data_key_list-list.sql b/pkg/storage/secret/encryption/testdata/sqlite--data_key_list-list.sql new file mode 100755 index 00000000000..2233ed1cd20 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/sqlite--data_key_list-list.sql @@ -0,0 +1,13 @@ +SELECT + "uid", + "namespace", + "label", + "provider", + "encrypted_data", + "active", + "created", + "updated" +FROM + "secret_data_key" +WHERE "namespace" = 'ns' +; diff --git a/pkg/storage/secret/encryption/testdata/sqlite--data_key_read-read.sql b/pkg/storage/secret/encryption/testdata/sqlite--data_key_read-read.sql new file mode 100755 index 00000000000..94bf46eb2c8 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/sqlite--data_key_read-read.sql @@ -0,0 +1,14 @@ +SELECT + "uid", + "namespace", + "label", + "provider", + "encrypted_data", + "active", + "created", + "updated" +FROM + "secret_data_key" +WHERE "namespace" = 'ns' AND + "uid" = 'abc123' +; diff --git a/pkg/storage/secret/encryption/testdata/sqlite--data_key_read_current-read_current.sql b/pkg/storage/secret/encryption/testdata/sqlite--data_key_read_current-read_current.sql new file mode 100755 index 00000000000..cd9438a7fb0 --- /dev/null +++ b/pkg/storage/secret/encryption/testdata/sqlite--data_key_read_current-read_current.sql @@ -0,0 +1,15 @@ +SELECT + "uid", + "namespace", + "label", + "provider", + "encrypted_data", + "active", + "created", + "updated" +FROM + "secret_data_key" +WHERE "namespace" = 'ns' AND + "label" = 'label' AND + "active" = true +; diff --git a/pkg/storage/secret/migrator/migrator.go b/pkg/storage/secret/migrator/migrator.go index 08233626e40..4db66a50645 100644 --- a/pkg/storage/secret/migrator/migrator.go +++ b/pkg/storage/secret/migrator/migrator.go @@ -15,6 +15,7 @@ const ( TableNameKeeper = "secret_keeper" TableNameSecureValue = "secret_secure_value" TableNameSecureValueOutbox = "secret_secure_value_outbox" + TableNameDataKey = "secret_data_key" TableNameEncryptedValue = "secret_encrypted_value" ) @@ -45,31 +46,6 @@ func (*SecretDB) AddMigration(mg *migrator.Migrator) { tables := []migrator.Table{} - tables = append(tables, migrator.Table{ - Name: TableNameKeeper, - Columns: []*migrator.Column{ - // Kubernetes Metadata - {Name: "guid", Type: migrator.DB_NVarchar, Length: 36, IsPrimaryKey: true}, // Fixed size of a UUID. - {Name: "name", Type: migrator.DB_NVarchar, Length: 253, Nullable: false}, // Limit enforced by K8s. - {Name: "namespace", Type: migrator.DB_NVarchar, Length: 253, Nullable: false}, // Limit enforced by K8s. - {Name: "annotations", Type: migrator.DB_Text, Nullable: true}, - {Name: "labels", Type: migrator.DB_Text, Nullable: true}, - {Name: "created", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "created_by", Type: migrator.DB_Text, Nullable: false}, - {Name: "updated", Type: migrator.DB_BigInt, Nullable: false}, // Used as RV (ResourceVersion) - {Name: "updated_by", Type: migrator.DB_Text, Nullable: false}, - - // Spec - {Name: "description", Type: migrator.DB_NVarchar, Length: 253, Nullable: false}, // Chosen arbitrarily, but should be enough. - {Name: "type", Type: migrator.DB_Text, Nullable: false}, - // Each keeper has a different payload so we store the whole thing as a blob. - {Name: "payload", Type: migrator.DB_Text, Nullable: true}, - }, - Indices: []*migrator.Index{ - {Cols: []string{"namespace", "name"}, Type: migrator.UniqueIndex}, - }, - }) - tables = append(tables, migrator.Table{ Name: TableNameSecureValue, Columns: []*migrator.Column{ @@ -100,6 +76,48 @@ func (*SecretDB) AddMigration(mg *migrator.Migrator) { }, }) + tables = append(tables, migrator.Table{ + Name: TableNameKeeper, + Columns: []*migrator.Column{ + // Kubernetes Metadata + {Name: "guid", Type: migrator.DB_NVarchar, Length: 36, IsPrimaryKey: true}, // Fixed size of a UUID. + {Name: "name", Type: migrator.DB_NVarchar, Length: 253, Nullable: false}, // Limit enforced by K8s. + {Name: "namespace", Type: migrator.DB_NVarchar, Length: 253, Nullable: false}, // Limit enforced by K8s. + {Name: "annotations", Type: migrator.DB_Text, Nullable: true}, + {Name: "labels", Type: migrator.DB_Text, Nullable: true}, + {Name: "created", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "created_by", Type: migrator.DB_Text, Nullable: false}, + {Name: "updated", Type: migrator.DB_BigInt, Nullable: false}, // Used as RV (ResourceVersion) + {Name: "updated_by", Type: migrator.DB_Text, Nullable: false}, + + // Spec + {Name: "description", Type: migrator.DB_NVarchar, Length: 253, Nullable: false}, // Chosen arbitrarily, but should be enough. + {Name: "type", Type: migrator.DB_Text, Nullable: false}, + // Each keeper has a different payload so we store the whole thing as a blob. + {Name: "payload", Type: migrator.DB_Text, Nullable: true}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"namespace", "name"}, Type: migrator.UniqueIndex}, + }, + }) + + // TODO -- document how the seemingly arbitrary column lengths were chosen + // The answer for now is that they come from the legacy secrets service, but it would be good to know that they will still work in the new service + tables = append(tables, migrator.Table{ + Name: TableNameDataKey, + Columns: []*migrator.Column{ + {Name: "uid", Type: migrator.DB_NVarchar, Length: 100, IsPrimaryKey: true}, + {Name: "namespace", Type: migrator.DB_NVarchar, Length: 253, Nullable: false}, // Limit enforced by K8s. + {Name: "label", Type: migrator.DB_NVarchar, Length: 100, IsPrimaryKey: false}, + {Name: "active", Type: migrator.DB_Bool, Nullable: false}, + {Name: "provider", Type: migrator.DB_NVarchar, Length: 50, Nullable: false}, + {Name: "encrypted_data", Type: migrator.DB_Blob, Nullable: false}, + {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, + }, + Indices: []*migrator.Index{}, // TODO: add indexes based on the queries we make. + }) + tables = append(tables, migrator.Table{ Name: TableNameEncryptedValue, Columns: []*migrator.Column{ diff --git a/pkg/storage/unified/federated/federatedtests/stats_test.go b/pkg/storage/unified/federated/federatedtests/stats_test.go index 48927b195f7..95cba546d30 100644 --- a/pkg/storage/unified/federated/federatedtests/stats_test.go +++ b/pkg/storage/unified/federated/federatedtests/stats_test.go @@ -38,7 +38,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -func TestDirectSQLStats(t *testing.T) { +func TestIntegrationDirectSQLStats(t *testing.T) { db, cfg := db.InitTestDBWithCfg(t) ctx := context.Background() diff --git a/pkg/storage/unified/resource/access.go b/pkg/storage/unified/resource/access.go index 6a21706a58e..4a2ea89d7a0 100644 --- a/pkg/storage/unified/resource/access.go +++ b/pkg/storage/unified/resource/access.go @@ -136,7 +136,7 @@ func (c authzLimitedClient) Check(ctx context.Context, id claims.AuthInfo, req c if !claims.NamespaceMatches(id.GetNamespace(), req.Namespace) { span.SetAttributes(attribute.Bool("allowed", false)) - span.SetStatus(codes.Error, "Namespace missmatch") + span.SetStatus(codes.Error, "Namespace mismatch") span.RecordError(claims.ErrNamespaceMissmatch) return claims.CheckResponse{Allowed: false}, claims.ErrNamespaceMissmatch } @@ -185,7 +185,7 @@ func (c authzLimitedClient) Compile(ctx context.Context, id claims.AuthInfo, req } if !claims.NamespaceMatches(id.GetNamespace(), req.Namespace) { span.SetAttributes(attribute.Bool("allowed", false)) - span.SetStatus(codes.Error, "Namespace missmatch") + span.SetStatus(codes.Error, "Namespace mismatch") span.RecordError(claims.ErrNamespaceMissmatch) return nil, claims.ErrNamespaceMissmatch } diff --git a/pkg/storage/unified/resource/datastore.go b/pkg/storage/unified/resource/datastore.go index b0e738a9d24..836008117ba 100644 --- a/pkg/storage/unified/resource/datastore.go +++ b/pkg/storage/unified/resource/datastore.go @@ -49,6 +49,10 @@ func (k DataKey) String() string { return fmt.Sprintf("%s/%s/%s/%s/%d~%s", k.Namespace, k.Group, k.Resource, k.Name, k.ResourceVersion, k.Action) } +func (k DataKey) Equals(other DataKey) bool { + return k.Namespace == other.Namespace && k.Group == other.Group && k.Resource == other.Resource && k.Name == other.Name && k.ResourceVersion == other.ResourceVersion && k.Action == other.Action +} + func (k DataKey) Validate() error { if k.Namespace == "" { if k.Group != "" || k.Resource != "" || k.Name != "" { @@ -207,6 +211,29 @@ func (d *dataStore) Keys(ctx context.Context, key ListRequestKey) iter.Seq2[Data } } +// LastResourceVersion returns the last key for a given resource +func (d *dataStore) LastResourceVersion(ctx context.Context, key ListRequestKey) (DataKey, error) { + if err := key.Validate(); err != nil { + return DataKey{}, fmt.Errorf("invalid data key: %w", err) + } + if key.Group == "" || key.Resource == "" || key.Namespace == "" || key.Name == "" { + return DataKey{}, fmt.Errorf("group, resource, namespace or name is empty") + } + prefix := key.Prefix() + for key, err := range d.kv.Keys(ctx, dataSection, ListOptions{ + StartKey: prefix, + EndKey: PrefixRangeEnd(prefix), + Limit: 1, + Sort: SortOrderDesc, + }) { + if err != nil { + return DataKey{}, err + } + return ParseKey(key) + } + return DataKey{}, ErrNotFound +} + func (d *dataStore) Get(ctx context.Context, key DataKey) (io.ReadCloser, error) { if err := key.Validate(); err != nil { return nil, fmt.Errorf("invalid data key: %w", err) diff --git a/pkg/storage/unified/resource/datastore_test.go b/pkg/storage/unified/resource/datastore_test.go index f3870ef25da..d82396f1ee7 100644 --- a/pkg/storage/unified/resource/datastore_test.go +++ b/pkg/storage/unified/resource/datastore_test.go @@ -3,6 +3,7 @@ package resource import ( "bytes" "context" + "fmt" "io" "testing" @@ -1353,3 +1354,105 @@ func TestListRequestKey_Prefix(t *testing.T) { }) } } + +func TestDataStore_LastResourceVersion(t *testing.T) { + ds := setupTestDataStore(t) + ctx := context.Background() + + t.Run("returns last resource version for existing data", func(t *testing.T) { + resourceKey := ListRequestKey{ + Namespace: "test-namespace", + Group: "test-group", + Resource: "test-resource", + Name: "test-name", + } + + // Create test data with multiple versions + rv1 := node.Generate() + rv2 := node.Generate() + rv3 := node.Generate() + + versions := []int64{ + rv1.Int64(), + rv2.Int64(), + rv3.Int64(), + } + + // Save all versions + for _, version := range versions { + dataKey := DataKey{ + Namespace: resourceKey.Namespace, + Group: resourceKey.Group, + Resource: resourceKey.Resource, + Name: resourceKey.Name, + ResourceVersion: version, + Action: DataActionCreated, + } + + err := ds.Save(ctx, dataKey, bytes.NewReader([]byte(fmt.Sprintf("version-%d", version)))) + require.NoError(t, err) + } + + // Get the last resource version + lastKey, err := ds.LastResourceVersion(ctx, resourceKey) + require.NoError(t, err) + + // Verify the result + require.Equal(t, resourceKey.Namespace, lastKey.Namespace) + require.Equal(t, resourceKey.Group, lastKey.Group) + require.Equal(t, resourceKey.Resource, lastKey.Resource) + require.Equal(t, resourceKey.Name, lastKey.Name) + require.Equal(t, DataActionCreated, lastKey.Action) + + require.Equal(t, rv3.Int64(), lastKey.ResourceVersion) + }) + + t.Run("returns error for non-existent resource", func(t *testing.T) { + nonExistentKey := ListRequestKey{ + Namespace: "non-existent-namespace", + Group: "non-existent-group", + Resource: "non-existent-resource", + Name: "non-existent-name", + } + + _, err := ds.LastResourceVersion(ctx, nonExistentKey) + require.Error(t, err) + require.Equal(t, ErrNotFound, err) + }) + + t.Run("returns error for empty required fields", func(t *testing.T) { + testCases := map[string]ListRequestKey{ + "empty namespace": { + Namespace: "", + Group: "test-group", + Resource: "test-resource", + Name: "test-name", + }, + "empty group": { + Namespace: "test-namespace", + Group: "", + Resource: "test-resource", + Name: "test-name", + }, + "empty resource": { + Namespace: "test-namespace", + Group: "test-group", + Resource: "", + Name: "test-name", + }, + "empty name": { + Namespace: "test-namespace", + Group: "test-group", + Resource: "test-resource", + Name: "", + }, + } + + for name, key := range testCases { + t.Run(name, func(t *testing.T) { + _, err := ds.LastResourceVersion(ctx, key) + require.Error(t, err) + }) + } + }) +} diff --git a/pkg/storage/unified/resource/distributor.go b/pkg/storage/unified/resource/distributor.go index 231a0431597..fc49901d255 100644 --- a/pkg/storage/unified/resource/distributor.go +++ b/pkg/storage/unified/resource/distributor.go @@ -246,6 +246,8 @@ func (ds *distributorServer) getClientToDistributeRequest(ctx context.Context, n ds.log.Info("distributing request to ", "methodName", methodName, "instanceId", rs.Instances[0].Id) + _ = grpc.SetHeader(ctx, metadata.Pairs("proxied-instance-id", rs.Instances[0].Id)) + return userutils.InjectOrgID(metadata.NewOutgoingContext(ctx, md), namespace), client.(*RingClient).Client, nil } diff --git a/pkg/storage/unified/resource/eventstore.go b/pkg/storage/unified/resource/eventstore.go new file mode 100644 index 00000000000..6070d1a6eeb --- /dev/null +++ b/pkg/storage/unified/resource/eventstore.go @@ -0,0 +1,189 @@ +package resource + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "iter" + "strconv" + "strings" +) + +const ( + eventsSection = "unified/events" +) + +// eventStore is a store for events. +type eventStore struct { + kv KV +} + +type EventKey struct { + Namespace string + Group string + Resource string + Name string + ResourceVersion int64 +} + +func (k EventKey) String() string { + return fmt.Sprintf("%d~%s~%s~%s~%s", k.ResourceVersion, k.Namespace, k.Group, k.Resource, k.Name) +} + +func (k EventKey) Validate() error { + if k.Namespace == "" { + return fmt.Errorf("namespace cannot be empty") + } + if k.Group == "" { + return fmt.Errorf("group cannot be empty") + } + if k.Resource == "" { + return fmt.Errorf("resource cannot be empty") + } + if k.Name == "" { + return fmt.Errorf("name cannot be empty") + } + if k.ResourceVersion < 0 { + return fmt.Errorf("resource version must be non-negative") + } + + // Validate each field against the naming rules (reusing the regex from datastore.go) + if !validNameRegex.MatchString(k.Namespace) { + return fmt.Errorf("namespace '%s' is invalid", k.Namespace) + } + if !validNameRegex.MatchString(k.Group) { + return fmt.Errorf("group '%s' is invalid", k.Group) + } + if !validNameRegex.MatchString(k.Resource) { + return fmt.Errorf("resource '%s' is invalid", k.Resource) + } + if !validNameRegex.MatchString(k.Name) { + return fmt.Errorf("name '%s' is invalid", k.Name) + } + + return nil +} + +type Event struct { + Namespace string `json:"namespace"` + Group string `json:"group"` + Resource string `json:"resource"` + Name string `json:"name"` + ResourceVersion int64 `json:"resource_version"` + Action DataAction `json:"action"` + Folder string `json:"folder"` + PreviousRV int64 `json:"previous_rv"` +} + +func newEventStore(kv KV) *eventStore { + return &eventStore{ + kv: kv, + } +} + +// ParseEventKey parses a key string back into an EventKey struct +func ParseEventKey(key string) (EventKey, error) { + parts := strings.Split(key, "~") + if len(parts) != 5 { + return EventKey{}, fmt.Errorf("invalid key format: expected 5 parts, got %d", len(parts)) + } + + rv, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return EventKey{}, fmt.Errorf("invalid resource version: %w", err) + } + + return EventKey{ + ResourceVersion: rv, + Namespace: parts[1], + Group: parts[2], + Resource: parts[3], + Name: parts[4], + }, nil +} + +// LastEventKey returns the Event Key of the event with the highest resource version. +// If no events are found, it returns ErrNotFound. +func (n *eventStore) LastEventKey(ctx context.Context) (EventKey, error) { + for key, err := range n.kv.Keys(ctx, eventsSection, ListOptions{Sort: SortOrderDesc, Limit: 1}) { + if err != nil { + return EventKey{}, err + } + eventKey, err := ParseEventKey(key) + if err != nil { + return EventKey{}, err + } + return eventKey, nil + } + + return EventKey{}, ErrNotFound +} + +// Save an event to the store. +func (n *eventStore) Save(ctx context.Context, event Event) error { + eventKey := EventKey{ + Namespace: event.Namespace, + Group: event.Group, + Resource: event.Resource, + Name: event.Name, + ResourceVersion: event.ResourceVersion, + } + + if err := eventKey.Validate(); err != nil { + return fmt.Errorf("invalid event key: %w", err) + } + + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + if err := encoder.Encode(event); err != nil { + return err + } + return n.kv.Save(ctx, eventsSection, eventKey.String(), &buf) +} + +func (n *eventStore) Get(ctx context.Context, key EventKey) (Event, error) { + if err := key.Validate(); err != nil { + return Event{}, fmt.Errorf("invalid event key: %w", err) + } + + obj, err := n.kv.Get(ctx, eventsSection, key.String()) + if err != nil { + return Event{}, err + } + var event Event + if err = json.NewDecoder(obj.Value).Decode(&event); err != nil { + _ = obj.Value.Close() + return Event{}, err + } + defer func() { _ = obj.Value.Close() }() + return event, nil +} + +// ListSince returns a sequence of events since the given resource version. +func (n *eventStore) ListSince(ctx context.Context, sinceRV int64) iter.Seq2[Event, error] { + opts := ListOptions{ + Sort: SortOrderAsc, + StartKey: EventKey{ + ResourceVersion: sinceRV, + }.String(), + } + return func(yield func(Event, error) bool) { + for key, err := range n.kv.Keys(ctx, eventsSection, opts) { + if err != nil { + return + } + obj, err := n.kv.Get(ctx, eventsSection, key) + if err != nil { + return + } + var event Event + if err := json.NewDecoder(obj.Value).Decode(&event); err != nil { + return + } + if !yield(event, nil) { + return + } + } + } +} diff --git a/pkg/storage/unified/resource/eventstore_test.go b/pkg/storage/unified/resource/eventstore_test.go new file mode 100644 index 00000000000..1ccc023ad58 --- /dev/null +++ b/pkg/storage/unified/resource/eventstore_test.go @@ -0,0 +1,398 @@ +package resource + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTestEventStore(t *testing.T) *eventStore { + db := setupTestBadgerDB(t) + t.Cleanup(func() { + err := db.Close() + require.NoError(t, err) + }) + kv := NewBadgerKV(db) + return newEventStore(kv) +} + +func TestNewEventStore(t *testing.T) { + store := setupTestEventStore(t) + assert.NotNil(t, store.kv) +} + +func TestEventKey_String(t *testing.T) { + tests := []struct { + name string + eventKey EventKey + expected string + }{ + { + name: "basic event key", + eventKey: EventKey{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource", + ResourceVersion: 1000, + }, + expected: "1000~default~apps~resource~test-resource", + }, + { + name: "empty namespace", + eventKey: EventKey{ + Namespace: "", + Group: "apps", + Resource: "resource", + Name: "test-resource", + ResourceVersion: 2000, + }, + expected: "2000~~apps~resource~test-resource", + }, + { + name: "special characters in name", + eventKey: EventKey{ + Namespace: "test-ns", + Group: "apps", + Resource: "resource", + Name: "test-resource-with-dashes", + ResourceVersion: 3000, + }, + expected: "3000~test-ns~apps~resource~test-resource-with-dashes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.eventKey.String() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEventKey_Validate(t *testing.T) { + tests := []struct { + name string + key string + expected EventKey + expectError bool + }{ + { + name: "valid key", + key: "1000~default~apps~resource~test-resource", + expected: EventKey{ + ResourceVersion: 1000, + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource", + }, + }, + { + name: "empty namespace", + key: "2000~~apps~resource~test-resource", + expected: EventKey{ + ResourceVersion: 2000, + Namespace: "", + Group: "apps", + Resource: "resource", + Name: "test-resource", + }, + }, + { + name: "special characters in name", + key: "3000~test-ns~apps~resource~test-resource-with-dashes", + expected: EventKey{ + ResourceVersion: 3000, + Namespace: "test-ns", + Group: "apps", + Resource: "resource", + Name: "test-resource-with-dashes", + }, + }, + { + name: "invalid key - too few parts", + key: "1000~default~apps~resource", + expectError: true, + }, + { + name: "invalid key - too many parts", + key: "1000~default~apps~resource~test~extra", + expectError: true, + }, + { + name: "invalid resource version", + key: "invalid~default~apps~resource~test", + expectError: true, + }, + { + name: "empty key", + key: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseEventKey(tt.key) + + if tt.expectError { + assert.Error(t, err) + assert.Equal(t, EventKey{}, result) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestEventStore_ParseEventKey(t *testing.T) { + originalKey := EventKey{ + ResourceVersion: 1234567890, + Namespace: "test-namespace", + Group: "apps", + Resource: "resource", + Name: "test-resource", + } + + // Convert to string and back + keyString := originalKey.String() + parsedKey, err := ParseEventKey(keyString) + + require.NoError(t, err) + assert.Equal(t, originalKey, parsedKey) +} + +func TestEventStore_Save_Get(t *testing.T) { + ctx := context.Background() + store := setupTestEventStore(t) + + event := Event{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource", + ResourceVersion: 1000, + Action: DataActionCreated, + Folder: "test-folder", + PreviousRV: 999, + } + + // Save the event + err := store.Save(ctx, event) + require.NoError(t, err) + + // Get the event back + eventKey := EventKey{ + Namespace: event.Namespace, + Group: event.Group, + Resource: event.Resource, + Name: event.Name, + ResourceVersion: event.ResourceVersion, + } + + retrievedEvent, err := store.Get(ctx, eventKey) + require.NoError(t, err) + assert.Equal(t, event, retrievedEvent) +} + +func TestEventStore_Get_NotFound(t *testing.T) { + ctx := context.Background() + store := setupTestEventStore(t) + + nonExistentKey := EventKey{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "non-existent", + ResourceVersion: 9999, + } + + _, err := store.Get(ctx, nonExistentKey) + assert.Error(t, err) +} + +func TestEventStore_LastEventKey(t *testing.T) { + ctx := context.Background() + store := setupTestEventStore(t) + + // Test when no events exist + _, err := store.LastEventKey(ctx) + assert.Error(t, err) + assert.Equal(t, ErrNotFound, err) + + // Add some events with different resource versions + events := []Event{ + { + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-1", + ResourceVersion: 1000, + Action: DataActionCreated, + }, + { + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-2", + ResourceVersion: 3000, // highest + Action: DataActionCreated, + }, + { + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-3", + ResourceVersion: 2000, + Action: DataActionCreated, + }, + } + + // Save all events + for _, event := range events { + err := store.Save(ctx, event) + require.NoError(t, err) + } + + // Get the last event key (should be the one with highest RV) + lastKey, err := store.LastEventKey(ctx) + require.NoError(t, err) + + expectedKey := EventKey{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-2", + ResourceVersion: 3000, + } + + assert.Equal(t, expectedKey, lastKey) +} + +func TestEventStore_ListSince(t *testing.T) { + ctx := context.Background() + store := setupTestEventStore(t) + + // Add events with different resource versions + events := []Event{ + { + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-1", + ResourceVersion: 1000, + Action: DataActionCreated, + }, + { + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-2", + ResourceVersion: 2000, + Action: DataActionUpdated, + }, + { + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-3", + ResourceVersion: 3000, + Action: DataActionDeleted, + }, + } + + // Save all events + for _, event := range events { + err := store.Save(ctx, event) + require.NoError(t, err) + } + + // List events since RV 1500 (should get events with RV 2000 and 3000) + retrievedEvents := make([]Event, 0, 2) + for event, err := range store.ListSince(ctx, 1500) { + require.NoError(t, err) + retrievedEvents = append(retrievedEvents, event) + } + + // Should return events in descending order of resource version + require.Len(t, retrievedEvents, 2) + assert.Equal(t, int64(2000), retrievedEvents[0].ResourceVersion) + assert.Equal(t, int64(3000), retrievedEvents[1].ResourceVersion) +} + +func TestEventStore_ListSince_Empty(t *testing.T) { + ctx := context.Background() + store := setupTestEventStore(t) + + // List events when store is empty + retrievedEvents := make([]Event, 0) + for event, err := range store.ListSince(ctx, 0) { + require.NoError(t, err) + retrievedEvents = append(retrievedEvents, event) + } + + assert.Empty(t, retrievedEvents) +} + +func TestEvent_JSONSerialization(t *testing.T) { + event := Event{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource", + ResourceVersion: 1000, + Action: DataActionCreated, + Folder: "test-folder", + PreviousRV: 999, + } + + // Serialize to JSON + data, err := json.Marshal(event) + require.NoError(t, err) + + // Deserialize from JSON + var deserializedEvent Event + err = json.Unmarshal(data, &deserializedEvent) + require.NoError(t, err) + + assert.Equal(t, event, deserializedEvent) +} + +func TestEventKey_Struct(t *testing.T) { + key := EventKey{ + Namespace: "test-namespace", + Group: "apps", + Resource: "resource", + Name: "test-resource", + ResourceVersion: 1234567890, + } + + assert.Equal(t, "test-namespace", key.Namespace) + assert.Equal(t, "apps", key.Group) + assert.Equal(t, "resource", key.Resource) + assert.Equal(t, "test-resource", key.Name) + assert.Equal(t, int64(1234567890), key.ResourceVersion) +} + +func TestEventStore_Save_InvalidJSON(t *testing.T) { + ctx := context.Background() + store := setupTestEventStore(t) + + // This should work fine as the Event struct should be serializable + event := Event{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test", + ResourceVersion: 1000, + Action: DataActionCreated, + } + + err := store.Save(ctx, event) + assert.NoError(t, err) +} diff --git a/pkg/storage/unified/resource/notifier.go b/pkg/storage/unified/resource/notifier.go new file mode 100644 index 00000000000..d0b27e4ad11 --- /dev/null +++ b/pkg/storage/unified/resource/notifier.go @@ -0,0 +1,120 @@ +package resource + +import ( + "context" + "errors" + "fmt" + + "github.com/grafana/grafana-app-sdk/logging" + gocache "github.com/patrickmn/go-cache" + + "time" +) + +const ( + defaultLookbackPeriod = 30 * time.Second + defaultPollInterval = 100 * time.Millisecond + defaultEventCacheSize = 10000 + defaultBufferSize = 10000 +) + +type notifier struct { + eventStore *eventStore + log logging.Logger +} + +type notifierOptions struct { + log logging.Logger +} + +type watchOptions struct { + LookbackPeriod time.Duration // How far back to look for events + PollInterval time.Duration // How often to poll for new events + BufferSize int // How many events to buffer +} + +func defaultWatchOptions() watchOptions { + return watchOptions{ + LookbackPeriod: defaultLookbackPeriod, + PollInterval: defaultPollInterval, + BufferSize: defaultBufferSize, + } +} + +func newNotifier(eventStore *eventStore, opts notifierOptions) *notifier { + if opts.log == nil { + opts.log = &logging.NoOpLogger{} + } + return ¬ifier{eventStore: eventStore, log: opts.log} +} + +// Return the last resource version from the event store +func (n *notifier) lastEventResourceVersion(ctx context.Context) (int64, error) { + e, err := n.eventStore.LastEventKey(ctx) + if err != nil { + return 0, err + } + return e.ResourceVersion, nil +} + +func (n *notifier) cacheKey(evt Event) string { + return fmt.Sprintf("%s~%s~%s~%s~%d", evt.Namespace, evt.Group, evt.Resource, evt.Name, evt.ResourceVersion) +} + +func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { + if opts.PollInterval <= 0 { + opts.PollInterval = defaultPollInterval + } + cacheTTL := opts.LookbackPeriod + cacheCleanupInterval := 2 * opts.LookbackPeriod + + cache := gocache.New(cacheTTL, cacheCleanupInterval) + events := make(chan Event, opts.BufferSize) + + initialRV, err := n.lastEventResourceVersion(ctx) + if errors.Is(err, ErrNotFound) { + initialRV = 0 // No events yet, start from the beginning + } else if err != nil { + n.log.Error("Failed to get last event resource version", "error", err) + } + lastRV := initialRV + 1 // We want to start watching from the next event + + go func() { + defer close(events) + for { + select { + case <-ctx.Done(): + return + case <-time.After(opts.PollInterval): + for evt, err := range n.eventStore.ListSince(ctx, lastRV-opts.LookbackPeriod.Nanoseconds()) { + if err != nil { + n.log.Error("Failed to list events since", "error", err) + continue + } + + // Skip old events lower than the requested resource version + if evt.ResourceVersion <= initialRV { + continue + } + + // Skip if the event is already sent + if _, found := cache.Get(n.cacheKey(evt)); found { + continue + } + + if evt.ResourceVersion > lastRV { + lastRV = evt.ResourceVersion + 1 + } + // Send the event + select { + case events <- evt: + cache.Set(n.cacheKey(evt), true, opts.LookbackPeriod) + case <-ctx.Done(): + return + } + } + } + } + }() + return events +} diff --git a/pkg/storage/unified/resource/notifier_test.go b/pkg/storage/unified/resource/notifier_test.go new file mode 100644 index 00000000000..a4272a36698 --- /dev/null +++ b/pkg/storage/unified/resource/notifier_test.go @@ -0,0 +1,442 @@ +package resource + +import ( + "context" + "testing" + "time" + + "github.com/grafana/grafana-app-sdk/logging" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTestNotifier(t *testing.T) (*notifier, *eventStore) { + db := setupTestBadgerDB(t) + t.Cleanup(func() { + err := db.Close() + require.NoError(t, err) + }) + kv := NewBadgerKV(db) + eventStore := newEventStore(kv) + notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}}) + return notifier, eventStore +} + +func TestNewNotifier(t *testing.T) { + notifier, _ := setupTestNotifier(t) + + assert.NotNil(t, notifier.eventStore) +} + +func TestDefaultWatchOptions(t *testing.T) { + opts := defaultWatchOptions() + + assert.Equal(t, defaultLookbackPeriod, opts.LookbackPeriod) + assert.Equal(t, defaultPollInterval, opts.PollInterval) + assert.Equal(t, defaultBufferSize, opts.BufferSize) +} + +func TestNotifier_lastEventResourceVersion(t *testing.T) { + ctx := context.Background() + notifier, eventStore := setupTestNotifier(t) + + // Test with no events + rv, err := notifier.lastEventResourceVersion(ctx) + assert.Error(t, err) + assert.ErrorIs(t, ErrNotFound, err) + assert.Equal(t, int64(0), rv) + + // Save an event + event := Event{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource", + ResourceVersion: 1000, + Action: DataActionCreated, + Folder: "test-folder", + PreviousRV: 999, + } + err = eventStore.Save(ctx, event) + require.NoError(t, err) + + // Test with events + rv, err = notifier.lastEventResourceVersion(ctx) + require.NoError(t, err) + assert.Equal(t, int64(1000), rv) + + // Save another event with higher RV + event2 := Event{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource-2", + ResourceVersion: 2000, + Action: DataActionCreated, + Folder: "test-folder", + PreviousRV: 1000, + } + err = eventStore.Save(ctx, event2) + require.NoError(t, err) + + // Should return the higher RV + rv, err = notifier.lastEventResourceVersion(ctx) + require.NoError(t, err) + assert.Equal(t, int64(2000), rv) +} + +func TestNotifier_cachekey(t *testing.T) { + notifier, _ := setupTestNotifier(t) + + tests := []struct { + name string + event Event + expected string + }{ + { + name: "basic event", + event: Event{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource", + ResourceVersion: 1000, + }, + expected: "default~apps~resource~test-resource~1000", + }, + { + name: "empty namespace", + event: Event{ + Namespace: "", + Group: "apps", + Resource: "resource", + Name: "test-resource", + ResourceVersion: 2000, + }, + expected: "~apps~resource~test-resource~2000", + }, + { + name: "special characters in name", + event: Event{ + Namespace: "test-ns", + Group: "apps", + Resource: "resource", + Name: "test-resource-with-dashes", + ResourceVersion: 3000, + }, + expected: "test-ns~apps~resource~test-resource-with-dashes~3000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := notifier.cacheKey(tt.event) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNotifier_Watch_NoEvents(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + notifier, eventStore := setupTestNotifier(t) + + // Add at least one event so that lastEventResourceVersion doesn't return ErrNotFound + initialEvent := Event{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "initial-resource", + ResourceVersion: 100, + Action: DataActionCreated, + Folder: "test-folder", + PreviousRV: 0, + } + err := eventStore.Save(ctx, initialEvent) + require.NoError(t, err) + + opts := watchOptions{ + LookbackPeriod: 100 * time.Millisecond, + PollInterval: 50 * time.Millisecond, + BufferSize: 10, + } + + events := notifier.Watch(ctx, opts) + + // Should receive no new events (only events after initial RV should be sent) + select { + case event := <-events: + t.Fatalf("Expected no events, but got: %+v", event) + case <-ctx.Done(): + // Expected - context timeout + } +} + +func TestNotifier_Watch_WithExistingEvents(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + notifier, eventStore := setupTestNotifier(t) + + // Save some initial events + initialEvents := []Event{ + { + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource-1", + ResourceVersion: 1000, + Action: DataActionCreated, + Folder: "test-folder", + PreviousRV: 0, + }, + { + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource-2", + ResourceVersion: 2000, + Action: DataActionUpdated, + Folder: "test-folder", + PreviousRV: 1000, + }, + } + + for _, event := range initialEvents { + err := eventStore.Save(ctx, event) + require.NoError(t, err) + } + + opts := watchOptions{ + LookbackPeriod: 100 * time.Millisecond, + PollInterval: 50 * time.Millisecond, + BufferSize: 10, + } + + // Start watching + events := notifier.Watch(ctx, opts) + + // Save a new event after starting to watch + newEvent := Event{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource-3", + ResourceVersion: 3000, + Action: DataActionCreated, + Folder: "test-folder", + PreviousRV: 2000, + } + + err := eventStore.Save(ctx, newEvent) + require.NoError(t, err) + + // Should receive the new event + select { + case receivedEvent := <-events: + assert.Equal(t, newEvent.Name, receivedEvent.Name) + assert.Equal(t, newEvent.ResourceVersion, receivedEvent.ResourceVersion) + assert.Equal(t, newEvent.Action, receivedEvent.Action) + case <-time.After(500 * time.Millisecond): + t.Fatal("Expected to receive an event, but timed out") + } +} + +func TestNotifier_Watch_EventDeduplication(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + notifier, eventStore := setupTestNotifier(t) + + // Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound + initialEvent := Event{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "initial-resource", + ResourceVersion: time.Now().UnixNano(), + Action: DataActionCreated, + Folder: "test-folder", + PreviousRV: 0, + } + err := eventStore.Save(ctx, initialEvent) + require.NoError(t, err) + + opts := watchOptions{ + LookbackPeriod: time.Second, + PollInterval: 20 * time.Millisecond, + BufferSize: 10, + } + + // Start watching + events := notifier.Watch(ctx, opts) + + // Save an event + event := Event{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource", + ResourceVersion: time.Now().UnixNano(), + Action: DataActionCreated, + Folder: "test-folder", + PreviousRV: 0, + } + + err = eventStore.Save(ctx, event) + require.NoError(t, err) + + // Should receive the event once + select { + case receivedEvent := <-events: + assert.Equal(t, event.Name, receivedEvent.Name) + assert.Equal(t, event.ResourceVersion, receivedEvent.ResourceVersion) + case <-time.After(200 * time.Millisecond): + t.Fatal("Expected to receive an event, but timed out") + } + + // Should not receive the same event again (due to caching) + select { + case duplicateEvent := <-events: + t.Fatalf("Expected no duplicate events, but got: %+v", duplicateEvent) + case <-time.After(100 * time.Millisecond): + // Expected - no duplicate events + } +} + +func TestNotifier_Watch_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + notifier, eventStore := setupTestNotifier(t) + + // Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound + initialEvent := Event{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "initial-resource", + ResourceVersion: time.Now().UnixNano(), + Action: DataActionCreated, + Folder: "test-folder", + PreviousRV: 0, + } + err := eventStore.Save(ctx, initialEvent) + require.NoError(t, err) + + opts := watchOptions{ + LookbackPeriod: 100 * time.Millisecond, + PollInterval: 20 * time.Millisecond, + BufferSize: 10, + } + + events := notifier.Watch(ctx, opts) + + // Cancel the context + cancel() + + // Channel should be closed + select { + case event, ok := <-events: + if ok { + t.Fatalf("Expected channel to be closed, but got event: %+v", event) + } + // Channel is closed as expected + case <-time.After(100 * time.Millisecond): + t.Fatal("Expected channel to be closed quickly after context cancellation") + } +} + +func TestNotifier_Watch_MultipleEvents(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + notifier, eventStore := setupTestNotifier(t) + rv := time.Now().UnixNano() + // Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound + initialEvent := Event{ + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "initial-resource", + ResourceVersion: rv, + Action: DataActionCreated, + Folder: "test-folder", + PreviousRV: 0, + } + err := eventStore.Save(ctx, initialEvent) + require.NoError(t, err) + + opts := watchOptions{ + LookbackPeriod: time.Second, + PollInterval: 20 * time.Millisecond, + BufferSize: 10, + } + + // Start watching + events := notifier.Watch(ctx, opts) + + // Save multiple events + testEvents := []Event{ + { + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource-1", + ResourceVersion: rv + 1, + Action: DataActionCreated, + Folder: "test-folder", + PreviousRV: 0, + }, + { + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource-2", + ResourceVersion: rv + 3, + Action: DataActionUpdated, + Folder: "test-folder", + PreviousRV: 1000, + }, + { + Namespace: "default", + Group: "apps", + Resource: "resource", + Name: "test-resource-3", + ResourceVersion: rv + 2, // Out of order on purpose + Action: DataActionDeleted, + Folder: "test-folder", + PreviousRV: 2000, + }, + } + + go func() { + for _, event := range testEvents { + err := eventStore.Save(ctx, event) + require.NoError(t, err) + } + }() + + // Receive events + receivedEvents := make([]Event, 0, len(testEvents)) + for i := 0; i < len(testEvents); i++ { + select { + case event := <-events: + receivedEvents = append(receivedEvents, event) + case <-time.After(1 * time.Second): + t.Fatalf("Timed out waiting for event %d", i+1) + } + } + + // Verify all events were received + assert.Len(t, receivedEvents, len(testEvents)) + + // Verify the events match and ordered by resource version + receivedNames := make([]string, len(receivedEvents)) + for i, event := range receivedEvents { + receivedNames[i] = event.Name + } + + expectedNames := []string{"test-resource-1", "test-resource-2", "test-resource-3"} + assert.ElementsMatch(t, expectedNames, receivedNames) +} diff --git a/pkg/storage/unified/sql/list_iterator_test.go b/pkg/storage/unified/sql/list_iterator_test.go index e67f5c388db..b4eacaf0db2 100644 --- a/pkg/storage/unified/sql/list_iterator_test.go +++ b/pkg/storage/unified/sql/list_iterator_test.go @@ -24,7 +24,7 @@ import ( func TestMain(m *testing.M) { testsuite.Run(m) } -func TestListIter(t *testing.T) { +func TestIntegrationListIter(t *testing.T) { ctx := context.Background() grafanaDB := db.InitTestDB(t) diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index 5c808c7df64..2215808970c 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -2419,68 +2419,6 @@ func runDashboardListTest(t *testing.T, ctx TestContext) { }) } -// TODO: this only works on mode0-3 right now. In modes 4/5, we need to start returning the connections endpoint -// from retrieving the panel count from search / indexing the dashboard library panels -func TestDashboardWithLibraryPanel(t *testing.T) { - dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3} - for _, dualWriterMode := range dualWriterModes { - t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) { - helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ - DisableAnonymous: true, - EnableFeatureToggles: []string{ - "unifiedStorageSearch", - "kubernetesClientDashboardsFolders", - }, - }) - ctx := createTestContext(t, helper, helper.Org1, dualWriterMode) - adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()) - - // create the library element first - libraryElement := map[string]interface{}{ - "kind": 1, - "name": "Test Library Panel", - "model": map[string]interface{}{ - "type": "timeseries", - "title": "Test Library Panel", - }, - } - libraryElementURL := "/api/library-elements" - libraryElementData, err := postHelper(t, &ctx, libraryElementURL, libraryElement, ctx.AdminUser) - require.NoError(t, err) - require.NotNil(t, libraryElementData) - data := libraryElementData["result"].(map[string]interface{}) - uid := data["uid"].(string) - require.NotEmpty(t, uid) - - // then reference the library element in the dashboard - dashboard := createDashboardObject(t, "Library Panel Test", "", 1) - dashboard.Object["spec"].(map[string]interface{})["panels"] = []interface{}{ - map[string]interface{}{ - "id": 1, - "title": "Library Panel", - "type": "library-panel-ref", - "libraryPanel": map[string]interface{}{ - "uid": uid, - "name": "Test Library Panel", - }, - }, - } - - createdDash, err := adminClient.Resource.Create(context.Background(), dashboard, v1.CreateOptions{}) - require.NoError(t, err) - require.NotNil(t, createdDash) - - // should have created a library panel connection - connectionsURL := fmt.Sprintf("/api/library-elements/%s/connections", uid) - connectionsData, err := getDashboardViaHTTP(t, &ctx, connectionsURL, ctx.AdminUser) - require.NoError(t, err) - require.NotNil(t, connectionsData) - connections := connectionsData["result"].([]interface{}) - require.Len(t, connections, 1) - }) - } -} - func postHelper(t *testing.T, ctx *TestContext, path string, body interface{}, user apis.User) (map[string]interface{}, error) { bodyJSON, err := json.Marshal(body) require.NoError(t, err) diff --git a/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go b/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go new file mode 100644 index 00000000000..23a0650ebdb --- /dev/null +++ b/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go @@ -0,0 +1,288 @@ +package integration + +import ( + "context" + "fmt" + "net/http" + "testing" + + dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" + "github.com/stretchr/testify/require" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" +) + +// this tests the /api path still, but behind the scenes is using search to get the library connections +// as in modes 3+, the connections are found via searching dashboards for the reference of the library panel +// +// it also ensures we create the connection in modes 0-2 if a dashboard v1 is created with a reference +func TestIntegrationLibraryPanelConnections(t *testing.T) { + dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5} + for _, dualWriterMode := range dualWriterModes { + t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + EnableFeatureToggles: []string{ + "unifiedStorageSearch", + "kubernetesClientDashboardsFolders", + "kubernetesLibraryPanels", + }, + }) + ctx := createTestContext(t, helper, helper.Org1, dualWriterMode) + adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()) + + // create the library element first + libraryElement := map[string]interface{}{ + "kind": 1, + "name": "Test Library Panel", + "model": map[string]interface{}{ + "type": "timeseries", + "title": "Test Library Panel", + }, + } + libraryElementURL := "/api/library-elements" + libraryElementData, err := postHelper(t, &ctx, libraryElementURL, libraryElement, ctx.AdminUser) + require.NoError(t, err) + require.NotNil(t, libraryElementData) + data := libraryElementData["result"].(map[string]interface{}) + uid := data["uid"].(string) + require.NotEmpty(t, uid) + + // then reference the library element in the dashboard + dashboard := createDashboardObject(t, "Library Panel Test", "", 1) + dashboard.Object["spec"].(map[string]interface{})["panels"] = []interface{}{ + map[string]interface{}{ + "id": 1, + "title": "Library Panel", + "type": "library-panel-ref", + "libraryPanel": map[string]interface{}{ + "uid": uid, + "name": "Test Library Panel", + }, + }, + } + + createdDash, err := adminClient.Resource.Create(context.Background(), dashboard, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, createdDash) + + // should have created a library panel connection + connectionsURL := fmt.Sprintf("/api/library-elements/%s/connections", uid) + connectionsData, err := getDashboardViaHTTP(t, &ctx, connectionsURL, ctx.AdminUser) + require.NoError(t, err) + require.NotNil(t, connectionsData) + connections := connectionsData["result"].([]interface{}) + require.Len(t, connections, 1) + }) + } +} + +// this tests the /apis path to ensure authorization is being enforced. /api integration tests are within the service package +// only works in modes 0-2 because the library element is created through the /api path +func TestIntegrationLibraryElementPermissions(t *testing.T) { + dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2} + for _, dualWriterMode := range dualWriterModes { + t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + EnableFeatureToggles: []string{ + "unifiedStorageSearch", + "kubernetesClientDashboardsFolders", + "kubernetesLibraryPanels", + "grafanaAPIServerWithExperimentalAPIs", // needed until we move it to v0beta1 at least (currently v0alpha1) + }, + }) + ctx := createTestContext(t, helper, helper.Org1, dualWriterMode) + + t.Run("Library element authorization tests", func(t *testing.T) { + runLibraryElementAuthorizationTests(t, ctx) + }) + + t.Run("Library element cross-organization tests", func(t *testing.T) { + org2Ctx := createTestContext(t, helper, helper.OrgB, dualWriterMode) + runLibraryElementCrossOrgTests(t, ctx, org2Ctx) + }) + }) + } +} + +func runLibraryElementAuthorizationTests(t *testing.T, ctx TestContext) { + t.Helper() + + roles := []string{"Admin", "Editor", "Viewer"} + + for _, role := range roles { + t.Run(role, func(t *testing.T) { + var client *apis.K8sResourceClient + switch role { + case "Admin": + client = getResourceClient(t, ctx.Helper, ctx.AdminUser, getLibraryElementGVR()) + case "Editor": + client = getResourceClient(t, ctx.Helper, ctx.EditorUser, getLibraryElementGVR()) + case "Viewer": + client = getResourceClient(t, ctx.Helper, ctx.ViewerUser, getLibraryElementGVR()) + } + + t.Run("library element viewing", func(t *testing.T) { + uid, err := createLibraryElement(t, ctx, ctx.AdminUser, "Library Element for "+role+" to view", "", nil) + require.NoError(t, err) + + viewedLibElement, err := client.Resource.Get(context.Background(), uid, v1.GetOptions{}) + require.NoError(t, err, "All identities should be able to view library elements") + require.NotNil(t, viewedLibElement) + + err = deleteLibraryElement(t, ctx, ctx.AdminUser, uid) + require.NoError(t, err) + }) + + t.Run("library element listing", func(t *testing.T) { + uid, err := createLibraryElement(t, ctx, ctx.AdminUser, "Library Element for "+role+" to list", "", nil) + require.NoError(t, err) + + listOpts := v1.ListOptions{} + libElementList, err := client.Resource.List(context.Background(), listOpts) + require.NoError(t, err, "All identities should be able to list library elements") + require.NotNil(t, libElementList) + require.Len(t, libElementList.Items, 1) + require.Equal(t, uid, libElementList.Items[0].GetName()) + + err = deleteLibraryElement(t, ctx, ctx.AdminUser, uid) + require.NoError(t, err) + }) + + t.Run("library element in a folder only an admin can see", func(t *testing.T) { + restrictedFolder, err := createFolder(t, ctx.Helper, ctx.AdminUser, "Restricted Folder for "+role) + require.NoError(t, err) + require.NotNil(t, restrictedFolder) + uid, err := createLibraryElement(t, ctx, ctx.AdminUser, "Library Element in restricted folder", restrictedFolder.UID, nil) + require.NoError(t, err) + setResourceUserPermission(t, ctx, ctx.AdminUser, false, restrictedFolder.UID, []ResourcePermissionSetting{}) + + if role == "Admin" { + _, err = client.Resource.Get(context.Background(), uid, v1.GetOptions{}) + require.NoError(t, err, "Admin should be able to access library element in restricted folder") + } else { + _, err = client.Resource.Get(context.Background(), uid, v1.GetOptions{}) + require.Error(t, err, "Should not be able to access library element in restricted folder") + } + + err = deleteLibraryElement(t, ctx, ctx.AdminUser, uid) + require.NoError(t, err) + }) + }) + } +} + +func runLibraryElementCrossOrgTests(t *testing.T, org1Ctx, org2Ctx TestContext) { + // org1 trying to access org2 + org1CrossEditorClient := org2Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{ + User: org1Ctx.EditorUser, + Namespace: org2Ctx.Helper.Namespacer(org2Ctx.OrgID), + GVR: getLibraryElementGVR(), + }) + org1CrossViewerClient := org2Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{ + User: org1Ctx.ViewerUser, + Namespace: org2Ctx.Helper.Namespacer(org2Ctx.OrgID), + GVR: getLibraryElementGVR(), + }) + + // org2 trying to access org1 + org2CrossEditorClient := org1Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{ + User: org2Ctx.EditorUser, + Namespace: org1Ctx.Helper.Namespacer(org1Ctx.OrgID), + GVR: getLibraryElementGVR(), + }) + org2CrossViewerClient := org1Ctx.Helper.GetResourceClient(apis.ResourceClientArgs{ + User: org2Ctx.ViewerUser, + Namespace: org1Ctx.Helper.Namespacer(org1Ctx.OrgID), + GVR: getLibraryElementGVR(), + }) + + t.Run("Cross-organization access", func(t *testing.T) { + org1LibElementUID, err := createLibraryElement(t, org1Ctx, org1Ctx.AdminUser, "Org1 Library Element", "", nil) + require.NoError(t, err) + + org2LibElementUID, err := createLibraryElement(t, org2Ctx, org2Ctx.AdminUser, "Org2 Library Element", "", nil) + require.NoError(t, err) + + defer func() { + err = deleteLibraryElement(t, org1Ctx, org1Ctx.AdminUser, org1LibElementUID) + require.NoError(t, err) + + err = deleteLibraryElement(t, org2Ctx, org2Ctx.AdminUser, org2LibElementUID) + require.NoError(t, err) + }() + + testCrossOrgAccess := func(client *apis.K8sResourceClient, targetLibElementUID string, description string) { + t.Run(description, func(t *testing.T) { + _, err := client.Resource.Get(context.Background(), targetLibElementUID, v1.GetOptions{}) + require.Error(t, err, "Should not be able to access library element from another org") + }) + } + + testCrossOrgAccess(org1CrossEditorClient, org2LibElementUID, "Org1 editor cannot access Org2 library element") + testCrossOrgAccess(org1CrossViewerClient, org2LibElementUID, "Org1 viewer cannot access Org2 library element") + + testCrossOrgAccess(org2CrossEditorClient, org1LibElementUID, "Org2 editor cannot access Org1 library element") + testCrossOrgAccess(org2CrossViewerClient, org1LibElementUID, "Org2 viewer cannot access Org1 library element") + }) +} + +func getLibraryElementGVR() schema.GroupVersionResource { + return schema.GroupVersionResource{ + Group: dashboardV0.APIGroup, + Version: dashboardV0.VERSION, + Resource: dashboardV0.LIBRARY_PANEL_RESOURCE, + } +} + +// currently through /api +func createLibraryElement(t *testing.T, ctx TestContext, user apis.User, title string, folderUID string, uid *string) (string, error) { + t.Helper() + libraryElement := map[string]interface{}{ + "kind": 1, + "name": title, + "model": map[string]interface{}{ + "type": "text", + "title": title, + }, + } + if folderUID != "" { + libraryElement["folderUid"] = folderUID + } + + libraryElementURL := "/api/library-elements" + libraryElementData, err := postHelper(t, &ctx, libraryElementURL, libraryElement, user) + if err != nil { + return "", err + } + + require.NotNil(t, libraryElementData) + data := libraryElementData["result"].(map[string]interface{}) + uidStr := data["uid"].(string) + require.NotEmpty(t, uidStr) + + return uidStr, nil +} + +// currently through /api +func deleteLibraryElement(t *testing.T, ctx TestContext, user apis.User, uid string) error { + t.Helper() + deleteURL := fmt.Sprintf("/api/library-elements/%s", uid) + + resp := apis.DoRequest(ctx.Helper, apis.RequestParams{ + User: user, + Method: http.MethodDelete, + Path: deleteURL, + }, &struct{}{}) + + if resp.Response.StatusCode != http.StatusOK { + return fmt.Errorf("failed to delete library element: %s", resp.Response.Status) + } + + return nil +} diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index c8975a601cb..81d2d00a182 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -16,7 +16,6 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" - "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/dtos" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" @@ -583,7 +582,6 @@ func TestIntegrationFolderCreatePermissions(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - t.Skip("not working yet") folderWithoutParentInput := "{ \"uid\": \"uid\", \"title\": \"Folder\"}" folderWithParentInput := "{ \"uid\": \"uid\", \"title\": \"Folder\", \"parentUid\": \"parentuid\"}" @@ -708,7 +706,6 @@ func TestIntegrationFolderGetPermissions(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - t.Skip("not yet working") type testCase struct { description string @@ -735,10 +732,10 @@ func TestIntegrationFolderGetPermissions(t *testing.T) { checkAccessControl: true, }, { - description: "get folder by UID should return parent folders redacted if nested folder are enabled and user does not have read access to parent folders", + description: "get folder by UID should not return parent folders if nested folder are enabled and user does not have read access to parent folders", expectedCode: http.StatusOK, - expectedParentUIDs: []string{api.REDACTED}, - expectedParentTitles: []string{api.REDACTED}, + expectedParentUIDs: []string{}, + expectedParentTitles: []string{}, permissions: []resourcepermissions.SetResourcePermissionCommand{ { Actions: []string{dashboards.ActionFoldersRead}, diff --git a/pkg/tsdb/loki/api.go b/pkg/tsdb/loki/api.go index 4c1006fad33..464a267391a 100644 --- a/pkg/tsdb/loki/api.go +++ b/pkg/tsdb/loki/api.go @@ -27,11 +27,10 @@ import ( ) type LokiAPI struct { - client *http.Client - url string - log log.Logger - tracer trace.Tracer - requestStructuredMetadata bool + client *http.Client + url string + log log.Logger + tracer trace.Tracer } type RawLokiResponse struct { @@ -40,11 +39,11 @@ type RawLokiResponse struct { Encoding string } -func newLokiAPI(client *http.Client, url string, log log.Logger, tracer trace.Tracer, requestStructuredMetadata bool) *LokiAPI { - return &LokiAPI{client: client, url: url, log: log, tracer: tracer, requestStructuredMetadata: requestStructuredMetadata} +func newLokiAPI(client *http.Client, url string, log log.Logger, tracer trace.Tracer) *LokiAPI { + return &LokiAPI{client: client, url: url, log: log, tracer: tracer} } -func makeDataRequest(ctx context.Context, lokiDsUrl string, query lokiQuery, categorizeLabels bool) (*http.Request, error) { +func makeDataRequest(ctx context.Context, lokiDsUrl string, query lokiQuery) (*http.Request, error) { qs := url.Values{} qs.Set("query", query.Expr) @@ -103,11 +102,7 @@ func makeDataRequest(ctx context.Context, lokiDsUrl string, query lokiQuery, cat req.Header.Set("X-Query-Tags", "Source="+value) } } - - if categorizeLabels { - req.Header.Set("X-Loki-Response-Encoding-Flags", "categorize-labels") - } - + req.Header.Set("X-Loki-Response-Encoding-Flags", "categorize-labels") setXScopeOrgIDHeader(req, ctx) return req, nil @@ -164,7 +159,7 @@ func readLokiError(body io.ReadCloser) error { } func (api *LokiAPI) DataQuery(ctx context.Context, query lokiQuery, responseOpts ResponseOpts) (*backend.DataResponse, error) { - req, err := makeDataRequest(ctx, api.url, query, api.requestStructuredMetadata) + req, err := makeDataRequest(ctx, api.url, query) if err != nil { return nil, err } diff --git a/pkg/tsdb/loki/api_mock.go b/pkg/tsdb/loki/api_mock.go index a3e565bf351..7fd7568162f 100644 --- a/pkg/tsdb/loki/api_mock.go +++ b/pkg/tsdb/loki/api_mock.go @@ -57,16 +57,16 @@ func (mockedRT *mockedCompressedRoundTripper) RoundTrip(req *http.Request) (*htt }, nil } -func makeMockedAPI(statusCode int, contentType string, responseBytes []byte, requestCallback mockRequestCallback, structuredMetadata bool) *LokiAPI { - return makeMockedAPIWithUrl("http://localhost:9999", statusCode, contentType, responseBytes, requestCallback, structuredMetadata) +func makeMockedAPI(statusCode int, contentType string, responseBytes []byte, requestCallback mockRequestCallback) *LokiAPI { + return makeMockedAPIWithUrl("http://localhost:9999", statusCode, contentType, responseBytes, requestCallback) } -func makeMockedAPIWithUrl(url string, statusCode int, contentType string, responseBytes []byte, requestCallback mockRequestCallback, structuredMetadata bool) *LokiAPI { +func makeMockedAPIWithUrl(url string, statusCode int, contentType string, responseBytes []byte, requestCallback mockRequestCallback) *LokiAPI { client := http.Client{ Transport: &mockedRoundTripper{statusCode: statusCode, contentType: contentType, responseBytes: responseBytes, requestCallback: requestCallback}, } - return newLokiAPI(&client, url, backend.NewLoggerWith("logger", "test"), tracing.DefaultTracer(), structuredMetadata) + return newLokiAPI(&client, url, backend.NewLoggerWith("logger", "test"), tracing.DefaultTracer()) } func makeCompressedMockedAPIWithUrl(url string, statusCode int, contentType string, responseBytes []byte, requestCallback mockRequestCallback) *LokiAPI { @@ -74,5 +74,5 @@ func makeCompressedMockedAPIWithUrl(url string, statusCode int, contentType stri Transport: &mockedCompressedRoundTripper{statusCode: statusCode, contentType: contentType, responseBytes: responseBytes, requestCallback: requestCallback}, } - return newLokiAPI(&client, url, backend.NewLoggerWith("logger", "test"), tracing.DefaultTracer(), false) + return newLokiAPI(&client, url, backend.NewLoggerWith("logger", "test"), tracing.DefaultTracer()) } diff --git a/pkg/tsdb/loki/api_test.go b/pkg/tsdb/loki/api_test.go index d48bed634b1..252608780e6 100644 --- a/pkg/tsdb/loki/api_test.go +++ b/pkg/tsdb/loki/api_test.go @@ -28,7 +28,7 @@ func TestApiLogVolume(t *testing.T) { api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { called = true require.Equal(t, "Source=logvolhist", req.Header.Get("X-Query-Tags")) - }, false) + }) _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryLogsVolume, QueryType: QueryTypeRange}, ResponseOpts{}) require.NoError(t, err) @@ -40,7 +40,7 @@ func TestApiLogVolume(t *testing.T) { api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { called = true require.Equal(t, "Source=logsample", req.Header.Get("X-Query-Tags")) - }, false) + }) _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryLogsSample, QueryType: QueryTypeRange}, ResponseOpts{}) require.NoError(t, err) @@ -52,7 +52,7 @@ func TestApiLogVolume(t *testing.T) { api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { called = true require.Equal(t, "Source=datasample", req.Header.Get("X-Query-Tags")) - }, false) + }) _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryDataSample, QueryType: QueryTypeRange}, ResponseOpts{}) require.NoError(t, err) @@ -64,7 +64,7 @@ func TestApiLogVolume(t *testing.T) { api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { called = true require.Equal(t, "", req.Header.Get("X-Query-Tags")) - }, false) + }) _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryNone, QueryType: QueryTypeRange}, ResponseOpts{}) require.NoError(t, err) @@ -76,19 +76,19 @@ func TestApiLogVolume(t *testing.T) { api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { called = true require.Equal(t, "Source=foo", req.Header.Get("X-Query-Tags")) - }, false) + }) _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryType("foo"), QueryType: QueryTypeRange}, ResponseOpts{}) require.NoError(t, err) require.True(t, called) }) - t.Run("with `structuredMetadata` should set correct http header", func(t *testing.T) { + t.Run("should set correct http header", func(t *testing.T) { called := false api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { called = true require.Equal(t, "categorize-labels", req.Header.Get("X-Loki-Response-Encoding-Flags")) - }, true) + }) _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: SupportingQueryLogsVolume, QueryType: QueryTypeRange}, ResponseOpts{}) require.NoError(t, err) @@ -112,7 +112,7 @@ func TestInfiniteScroll(t *testing.T) { api := makeMockedAPI(200, "application/json", response, func(req *http.Request) { called = true require.Equal(t, "Source=infinitescroll", req.Header.Get("X-Query-Tags")) - }, false) + }) _, err := api.DataQuery(context.Background(), lokiQuery{Expr: "", SupportingQueryType: dataquery.SupportingQueryTypeInfiniteScroll, QueryType: QueryTypeRange}, ResponseOpts{}) require.NoError(t, err) @@ -177,7 +177,7 @@ func TestApiUrlHandling(t *testing.T) { wantedPrefix := test.rangeQueryPrefix failMessage := fmt.Sprintf(`wanted prefix: [%s], got string [%s]`, wantedPrefix, urlString) require.True(t, strings.HasPrefix(urlString, wantedPrefix), failMessage) - }, false) + }) query := lokiQuery{ QueryType: QueryTypeRange, @@ -198,7 +198,7 @@ func TestApiUrlHandling(t *testing.T) { wantedPrefix := test.instantQueryPrefix failMessage := fmt.Sprintf(`wanted prefix: [%s], got string [%s]`, wantedPrefix, urlString) require.True(t, strings.HasPrefix(urlString, wantedPrefix), failMessage) - }, false) + }) query := lokiQuery{ QueryType: QueryTypeInstant, @@ -216,7 +216,7 @@ func TestApiUrlHandling(t *testing.T) { api := makeMockedAPIWithUrl(test.dsUrl, 200, "application/json", response, func(req *http.Request) { called = true require.Equal(t, test.metaUrl, req.URL.String()) - }, false) + }) _, err := api.RawQuery(context.Background(), "/loki/api/v1/labels?start=1&end=2") require.NoError(t, err) @@ -289,7 +289,7 @@ func TestErrorSources(t *testing.T) { called := false api := makeMockedAPI(400, "application/json", errorResponse, func(req *http.Request) { called = true - }, false) + }) res, err := api.DataQuery(context.Background(), lokiQuery{QueryType: QueryTypeRange}, ResponseOpts{}) require.NoError(t, err) @@ -302,7 +302,7 @@ func TestErrorSources(t *testing.T) { called := false api := makeMockedAPI(406, "application/json", errorResponse, func(req *http.Request) { called = true - }, false) + }) res, err := api.DataQuery(context.Background(), lokiQuery{QueryType: QueryTypeRange}, ResponseOpts{}) require.NoError(t, err) @@ -315,7 +315,7 @@ func TestErrorSources(t *testing.T) { called := false api := makeMockedAPI(500, "application/json", errorResponse, func(req *http.Request) { called = true - }, false) + }) res, err := api.DataQuery(context.Background(), lokiQuery{QueryType: QueryTypeRange}, ResponseOpts{}) require.NoError(t, err) @@ -328,7 +328,7 @@ func TestErrorSources(t *testing.T) { called := false api := makeMockedAPI(400, "application/json", errorResponse, func(req *http.Request) { called = true - }, false) + }) res, err := api.DataQuery(context.Background(), lokiQuery{QueryType: QueryTypeRange}, ResponseOpts{}) require.NoError(t, err) @@ -342,7 +342,7 @@ func TestErrorSources(t *testing.T) { called := false api := makeMockedAPI(400, "application/json", errorResponse, func(req *http.Request) { called = true - }, false) + }) res, err := api.RawQuery(context.Background(), "/loki/api/v1/labels") require.NoError(t, err) @@ -355,7 +355,7 @@ func TestErrorSources(t *testing.T) { called := false api := makeMockedAPI(500, "application/json", errorResponse, func(req *http.Request) { called = true - }, false) + }) _, err := api.RawQuery(context.Background(), "/loki/api/v1/labels") require.Error(t, err) diff --git a/pkg/tsdb/loki/framing_test.go b/pkg/tsdb/loki/framing_test.go index 8715f98e4d5..624922ca49d 100644 --- a/pkg/tsdb/loki/framing_test.go +++ b/pkg/tsdb/loki/framing_test.go @@ -66,7 +66,7 @@ func TestSuccessResponse(t *testing.T) { bytes, err := os.ReadFile(responseFileName) require.NoError(t, err) - dr, err := runQuery(context.Background(), makeMockedAPI(http.StatusOK, "application/json", bytes, nil, false), &query, responseOpts, backend.NewLoggerWith("logger", "test")) + dr, err := runQuery(context.Background(), makeMockedAPI(http.StatusOK, "application/json", bytes, nil), &query, responseOpts, backend.NewLoggerWith("logger", "test")) require.NoError(t, err) experimental.CheckGoldenJSONResponse(t, folder, goldenFileName, dr, false) @@ -125,7 +125,7 @@ func TestErrorResponse(t *testing.T) { for _, test := range tt { t.Run(test.name, func(t *testing.T) { - dr, err := runQuery(context.Background(), makeMockedAPI(400, test.contentType, test.body, nil, false), &lokiQuery{QueryType: QueryTypeRange, Direction: DirectionBackward}, ResponseOpts{}, backend.NewLoggerWith("logger", "test")) + dr, err := runQuery(context.Background(), makeMockedAPI(400, test.contentType, test.body, nil), &lokiQuery{QueryType: QueryTypeRange, Direction: DirectionBackward}, ResponseOpts{}, backend.NewLoggerWith("logger", "test")) require.NoError(t, err) require.Len(t, dr.Frames, 0) require.Equal(t, dr.Error.Error(), test.errorMessage) @@ -172,7 +172,7 @@ func TestErrorsFromResponseCodes(t *testing.T) { for _, test := range tt { t.Run(test.name, func(t *testing.T) { - dr, _ := runQuery(context.Background(), makeMockedAPI(test.statusCode, contentType, []byte(errorString), nil, false), &lokiQuery{QueryType: QueryTypeRange, Direction: DirectionBackward}, ResponseOpts{}, backend.NewLoggerWith("logger", "test")) + dr, _ := runQuery(context.Background(), makeMockedAPI(test.statusCode, contentType, []byte(errorString), nil), &lokiQuery{QueryType: QueryTypeRange, Direction: DirectionBackward}, ResponseOpts{}, backend.NewLoggerWith("logger", "test")) require.Len(t, dr.Frames, 0) require.Equal(t, dr.Error.Error(), errorString) require.Equal(t, dr.ErrorSource, test.errorSource) diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index 5114c514610..4b68c66b42a 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -29,7 +29,6 @@ import ( const ( flagLokiLogsDataplane = "lokiLogsDataplane" flagLokiRunQueriesInParallel = "lokiRunQueriesInParallel" - flagLokiStructuredMetadata = "lokiStructuredMetadata" flagLogQLScope = "logQLScope" flagLokiExperimentalStreaming = "lokiExperimentalStreaming" fromAlertHeaderName = "FromAlert" @@ -134,7 +133,7 @@ func callResource(ctx context.Context, req *backend.CallResourceRequest, sender )) defer span.End() - api := newLokiAPI(dsInfo.HTTPClient, dsInfo.URL, plog, tracer, false) + api := newLokiAPI(dsInfo.HTTPClient, dsInfo.URL, plog, tracer) var rawLokiResponse RawLokiResponse var err error @@ -186,13 +185,13 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) logsDataplane: isFeatureEnabled(ctx, flagLokiLogsDataplane), } - return queryData(ctx, req, dsInfo, responseOpts, s.tracer, logger, isFeatureEnabled(ctx, flagLokiRunQueriesInParallel), isFeatureEnabled(ctx, flagLokiStructuredMetadata), isFeatureEnabled(ctx, flagLogQLScope)) + return queryData(ctx, req, dsInfo, responseOpts, s.tracer, logger, isFeatureEnabled(ctx, flagLokiRunQueriesInParallel), isFeatureEnabled(ctx, flagLogQLScope)) } -func queryData(ctx context.Context, req *backend.QueryDataRequest, dsInfo *datasourceInfo, responseOpts ResponseOpts, tracer trace.Tracer, plog log.Logger, runInParallel bool, requestStructuredMetadata, logQLScopes bool) (*backend.QueryDataResponse, error) { +func queryData(ctx context.Context, req *backend.QueryDataRequest, dsInfo *datasourceInfo, responseOpts ResponseOpts, tracer trace.Tracer, plog log.Logger, runInParallel bool, logQLScopes bool) (*backend.QueryDataResponse, error) { result := backend.NewQueryDataResponse() - api := newLokiAPI(dsInfo.HTTPClient, dsInfo.URL, plog, tracer, requestStructuredMetadata) + api := newLokiAPI(dsInfo.HTTPClient, dsInfo.URL, plog, tracer) start := time.Now() queries, err := parseQuery(req, logQLScopes) diff --git a/pkg/tsdb/loki/loki_bench_test.go b/pkg/tsdb/loki/loki_bench_test.go index f9c5e15f293..5819feee802 100644 --- a/pkg/tsdb/loki/loki_bench_test.go +++ b/pkg/tsdb/loki/loki_bench_test.go @@ -19,7 +19,7 @@ func BenchmarkMatrixJson(b *testing.B) { b.ResetTimer() for n := 0; n < b.N; n++ { - _, _ = runQuery(context.Background(), makeMockedAPI(http.StatusOK, "application/json", bytes, nil, false), &lokiQuery{}, ResponseOpts{}, backend.NewLoggerWith("logger", "test")) + _, _ = runQuery(context.Background(), makeMockedAPI(http.StatusOK, "application/json", bytes, nil), &lokiQuery{}, ResponseOpts{}, backend.NewLoggerWith("logger", "test")) } } diff --git a/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx b/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx new file mode 100644 index 00000000000..9c1f0330ac7 --- /dev/null +++ b/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx @@ -0,0 +1,48 @@ +import { useLocation } from 'react-router-dom'; + +import { PluginExtensionPoints } from '@grafana/data'; +import { config, renderLimitedComponents, usePluginComponents } from '@grafana/runtime'; +import { ErrorBoundaryAlert } from '@grafana/ui'; + +const excludedRoutes: Record = { + '/login': true, + '/signup': true, + '/verify': true, + '/user/password/send-reset-email': true, + '/user/password/reset': true, + '/sandbox/benchmarks': true, +}; + +export function AppChromeExtensionPoint(): JSX.Element | null { + const location = useLocation(); + + if (config.featureToggles.enableAppChromeExtensions !== true) { + return null; + } + + if (excludedRoutes[location.pathname]) { + return null; + } + + return ( + + + + ); +} + +function InternalAppChromeExtensionPoint(): JSX.Element | null { + const { components, isLoading } = usePluginComponents({ + extensionPointId: PluginExtensionPoints.AppChrome, + }); + + if (isLoading) { + return null; + } + + return renderLimitedComponents({ + props: {}, + components: components, + pluginId: 'grafana-setupguide-app', + }); +} diff --git a/public/app/core/components/RolePicker/BuiltinRoleSelector.tsx b/public/app/core/components/RolePicker/BuiltinRoleSelector.tsx index e953f3242a6..c0fa457d8c2 100644 --- a/public/app/core/components/RolePicker/BuiltinRoleSelector.tsx +++ b/public/app/core/components/RolePicker/BuiltinRoleSelector.tsx @@ -1,15 +1,11 @@ import { SelectableValue } from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { Icon, RadioButtonList, Tooltip, useStyles2, useTheme2, PopoverContent } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; import { OrgRole } from 'app/types'; import { getStyles } from './styles'; -const BasicRoleOption: Array> = Object.values(OrgRole).map((r) => ({ - label: r === OrgRole.None ? 'No basic role' : r, - value: r, -})); - interface Props { value?: OrgRole; onChange: (value: OrgRole) => void; @@ -22,6 +18,20 @@ export const BuiltinRoleSelector = ({ value, onChange, disabled, disabledMesssag const styles = useStyles2(getStyles); const theme = useTheme2(); + // Create options dynamically to filter out OrgRole.None when access control is not licensed + const basicRoleOptions: Array> = Object.values(OrgRole) + .filter((r) => { + // Filter out OrgRole.None if access control is not licensed + if (r === OrgRole.None && !contextSrv.licensedAccessControlEnabled()) { + return false; + } + return true; + }) + .map((r) => ({ + label: r === OrgRole.None ? 'No basic role' : r, + value: r, + })); + return ( <>
@@ -42,7 +52,7 @@ export const BuiltinRoleSelector = ({ value, onChange, disabled, disabledMesssag (Boolean(helpFlags & HELP_FLAG_ENTERPRISE_AUTH)); + + const onDismiss = () => { + backendSrv + .put(`/api/user/helpflags/${HELP_FLAG_ENTERPRISE_AUTH}`, undefined, { showSuccessAlert: false }) + .then((res) => { + contextSrv.user.helpFlags1 = res.helpFlags1; + setDismissed(true); + }); + }; + + // This card is only visible in oss + if (isDismissed || !isOpenSourceBuildOrUnlicenced()) { + return null; + } + + return ( +
+ + +
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + cloudBadge: css({ + display: 'flex', + alignItems: 'center', + background: theme.colors.gradients.brandHorizontal, + color: theme.colors.primary.contrastText, + padding: theme.spacing(0.5, 1), + borderRadius: theme.shape.radius.pill, + fontSize: theme.typography.bodySmall.fontSize, + gap: theme.spacing(1), + }), + box: css({ + padding: theme.spacing(3), + border: `1px solid ${theme.colors.border.weak}`, + backgroundColor: theme.colors.background.secondary, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1.5), + borderRadius: theme.shape.radius.default, + marginTop: theme.spacing(3), + strong: { + color: theme.colors.text.primary, + }, + }), + icon: css({ + position: 'relative', + top: -1, + }), + }; +} + +export function isOpenSourceBuildOrUnlicenced() { + if (config.buildInfo.edition === GrafanaEdition.OpenSource) { + return true; + } + + if (config.licenseInfo.stateInfo !== 'Licensed') { + return true; + } + + return false; +} diff --git a/public/app/features/admin/UserListAdminPage.tsx b/public/app/features/admin/UserListAdminPage.tsx index d14b2d02f09..d4b485c3ada 100644 --- a/public/app/features/admin/UserListAdminPage.tsx +++ b/public/app/features/admin/UserListAdminPage.tsx @@ -11,6 +11,7 @@ import { contextSrv } from 'app/core/core'; import { AccessControlAction, StoreState, UserFilter } from '../../types'; +import { EnterpriseAuthFeaturesCard } from './EnterpriseAuthFeaturesCard'; import { UsersTable } from './Users/UsersTable'; import { changeFilter, changePage, changeQuery, changeSort, fetchUsers } from './state/actions'; @@ -118,6 +119,7 @@ const UserListAdminPageUnConnected = ({ fetchData={changeSort} /> )} + ); }; diff --git a/public/app/features/auth-config/components/ProviderCard.tsx b/public/app/features/auth-config/components/ProviderCard.tsx index cb83ca8f28b..b7263c342dd 100644 --- a/public/app/features/auth-config/components/ProviderCard.tsx +++ b/public/app/features/auth-config/components/ProviderCard.tsx @@ -51,7 +51,7 @@ export function ProviderSAMLCard() { external variant="bodySmall" color="secondary" - href="https://grafana.com/auth/sign-up/create-user?cloud-auth=&redirectPath=cloud-auth&utm_source=oss-authorization-admin" + href="https://grafana.com/auth/sign-up/create-user?cloud-auth=&redirectPath=cloud-auth&utm_source=oss-grafana&cnt=admin-authorization-saml" > {t('auth-config.provider-card.saml-learn-more', 'Single sign-on (SSO) with SAML.')} @@ -77,7 +77,7 @@ export function ProviderSCIMCard() { external variant="bodySmall" color="secondary" - href="https://grafana.com/auth/sign-up/create-user?cloud-auth=&redirectPath=cloud-auth&utm_source=oss-authorization-admin" + href="https://grafana.com/auth/sign-up/create-user?cloud-auth=&redirectPath=cloud-auth&utm_source=oss-grafana&cnt=admin-authorization-scim" > {t('auth-config.provider-card.scim-learn-more', ' Sync users and teams with SCIM.')} diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx index 51583987271..2e8e92f7321 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx @@ -333,7 +333,7 @@ describe('usePluginComponent()', () => { expect(log.warning).not.toHaveBeenCalled(); }); - it('should pass a read-only copy of the props (in dev mode)', async () => { + it('should pass a writable copy of the props (in dev mode)', async () => { config.buildInfo.env = 'development'; type Props = { @@ -377,12 +377,13 @@ describe('usePluginComponent()', () => { const rendered = render(Component && ); expect(rendered.getByText('Foo')).toBeVisible(); - // Should throw an error if it mutates the props - jest.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render(Component && )).toThrow( - TypeError("'set' on proxy: trap returned falsish for property 'c'") - ); - jest.spyOn(console, 'error').mockRestore(); + // Should not throw an error if it mutates the props + expect(() => render(Component && )).not.toThrow(); + + // Should log a warning + expect(log.warning).toHaveBeenCalledWith('Attempted to mutate object property "c"', { + stack: expect.any(String), + }); }); it('should pass a writable copy of the props (in production mode)', async () => { @@ -429,7 +430,7 @@ describe('usePluginComponent()', () => { const rendered = render(Component && ); expect(rendered.getByText('Foo')).toBeVisible(); - // Should throw an error if it mutates the props + // Should not throw an error if it mutates the props expect(() => render(Component && )).not.toThrow(); // Should log a warning diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx index 7f306ff3335..c2ab7ec6845 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx @@ -211,7 +211,7 @@ describe('usePluginComponents()', () => { }); }); - it('should pass a read only copy of the props to the components (in dev mode)', async () => { + it('should pass a copy of the props to the components (in dev mode)', async () => { config.buildInfo.env = 'development'; type Props = { @@ -264,9 +264,11 @@ describe('usePluginComponents()', () => { const rendered = render(); expect(rendered.getByText('Foo')).toBeVisible(); - // Check if it throws a TypeError due to trying to change the prop - jest.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow(TypeError); + // Should also render the component if it wants to change the props + expect(() => render()).not.toThrow(); + expect(log.warning).toHaveBeenCalledWith(`Attempted to mutate object property "foo4"`, { + stack: expect.any(String), + }); // Check if the original property hasn't been changed expect(originalFoo.foo2.foo3.foo4).toBe('bar'); diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index b2cf153cb7d..e1edf9bd3c8 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { type Unsubscribable } from 'rxjs'; import { dateTime, usePluginContext, PluginLoadingStrategy } from '@grafana/data'; @@ -476,7 +476,7 @@ describe('Plugin Extensions / Utils', () => { }).toThrow(TypeError); }); - it('should return a read-only proxy of a deep-copy of the original object in dev mode', () => { + it('should return a writable deep-copy of the original object in dev mode', () => { config.featureToggles.extensionsReadOnlyProxy = false; config.buildInfo.env = 'development'; @@ -485,34 +485,16 @@ describe('Plugin Extensions / Utils', () => { expect(copy).not.toBe(obj); expect(copy.a).toBe('a'); - expect(isReadOnlyProxy(copy)).toBe(true); + expect(isMutationObserverProxy(copy)).toBe(true); expect(() => { copy.a = 'b'; - }).toThrow(TypeError); + }).not.toThrow(); - // Also test that we can handle frozen objects - // (This is not possible with getReadOnlyProxy, as it throws an error when the object is already frozen) - const obj2 = { - a: { - b: { - c: { - d: 'd', - }, - }, - }, - }; + expect(log.warning).toHaveBeenCalledWith(`Attempted to mutate object property "a"`, { + stack: expect.any(String), + }); - Object.freeze(obj2); - Object.freeze(obj2.a); - Object.freeze(obj2.a.b); - - const copy2 = readOnlyCopy(obj2); - - expect(() => { - copy2.a.b.c.d = 'testing'; - }).toThrow("'set' on proxy: trap returned falsish for property 'd'"); - - expect(copy2.a.b.c.d).toBe('d'); + expect(copy.a).toBe('b'); }); it('should return a writable deep-copy of the original object in production mode', () => { @@ -705,7 +687,7 @@ describe('Plugin Extensions / Utils', () => { expect(screen.getByText('Version: 1.0.0')).toBeVisible(); }); - it('should not be possible to mutate the props in development mode, and it also throws an error', async () => { + it('should not be possible to mutate the props in development mode, but it logs a warning', async () => { config.buildInfo.env = 'development'; const pluginId = 'grafana-worldmap-panel'; const Component = wrapWithPluginContext(pluginId, ExampleComponent, log); @@ -713,16 +695,17 @@ describe('Plugin Extensions / Utils', () => { jest.spyOn(console, 'error').mockImplementation(); - await expect(async () => { - await act(async () => { - render(); - }); - }).rejects.toThrow(`'set' on proxy: trap returned falsish for property 'c'`); + render(); - // Logs an error - expect(console.error).toHaveBeenCalledWith(expect.any(String)); + expect(await screen.findByText('Hello Grafana!')).toBeVisible(); - // Not able to mutate the props in development mode + // Logs a warning + expect(log.warning).toHaveBeenCalledTimes(1); + expect(log.warning).toHaveBeenCalledWith(`Attempted to mutate object property "c"`, { + stack: expect.any(String), + }); + + // Not able to mutate the props in dev mode either expect(props.a.b.c).toBe('Grafana'); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index e60cff38877..72f17ee6974 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -295,11 +295,6 @@ export function readOnlyCopy(value: T, _log: ExtensionsLog = log): T { return getReadOnlyProxy(value); } - // In dev mode: we return a read-only proxy (throws errors for any mutation), but with a deep-cloned version of the original object (so no interference with other call-sites) - if (isGrafanaDevMode()) { - return getReadOnlyProxy(cloneDeep(value)); - } - // Default: we return a proxy of a deep-cloned version of the original object, which logs warnings when mutation is attempted return getMutationObserverProxy(cloneDeep(value), _log); } diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx index abbfff37583..7629076e3c2 100644 --- a/public/app/features/teams/TeamList.test.tsx +++ b/public/app/features/teams/TeamList.test.tsx @@ -13,6 +13,9 @@ jest.mock('app/core/core', () => ({ contextSrv: { hasPermission: (action: string) => true, licensedAccessControlEnabled: () => false, + user: { + helpFlags1: 0, + }, }, })); diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index 369285604ae..dd93bd941e7 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -27,6 +27,7 @@ import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction, Role, StoreState, TeamWithRoles } from 'app/types'; import { TeamRolePicker } from '../../core/components/RolePicker/TeamRolePicker'; +import { EnterpriseAuthFeaturesCard } from '../admin/EnterpriseAuthFeaturesCard'; import { deleteTeam, loadTeams, changePage, changeQuery, changeSort } from './state/actions'; @@ -288,6 +289,7 @@ export const TeamList = ({ )} )} + {!query && } ); diff --git a/public/app/features/transformers/regression/regression.test.ts b/public/app/features/transformers/regression/regression.test.ts index 00dd836f064..ec82cfca2d5 100644 --- a/public/app/features/transformers/regression/regression.test.ts +++ b/public/app/features/transformers/regression/regression.test.ts @@ -42,7 +42,7 @@ describe('Regression transformation', () => { length: 6, }), toEquableDataFrame({ - name: 'linear regression', + name: 'Linear regression', fields: [ { name: 'time', type: FieldType.time, values: [0, 1, 2, 3, 4, 5], config: {} }, { name: 'value predicted', type: FieldType.number, values: [0, 1, 2, 3, 4, 5], config: {} }, @@ -83,7 +83,7 @@ describe('Regression transformation', () => { length: 6, }), toEquableDataFrame({ - name: 'linear regression', + name: 'Linear regression', fields: [ { name: 'time', type: FieldType.time, values: [0, 1, 2, 3, 4, 5], config: {} }, { name: 'value predicted', type: FieldType.number, values: [1, 1, 1, 1, 1, 1], config: {} }, @@ -122,6 +122,7 @@ describe('Regression transformation', () => { expect(result[1].fields[1].values[3]).toBeCloseTo(1.86, 1); expect(result[1].fields[1].values[4]).toBeCloseTo(1.2, 1); expect(result[1].fields[1].values[5]).toBeCloseTo(-0.1, 1); + expect(result[1].name).toBe('Quadratic polynomial regression'); }); it('should have the last x point be equal to the last x point of the data', () => { @@ -151,6 +152,7 @@ describe('Regression transformation', () => { expect(result[1].fields[0].values[4]).toBeCloseTo(1.76, 1); expect(result[1].fields[0].values[8]).toBeCloseTo(3.55, 1); expect(result[1].fields[0].values[9]).toBe(4); + expect(result[1].name).toBe('Quadratic polynomial regression'); }); it('should filter NaNs', () => { @@ -179,6 +181,7 @@ describe('Regression transformation', () => { expect(result[1].fields[1].values[2]).toBe(2); expect(result[1].fields[1].values[3]).toBe(3); expect(result[1].fields[1].values[4]).toBe(4); + expect(result[1].name).toBe('Linear regression'); }); }); diff --git a/public/app/features/transformers/regression/regression.ts b/public/app/features/transformers/regression/regression.ts index 80e70367284..751dbd63b25 100644 --- a/public/app/features/transformers/regression/regression.ts +++ b/public/app/features/transformers/regression/regression.ts @@ -11,6 +11,7 @@ import { fieldMatchers, getFieldDisplayName, } from '@grafana/data'; +import { t } from '@grafana/i18n'; export enum ModelType { linear = 'linear', @@ -27,6 +28,13 @@ export interface RegressionTransformerOptions { export const DEFAULTS = { predictionCount: 100, modelType: ModelType.linear, degree: 2 }; +export const DEGREES = [ + { label: () => t('transformers.regression-transformer-editor.label.quadratic', 'Quadratic'), value: 2 }, + { label: () => t('transformers.regression-transformer-editor.label.cubic', 'Cubic'), value: 3 }, + { label: () => t('transformers.regression-transformer-editor.label.quartic', 'Quartic'), value: 4 }, + { label: () => t('transformers.regression-transformer-editor.label.quintic', 'Quintic'), value: 5 }, +]; + export const RegressionTransformer: SynchronousDataTransformerInfo = { id: DataTransformerID.regression, name: 'Regression analysis', @@ -105,8 +113,14 @@ export const RegressionTransformer: SynchronousDataTransformerInfo deg.value === degree); + frameName = `${degreeData?.label()} ${t('transformers.regression-transformer-editor.model-type-options.label.polynomial', 'Polynomial').toLocaleLowerCase()} ${t('transformers.regression-transformer-editor.regression', 'regression')}`; + } + const newFrame: DataFrame = { - name: `${modelType} regression`, + name: `${frameName}`, length: predictionPoints.length, fields: [ { name: xField.name, type: xField.type, values: predictionPoints, config: {} }, diff --git a/public/app/features/transformers/regression/regressionEditor.tsx b/public/app/features/transformers/regression/regressionEditor.tsx index cf943c1f949..2b340daef2e 100644 --- a/public/app/features/transformers/regression/regressionEditor.tsx +++ b/public/app/features/transformers/regression/regressionEditor.tsx @@ -16,7 +16,7 @@ import { NumberInput } from 'app/core/components/OptionsUI/NumberInput'; import { getTransformationContent } from '../docs/getTransformationContent'; -import { DEFAULTS, ModelType, RegressionTransformer, RegressionTransformerOptions } from './regression'; +import { DEFAULTS, DEGREES, ModelType, RegressionTransformer, RegressionTransformerOptions } from './regression'; const fieldNamePickerSettings = { editor: FieldNamePicker, @@ -141,12 +141,12 @@ export const RegressionTransformerEditor = ({ > value={options.degree ?? DEFAULTS.degree} - options={[ - { label: t('transformers.regression-transformer-editor.label.quadratic', 'Quadratic'), value: 2 }, - { label: t('transformers.regression-transformer-editor.label.cubic', 'Cubic'), value: 3 }, - { label: t('transformers.regression-transformer-editor.label.quartic', 'Quartic'), value: 4 }, - { label: t('transformers.regression-transformer-editor.label.quintic', 'Quintic'), value: 5 }, - ]} + options={DEGREES.map((deg) => { + return { + label: deg.label(), + value: deg.value, + }; + })} onChange={(v) => { onChange({ ...options, degree: v.value }); }} diff --git a/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx b/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx index 677ec742b69..c33f9123961 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx @@ -12,7 +12,7 @@ import { QueryHeaderSwitch, QueryEditorMode, } from '@grafana/plugin-ui'; -import { config, reportInteraction } from '@grafana/runtime'; +import { reportInteraction } from '@grafana/runtime'; import { Button, ConfirmModal, Space, Stack } from '@grafana/ui'; import { LabelBrowserModal } from '../querybuilder/components/LabelBrowserModal'; @@ -43,13 +43,9 @@ export const LokiQueryEditor = memo((props) => { const [queryStats, setQueryStats] = useState(null); const [explain, setExplain] = useState(window.localStorage.getItem(lokiQueryEditorExplainKey) === 'true'); - const predefinedOperations = datasource.predefinedOperations; const previousTimeRange = usePrevious(timeRange); const query = getQueryWithDefaults(props.query); - if (config.featureToggles.lokiPredefinedOperations && !query.expr && predefinedOperations) { - query.expr = `{} ${predefinedOperations}`; - } const previousQueryExpr = usePrevious(query.expr); const previousQueryType = usePrevious(query.queryType); diff --git a/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx index 34e5420c53d..a63878dee1e 100644 --- a/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx @@ -1,5 +1,3 @@ -import { useCallback } from 'react'; - import { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data'; import { ConfigSection, @@ -9,7 +7,7 @@ import { convertLegacyAuthProps, AdvancedHttpSettings, } from '@grafana/plugin-ui'; -import { config, reportInteraction } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; import { Divider, SecureSocksProxySettings, Stack } from '@grafana/ui'; import { LokiOptions } from '../types'; @@ -33,20 +31,10 @@ const makeJsonUpdater = }; const setMaxLines = makeJsonUpdater('maxLines'); -const setPredefinedOperations = makeJsonUpdater('predefinedOperations'); const setDerivedFields = makeJsonUpdater('derivedFields'); export const ConfigEditor = (props: Props) => { const { options, onOptionsChange } = props; - - const updatePredefinedOperations = useCallback( - (value: string) => { - reportInteraction('grafana_loki_predefined_operations_changed', { value }); - onOptionsChange(setPredefinedOperations(options, value)); - }, - [options, onOptionsChange] - ); - return ( <> { onOptionsChange(setMaxLines(options, value))} - predefinedOperations={options.jsonData.predefinedOperations || ''} - onPredefinedOperationsChange={updatePredefinedOperations} /> void; - predefinedOperations: string; - onPredefinedOperationsChange: (value: string) => void; }; export const QuerySettings = (props: Props) => { - const { maxLines, onMaxLinedChange, predefinedOperations, onPredefinedOperationsChange } = props; + const { maxLines, onMaxLinedChange } = props; return ( { spellCheck={false} /> - - {config.featureToggles.lokiPredefinedOperations && ( - - - { - 'Predefined operations are used as an initial state for your queries. They are useful, if you want to unpack, parse or format all log lines. Currently we support only log operations starting with |. For example: | unpack | line_format "{{.message}}".' - } - - } - > - ) => - onPredefinedOperationsChange(event.currentTarget.value) - } - width={40} - placeholder="| unpack | line_format" - spellCheck={false} - /> - - - - - - )} ); }; diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 830c8ebb70e..4ded88df124 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -147,7 +147,6 @@ export class LokiDatasource private logContextProvider: LogContextProvider; languageProvider: LanguageProvider; maxLines: number; - predefinedOperations: string; constructor( private instanceSettings: DataSourceInstanceSettings, @@ -158,7 +157,6 @@ export class LokiDatasource this.languageProvider = new LanguageProvider(this); const settingsData = instanceSettings.jsonData || {}; this.maxLines = parseInt(settingsData.maxLines ?? '0', 10) || DEFAULT_MAX_LINES; - this.predefinedOperations = settingsData.predefinedOperations ?? ''; this.annotations = { QueryEditor: LokiAnnotationsQueryEditor, }; @@ -342,11 +340,7 @@ export class LokiDatasource } const startTime = new Date(); - return this.runQuery(fixedRequest).pipe( - tap((response) => - trackQuery(response, fixedRequest, startTime, { predefinedOperations: this.predefinedOperations }) - ) - ); + return this.runQuery(fixedRequest).pipe(tap((response) => trackQuery(response, fixedRequest, startTime))); } /** diff --git a/public/app/plugins/datasource/loki/querySplitting.test.ts b/public/app/plugins/datasource/loki/querySplitting.test.ts index 5920b08fcdd..9394adf938b 100644 --- a/public/app/plugins/datasource/loki/querySplitting.test.ts +++ b/public/app/plugins/datasource/loki/querySplitting.test.ts @@ -306,8 +306,7 @@ describe('runSplitQuery()', () => { }, ], request, - new Date(), - { predefinedOperations: '' } + new Date() ); }); }); @@ -336,8 +335,7 @@ describe('runSplitQuery()', () => { }, ], request, - new Date(), - { predefinedOperations: '' } + new Date() ); }); }); @@ -444,90 +442,6 @@ describe('runSplitQuery()', () => { }); }); - describe('Splitting targets based on splitDuration', () => { - const range1h = { - from: dateTime('2023-02-08T05:00:00.000Z'), - to: dateTime('2023-02-08T06:00:00.000Z'), - raw: { - from: dateTime('2023-02-08T05:00:00.000Z'), - to: dateTime('2023-02-08T06:00:00.000Z'), - }, - }; - beforeEach(() => { - jest.spyOn(datasource, 'runQuery').mockReturnValue(of({ data: [], refId: 'A' })); - }); - test('with 30m splitDuration runs 2 queries', async () => { - const request = { - targets: [{ expr: '{a="b"}', refId: 'A', splitDuration: '30m' }], - range: range1h, - } as DataQueryRequest; - await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { - expect(datasource.runQuery).toHaveBeenCalledTimes(2); - }); - }); - test('with 1h splitDuration runs 1 queries', async () => { - const request = { - targets: [{ expr: '{a="b"}', refId: 'A', splitDuration: '1h' }], - range: range1h, - } as DataQueryRequest; - await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { - expect(datasource.runQuery).toHaveBeenCalledTimes(1); - }); - }); - test('with 1h splitDuration and 2 targets runs 1 queries', async () => { - const request = { - targets: [ - { expr: '{a="b"}', refId: 'A', splitDuration: '1h' }, - { expr: '{a="b"}', refId: 'B', splitDuration: '1h' }, - ], - range: range1h, - } as DataQueryRequest; - await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { - expect(datasource.runQuery).toHaveBeenCalledTimes(1); - }); - }); - test('with 1h/30m splitDuration and 2 targets runs 3 queries', async () => { - const request = { - targets: [ - { expr: '{a="b"}', refId: 'A', splitDuration: '1h' }, - { expr: '{a="b"}', refId: 'B', splitDuration: '30m' }, - ], - range: range1h, - } as DataQueryRequest; - await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { - // 2 x 30m + 1 x 1h - expect(datasource.runQuery).toHaveBeenCalledTimes(3); - }); - }); - test('with mixed splitDuration runs the expected amount of queries', async () => { - const request = createRequest( - [ - { expr: 'count_over_time({c="d"}[1m])', refId: 'A', splitDuration: '15m' }, - { expr: '{a="b"}', refId: 'B', splitDuration: '15m' }, - { expr: '{a="b"}', refId: 'C', splitDuration: '1h' }, - ], - { range: range1h } - ); - await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { - // 4 * 15m + 4 * 15m + 1 * 1h - expect(datasource.runQuery).toHaveBeenCalledTimes(9); - }); - }); - test('with 1h/30m splitDuration and 1 log and 2 metric target runs 3 queries', async () => { - const request = createRequest( - [ - { expr: '{a="b"}', refId: 'A', splitDuration: '1h' }, - { expr: 'count_over_time({c="d"}[1m])', refId: 'C', splitDuration: '30m' }, - ], - { range: range1h } - ); - await expect(runSplitQuery(datasource, request)).toEmitValuesWith(() => { - // 2 x 30m + 1 x 1h - expect(datasource.runQuery).toHaveBeenCalledTimes(3); - }); - }); - }); - describe('Splitting targets based on resolution', () => { const range1d = { from: dateTime('2023-02-08T05:00:00.000Z'), diff --git a/public/app/plugins/datasource/loki/querySplitting.ts b/public/app/plugins/datasource/loki/querySplitting.ts index d5ff1c870df..069ed4e7b90 100644 --- a/public/app/plugins/datasource/loki/querySplitting.ts +++ b/public/app/plugins/datasource/loki/querySplitting.ts @@ -8,8 +8,6 @@ import { DataQueryResponse, DataTopic, dateTime, - durationToMilliseconds, - parseDuration, rangeUtil, TimeRange, LoadingState, @@ -307,45 +305,35 @@ export function runSplitQuery( const requests: LokiGroupedRequest[] = []; for (const direction in directionPartitionedLogQueries) { - const rangePartitionedLogQueries = groupBy(directionPartitionedLogQueries[direction], (query) => - query.splitDuration ? durationToMilliseconds(parseDuration(query.splitDuration)) : oneDayMs - ); - for (const [chunkRangeMs, queries] of Object.entries(rangePartitionedLogQueries)) { - const resolutionPartition = groupBy(queries, (query) => query.resolution || 1); - for (const resolution in resolutionPartition) { - const groupedRequest = { - request: { ...request, targets: resolutionPartition[resolution] }, - partition: partitionTimeRange(true, request.range, request.intervalMs, Number(chunkRangeMs)), - }; + const queries = directionPartitionedLogQueries[direction]; + const resolutionPartition = groupBy(queries, (query) => query.resolution || 1); + for (const resolution in resolutionPartition) { + const groupedRequest = { + request: { ...request, targets: resolutionPartition[resolution] }, + partition: partitionTimeRange(true, request.range, request.intervalMs, oneDayMs), + }; - if (direction === LokiQueryDirection.Forward) { - groupedRequest.partition.reverse(); - } - - requests.push(groupedRequest); + if (direction === LokiQueryDirection.Forward) { + groupedRequest.partition.reverse(); } + + requests.push(groupedRequest); } } - const rangePartitionedMetricQueries = groupBy(metricQueries, (query) => - query.splitDuration ? durationToMilliseconds(parseDuration(query.splitDuration)) : oneDayMs + const stepMsPartition = groupBy(metricQueries, (query) => + calculateStep(request.intervalMs, request.range, query.resolution || 1, query.step) ); - for (const [chunkRangeMs, queries] of Object.entries(rangePartitionedMetricQueries)) { - const stepMsPartition = groupBy(queries, (query) => - calculateStep(request.intervalMs, request.range, query.resolution || 1, query.step) - ); - - for (const stepMs in stepMsPartition) { - const targets = stepMsPartition[stepMs].map((q) => { - const { maxLines, ...query } = q; - return query; - }); - requests.push({ - request: { ...request, targets }, - partition: partitionTimeRange(false, request.range, Number(stepMs), Number(chunkRangeMs)), - }); - } + for (const stepMs in stepMsPartition) { + const targets = stepMsPartition[stepMs].map((q) => { + const { maxLines, ...query } = q; + return query; + }); + requests.push({ + request: { ...request, targets }, + partition: partitionTimeRange(false, request.range, Number(stepMs), oneDayMs), + }); } if (nonSplittingQueries.length) { @@ -359,9 +347,7 @@ export function runSplitQuery( return runSplitGroupedQueries(datasource, requests, options).pipe( tap((response) => { if (response.state === LoadingState.Done) { - trackGroupedQueries(response, requests, request, startTime, { - predefinedOperations: datasource.predefinedOperations, - }); + trackGroupedQueries(response, requests, request, startTime); } }) ); diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx index d04e9fc42f6..8328bc42b86 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx @@ -1,17 +1,10 @@ import { trim } from 'lodash'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import * as React from 'react'; -import { - CoreApp, - isValidDuration, - isValidGrafanaDuration, - LogSortOrderChangeEvent, - LogsSortOrder, - store, -} from '@grafana/data'; +import { CoreApp, isValidGrafanaDuration, LogSortOrderChangeEvent, LogsSortOrder, store } from '@grafana/data'; import { EditorField, EditorRow, QueryOptionGroup } from '@grafana/plugin-ui'; -import { config, getAppEvents } from '@grafana/runtime'; +import { getAppEvents } from '@grafana/runtime'; import { AutoSizeInput, RadioButtonGroup } from '@grafana/ui'; import { @@ -36,7 +29,6 @@ export interface Props { export const LokiQueryBuilderOptions = React.memo( ({ app, query, onChange, onRunQuery, queryStats, datasource }) => { - const [splitDurationValid, setSplitDurationValid] = useState(true); const maxLines = datasource.maxLines; useEffect(() => { @@ -71,17 +63,6 @@ export const LokiQueryBuilderOptions = React.memo( [onChange, onRunQuery, query] ); - const onChunkRangeChange = (evt: React.FormEvent) => { - const value = evt.currentTarget.value; - if (!isValidDuration(value)) { - setSplitDurationValid(false); - return; - } - setSplitDurationValid(true); - onChange({ ...query, splitDuration: value }); - onRunQuery(); - }; - const onLegendFormatChanged = (evt: React.FormEvent) => { onChange({ ...query, legendFormat: evt.currentTarget.value }); onRunQuery(); @@ -214,21 +195,6 @@ export const LokiQueryBuilderOptions = React.memo( )} - {config.featureToggles.lokiQuerySplittingConfig && config.featureToggles.lokiQuerySplitting && ( - - - - )} ); diff --git a/public/app/plugins/datasource/loki/tracking.test.ts b/public/app/plugins/datasource/loki/tracking.test.ts index 2c46e75ffe2..5cfdb664709 100644 --- a/public/app/plugins/datasource/loki/tracking.test.ts +++ b/public/app/plugins/datasource/loki/tracking.test.ts @@ -85,7 +85,6 @@ describe('Tracks queries', () => { time_range_from: '2023-02-08T05:00:00.000Z', time_range_to: '2023-02-10T06:00:00.000Z', time_taken: 0, - predefined_operations_applied: 'n/a', }); }); @@ -100,31 +99,6 @@ describe('Tracks queries', () => { }); }); -test('Tracks predefined operations', () => { - trackQuery({ data: [] }, originalRequest, new Date(), { predefinedOperations: 'count_over_time' }); - - expect(reportInteraction).toHaveBeenCalledWith('grafana_explore_loki_query_executed', { - bytes_processed: 0, - editor_mode: 'builder', - grafana_version: '1.0', - has_data: false, - has_error: false, - is_split: false, - legend: undefined, - line_limit: undefined, - obfuscated_query: 'count_over_time({Identifier=String}[1m])', - query_type: 'metric', - query_vector_type: undefined, - resolution: 1, - simultaneously_executed_query_count: 2, - simultaneously_hidden_query_count: 1, - time_range_from: '2023-02-08T05:00:00.000Z', - time_range_to: '2023-02-10T06:00:00.000Z', - time_taken: 0, - predefined_operations_applied: true, - }); -}); - test('Tracks grouped queries', () => { trackGroupedQueries({ data: [] }, requests, originalRequest, new Date()); @@ -150,7 +124,6 @@ test('Tracks grouped queries', () => { time_range_from: '2023-02-08T05:00:00.000Z', time_range_to: '2023-02-10T06:00:00.000Z', time_taken: 0, - predefined_operations_applied: 'n/a', }); expect(reportInteraction).toHaveBeenCalledWith('grafana_explore_loki_query_executed', { @@ -175,7 +148,6 @@ test('Tracks grouped queries', () => { time_range_from: '2023-02-08T05:00:00.000Z', time_range_to: '2023-02-10T06:00:00.000Z', time_taken: 0, - predefined_operations_applied: 'n/a', }); }); diff --git a/public/app/plugins/datasource/loki/tracking.ts b/public/app/plugins/datasource/loki/tracking.ts index 6810ce40876..5714d8b4e5a 100644 --- a/public/app/plugins/datasource/loki/tracking.ts +++ b/public/app/plugins/datasource/loki/tracking.ts @@ -53,10 +53,6 @@ type LokiOnDashboardLoadedTrackingEvent = { queries_with_changed_legend_count: number; }; -export type LokiTrackingSettings = { - predefinedOperations?: string; -}; - export const onDashboardLoadedHandler = ({ payload: { dashboardId, orgId, grafanaVersion, queries }, }: DashboardLoadedEvent) => { @@ -158,7 +154,6 @@ export function trackQuery( response: DataQueryResponse, request: DataQueryRequest, startTime: Date, - trackingSettings: LokiTrackingSettings = {}, extraPayload: Record = {} ): void { // We only want to track usage for these specific apps @@ -193,9 +188,6 @@ export function trackQuery( time_taken: Date.now() - startTime.getTime(), bytes_processed: totalBytes, is_split: false, - predefined_operations_applied: trackingSettings.predefinedOperations - ? query.expr.includes(trackingSettings.predefinedOperations) - : 'n/a', ...extraPayload, }); } @@ -205,8 +197,7 @@ export function trackGroupedQueries( response: DataQueryResponse, groupedRequests: LokiGroupedRequest[], originalRequest: DataQueryRequest, - startTime: Date, - trackingSettings: LokiTrackingSettings = {} + startTime: Date ): void { const splittingPayload = { split_query_group_count: groupedRequests.length, @@ -219,7 +210,7 @@ export function trackGroupedQueries( for (const group of groupedRequests) { const split_query_partition_size = group.partition.length; - trackQuery(response, group.request, startTime, trackingSettings, { + trackQuery(response, group.request, startTime, { ...splittingPayload, split_query_partition_size, }); diff --git a/public/app/plugins/datasource/loki/types.ts b/public/app/plugins/datasource/loki/types.ts index 50fb235f267..ae650931390 100644 --- a/public/app/plugins/datasource/loki/types.ts +++ b/public/app/plugins/datasource/loki/types.ts @@ -30,12 +30,6 @@ export interface LokiQuery extends LokiQueryFromSchema { // the temporary fix (until this gets improved in the codegen), is to // override it here queryType?: LokiQueryType; - - /** - * This is a property for the experimental query splitting feature. - * @experimental - */ - splitDuration?: string; } export interface LokiOptions extends DataSourceJsonData { @@ -43,7 +37,6 @@ export interface LokiOptions extends DataSourceJsonData { derivedFields?: DerivedFieldConfig[]; alertmanager?: string; keepCookies?: string[]; - predefinedOperations?: string; } export interface LokiStreamResult { diff --git a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx index 35c25310fb8..fff70ecda1a 100644 --- a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx +++ b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx @@ -22,7 +22,7 @@ import { MarkersLegend, MarkersLegendProps } from '../../components/MarkersLegen import { ObservablePropsWrapper } from '../../components/ObservablePropsWrapper'; import { StyleEditor } from '../../editor/StyleEditor'; import { getWebGLStyle, textMarker } from '../../style/markers'; -import { DEFAULT_SIZE, defaultStyleConfig, StyleConfig } from '../../style/types'; +import { DEFAULT_SIZE, defaultStyleConfig, StyleConfig, StyleConfigValues } from '../../style/types'; import { getDisplacement, getRGBValues, getStyleConfigState, styleUsesText } from '../../style/utils'; import { getStyleDimension } from '../../utils/utils'; @@ -122,12 +122,24 @@ export const markersLayer: MapLayerRegistryItem = { // Track if we find any line strings during feature processing let hasLineString = false; + // Track coordinates to avoid rendering duplicate markers at the same location + const processedMarkers = new Set(); + + // Helper function to create a robust uniqueness key + const createMarkerKey = (coordinates: number[], markerValues: StyleConfigValues): string => { + const coord = `${coordinates[0]},${coordinates[1]}`; + const { color, size, text, rotation } = markerValues; + return `markerAddressKey|${coord}|${color}|${size}|${text}|${rotation}`; + }; source.forEachFeature((feature) => { - const isLineString = feature.getGeometry()?.getType() === 'LineString'; + const geometry = feature.getGeometry(); + const isLineString = geometry?.getType() === 'LineString'; + if (isLineString) { hasLineString = true; } + const idx: number = feature.get('rowIndex'); const dims = style.dims; const values = { ...style.base }; @@ -144,6 +156,25 @@ export const markersLayer: MapLayerRegistryItem = { if (dims?.rotation) { values.rotation = dims.rotation.get(idx); } + + // For point geometries, check if we've already processed this marker + if (geometry?.getType() === 'Point') { + const coordinates = geometry.getCoordinates(); + + // Skip this feature if coordinates are invalid + if (!coordinates || coordinates.length < 2) { + return; + } + + const markerKey = createMarkerKey(coordinates, values); + + // Skip this feature if we've already processed a marker with identical properties + if (processedMarkers.has(markerKey)) { + return; + } + processedMarkers.add(markerKey); + } + if (!isLineString) { const colorString = tinycolor(theme.visualization.getColorByName(values.color)).toString(); const colorValues = getRGBValues(colorString); diff --git a/public/app/plugins/panel/table/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/TableCellOptionEditor.tsx index 39b2059d760..ad5d9e400be 100644 --- a/public/app/plugins/panel/table/TableCellOptionEditor.tsx +++ b/public/app/plugins/panel/table/TableCellOptionEditor.tsx @@ -41,7 +41,7 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => { // When changing cell type see if there were previously stored // settings and merge those with the changed value if (settingCache[value.type] !== undefined && Object.keys(settingCache[value.type]).length > 1) { - value = merge(value, settingCache[value.type]); + value = merge({}, value, settingCache[value.type]); } onChange(value); @@ -51,7 +51,7 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => { // When options for a cell change we merge // any option changes with our options object const onCellOptionsChange = (options: TableCellOptions) => { - settingCache[value.type] = merge(value, options); + settingCache[value.type] = merge({}, value, options); setSettingCache(settingCache); onChange(settingCache[value.type]); }; diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx index 66250d8ffaf..4b55b60031b 100644 --- a/public/app/plugins/panel/table/module.tsx +++ b/public/app/plugins/panel/table/module.tsx @@ -51,7 +51,6 @@ export const plugin = new PanelPlugin(TablePanel) settings: { placeholder: t('table.placeholder-column-width', 'auto'), min: 20, - max: 300, }, shouldApply: () => true, defaultValue: defaultTableFieldOptions.width, diff --git a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx index 39b2059d760..ad5d9e400be 100644 --- a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx +++ b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx @@ -41,7 +41,7 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => { // When changing cell type see if there were previously stored // settings and merge those with the changed value if (settingCache[value.type] !== undefined && Object.keys(settingCache[value.type]).length > 1) { - value = merge(value, settingCache[value.type]); + value = merge({}, value, settingCache[value.type]); } onChange(value); @@ -51,7 +51,7 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => { // When options for a cell change we merge // any option changes with our options object const onCellOptionsChange = (options: TableCellOptions) => { - settingCache[value.type] = merge(value, options); + settingCache[value.type] = merge({}, value, options); setSettingCache(settingCache); onChange(settingCache[value.type]); }; diff --git a/public/app/plugins/panel/table/table-new/TablePanel.tsx b/public/app/plugins/panel/table/table-new/TablePanel.tsx index 711d2d50579..903a74f65c3 100644 --- a/public/app/plugins/panel/table/table-new/TablePanel.tsx +++ b/public/app/plugins/panel/table/table-new/TablePanel.tsx @@ -77,6 +77,7 @@ export function TablePanel(props: Props) { fieldConfig={fieldConfig} getActions={getCellActions} replaceVariables={replaceVariables} + structureRev={data.structureRev} /> ); diff --git a/public/app/plugins/panel/table/table-new/module.tsx b/public/app/plugins/panel/table/table-new/module.tsx index ecbe9cbf782..06d61bc11f9 100644 --- a/public/app/plugins/panel/table/table-new/module.tsx +++ b/public/app/plugins/panel/table/table-new/module.tsx @@ -51,7 +51,6 @@ export const plugin = new PanelPlugin(TablePanel) settings: { placeholder: t('table-new.placeholder-column-width', 'auto'), min: 20, - max: 300, }, shouldApply: () => true, defaultValue: defaultTableFieldOptions.width, diff --git a/public/app/routes/RoutesWrapper.tsx b/public/app/routes/RoutesWrapper.tsx index 21a74256d74..1c79403d8ed 100644 --- a/public/app/routes/RoutesWrapper.tsx +++ b/public/app/routes/RoutesWrapper.tsx @@ -6,6 +6,7 @@ import { locationService, LocationServiceProvider } from '@grafana/runtime'; import { ModalRoot, Stack } from '@grafana/ui'; import { AppChrome } from '../core/components/AppChrome/AppChrome'; +import { AppChromeExtensionPoint } from '../core/components/AppChrome/AppChromeExtensionPoint'; import { AppNotificationList } from '../core/components/AppNotifications/AppNotificationList'; import { ModalsContextProvider } from '../core/context/ModalsContextProvider'; import { QueriesDrawerContextProvider } from '../features/explore/QueriesDrawer/QueriesDrawerContext'; @@ -41,6 +42,7 @@ export function RouterWrapper(props: RouterWrapperProps) { {props.bodyRenderHooks.map((Hook, index) => ( ))} + diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 9e8789046a4..8e416101cdb 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -110,6 +110,12 @@ "update-button": "Update", "users-heading": "Organization users" }, + "enterprise-auth-features-card": { + "dismiss": "Dismiss", + "heading": "Enterprise authentication", + "learn-more-link": "Learn more", + "text": "Manage users, teams, and permissions automatically with <1>SAML, <3>SCIM, <6>LDAP, and <8>RBAC — available in Grafana Cloud and Enterprise." + }, "feature-listing": { "title-auditing": "Auditing", "title-custom-branding": "Custom branding", @@ -12073,6 +12079,7 @@ "polynomial": "Polynomial" } }, + "regression": "regression", "tooltip-number-of-xy-points-to-predict": "Number of X,Y points to predict" }, "rename-by-regex-transformer-editor": { diff --git a/yarn.lock b/yarn.lock index 2556424e144..977c1b5cb1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3775,7 +3775,7 @@ __metadata: react-calendar: "npm:^5.1.0" react-colorful: "npm:5.6.1" react-custom-scrollbars-2: "npm:4.5.0" - react-data-grid: "grafana/react-data-grid#3420f7f2a9e0d707d3313ec5b143a6be53f720b5" + react-data-grid: "grafana/react-data-grid#de920f0105cb2b7d774444e7443a675f3b568ad6" react-dom: "npm:18.3.1" react-dropzone: "npm:14.3.8" react-highlight-words: "npm:0.21.0" @@ -26659,15 +26659,15 @@ __metadata: languageName: node linkType: hard -"react-data-grid@grafana/react-data-grid#3420f7f2a9e0d707d3313ec5b143a6be53f720b5": - version: 7.0.0-beta.55 - resolution: "react-data-grid@https://github.com/grafana/react-data-grid.git#commit=3420f7f2a9e0d707d3313ec5b143a6be53f720b5" +"react-data-grid@grafana/react-data-grid#de920f0105cb2b7d774444e7443a675f3b568ad6": + version: 7.0.0-beta.56 + resolution: "react-data-grid@https://github.com/grafana/react-data-grid.git#commit=de920f0105cb2b7d774444e7443a675f3b568ad6" dependencies: clsx: "npm:^2.0.0" peerDependencies: react: ^18.0 || ^19.0 react-dom: ^18.0 || ^19.0 - checksum: 10/9fe309924a7b22d0a62f0df69bdc7b9e0df62c5624e96125f2d729500dfe103037cfbe654fa63294d89fc9b5fea618a540160e2e12e7de4c7052049827df3687 + checksum: 10/efc1dcb764fa5f3549d012737e79d423b34e6ad7b0a122849d757f59b45e648afe7c5cfeb1a61464245aa7b393f9251ff91fd49f8f857139d7df185c23d68b68 languageName: node linkType: hard