diff --git a/.betterer.results b/.betterer.results index 6eb58950612..ada77c000ef 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2562,19 +2562,6 @@ exports[`better eslint`] = { "public/app/features/live/centrifuge/channel.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/logs/components/panel/LogLineDetailsFields.tsx:5381": [ - [0, 0, 0, "React Hook \\"useCallback\\" is called conditionally. React Hooks must be called in the exact same order in every component render.", "0"], - [0, 0, 0, "React Hook \\"useCallback\\" is called conditionally. React Hooks must be called in the exact same order in every component render.", "1"], - [0, 0, 0, "React Hook \\"useMemo\\" is called conditionally. React Hooks must be called in the exact same order in every component render.", "2"], - [0, 0, 0, "React Hook \\"useMemo\\" is called conditionally. React Hooks must be called in the exact same order in every component render.", "3"], - [0, 0, 0, "React Hook \\"useStyles2\\" is called conditionally. React Hooks must be called in the exact same order in every component render.", "4"], - [0, 0, 0, "React Hook \\"useStyles2\\" is called conditionally. React Hooks must be called in the exact same order in every component render.", "5"] - ], - "public/app/features/logs/components/panel/LogList.tsx:5381": [ - [0, 0, 0, "React Hook \\"useCallback\\" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?", "0"], - [0, 0, 0, "React Hook \\"useCallback\\" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?", "1"], - [0, 0, 0, "React Hook \\"useCallback\\" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?", "2"] - ], "public/app/features/logs/logsFrame.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -2954,9 +2941,6 @@ exports[`better eslint`] = { "public/app/features/transformers/editors/GroupToNestedTableTransformerEditor.tsx:5381": [ [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] ], - "public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx:5381": [ - [0, 0, 0, "React Hook \\"useStyles2\\" is called conditionally. React Hooks must be called in the exact same order in every component render.", "0"] - ], "public/app/features/transformers/editors/ReduceTransformerEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/.github/commands.json b/.github/commands.json index 9923a064fd2..7b9c64b18e6 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -136,7 +136,7 @@ "name": "datasource/Tempo", "action": "addToProject", "addToProject": { - "url": "https://github.com/orgs/grafana/projects/221" + "url": "https://github.com/orgs/grafana/projects/457" } }, { @@ -152,7 +152,7 @@ "name": "datasource/Parca", "action": "addToProject", "addToProject": { - "url": "https://github.com/orgs/grafana/projects/221" + "url": "https://github.com/orgs/grafana/projects/457" } }, { @@ -168,7 +168,7 @@ "name": "datasource/Jaeger", "action": "addToProject", "addToProject": { - "url": "https://github.com/orgs/grafana/projects/221" + "url": "https://github.com/orgs/grafana/projects/457" } }, { @@ -176,7 +176,7 @@ "name": "datasource/Zipkin", "action": "addToProject", "addToProject": { - "url": "https://github.com/orgs/grafana/projects/221" + "url": "https://github.com/orgs/grafana/projects/457" } }, { diff --git a/.github/workflows/backend-unit-tests.yml b/.github/workflows/backend-unit-tests.yml index d4fe25ccb26..b81d8b4cc10 100644 --- a/.github/workflows/backend-unit-tests.yml +++ b/.github/workflows/backend-unit-tests.yml @@ -16,6 +16,8 @@ permissions: {} jobs: detect-changes: name: Detect whether code changed + # Run on `grafana/grafana` main branch, or on pull requests to prevent double-running on mirrors + if: (github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'grafana/grafana')) runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/go-lint.yml b/.github/workflows/go-lint.yml index ec9bfc62bec..d352b8f8235 100644 --- a/.github/workflows/go-lint.yml +++ b/.github/workflows/go-lint.yml @@ -14,6 +14,8 @@ permissions: jobs: detect-changes: + # Run on `grafana/grafana` main branch, or on pull requests to prevent double-running on mirrors + if: (github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'grafana/grafana')) name: Detect whether code changed runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/i18n-verify.yml b/.github/workflows/i18n-verify.yml index ccbed74d4ea..7488e46737a 100644 --- a/.github/workflows/i18n-verify.yml +++ b/.github/workflows/i18n-verify.yml @@ -12,4 +12,6 @@ on: jobs: verify-i18n: + # Run on `grafana/grafana` main branch, or on pull requests to prevent double-running on mirrors + if: (github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'grafana/grafana')) uses: grafana/grafana-github-actions/.github/workflows/verify-i18n.yml@main diff --git a/.github/workflows/lint-build-docs.yml b/.github/workflows/lint-build-docs.yml index f4fd7d7a2f0..2f3888aa723 100644 --- a/.github/workflows/lint-build-docs.yml +++ b/.github/workflows/lint-build-docs.yml @@ -20,6 +20,8 @@ permissions: {} jobs: docs: + # Run on `grafana/grafana` main branch, or on pull requests to prevent double-running on mirrors + if: (github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'grafana/grafana')) name: Build & Verify Docs runs-on: ubuntu-latest diff --git a/.github/workflows/pr-e2e-tests.yml b/.github/workflows/pr-e2e-tests.yml index b4a9f6baf6c..7bb90af74e2 100644 --- a/.github/workflows/pr-e2e-tests.yml +++ b/.github/workflows/pr-e2e-tests.yml @@ -20,6 +20,8 @@ env: jobs: detect-changes: + # Run on `grafana/grafana` main branch, or on pull requests to prevent double-running on mirrors + if: (github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'grafana/grafana')) name: Detect whether code changed runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/pr-frontend-unit-tests.yml b/.github/workflows/pr-frontend-unit-tests.yml index 1d320df49e9..27ae8337303 100644 --- a/.github/workflows/pr-frontend-unit-tests.yml +++ b/.github/workflows/pr-frontend-unit-tests.yml @@ -10,6 +10,8 @@ permissions: {} jobs: detect-changes: + # Run on `grafana/grafana` main branch, or on pull requests to prevent double-running on mirrors + if: (github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'grafana/grafana')) name: Detect whether code changed runs-on: ubuntu-x64-small permissions: @@ -166,4 +168,4 @@ jobs: with: needs: ${{ toJson(needs) }} failure-message: "One or more unit test jobs have failed" - success-message: "All unit tests completed successfully" \ No newline at end of file + success-message: "All unit tests completed successfully" diff --git a/.github/workflows/pr-test-integration.yml b/.github/workflows/pr-test-integration.yml index 8bf57b530ad..555364efdcd 100644 --- a/.github/workflows/pr-test-integration.yml +++ b/.github/workflows/pr-test-integration.yml @@ -19,6 +19,8 @@ permissions: {} jobs: detect-changes: + # Run on `grafana/grafana` main branch, or on pull requests to prevent double-running on mirrors + if: (github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'grafana/grafana')) name: Detect whether code changed runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/reject-gh-secrets.yml b/.github/workflows/reject-gh-secrets.yml index 066b7353116..63dc44df057 100644 --- a/.github/workflows/reject-gh-secrets.yml +++ b/.github/workflows/reject-gh-secrets.yml @@ -12,6 +12,8 @@ permissions: {} jobs: reject-gh-secrets: + # Run on `grafana/grafana` main branch, or on pull requests to prevent double-running on mirrors + if: (github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'grafana/grafana')) runs-on: ubuntu-latest permissions: contents: read @@ -28,4 +30,4 @@ jobs: echo "Found secrets access in the codebase. Please remove it in favour of Vault secrets." echo "If you are sure this is correct, add '# nolint:reject-gh-secrets' to the end of the line. Be VERY careful with this." exit 1 - fi \ No newline at end of file + fi diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index ba57ca81d23..5dd0329450a 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -49,7 +49,7 @@ jobs: setup: name: setup runs-on: github-hosted-ubuntu-x64-small - if: github.repository == 'grafana/grafana' + if: (github.repository == 'grafana/grafana') || (github.repository == 'grafana/grafana-security-mirror' && contains(github.ref_name, '+security')) outputs: version: ${{ steps.output.outputs.version }} grafana-commit: ${{ steps.output.outputs.grafana_commit }} @@ -104,10 +104,11 @@ jobs: BUCKET: grafana-prerelease GRAFANA_COMMIT: ${{ needs.setup.outputs.grafana-commit }} SOURCE_EVENT: ${{ inputs.source-event || github.event_name }} + REPO: ${{ github.repository }} with: github-token: ${{ steps.generate_token.outputs.token }} script: | - const {REF, VERSION, BUILD_ID, BUCKET, GRAFANA_COMMIT, SOURCE_EVENT} = process.env; + const {REF, VERSION, BUILD_ID, BUCKET, GRAFANA_COMMIT, SOURCE_EVENT, REPO} = process.env; await github.rest.actions.createWorkflowDispatch({ owner: 'grafana', @@ -120,6 +121,7 @@ jobs: "bucket": BUCKET, "grafana-commit": GRAFANA_COMMIT, "source-event": SOURCE_EVENT, + "upstream": REPO, } }) diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index c0159f448f3..fad0ca6c58a 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -15,6 +15,8 @@ permissions: {} jobs: shellcheck: + # Run on `grafana/grafana` main branch, or on pull requests to prevent double-running on mirrors + if: (github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'grafana/grafana')) name: Shellcheck scripts runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/swagger-gen.yml b/.github/workflows/swagger-gen.yml index 9399a366933..622717eae0c 100644 --- a/.github/workflows/swagger-gen.yml +++ b/.github/workflows/swagger-gen.yml @@ -15,6 +15,8 @@ concurrency: jobs: detect-changes: + # Run on `grafana/grafana` main branch, or on pull requests to prevent double-running on mirrors + if: (github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'grafana/grafana')) name: Detect whether code changed runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/trigger-dashboard-search-e2e.yml b/.github/workflows/trigger-dashboard-search-e2e.yml index db7025f17c1..370779c8927 100644 --- a/.github/workflows/trigger-dashboard-search-e2e.yml +++ b/.github/workflows/trigger-dashboard-search-e2e.yml @@ -22,7 +22,8 @@ env: jobs: trigger-search-e2e: runs-on: ubuntu-latest - if: github.event.pull_request.draft == false + # Run on `grafana/grafana` main branch, or on pull requests to prevent double-running on mirrors + if: (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || (github.event_name == 'push' && github.repository == 'grafana/grafana') steps: - name: Trigger Dashboard Search E2E - run: echo "Triggered Dashboard Search e2e..." \ No newline at end of file + run: echo "Triggered Dashboard Search e2e..." diff --git a/.gitignore b/.gitignore index 66a35404ee5..5703f0958a3 100644 --- a/.gitignore +++ b/.gitignore @@ -241,3 +241,4 @@ public/app/plugins/**/dist/ public/mockServiceWorker.js /e2e-playwright/test-plugins/*/dist +/apps/provisioning/cmd/job-controller/bin/ diff --git a/apps/alerting/notifications/go.mod b/apps/alerting/notifications/go.mod index 9e1d9886718..974455b6499 100644 --- a/apps/alerting/notifications/go.mod +++ b/apps/alerting/notifications/go.mod @@ -4,7 +4,7 @@ go 1.24.6 require ( github.com/grafana/grafana-app-sdk v0.40.3 - github.com/grafana/grafana-app-sdk/logging v0.40.2 + github.com/grafana/grafana-app-sdk/logging v0.40.3 k8s.io/apimachinery v0.33.3 k8s.io/apiserver v0.33.3 k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff diff --git a/apps/alerting/notifications/go.sum b/apps/alerting/notifications/go.sum index 7abf0ddda54..e8fd06f910f 100644 --- a/apps/alerting/notifications/go.sum +++ b/apps/alerting/notifications/go.sum @@ -86,8 +86,8 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhckclH/N4DYU= github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= -github.com/grafana/grafana-app-sdk/logging v0.40.2 h1:HQ1+y9Od92iMbWWB54QxiYpNtCvYGUVpyxvxZ7ywB1k= -github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= +github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= diff --git a/apps/dashboard/go.mod b/apps/dashboard/go.mod index 296f0d6981e..e9c946a76e4 100644 --- a/apps/dashboard/go.mod +++ b/apps/dashboard/go.mod @@ -5,12 +5,14 @@ go 1.24.6 require ( cuelang.org/go v0.11.1 github.com/grafana/grafana-app-sdk v0.40.3 + github.com/grafana/grafana-app-sdk/logging v0.40.3 github.com/grafana/grafana-plugin-sdk-go v0.278.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e github.com/prometheus/client_golang v1.23.0 github.com/stretchr/testify v1.10.0 k8s.io/apimachinery v0.33.3 k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff + k8s.io/utils v0.0.0-20241210054802-24370beab758 ) require ( @@ -22,7 +24,7 @@ require ( github.com/cheekybits/genny v1.0.0 // indirect github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 // indirect github.com/cockroachdb/apd/v3 v3.2.1 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/elazarl/goproxy v1.7.2 // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect @@ -45,7 +47,6 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.2 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect @@ -129,7 +130,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/client-go v0.33.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect diff --git a/apps/dashboard/go.sum b/apps/dashboard/go.sum index cb04c2614e6..4bff6146f12 100644 --- a/apps/dashboard/go.sum +++ b/apps/dashboard/go.sum @@ -28,8 +28,8 @@ github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEa github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -96,8 +96,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhckclH/N4DYU= github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= -github.com/grafana/grafana-app-sdk/logging v0.40.2 h1:HQ1+y9Od92iMbWWB54QxiYpNtCvYGUVpyxvxZ7ywB1k= -github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= +github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana-plugin-sdk-go v0.278.0 h1:5/rIYparLi02pofdaag8wnjspMMVNCi8cZhC4cdC3Ho= github.com/grafana/grafana-plugin-sdk-go v0.278.0/go.mod h1:+8NXT/XUJ/89GV6FxGQ366NZ3nU+cAXDMd0OUESF9H4= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= @@ -315,12 +315,14 @@ golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aC 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/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -340,10 +342,13 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 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= @@ -352,6 +357,7 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 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= diff --git a/apps/dashboard/kinds/dashboard.cue b/apps/dashboard/kinds/dashboard.cue index 490f6173700..905b9606336 100644 --- a/apps/dashboard/kinds/dashboard.cue +++ b/apps/dashboard/kinds/dashboard.cue @@ -20,13 +20,13 @@ ConversionStatus: { // and the caller should instead fetch the stored version. failed: bool - // The version which was stored when the dashboard was created / updated. - // Fetching this version should always succeed. - storedVersion: string - // The error message from the conversion. // Empty if the conversion has not failed. - error: string + error?: string + + // The version which was stored when the dashboard was created / updated. + // Fetching this version should always succeed. + storedVersion?: string } dashboard: { diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_status_gen.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_status_gen.go index 70fbb4e9263..2dec6aea951 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_status_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_status_gen.go @@ -9,12 +9,12 @@ type DashboardConversionStatus struct { // If true, means that the dashboard is not valid, // and the caller should instead fetch the stored version. Failed bool `json:"failed"` - // The version which was stored when the dashboard was created / updated. - // Fetching this version should always succeed. - StoredVersion string `json:"storedVersion"` // The error message from the conversion. // Empty if the conversion has not failed. - Error string `json:"error"` + Error *string `json:"error,omitempty"` + // The version which was stored when the dashboard was created / updated. + // Fetching this version should always succeed. + StoredVersion *string `json:"storedVersion,omitempty"` } // NewDashboardConversionStatus creates a new DashboardConversionStatus object. 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 13faf44445d..946f34756fe 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go @@ -236,24 +236,22 @@ func schema_pkg_apis_dashboard_v0alpha1_DashboardConversionStatus(ref common.Ref Format: "", }, }, - "storedVersion": { + "error": { SchemaProps: spec.SchemaProps{ - Description: "The version which was stored when the dashboard was created / updated. Fetching this version should always succeed.", - Default: "", + Description: "The error message from the conversion. Empty if the conversion has not failed.", Type: []string{"string"}, Format: "", }, }, - "error": { + "storedVersion": { SchemaProps: spec.SchemaProps{ - Description: "The error message from the conversion. Empty if the conversion has not failed.", - Default: "", + Description: "The version which was stored when the dashboard was created / updated. Fetching this version should always succeed.", Type: []string{"string"}, Format: "", }, }, }, - Required: []string{"failed", "storedVersion", "error"}, + Required: []string{"failed"}, }, }, } diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_status_gen.go b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_status_gen.go index 5213df7919b..a2ac9850b09 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_status_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_status_gen.go @@ -9,12 +9,12 @@ type DashboardConversionStatus struct { // If true, means that the dashboard is not valid, // and the caller should instead fetch the stored version. Failed bool `json:"failed"` - // The version which was stored when the dashboard was created / updated. - // Fetching this version should always succeed. - StoredVersion string `json:"storedVersion"` // The error message from the conversion. // Empty if the conversion has not failed. - Error string `json:"error"` + Error *string `json:"error,omitempty"` + // The version which was stored when the dashboard was created / updated. + // Fetching this version should always succeed. + StoredVersion *string `json:"storedVersion,omitempty"` } // NewDashboardConversionStatus creates a new DashboardConversionStatus object. diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v1beta1/zz_generated.openapi.go index 88a534a0746..2635b842046 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/zz_generated.openapi.go @@ -229,24 +229,22 @@ func schema_pkg_apis_dashboard_v1beta1_DashboardConversionStatus(ref common.Refe Format: "", }, }, - "storedVersion": { + "error": { SchemaProps: spec.SchemaProps{ - Description: "The version which was stored when the dashboard was created / updated. Fetching this version should always succeed.", - Default: "", + Description: "The error message from the conversion. Empty if the conversion has not failed.", Type: []string{"string"}, Format: "", }, }, - "error": { + "storedVersion": { SchemaProps: spec.SchemaProps{ - Description: "The error message from the conversion. Empty if the conversion has not failed.", - Default: "", + Description: "The version which was stored when the dashboard was created / updated. Fetching this version should always succeed.", Type: []string{"string"}, Format: "", }, }, }, - Required: []string{"failed", "storedVersion", "error"}, + Required: []string{"failed"}, }, }, } diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_status_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_status_gen.go index 5bd84ac30cc..9544b902dd8 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_status_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_status_gen.go @@ -9,12 +9,12 @@ type DashboardConversionStatus struct { // If true, means that the dashboard is not valid, // and the caller should instead fetch the stored version. Failed bool `json:"failed"` - // The version which was stored when the dashboard was created / updated. - // Fetching this version should always succeed. - StoredVersion string `json:"storedVersion"` // The error message from the conversion. // Empty if the conversion has not failed. - Error string `json:"error"` + Error *string `json:"error,omitempty"` + // The version which was stored when the dashboard was created / updated. + // Fetching this version should always succeed. + StoredVersion *string `json:"storedVersion,omitempty"` } // NewDashboardConversionStatus creates a new DashboardConversionStatus object. diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go index ba0a80e050b..a53ba6483cb 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go @@ -1210,24 +1210,22 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardConversionStatus(ref common.Ref Format: "", }, }, - "storedVersion": { + "error": { SchemaProps: spec.SchemaProps{ - Description: "The version which was stored when the dashboard was created / updated. Fetching this version should always succeed.", - Default: "", + Description: "The error message from the conversion. Empty if the conversion has not failed.", Type: []string{"string"}, Format: "", }, }, - "error": { + "storedVersion": { SchemaProps: spec.SchemaProps{ - Description: "The error message from the conversion. Empty if the conversion has not failed.", - Default: "", + Description: "The version which was stored when the dashboard was created / updated. Fetching this version should always succeed.", Type: []string{"string"}, Format: "", }, }, }, - Required: []string{"failed", "storedVersion", "error"}, + Required: []string{"failed"}, }, }, } diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_status_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_status_gen.go index 87ab45e9c54..62d39eb766d 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_status_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_status_gen.go @@ -9,12 +9,12 @@ type DashboardConversionStatus struct { // If true, means that the dashboard is not valid, // and the caller should instead fetch the stored version. Failed bool `json:"failed"` - // The version which was stored when the dashboard was created / updated. - // Fetching this version should always succeed. - StoredVersion string `json:"storedVersion"` // The error message from the conversion. // Empty if the conversion has not failed. - Error string `json:"error"` + Error *string `json:"error,omitempty"` + // The version which was stored when the dashboard was created / updated. + // Fetching this version should always succeed. + StoredVersion *string `json:"storedVersion,omitempty"` } // NewDashboardConversionStatus creates a new DashboardConversionStatus object. diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go index c7aa3cb8481..8f555e11ee2 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go @@ -1215,24 +1215,22 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardConversionStatus(ref common.Refe Format: "", }, }, - "storedVersion": { + "error": { SchemaProps: spec.SchemaProps{ - Description: "The version which was stored when the dashboard was created / updated. Fetching this version should always succeed.", - Default: "", + Description: "The error message from the conversion. Empty if the conversion has not failed.", Type: []string{"string"}, Format: "", }, }, - "error": { + "storedVersion": { SchemaProps: spec.SchemaProps{ - Description: "The error message from the conversion. Empty if the conversion has not failed.", - Default: "", + Description: "The version which was stored when the dashboard was created / updated. Fetching this version should always succeed.", Type: []string{"string"}, Format: "", }, }, }, - Required: []string{"failed", "storedVersion", "error"}, + Required: []string{"failed"}, }, }, } diff --git a/apps/dashboard/pkg/migration/conversion/conversion.go b/apps/dashboard/pkg/migration/conversion/conversion.go index ca93badb127..2b94b4dbb0a 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion.go +++ b/apps/dashboard/pkg/migration/conversion/conversion.go @@ -4,14 +4,14 @@ import ( "k8s.io/apimachinery/pkg/conversion" "k8s.io/apimachinery/pkg/runtime" + "github.com/grafana/grafana-app-sdk/logging" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" - "github.com/grafana/grafana/pkg/infra/log" ) -var logger = log.New("dashboard.conversion") +var logger = logging.DefaultLogger.With("logger", "dashboard.conversion") func RegisterConversions(s *runtime.Scheme) error { // v0 conversions diff --git a/apps/dashboard/pkg/migration/conversion/conversion_test.go b/apps/dashboard/pkg/migration/conversion/conversion_test.go index f27658ccd4f..e46c86fefd1 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion_test.go +++ b/apps/dashboard/pkg/migration/conversion/conversion_test.go @@ -9,8 +9,9 @@ import ( "testing" "github.com/stretchr/testify/require" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "github.com/grafana/grafana/apps/dashboard/pkg/apis" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" @@ -27,7 +28,7 @@ func TestConversionMatrixExist(t *testing.T) { // Initialize the migrator with a test data source provider migration.Initialize(testutil.GetTestDataSourceProvider(), testutil.GetTestPanelProvider()) - versions := []v1.Object{ + versions := []metav1.Object{ &dashv0.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV0"}}}, &dashv1.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV1"}}}, &dashv2alpha1.Dashboard{Spec: dashv2alpha1.DashboardSpec{Title: "dashboardV2alpha1"}}, @@ -110,13 +111,13 @@ func TestDashboardConversionToAllVersions(t *testing.T) { require.True(t, ok, "apiVersion not found or not a string") // Parse group and version from apiVersion (format: "group/version") - parts := strings.Split(apiVersion, "/") - require.Equal(t, 2, len(parts), "apiVersion should be in format 'group/version'") - sourceVersion := parts[1] + gv, err := schema.ParseGroupVersion(apiVersion) + require.NoError(t, err) + require.Equal(t, dashv0.GROUP, gv.Group) // Create source object based on version - var sourceDash v1.Object - switch sourceVersion { + var sourceDash metav1.Object + switch gv.Version { case "v0alpha1": var dash dashv0.Dashboard err = json.Unmarshal(inputData, &dash) @@ -134,7 +135,7 @@ func TestDashboardConversionToAllVersions(t *testing.T) { err = json.Unmarshal(inputData, &dash) sourceDash = &dash default: - t.Fatalf("Unsupported source version: %s", sourceVersion) + t.Fatalf("Unsupported source version: %s", gv.Version) } require.NoError(t, err, "Failed to unmarshal dashboard into typed object") @@ -157,22 +158,26 @@ func TestDashboardConversionToAllVersions(t *testing.T) { if kind.Kind == "Dashboard" { for _, version := range kind.Versions { // Skip converting to the same version - if version.VersionName == sourceVersion { + if version.VersionName == gv.Version { continue } filename := fmt.Sprintf("%s.%s.json", originalName, version.VersionName) + typeMeta := metav1.TypeMeta{ + APIVersion: fmt.Sprintf("%s/%s", dashv0.APIGroup, version.VersionName), + Kind: kind.Kind, // Dashboard + } // Create target object based on version switch version.VersionName { case "v0alpha1": - targetVersions[filename] = &dashv0.Dashboard{} + targetVersions[filename] = &dashv0.Dashboard{TypeMeta: typeMeta} case "v1beta1": - targetVersions[filename] = &dashv1.Dashboard{} + targetVersions[filename] = &dashv1.Dashboard{TypeMeta: typeMeta} case "v2alpha1": - targetVersions[filename] = &dashv2alpha1.Dashboard{} + targetVersions[filename] = &dashv2alpha1.Dashboard{TypeMeta: typeMeta} case "v2beta1": - targetVersions[filename] = &dashv2beta1.Dashboard{} + targetVersions[filename] = &dashv2beta1.Dashboard{TypeMeta: typeMeta} default: t.Logf("Unknown version %s, skipping", version.VersionName) } @@ -192,14 +197,14 @@ func TestDashboardConversionToAllVersions(t *testing.T) { require.NoError(t, err, "Conversion failed for %s", filename) // Test the changes in the conversion result - testConversion(t, target.(v1.Object), filename, outDir) + testConversion(t, target.(metav1.Object), filename, outDir) }) } }) } } -func testConversion(t *testing.T, convertedDash v1.Object, filename, outputDir string) { +func testConversion(t *testing.T, convertedDash metav1.Object, filename, outputDir string) { t.Helper() outPath := filepath.Join(outputDir, filename) diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json index 0c8b072ab49..1204b3a4b01 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json @@ -1,4 +1,6 @@ { + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v0alpha1", "metadata": { "name": "test-v2alpha1-complete", "creationTimestamp": null, @@ -13,8 +15,8 @@ "status": { "conversion": { "failed": true, - "storedVersion": "v2alpha1", - "error": "backend conversion not yet implemented" + "error": "backend conversion not yet implemented", + "storedVersion": "v2alpha1" } } } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json index 0c8b072ab49..38a064ff429 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json @@ -1,4 +1,6 @@ { + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", "metadata": { "name": "test-v2alpha1-complete", "creationTimestamp": null, @@ -13,8 +15,8 @@ "status": { "conversion": { "failed": true, - "storedVersion": "v2alpha1", - "error": "backend conversion not yet implemented" + "error": "backend conversion not yet implemented", + "storedVersion": "v2alpha1" } } } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v2beta1.json index 7662f66d855..c26b6ca8761 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v2beta1.json @@ -504,8 +504,7 @@ "status": { "conversion": { "failed": false, - "storedVersion": "v2alpha1", - "error": "" + "storedVersion": "v2alpha1" } } } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json index 0a9980b5ff0..b87aaa8b455 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json @@ -1,4 +1,6 @@ { + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v0alpha1", "metadata": { "name": "test-v2alpha1-annotations", "creationTimestamp": null @@ -7,8 +9,8 @@ "status": { "conversion": { "failed": true, - "storedVersion": "v2alpha1", - "error": "backend conversion not yet implemented" + "error": "backend conversion not yet implemented", + "storedVersion": "v2alpha1" } } } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json index 0a9980b5ff0..4fe7b31c3b2 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json @@ -1,4 +1,6 @@ { + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", "metadata": { "name": "test-v2alpha1-annotations", "creationTimestamp": null @@ -7,8 +9,8 @@ "status": { "conversion": { "failed": true, - "storedVersion": "v2alpha1", - "error": "backend conversion not yet implemented" + "error": "backend conversion not yet implemented", + "storedVersion": "v2alpha1" } } } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json index ba6bc946dc0..ad21f789043 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json @@ -1087,8 +1087,7 @@ "status": { "conversion": { "failed": false, - "storedVersion": "v2alpha1", - "error": "" + "storedVersion": "v2alpha1" } } } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v0alpha1.json index aa3ee7b86ef..1b3d220f37f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v0alpha1.json @@ -1,4 +1,6 @@ { + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v0alpha1", "metadata": { "name": "test-v2alpha1-groupby-adhoc-vars", "creationTimestamp": null @@ -7,8 +9,8 @@ "status": { "conversion": { "failed": true, - "storedVersion": "v2alpha1", - "error": "backend conversion not yet implemented" + "error": "backend conversion not yet implemented", + "storedVersion": "v2alpha1" } } } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v1beta1.json index aa3ee7b86ef..1d545f2336f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v1beta1.json @@ -1,4 +1,6 @@ { + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", "metadata": { "name": "test-v2alpha1-groupby-adhoc-vars", "creationTimestamp": null @@ -7,8 +9,8 @@ "status": { "conversion": { "failed": true, - "storedVersion": "v2alpha1", - "error": "backend conversion not yet implemented" + "error": "backend conversion not yet implemented", + "storedVersion": "v2alpha1" } } } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v2beta1.json index 9d34353a32b..1196535c088 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v2beta1.json @@ -93,8 +93,7 @@ "status": { "conversion": { "failed": false, - "storedVersion": "v2alpha1", - "error": "" + "storedVersion": "v2alpha1" } } } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json index 9c74ecaa74f..957ea57bf17 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json @@ -1,4 +1,6 @@ { + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v0alpha1", "metadata": { "name": "test-v2alpha1-viz-config", "creationTimestamp": null @@ -7,8 +9,8 @@ "status": { "conversion": { "failed": true, - "storedVersion": "v2alpha1", - "error": "backend conversion not yet implemented" + "error": "backend conversion not yet implemented", + "storedVersion": "v2alpha1" } } } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json index 9c74ecaa74f..d473dc0e9b8 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json @@ -1,4 +1,6 @@ { + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", "metadata": { "name": "test-v2alpha1-viz-config", "creationTimestamp": null @@ -7,8 +9,8 @@ "status": { "conversion": { "failed": true, - "storedVersion": "v2alpha1", - "error": "backend conversion not yet implemented" + "error": "backend conversion not yet implemented", + "storedVersion": "v2alpha1" } } } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v2beta1.json index b867057085e..a92f2538263 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v2beta1.json @@ -215,8 +215,7 @@ "status": { "conversion": { "failed": false, - "storedVersion": "v2alpha1", - "error": "" + "storedVersion": "v2alpha1" } } } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v0.go b/apps/dashboard/pkg/migration/conversion/v0.go index 6ea720a3fb5..bd81e30fa86 100644 --- a/apps/dashboard/pkg/migration/conversion/v0.go +++ b/apps/dashboard/pkg/migration/conversion/v0.go @@ -5,6 +5,7 @@ import ( "fmt" "k8s.io/apimachinery/pkg/conversion" + "k8s.io/utils/ptr" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" @@ -21,13 +22,13 @@ func Convert_V0_to_V1(in *dashv0.Dashboard, out *dashv1.Dashboard, scope convers out.Status = dashv1.DashboardStatus{ Conversion: &dashv1.DashboardConversionStatus{ - StoredVersion: dashv0.VERSION, + StoredVersion: ptr.To(dashv0.VERSION), }, } if err := migration.Migrate(out.Spec.Object, schemaversion.LATEST_VERSION); err != nil { out.Status.Conversion.Failed = true - out.Status.Conversion.Error = err.Error() + out.Status.Conversion.Error = ptr.To(err.Error()) // Classify error type for metrics errorType := "conversion_error" @@ -92,9 +93,9 @@ func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, s out.Status = dashv2alpha1.DashboardStatus{ Conversion: &dashv2alpha1.DashboardConversionStatus{ - StoredVersion: dashv0.VERSION, + StoredVersion: ptr.To(dashv0.VERSION), Failed: true, - Error: "backend conversion not yet implemented", + Error: ptr.To("backend conversion not yet implemented"), }, } @@ -108,9 +109,9 @@ func Convert_V0_to_V2beta1(in *dashv0.Dashboard, out *dashv2beta1.Dashboard, sco out.Status = dashv2beta1.DashboardStatus{ Conversion: &dashv2beta1.DashboardConversionStatus{ - StoredVersion: dashv0.VERSION, + StoredVersion: ptr.To(dashv0.VERSION), Failed: true, - Error: "backend conversion not yet implemented", + Error: ptr.To("backend conversion not yet implemented"), }, } diff --git a/apps/dashboard/pkg/migration/conversion/v1.go b/apps/dashboard/pkg/migration/conversion/v1.go index 0e1bb8fc8ed..2dd51ffff03 100644 --- a/apps/dashboard/pkg/migration/conversion/v1.go +++ b/apps/dashboard/pkg/migration/conversion/v1.go @@ -2,6 +2,7 @@ package conversion import ( "k8s.io/apimachinery/pkg/conversion" + "k8s.io/utils/ptr" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" @@ -16,7 +17,7 @@ func Convert_V1_to_V0(in *dashv1.Dashboard, out *dashv0.Dashboard, scope convers out.Status = dashv0.DashboardStatus{ Conversion: &dashv0.DashboardConversionStatus{ - StoredVersion: dashv1.VERSION, + StoredVersion: ptr.To(dashv1.VERSION), }, } @@ -45,9 +46,9 @@ func Convert_V1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, s out.Status = dashv2alpha1.DashboardStatus{ Conversion: &dashv2alpha1.DashboardConversionStatus{ - StoredVersion: dashv1.VERSION, + StoredVersion: ptr.To(dashv1.VERSION), Failed: true, - Error: "backend conversion not yet implemented", + Error: ptr.To("backend conversion not yet implemented"), }, } @@ -61,9 +62,9 @@ func Convert_V1_to_V2beta1(in *dashv1.Dashboard, out *dashv2beta1.Dashboard, sco out.Status = dashv2beta1.DashboardStatus{ Conversion: &dashv2beta1.DashboardConversionStatus{ - StoredVersion: dashv1.VERSION, + StoredVersion: ptr.To(dashv1.VERSION), Failed: true, - Error: "backend conversion not yet implemented", + Error: ptr.To("backend conversion not yet implemented"), }, } diff --git a/apps/dashboard/pkg/migration/conversion/v2.go b/apps/dashboard/pkg/migration/conversion/v2.go index ccdb98fdcf4..9bb126168e3 100644 --- a/apps/dashboard/pkg/migration/conversion/v2.go +++ b/apps/dashboard/pkg/migration/conversion/v2.go @@ -2,6 +2,7 @@ package conversion import ( "k8s.io/apimachinery/pkg/conversion" + "k8s.io/utils/ptr" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" @@ -16,9 +17,9 @@ func Convert_V2alpha1_to_V0(in *dashv2alpha1.Dashboard, out *dashv0.Dashboard, s out.Status = dashv0.DashboardStatus{ Conversion: &dashv0.DashboardConversionStatus{ - StoredVersion: dashv2alpha1.VERSION, + StoredVersion: ptr.To(dashv2alpha1.VERSION), Failed: true, - Error: "backend conversion not yet implemented", + Error: ptr.To("backend conversion not yet implemented"), }, } @@ -32,9 +33,9 @@ func Convert_V2alpha1_to_V1(in *dashv2alpha1.Dashboard, out *dashv1.Dashboard, s out.Status = dashv1.DashboardStatus{ Conversion: &dashv1.DashboardConversionStatus{ - StoredVersion: dashv2alpha1.VERSION, + StoredVersion: ptr.To(dashv2alpha1.VERSION), Failed: true, - Error: "backend conversion not yet implemented", + Error: ptr.To("backend conversion not yet implemented"), }, } @@ -48,9 +49,9 @@ func Convert_V2alpha1_to_V2beta1(in *dashv2alpha1.Dashboard, out *dashv2beta1.Da if err := ConvertDashboard_V2alpha1_to_V2beta1(in, out, scope); err != nil { out.Status = dashv2beta1.DashboardStatus{ Conversion: &dashv2beta1.DashboardConversionStatus{ - StoredVersion: dashv2alpha1.VERSION, + StoredVersion: ptr.To(dashv2alpha1.VERSION), Failed: true, - Error: err.Error(), + Error: ptr.To(err.Error()), }, } return err @@ -59,7 +60,7 @@ func Convert_V2alpha1_to_V2beta1(in *dashv2alpha1.Dashboard, out *dashv2beta1.Da // Set successful conversion status out.Status = dashv2beta1.DashboardStatus{ Conversion: &dashv2beta1.DashboardConversionStatus{ - StoredVersion: dashv2alpha1.VERSION, + StoredVersion: ptr.To(dashv2alpha1.VERSION), Failed: false, }, } @@ -74,9 +75,9 @@ func Convert_V2beta1_to_V0(in *dashv2beta1.Dashboard, out *dashv0.Dashboard, sco out.Status = dashv0.DashboardStatus{ Conversion: &dashv0.DashboardConversionStatus{ - StoredVersion: dashv2beta1.VERSION, + StoredVersion: ptr.To(dashv2beta1.VERSION), Failed: true, - Error: "backend conversion not yet implemented", + Error: ptr.To("backend conversion not yet implemented"), }, } @@ -90,9 +91,9 @@ func Convert_V2beta1_to_V1(in *dashv2beta1.Dashboard, out *dashv1.Dashboard, sco out.Status = dashv1.DashboardStatus{ Conversion: &dashv1.DashboardConversionStatus{ - StoredVersion: dashv2beta1.VERSION, + StoredVersion: ptr.To(dashv2beta1.VERSION), Failed: true, - Error: "backend conversion not yet implemented", + Error: ptr.To("backend conversion not yet implemented"), }, } @@ -106,9 +107,9 @@ func Convert_V2beta1_to_V2alpha1(in *dashv2beta1.Dashboard, out *dashv2alpha1.Da out.Status = dashv2alpha1.DashboardStatus{ Conversion: &dashv2alpha1.DashboardConversionStatus{ - StoredVersion: dashv2beta1.VERSION, + StoredVersion: ptr.To(dashv2beta1.VERSION), Failed: true, - Error: "backend conversion not yet implemented", + Error: ptr.To("backend conversion not yet implemented"), }, } diff --git a/apps/folder/go.mod b/apps/folder/go.mod index effa747e628..652fec43489 100644 --- a/apps/folder/go.mod +++ b/apps/folder/go.mod @@ -23,7 +23,7 @@ require ( github.com/go-test/deep v1.1.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.2 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/apps/folder/go.sum b/apps/folder/go.sum index 12336f31c0f..ccbf0420c0e 100644 --- a/apps/folder/go.sum +++ b/apps/folder/go.sum @@ -34,8 +34,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhckclH/N4DYU= github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= -github.com/grafana/grafana-app-sdk/logging v0.40.2 h1:HQ1+y9Od92iMbWWB54QxiYpNtCvYGUVpyxvxZ7ywB1k= -github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= +github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index a39818ae099..18ba313658b 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -24,7 +24,7 @@ require ( github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 github.com/grafana/grafana v0.0.0-00010101000000-000000000000 github.com/grafana/grafana-app-sdk v0.40.3 - github.com/grafana/grafana-app-sdk/logging v0.40.2 + github.com/grafana/grafana-app-sdk/logging v0.40.3 github.com/grafana/grafana-app-sdk/plugin v0.40.3 github.com/grafana/grafana/apps/folder v0.0.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0 @@ -81,7 +81,7 @@ require ( github.com/cloudflare/circl v1.6.1 // indirect github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect github.com/dlmiddlecote/sqlstats v1.0.2 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index d8ee190a9e6..0b292c52c5f 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -336,8 +336,8 @@ github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8= github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg= @@ -687,8 +687,8 @@ github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 h1:qcSGhr691f1mmPHwg github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914/go.mod h1:OiN4P4aC6LwLzLbEupH3Ue83VfQoNMfG48rsna8jI/E= github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhckclH/N4DYU= github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= -github.com/grafana/grafana-app-sdk/logging v0.40.2 h1:HQ1+y9Od92iMbWWB54QxiYpNtCvYGUVpyxvxZ7ywB1k= -github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= +github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana-app-sdk/plugin v0.40.3 h1:uH0oFZnYOUL+OXcyhd5NVYwoM+Wa0WUXvZ2Om1M91r0= github.com/grafana/grafana-app-sdk/plugin v0.40.3/go.mod h1:+ylwE0P8WgPu5zURK5aDnVJpwRpuK3573rwrVV28qzQ= github.com/grafana/grafana-aws-sdk v1.1.0 h1:G0fvwbQmHw14c5RXPd7Gnw9ZQcgzl139LtMDoe0KhmE= diff --git a/apps/investigations/go.mod b/apps/investigations/go.mod index ab3de62ade8..278334ce0f1 100644 --- a/apps/investigations/go.mod +++ b/apps/investigations/go.mod @@ -30,7 +30,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.2 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect diff --git a/apps/investigations/go.sum b/apps/investigations/go.sum index 2ba66fa1434..2f26b92ca0c 100644 --- a/apps/investigations/go.sum +++ b/apps/investigations/go.sum @@ -49,8 +49,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhckclH/N4DYU= github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= -github.com/grafana/grafana-app-sdk/logging v0.40.2 h1:HQ1+y9Od92iMbWWB54QxiYpNtCvYGUVpyxvxZ7ywB1k= -github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= +github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/apps/playlist/go.mod b/apps/playlist/go.mod index 5be8ea92b75..96637140467 100644 --- a/apps/playlist/go.mod +++ b/apps/playlist/go.mod @@ -30,7 +30,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.2 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect diff --git a/apps/playlist/go.sum b/apps/playlist/go.sum index 2ba66fa1434..2f26b92ca0c 100644 --- a/apps/playlist/go.sum +++ b/apps/playlist/go.sum @@ -49,8 +49,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhckclH/N4DYU= github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= -github.com/grafana/grafana-app-sdk/logging v0.40.2 h1:HQ1+y9Od92iMbWWB54QxiYpNtCvYGUVpyxvxZ7ywB1k= -github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= +github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index 79a53eaca86..a6713c10599 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -36,7 +36,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 // indirect github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.2 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index 0bf5302edb2..c07e28b199a 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -59,8 +59,8 @@ github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 h1:qcSGhr691f1mmPHwg github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914/go.mod h1:OiN4P4aC6LwLzLbEupH3Ue83VfQoNMfG48rsna8jI/E= github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhckclH/N4DYU= github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= -github.com/grafana/grafana-app-sdk/logging v0.40.2 h1:HQ1+y9Od92iMbWWB54QxiYpNtCvYGUVpyxvxZ7ywB1k= -github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= +github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250428110029-a8ea72012bde h1:ydSrBIOCxJQ84+JU+cyYsOLL40QeXrB7rYfsY/ezU4w= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250428110029-a8ea72012bde/go.mod h1:3MwgP0ISxGviTy3ZUJZsNz/56NNtHztMlH+gcxDt6Tw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= diff --git a/apps/provisioning/cmd/job-controller/Makefile b/apps/provisioning/cmd/job-controller/Makefile new file mode 100644 index 00000000000..c966afa283a --- /dev/null +++ b/apps/provisioning/cmd/job-controller/Makefile @@ -0,0 +1,28 @@ +.PHONY: build clean test +BINARY_NAME=job-controller +BUILD_DIR=bin +LDFLAGS=-w -s + +build: + @echo "Building $(BINARY_NAME)..." + @mkdir -p $(BUILD_DIR) + go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) . + +clean: + @echo "Cleaning..." + @rm -rf $(BUILD_DIR) +run: + @echo "Running $(BINARY_NAME)..." + ./$(BUILD_DIR)/$(BINARY_NAME) + +install: + @echo "Installing $(BINARY_NAME)..." + go install . + +help: + @echo "Available targets:" + @echo " build - Build the binary" + @echo " clean - Clean build artifacts" + @echo " run - Run the binary" + @echo " install - Install the binary" + @echo " help - Show this help" diff --git a/apps/provisioning/cmd/job-controller/README.md b/apps/provisioning/cmd/job-controller/README.md new file mode 100644 index 00000000000..96b95bc616b --- /dev/null +++ b/apps/provisioning/cmd/job-controller/README.md @@ -0,0 +1,138 @@ +# Jobs Controller + +> [!WARNING] +> This controller has current limitations: +> +> - This binary does not start the ConcurrentJobDriver yet. Notifications are logged but not consumed by workers here. +> - Job processing (claim/renew/update/complete) isn't implemented yet as it requires refactoring of some components. + +### Behavior + +- Watches provisioning `Jobs` and emits notifications on job creation. +- Optionally cleans up `HistoricJobs` after a configurable expiration. Disable when job history is stored in Loki. + +- Queueing and claiming: + - Creating a `Job` enqueues work. Drivers “claim” one job at a time under a time-bound lease so only one worker processes it at once. + - If a driver crashes or loses its lease, cleanup makes the job eligible to be claimed again. This yields at-least-once processing. + - New job notifications reduce latency; periodic ticks ensure progress even without notifications. + +- Processing and status: + - A supporting worker processes the job, renewing the lease in the background. If lease renewal fails or expires, processing aborts. + - Status updates are persisted with conflict-aware retries. Progress is throttled to avoid excessive writes while still providing timely feedback. + - When processing finishes, the job is marked complete and a copy is written to history. + +- Historic jobs role: + - Historic jobs are a read-only audit trail and UX surface for recent job outcomes, progress summaries, errors, and reference URLs. +- Retention is implementation-dependent: this controller can prune old history objects periodically, or history can be stored in Loki; when using Loki, disable local cleanup with `--history-expiration=0`. + +This binary currently wires informers and emits job-create notifications. In the full setup, concurrent drivers consume notifications and execute workers to process jobs using the behavior above. + +### Flags + +- `--token` (string): Token to use for authentication against the provisioning API. +- `--token-exchange-url` (string): Token exchange endpoint used to mint the access token for the provisioning API. +- `--provisioning-server-url` (string): Base URL to the provisioning API server (e.g., `https://localhost:6446`). +- `--history-expiration` (duration): If greater than zero, enables HistoricJobs cleanup and sets the retention window (e.g., `30s`, `15m`, `24h`). If `0`, cleanup is disabled. + +#### TLS Configuration + +- `--tls-insecure` (bool): Skip TLS certificate verification. Default: `true` (for development/testing). +- `--tls-cert-file` (string): Path to TLS client certificate file for mutual TLS authentication. +- `--tls-key-file` (string): Path to TLS client private key file for mutual TLS authentication. +- `--tls-ca-file` (string): Path to TLS CA certificate file for server certificate verification. + +### How to run + +1. Build from this folder: + - `make build` +2. Ensure the following services are running locally: provisioning API server, secrets service API server, repository controller, unified storage, and auth. +3. Start the controller: + - Using Loki for job history: + - Ensure the Provisioning API is configured with Loki for job history (see `createJobHistoryConfigFromSettings` in `pkg/registry/apis/provisioning/register.go`). + - Run without history cleanup: + - `./bin/job-controller --token-exchange-url=http://localhost:6481/sign/access-token --token=ProvisioningAdminToken --provisioning-server-url=https://localhost:6446` + - Without Loki (local/dev or when Loki is unavailable): + - Run without cleanup: + - `./bin/job-controller --token-exchange-url=http://localhost:6481/sign/access-token --token=ProvisioningAdminToken --provisioning-server-url=https://localhost:6446` + - Or enable local HistoricJobs cleanup with a retention window: + - `./bin/job-controller --token-exchange-url=http://localhost:6481/sign/access-token --token=ProvisioningAdminToken --provisioning-server-url=https://localhost:6446 --history-expiration=30s` + +#### TLS Configuration Examples + +- **Production with proper TLS verification**: + + ```bash + ./bin/job-controller \ + --token-exchange-url=http://localhost:6481/sign/access-token \ + --token=ProvisioningAdminToken \ + --provisioning-server-url=https://provisioning.example.com:6446 \ + --tls-insecure=false \ + --tls-ca-file=/path/to/ca-cert.pem + ``` + +- **Mutual TLS authentication**: + + ```bash + ./bin/job-controller \ + --token-exchange-url=http://localhost:6481/sign/access-token \ + --token=ProvisioningAdminToken \ + --provisioning-server-url=https://provisioning.example.com:6446 \ + --tls-insecure=false \ + --tls-ca-file=/path/to/ca-cert.pem \ + --tls-cert-file=/path/to/client-cert.pem \ + --tls-key-file=/path/to/client-key.pem + ``` + +- **Development with self-signed certificates (insecure)**: + + ```bash + ./bin/job-controller \ + --token-exchange-url=http://localhost:6481/sign/access-token \ + --token=ProvisioningAdminToken \ + --provisioning-server-url=https://localhost:6446 \ + --tls-insecure=true + ``` + +### Expected behavior + +1. Create a repository and enqueue a job (note that the repository must be marked as healthy): + +```curl + +export ACCESS_TOKEN=$(curl -X POST http://localhost:6481/sign/access-token \ + -H "X-Realms: [{\"type\":\"system\",\"identifier\":\"system\"}]" \ + -H "X-Org-ID: 0" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ProvisioningAdminToken" \ + -d '{ + "namespace": "*", + "audiences": ["provisioning.grafana.app"] + }' | jq -r '.data.token') +``` + +```curl + +curl -X POST https://localhost:6446/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/test6/jobs \ + -H "Content-Type: application/json" --insecure \ + -H "X-Access-Token: Bearer $ACCESS_TOKEN" \ + -d '{ + "action": "pull", + "pull": { + "incremental": false + } + }' +``` + +2. The controller emits a notification on job creation. + +``` +➜ job-controller git:(feature/standalone-job-controller) ✗ ./bin/job-controller --token-exchange-url=http://localhost:6481/sign/access-token --token=ProvisioningAdminToken --provisioning-server-url=https://localhost:6446 +{"time":"2025-08-21T14:27:03.789337+02:00","level":"INFO","msg":"job create notification received","logger":"provisioning-job-controller"} +``` + +``` + +``` + +3. In a full setup with the concurrent driver, workers claim and process jobs, updating status and writing history. +4. Entries move to `HistoricJobs`; if cleanup is enabled, older entries are pruned based on `--history-expiration`. diff --git a/apps/provisioning/cmd/job-controller/main.go b/apps/provisioning/cmd/job-controller/main.go new file mode 100644 index 00000000000..c953e7095b6 --- /dev/null +++ b/apps/provisioning/cmd/job-controller/main.go @@ -0,0 +1,234 @@ +package main + +import ( + "context" + "crypto/x509" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/grafana/authlib/authn" + "github.com/grafana/grafana-app-sdk/logging" + "github.com/urfave/cli/v2" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/transport" + + authrt "github.com/grafana/grafana/apps/provisioning/pkg/auth" + "github.com/grafana/grafana/apps/provisioning/pkg/controller" + client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" + informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions" +) + +var ( + token = flag.String("token", "", "Token to use for authentication") + tokenExchangeURL = flag.String("token-exchange-url", "", "Token exchange URL") + provisioningServerURL = flag.String("provisioning-server-url", "", "Provisioning server URL") + tlsInsecure = flag.Bool("tls-insecure", true, "Skip TLS certificate verification") + tlsCertFile = flag.String("tls-cert-file", "", "Path to TLS certificate file") + tlsKeyFile = flag.String("tls-key-file", "", "Path to TLS private key file") + tlsCAFile = flag.String("tls-ca-file", "", "Path to TLS CA certificate file") +) + +func main() { + app := &cli.App{ + Name: "job-controller", + Usage: "Watch provisioning jobs and manage job history cleanup", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "token", + Usage: "Token to use for authentication", + Value: "", + Destination: token, + }, + &cli.StringFlag{ + Name: "token-exchange-url", + Usage: "Token exchange URL", + Value: "", + Destination: tokenExchangeURL, + }, + &cli.StringFlag{ + Name: "provisioning-server-url", + Usage: "Provisioning server URL", + Value: "", + Destination: provisioningServerURL, + }, + &cli.BoolFlag{ + Name: "tls-insecure", + Usage: "Skip TLS certificate verification", + Value: true, + Destination: tlsInsecure, + }, + &cli.StringFlag{ + Name: "tls-cert-file", + Usage: "Path to TLS certificate file", + Value: "", + Destination: tlsCertFile, + }, + &cli.StringFlag{ + Name: "tls-key-file", + Usage: "Path to TLS private key file", + Value: "", + Destination: tlsKeyFile, + }, + &cli.StringFlag{ + Name: "tls-ca-file", + Usage: "Path to TLS CA certificate file", + Value: "", + Destination: tlsCAFile, + }, + &cli.DurationFlag{ + Name: "history-expiration", + Usage: "Duration after which HistoricJobs are deleted; 0 disables cleanup. When the Provisioning API is configured to use Loki for job history, leave this at 0.", + Value: 0, + }, + }, + Action: runJobController, + } + + if err := app.Run(os.Args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func runJobController(c *cli.Context) error { + // TODO: Wire notifications into a ConcurrentJobDriver when a client-backed Store and Workers are available. + // For now, just log notifications to verify events end-to-end. + logger := logging.NewSLogLogger(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })).With("logger", "provisioning-job-controller") + logger.Info("Starting provisioning job controller") + + tokenExchangeClient, err := authn.NewTokenExchangeClient(authn.TokenExchangeConfig{ + TokenExchangeURL: *tokenExchangeURL, + Token: *token, + }) + if err != nil { + return fmt.Errorf("failed to create token exchange client: %w", err) + } + + tlsConfig, err := buildTLSConfig() + if err != nil { + return fmt.Errorf("failed to build TLS configuration: %w", err) + } + + config := &rest.Config{ + APIPath: "/apis", + Host: *provisioningServerURL, + WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper { + return authrt.NewRoundTripper(tokenExchangeClient, rt) + }), + TLSClientConfig: tlsConfig, + } + + provisioningClient, err := client.NewForConfig(config) + if err != nil { + return fmt.Errorf("failed to create provisioning client: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Println("Received shutdown signal, stopping controllers") + cancel() + }() + + // Jobs informer and controller (resync ~60s like in register.go) + jobInformerFactory := informer.NewSharedInformerFactoryWithOptions( + provisioningClient, + 60*time.Second, + ) + jobInformer := jobInformerFactory.Provisioning().V0alpha1().Jobs() + jobController, err := controller.NewJobController(jobInformer) + if err != nil { + return fmt.Errorf("failed to create job controller: %w", err) + } + + logger.Info("jobs controller started") + notifications := jobController.InsertNotifications() + go func() { + for { + select { + case <-ctx.Done(): + return + case <-notifications: + logger.Info("job create notification received") + } + } + }() + + // Optionally enable history cleanup if a positive expiration is provided + historyExpiration := c.Duration("history-expiration") + var startHistoryInformers func() + if historyExpiration > 0 { + // History jobs informer and controller (separate factory with resync == expiration) + historyInformerFactory := informer.NewSharedInformerFactoryWithOptions( + provisioningClient, + historyExpiration, + ) + historyJobInformer := historyInformerFactory.Provisioning().V0alpha1().HistoricJobs() + _, err = controller.NewHistoryJobController( + provisioningClient.ProvisioningV0alpha1(), + historyJobInformer, + historyExpiration, + ) + if err != nil { + return fmt.Errorf("failed to create history job controller: %w", err) + } + logger.Info("history cleanup enabled", "expiration", historyExpiration.String()) + startHistoryInformers = func() { historyInformerFactory.Start(ctx.Done()) } + } else { + startHistoryInformers = func() {} + } + + // Start informers + go jobInformerFactory.Start(ctx.Done()) + go startHistoryInformers() + + // Optionally wait for job cache sync; history cleanup can rely on resync events + if !cache.WaitForCacheSync(ctx.Done(), jobInformer.Informer().HasSynced) { + return fmt.Errorf("failed to sync job informer cache") + } + + <-ctx.Done() + return nil +} + +func buildTLSConfig() (rest.TLSClientConfig, error) { + tlsConfig := rest.TLSClientConfig{ + Insecure: *tlsInsecure, + } + + // If client certificate and key are provided + if *tlsCertFile != "" && *tlsKeyFile != "" { + tlsConfig.CertFile = *tlsCertFile + tlsConfig.KeyFile = *tlsKeyFile + } + + // If CA certificate is provided + if *tlsCAFile != "" { + caCert, err := os.ReadFile(*tlsCAFile) + if err != nil { + return tlsConfig, fmt.Errorf("failed to read CA certificate file: %w", err) + } + + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return tlsConfig, fmt.Errorf("failed to parse CA certificate") + } + + tlsConfig.CAData = caCert + } + + return tlsConfig, nil +} diff --git a/apps/provisioning/go.mod b/apps/provisioning/go.mod index fd16b3948da..5f04a324097 100644 --- a/apps/provisioning/go.mod +++ b/apps/provisioning/go.mod @@ -3,18 +3,29 @@ module github.com/grafana/grafana/apps/provisioning go 1.24.6 require ( + github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 + github.com/grafana/grafana-app-sdk/logging v0.40.3 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 + github.com/stretchr/testify v1.10.0 + github.com/urfave/cli/v2 v2.27.7 k8s.io/apimachinery v0.33.3 + k8s.io/apiserver v0.33.3 k8s.io/client-go v0.33.3 k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff sigs.k8s.io/structured-merge-diff/v4 v4.7.0 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect 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 @@ -23,6 +34,8 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 // indirect + github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.9.0 // indirect @@ -31,24 +44,41 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/onsi/ginkgo/v2 v2.22.2 // indirect github.com/onsi/gomega v1.36.2 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/spf13/pflag v1.0.7 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.10.0 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/term v0.34.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.36.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.33.3 // indirect + k8s.io/component-base v0.33.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect diff --git a/apps/provisioning/go.sum b/apps/provisioning/go.sum index c4b1a212f72..0fab9de724f 100644 --- a/apps/provisioning/go.sum +++ b/apps/provisioning/go.sum @@ -1,3 +1,11 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -6,8 +14,13 @@ github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtz github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= +github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -18,6 +31,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/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= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -28,6 +43,14 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 h1:vVPT0i5Y1vI6qzecYStV2yk7cHKrC3Pc7AgvwT5KydQ= +github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI= +github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 h1:NlkGMnVi/oUn6Cr90QbJYpQJ4FnjyAIG9Ex5GtTZIzw= +github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 h1:qcSGhr691f1mmPHwg2svGyO40Ex92G02aOyHzP6XHCE= +github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914/go.mod h1:OiN4P4aC6LwLzLbEupH3Ue83VfQoNMfG48rsna8jI/E= +github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= +github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 h1:X0cnaFdR+iz+sDSuoZmkryFSjOirchHe2MdKSRwBWgM= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:RRvSjHH12/PnQaXraMO65jUhVu8n59mzvhfIMBETnV4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -36,10 +59,14 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -53,25 +80,55 @@ github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= +github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= @@ -81,12 +138,22 @@ go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= @@ -94,15 +161,35 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl 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= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.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= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= @@ -111,12 +198,18 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 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= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= 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= @@ -126,14 +219,19 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8= k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE= k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA= k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/apiserver v0.33.3 h1:Wv0hGc+QFdMJB4ZSiHrCgN3zL3QRatu56+rpccKC3J4= +k8s.io/apiserver v0.33.3/go.mod h1:05632ifFEe6TxwjdAIrwINHWE2hLwyADFk5mBsQa15E= k8s.io/client-go v0.33.3 h1:M5AfDnKfYmVJif92ngN532gFqakcGi6RvaOF16efrpA= k8s.io/client-go v0.33.3/go.mod h1:luqKBQggEf3shbxHY4uVENAxrDISLOarxpTKMiUuujg= +k8s.io/component-base v0.33.3 h1:mlAuyJqyPlKZM7FyaoM/LcunZaaY353RXiOd2+B5tGA= +k8s.io/component-base v0.33.3/go.mod h1:ktBVsBzkI3imDuxYXmVxZ2zxJnYTZ4HAsVj9iF09qp4= 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/apps/provisioning/pkg/auth/round_tripper.go b/apps/provisioning/pkg/auth/round_tripper.go new file mode 100644 index 00000000000..327999457ae --- /dev/null +++ b/apps/provisioning/pkg/auth/round_tripper.go @@ -0,0 +1,45 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + + "github.com/grafana/authlib/authn" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + utilnet "k8s.io/apimachinery/pkg/util/net" +) + +// tokenExchanger abstracts the token exchange client for testability. +type tokenExchanger interface { + Exchange(ctx context.Context, req authn.TokenExchangeRequest) (*authn.TokenExchangeResponse, error) +} + +// RoundTripper injects an exchanged access token for the provisioning API into outgoing requests. +type RoundTripper struct { + client tokenExchanger + transport http.RoundTripper +} + +// NewRoundTripper constructs a RoundTripper that exchanges the provided token per request +// and forwards the request to the provided base transport. +func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper) *RoundTripper { + return &RoundTripper{ + client: tokenExchangeClient, + transport: base, + } +} + +func (t *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + tokenResponse, err := t.client.Exchange(req.Context(), authn.TokenExchangeRequest{ + Audiences: []string{provisioning.GROUP}, + Namespace: "*", + }) + if err != nil { + return nil, fmt.Errorf("failed to exchange token: %w", err) + } + + req = utilnet.CloneRequest(req) + req.Header.Set("X-Access-Token", "Bearer "+tokenResponse.Token) + return t.transport.RoundTrip(req) +} diff --git a/apps/provisioning/pkg/auth/round_tripper_test.go b/apps/provisioning/pkg/auth/round_tripper_test.go new file mode 100644 index 00000000000..7925c46f973 --- /dev/null +++ b/apps/provisioning/pkg/auth/round_tripper_test.go @@ -0,0 +1,63 @@ +package auth + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/authlib/authn" +) + +type fakeExchanger struct { + resp *authn.TokenExchangeResponse + err error +} + +func (f *fakeExchanger) Exchange(_ context.Context, req authn.TokenExchangeRequest) (*authn.TokenExchangeResponse, error) { + return f.resp, f.err +} + +// roundTripperFunc allows building a stub transport inline +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestRoundTripper_SetsAccessTokenHeader(t *testing.T) { + tr := NewRoundTripper(&fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "abc123"}}, roundTripperFunc(func(r *http.Request) (*http.Response, error) { + got := r.Header.Get("X-Access-Token") + if got != "Bearer abc123" { + t.Fatalf("expected X-Access-Token header 'Bearer abc123', got %q", got) + } + // Return a minimal response; body must be non-nil per http.RoundTripper contract + rr := httptest.NewRecorder() + rr.WriteHeader(http.StatusOK) + return rr.Result(), nil + })) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // drain and close body + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() +} + +func TestRoundTripper_PropagatesExchangeError(t *testing.T) { + tr := NewRoundTripper(&fakeExchanger{err: io.EOF}, roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + t.Fatal("transport should not be called on exchange error") + return nil, nil + })) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil) + resp, err := tr.RoundTrip(req) + if err == nil { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + t.Fatalf("expected error, got nil") + } +} diff --git a/pkg/registry/apis/provisioning/controller/historyjob.go b/apps/provisioning/pkg/controller/historyjob.go similarity index 100% rename from pkg/registry/apis/provisioning/controller/historyjob.go rename to apps/provisioning/pkg/controller/historyjob.go diff --git a/pkg/registry/apis/provisioning/controller/job.go b/apps/provisioning/pkg/controller/job.go similarity index 100% rename from pkg/registry/apis/provisioning/controller/job.go rename to apps/provisioning/pkg/controller/job.go diff --git a/pkg/registry/apis/provisioning/controller/job_test.go b/apps/provisioning/pkg/controller/job_test.go similarity index 100% rename from pkg/registry/apis/provisioning/controller/job_test.go rename to apps/provisioning/pkg/controller/job_test.go diff --git a/apps/secret/go.mod b/apps/secret/go.mod index bd3d4f3542b..26df11b118a 100644 --- a/apps/secret/go.mod +++ b/apps/secret/go.mod @@ -28,7 +28,7 @@ require ( github.com/go-test/deep v1.1.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.2 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/apps/secret/go.sum b/apps/secret/go.sum index f98c5385f61..62cfe8b9ac1 100644 --- a/apps/secret/go.sum +++ b/apps/secret/go.sum @@ -38,8 +38,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhckclH/N4DYU= github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= -github.com/grafana/grafana-app-sdk/logging v0.40.2 h1:HQ1+y9Od92iMbWWB54QxiYpNtCvYGUVpyxvxZ7ywB1k= -github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= +github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250710134100-1f3dc0533caf h1:BBGDHffvVNLoYQlXEpbXcxE0vbpq7pm/8OWF5I+UDZg= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250710134100-1f3dc0533caf/go.mod h1:eAlOam2uWhrsEZlOoAr7XZ9hbBP7SyYGYn31/aQAPs8= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/apps/shorturl/go.mod b/apps/shorturl/go.mod index 37c9d61621c..144f22332c3 100644 --- a/apps/shorturl/go.mod +++ b/apps/shorturl/go.mod @@ -31,7 +31,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.2 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect diff --git a/apps/shorturl/go.sum b/apps/shorturl/go.sum index 2ba66fa1434..2f26b92ca0c 100644 --- a/apps/shorturl/go.sum +++ b/apps/shorturl/go.sum @@ -49,8 +49,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhckclH/N4DYU= github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= -github.com/grafana/grafana-app-sdk/logging v0.40.2 h1:HQ1+y9Od92iMbWWB54QxiYpNtCvYGUVpyxvxZ7ywB1k= -github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= +github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/devenv/dev-dashboards/panel-table/table_kitchen_sink.json b/devenv/dev-dashboards/panel-table/table_kitchen_sink.json index 6e806785038..8ff35adb06d 100644 --- a/devenv/dev-dashboards/panel-table/table_kitchen_sink.json +++ b/devenv/dev-dashboards/panel-table/table_kitchen_sink.json @@ -434,9 +434,6 @@ "id": 1, "options": { "cellHeight": "sm", - "frozenColumns": { - "left": 1 - }, "footer": { "countRows": false, "enablePagination": false, @@ -445,6 +442,9 @@ "show": true }, "frameIndex": 0, + "frozenColumns": { + "left": 1 + }, "showHeader": true, "sortBy": [ { @@ -797,6 +797,10 @@ "value": { "type": "data-links" } + }, + { + "id": "custom.width", + "value": 140 } ] } @@ -804,7 +808,7 @@ }, "gridPos": { "h": 7, - "w": 24, + "w": 12, "x": 0, "y": 12 }, @@ -834,6 +838,166 @@ "title": "Colors and Links", "type": "table" }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "transparent", + "mode": "fixed" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false, + "width": 100, + "wrapHeaderText": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "highlight" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "applyToRow": true, + "mode": "basic", + "type": "color-background" + } + }, + { + "id": "mappings", + "value": [ + { + "options": { + "1": { + "color": "#fff899", + "index": 0 + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/^color-*/" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "1": { + "color": "green", + "index": 0 + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "color-bg" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "color-text" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + }, + { + "id": "color", + "value": { + "fixedColor": "text", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "use-case" + }, + "properties": [ + { + "id": "custom.width" + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 12 + }, + "id": 10, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "12.2.0-pre", + "targets": [ + { + "csvContent": "use-case,normal,color-text,color-bg,highlight\n\"color bg and apply to row\",1,0,1,1\n\"color text and apply to row\",1,1,0,1\n\"color text + color bg + apply to row\",1,1,1,1\n\"only apply to row\",1,0,0,1\n\"only color bg\",1,0,1,0\n\"only color text\",1,1,0,0\n\"color text + color bg\",1,1,1,0\n\"no colorization\",1,0,0,0", + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "Apply to Row - mixed color cell types", + "type": "table" + }, { "datasource": { "type": "grafana-testdata-datasource" @@ -1697,5 +1861,5 @@ "timezone": "", "title": "Panel Tests - Table - Kitchen Sink", "uid": "dcb9f5e9-8066-4397-889e-864b99555dbb", - "version": 9 + "version": 32 } diff --git a/devenv/dev-dashboards/panel-trend/trend_example.json b/devenv/dev-dashboards/panel-trend/trend_example.json index 9961b07fc92..2f16489573d 100644 --- a/devenv/dev-dashboards/panel-trend/trend_example.json +++ b/devenv/dev-dashboards/panel-trend/trend_example.json @@ -57,6 +57,7 @@ "type": "linear" }, "showPoints": "auto", + "showValues": true, "spanNulls": false, "stacking": { "group": "A", diff --git a/docs/sources/dashboards/manage-dashboards/index.md b/docs/sources/dashboards/manage-dashboards/index.md index 7e8ff1bd941..a5473c5a71f 100644 --- a/docs/sources/dashboards/manage-dashboards/index.md +++ b/docs/sources/dashboards/manage-dashboards/index.md @@ -79,20 +79,18 @@ Folders help you organize and group dashboards, which is useful when you have ma - On the **Dashboards** page, click **New** and select **New folder** in the drop-down. - Click an existing folder and on the folder’s page, click **New** and select **New folder** in the drop-down. -1. Enter a unique name and click **Create**. +1. Enter a unique name. Folder names can't include underscores (\_) or percentage signs (%), as it interferes with the search functionality. Also, alerts can't be placed in folders with slashes (\ /) in the name. If you want to place alerts in the folder, don't use slashes in the folder name. +1. Click **Create** + When you nest folders, you can do so up to four levels deep. When you save a dashboard, you can optionally select a folder to save the dashboard in. -{{< admonition type="note" >}} - -{{< /admonition >}} - **To edit the name of a folder:** 1. Click **Dashboards** in the primary menu. diff --git a/docs/sources/developers/http_api/data_source.md b/docs/sources/developers/http_api/data_source.md index af941c48a28..b7eddb23310 100644 --- a/docs/sources/developers/http_api/data_source.md +++ b/docs/sources/developers/http_api/data_source.md @@ -482,6 +482,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk } ``` +Note that the UID cannot be modified. + **Example Response**: ```http @@ -547,7 +549,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk { "id":1, - "uid": "updated UID", + "uid": "uid", "orgId":1, "name":"test_datasource", "type":"graphite", @@ -566,6 +568,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk } ``` +Note that the UID cannot be modified. + **Example Response**: ```http @@ -575,7 +579,7 @@ Content-Type: application/json { "datasource": { "id": 1, - "uid": "updated UID", + "uid": "uid", "orgId": 1, "name": "test_datasource", "type": "graphite", diff --git a/go.mod b/go.mod index c38fde0b190..3f52ee15112 100644 --- a/go.mod +++ b/go.mod @@ -96,7 +96,7 @@ require ( github.com/grafana/gomemcache v0.0.0-20250318131618-74242eea118d // @grafana/grafana-operator-experience-squad github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend github.com/grafana/grafana-app-sdk v0.40.3 // @grafana/grafana-app-platform-squad - github.com/grafana/grafana-app-sdk/logging v0.40.2 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana-app-sdk/logging v0.40.3 // @grafana/grafana-app-platform-squad github.com/grafana/grafana-aws-sdk v1.1.0 // @grafana/aws-datasources github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 // @grafana/partner-datasources github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 // @grafana/grafana-operator-experience-squad @@ -172,7 +172,7 @@ require ( github.com/tjhop/slog-gokit v0.1.3 // @grafana/grafana-app-platform-squad github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90 // @grafana/grafana-backend-group github.com/urfave/cli v1.22.16 // indirect; @grafana/grafana-backend-group - github.com/urfave/cli/v2 v2.27.6 // @grafana/grafana-backend-group + github.com/urfave/cli/v2 v2.27.7 // @grafana/grafana-backend-group github.com/urfave/cli/v3 v3.3.3 // @grafana/grafana-backend-group github.com/wk8/go-ordered-map v1.0.0 // @grafana/grafana-backend-group github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling @@ -377,7 +377,7 @@ require ( github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect diff --git a/go.sum b/go.sum index b39f4fa34d0..21ee7981bca 100644 --- a/go.sum +++ b/go.sum @@ -1054,8 +1054,9 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8= github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg= @@ -1602,8 +1603,8 @@ github.com/grafana/grafana-api-golang-client v0.27.0 h1:zIwMXcbCB4n588i3O2N6HfNc github.com/grafana/grafana-api-golang-client v0.27.0/go.mod h1:uNLZEmgKtTjHBtCQMwNn3qsx2mpMb8zU+7T4Xv3NR9Y= github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhckclH/N4DYU= github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= -github.com/grafana/grafana-app-sdk/logging v0.40.2 h1:HQ1+y9Od92iMbWWB54QxiYpNtCvYGUVpyxvxZ7ywB1k= -github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= +github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana-aws-sdk v1.1.0 h1:G0fvwbQmHw14c5RXPd7Gnw9ZQcgzl139LtMDoe0KhmE= github.com/grafana/grafana-aws-sdk v1.1.0/go.mod h1:7e+47EdHynteYWGoT5Ere9KeOXQObsk8F0vkOLQ1tz8= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 h1:0TYrkzAc3u0HX+9GK86cGrLTUAcmQfl3/LEB3tL+SOA= @@ -2447,8 +2448,8 @@ github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a/go.mod h1:1xEUf2abjfP9 github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= -github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= -github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= +github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/go.work.sum b/go.work.sum index 8fbfb8dfd88..8daacb001d5 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1003,8 +1003,7 @@ github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+ github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= -github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= -github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana-app-sdk/plugin v0.41.0 h1:ShUvGpAVzM3UxcsfwS6l/lwW4ytDeTbCQXf8w2P8Yp8= github.com/grafana/grafana-app-sdk/plugin v0.41.0/go.mod h1:YIhimVfAqtOp3kdhxOanaSZjypVKh/bYxf9wfFfhDm0= github.com/grafana/grafana-aws-sdk v0.38.2 h1:TzQD0OpWsNjtldi5G5TLDlBRk8OyDf+B5ujcoAu4Dp0= diff --git a/grafana-mixin/dashboards/grafana-overview.json b/grafana-mixin/dashboards/grafana-overview.json index ed16a77b4f1..784d001297b 100644 --- a/grafana-mixin/dashboards/grafana-overview.json +++ b/grafana-mixin/dashboards/grafana-overview.json @@ -3,7 +3,10 @@ "list": [ { "builtIn": 1, - "datasource": "-- Grafana --", + "datasource": { + "type": "datasource", + "uid": "grafana" + }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", @@ -18,15 +21,16 @@ } ] }, - "editable": true, - "gnetId": null, + "editable": false, + "fiscalYearStartMonth": 0, "graphTooltip": 0, - "id": 3085, - "iteration": 1631554945276, + "id": 23, "links": [], "panels": [ { - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "fieldConfig": { "defaults": { "mappings": [], @@ -35,8 +39,7 @@ "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "red", @@ -59,31 +62,37 @@ "graphMode": "area", "justifyMode": "auto", "orientation": "auto", + "percentChangeColorMode": "standard", "reduceOptions": { "calcs": ["mean"], "fields": "", "values": false }, + "showPercentChange": false, "text": {}, - "textMode": "auto" + "textMode": "auto", + "wideLayout": true }, - "pluginVersion": "8.1.3", + "pluginVersion": "12.0.2", "targets": [ { + "datasource": { + "uid": "$datasource" + }, "expr": "grafana_alerting_result_total{job=~\"$job\", instance=~\"$instance\", state=\"alerting\"}", "instant": true, - "interval": "", + "interval": "1m", "legendFormat": "", "refId": "A" } ], - "timeFrom": null, - "timeShift": null, "title": "Firing Alerts", "type": "stat" }, { - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "fieldConfig": { "defaults": { "mappings": [], @@ -91,8 +100,7 @@ "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "red", @@ -115,43 +123,50 @@ "graphMode": "area", "justifyMode": "auto", "orientation": "auto", + "percentChangeColorMode": "standard", "reduceOptions": { "calcs": ["mean"], "fields": "", "values": false }, + "showPercentChange": false, "text": {}, - "textMode": "auto" + "textMode": "auto", + "wideLayout": true }, - "pluginVersion": "8.1.3", + "pluginVersion": "12.0.2", "targets": [ { + "datasource": { + "uid": "$datasource" + }, "expr": "sum(grafana_stat_totals_dashboard{job=~\"$job\", instance=~\"$instance\"})", - "interval": "", + "interval": "1m", "legendFormat": "", "refId": "A" } ], - "timeFrom": null, - "timeShift": null, "title": "Dashboards", "type": "stat" }, { - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "fieldConfig": { "defaults": { "custom": { - "align": null, - "displayMode": "auto" + "cellOptions": { + "type": "auto" + }, + "inspect": false }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "red", @@ -170,26 +185,38 @@ }, "id": 10, "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, "showHeader": true }, - "pluginVersion": "8.1.3", + "pluginVersion": "12.0.2", "targets": [ { + "datasource": { + "uid": "$datasource" + }, "expr": "grafana_build_info{job=~\"$job\", instance=~\"$instance\"}", "instant": true, - "interval": "", + "interval": "1m", "legendFormat": "", "refId": "A" } ], - "timeFrom": null, - "timeShift": null, "title": "Build Info", "transformations": [ { "id": "labelsToFields", "options": {} }, + { + "id": "merge", + "options": {} + }, { "id": "organize", "options": { @@ -224,254 +251,247 @@ "type": "table" }, { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "fieldConfig": { "defaults": { - "links": [] + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" }, "overrides": [] }, - "fill": 1, - "fillGradient": 0, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 }, - "hiddenSeries": false, "id": 2, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", "options": { - "alertThreshold": true + "alertThreshold": true, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } }, - "percentage": false, - "pluginVersion": "8.1.3", - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": true, - "steppedLine": false, + "pluginVersion": "12.0.2", "targets": [ { + "datasource": { + "uid": "$datasource" + }, "expr": "sum by (status_code) (irate(grafana_http_request_duration_seconds_count{job=~\"$job\", instance=~\"$instance\"}[1m])) ", - "interval": "", + "interval": "1m", "legendFormat": "{{status_code}}", "refId": "A" } ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, "title": "RPS", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "$$hashKey": "object:157", - "format": "reqps", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "$$hashKey": "object:158", - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": false - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } + "type": "timeseries" }, { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "fieldConfig": { "defaults": { - "links": [] + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" }, "overrides": [] }, - "fill": 1, - "fillGradient": 0, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 }, - "hiddenSeries": false, "id": 4, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 1, - "nullPointMode": "null", "options": { - "alertThreshold": true + "alertThreshold": true, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } }, - "percentage": false, - "pluginVersion": "8.1.3", - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": false, - "steppedLine": false, + "pluginVersion": "12.0.2", "targets": [ { + "datasource": { + "uid": "$datasource" + }, "exemplar": true, "expr": "histogram_quantile(0.99, sum(irate(grafana_http_request_duration_seconds_bucket{instance=~\"$instance\", job=~\"$job\"}[$__rate_interval])) by (le)) * 1", - "interval": "", + "interval": "1m", "legendFormat": "99th Percentile", "refId": "A" }, { + "datasource": { + "uid": "$datasource" + }, "exemplar": true, "expr": "histogram_quantile(0.50, sum(irate(grafana_http_request_duration_seconds_bucket{instance=~\"$instance\", job=~\"$job\"}[$__rate_interval])) by (le)) * 1", - "interval": "", + "interval": "1m", "legendFormat": "50th Percentile", "refId": "B" }, { + "datasource": { + "uid": "$datasource" + }, "exemplar": true, "expr": "sum(irate(grafana_http_request_duration_seconds_sum{instance=~\"$instance\", job=~\"$job\"}[$__rate_interval])) * 1 / sum(irate(grafana_http_request_duration_seconds_count{instance=~\"$instance\", job=~\"$job\"}[$__rate_interval]))", - "interval": "", + "interval": "1m", "legendFormat": "Average", "refId": "C" } ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, "title": "Request Latency", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "$$hashKey": "object:210", - "format": "ms", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "$$hashKey": "object:211", - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } + "type": "timeseries" } ], - "schemaVersion": 30, + "preload": false, + "refresh": "", + "schemaVersion": 41, "tags": [], "templating": { "list": [ { "current": { - "selected": true, - "text": "dev-cortex", - "value": "dev-cortex" + "text": "Prometheus", + "value": "prometheus" }, - "description": null, - "error": null, - "hide": 0, "includeAll": false, - "label": null, - "multi": false, "name": "datasource", "options": [], "query": "prometheus", - "queryValue": "", "refresh": 1, "regex": "", - "skipUrlSync": false, "type": "datasource" }, { "allValue": ".*", "current": { - "selected": false, - "text": ["default/grafana"], - "value": ["default/grafana"] + "text": "All", + "value": ["$__all"] }, "datasource": "$datasource", "definition": "label_values(grafana_build_info, job)", - "description": null, - "error": null, - "hide": 0, "includeAll": true, - "label": null, "multi": true, "name": "job", "options": [], @@ -481,27 +501,17 @@ }, "refresh": 1, "regex": "", - "skipUrlSync": false, - "sort": 0, - "tagValuesQuery": "", - "tagsQuery": "", - "type": "query", - "useTags": false + "type": "query" }, { "allValue": ".*", "current": { - "selected": false, "text": "All", "value": "$__all" }, "datasource": "$datasource", "definition": "label_values(grafana_build_info, instance)", - "description": null, - "error": null, - "hide": 0, "includeAll": true, - "label": null, "multi": true, "name": "instance", "options": [], @@ -511,12 +521,7 @@ }, "refresh": 1, "regex": "", - "skipUrlSync": false, - "sort": 0, - "tagValuesQuery": "", - "tagsQuery": "", - "type": "query", - "useTags": false + "type": "query" } ] }, @@ -527,8 +532,8 @@ "timepicker": { "refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, - "timezone": "", + "timezone": "utc", "title": "Grafana Overview", "uid": "6be0s85Mk", - "version": 2 + "version": 1 } diff --git a/package.json b/package.json index 388b925397a..d474a542bc7 100644 --- a/package.json +++ b/package.json @@ -291,8 +291,8 @@ "@grafana/plugin-ui": "0.10.9", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "^6.30.0", - "@grafana/scenes-react": "^6.30.0", + "@grafana/scenes": "6.30.4", + "@grafana/scenes-react": "6.30.4", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index 1931a6ad967..53467e1f0b2 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -635,6 +635,7 @@ export interface GraphFieldConfig extends LineConfig, FillConfig, PointsConfig, drawStyle?: GraphDrawStyle; gradientMode?: GraphGradientMode; insertNulls?: (boolean | number); + showValues?: boolean; thresholdsStyle?: GraphThresholdsStyleConfig; transform?: GraphTransform; } diff --git a/packages/grafana-schema/src/common/mudball.cue b/packages/grafana-schema/src/common/mudball.cue index ed58696267e..24c371d8715 100644 --- a/packages/grafana-schema/src/common/mudball.cue +++ b/packages/grafana-schema/src/common/mudball.cue @@ -229,6 +229,7 @@ GraphFieldConfig: { gradientMode?: GraphGradientMode thresholdsStyle?: GraphThresholdsStyleConfig transform?: GraphTransform + showValues?: bool insertNulls?: bool | number } @cuetsy(kind="interface") diff --git a/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts index 9f48e49018d..803b6edf1c4 100644 --- a/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts @@ -54,6 +54,10 @@ export interface TempoQuery extends common.DataQuery { * Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}. Providing multiple values will produce union of results for each filter, using PromQL OR operator internally. */ serviceMapQuery?: (string | Array); + /** + * Whether to use native histograms for service map queries + */ + serviceMapUseNativeHistograms?: boolean; /** * @deprecated Query traces by service name */ diff --git a/packages/grafana-schema/src/schema/dashboard/v0alpha1/types.status.gen.ts b/packages/grafana-schema/src/schema/dashboard/v0alpha1/types.status.gen.ts index 29494dfaf24..91bf7909fd3 100644 --- a/packages/grafana-schema/src/schema/dashboard/v0alpha1/types.status.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v0alpha1/types.status.gen.ts @@ -6,18 +6,16 @@ export interface ConversionStatus { // If true, means that the dashboard is not valid, // and the caller should instead fetch the stored version. failed: boolean; - // The version which was stored when the dashboard was created / updated. - // Fetching this version should always succeed. - storedVersion: string; // The error message from the conversion. // Empty if the conversion has not failed. - error: string; + error?: string; + // The version which was stored when the dashboard was created / updated. + // Fetching this version should always succeed. + storedVersion?: string; } export const defaultConversionStatus = (): ConversionStatus => ({ failed: false, - storedVersion: "", - error: "", }); export interface Status { diff --git a/packages/grafana-schema/src/schema/dashboard/v1beta1/types.status.gen.ts b/packages/grafana-schema/src/schema/dashboard/v1beta1/types.status.gen.ts index 29494dfaf24..91bf7909fd3 100644 --- a/packages/grafana-schema/src/schema/dashboard/v1beta1/types.status.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v1beta1/types.status.gen.ts @@ -6,18 +6,16 @@ export interface ConversionStatus { // If true, means that the dashboard is not valid, // and the caller should instead fetch the stored version. failed: boolean; - // The version which was stored when the dashboard was created / updated. - // Fetching this version should always succeed. - storedVersion: string; // The error message from the conversion. // Empty if the conversion has not failed. - error: string; + error?: string; + // The version which was stored when the dashboard was created / updated. + // Fetching this version should always succeed. + storedVersion?: string; } export const defaultConversionStatus = (): ConversionStatus => ({ failed: false, - storedVersion: "", - error: "", }); export interface Status { diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.status.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.status.gen.ts index 29494dfaf24..91bf7909fd3 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.status.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.status.gen.ts @@ -6,18 +6,16 @@ export interface ConversionStatus { // If true, means that the dashboard is not valid, // and the caller should instead fetch the stored version. failed: boolean; - // The version which was stored when the dashboard was created / updated. - // Fetching this version should always succeed. - storedVersion: string; // The error message from the conversion. // Empty if the conversion has not failed. - error: string; + error?: string; + // The version which was stored when the dashboard was created / updated. + // Fetching this version should always succeed. + storedVersion?: string; } export const defaultConversionStatus = (): ConversionStatus => ({ failed: false, - storedVersion: "", - error: "", }); export interface Status { diff --git a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.status.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.status.gen.ts index 29494dfaf24..91bf7909fd3 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.status.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.status.gen.ts @@ -6,18 +6,16 @@ export interface ConversionStatus { // If true, means that the dashboard is not valid, // and the caller should instead fetch the stored version. failed: boolean; - // The version which was stored when the dashboard was created / updated. - // Fetching this version should always succeed. - storedVersion: string; // The error message from the conversion. // Empty if the conversion has not failed. - error: string; + error?: string; + // The version which was stored when the dashboard was created / updated. + // Fetching this version should always succeed. + storedVersion?: string; } export const defaultConversionStatus = (): ConversionStatus => ({ failed: false, - storedVersion: "", - error: "", }); export interface Status { diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx index 57001a4176e..0b54c3ef717 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx @@ -2,6 +2,8 @@ import { render, RenderResult } from '@testing-library/react'; import { Field, FieldType, MappingType, createTheme } from '@grafana/data'; +import { getTextColorForBackground } from '../../../../utils/colors'; + import { PillCell } from './PillCell'; describe('PillCell', () => { @@ -26,49 +28,94 @@ describe('PillCell', () => { describe('Color by hash (classic palette)', () => { it('single value', () => { expectHTML( - render(), - `value1` + render( + + ), + `value1` ); }); it('empty string', () => { - expectHTML(render(), ''); + expectHTML( + render( + + ), + '' + ); }); it('null', () => { - const { container } = render(); + const { container } = render( + + ); expect(container).toBeEmptyDOMElement(); }); it('CSV values', () => { expectHTML( - render(), + render( + + ), ` - value1 - value2 - value3 + value1 + value2 + value3 ` ); }); it('JSON array values', () => { expectHTML( - render(), + render( + + ), ` - value1 - value2 - value3 + value1 + value2 + value3 ` ); }); it('non-string values', () => { expectHTML( - render(), + render( + + ), ` - 100 - 200 - 300 + 100 + 200 + 300 ` ); }); @@ -107,12 +154,14 @@ describe('PillCell', () => { } satisfies Field; expectHTML( - render(), + render( + + ), ` - success - error - warning - unknown + success + error + warning + unknown ` ); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx index 4ba20f36605..9ab5e732d8d 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx @@ -4,7 +4,6 @@ import { useMemo } from 'react'; import { GrafanaTheme2, classicColors, - colorManipulator, Field, getColorByStringHash, FALLBACK_COLOR, @@ -14,12 +13,23 @@ import { FieldColorModeId } from '@grafana/schema'; import { PillCellProps, TableCellStyles, TableCellValue } from '../types'; -export function PillCell({ rowIdx, field, theme }: PillCellProps) { +export function PillCell({ rowIdx, field, theme, getTextColorForBackground }: PillCellProps) { const value = field.values[rowIdx]; const pills: Pill[] = useMemo(() => { const pillValues = inferPills(value); - return pillValues.length > 0 ? createPills(pillValues, field, theme) : []; - }, [value, field, theme]); + return pillValues.length > 0 + ? pillValues.map((pill, index) => { + const bgColor = getPillColor(pill, field, theme); + const textColor = getTextColorForBackground(bgColor); + return { + value: String(pill), + key: `${pill}-${index}`, + bgColor, + color: textColor, + }; + }) + : []; + }, [value, field, theme, getTextColorForBackground]); if (pills.length === 0) { return null; @@ -49,19 +59,6 @@ interface Pill { const SPLIT_RE = /\s*,\s*/; const TRANSPARENT = 'rgba(0,0,0,0)'; -function createPills(pillValues: unknown[], field: Field, theme: GrafanaTheme2): Pill[] { - return pillValues.map((pill, index) => { - const bgColor = getPillColor(pill, field, theme); - const textColor = colorManipulator.getContrastRatio('#FFFFFF', bgColor) >= 4.5 ? '#FFFFFF' : '#000000'; - return { - value: String(pill), - key: `${pill}-${index}`, - bgColor, - color: textColor, - }; - }); -} - export function inferPills(rawValue: TableCellValue): unknown[] { if (rawValue === '' || rawValue == null) { return []; 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 index 48b444839a6..da01b284216 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx @@ -91,6 +91,7 @@ describe('TableNG Cells renderers', () => { getActions={jest.fn(() => [ { title: 'Action', onClick: jest.fn(() => {}), confirmation: jest.fn(), style: {} }, ])} + getTextColorForBackground={jest.fn(() => '#000000')} /> ); }; @@ -109,6 +110,7 @@ describe('TableNG Cells renderers', () => { height={100} width={100} theme={createTheme()} + getTextColorForBackground={jest.fn(() => '#000000')} cellOptions={cellOptions} cellInspect={false} showFilters={false} diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx index ba8df94ac5c..6079a4f63d0 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx @@ -110,7 +110,12 @@ const CELL_REGISTRY: Record = { [TableCellDisplayMode.Pill]: { // eslint-disable-next-line react/display-name renderer: memo((props: TableCellRendererProps) => ( - + )), getStyles: getPillStyles, testField: (field: Field) => field.type === FieldType.string, diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index bb3c3fea8fa..39979cabfb3 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -1,6 +1,7 @@ import 'react-data-grid/lib/styles.css'; import { clsx } from 'clsx'; +import memoize from 'micro-memoize'; import { CSSProperties, Key, ReactNode, useCallback, useMemo, useRef, useState } from 'react'; import { Cell, @@ -28,6 +29,7 @@ import { Trans } from '@grafana/i18n'; import { FieldColorModeId, TableCellTooltipPlacement } from '@grafana/schema'; import { useStyles2, useTheme2 } from '../../../themes/ThemeContext'; +import { getTextColorForBackground as _getTextColorForBackground } from '../../../utils/colors'; import { Pagination } from '../../Pagination/Pagination'; import { PanelContext, usePanelContext } from '../../PanelChrome'; import { DataLinksActionsTooltip } from '../DataLinksActionsTooltip'; @@ -80,7 +82,7 @@ import { frameToRecords, getAlignment, getApplyToRowBgFn, - getCellColorInlineStyles, + getCellColorInlineStylesFactory, getCellLinks, getCellOptions, getDefaultRowHeight, @@ -143,6 +145,7 @@ export function TableNG(props: TableNGProps) { const rows = useMemo(() => frameToRecords(data), [data]); const hasNestedFrames = useMemo(() => getIsNestedTable(data.fields), [data]); + const getTextColorForBackground = useMemo(() => memoize(_getTextColorForBackground, { maxSize: 1000 }), []); const { rows: filteredRows, @@ -174,6 +177,11 @@ export function TableNG(props: TableNGProps) { () => (hasNestedFrames ? width - COLUMN.EXPANDER_WIDTH : width) - scrollbarWidth, [width, hasNestedFrames, scrollbarWidth] ); + const getCellColorInlineStyles = useMemo(() => getCellColorInlineStylesFactory(theme), [theme]); + const applyToRowBgFn = useMemo( + () => getApplyToRowBgFn(data.fields, getCellColorInlineStyles) ?? undefined, + [data.fields, getCellColorInlineStyles] + ); const typographyCtx = useMemo( () => createTypographyContext( @@ -226,7 +234,6 @@ export function TableNG(props: TableNGProps) { footerOptions, isCountRowsSet, }); - const applyToRowBgFn = useMemo(() => getApplyToRowBgFn(data.fields, theme) ?? undefined, [data.fields, theme]); // normalize the row height into a function which returns a number, so we avoid a bunch of conditionals during rendering. const rowHeightFn = useMemo((): ((row: TableRow) => number) => { @@ -438,14 +445,12 @@ export function TableNG(props: TableNGProps) { } } - let style: CSSProperties | undefined; - - if (rowCellStyle.color != null || rowCellStyle.background != null) { - style = rowCellStyle; - } else if (canBeColorized) { + let style: CSSProperties = { ...rowCellStyle }; + if (canBeColorized) { const value = props.row[props.column.key]; const displayValue = field.display!(value); // this fires here to get colors, then again to get rendered value? - style = getCellColorInlineStyles(theme, cellOptions, displayValue); + const cellColorStyles = getCellColorInlineStyles(cellOptions, displayValue, applyToRowBgFn != null); + Object.assign(style, cellColorStyles); } return ( @@ -486,6 +491,7 @@ export function TableNG(props: TableNGProps) { showFilters={showFilters} getActions={getCellActions} disableSanitizeHtml={disableSanitizeHtml} + getTextColorForBackground={getTextColorForBackground} /> {showActions && ( ): JSX.Element => { // cached so we don't care about multiple calls. const tooltipHeight = rowHeightFn(props.row); - let tooltipStyle: CSSProperties | undefined; + let tooltipStyle: CSSProperties = { ...rowCellStyle }; if (tooltipCanBeColorized) { - const tooltipDisplayValue = tooltipField.display!(props.row[tooltipDisplayName]); // this is yet another call to field.display() for the tooltip field - tooltipStyle = getCellColorInlineStyles(theme, tooltipCellOptions, tooltipDisplayValue); + const tooltipDisplayValue = tooltipField.display!(props.row[tooltipDisplayName]); + const tooltipCellColorStyles = getCellColorInlineStyles( + tooltipCellOptions, + tooltipDisplayValue, + applyToRowBgFn != null + ); + Object.assign(tooltipStyle, tooltipCellColorStyles); } return ( @@ -624,6 +636,8 @@ export function TableNG(props: TableNGProps) { footerCalcs, frozenColumns, getCellActions, + getCellColorInlineStyles, + getTextColorForBackground, isCountRowsSet, numFrozenColsFullyInView, onCellFilterAdded, diff --git a/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx index 156854bcfd9..232f3bb01a5 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx @@ -19,6 +19,7 @@ export interface TableCellTooltipProps { disableSanitizeHtml?: boolean; field: Field; getActions: (field: Field, rowIdx: number) => ActionModel[]; + getTextColorForBackground: (bgColor: string) => string; gridRef: RefObject; height: number; placement?: TableCellTooltipPlacement; @@ -40,6 +41,7 @@ export const TableCellTooltip = memo( disableSanitizeHtml, field, getActions, + getTextColorForBackground, gridRef, height, placement, @@ -100,6 +102,7 @@ export const TableCellTooltip = memo( field, frame: data, getActions, + getTextColorForBackground, height, rowIdx, showFilters: false, @@ -107,7 +110,19 @@ export const TableCellTooltip = memo( value: rawValue, width, }) satisfies TableCellRendererProps, - [cellOptions, data, disableSanitizeHtml, field, getActions, height, rawValue, rowIdx, theme, width] + [ + cellOptions, + data, + disableSanitizeHtml, + field, + getActions, + getTextColorForBackground, + height, + rawValue, + rowIdx, + theme, + width, + ] ); const cellElement = tooltipCaretRef.current?.closest('.rdg-cell'); diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index efdd9495336..123a8f39e7d 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -167,6 +167,7 @@ export interface TableCellRendererProps { showFilters: boolean; getActions?: GetActionsFunctionLocal; disableSanitizeHtml?: boolean; + getTextColorForBackground: (color: string) => string; } export type InspectCellProps = { @@ -250,6 +251,7 @@ export interface PillCellProps { theme: GrafanaTheme2; field: Field; rowIdx: number; + getTextColorForBackground: (color: string) => string; } export interface TableCellStyleOptions { 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 8ca2ba7870f..bec5094ac77 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts @@ -23,7 +23,7 @@ import { extractPixelValue, frameToRecords, getAlignmentFactor, - getCellColorInlineStyles, + getCellColorInlineStylesFactory, getCellLinks, getCellOptions, getComparator, @@ -107,28 +107,104 @@ describe('TableNG utils', () => { }, } as unknown as GrafanaTheme2; - it('should handle color background mode', () => { - const field = { type: TableCellDisplayMode.ColorBackground as const, mode: TableCellBackgroundDisplayMode.Basic }; + it('should handle color text cell type', () => { + const cellOptions = { + type: TableCellDisplayMode.ColorText as const, + }; const displayValue = { text: '100', numeric: 100, color: '#ff0000' }; - const colors = getCellColorInlineStyles(theme, field, displayValue); - expect(colors.background).toBe('rgb(255, 0, 0)'); + const getCellColorInlineStyles = getCellColorInlineStylesFactory(theme); + const colors = getCellColorInlineStyles(cellOptions, displayValue, false); + expect(colors.color).toBe('#ff0000'); + expect(colors).not.toHaveProperty('background'); + }); + + it('should pass thru color background cell type in basic mode', () => { + const cellOptions = { + type: TableCellDisplayMode.ColorBackground as const, + mode: TableCellBackgroundDisplayMode.Basic, + }; + + const displayValue = { text: '100', numeric: 100, color: '#ff0000' }; + + const getCellColorInlineStyles = getCellColorInlineStylesFactory(theme); + const colors = getCellColorInlineStyles(cellOptions, displayValue, false); + expect(colors.background).toBe('#ff0000'); expect(colors.color).toBe('rgb(247, 248, 250)'); }); - it('should handle color background gradient mode', () => { - const field = { + it('should handle color background cell type in gradient mode', () => { + const cellOptions = { type: TableCellDisplayMode.ColorBackground as const, mode: TableCellBackgroundDisplayMode.Gradient, }; const displayValue = { text: '100', numeric: 100, color: '#ff0000' }; - const colors = getCellColorInlineStyles(theme, field, displayValue); + const getCellColorInlineStyles = getCellColorInlineStylesFactory(theme); + const colors = getCellColorInlineStyles(cellOptions, displayValue, false); expect(colors.background).toBe('linear-gradient(120deg, rgb(255, 54, 36), #ff0000)'); expect(colors.color).toBe('rgb(247, 248, 250)'); }); + + it('does not set CSSProperties for un-mapped cell types', () => { + const cellOptions = { type: TableCellDisplayMode.JSONView as const }; + + const displayValue = { text: '100', numeric: 100, color: '#ff0000' }; + + const getCellColorInlineStyles = getCellColorInlineStylesFactory(theme); + const colors = getCellColorInlineStyles(cellOptions, displayValue, false); + + expect(colors).toEqual({}); + }); + + describe('applyToRow', () => { + it.each([ + ['hex', '#ffffff00'], + ['rgba', 'rgba(255,255,255,0)'], + ['hsla', 'hsla(0,100%,100%,0)'], + ])( + 'should not apply background color if the display value is transparent (%s) and applyToRow is on', + (_format, colorDisplayValue) => { + const cellOptions = { + type: TableCellDisplayMode.ColorBackground as const, + mode: TableCellBackgroundDisplayMode.Basic, + }; + + const displayValue = { text: '100', numeric: 100, color: colorDisplayValue }; + + const getCellColorInlineStyles = getCellColorInlineStylesFactory(theme); + const colors = getCellColorInlineStyles(cellOptions, displayValue, true); + + expect(colors).toEqual({}); + } + ); + + it.each([ + ['hex', '#ffffff00'], + ['rgba', 'rgba(255,255,255,0)'], + ['hsla', 'hsla(0,100%,100%,0)'], + ])( + 'should apply background color if the display value is transparent (%s) and applyToRow is off', + (_format, colorDisplayValue) => { + const cellOptions = { + type: TableCellDisplayMode.ColorBackground as const, + mode: TableCellBackgroundDisplayMode.Basic, + }; + + const displayValue = { text: '100', numeric: 100, color: colorDisplayValue }; + + const getCellColorInlineStyles = getCellColorInlineStylesFactory(theme); + const colors = getCellColorInlineStyles(cellOptions, displayValue, false); + + expect(colors).toEqual({ + background: colorDisplayValue, + color: 'rgb(32, 34, 38)', + }); + } + ); + }); }); describe('frame to records conversion', () => { diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 3eb89230a58..e6289ca0cba 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -1,4 +1,5 @@ import { Property } from 'csstype'; +import memoize from 'micro-memoize'; import { CSSProperties } from 'react'; import { SortColumn } from 'react-data-grid'; import tinycolor from 'tinycolor2'; @@ -499,36 +500,58 @@ 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 getCellColorInlineStyles( - theme: GrafanaTheme2, - cellOptions: TableCellOptions, - displayValue: DisplayValue -): CSSProperties { - // 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; - - if (cellOptions.type === TableCellDisplayMode.ColorText) { - textColor = displayValue.color; - } else if (cellOptions.type === TableCellDisplayMode.ColorBackground) { - const mode = cellOptions.mode ?? TableCellBackgroundDisplayMode.Gradient; - - if (mode === TableCellBackgroundDisplayMode.Basic) { - textColor = getTextColorForAlphaBackground(displayValue.color!, theme.isDark); - bgColor = tinycolor(displayValue.color).toRgbString(); - } else if (mode === TableCellBackgroundDisplayMode.Gradient) { - const bgColor2 = tinycolor(displayValue.color) +export function getCellColorInlineStylesFactory(theme: GrafanaTheme2) { + const bgCellTextColor = memoize((color: string) => getTextColorForAlphaBackground(color, theme.isDark), { + maxSize: 1000, + }); + const darkeningFactor = theme.isDark ? 1 : -0.7; // How much to darken elements depends upon if we're in dark mode + const gradientBg = memoize( + (color: string) => + tinycolor(color) .darken(CELL_COLOR_DARKENING_MULTIPLIER * darkeningFactor) - .spin(CELL_GRADIENT_HUE_ROTATION_DEGREES); - textColor = getTextColorForAlphaBackground(displayValue.color!, theme.isDark); - bgColor = `linear-gradient(120deg, ${bgColor2.toRgbString()}, ${displayValue.color})`; - } - } + .spin(CELL_GRADIENT_HUE_ROTATION_DEGREES) + .toRgbString(), + { maxSize: 1000 } + ); + const isTransparent = memoize( + (color: string) => { + // if hex, do the simple thing. + if (color[0] === '#') { + return color.length === 9 && color.endsWith('00'); + } + // if not hex, just use tinycolor to avoid extra logic. + return tinycolor(color).getAlpha() === 0; + }, + { maxSize: 1000 } + ); - return { color: textColor, background: bgColor }; + return (cellOptions: TableCellOptions, displayValue: DisplayValue, hasApplyToRow: boolean): CSSProperties => { + const result: CSSProperties = {}; + const displayValueColor = displayValue.color; + + if (!displayValueColor) { + return result; + } + + if (cellOptions.type === TableCellDisplayMode.ColorText) { + result.color = displayValueColor; + } else if (cellOptions.type === TableCellDisplayMode.ColorBackground) { + // return without setting anything if the bg is transparent. this allows + // the cell to inherit the row bg color if `applyToRow` is set. + if (hasApplyToRow && isTransparent(displayValueColor)) { + return result; + } + + const mode = cellOptions.mode ?? TableCellBackgroundDisplayMode.Gradient; + result.color = bgCellTextColor(displayValueColor); + result.background = + mode === TableCellBackgroundDisplayMode.Gradient + ? `linear-gradient(120deg, ${gradientBg(displayValueColor)}, ${displayValueColor})` + : displayValueColor; + } + + return result; + }; } /** @@ -881,7 +904,10 @@ export function computeColWidths(fields: Field[], availWidth: number) { * @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) => CSSProperties) | void { +export function getApplyToRowBgFn( + fields: Field[], + getCellColorInlineStyles: ReturnType +): ((rowIndex: number) => CSSProperties) | void { for (const field of fields) { const cellOptions = getCellOptions(field); const fieldDisplay = field.display; @@ -890,7 +916,7 @@ export function getApplyToRowBgFn(fields: Field[], theme: GrafanaTheme2): ((rowI cellOptions.type === TableCellDisplayMode.ColorBackground && cellOptions.applyToRow === true ) { - return (rowIndex: number) => getCellColorInlineStyles(theme, cellOptions, fieldDisplay(field.values[rowIndex])); + return (rowIndex: number) => getCellColorInlineStyles(cellOptions, fieldDisplay(field.values[rowIndex]), true); } } } diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts index 62e651f919b..0889b3a9b4b 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts @@ -49,6 +49,7 @@ export interface SeriesProps extends LineConfig, BarConfig, FillConfig, PointsCo dataFrameFieldIndex?: DataFrameFieldIndex; theme: GrafanaTheme2; value?: uPlot.Series.Value; + showValues?: boolean; } export class UPlotSeriesBuilder extends PlotConfigBuilder { diff --git a/pkg/aggregator/go.mod b/pkg/aggregator/go.mod index 51ddc319f3f..eca1d0f9ae7 100644 --- a/pkg/aggregator/go.mod +++ b/pkg/aggregator/go.mod @@ -34,7 +34,7 @@ require ( github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/elazarl/goproxy v1.7.2 // indirect github.com/evanphx/json-patch v5.9.11+incompatible // indirect diff --git a/pkg/aggregator/go.sum b/pkg/aggregator/go.sum index bec1d40e6bb..bfe89011be6 100644 --- a/pkg/aggregator/go.sum +++ b/pkg/aggregator/go.sum @@ -40,8 +40,9 @@ github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod index d45b4d47541..8a363f0fa04 100644 --- a/pkg/apiserver/go.mod +++ b/pkg/apiserver/go.mod @@ -5,7 +5,7 @@ go 1.24.6 require ( github.com/google/go-cmp v0.7.0 github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 - github.com/grafana/grafana-app-sdk/logging v0.40.2 + github.com/grafana/grafana-app-sdk/logging v0.40.3 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e github.com/prometheus/client_golang v1.23.0 github.com/stretchr/testify v1.10.0 diff --git a/pkg/apiserver/go.sum b/pkg/apiserver/go.sum index 3e0c96a17c5..b44bbb5bcaf 100644 --- a/pkg/apiserver/go.sum +++ b/pkg/apiserver/go.sum @@ -84,8 +84,8 @@ github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 h1:NlkGMnVi/ github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 h1:qcSGhr691f1mmPHwg2svGyO40Ex92G02aOyHzP6XHCE= github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914/go.mod h1:OiN4P4aC6LwLzLbEupH3Ue83VfQoNMfG48rsna8jI/E= -github.com/grafana/grafana-app-sdk/logging v0.40.2 h1:HQ1+y9Od92iMbWWB54QxiYpNtCvYGUVpyxvxZ7ywB1k= -github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= +github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= diff --git a/pkg/build/go.mod b/pkg/build/go.mod index 2c3ea9cebf7..e5b4ab234d8 100644 --- a/pkg/build/go.mod +++ b/pkg/build/go.mod @@ -9,7 +9,7 @@ replace github.com/docker/docker => github.com/moby/moby v27.5.1+incompatible require ( github.com/google/uuid v1.6.0 // indirect; @grafana/grafana-backend-group - github.com/urfave/cli/v2 v2.27.6 // @grafana/grafana-backend-group + github.com/urfave/cli/v2 v2.27.7 // @grafana/grafana-backend-group go.opentelemetry.io/otel v1.37.0 // indirect; @grafana/grafana-backend-group go.opentelemetry.io/otel/sdk v1.37.0 // indirect; @grafana/grafana-backend-group go.opentelemetry.io/otel/trace v1.37.0 // indirect; @grafana/grafana-backend-group @@ -21,7 +21,7 @@ require ( ) require ( - github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-logr/logr v1.4.3 // indirect; @grafana/grafana-app-platform-squad github.com/go-logr/stdr v1.2.2 // indirect diff --git a/pkg/build/go.sum b/pkg/build/go.sum index d2257f2690b..8e049a4067c 100644 --- a/pkg/build/go.sum +++ b/pkg/build/go.sum @@ -12,8 +12,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -51,8 +51,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= -github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= +github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= diff --git a/pkg/expr/convert_to_full_long.go b/pkg/expr/convert_to_full_long.go index 863e45b39df..6c17badb1d8 100644 --- a/pkg/expr/convert_to_full_long.go +++ b/pkg/expr/convert_to_full_long.go @@ -27,6 +27,7 @@ func ConvertToFullLong(frames data.Frames) (data.Frames, error) { if frames[0].Meta != nil && frames[0].Meta.Type != "" { inputType = frames[0].Meta.Type } else { + // shouldn't hit this when calling from handleSqlInput as supportedToLongConversion is called first return nil, fmt.Errorf("input frame missing FrameMeta.Type") } @@ -40,6 +41,7 @@ func ConvertToFullLong(frames data.Frames) (data.Frames, error) { case data.FrameTypeTimeSeriesWide: return convertTimeSeriesWideToFullLong(frames) default: + // Shouldn't hit this when calling from handleSqlInput as supportedToLongConversion is called first return nil, fmt.Errorf("unsupported input type %s for full long conversion", inputType) } } diff --git a/pkg/expr/converter.go b/pkg/expr/converter.go index e4c444c5ce7..3017afaeef1 100644 --- a/pkg/expr/converter.go +++ b/pkg/expr/converter.go @@ -23,17 +23,11 @@ type ResultConverter struct { func (c *ResultConverter) Convert(ctx context.Context, datasourceType string, frames data.Frames, - forSqlInput bool, ) (string, mathexp.Results, error) { if len(frames) == 0 { return "no-data", mathexp.Results{Values: mathexp.Values{mathexp.NewNoData()}}, nil } - if forSqlInput { - results := handleSqlInput(frames) - return "sql input", results, nil - } - var dt data.FrameType dt, useDataplane, _ := shouldUseDataplane(frames, logger, c.Features.IsEnabled(ctx, featuremgmt.FlagDisableSSEDataplane)) if useDataplane { @@ -126,73 +120,6 @@ func (c *ResultConverter) Convert(ctx context.Context, }, nil } -// handleSqlInput normalizes input DataFrames into a single dataframe with no labels for use with SQL expressions. -// -// It handles three cases: -// 1. If the input declares a supported time series or numeric kind in the wide or multi format (via FrameMeta.Type), it converts to a full-long formatted table using ConvertToFullLong. -// 2. If the input is a single frame (no labels, no declared type), it passes through as-is. -// 3. If the input has multiple frames or label metadata but lacks a supported type, it returns an error. -func handleSqlInput(dataFrames data.Frames) mathexp.Results { - var result mathexp.Results - - // dataframes len > 0 is checked in the caller -- Convert - first := dataFrames[0] - - // Single Frame no data case - // Note: In the case of a support Frame Type, we may want to return the matching schema - // with no rows (e.g. include the `__value__` column). But not sure about this at this time. - if len(dataFrames) == 1 && len(first.Fields) == 0 { - result.Values = mathexp.Values{ - mathexp.TableData{Frame: first}, - } - - return result - } - - var metaType data.FrameType - if first.Meta != nil { - metaType = first.Meta.Type - } - - if supportedToLongConversion(metaType) { - convertedFrames, err := ConvertToFullLong(dataFrames) - if err != nil { - result.Error = fmt.Errorf("failed to convert data frames to long format for SQL: %w", err) - } - - if len(convertedFrames) == 0 { - result.Error = fmt.Errorf("conversion succeeded but returned no frames") - return result - } - - result.Values = mathexp.Values{ - mathexp.TableData{Frame: convertedFrames[0]}, - } - - return result - } - - // If Meta.Type is not supported, but there are labels or more than 1 frame, fail fast - if len(dataFrames) > 1 { - result.Error = fmt.Errorf("response has more than one frame but frame type is missing or unsupported for sql conversion") - return result - } - for _, frame := range dataFrames { - for _, field := range frame.Fields { - if len(field.Labels) > 0 { - result.Error = fmt.Errorf("frame has labels but frame type is missing or unsupported for sql conversion") - return result - } - } - } - - // Can pass through as table without conversion - result.Values = mathexp.Values{ - mathexp.TableData{Frame: first}, - } - return result -} - func getResponseFrame(logger *log.ConcreteLogger, resp *backend.QueryDataResponse, refID string) (data.Frames, error) { response, ok := resp.Responses[refID] if !ok { diff --git a/pkg/expr/converter_test.go b/pkg/expr/converter_test.go index 15c45ebf0e2..692573728a7 100644 --- a/pkg/expr/converter_test.go +++ b/pkg/expr/converter_test.go @@ -41,7 +41,7 @@ func TestConvertDataFramesToResults(t *testing.T) { for _, dtype := range supported { t.Run(dtype, func(t *testing.T) { - resultType, res, err := converter.Convert(context.Background(), dtype, frames, false) + resultType, res, err := converter.Convert(context.Background(), dtype, frames) require.NoError(t, err) assert.Equal(t, "single frame series", resultType) require.Len(t, res.Values, 2) @@ -69,7 +69,7 @@ func TestConvertDataFramesToResults(t *testing.T) { for _, dtype := range supported { t.Run(dtype, func(t *testing.T) { - resultType, res, err := converter.Convert(context.Background(), dtype, frames, false) + resultType, res, err := converter.Convert(context.Background(), dtype, frames) require.NoError(t, err) assert.Equal(t, "multi frame series", resultType) require.Len(t, res.Values, 2) @@ -102,7 +102,7 @@ func TestConvertDataFramesToResults(t *testing.T) { for _, dtype := range supported { t.Run(dtype, func(t *testing.T) { - resultType, res, err := converter.Convert(context.Background(), dtype, frames, false) + resultType, res, err := converter.Convert(context.Background(), dtype, frames) require.NoError(t, err) assert.Equal(t, "multi frame series", resultType) require.Len(t, res.Values, 2) @@ -120,85 +120,3 @@ func TestConvertDataFramesToResults(t *testing.T) { }) }) } - -func TestHandleSqlInput(t *testing.T) { - tests := []struct { - name string - frames data.Frames - expectErr string - expectFrame bool - }{ - { - name: "single frame with no fields and no type is passed through", - frames: data.Frames{data.NewFrame("")}, - expectFrame: true, - }, - { - name: "single frame with no fields but type timeseries-multi is passed through", - frames: data.Frames{data.NewFrame("").SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti})}, - expectFrame: true, - }, - { - name: "single frame, no labels, no type → passes through", - frames: data.Frames{ - data.NewFrame("", - data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), - data.NewField("value", nil, []*float64{fp(2)}), - ), - }, - expectFrame: true, - }, - { - name: "single frame with labels, but missing FrameMeta.Type → error", - frames: data.Frames{ - data.NewFrame("", - data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), - data.NewField("value", data.Labels{"foo": "bar"}, []*float64{fp(2)}), - ), - }, - expectErr: "frame has labels but frame type is missing or unsupported", - }, - { - name: "multiple frames, no type → error", - frames: data.Frames{ - data.NewFrame("", - data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), - data.NewField("value", nil, []*float64{fp(2)}), - ), - data.NewFrame("", - data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), - data.NewField("value", nil, []*float64{fp(2)}), - ), - }, - expectErr: "response has more than one frame but frame type is missing or unsupported", - }, - { - name: "supported type (timeseries-multi) triggers ConvertToFullLong", - frames: data.Frames{ - data.NewFrame("", - data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), - data.NewField("value", data.Labels{"host": "a"}, []*float64{fp(2)}), - ).SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}), - }, - expectFrame: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - res := handleSqlInput(tc.frames) - - if tc.expectErr != "" { - require.Error(t, res.Error) - require.ErrorContains(t, res.Error, tc.expectErr) - } else { - require.NoError(t, res.Error) - if tc.expectFrame { - require.Len(t, res.Values, 1) - require.IsType(t, mathexp.TableData{}, res.Values[0]) - assert.NotNil(t, res.Values[0].(mathexp.TableData).Frame) - } - } - }) - } -} diff --git a/pkg/expr/errors.go b/pkg/expr/errors.go index c14e550f972..072a2d27f4f 100644 --- a/pkg/expr/errors.go +++ b/pkg/expr/errors.go @@ -3,8 +3,6 @@ package expr import ( "errors" "fmt" - "sort" - "strings" "github.com/grafana/grafana/pkg/apimachinery/errutil" ) @@ -95,31 +93,3 @@ func makeUnexpectedNodeTypeError(refID, nodeType string) error { return UnexpectedNodeTypeError.Build(data) } - -var DuplicateStringColumnError = errutil.NewBase( - errutil.StatusBadRequest, "sse.duplicateStringColumns").MustTemplate( - "your SQL query returned {{ .Public.count }} rows with duplicate values across the string columns, which is not allowed for alerting. Examples: ({{ .Public.examples }}). Hint: use GROUP BY or aggregation (e.g. MAX(), AVG()) to return one row per unique combination.", - errutil.WithPublic("SQL query returned duplicate combinations of string column values. Use GROUP BY or aggregation to return one row per combination."), -) - -func makeDuplicateStringColumnError(examples []string) error { - const limit = 5 - sort.Strings(examples) - exampleStr := strings.Join(truncateExamples(examples, limit), ", ") - - return DuplicateStringColumnError.Build(errutil.TemplateData{ - Public: map[string]any{ - "examples": exampleStr, - "count": len(examples), - }, - }) -} - -func truncateExamples(examples []string, limit int) []string { - if len(examples) <= limit { - return examples - } - truncated := examples[:limit] - truncated = append(truncated, fmt.Sprintf("... and %d more", len(examples)-limit)) - return truncated -} diff --git a/pkg/expr/graph.go b/pkg/expr/graph.go index ad60027d988..8e5fa8abea1 100644 --- a/pkg/expr/graph.go +++ b/pkg/expr/graph.go @@ -3,6 +3,7 @@ package expr import ( "context" "encoding/json" + "errors" "fmt" "slices" "time" @@ -13,6 +14,7 @@ import ( "gonum.org/v1/gonum/graph/topo" "github.com/grafana/grafana/pkg/expr/mathexp" + "github.com/grafana/grafana/pkg/expr/sql" "github.com/grafana/grafana/pkg/services/featuremgmt" ) @@ -48,6 +50,8 @@ type Node interface { RefID() string String() string NeedsVars() []string + SetInputTo(refID string) + IsInputTo() map[string]struct{} } type ExecutableNode interface { @@ -87,8 +91,26 @@ func (dp *DataPipeline) execute(c context.Context, now time.Time, s *Service) (m for _, neededVar := range node.NeedsVars() { if res, ok := vars[neededVar]; ok { if res.Error != nil { + var depErr error + // IF SQL expression dependency error + if node.NodeType() == TypeCMDNode && node.(*CMDNode).CMDType == TypeSQL { + e := sql.MakeSQLDependencyError(node.RefID(), neededVar) + + // although the SQL expression won't be executed, + // we track a dependency error on the metric. + eType := e.Category() + var errWithType *sql.ErrorWithCategory + if errors.As(res.Error, &errWithType) { + // If it is already SQL error with type (e.g. limit exceeded, input conversion, capture the type as that) + eType = errWithType.Category() + } + s.metrics.SqlCommandCount.WithLabelValues("error", eType) + depErr = e + } else { // general SSE dependency error + depErr = MakeDependencyError(node.RefID(), neededVar) + } errResult := mathexp.Results{ - Error: MakeDependencyError(node.RefID(), neededVar), + Error: depErr, } vars[node.RefID()] = errResult hasDepError = true @@ -202,7 +224,7 @@ func (s *Service) buildDependencyGraph(ctx context.Context, req *Request) (*simp registry := buildNodeRegistry(graph) - if err := buildGraphEdges(graph, registry); err != nil { + if err := s.buildGraphEdges(graph, registry); err != nil { return nil, err } @@ -311,7 +333,7 @@ func (s *Service) buildGraph(ctx context.Context, req *Request) (*simple.Directe } // buildGraphEdges generates graph edges based on each node's dependencies. -func buildGraphEdges(dp *simple.DirectedGraph, registry map[string]Node) error { +func (s *Service) buildGraphEdges(dp *simple.DirectedGraph, registry map[string]Node) error { nodeIt := dp.Nodes() for nodeIt.Next() { @@ -328,6 +350,14 @@ func buildGraphEdges(dp *simple.DirectedGraph, registry map[string]Node) error { for _, neededVar := range cmdNode.Command.NeedsVars() { neededNode, ok := registry[neededVar] if !ok { + if cmdNode.CMDType == TypeSQL { + // With the current flow, the SQL expression won't be executed with + // this missing dependency. But we collection the metric as there was an + // attempt to execute a SQL expression. + e := sql.MakeTableNotFoundError(cmdNode.refID, neededVar) + s.metrics.SqlCommandCount.WithLabelValues("error", e.Category()).Inc() + return e + } return fmt.Errorf("unable to find dependent node '%v'", neededVar) } @@ -365,6 +395,7 @@ func buildGraphEdges(dp *simple.DirectedGraph, registry map[string]Node) error { } edge := dp.NewEdge(neededNode, cmdNode) + neededNode.SetInputTo(cmdNode.RefID()) dp.SetEdge(edge) } diff --git a/pkg/expr/metrics/metrics.go b/pkg/expr/metrics/metrics.go index ee5c99ce405..8040b2ede91 100644 --- a/pkg/expr/metrics/metrics.go +++ b/pkg/expr/metrics/metrics.go @@ -12,6 +12,7 @@ type ExprMetrics struct { SqlCommandDuration *prometheus.HistogramVec SqlCommandCount *prometheus.CounterVec SqlCommandCellCount *prometheus.HistogramVec + SqlCommandInputCount *prometheus.CounterVec } func newExprMetrics(subsystem string) *ExprMetrics { @@ -46,8 +47,8 @@ func newExprMetrics(subsystem string) *ExprMetrics { Namespace: "grafana", Subsystem: subsystem, Name: "sql_command_count", - Help: "Total number of SQL command executions with a status label", - }, []string{"status"}), + Help: "Total number of SQL command executions with a status label and error_type for more detailed categorization of errors. When there is no error, error_type is 'none'. The two types of error_types that are unhandled are 'general_gms_error', and and 'unknown'", + }, []string{"status", "error_type"}), SqlCommandCellCount: prometheus.NewHistogramVec( prometheus.HistogramOpts{ @@ -59,6 +60,13 @@ func newExprMetrics(subsystem string) *ExprMetrics { }, []string{"status"}, ), + + SqlCommandInputCount: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "grafana", + Subsystem: subsystem, + Name: "sql_command_input_count", + Help: "Total number of inputs to the SQL command. Errors here are also counted in the sql_command_count metric but without the datasource_type and input_frame_type. The attempted_conversion label indicates if the input was converted from another format (e.g. from labeled time series) or passed through as a table. Since a single SQL expression can have multiple inputs, this can count higher than sql_command_count.", + }, []string{"status", "attempted_conversion", "datasource_type", "input_frame_type"}), } } @@ -76,6 +84,8 @@ func NewSSEMetrics(reg prometheus.Registerer) *ExprMetrics { SqlCommandCount: newExprMetrics(metricsSubSystem).SqlCommandCount, SqlCommandCellCount: newExprMetrics(metricsSubSystem).SqlCommandCellCount, + + SqlCommandInputCount: newExprMetrics(metricsSubSystem).SqlCommandInputCount, } if reg != nil { @@ -85,6 +95,7 @@ func NewSSEMetrics(reg prometheus.Registerer) *ExprMetrics { m.SqlCommandDuration, m.SqlCommandCount, m.SqlCommandCellCount, + m.SqlCommandInputCount, ) } @@ -105,6 +116,8 @@ func NewQueryServiceExpressionsMetrics(reg prometheus.Registerer) *ExprMetrics { SqlCommandCount: newExprMetrics(metricsSubSystem).SqlCommandCount, SqlCommandCellCount: newExprMetrics(metricsSubSystem).SqlCommandCellCount, + + SqlCommandInputCount: newExprMetrics(metricsSubSystem).SqlCommandInputCount, } if reg != nil { @@ -114,6 +127,7 @@ func NewQueryServiceExpressionsMetrics(reg prometheus.Registerer) *ExprMetrics { m.SqlCommandDuration, m.SqlCommandCount, m.SqlCommandCellCount, + m.SqlCommandInputCount, ) } diff --git a/pkg/expr/ml.go b/pkg/expr/ml.go index bee5d18ad22..54fdbc798a9 100644 --- a/pkg/expr/ml.go +++ b/pkg/expr/ml.go @@ -130,7 +130,7 @@ func (m *MLNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s * } // process the response the same way DSNode does. Use plugin ID as data source type. Semantically, they are the same. - responseType, result, err = s.converter.Convert(ctx, mlPluginID, dataFrames, false) + responseType, result, err = s.converter.Convert(ctx, mlPluginID, dataFrames) return result, err } diff --git a/pkg/expr/nodes.go b/pkg/expr/nodes.go index 85ce5d533ad..2ea0e36c40e 100644 --- a/pkg/expr/nodes.go +++ b/pkg/expr/nodes.go @@ -32,8 +32,9 @@ var ( // baseNode includes common properties used across DPNodes. type baseNode struct { - id int64 - refID string + id int64 + refID string + isInputTo map[string]struct{} } type rawNode struct { @@ -91,6 +92,17 @@ func (b *baseNode) RefID() string { return b.refID } +func (b *baseNode) SetInputTo(refID string) { + if b.isInputTo == nil { + b.isInputTo = make(map[string]struct{}) + } + b.isInputTo[refID] = struct{}{} +} + +func (b *baseNode) IsInputTo() map[string]struct{} { + return b.isInputTo +} + // NodeType returns the data pipeline node type. func (gn *CMDNode) NodeType() NodeType { return TypeCMDNode @@ -274,31 +286,23 @@ func executeDSNodesGrouped(ctx context.Context, now time.Time, vars mathexp.Vars func() { ctx, span := s.tracer.Start(ctx, "SSE.ExecuteDatasourceQuery") defer span.End() - firstNode := nodeGroup[0] - pCtx, err := s.pCtxProvider.GetWithDataSource(ctx, firstNode.datasource.Type, firstNode.request.User, firstNode.datasource) - if err != nil { - for _, dn := range nodeGroup { - vars[dn.refID] = mathexp.Results{Error: datasources.ErrDataSourceNotFound} - } - return - } + firstNode := nodeGroup[0] logger := logger.FromContext(ctx).New("datasourceType", firstNode.datasource.Type, "queryRefId", firstNode.refID, "datasourceUid", firstNode.datasource.UID, "datasourceVersion", firstNode.datasource.Version, ) - span.SetAttributes( attribute.String("datasource.type", firstNode.datasource.Type), attribute.String("datasource.uid", firstNode.datasource.UID), ) req := &backend.QueryDataRequest{ - PluginContext: pCtx, - Headers: firstNode.request.Headers, + Headers: firstNode.request.Headers, } + // add all the queries from the node group to the request for _, dn := range nodeGroup { req.Queries = append(req.Queries, backend.DataQuery{ RefID: dn.refID, @@ -324,15 +328,48 @@ func executeDSNodesGrouped(ctx context.Context, now time.Time, vars mathexp.Vars s.metrics.DSRequests.WithLabelValues(respStatus, fmt.Sprintf("%t", useDataplane), firstNode.datasource.Type).Inc() } - resp, err := s.dataService.QueryData(ctx, req) + var resp *backend.QueryDataResponse + + // get the new client if it exists + qsDSClient, ok, err := s.qsDatasourceClientBuilder.BuildClient(firstNode.datasource.Type, firstNode.datasource.UID) if err != nil { for _, dn := range nodeGroup { - vars[dn.refID] = mathexp.Results{Error: MakeQueryError(firstNode.refID, firstNode.datasource.UID, err)} + vars[dn.refID] = mathexp.Results{Error: datasources.ErrDataSourceNotFound} } instrument(err, "") return } + var queryErr error + if !ok { // legacy flow + pCtx, err := s.pCtxProvider.GetWithDataSource(ctx, firstNode.datasource.Type, firstNode.request.User, firstNode.datasource) + if err != nil { + for _, dn := range nodeGroup { + vars[dn.refID] = mathexp.Results{Error: datasources.ErrDataSourceNotFound} + } + return + } + req.PluginContext = pCtx + resp, queryErr = s.dataService.QueryData(ctx, req) + } else { // new query service flow + k8sReq, err := ConvertBackendRequestToDataRequest(req) + if err != nil { + for _, dn := range nodeGroup { + vars[dn.refID] = mathexp.Results{Error: datasources.ErrDataSourceNotFound} + } + return + } + + resp, queryErr = qsDSClient.QueryData(ctx, *k8sReq) + } + + if queryErr != nil { + for _, dn := range nodeGroup { + vars[dn.refID] = mathexp.Results{Error: MakeQueryError(firstNode.refID, firstNode.datasource.UID, queryErr)} + } + instrument(queryErr, "") + return + } for _, dn := range nodeGroup { dataFrames, err := getResponseFrame(logger, resp, dn.refID) if err != nil { @@ -342,7 +379,7 @@ func executeDSNodesGrouped(ctx context.Context, now time.Time, vars mathexp.Vars } var result mathexp.Results - responseType, result, err := s.converter.Convert(ctx, dn.datasource.Type, dataFrames, dn.isInputToSQLExpr) + responseType, result, err := s.converter.Convert(ctx, dn.datasource.Type, dataFrames) if err != nil { result.Error = makeConversionError(dn.RefID(), err) } @@ -432,10 +469,22 @@ func (dn *DSNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s var result mathexp.Results - responseType, result, err = s.converter.Convert(ctx, dn.datasource.Type, dataFrames, dn.isInputToSQLExpr) + if dn.isInputToSQLExpr { + var converted bool + dataType := categorizeFrameInputType(dataFrames) - if err != nil { - err = makeConversionError(dn.refID, err) + result, converted = handleSqlInput(ctx, s.tracer, dn.RefID(), dn.IsInputTo(), dn.datasource.Type, dataFrames) + status := "ok" + if result.Error != nil { + status = "error" + } + s.metrics.SqlCommandInputCount.WithLabelValues(status, fmt.Sprintf("%t", converted), dn.datasource.Type, dataType).Inc() + } else { + responseType, result, err = s.converter.Convert(ctx, dn.datasource.Type, dataFrames) + if err != nil { + err = makeConversionError(dn.refID, err) + } } + return result, err } diff --git a/pkg/expr/reader.go b/pkg/expr/reader.go index 5c85b9d61fb..ddabbf3cb04 100644 --- a/pkg/expr/reader.go +++ b/pkg/expr/reader.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter" data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" @@ -137,7 +138,8 @@ func (h *ExpressionQueryReader) ReadQuery( eq.Properties = q // TODO: Cascade limit from Grafana config in this (new Expression Parser) branch of the code cellLimit := 0 // zero means no limit - eq.Command, err = NewSQLCommand(ctx, common.RefID, q.Format, q.Expression, int64(cellLimit), 0, 0) + sqlLogger := backend.NewLoggerWith("logger", SQLLoggerName).FromContext(ctx) + eq.Command, err = NewSQLCommand(ctx, sqlLogger, common.RefID, q.Format, q.Expression, int64(cellLimit), 0, 0) } case QueryTypeThreshold: diff --git a/pkg/expr/service_sql_test.go b/pkg/expr/service_sql_test.go index 714c1b98d7b..d65702a9d45 100644 --- a/pkg/expr/service_sql_test.go +++ b/pkg/expr/service_sql_test.go @@ -90,7 +90,7 @@ func TestSQLService(t *testing.T) { require.NoError(t, err) require.Error(t, rsp.Responses["B"].Error, "should return invalid sql error") - require.ErrorContains(t, rsp.Responses["B"].Error, "blocked function load_file") + require.ErrorContains(t, rsp.Responses["B"].Error, "not in the allowed list of") }) t.Run("parse error should be returned", func(t *testing.T) { @@ -110,3 +110,93 @@ func TestSQLService(t *testing.T) { require.ErrorContains(t, rsp.Responses["B"].Error, "limit expression expected to be numeric") }) } + +func TestSQLServiceErrors(t *testing.T) { + tsMulti := data.NewFrame("", + data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), + data.NewField("value", data.Labels{"testLabelKey": "testLabelValue"}, []*float64{fp(2)}), + ).SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}) + + tsMultiNoType := data.NewFrame("", + data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), + data.NewField("value", data.Labels{"testLabelKey": "testLabelValue"}, []*float64{fp(2)}), + ) + + resp := map[string]backend.DataResponse{ + "tsMulti": {Frames: data.Frames{tsMulti}}, + "tsMultiNoType": {Frames: data.Frames{tsMultiNoType}}, + } + + newABSQLQueries := func(q string) []Query { + escaped, err := json.Marshal(q) + require.NoError(t, err) + return []Query{ + { + RefID: "tsMulti", + DataSource: &datasources.DataSource{ + OrgID: 1, + UID: "test", + Type: "test", + }, + JSON: json.RawMessage(`{ "datasource": { "uid": "1" }, "intervalMs": 1000, "maxDataPoints": 1000 }`), + TimeRange: AbsoluteTimeRange{ + From: time.Time{}, + To: time.Time{}, + }, + }, + { + RefID: "tsMultiNoType", + DataSource: &datasources.DataSource{ + OrgID: 1, + UID: "test", + Type: "test", + }, + JSON: json.RawMessage(`{ "datasource": { "uid": "1" }, "intervalMs": 1000, "maxDataPoints": 1000 }`), + TimeRange: AbsoluteTimeRange{ + From: time.Time{}, + To: time.Time{}, + }, + }, + { + RefID: "sqlExpression", + DataSource: dataSourceModel(), + JSON: json.RawMessage(fmt.Sprintf(`{ "datasource": { "uid": "__expr__", "type": "__expr__"}, "type": "sql", "expression": %s }`, escaped)), + TimeRange: AbsoluteTimeRange{ + From: time.Time{}, + To: time.Time{}, + }, + }, + } + } + + t.Run("conversion failure (and therefore dependency error)", func(t *testing.T) { + s, req := newMockQueryService(resp, + newABSQLQueries(`SELECT * FROM tsMultiNoType`), + ) + + s.features = featuremgmt.WithFeatures(featuremgmt.FlagSqlExpressions) + + pl, err := s.BuildPipeline(t.Context(), req) + require.NoError(t, err) + + rsp, err := s.ExecutePipeline(context.Background(), time.Now(), pl) + require.NoError(t, err) + + require.Error(t, rsp.Responses["tsMultiNoType"].Error, "should return conversion error on DS response") + require.ErrorContains(t, rsp.Responses["tsMultiNoType"].Error, "missing the data type") + + require.Error(t, rsp.Responses["sqlExpression"].Error, "should return dependency error") + require.ErrorContains(t, rsp.Responses["sqlExpression"].Error, "dependency") + }) + + t.Run("pipeline (expressions and DS queries) will fail if the table is not found, before execution of the sql expression", func(t *testing.T) { + s, req := newMockQueryService(resp, + newABSQLQueries(`SELECT * FROM nonExisting`), + ) + + s.features = featuremgmt.WithFeatures(featuremgmt.FlagSqlExpressions) + + _, err := s.BuildPipeline(t.Context(), req) + require.Error(t, err, "whole pipeline fails when selecting a dependency that does not exist") + }) +} diff --git a/pkg/expr/sql/db.go b/pkg/expr/sql/db.go index d8a8934f81e..8d98e3dea51 100644 --- a/pkg/expr/sql/db.go +++ b/pkg/expr/sql/db.go @@ -19,42 +19,6 @@ import ( // DB is a database that can execute SQL queries against a set of Frames. type DB struct{} -// GoMySQLServerError represents an error from the underlying Go MySQL Server -type GoMySQLServerError struct { - Err error -} - -// Error implements the error interface -func (e *GoMySQLServerError) Error() string { - return fmt.Sprintf("error in go-mysql-server: %v", e.Err) -} - -// Unwrap provides the original error for errors.Is/As -func (e *GoMySQLServerError) Unwrap() error { - return e.Err -} - -// WrapGoMySQLServerError wraps errors from Go MySQL Server with additional context -func WrapGoMySQLServerError(err error) error { - // Don't wrap nil errors - if err == nil { - return nil - } - - // Check if it's a function not found error or other specific GMS errors - if isFunctionNotFoundError(err) { - return &GoMySQLServerError{Err: err} - } - - // Return original error if it's not one we want to wrap - return err -} - -// isFunctionNotFoundError checks if the error is related to a function not being found -func isFunctionNotFoundError(err error) bool { - return mysql.ErrFunctionNotFound.Is(err) -} - type QueryOption func(*QueryOptions) type QueryOptions struct { @@ -80,7 +44,7 @@ func WithMaxOutputCells(n int64) QueryOption { // The name becomes the name and RefID of the returned frame. func (db *DB) QueryFrames(ctx context.Context, tracer tracing.Tracer, name string, query string, frames []*data.Frame, opts ...QueryOption) (*data.Frame, error) { // We are parsing twice due to TablesList, but don't care fow now. We can save the parsed query and reuse it later if we want. - if allow, err := AllowQuery(query); err != nil || !allow { + if allow, err := AllowQuery(name, query); err != nil || !allow { if err != nil { return nil, err } @@ -122,9 +86,9 @@ func (db *DB) QueryFrames(ctx context.Context, tracer tracing.Tracer, name strin contextErr := func(err error) error { switch { case errors.Is(err, context.DeadlineExceeded): - return fmt.Errorf("SQL expression for refId %v did not complete within the timeout of %v: %w", name, QueryOptions.Timeout, err) + return MakeTimeOutError(err, name, QueryOptions.Timeout) case errors.Is(err, context.Canceled): - return fmt.Errorf("SQL expression for refId %v was cancelled before it completed: %w", name, err) + return MakeCancelError(err, name) default: return fmt.Errorf("SQL expression for refId %v ended unexpectedly: %w", name, err) } @@ -136,7 +100,7 @@ func (db *DB) QueryFrames(ctx context.Context, tracer tracing.Tracer, name strin if ctx.Err() != nil { return nil, contextErr(ctx.Err()) } - return nil, WrapGoMySQLServerError(err) + return nil, MakeGMSError(name, err) } // Convert the iterator into a Grafana data.Frame diff --git a/pkg/expr/sql/db_test.go b/pkg/expr/sql/db_test.go index 5eb9cb069c8..ac4448ed1b5 100644 --- a/pkg/expr/sql/db_test.go +++ b/pkg/expr/sql/db_test.go @@ -206,7 +206,7 @@ func TestErrorsFromGoMySQLServerAreFlagged(t *testing.T) { _, err := db.QueryFrames(context.Background(), &testTracer{}, "sqlExpressionRefId", query, nil) require.Error(t, err) - require.Contains(t, err.Error(), "error in go-mysql-server") + require.Contains(t, err.Error(), "error from the sql expression engine") } func TestFrameToSQLAndBack_JSONRoundtrip(t *testing.T) { @@ -308,7 +308,7 @@ func TestQueryFrames_Limits(t *testing.T) { CROSS JOIN (SELECT 1 AS val UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5) b `, opts: []QueryOption{WithTimeout(1 * time.Nanosecond)}, - expectError: "did not complete within the timeout", + expectError: "timed out", }, } diff --git a/pkg/expr/sql/errors.go b/pkg/expr/sql/errors.go new file mode 100644 index 00000000000..24953ff8d30 --- /dev/null +++ b/pkg/expr/sql/errors.go @@ -0,0 +1,391 @@ +package sql + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" + + mysql "github.com/dolthub/go-mysql-server/sql" + "github.com/grafana/grafana/pkg/apimachinery/errutil" +) + +const sseErrBase = "sse.sql." + +// GoMySQLServerError represents an error from the underlying Go MySQL Server +type GoMySQLServerError struct { + err error + category string +} + +// CategorizedError is an Error with a Category string for use with metrics, logs, and traces. +type CategorizedError interface { + error + Category() string +} + +// ErrorWithCategory is a concrete implementation of CategorizedError that holds an error and its category. +type ErrorWithCategory struct { + category string + err error +} + +func (e *ErrorWithCategory) Error() string { + return e.err.Error() +} + +func (e *ErrorWithCategory) Category() string { + return e.category +} + +// Unwrap provides the original error for errors.Is/As +func (e *ErrorWithCategory) Unwrap() error { + return e.err +} + +// Error implements the error interface +func (e *GoMySQLServerError) Error() string { + return e.err.Error() +} + +// Unwrap provides the original error for errors.Is/As +func (e *GoMySQLServerError) Unwrap() error { + return e.err +} + +func (e *GoMySQLServerError) Category() string { + return e.category +} + +// MakeGMSError creates a GoMySQLServerError with the given refID and error. +// It also used to wrap GMS errors into a GeneralGMSError or specific CategorizedError. +func MakeGMSError(refID string, err error) error { + err = WrapGoMySQLServerError(refID, err) + + gmsError := &GoMySQLServerError{} + if errors.As(err, &gmsError) { + return MakeGeneralGMSError(gmsError, refID) + } + + return err +} + +const ErrCategoryGMSFunctionNotFound = "gms_function_not_found" +const ErrCategoryGMSTableNotFound = "gms_table_not_found" + +// WrapGoMySQLServerError wraps errors from Go MySQL Server with additional context +// and a category. +func WrapGoMySQLServerError(refID string, err error) error { + // Don't wrap nil errors + if err == nil { + return nil + } + + switch { + case mysql.ErrFunctionNotFound.Is(err): + return &GoMySQLServerError{err: err, category: ErrCategoryGMSFunctionNotFound} + case mysql.ErrTableNotFound.Is(err): + // This is different from the TableNotFoundError, which is used when the engine can't find the dependency before it gets to the SQL engine. + return &GoMySQLServerError{err: err, category: ErrCategoryGMSTableNotFound} + case mysql.ErrColumnNotFound.Is(err): + return MakeColumnNotFoundError(refID, err) + default: + // For all other errors, wrap them as a general GMS error + return MakeGeneralGMSError(&GoMySQLServerError{ + err: err, + category: ErrCategoryGeneralGMSError, + }, refID) + } +} + +const ErrCategoryGeneralGMSError = "general_gms_error" + +var generalGMSErrorStr = "sql expression failed due to error from the sql expression engine: {{ .Error }}" + +var GeneralGMSError = errutil.NewBase( + errutil.StatusBadRequest, sseErrBase+ErrCategoryGeneralGMSError).MustTemplate( + generalGMSErrorStr, + errutil.WithPublic(generalGMSErrorStr)) + +// MakeGeneralGMSError is for errors returned from the GMS engine that we have not make a more specific error for. +func MakeGeneralGMSError(err *GoMySQLServerError, refID string) CategorizedError { + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "refId": refID, + }, + Error: err, + } + + return &ErrorWithCategory{category: err.Category(), err: GeneralGMSError.Build(data)} +} + +const ErrCategoryInputLimitExceeded = "input_limit_exceeded" + +var inputLimitExceededStr = "sql expression [{{ .Public.refId }}] was not run because the number of input cells (columns*rows) to the sql expression exceeded the configured limit of {{ .Public.inputLimit }}" + +var InputLimitExceededError = errutil.NewBase( + errutil.StatusBadRequest, sseErrBase+ErrCategoryInputLimitExceeded).MustTemplate( + inputLimitExceededStr, + errutil.WithPublic(inputLimitExceededStr)) + +func MakeInputLimitExceededError(refID string, inputLimit int64) CategorizedError { + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "refId": refID, + "inputLimit": inputLimit, + }, + } + + return &ErrorWithCategory{category: ErrCategoryInputLimitExceeded, err: InputLimitExceededError.Build(data)} +} + +const ErrCategoryDuplicateStringColumns = "duplicate_string_columns" + +var duplicateStringColumnErrorStr = "sql expression [{{ .Public.refId }}] failed because it returned duplicate values across the string columns, which is not allowed for alerting. Examples: ({{ .Public.examples }}). Hint: use GROUP BY or aggregation (e.g. MAX(), AVG()) to return one row per unique combination." + +var DuplicateStringColumnError = errutil.NewBase( + errutil.StatusBadRequest, sseErrBase+ErrCategoryDuplicateStringColumns).MustTemplate( + duplicateStringColumnErrorStr, + errutil.WithPublic(duplicateStringColumnErrorStr), +) + +func MakeDuplicateStringColumnError(examples []string) CategorizedError { + const limit = 5 + sort.Strings(examples) + exampleStr := strings.Join(truncateExamples(examples, limit), ", ") + + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "examples": exampleStr, + "count": len(examples), + }, + } + + return &ErrorWithCategory{ + category: ErrCategoryDuplicateStringColumns, + err: DuplicateStringColumnError.Build(data), + } +} + +func truncateExamples(examples []string, limit int) []string { + if len(examples) <= limit { + return examples + } + truncated := examples[:limit] + truncated = append(truncated, fmt.Sprintf("... and %d more", len(examples)-limit)) + return truncated +} + +const ErrCategoryTimeout = "timeout" + +var timeoutStr = "sql expression [{{ .Public.refId }}] timed out after {{ .Public.timeout }}" + +var TimeoutError = errutil.NewBase( + errutil.StatusTimeout, sseErrBase+ErrCategoryTimeout).MustTemplate( + timeoutStr, + errutil.WithPublic(timeoutStr)) + +// MakeTimeOutError creates an error for when a query times out because it took longer that the configured timeout. +func MakeTimeOutError(err error, refID string, timeout time.Duration) CategorizedError { + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "refId": refID, + "timeout": timeout.String(), + }, + + Error: err, + } + + return &ErrorWithCategory{category: ErrCategoryTimeout, err: TimeoutError.Build(data)} +} + +var ErrCategoryCancelled = "cancelled" + +var cancelStr = "sql expression [{{ .Public.refId }}] was cancelled before completion" + +var CancelError = errutil.NewBase( + errutil.StatusClientClosedRequest, sseErrBase+ErrCategoryCancelled).MustTemplate( + cancelStr, + errutil.WithPublic(cancelStr)) + +// MakeCancelError creates an error for when a query is cancelled before completion. +// Users won't see this error in the browser, rather an empty response when the browser cancels the connection. +func MakeCancelError(err error, refID string) CategorizedError { + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "refId": refID, + }, + + Error: err, + } + + return &ErrorWithCategory{category: ErrCategoryCancelled, err: CancelError.Build(data)} +} + +var ErrCategoryTableNotFound = "table_not_found" + +var tableNotFoundStr = "failed to run sql expression [{{ .Public.refId }}] because it selects from table (refId/query) [{{ .Public.table }}] and that table was not found" + +var TableNotFoundError = errutil.NewBase( + errutil.StatusBadRequest, sseErrBase+ErrCategoryTableNotFound).MustTemplate( + tableNotFoundStr, + errutil.WithPublic(tableNotFoundStr)) + +// MakeTableNotFoundError creates an error for when a referenced table +// does not exist. +func MakeTableNotFoundError(refID, table string) CategorizedError { + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "refId": refID, + "table": table, + }, + + Error: fmt.Errorf("sql expression [%s] failed: table (refId)'%s' not found", refID, table), + } + + return &ErrorWithCategory{category: ErrCategoryTableNotFound, err: TableNotFoundError.Build(data)} +} + +const ErrCategoryDependency = "failed_dependency" + +var sqlDepErrStr = "could not run sql expression [{{ .Public.refId }}] because it selects from the results of query [{{.Public.depRefId }}] which has an error" + +var DependencyError = errutil.NewBase( + errutil.StatusBadRequest, sseErrBase+ErrCategoryDependency).MustTemplate( + sqlDepErrStr, + errutil.WithPublic(sqlDepErrStr)) + +func MakeSQLDependencyError(refID, depRefID string) CategorizedError { + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "refId": refID, + "depRefId": depRefID, + }, + Error: fmt.Errorf("could not run sql expression %v because it selects from the results of query %v which has an error", refID, depRefID), + } + + return &ErrorWithCategory{category: ErrCategoryDependency, err: DependencyError.Build(data)} +} + +const ErrCategoryInputConversion = "input_conversion" + +var sqlInputConvertErrorStr = "failed to convert the results of query [{{.Public.refId}}] (Datasource Type: [{{.Public.dsType}}]) into a SQL/Tabular format for sql expression {{ .Public.forRefID }}: {{ .Error }}" + +var InputConvertError = errutil.NewBase( + errutil.StatusBadRequest, sseErrBase+ErrCategoryInputConversion).MustTemplate( + sqlInputConvertErrorStr, + errutil.WithPublic(sqlInputConvertErrorStr)) + +// MakeInputConvertError creates an error for when the input conversion to a table for a SQL expressions fails. +func MakeInputConvertError(err error, refID string, forRefIDs map[string]struct{}, dsType string) CategorizedError { + forRefIdsSlice := make([]string, 0, len(forRefIDs)) + for k := range forRefIDs { + forRefIdsSlice = append(forRefIdsSlice, k) + } + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "refId": refID, + "forRefID": forRefIdsSlice, + "dsType": dsType, + }, + Error: err, + } + + return &ErrorWithCategory{category: ErrCategoryInputConversion, err: InputConvertError.Build(data)} +} + +const ErrCategoryEmptyQuery = "empty_query" + +var errEmptyQueryString = "sql expression [{{.Public.refId}}] failed because it has an empty SQL query" + +var ErrEmptySQLQuery = errutil.NewBase( + errutil.StatusBadRequest, sseErrBase+ErrCategoryEmptyQuery).MustTemplate( + errEmptyQueryString, + errutil.WithPublic(errEmptyQueryString)) + +// MakeTableNotFoundError creates an error for when a referenced table +// does not exist. +func MakeErrEmptyQuery(refID string) CategorizedError { + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "refId": refID, + }, + + Error: fmt.Errorf("sql expression [%s] failed because it has an empty SQL query", refID), + } + + return &ErrorWithCategory{category: ErrCategoryEmptyQuery, err: ErrEmptySQLQuery.Build(data)} +} + +const ErrCategoryInvalidQuery = "invalid_query" + +var invalidQueryStr = "sql expression [{{.Public.refId}}] failed because it has an invalid SQL query: {{ .Public.error }}" + +var ErrInvalidQuery = errutil.NewBase( + errutil.StatusBadRequest, sseErrBase+ErrCategoryInvalidQuery).MustTemplate( + invalidQueryStr, + errutil.WithPublic(invalidQueryStr)) + +func MakeErrInvalidQuery(refID string, err error) CategorizedError { + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "refId": refID, + "error": err.Error(), + }, + + Error: fmt.Errorf("sql expression [%s] failed because it has an invalid SQL query: %w", refID, err), + } + + return &ErrorWithCategory{category: ErrCategoryInvalidQuery, err: ErrInvalidQuery.Build(data)} +} + +var ErrCategoryBlockedNodeOrFunc = "blocked_node_or_func" + +var blockedNodeOrFuncStr = "did not execute the SQL expression {{.Public.refId}} because the sql {{.Public.tokenType}} '{{.Public.token}}' is not in the allowed list of {{.Public.tokenType}}s" + +var BlockedNodeOrFuncError = errutil.NewBase( + errutil.StatusBadRequest, sseErrBase+ErrCategoryBlockedNodeOrFunc).MustTemplate( + blockedNodeOrFuncStr, + errutil.WithPublic(blockedNodeOrFuncStr)) + +// MakeBlockedNodeOrFuncError creates an error for when a sql function or keyword is not allowed. +func MakeBlockedNodeOrFuncError(refID, token string, isFunction bool) CategorizedError { + tokenType := "keyword" + if isFunction { + tokenType = "function" + } + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "refId": refID, + "token": token, + "tokenType": tokenType, + }, + + Error: fmt.Errorf("sql expression [%s] failed because the sql function or keyword '%s' is not in the allowed list of keywords and functions", refID, token), + } + + return &ErrorWithCategory{category: ErrCategoryBlockedNodeOrFunc, err: BlockedNodeOrFuncError.Build(data)} +} + +const ErrCategoryColumnNotFound = "column_not_found" + +var columnNotFoundStr = `sql expression [{{.Public.refId}}] failed because it selects from a column (refId/query) that does not exist: {{ .Error }}. +If this happens on a previously working query, it might mean that the query has returned no data, or the resulting schema of the query has changed.` + +var ColumnNotFoundError = errutil.NewBase( + errutil.StatusBadRequest, sseErrBase+ErrCategoryColumnNotFound).MustTemplate( + columnNotFoundStr, + errutil.WithPublic(columnNotFoundStr)) + +func MakeColumnNotFoundError(refID string, err error) CategorizedError { + data := errutil.TemplateData{ + Public: map[string]interface{}{ + "refId": refID, + }, + + Error: err, + } + + return &ErrorWithCategory{category: ErrCategoryColumnNotFound, err: ColumnNotFoundError.Build(data)} +} diff --git a/pkg/expr/sql/parser_allow.go b/pkg/expr/sql/parser_allow.go index ad04529ba86..6f8cfc1d063 100644 --- a/pkg/expr/sql/parser_allow.go +++ b/pkg/expr/sql/parser_allow.go @@ -1,6 +1,7 @@ package sql import ( + "errors" "fmt" "strings" @@ -9,7 +10,7 @@ import ( // AllowQuery parses the query and checks it against an allow list of allowed SQL nodes // and functions. -func AllowQuery(rawSQL string) (bool, error) { +func AllowQuery(refID, rawSQL string) (bool, error) { s, err := sqlparser.Parse(rawSQL) if err != nil { return false, fmt.Errorf("error parsing sql: %s", err.Error()) @@ -19,15 +20,19 @@ func AllowQuery(rawSQL string) (bool, error) { err := sqlparser.Walk(func(node sqlparser.SQLNode) (bool, error) { if !allowedNode(node) { if fT, ok := node.(*sqlparser.FuncExpr); ok { - return false, fmt.Errorf("blocked function %s - not supported in queries", fT.Name) + return false, MakeBlockedNodeOrFuncError(refID, fT.Name.String(), true) } - return false, fmt.Errorf("blocked node %T - not supported in queries", node) + return false, MakeBlockedNodeOrFuncError(refID, fmt.Sprintf("%T", node), false) } return true, nil }, node) if err != nil { - return fmt.Errorf("failed to parse SQL expression: %w", err) + var bn *ErrorWithCategory + if !errors.As(err, &bn) { + return fmt.Errorf("failed to parse SQL expression: %w", err) + } + return err } return nil diff --git a/pkg/expr/sql/parser_allow_test.go b/pkg/expr/sql/parser_allow_test.go index f03b3ff2cd1..9a36b3ad8e8 100644 --- a/pkg/expr/sql/parser_allow_test.go +++ b/pkg/expr/sql/parser_allow_test.go @@ -95,7 +95,7 @@ func TestAllowQuery(t *testing.T) { } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - _, err := AllowQuery(tc.q) + _, err := AllowQuery("A", tc.q) if tc.err != nil { require.Error(t, err) } else { diff --git a/pkg/expr/sql_command.go b/pkg/expr/sql_command.go index 016b1c87f5e..8a93400a9f8 100644 --- a/pkg/expr/sql_command.go +++ b/pkg/expr/sql_command.go @@ -8,9 +8,12 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" + semconv "go.opentelemetry.io/otel/semconv/v1.17.0" + "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/apimachinery/errutil" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana/pkg/expr/mathexp" "github.com/grafana/grafana/pkg/expr/metrics" "github.com/grafana/grafana/pkg/expr/sql" @@ -18,15 +21,7 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -var ( - ErrMissingSQLQuery = errutil.BadRequest("sql-missing-query").Errorf("missing SQL query") - ErrInvalidSQLQuery = errutil.BadRequest("sql-invalid-sql").MustTemplate( - "invalid SQL query: {{ .Private.query }} err: {{ .Error }}", - errutil.WithPublic( - "Invalid SQL query: {{ .Public.error }}", - ), - ) -) +const SQLLoggerName = "expr.sql" // SQLCommand is an expression to run SQL over results type SQLCommand struct { @@ -39,31 +34,25 @@ type SQLCommand struct { inputLimit int64 outputLimit int64 timeout time.Duration + logger log.Logger } // NewSQLCommand creates a new SQLCommand. -func NewSQLCommand(ctx context.Context, refID, format, rawSQL string, intputLimit, outputLimit int64, timeout time.Duration) (*SQLCommand, error) { +func NewSQLCommand(ctx context.Context, logger log.Logger, refID, format, rawSQL string, intputLimit, outputLimit int64, timeout time.Duration) (*SQLCommand, error) { + sqlLogger := backend.NewLoggerWith("logger", SQLLoggerName).FromContext(ctx) if rawSQL == "" { - return nil, ErrMissingSQLQuery + return nil, sql.MakeErrEmptyQuery(refID) } tables, err := sql.TablesList(ctx, rawSQL) if err != nil { - logger.Warn("invalid sql query", "sql", rawSQL, "error", err) - return nil, ErrInvalidSQLQuery.Build(errutil.TemplateData{ - Error: err, - Public: map[string]any{ - "error": err.Error(), - }, - Private: map[string]any{ - "query": rawSQL, - }, - }) + sqlLogger.Warn("invalid sql query", "sql", rawSQL, "error", err) + return nil, sql.MakeErrInvalidQuery(refID, err) } if len(tables) == 0 { - logger.Warn("no tables found in SQL query", "sql", rawSQL) + sqlLogger.Warn("no tables found in SQL query", "sql", rawSQL) } if tables != nil { - logger.Debug("REF tables", "tables", tables, "sql", rawSQL) + sqlLogger.Debug("REF tables", "tables", tables, "sql", rawSQL) } return &SQLCommand{ @@ -74,14 +63,15 @@ func NewSQLCommand(ctx context.Context, refID, format, rawSQL string, intputLimi outputLimit: outputLimit, timeout: timeout, format: format, + logger: sqlLogger, }, nil } // UnmarshalSQLCommand creates a SQLCommand from Grafana's frontend query. func UnmarshalSQLCommand(ctx context.Context, rn *rawNode, cfg *setting.Cfg) (*SQLCommand, error) { - sqlLogger := backend.NewLoggerWith("logger", "expr.sql").FromContext(ctx) + sqlLogger := backend.NewLoggerWith("logger", SQLLoggerName).FromContext(ctx) if rn.TimeRange == nil { - logger.Error("time range must be specified for refID", "refID", rn.RefID) + sqlLogger.Error("time range must be specified for refID", "refID", rn.RefID) return nil, fmt.Errorf("time range must be specified for refID %s", rn.RefID) } @@ -99,7 +89,7 @@ func UnmarshalSQLCommand(ctx context.Context, rn *rawNode, cfg *setting.Cfg) (*S formatRaw := rn.Query["format"] format, _ := formatRaw.(string) - return NewSQLCommand(ctx, rn.RefID, format, expression, cfg.SQLExpressionCellLimit, cfg.SQLExpressionOutputCellLimit, cfg.SQLExpressionTimeout) + return NewSQLCommand(ctx, sqlLogger, rn.RefID, format, expression, cfg.SQLExpressionCellLimit, cfg.SQLExpressionOutputCellLimit, cfg.SQLExpressionTimeout) } // NeedsVars returns the variable names (refIds) that are dependencies @@ -113,23 +103,32 @@ func (gr *SQLCommand) NeedsVars() []string { func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer, metrics *metrics.ExprMetrics) (mathexp.Results, error) { _, span := tracer.Start(ctx, "SSE.ExecuteSQL") start := time.Now() - sqlLogger := backend.NewLoggerWith("logger", "expr.sql").FromContext(ctx) tc := int64(0) rsp := mathexp.Results{} + errorType := "none" defer func() { duration := float64(time.Since(start).Milliseconds()) - statusLabel := "ok" if rsp.Error != nil { + e := &sql.ErrorWithCategory{} + if errors.As(rsp.Error, &e) { + errorType = e.Category() + } else { + errorType = "unknown" + } statusLabel = "error" - span.RecordError(rsp.Error) - span.SetStatus(codes.Error, rsp.Error.Error()) - sqlLogger.Error("SQL command execution failed", "error", rsp.Error.Error()) + span.AddEvent("exception", trace.WithAttributes( + semconv.ExceptionType(errorType), + semconv.ExceptionMessage(rsp.Error.Error()), + )) + span.SetAttributes(attribute.String("error.category", errorType)) + span.SetStatus(codes.Error, errorType) + gr.logger.Error("SQL command execution failed", "error", rsp.Error.Error(), "error_type", errorType) } span.End() - metrics.SqlCommandCount.WithLabelValues(statusLabel).Inc() + metrics.SqlCommandCount.WithLabelValues(statusLabel, errorType).Inc() metrics.SqlCommandDuration.WithLabelValues(statusLabel).Observe(duration) metrics.SqlCommandCellCount.WithLabelValues(statusLabel).Observe(float64(tc)) }() @@ -138,7 +137,7 @@ func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.V for _, ref := range gr.varsToQuery { results, ok := vars[ref] if !ok { - sqlLogger.Warn("no results found for", "ref", ref) + gr.logger.Warn("no results found for", "ref", ref) continue } frames := results.Values.AsDataFrames(ref) @@ -149,15 +148,11 @@ func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.V // limit of 0 or less means no limit (following convention) if gr.inputLimit > 0 && tc > gr.inputLimit { - rsp.Error = fmt.Errorf( - "SQL expression: total cell count across all input tables exceeds limit of %d. Total cells: %d", - gr.inputLimit, - tc, - ) + rsp.Error = sql.MakeInputLimitExceededError(gr.refID, gr.inputLimit) return rsp, nil } - sqlLogger.Debug("Executing query", "query", gr.query, "frames", len(allFrames)) + gr.logger.Debug("Executing query", "query", gr.query, "frames", len(allFrames)) db := sql.DB{} frame, err := db.QueryFrames(ctx, tracer, gr.refID, gr.query, allFrames, sql.WithMaxOutputCells(gr.outputLimit), sql.WithTimeout(gr.timeout)) @@ -166,7 +161,7 @@ func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.V return rsp, nil } - sqlLogger.Debug("Done Executing query", "query", gr.query, "rows", frame.Rows()) + gr.logger.Debug("Done Executing query", "query", gr.query, "rows", frame.Rows()) if frame.Rows() == 0 { rsp.Values = mathexp.Values{ @@ -293,7 +288,7 @@ func extractNumberSetFromSQLForAlerting(frame *data.Frame) ([]mathexp.Number, er } if len(duplicates) > 0 { - return nil, makeDuplicateStringColumnError(duplicates) + return nil, sql.MakeDuplicateStringColumnError(duplicates) } // Build final result @@ -307,3 +302,132 @@ func extractNumberSetFromSQLForAlerting(frame *data.Frame) ([]mathexp.Number, er return numbers, nil } + +// handleSqlInput normalizes input DataFrames into a single dataframe with no labels so it can represent a table for use with SQL expressions. +// +// It handles three cases: +// 1. If the input declares a supported time series or numeric kind in the wide or multi format (via FrameMeta.Type), it converts to a full-long formatted table using ConvertToFullLong. +// 2. If the input is a single frame (no labels, no declared type), it passes through as-is. +// 3. If the input has multiple frames or label metadata but lacks a supported type, it returns an error. +// +// The returned bool indicates if the input was (attempted to be) converted or passed through as-is. +func handleSqlInput(ctx context.Context, tracer trace.Tracer, refID string, forRefIDs map[string]struct{}, dsType string, dataFrames data.Frames) (mathexp.Results, bool) { + _, span := tracer.Start(ctx, "SSE.HandleConvertSQLInput") + start := time.Now() + var result mathexp.Results + errorType := "none" + var metaType data.FrameType + + defer func() { + duration := float64(time.Since(start).Milliseconds()) + statusLabel := "ok" + if result.Error != nil { + statusLabel = "error" + } + dataType := categorizeFrameInputType(dataFrames) + span.SetAttributes( + attribute.String("status", statusLabel), + attribute.Float64("duration", duration), + attribute.String("data.type", dataType), + attribute.String("datasource.type", dsType), + ) + + if result.Error != nil { + e := &sql.ErrorWithCategory{} + if errors.As(result.Error, &e) { + errorType = e.Category() + } else { + errorType = "unknown" + } + span.AddEvent("exception", trace.WithAttributes( + semconv.ExceptionType(errorType), + semconv.ExceptionMessage(result.Error.Error()), + )) + span.SetAttributes(attribute.String("error.category", errorType)) + span.SetStatus(codes.Error, errorType) + } + span.End() + }() + + if len(dataFrames) == 0 { + return mathexp.Results{Values: mathexp.Values{mathexp.NewNoData()}}, false + } + + first := dataFrames[0] + + // Single Frame no data case + // Note: In the case of a support Frame Type, we may want to return the matching schema + // with no rows (e.g. include the `__value__` column). But not sure about this at this time. + if len(dataFrames) == 1 && len(first.Fields) == 0 { + result.Values = mathexp.Values{ + mathexp.TableData{Frame: first}, + } + + return result, false + } + + if first.Meta != nil { + metaType = first.Meta.Type + } + + if supportedToLongConversion(metaType) { + convertedFrames, err := ConvertToFullLong(dataFrames) + if err != nil { + result.Error = sql.MakeInputConvertError(err, refID, forRefIDs, dsType) + } + + if len(convertedFrames) == 0 { + result.Error = fmt.Errorf("conversion succeeded but returned no frames") + return result, true + } + + result.Values = mathexp.Values{ + mathexp.TableData{Frame: convertedFrames[0]}, + } + + return result, true + } + + // If we don't have a supported type for conversion, see if we can pass through as a table (no labels, and only a single frame) + var frameTypeIssue string + if metaType == "" { + frameTypeIssue = "is missing the data type (frame.meta.type)" + } else { + frameTypeIssue = fmt.Sprintf("has an unsupported data type [%s]", metaType) + } + + // If meta.type is not supported, but there are labels or more than 1 frame error + if len(dataFrames) > 1 { + result.Error = sql.MakeInputConvertError(fmt.Errorf("can not convert because the response %s and has more than one dataframe that can not be automatically mapped to a single table", frameTypeIssue), refID, forRefIDs, dsType) + return result, false + } + for _, frame := range dataFrames { + for _, field := range frame.Fields { + if len(field.Labels) > 0 { + result.Error = sql.MakeInputConvertError(fmt.Errorf("can not convert because the response %s and has labels in the response that can not be mapped to a table", frameTypeIssue), refID, forRefIDs, dsType) + return result, false + } + } + } + + // Can pass through as table without conversion + result.Values = mathexp.Values{ + mathexp.TableData{Frame: first}, + } + return result, false +} + +func categorizeFrameInputType(dataFrames data.Frames) string { + switch { + case len(dataFrames) == 0: + return "missing" + case dataFrames[0].Meta == nil: + return "missing" + case dataFrames[0].Meta.Type == "": + return "missing" + case dataFrames[0].Meta.Type.IsKnownType(): + return string(dataFrames[0].Meta.Type) + default: + return "unknown" + } +} diff --git a/pkg/expr/sql_command_test.go b/pkg/expr/sql_command_test.go index dd9b274a8cd..bc4faa5ec0f 100644 --- a/pkg/expr/sql_command_test.go +++ b/pkg/expr/sql_command_test.go @@ -8,17 +8,19 @@ import ( "testing" "time" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/expr/mathexp" "github.com/grafana/grafana/pkg/expr/metrics" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" ) func TestNewCommand(t *testing.T) { - cmd, err := NewSQLCommand(t.Context(), "a", "", "select a from foo, bar", 0, 0, 0) + cmd, err := NewSQLCommand(t.Context(), log.NewNullLogger(), "a", "", "select a from foo, bar", 0, 0, 0) if err != nil && strings.Contains(err.Error(), "feature is not enabled") { return } @@ -91,7 +93,7 @@ func TestSQLCommandCellLimits(t *testing.T) { }, vars: []string{"foo"}, expectError: true, - errorContains: "exceeds limit", + errorContains: "exceeded the configured limit", }, { name: "single (wide) frame exceeds cell limit", @@ -101,7 +103,7 @@ func TestSQLCommandCellLimits(t *testing.T) { }, vars: []string{"foo"}, expectError: true, - errorContains: "exceeds limit", + errorContains: "exceeded the configured limit", }, { name: "multiple frames exceed cell limit", @@ -112,7 +114,7 @@ func TestSQLCommandCellLimits(t *testing.T) { }, vars: []string{"foo", "bar"}, expectError: true, - errorContains: "exceeds limit", + errorContains: "exceeded the configured limit", }, { name: "limit of 0 means no limit: allow large frame", @@ -126,7 +128,7 @@ func TestSQLCommandCellLimits(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cmd, err := NewSQLCommand(t.Context(), "a", "", "select a from foo, bar", tt.limit, 0, 0) + cmd, err := NewSQLCommand(t.Context(), log.New(), "a", "", "select a from foo, bar", tt.limit, 0, 0) require.NoError(t, err, "Failed to create SQL command") vars := mathexp.Vars{} @@ -154,15 +156,15 @@ func TestSQLCommandMetrics(t *testing.T) { m := metrics.NewTestMetrics() // Create a command - cmd, err := NewSQLCommand(t.Context(), "A", "someformat", "select * from foo", 0, 0, 0) + cmd, err := NewSQLCommand(t.Context(), log.NewNullLogger(), "A", "someformat", "select * from foo", 0, 0, 0) require.NoError(t, err) // Execute successful command _, err = cmd.Execute(context.Background(), time.Now(), mathexp.Vars{}, &testTracer{}, m) require.NoError(t, err) - // Verify error count was not incremented - require.Equal(t, 1, testutil.CollectAndCount(m.SqlCommandCount), "Expected error metric not to be recorded") + // Verify count metric was recorded + require.Equal(t, 1, testutil.CollectAndCount(m.SqlCommandCount), "Expected count metric to be recorded") // Verify duration was recorded require.Equal(t, 1, testutil.CollectAndCount(m.SqlCommandDuration), "Expected duration metric to be recorded") @@ -171,6 +173,90 @@ func TestSQLCommandMetrics(t *testing.T) { require.Equal(t, 1, testutil.CollectAndCount(m.SqlCommandCellCount), "Expected cell count metric to be recorded") } +func TestHandleSqlInput(t *testing.T) { + tests := []struct { + name string + frames data.Frames + expectErr string + expectFrame bool + converted bool + }{ + { + name: "single frame with no fields and no type is passed through", + frames: data.Frames{data.NewFrame("")}, + expectFrame: true, + }, + { + name: "single frame with no fields but type timeseries-multi is passed through", + frames: data.Frames{data.NewFrame("").SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti})}, + expectFrame: true, + }, + { + name: "single frame, no labels, no type → passes through", + frames: data.Frames{ + data.NewFrame("", + data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), + data.NewField("value", nil, []*float64{fp(2)}), + ), + }, + expectFrame: true, + }, + { + name: "single frame with labels, but missing FrameMeta.Type → error", + frames: data.Frames{ + data.NewFrame("", + data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), + data.NewField("value", data.Labels{"foo": "bar"}, []*float64{fp(2)}), + ), + }, + expectErr: "labels in the response that can not be mapped to a table", + }, + { + name: "multiple frames, no type → error", + frames: data.Frames{ + data.NewFrame("", + data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), + data.NewField("value", nil, []*float64{fp(2)}), + ), + data.NewFrame("", + data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), + data.NewField("value", nil, []*float64{fp(2)}), + ), + }, + expectErr: "more than one dataframe that can not be automatically mapped to a single table", + }, + { + name: "supported type (timeseries-multi) triggers ConvertToFullLong", + frames: data.Frames{ + data.NewFrame("", + data.NewField("time", nil, []time.Time{time.Unix(1, 0)}), + data.NewField("value", data.Labels{"host": "a"}, []*float64{fp(2)}), + ).SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}), + }, + expectFrame: true, + converted: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + res, c := handleSqlInput(t.Context(), &testTracer{}, "a", map[string]struct{}{"b": {}}, "fakeDS", tc.frames) + require.Equal(t, tc.converted, c, "conversion bool mismatch") + if tc.expectErr != "" { + require.Error(t, res.Error) + require.ErrorContains(t, res.Error, tc.expectErr) + } else { + require.NoError(t, res.Error) + if tc.expectFrame { + require.Len(t, res.Values, 1) + require.IsType(t, mathexp.TableData{}, res.Values[0]) + require.NotNil(t, res.Values[0].(mathexp.TableData).Frame) + } + } + }) + } +} + type testTracer struct { trace.Tracer } @@ -193,3 +279,7 @@ func (ts *testSpan) RecordError(err error, opt ...trace.EventOption) { } func (ts *testSpan) SetStatus(code codes.Code, msg string) {} + +func (ts *testSpan) AddEvent(name string, opts ...trace.EventOption) {} + +func (ts *testSpan) SetAttributes(kv ...attribute.KeyValue) {} diff --git a/pkg/promlib/go.mod b/pkg/promlib/go.mod index 6e294ad34ae..11131609983 100644 --- a/pkg/promlib/go.mod +++ b/pkg/promlib/go.mod @@ -29,7 +29,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/elazarl/goproxy v1.7.2 // indirect diff --git a/pkg/promlib/go.sum b/pkg/promlib/go.sum index a478d60c720..a262f6ec95d 100644 --- a/pkg/promlib/go.sum +++ b/pkg/promlib/go.sum @@ -49,8 +49,8 @@ github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 h1:UZdrvid2JFwnvP github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= diff --git a/pkg/registry/apis/dashboard/mutate.go b/pkg/registry/apis/dashboard/mutate.go index e1a7c05618e..9ca6ae23a07 100644 --- a/pkg/registry/apis/dashboard/mutate.go +++ b/pkg/registry/apis/dashboard/mutate.go @@ -8,6 +8,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apiserver/pkg/admission" + "k8s.io/utils/ptr" dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" @@ -43,6 +44,7 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute internalID = int64(id) } resourceInfo = dashboardV0.DashboardResourceInfo + case *dashboardV1.Dashboard: delete(v.Spec.Object, "uid") delete(v.Spec.Object, "version") @@ -55,7 +57,7 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute if migrationErr != nil { v.Status.Conversion = &dashboardV1.DashboardConversionStatus{ Failed: true, - Error: migrationErr.Error(), + Error: ptr.To(migrationErr.Error()), } } @@ -68,7 +70,6 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute Spec: dashboardV2alpha1.DashboardGridLayoutSpec{}, } } - resourceInfo = dashboardV2alpha1.DashboardResourceInfo case *dashboardV2beta1.Dashboard: @@ -80,7 +81,6 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute Spec: dashboardV2beta1.DashboardGridLayoutSpec{}, } } - resourceInfo = dashboardV2beta1.DashboardResourceInfo // Noop for V2 diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index bf15cd7cc14..340ad01dc86 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -691,3 +691,6 @@ func (m *MockClient) IsHealthy(ctx context.Context, in *resourcepb.HealthCheckRe func (m *MockClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error) { return nil, nil } +func (m *MockClient) UpdateIndex(ctx context.Context, reason string) error { + return nil +} diff --git a/pkg/registry/apis/provisioning/jobs.go b/pkg/registry/apis/provisioning/jobs.go index a1fc5d7648b..52a9cddcedf 100644 --- a/pkg/registry/apis/provisioning/jobs.go +++ b/pkg/registry/apis/provisioning/jobs.go @@ -15,12 +15,24 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" ) +type JobQueueGetter interface { + GetJobQueue() jobs.Queue +} + type jobsConnector struct { repoGetter RepoGetter - jobs jobs.Queue + jobs JobQueueGetter historic jobs.HistoryReader } +func NewJobsConnector(repoGetter RepoGetter, jobs JobQueueGetter, historic jobs.HistoryReader) *jobsConnector { + return &jobsConnector{ + repoGetter: repoGetter, + jobs: jobs, + historic: historic, + } +} + func (*jobsConnector) New() runtime.Object { return &provisioning.Repository{} } @@ -103,7 +115,7 @@ func (c *jobsConnector) Connect( } spec.Repository = name - job, err := c.jobs.Insert(ctx, cfg.Namespace, spec) + job, err := c.jobs.GetJobQueue().Insert(ctx, cfg.Namespace, spec) if err != nil { responder.Error(err) return diff --git a/pkg/registry/apis/provisioning/jobs/driver.go b/pkg/registry/apis/provisioning/jobs/driver.go index cd186f3151f..cce14f6827f 100644 --- a/pkg/registry/apis/provisioning/jobs/driver.go +++ b/pkg/registry/apis/provisioning/jobs/driver.go @@ -44,11 +44,9 @@ type Store interface { RenewLease(ctx context.Context, job *provisioning.Job) error // Get retrieves a job by name for conflict resolution. - Get(ctx context.Context, name string) (*provisioning.Job, error) + Get(ctx context.Context, namespace, name string) (*provisioning.Job, error) } -var _ Store = (*persistentStore)(nil) - // jobDriver drives jobs to completion and manages the job queue. // There may be multiple jobDrivers running in parallel. // The jobDriver processes jobs but does not handle cleanup - that's handled by ConcurrentJobDriver. @@ -299,7 +297,7 @@ func (d *jobDriver) onProgress(job *provisioning.Job) ProgressFn { currentJob := job if attempt > 0 { // Fetch the latest version to resolve conflicts - latest, err := d.store.Get(ctx, job.GetName()) + latest, err := d.store.Get(ctx, job.GetNamespace(), job.GetName()) if err != nil { if apierrors.IsNotFound(err) { // Job was completed/deleted, nothing to update diff --git a/pkg/registry/apis/provisioning/jobs/persistentstore.go b/pkg/registry/apis/provisioning/jobs/persistentstore.go index 2649337b76f..bac3bd92441 100644 --- a/pkg/registry/apis/provisioning/jobs/persistentstore.go +++ b/pkg/registry/apis/provisioning/jobs/persistentstore.go @@ -9,16 +9,13 @@ import ( "time" apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/selection" - "k8s.io/apiserver/pkg/endpoints/request" - "k8s.io/apiserver/pkg/registry/rest" "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/registry/apis/provisioning/apifmt" ) @@ -34,26 +31,19 @@ const ( LabelJobOriginalUID = "provisioning.grafana.app/original-uid" ) -var ( - ErrNoJobs = &apierrors.StatusError{ - ErrStatus: metav1.Status{ - Status: metav1.StatusFailure, - Reason: metav1.StatusReasonConflict, - Message: "no jobs are available to claim, try again later", - Code: http.StatusNoContent, - Details: &metav1.StatusDetails{ - Group: provisioning.GROUP, - Kind: provisioning.JobResourceInfo.GetName(), - RetryAfterSeconds: 3, - }, +var ErrNoJobs = &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Reason: metav1.StatusReasonConflict, + Message: "no jobs are available to claim, try again later", + Code: http.StatusNoContent, + Details: &metav1.StatusDetails{ + Group: provisioning.GROUP, + Kind: provisioning.JobResourceInfo.GetName(), + RetryAfterSeconds: 3, }, - } - - errWouldCreate = errors.New("this call would have created a new resource; it is rejected") - failCreation rest.ValidateObjectFunc = func(_ context.Context, _ runtime.Object) error { - return errWouldCreate - } -) + }, +} // Queue is a job queue abstraction. // @@ -67,21 +57,14 @@ type Queue interface { Insert(ctx context.Context, namespace string, spec provisioning.JobSpec) (*provisioning.Job, error) } -var _ Queue = (*persistentStore)(nil) +var ( + _ Queue = (*persistentStore)(nil) + _ Store = (*persistentStore)(nil) +) -type jobStorage interface { - rest.Creater - rest.Lister - rest.Patcher - rest.GracefulDeleter -} - -// persistentStore is a job queue abstraction. -// It calls out to a real storage implementation to store the jobs, and a separate storage for historic jobs that have been completed. -// When persistentStore claims a job, it will update the status of it. This does a ResourceVersion check to ensure it is atomic; if the job has been claimed by another worker, the claim will fail. -// When a job is completed, it is moved to the historic job store by first deleting it from the job store and then creating it in the historic job store. We are fine with the job being lost if the historic job store fails to create it. +// persistentStore is a job queue implementation that uses the API client instead of rest.Storage. type persistentStore struct { - jobStore jobStorage + client client.ProvisioningV0alpha1Interface // clock is a function that returns the current time. clock func() time.Time @@ -91,15 +74,16 @@ type persistentStore struct { expiry time.Duration } -func NewJobStore(jobStore jobStorage, expiry time.Duration) (*persistentStore, error) { +// NewJobStore creates a new job queue implementation using the API client. +func NewJobStore(provisioningClient client.ProvisioningV0alpha1Interface, expiry time.Duration) (*persistentStore, error) { if expiry <= 0 { expiry = time.Second * 30 } return &persistentStore{ - jobStore: jobStore, - clock: time.Now, - expiry: expiry, + client: provisioningClient, + clock: time.Now, + expiry: expiry, }, nil } @@ -115,17 +99,13 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol return nil, nil, apifmt.Errorf("could not create requirement: %w", err) } - jobsObj, err := s.jobStore.List(ctx, &internalversion.ListOptions{ - LabelSelector: labels.NewSelector().Add(*requirement), + jobs, err := s.client.Jobs("").List(ctx, metav1.ListOptions{ + LabelSelector: labels.NewSelector().Add(*requirement).String(), Limit: 16, }) if err != nil { return nil, nil, apifmt.Errorf("failed to list jobs: %w", err) } - jobs, ok := jobsObj.(*provisioning.JobList) - if !ok { - return nil, nil, apifmt.Errorf("unexpected object type %T", jobsObj) - } if len(jobs.Items) == 0 { return nil, nil, ErrNoJobs @@ -137,9 +117,7 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol } job.Labels[LabelJobClaim] = strconv.FormatInt(s.clock().UnixMilli(), 10) - // We list jobs from all namespaces. So when we want to update a specific job, we also need its namespace in the context. - ctx := request.WithNamespace(ctx, job.GetNamespace()) - // Likewise, we should use the provisioning identity now that we have the namespace we are operating within. + // Set up the provisioning identity for this namespace ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace()) if err != nil { // This should never happen, as it is already a valid namespace from the job existing... but better be safe. @@ -149,15 +127,8 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol // This relies on the resource version being updated for us. // If the resource version we pass in via the current job is not the same as the one currently in the store, it will fail with Conflict. // This is the desired behavior, as it ensures that claims are atomic. - updated, _, err := s.jobStore.Update(ctx, - job.GetName(), // name - rest.DefaultUpdatedObjectInfo(&job), // objInfo - failCreation, // createValidation - nil, // updateValidation - false, // forceAllowCreate - &metav1.UpdateOptions{}, // options - ) - if apierrors.IsConflict(err) || errors.Is(err, errWouldCreate) { + updatedJob, err := s.client.Jobs(job.GetNamespace()).Update(ctx, &job, metav1.UpdateOptions{}) + if apierrors.IsConflict(err) { // On conflict: another worker claimed the job before us. // On would create: the job was completed and deleted before we could claim it. // We'll just move on to the next job. @@ -166,10 +137,6 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol if err != nil { return nil, nil, apifmt.Errorf("failed to claim job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) } - updatedJob, ok := updated.(*provisioning.Job) - if !ok { - return nil, nil, apifmt.Errorf("unexpected object type %T", updated) - } return updatedJob.DeepCopy(), func() { // Rolling back does not need to care about the parent's cancellation state. @@ -179,8 +146,8 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol logger := logging.FromContext(ctx).With("namespace", updatedJob.GetNamespace(), "job", updatedJob.GetName()) timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - refetched, err := s.jobStore.Get(timeoutCtx, updatedJob.GetName(), &metav1.GetOptions{}) - cancel() // we have no response body to read (the obj already contains all of it), so just cancel immediately + refetched, err := s.client.Jobs(updatedJob.GetNamespace()).Get(timeoutCtx, updatedJob.GetName(), metav1.GetOptions{}) + cancel() if apierrors.IsNotFound(err) { // The job was probably completed already. Nothing to roll back! return @@ -189,28 +156,16 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol logger.Warn("failed to roll back job claim; letting periodic cleaner deal with it", "error", err) return } - refetchedJob, ok := refetched.(*provisioning.Job) - if !ok { - logger.Warn("failed to roll back job claim: the job we got is not a *provisioning.Job?", "got", refetched) - return - } // Rollback the claim. - refetchedJob = refetchedJob.DeepCopy() + refetchedJob := refetched.DeepCopy() delete(refetchedJob.Labels, LabelJobClaim) refetchedJob.Status.State = provisioning.JobStatePending timeoutCtx, cancel = context.WithTimeout(ctx, 5*time.Second) - _, _, err = s.jobStore.Update(timeoutCtx, - refetchedJob.GetName(), // name - rest.DefaultUpdatedObjectInfo(refetchedJob), // objInfo - failCreation, // createValidation - nil, // updateValidation - false, // forceAllowCreate - &metav1.UpdateOptions{}, // options - ) - cancel() // we have no response body to read (the obj already contains all of it), so just cancel immediately - if err != nil && !apierrors.IsConflict(err) && !errors.Is(err, errWouldCreate) { + _, err = s.client.Jobs(updatedJob.GetNamespace()).Update(timeoutCtx, refetchedJob, metav1.UpdateOptions{}) + cancel() + if err != nil && !apierrors.IsConflict(err) { logger.Warn("failed to roll back job claim; letting periodic cleaner deal with it", "error", err) } else if err != nil { logger.Debug("failed to roll back job claim; got an OK error", "error", err) @@ -224,39 +179,32 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol // Update saves the job back to the store. func (s *persistentStore) Update(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error) { - obj, _, err := s.jobStore.Update(ctx, - job.GetName(), // name - rest.DefaultUpdatedObjectInfo(job), // objInfo - failCreation, // createValidation - nil, // updateValidation - false, // forceAllowCreate - &metav1.UpdateOptions{}, // options - ) + // Set up the provisioning identity for this namespace + ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace()) if err != nil { - return nil, apifmt.Errorf("failed to update job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) + return nil, apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) } - updatedJob, ok := obj.(*provisioning.Job) - if !ok { - return nil, apifmt.Errorf("unexpected object type %T", obj) + updatedJob, err := s.client.Jobs(job.GetNamespace()).Update(ctx, job, metav1.UpdateOptions{}) + if err != nil { + return nil, apifmt.Errorf("failed to update job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) } return updatedJob, nil } // Get retrieves a job by name for conflict resolution. -func (s *persistentStore) Get(ctx context.Context, name string) (*provisioning.Job, error) { - obj, err := s.jobStore.Get(ctx, name, &metav1.GetOptions{}) +func (s *persistentStore) Get(ctx context.Context, namespace, name string) (*provisioning.Job, error) { + // Set up provisioning identity to access jobs across all namespaces + ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace) if err != nil { - if apierrors.IsNotFound(err) { - return nil, apifmt.Errorf("job '%s' not found", name) - } - return nil, apifmt.Errorf("failed to get job '%s': %w", name, err) + return nil, apifmt.Errorf("failed to grant provisioning identity for job lookup: %w", err) } - job, ok := obj.(*provisioning.Job) - if !ok { - return nil, apifmt.Errorf("unexpected object type %T", obj) + // Use Get to directly fetch the job by name + job, err := s.client.Jobs(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, apifmt.Errorf("failed to get job by name '%s': %w", name, err) } return job, nil @@ -267,12 +215,18 @@ func (s *persistentStore) Get(ctx context.Context, name string) (*provisioning.J func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) error { logger := logging.FromContext(ctx).With("namespace", job.GetNamespace(), "job", job.GetName()) + // Set up the provisioning identity for this namespace + ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace()) + if err != nil { + return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) + } + // We need to delete the job from the job store and create it in the historic job store. // We are fine with the job being lost if the historic job store fails to create it. // // We will assume that the caller is the claimant. If this is not true, an error is returned. // This is a best-effort operation; if the job is not in the claimed state, we will still attempt to move it to the historic job store. - _, _, err := s.jobStore.Delete(ctx, job.GetName(), nil, &metav1.DeleteOptions{}) + err = s.client.Jobs(job.GetNamespace()).Delete(ctx, job.GetName(), metav1.DeleteOptions{}) if err != nil { return apifmt.Errorf("failed to delete job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) } @@ -295,8 +249,14 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) return apifmt.Errorf("job '%s' in '%s' is not claimed", job.GetName(), job.GetNamespace()) } + // Set up the provisioning identity for this namespace + ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace()) + if err != nil { + return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) + } + // Fetch the latest version to avoid conflicts - latestObj, err := s.jobStore.Get(ctx, job.GetName(), &metav1.GetOptions{}) + latestJob, err := s.client.Jobs(job.GetNamespace()).Get(ctx, job.GetName(), metav1.GetOptions{}) if err != nil { if apierrors.IsNotFound(err) { return apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace()) @@ -304,11 +264,6 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) return apifmt.Errorf("failed to fetch job for lease renewal '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) } - latestJob, ok := latestObj.(*provisioning.Job) - if !ok { - return apifmt.Errorf("unexpected object type %T", latestObj) - } - // Verify we still own the lease if latestJob.Labels == nil || latestJob.Labels[LabelJobClaim] == "" { return apifmt.Errorf("lease lost for job '%s' in '%s': no longer claimed", job.GetName(), job.GetNamespace()) @@ -319,18 +274,11 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) updatedJob.Labels[LabelJobClaim] = strconv.FormatInt(s.clock().UnixMilli(), 10) // Update the job in storage with the latest resource version - _, _, err = s.jobStore.Update(ctx, - updatedJob.GetName(), // name - rest.DefaultUpdatedObjectInfo(updatedJob), // objInfo - failCreation, // createValidation - nil, // updateValidation - false, // forceAllowCreate - &metav1.UpdateOptions{}, // options - ) + _, err = s.client.Jobs(job.GetNamespace()).Update(ctx, updatedJob, metav1.UpdateOptions{}) if apierrors.IsConflict(err) { return apifmt.Errorf("failed to renew lease for job '%s' in '%s': lease conflict", job.GetName(), job.GetNamespace()) } - if apierrors.IsNotFound(err) || errors.Is(err, errWouldCreate) { + if apierrors.IsNotFound(err) { return apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace()) } if err != nil { @@ -360,18 +308,14 @@ func (s *persistentStore) Cleanup(ctx context.Context) error { } timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - jobsObj, err := s.jobStore.List(timeoutCtx, &internalversion.ListOptions{ - LabelSelector: labels.NewSelector().Add(*requirement), + jobs, err := s.client.Jobs("").List(timeoutCtx, metav1.ListOptions{ + LabelSelector: labels.NewSelector().Add(*requirement).String(), Limit: 100, // Process in batches }) cancel() if err != nil { return apifmt.Errorf("failed to list jobs with expired leases: %w", err) } - jobs, ok := jobsObj.(*provisioning.JobList) - if !ok { - return apifmt.Errorf("unexpected object type %T", jobsObj) - } // If no jobs found, cleanup is complete if len(jobs.Items) == 0 { @@ -385,7 +329,6 @@ func (s *persistentStore) Cleanup(ctx context.Context) error { job.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection" // Set namespace context for the completion - ctx := request.WithNamespace(ctx, job.GetNamespace()) ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace()) if err != nil { return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) @@ -409,6 +352,12 @@ func (s *persistentStore) Insert(ctx context.Context, namespace string, spec pro return nil, errors.New("missing repository in job") } + // Set up the provisioning identity for this namespace + ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace) + if err != nil { + return nil, apifmt.Errorf("failed to get provisioning identity for '%s': %w", namespace, err) + } + job := &provisioning.Job{ ObjectMeta: metav1.ObjectMeta{ Namespace: namespace, @@ -421,10 +370,8 @@ func (s *persistentStore) Insert(ctx context.Context, namespace string, spec pro if err := mutateJobAction(job); err != nil { return nil, err } - s.generateJobName(job) // Side-effect: updates the job's name. - - ctx = request.WithNamespace(ctx, job.GetNamespace()) - obj, err := s.jobStore.Create(ctx, job, nil, &metav1.CreateOptions{}) + generateJobName(job) // Side-effect: updates the job's name. + created, err := s.client.Jobs(namespace).Create(ctx, job, metav1.CreateOptions{}) if apierrors.IsAlreadyExists(err) { return nil, apifmt.Errorf("job '%s' in '%s' already exists: %w", job.GetName(), job.GetNamespace(), err) } @@ -432,16 +379,11 @@ func (s *persistentStore) Insert(ctx context.Context, namespace string, spec pro return nil, apifmt.Errorf("failed to create job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) } - created, ok := obj.(*provisioning.Job) - if !ok { - return nil, apifmt.Errorf("unexpected object type %T", obj) - } - return created, nil } // generateJobName creates and updates the job's name to one that fits it. -func (s *persistentStore) generateJobName(job *provisioning.Job) { +func generateJobName(job *provisioning.Job) { switch job.Spec.Action { case provisioning.JobActionMigrate, provisioning.JobActionPull: // Pull and migrate jobs should never run at the same time. Hence, the name encapsulates them both (and the spec differentiates them). diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index e8b834bd255..0f264a66d32 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -34,15 +34,15 @@ import ( client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" informers "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions" listers "github.com/grafana/grafana/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1" - commonMeta "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" apiutils "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/apiserver/readonly" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/registry/apis/provisioning/controller" + + appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" deletepkg "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/delete" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export" @@ -429,7 +429,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage) b.getter = repositoryStorage - realJobStore, err := grafanaregistry.NewCompleteRegistryStore(opts.Scheme, provisioning.JobResourceInfo, opts.OptsGetter) + jobStore, err := grafanaregistry.NewCompleteRegistryStore(opts.Scheme, provisioning.JobResourceInfo, opts.OptsGetter) if err != nil { return fmt.Errorf("failed to create job storage: %w", err) } @@ -454,14 +454,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI storage[provisioning.HistoricJobResourceInfo.StoragePath()] = historicJobStore } - b.jobs, err = jobs.NewJobStore(realJobStore, 30*time.Second) // FIXME: this timeout - if err != nil { - return fmt.Errorf("create job store: %w", err) - } - - // Although we never interact with jobs via the API, we want them to be readable (watchable!) from the API. - storage[provisioning.JobResourceInfo.StoragePath()] = readonly.Wrap(realJobStore) - + storage[provisioning.JobResourceInfo.StoragePath()] = jobStore storage[provisioning.RepositoryResourceInfo.StoragePath()] = repositoryStorage storage[provisioning.RepositoryResourceInfo.StoragePath("status")] = repositoryStatusStorage @@ -476,11 +469,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI storage[provisioning.RepositoryResourceInfo.StoragePath("history")] = &historySubresource{ repoGetter: b, } - storage[provisioning.RepositoryResourceInfo.StoragePath("jobs")] = &jobsConnector{ - repoGetter: b, - jobs: b.jobs, - historic: jobHistory, - } + storage[provisioning.RepositoryResourceInfo.StoragePath("jobs")] = NewJobsConnector(b, b, jobHistory) // Add any extra storage for _, extra := range b.extras { @@ -506,6 +495,11 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis if ok { return nil } + // FIXME: Do nothing for Jobs for now + _, ok = obj.(*provisioning.Job) + if ok { + return nil + } r, ok := obj.(*provisioning.Repository) if !ok { @@ -557,6 +551,12 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm return nil } + // FIXME: Do nothing for Jobs for now + _, ok = obj.(*provisioning.Job) + if ok { + return nil + } + repo, err := b.asRepository(ctx, obj, a.GetOldObject()) if err != nil { return err @@ -661,6 +661,12 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH b.client = c.ProvisioningV0alpha1() b.repositoryLister = repoInformer.Lister() + // Initialize the API client-based job store + b.jobs, err = jobs.NewJobStore(b.client, 30*time.Second) + if err != nil { + return fmt.Errorf("create API client job store: %w", err) + } + b.statusPatcher = controller.NewRepositoryStatusPatcher(b.GetClient()) b.healthChecker = controller.NewHealthChecker(&repository.Tester{}, b.statusPatcher) @@ -739,7 +745,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH } // Create JobController to handle job create notifications - jobController, err := controller.NewJobController(jobInformer) + jobController, err := appcontroller.NewJobController(jobInformer) if err != nil { return err } @@ -804,7 +810,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH historyJobInformerFactory := informers.NewSharedInformerFactory(c, historyJobExpiration) historyJobInformer := historyJobInformerFactory.Provisioning().V0alpha1().HistoricJobs() go historyJobInformer.Informer().Run(postStartHookCtx.Done()) - _, err = controller.NewHistoryJobController( + _, err = appcontroller.NewHistoryJobController( b.GetClient(), historyJobInformer, historyJobExpiration, @@ -1319,13 +1325,9 @@ func (b *APIBuilder) RepositoryFromConfig(ctx context.Context, r *provisioning.R } } - var token commonMeta.RawSecureValue - if r.Secure.Token.IsZero() { - t, err := secure.Token(ctx) - if err != nil { - return nil, fmt.Errorf("unable to decrypt token: %w", err) - } - token = t + token, err := secure.Token(ctx) + if err != nil { + return nil, fmt.Errorf("unable to decrypt token: %w", err) } switch r.Spec.Type { diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go index 25bf6439908..4539662c7eb 100644 --- a/pkg/registry/apis/query/query.go +++ b/pkg/registry/apis/query/query.go @@ -167,7 +167,7 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O raw := &query.QueryDataRequest{} err := web.Bind(httpreq, raw) if err != nil { - b.log.Error("Hit unexpected error when reading query", "err", err) + connectLogger.Error("Hit unexpected error when reading query", "err", err) err = errorsK8s.NewBadRequest("error reading query") // TODO: can we wrap the error so details are not lost?! // errutil.BadRequest( @@ -181,8 +181,8 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O qdr, err := handleQuery(ctx, *raw, *b, httpreq, *responder, connectLogger) if err != nil { - b.log.Error("execute error", "http code", query.GetResponseCode(qdr), "err", err) - logEmptyRefids(raw.Queries, b.log) + connectLogger.Error("execute error", "http code", query.GetResponseCode(qdr), "err", err) + logEmptyRefids(raw.Queries, connectLogger) if qdr != nil { // if we have a response, we assume the err is set in the response responder.Object(query.GetResponseCode(qdr), &query.QueryDataResponse{ QueryDataResponse: *qdr, @@ -190,19 +190,40 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O return } else { var errorDataResponse backend.DataResponse - if errors.Is(err, service.ErrInvalidDatasourceID) || errors.Is(err, service.ErrNoQueriesFound) || errors.Is(err, service.ErrMissingDataSourceInfo) || errors.Is(err, service.ErrQueryParamMismatch) || errors.Is(err, service.ErrDuplicateRefId) { + + badRequestErrors := []error{ + service.ErrInvalidDatasourceID, + service.ErrNoQueriesFound, + service.ErrMissingDataSourceInfo, + service.ErrQueryParamMismatch, + service.ErrDuplicateRefId, + datasources.ErrDataSourceNotFound, + } + isTypedBadRequestError := false + for _, badRequestError := range badRequestErrors { + if errors.Is(err, badRequestError) { + isTypedBadRequestError = true + } + } + if isTypedBadRequestError { errorDataResponse = backend.ErrDataResponseWithSource(backend.StatusBadRequest, backend.ErrorSourceDownstream, err.Error()) } else if strings.Contains(err.Error(), "expression request error") { - b.log.Error("Error calling TransformData in an expression", "err", err) + connectLogger.Error("Error calling TransformData in an expression", "err", err) errorDataResponse = backend.ErrDataResponseWithSource(backend.StatusBadRequest, backend.ErrorSourceDownstream, err.Error()) } else { - b.log.Error("unknown error, treated as a 500", "err", err) + connectLogger.Error("unknown error, treated as a 500", "err", err) responder.Error(err) return } + // TODO ensure errors also return the refId wherever possible + errorRefId := raw.Queries[0].RefID + if errorRefId == "" { + errorRefId = "A" + } + qdr = &backend.QueryDataResponse{ Responses: map[string]backend.DataResponse{ - "A": errorDataResponse, + errorRefId: errorDataResponse, }, } responder.Object(query.GetResponseCode(qdr), &query.QueryDataResponse{ @@ -223,12 +244,12 @@ func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuil for _, query := range raw.Queries { jsonBytes, err := json.Marshal(query) if err != nil { - b.log.Error("error marshalling", err) + connectLogger.Error("error marshalling", err) } sjQuery, _ := simplejson.NewJson(jsonBytes) if err != nil { - b.log.Error("error unmarshalling", err) + connectLogger.Error("error unmarshalling", err) } jsonQueries = append(jsonQueries, sjQuery) diff --git a/pkg/registry/apis/secret/inline/service.go b/pkg/registry/apis/secret/inline/service.go index 33fbc50736e..8d48dfa8fb9 100644 --- a/pkg/registry/apis/secret/inline/service.go +++ b/pkg/registry/apis/secret/inline/service.go @@ -19,37 +19,46 @@ func ProvideInlineSecureValueService( accessClient authlib.AccessClient, ) (contracts.InlineSecureValueSupport, error) { if cfg.SecretsManagement.GrpcClientEnable { - grpcClientConfig := grpcutils.ReadGrpcClientConfig(cfg) - - if cfg.SecretsManagement.GrpcServerAddress == "" { - return nil, fmt.Errorf("grpc_server_address is required when grpc client is enabled") - } - - if grpcClientConfig.Token == "" || grpcClientConfig.TokenExchangeURL == "" { - return nil, fmt.Errorf("grpc_client_authentication.token and grpc_client_authentication.token_exchange_url are required when grpc client is enabled") - } - - tokenExchangeClient, err := authnlib.NewTokenExchangeClient(authnlib.TokenExchangeConfig{ - Token: grpcClientConfig.Token, - TokenExchangeURL: grpcClientConfig.TokenExchangeURL, - }) - if err != nil { - return nil, fmt.Errorf("failed to create token exchange client: %w", err) - } - - tlsConfig := readTLSFromConfig(cfg) - - client, err := NewGRPCInlineClient(tokenExchangeClient, tracer, cfg.SecretsManagement.GrpcServerAddress, tlsConfig) - if err != nil { - return nil, fmt.Errorf("failed to create grpc inline secure value client: %w", err) - } - - return client, nil + return NewGRPCSecureValueService( + grpcutils.ReadGrpcClientConfig(cfg), + cfg.SecretsManagement.GrpcServerAddress, + readTLSFromConfig(cfg), + tracer, + ) } return NewLocalInlineSecureValueService(tracer, secureValueService, accessClient), nil } +func NewGRPCSecureValueService(tokenCfg *grpcutils.GrpcClientConfig, + address string, + tlsCfg TLSConfig, + tracer trace.Tracer, +) (contracts.InlineSecureValueSupport, error) { + if address == "" { + return nil, fmt.Errorf("grpc_server_address is required when grpc client is enabled") + } + + if tokenCfg.Token == "" || tokenCfg.TokenExchangeURL == "" { + return nil, fmt.Errorf("grpc_client_authentication.token and grpc_client_authentication.token_exchange_url are required when grpc client is enabled") + } + + tokenExchangeClient, err := authnlib.NewTokenExchangeClient(authnlib.TokenExchangeConfig{ + Token: tokenCfg.Token, + TokenExchangeURL: tokenCfg.TokenExchangeURL, + }) + if err != nil { + return nil, fmt.Errorf("failed to create token exchange client: %w", err) + } + + client, err := NewGRPCInlineClient(tokenExchangeClient, tracer, address, tlsCfg) + if err != nil { + return nil, fmt.Errorf("failed to create grpc inline secure value client: %w", err) + } + + return client, nil +} + func readTLSFromConfig(cfg *setting.Cfg) TLSConfig { if !cfg.SecretsManagement.GrpcServerUseTLS { return TLSConfig{ diff --git a/pkg/services/apiserver/options/storage.go b/pkg/services/apiserver/options/storage.go index 5d7661a99e1..db4a0fe891d 100644 --- a/pkg/services/apiserver/options/storage.go +++ b/pkg/services/apiserver/options/storage.go @@ -15,6 +15,8 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" secret "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + inlinesecurevalue "github.com/grafana/grafana/pkg/registry/apis/secret/inline" + "github.com/grafana/grafana/pkg/services/authn/grpcutils" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/apistore" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -51,6 +53,14 @@ type StorageOptions struct { GrpcClientAuthenticationTokenNamespace string GrpcClientAuthenticationAllowInsecure bool + // Secrets Manager Configuration for InlineSecureValueSupport + SecretsManagerGrpcClientEnable bool + SecretsManagerGrpcServerAddress string + SecretsManagerGrpcServerUseTLS bool + SecretsManagerGrpcServerTLSSkipVerify bool + SecretsManagerGrpcServerTLSServerName string + SecretsManagerGrpcServerTLSCAFile string + // For file storage, this is the requested path DataPath string @@ -92,6 +102,14 @@ func (o *StorageOptions) AddFlags(fs *pflag.FlagSet) { fs.StringVar(&o.GrpcClientAuthenticationTokenExchangeURL, "grpc-client-authentication-token-exchange-url", o.GrpcClientAuthenticationTokenExchangeURL, "Token exchange url for grpc client authentication") fs.StringVar(&o.GrpcClientAuthenticationTokenNamespace, "grpc-client-authentication-token-namespace", o.GrpcClientAuthenticationTokenNamespace, "Token namespace for grpc client authentication") fs.BoolVar(&o.GrpcClientAuthenticationAllowInsecure, "grpc-client-authentication-allow-insecure", o.GrpcClientAuthenticationAllowInsecure, "Allow insecure grpc client authentication") + + // Secrets Manager Configuration flags + fs.BoolVar(&o.SecretsManagerGrpcClientEnable, "grafana.secrets-manager.grpc-client-enable", false, "Enable gRPC client for secrets manager") + fs.StringVar(&o.SecretsManagerGrpcServerAddress, "grafana.secrets-manager.grpc-server-address", "", "gRPC server address for secrets manager") + fs.BoolVar(&o.SecretsManagerGrpcServerUseTLS, "grafana.secrets-manager.grpc-server-use-tls", false, "Use TLS for gRPC server communication") + fs.BoolVar(&o.SecretsManagerGrpcServerTLSSkipVerify, "grafana.secrets-manager.grpc-server-tls-skip-verify", false, "Skip TLS verification for gRPC server") + fs.StringVar(&o.SecretsManagerGrpcServerTLSServerName, "grafana.secrets-manager.grpc-server-tls-server-name", "", "Server name for TLS verification") + fs.StringVar(&o.SecretsManagerGrpcServerTLSCAFile, "grafana.secrets-manager.grpc-server-tls-ca-file", "", "CA file for TLS verification") } func (o *StorageOptions) Validate() []error { @@ -130,10 +148,19 @@ func (o *StorageOptions) Validate() []error { errs = append(errs, fmt.Errorf("grpc client auth namespace is required for unified-grpc storage")) } } + + if o.SecretsManagerGrpcClientEnable { + if o.SecretsManagerGrpcServerAddress == "" { + errs = append(errs, fmt.Errorf("secrets manager grpc server address is required for secrets manager grpc client")) + } + if o.SecretsManagerGrpcServerUseTLS && !o.SecretsManagerGrpcServerTLSSkipVerify && o.SecretsManagerGrpcServerTLSCAFile == "" { + errs = append(errs, fmt.Errorf("secrets manager grpc server ca file is required for secrets manager grpc client")) + } + } return errs } -func (o *StorageOptions) ApplyTo(serverConfig *genericapiserver.RecommendedConfig, etcdOptions *options.EtcdOptions, tracer tracing.Tracer) error { +func (o *StorageOptions) ApplyTo(serverConfig *genericapiserver.RecommendedConfig, etcdOptions *options.EtcdOptions, tracer tracing.Tracer, secureServing *options.SecureServingOptions) error { if o.StorageType != StorageTypeUnifiedGrpc { return nil } @@ -168,6 +195,35 @@ func (o *StorageOptions) ApplyTo(serverConfig *genericapiserver.RecommendedConfi if err != nil { return err } + + // setup inline secrets if configured + if o.InlineSecrets == nil && o.SecretsManagerGrpcClientEnable { + tlsCfg := inlinesecurevalue.TLSConfig{ + UseTLS: o.SecretsManagerGrpcServerUseTLS, + CAFile: o.SecretsManagerGrpcServerTLSCAFile, + ServerName: o.SecretsManagerGrpcServerTLSServerName, + InsecureSkipVerify: o.SecretsManagerGrpcServerTLSSkipVerify, + } + if o.SecretsManagerGrpcServerUseTLS && secureServing != nil { + tlsCfg.CertFile = secureServing.ServerCert.CertKey.CertFile + tlsCfg.KeyFile = secureServing.ServerCert.CertKey.KeyFile + } + inlineSecureValueService, err := inlinesecurevalue.NewGRPCSecureValueService( + &grpcutils.GrpcClientConfig{ + Token: o.GrpcClientAuthenticationToken, + TokenExchangeURL: o.GrpcClientAuthenticationTokenExchangeURL, + TokenNamespace: o.GrpcClientAuthenticationTokenNamespace, + }, + o.SecretsManagerGrpcServerAddress, + tlsCfg, + tracer, + ) + if err != nil { + return fmt.Errorf("failed to create inline secure value service: %w", err) + } + o.InlineSecrets = inlineSecureValueService + } + getter := apistore.NewRESTOptionsGetterForClient(unified, o.InlineSecrets, etcdOptions.StorageConfig, o.ConfigProvider) serverConfig.RESTOptionsGetter = getter return nil diff --git a/pkg/services/apiserver/options/storage_test.go b/pkg/services/apiserver/options/storage_test.go index 4710442c65b..3bdbc96b248 100644 --- a/pkg/services/apiserver/options/storage_test.go +++ b/pkg/services/apiserver/options/storage_test.go @@ -30,6 +30,47 @@ func TestStorageOptions_Validate(t *testing.T) { }, wantErr: false, }, + { + name: "with secrets manager grpc client and no server address", + Opts: StorageOptions{ + StorageType: StorageTypeUnifiedGrpc, + Address: "localhost:10000", + GrpcClientAuthenticationToken: "1234", + GrpcClientAuthenticationTokenExchangeURL: "http://localhost:8080", + GrpcClientAuthenticationTokenNamespace: "*", + SecretsManagerGrpcClientEnable: true, + }, + wantErr: true, + }, + { + name: "with secrets manager grpc client and no server ca file", + Opts: StorageOptions{ + StorageType: StorageTypeUnifiedGrpc, + Address: "localhost:10000", + GrpcClientAuthenticationToken: "1234", + GrpcClientAuthenticationTokenExchangeURL: "http://localhost:8080", + GrpcClientAuthenticationTokenNamespace: "*", + SecretsManagerGrpcClientEnable: true, + SecretsManagerGrpcServerAddress: "localhost:10000", + SecretsManagerGrpcServerUseTLS: true, + }, + wantErr: true, + }, + { + name: "with secrets manager grpc client and server ca file", + Opts: StorageOptions{ + StorageType: StorageTypeUnifiedGrpc, + Address: "localhost:10000", + GrpcClientAuthenticationToken: "1234", + GrpcClientAuthenticationTokenExchangeURL: "http://localhost:8080", + GrpcClientAuthenticationTokenNamespace: "*", + SecretsManagerGrpcClientEnable: true, + SecretsManagerGrpcServerAddress: "localhost:10000", + SecretsManagerGrpcServerUseTLS: true, + SecretsManagerGrpcServerTLSCAFile: "ca.crt", + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/services/authn/authnimpl/registration.go b/pkg/services/authn/authnimpl/registration.go index ce0ed9a9e89..ea7afcd921f 100644 --- a/pkg/services/authn/authnimpl/registration.go +++ b/pkg/services/authn/authnimpl/registration.go @@ -152,6 +152,7 @@ func ProvideRegistration( authnSvc.RegisterPostAuthHook(rbacSync.SyncPermissionsHook, 120) authnSvc.RegisterPostLoginHook(orgSync.SetDefaultOrgHook, 140) + authnSvc.RegisterPostLoginHook(userSync.CatalogLoginHook, 145) authnSvc.RegisterPostLoginHook(rbacSync.ClearUserPermissionCacheHook, 170) nsSync := sync.ProvideNamespaceSync(cfg) diff --git a/pkg/services/authn/authnimpl/sync/user_sync.go b/pkg/services/authn/authnimpl/sync/user_sync.go index a82b1434152..01315058a9d 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync.go +++ b/pkg/services/authn/authnimpl/sync/user_sync.go @@ -5,8 +5,10 @@ import ( "errors" "fmt" "strconv" + "sync" "sync/atomic" + "github.com/Masterminds/semver/v3" claims "github.com/grafana/authlib/types" "go.opentelemetry.io/otel/attribute" "golang.org/x/sync/singleflight" @@ -128,6 +130,7 @@ type UserSync struct { scimUtil *scimutil.SCIMUtil staticConfig *StaticSCIMConfig scimSuccessfulLogin atomic.Bool + samlCatalogStats sync.Map } // GetUsageStats implements registry.ProvidesUsageStats @@ -138,9 +141,43 @@ func (s *UserSync) GetUsageStats(ctx context.Context) map[string]any { } else { stats["stats.features.scim.has_successful_login.count"] = 0 } + + s.samlCatalogStats.Range(func(key, value interface{}) bool { + version := key.(string) + flag := value.(*atomic.Bool) + if flag.Load() { + stats[fmt.Sprintf("stats.features.saml.catalog_version_%s.count", version)] = 1 + } else { + stats[fmt.Sprintf("stats.features.saml.catalog_version_%s.count", version)] = 0 + } + return true + }) return stats } +func (s *UserSync) setSamlCatalogVersion(version string) { + value, loaded := s.samlCatalogStats.LoadOrStore(version, &atomic.Bool{}) + flag := value.(*atomic.Bool) + flag.Store(true) + + if !loaded { + s.log.Info("New SAML catalog version detected", "version", version) + } +} + +func (s *UserSync) CatalogLoginHook(_ context.Context, identity *authn.Identity, r *authn.Request, err error) { + if err != nil || identity == nil || !identity.ClientParams.SyncUser || r == nil { + return + } + catalogVersion := r.GetMeta("catalog_version") + if _, err := semver.NewVersion(catalogVersion); err != nil { + s.log.Warn("The SAML catalog used for this login has an incorrect version format", "catalogVersion", catalogVersion) + return + } + + s.setSamlCatalogVersion(catalogVersion) +} + // ValidateUserProvisioningHook validates if a user should be allowed access based on provisioning status and configuration func (s *UserSync) ValidateUserProvisioningHook(ctx context.Context, currentIdentity *authn.Identity, _ *authn.Request) error { log := s.log.FromContext(ctx).New("auth_module", currentIdentity.AuthenticatedBy, "auth_id", currentIdentity.AuthID) diff --git a/pkg/services/authn/authnimpl/sync/user_sync_test.go b/pkg/services/authn/authnimpl/sync/user_sync_test.go index 49e50f55fdc..e9710d53ff9 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/user_sync_test.go @@ -3,6 +3,7 @@ package sync import ( "context" "errors" + "fmt" "strconv" "testing" @@ -975,6 +976,93 @@ func TestUserSync_FetchSyncedUserHook(t *testing.T) { } } +func TestUserSync_CatalogLoginHook(t *testing.T) { + type testCase struct { + name string + identity *authn.Identity + expectFlagSet bool + catalogVersion string + } + + tests := []testCase{ + { + name: "should skip hook when SyncUser flag is not enabled", + identity: &authn.Identity{ + ClientParams: authn.ClientParams{ + SyncUser: false, + }, + }, + expectFlagSet: false, + }, + { + name: "should skip hook when request is nil", + identity: &authn.Identity{ + ClientParams: authn.ClientParams{ + SyncUser: true, + }, + }, + }, + { + name: "should skip hook when catalog version is not set", + identity: &authn.Identity{ + ClientParams: authn.ClientParams{ + SyncUser: true, + }, + }, + expectFlagSet: false, + }, + { + name: "should not set loginflag when catalog version is set incorrectly", + identity: &authn.Identity{ + ClientParams: authn.ClientParams{ + SyncUser: true, + }, + }, + catalogVersion: "v0aplha1", + expectFlagSet: false, + }, + { + name: "should not set loginflag when catalog version is empty", + identity: &authn.Identity{ + ClientParams: authn.ClientParams{ + SyncUser: true, + }, + }, + expectFlagSet: false, + }, + { + name: "should set successful loginflag when catalog version is set correctly", + identity: &authn.Identity{ + ClientParams: authn.ClientParams{ + SyncUser: true, + }, + }, + catalogVersion: "1.0.0", + expectFlagSet: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := UserSync{ + tracer: tracing.InitializeTracerForTest(), + log: log.New("test"), + } + + req := authn.Request{} + if tt.catalogVersion != "" { + req.SetMeta("catalog_version", tt.catalogVersion) + } + + s.CatalogLoginHook(context.Background(), tt.identity, &req, nil) + usageStats := s.GetUsageStats(context.Background()) + countIndex := fmt.Sprintf("stats.features.saml.catalog_version_%s.count", tt.catalogVersion) + countResult := usageStats[countIndex] != nil && usageStats[countIndex].(int) == 1 + assert.Equal(t, tt.expectFlagSet, countResult) + }) + } +} + func TestUserSync_EnableDisabledUserHook(t *testing.T) { type testCase struct { desc string diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index ee2c04e2e22..21d57f33c52 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2636,20 +2636,6 @@ "requiresRestart": true } }, - { - "metadata": { - "name": "provisioningSecretsService", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-07-25T13:06:00Z", - "deletionTimestamp": "2025-08-20T12:48:19Z" - }, - "spec": { - "description": "Experimental feature to use the secrets service for provisioning instead of the legacy secrets", - "stage": "experimental", - "codeowner": "@grafana/grafana-app-platform-squad", - "requiresRestart": true - } - }, { "metadata": { "name": "publicDashboardsEmailSharing", diff --git a/pkg/services/ngalert/eval/eval_test.go b/pkg/services/ngalert/eval/eval_test.go index abc66238702..b66ee008739 100644 --- a/pkg/services/ngalert/eval/eval_test.go +++ b/pkg/services/ngalert/eval/eval_test.go @@ -1596,3 +1596,10 @@ func (f fakeNode) String() string { func (f fakeNode) NeedsVars() []string { return nil } + +func (f fakeNode) IsInputTo() map[string]struct{} { + return nil +} + +func (f fakeNode) SetInputTo(a string) { +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 8a9ed2603cc..b31584951bf 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -1087,6 +1087,12 @@ func NewCfgFromBytes(bytes []byte) (*Cfg, error) { return NewCfgFromINIFile(parsedFile) } +// prevents a log line from being printed when the static root path is not found, useful for apiservers that have no frontend +func NewCfgFromBytesWithoutJSValidation(bytes []byte) (*Cfg, error) { + skipStaticRootValidation = true + return NewCfgFromBytes(bytes) +} + // NewCfgFromINIFile specialized function to create a new Cfg from an ini.File. func NewCfgFromINIFile(iniFile *ini.File) (*Cfg, error) { cfg := NewCfg() diff --git a/pkg/storage/unified/apistore/prepare_test.go b/pkg/storage/unified/apistore/prepare_test.go index 1501b63da40..3564e183e34 100644 --- a/pkg/storage/unified/apistore/prepare_test.go +++ b/pkg/storage/unified/apistore/prepare_test.go @@ -14,6 +14,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apiserver/pkg/storage" + "k8s.io/utils/ptr" authlib "github.com/grafana/authlib/types" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" @@ -159,7 +160,7 @@ func TestPrepareObjectForStorage(t *testing.T) { err = meta.SetStatus(dashv1.DashboardStatus{ Conversion: &dashv1.DashboardConversionStatus{ Failed: true, - Error: "test", + Error: ptr.To("test"), }, }) require.NoError(t, err) diff --git a/pkg/storage/unified/apistore/store_test.go b/pkg/storage/unified/apistore/store_test.go index c1940b547d8..495efc32175 100644 --- a/pkg/storage/unified/apistore/store_test.go +++ b/pkg/storage/unified/apistore/store_test.go @@ -29,7 +29,6 @@ import ( "k8s.io/apiserver/pkg/storage/storagebackend" claims "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/apimachinery/identity" storagetesting "github.com/grafana/grafana/pkg/apiserver/storage/testing" "github.com/grafana/grafana/pkg/storage/unified/apistore" diff --git a/pkg/storage/unified/resource/bleve_index_metrics.go b/pkg/storage/unified/resource/bleve_index_metrics.go index 3f130eb0b1b..b5447b10062 100644 --- a/pkg/storage/unified/resource/bleve_index_metrics.go +++ b/pkg/storage/unified/resource/bleve_index_metrics.go @@ -9,14 +9,17 @@ import ( ) type BleveIndexMetrics struct { - IndexLatency *prometheus.HistogramVec - IndexSize prometheus.Gauge - IndexedKinds *prometheus.GaugeVec - IndexCreationTime *prometheus.HistogramVec - OpenIndexes *prometheus.GaugeVec - IndexBuilds *prometheus.CounterVec - IndexBuildFailures prometheus.Counter - IndexBuildSkipped prometheus.Counter + IndexLatency *prometheus.HistogramVec + IndexSize prometheus.Gauge + IndexedKinds *prometheus.GaugeVec + IndexCreationTime *prometheus.HistogramVec + OpenIndexes *prometheus.GaugeVec + IndexBuilds *prometheus.CounterVec + IndexBuildFailures prometheus.Counter + IndexBuildSkipped prometheus.Counter + UpdateLatency prometheus.Histogram + UpdatedDocuments prometheus.Summary + SearchUpdateWaitTime *prometheus.HistogramVec } var IndexCreationBuckets = []float64{1, 5, 10, 25, 50, 75, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000} @@ -63,6 +66,24 @@ func ProvideIndexMetrics(reg prometheus.Registerer) *BleveIndexMetrics { Name: "index_server_index_build_skipped_total", Help: "Number of times index build has been skipped due to existing valid index being found on disk", }), + UpdateLatency: promauto.With(reg).NewHistogram(prometheus.HistogramOpts{ + Name: "index_server_update_latency_seconds", + Help: "Time to execute index update with latest modifications", + NativeHistogramBucketFactor: 1.1, + NativeHistogramMaxBucketNumber: 160, + NativeHistogramMinResetDuration: time.Hour, + }), + UpdatedDocuments: promauto.With(reg).NewSummary(prometheus.SummaryOpts{ + Name: "index_server_update_documents_total", + Help: "Number of documents indexed during index update", + }), + SearchUpdateWaitTime: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{ + Name: "index_server_search_update_wait_time_seconds", + Help: "Time spent waiting for index update during search queries", + NativeHistogramBucketFactor: 1.1, + NativeHistogramMaxBucketNumber: 160, + NativeHistogramMinResetDuration: time.Hour, + }, []string{"reason"}), } // Initialize labels. diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index f7b822df600..f51f6aaa2b7 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -79,8 +79,17 @@ type ResourceIndex interface { // Get the number of documents in the index DocCount(ctx context.Context, folder string) (int64, error) + + // UpdateIndex updates the index with the latest data (using update function provided when index was built) to guarantee strong consistency during the search. + // Returns RV to which index was updated. + UpdateIndex(ctx context.Context, reason string) (int64, error) } +type BuildFn func(index ResourceIndex) (int64, error) + +// UpdateFn is responsible for updating index with changes since given RV. It should return new RV (to be used as next sinceRV), number of updated documents and error, if any. +type UpdateFn func(context context.Context, index ResourceIndex, sinceRV int64) (newRV int64, updatedDocs int, _ error) + // SearchBackend contains the technology specific logic to support search type SearchBackend interface { // GetIndex returns existing index, or nil. @@ -90,7 +99,17 @@ type SearchBackend interface { // Depending on the size, the backend may choose different options (eg: memory vs disk). // The last known resource version can be used to detect that nothing has changed, and existing on-disk index can be reused. // The builder will write all documents before returning. - BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, nonStandardFields SearchableDocumentFields, indexBuildReason string, builder func(index ResourceIndex) (int64, error)) (ResourceIndex, error) + // Updater function is used to update the index before performing the search. + BuildIndex( + ctx context.Context, + key NamespacedResource, + size int64, + resourceVersion int64, + nonStandardFields SearchableDocumentFields, + indexBuildReason string, + builder BuildFn, + updater UpdateFn, + ) (ResourceIndex, error) // TotalDocs returns the total number of documents across all indexes. TotalDocs() int64 @@ -644,61 +663,73 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso return nil, err } - if idx != nil { - return idx, nil - } + if idx == nil { + ch := s.buildIndex.DoChan(key.String(), func() (interface{}, error) { + // We want to finish building of the index even if original context is canceled. + // We reuse original context without cancel to keep the tracing spans correct. + ctx := context.WithoutCancel(ctx) - ch := s.buildIndex.DoChan(key.String(), func() (interface{}, error) { - // We want to finish building of the index even if original context is canceled. - // We reuse original context without cancel to keep the tracing spans correct. - ctx := context.WithoutCancel(ctx) - - // Recheck if some other goroutine managed to build an index in the meantime. - // (That is, it finished running this function and stored the index into the cache) - idx, err := s.search.GetIndex(ctx, key) - if err == nil && idx != nil { - return idx, nil - } - - // Get correct value of size + RV for building the index. This is important for our Bleve - // backend to decide whether to build index in-memory or as file-based. - stats, err := s.storage.GetResourceStats(ctx, key.Namespace, 0) - if err != nil { - return nil, fmt.Errorf("failed to get resource stats: %w", err) - } - - size := int64(0) - rv := int64(0) - for _, stat := range stats { - if stat.Namespace == key.Namespace && stat.Group == key.Group && stat.Resource == key.Resource { - size = stat.Count - rv = stat.ResourceVersion - break + // Recheck if some other goroutine managed to build an index in the meantime. + // (That is, it finished running this function and stored the index into the cache) + idx, err := s.search.GetIndex(ctx, key) + if err == nil && idx != nil { + return idx, nil } - } - idx, _, err = s.build(ctx, key, size, rv, reason) - if err != nil { - return nil, fmt.Errorf("error building search index, %w", err) - } - if idx == nil { - return nil, fmt.Errorf("nil index after build") - } - return idx, nil - }) + // Get correct value of size + RV for building the index. This is important for our Bleve + // backend to decide whether to build index in-memory or as file-based. + stats, err := s.storage.GetResourceStats(ctx, key.Namespace, 0) + if err != nil { + return nil, fmt.Errorf("failed to get resource stats: %w", err) + } - select { - case res := <-ch: - if res.Err != nil { - return nil, res.Err + size := int64(0) + rv := int64(0) + for _, stat := range stats { + if stat.Namespace == key.Namespace && stat.Group == key.Group && stat.Resource == key.Resource { + size = stat.Count + rv = stat.ResourceVersion + break + } + } + + idx, _, err = s.build(ctx, key, size, rv, reason) + if err != nil { + return nil, fmt.Errorf("error building search index, %w", err) + } + if idx == nil { + return nil, fmt.Errorf("nil index after build") + } + return idx, nil + }) + + select { + case res := <-ch: + if res.Err != nil { + return nil, res.Err + } + idx = res.Val.(ResourceIndex) + case <-ctx.Done(): + return nil, fmt.Errorf("failed to get index: %w", ctx.Err()) } - return res.Val.(ResourceIndex), nil - case <-ctx.Done(): - return nil, fmt.Errorf("failed to get index: %w", ctx.Err()) } + + if s.searchAfterWrite { + start := time.Now() + _, err := idx.UpdateIndex(ctx, reason) + if err != nil { + return nil, fmt.Errorf("failed to update index to guarantee strong consistency: %w", err) + } + elapsed := time.Since(start) + if s.indexMetrics != nil { + s.indexMetrics.SearchUpdateWaitTime.WithLabelValues(reason).Observe(elapsed.Seconds()) + } + } + + return idx, nil } -func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size int64, rv int64, indexBuildReason string) (ResourceIndex, int64, error) { +func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size int64, documentStatsRV int64, indexBuildReason string) (ResourceIndex, int64, error) { ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Build") defer span.End() @@ -707,7 +738,7 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size attribute.String("group", nsr.Group), attribute.String("resource", nsr.Resource), attribute.Int64("size", size), - attribute.Int64("rv", rv), + attribute.Int64("rv", documentStatsRV), ) logger := s.log.With("namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource) @@ -718,9 +749,9 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size } fields := s.builders.GetFields(nsr) - index, err := s.search.BuildIndex(ctx, nsr, size, rv, fields, indexBuildReason, func(index ResourceIndex) (int64, error) { + builderFn := func(index ResourceIndex) (int64, error) { span := trace.SpanFromContext(ctx) - span.AddEvent("building index", trace.WithAttributes(attribute.Int64("size", size), attribute.Int64("rv", rv), attribute.String("reason", indexBuildReason))) + span.AddEvent("building index", trace.WithAttributes(attribute.Int64("size", size), attribute.Int64("rv", documentStatsRV), attribute.String("reason", indexBuildReason))) listRV, err := s.storage.ListIterator(ctx, &resourcepb.ListRequest{ Limit: 1000000000000, // big number @@ -768,13 +799,10 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size // When we reach the batch size, perform bulk index and reset the batch. if len(items) >= maxBatchSize { span.AddEvent("bulk indexing", trace.WithAttributes(attribute.Int("count", len(items)))) - if err = index.BulkIndex(&BulkIndexRequest{ - Items: items, - }); err != nil { + if err = index.BulkIndex(&BulkIndexRequest{Items: items}); err != nil { return err } - // Reset the slice for the next batch while preserving capacity. items = items[:0] } } @@ -782,16 +810,94 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size // Index any remaining items in the final batch. if len(items) > 0 { span.AddEvent("bulk indexing", trace.WithAttributes(attribute.Int("count", len(items)))) - if err = index.BulkIndex(&BulkIndexRequest{ - Items: items, - }); err != nil { + if err = index.BulkIndex(&BulkIndexRequest{Items: items}); err != nil { return err } } return iter.Error() }) return listRV, err - }) + } + + updaterFn := func(ctx context.Context, index ResourceIndex, sinceRV int64) (int64, int, error) { + span := trace.SpanFromContext(ctx) + span.AddEvent("updating index", trace.WithAttributes(attribute.Int64("sinceRV", documentStatsRV))) + + rv, it := s.storage.ListModifiedSince(ctx, NamespacedResource{ + Group: nsr.Group, + Resource: nsr.Resource, + Namespace: nsr.Namespace, + }, sinceRV) + + // Process documents in batches to avoid memory issues + // When dealing with large collections (e.g., 100k+ documents), + // loading all documents into memory at once can cause OOM errors. + items := make([]*BulkIndexItem, 0, maxBatchSize) + + docs := 0 + for res, err := range it { + // Finish quickly if context is done. + if ctx.Err() != nil { + return 0, 0, ctx.Err() + } + + docs++ + + if err != nil { + span.RecordError(err) + return 0, 0, err + } + + key := &res.Key + switch res.Action { + case resourcepb.WatchEvent_ADDED, resourcepb.WatchEvent_MODIFIED: + span.AddEvent("building document", trace.WithAttributes(attribute.String("name", res.Key.Name))) + // Convert it to an indexable document + doc, err := builder.BuildDocument(ctx, key, res.ResourceVersion, res.Value) + if err != nil { + span.RecordError(err) + logger.Error("error building search document", "key", SearchID(key), "err", err) + continue + } + + items = append(items, &BulkIndexItem{ + Action: ActionIndex, + Doc: doc, + }) + case resourcepb.WatchEvent_DELETED: + span.AddEvent("deleting document", trace.WithAttributes(attribute.String("name", res.Key.Name))) + items = append(items, &BulkIndexItem{ + Action: ActionDelete, + Key: &res.Key, + }) + default: + logger.Error("can't update index with item, unknown action", "action", res.Action, "key", key) + continue + } + + // When we reach the batch size, perform bulk index and reset the batch. + if len(items) >= maxBatchSize { + span.AddEvent("bulk indexing", trace.WithAttributes(attribute.Int("count", len(items)))) + if err = index.BulkIndex(&BulkIndexRequest{Items: items}); err != nil { + return 0, 0, err + } + + items = items[:0] + } + } + + // Index any remaining items in the final batch. + if len(items) > 0 { + span.AddEvent("bulk indexing", trace.WithAttributes(attribute.Int("count", len(items)))) + if err = index.BulkIndex(&BulkIndexRequest{Items: items}); err != nil { + return 0, 0, err + } + } + + return rv, docs, nil + } + + index, err := s.search.BuildIndex(ctx, nsr, size, documentStatsRV, fields, indexBuildReason, builderFn, updaterFn) if err != nil { return nil, 0, err @@ -807,7 +913,7 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size } // rv is the last RV we read. when watching, we must add all events since that time - return index, rv, err + return index, documentStatsRV, err } // buildEmptyIndex creates an empty index without adding any documents @@ -822,6 +928,9 @@ func (s *searchSupport) buildEmptyIndex(ctx context.Context, nsr NamespacedResou return s.search.BuildIndex(ctx, nsr, 0, rv, fields, "empty", func(index ResourceIndex) (int64, error) { // Return the resource version without adding any documents to the index return 0, nil + }, func(context context.Context, index ResourceIndex, sinceRV int64) (int64, int, error) { + // No update is performed. + return 0, 0, nil }) } diff --git a/pkg/storage/unified/resource/search_test.go b/pkg/storage/unified/resource/search_test.go index f1e005ade86..45c32f2e462 100644 --- a/pkg/storage/unified/resource/search_test.go +++ b/pkg/storage/unified/resource/search_test.go @@ -23,6 +23,11 @@ var _ ResourceIndex = &MockResourceIndex{} // Mock implementations type MockResourceIndex struct { mock.Mock + + updateIndexError error + + updateIndexMu sync.Mutex + updateIndexCalls []string } func (m *MockResourceIndex) BulkIndex(req *BulkIndexRequest) error { @@ -50,6 +55,14 @@ func (m *MockResourceIndex) ListManagedObjects(ctx context.Context, req *resourc return args.Get(0).(*resourcepb.ListManagedObjectsResponse), args.Error(1) } +func (m *MockResourceIndex) UpdateIndex(ctx context.Context, reason string) (int64, error) { + m.updateIndexMu.Lock() + defer m.updateIndexMu.Unlock() + + m.updateIndexCalls = append(m.updateIndexCalls, reason) + return 0, m.updateIndexError +} + var _ DocumentBuilder = &MockDocumentBuilder{} type MockDocumentBuilder struct { @@ -111,6 +124,7 @@ type mockSearchBackend struct { mu sync.Mutex buildIndexCalls []buildIndexCall buildEmptyIndexCalls []buildEmptyIndexCall + cache map[NamespacedResource]ResourceIndex } type buildIndexCall struct { @@ -128,10 +142,12 @@ type buildEmptyIndexCall struct { } func (m *mockSearchBackend) GetIndex(ctx context.Context, key NamespacedResource) (ResourceIndex, error) { - return nil, nil + m.mu.Lock() + defer m.mu.Unlock() + return m.cache[key], nil } -func (m *mockSearchBackend) BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, fields SearchableDocumentFields, reason string, builder func(index ResourceIndex) (int64, error)) (ResourceIndex, error) { +func (m *mockSearchBackend) BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, fields SearchableDocumentFields, reason string, builder BuildFn, updater UpdateFn) (ResourceIndex, error) { index := &MockResourceIndex{} index.On("BulkIndex", mock.Anything).Return(nil).Maybe() index.On("DocCount", mock.Anything, mock.Anything).Return(int64(0), nil).Maybe() @@ -145,6 +161,11 @@ func (m *mockSearchBackend) BuildIndex(ctx context.Context, key NamespacedResour m.mu.Lock() defer m.mu.Unlock() + if m.cache == nil { + m.cache = make(map[NamespacedResource]ResourceIndex) + } + m.cache[key] = index + // Determine if this is an empty index based on size // Empty indexes are characterized by size == 0 if size == 0 { @@ -341,6 +362,72 @@ func TestSearchGetOrCreateIndex(t *testing.T) { require.Less(t, len(search.buildIndexCalls), concurrency, "Should not have built index more than a few times (ideally once)") require.Equal(t, int64(50), search.buildIndexCalls[0].size) require.Equal(t, int64(11111111), search.buildIndexCalls[0].resourceVersion) + + // Verify that UpdateIndex was not called at all, since searchAfterWrite is not enabled. + idx, err := support.getOrCreateIndex(context.Background(), NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, "test") + require.NoError(t, err) + checkMockIndexUpdateCalls(t, idx, nil) +} + +func TestSearchGetOrCreateIndexWithIndexUpdate(t *testing.T) { + // Setup mock implementations + storage := &mockStorageBackend{ + resourceStats: []ResourceStats{ + {NamespacedResource: NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, Count: 50, ResourceVersion: 11111111}, + }, + } + failedErr := fmt.Errorf("failed to update index") + search := &mockSearchBackend{ + buildIndexCalls: []buildIndexCall{}, + buildEmptyIndexCalls: []buildEmptyIndexCall{}, + + cache: map[NamespacedResource]ResourceIndex{ + NamespacedResource{Namespace: "ns", Group: "group", Resource: "bad"}: &MockResourceIndex{ + updateIndexError: failedErr, + }, + }, + } + supplier := &TestDocumentBuilderSupplier{ + GroupsResources: map[string]string{ + "group": "resource", + }, + } + + // Create search support with the specified initMaxSize + opts := SearchOptions{ + Backend: search, + Resources: supplier, + WorkerThreads: 1, + InitMinCount: 1, // set min count to default for this test + InitMaxCount: 0, + } + + // Enable searchAfterWrite + support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil, nil, true) + require.NoError(t, err) + require.NotNil(t, support) + + idx, err := support.getOrCreateIndex(context.Background(), NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, "initial call") + require.NoError(t, err) + require.NotNil(t, idx) + checkMockIndexUpdateCalls(t, idx, []string{"initial call"}) + + idx, err = support.getOrCreateIndex(context.Background(), NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, "second call") + require.NoError(t, err) + require.NotNil(t, idx) + checkMockIndexUpdateCalls(t, idx, []string{"initial call", "second call"}) + + idx, err = support.getOrCreateIndex(context.Background(), NamespacedResource{Namespace: "ns", Group: "group", Resource: "bad"}, "call to bad index") + require.ErrorIs(t, err, failedErr) + require.Nil(t, idx) +} + +func checkMockIndexUpdateCalls(t *testing.T, idx ResourceIndex, strings []string) { + mi, ok := idx.(*MockResourceIndex) + require.True(t, ok) + mi.updateIndexMu.Lock() + defer mi.updateIndexMu.Unlock() + require.Equal(t, strings, mi.updateIndexCalls) } func TestSearchGetOrCreateIndexWithCancellation(t *testing.T) { @@ -443,9 +530,6 @@ func TestSearchWillUpdateIndexOnQueueProcessor(t *testing.T) { type slowSearchBackendWithCache struct { mockSearchBackend wg sync.WaitGroup - - mu sync.Mutex - cache map[NamespacedResource]ResourceIndex } func (m *slowSearchBackendWithCache) GetIndex(ctx context.Context, key NamespacedResource) (ResourceIndex, error) { @@ -454,7 +538,7 @@ func (m *slowSearchBackendWithCache) GetIndex(ctx context.Context, key Namespace return m.cache[key], nil } -func (m *slowSearchBackendWithCache) BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, fields SearchableDocumentFields, reason string, builder func(index ResourceIndex) (int64, error)) (ResourceIndex, error) { +func (m *slowSearchBackendWithCache) BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, fields SearchableDocumentFields, reason string, builder BuildFn, updater UpdateFn) (ResourceIndex, error) { m.wg.Add(1) defer m.wg.Done() @@ -464,17 +548,9 @@ func (m *slowSearchBackendWithCache) BuildIndex(ctx context.Context, key Namespa if ctx.Err() != nil { return nil, ctx.Err() } - idx, err := m.mockSearchBackend.BuildIndex(ctx, key, size, resourceVersion, fields, reason, builder) + idx, err := m.mockSearchBackend.BuildIndex(ctx, key, size, resourceVersion, fields, reason, builder, updater) if err != nil { return nil, err } - - m.mu.Lock() - defer m.mu.Unlock() - - if m.cache == nil { - m.cache = make(map[NamespacedResource]ResourceIndex) - } - m.cache[key] = idx return idx, nil } diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index 347ee8103aa..1bb6953a527 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -24,6 +24,7 @@ import ( "github.com/blevesearch/bleve/v2/search/query" bleveSearch "github.com/blevesearch/bleve/v2/search/searcher" index "github.com/blevesearch/bleve_index_api" + "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "k8s.io/apimachinery/pkg/selection" @@ -145,7 +146,7 @@ func (b *bleveBackend) getCachedIndex(key resource.NamespacedResource) *bleveInd } // Index is no longer in the cache, but we need to close it. - err := val.index.Close() + err := val.stopUpdaterAndCloseIndex() if err != nil { b.log.Error("failed to close index", "key", key, "err", err) } @@ -205,7 +206,8 @@ func (b *bleveBackend) BuildIndex( resourceVersion int64, fields resource.SearchableDocumentFields, indexBuildReason string, - builder func(index resource.ResourceIndex) (int64, error), + builder resource.BuildFn, + updater resource.UpdateFn, ) (resource.ResourceIndex, error) { _, span := b.tracer.Start(ctx, tracingPrexfixBleve+"BuildIndex") defer span.End() @@ -261,7 +263,7 @@ func (b *bleveBackend) BuildIndex( newIndexType := indexStorageMemory build := true - if size > b.opts.FileThreshold { + if size >= b.opts.FileThreshold { newIndexType = indexStorageFile // We only check for the existing file-based index if we don't already have an open index for this key. @@ -312,16 +314,7 @@ func (b *bleveBackend) BuildIndex( } // Batch all the changes - idx := &bleveIndex{ - key: key, - index: index, - indexStorage: newIndexType, - fields: fields, - allFields: allFields, - standard: standardSearchFields, - features: b.features, - tracing: b.tracer, - } + idx := b.newBleveIndex(key, index, newIndexType, fields, allFields, standardSearchFields, updater, b.log.With("namespace", key.Namespace, "group", key.Group, "resource", key.Resource)) if build { if b.indexMetrics != nil { @@ -339,11 +332,12 @@ func (b *bleveBackend) BuildIndex( } err = idx.updateResourceVersion(listRV) if err != nil { - return nil, fmt.Errorf("fail to persist rv to index: %w", err) + logWithDetails.Error("Failed to persist RV to index", "err", err, "rv", listRV) + return nil, fmt.Errorf("failed to persist RV to index: %w", err) } elapsed := time.Since(start) - logWithDetails.Info("Finished building index", "elapsed", elapsed) + logWithDetails.Info("Finished building index", "elapsed", elapsed, "listRV", listRV) if b.indexMetrics != nil { b.indexMetrics.IndexCreationTime.WithLabelValues().Observe(elapsed.Seconds()) @@ -351,10 +345,7 @@ func (b *bleveBackend) BuildIndex( } else { logWithDetails.Info("Skipping index build, using existing index") - idx.resourceVersion, err = getRV(index) - if err != nil { - return nil, fmt.Errorf("failed to get RV from bleve index: %w", err) - } + idx.resourceVersion = indexRV if b.indexMetrics != nil { b.indexMetrics.IndexBuildSkipped.Inc() @@ -387,7 +378,7 @@ func (b *bleveBackend) BuildIndex( b.indexMetrics.OpenIndexes.WithLabelValues(prev.indexStorage).Dec() } - err := prev.index.Close() + err := prev.stopUpdaterAndCloseIndex() if err != nil { logWithDetails.Error("failed to close previous index", "key", key, "err", err) } @@ -555,7 +546,9 @@ func (b *bleveBackend) CloseAllIndexes() { defer b.cacheMx.Unlock() for key, idx := range b.cache { - _ = idx.index.Close() + if err := idx.stopUpdaterAndCloseIndex(); err != nil { + b.log.Error("Failed to close index", "err", err) + } delete(b.cache, key) if b.indexMetrics != nil { @@ -564,10 +557,21 @@ func (b *bleveBackend) CloseAllIndexes() { } } +type updateRequest struct { + reason string + callback chan updateResult +} + +type updateResult struct { + rv int64 + err error +} + type bleveIndex struct { key resource.NamespacedResource index bleve.Index + // RV returned by last List/ListModifiedSince operation. Updated when updating index. resourceVersion int64 standard resource.SearchableDocumentFields @@ -583,6 +587,49 @@ type bleveIndex struct { allFields []*resourcepb.ResourceTableColumnDefinition features featuremgmt.FeatureToggles tracing trace.Tracer + logger *slog.Logger + + updaterFn resource.UpdateFn + + updaterMu sync.Mutex + updaterCond *sync.Cond // Used to signal the updater goroutine that there is work to do, or updater is no longer enabled and should stop. Also used by updater itself to stop early if there's no work to be done. + updaterShutdown bool // When set to true, index is getting closed and updater is no longer going to update index. + updaterQueue []updateRequest // Queue of requests for next updater iteration. + updaterCancel context.CancelFunc // If not nil, the updater goroutine is running with context associated with this cancel function. + updaterWg sync.WaitGroup + + updateLatency prometheus.Histogram + updatedDocuments prometheus.Summary +} + +func (b *bleveBackend) newBleveIndex( + key resource.NamespacedResource, + index bleve.Index, + newIndexType string, + fields resource.SearchableDocumentFields, + allFields []*resourcepb.ResourceTableColumnDefinition, + standardSearchFields resource.SearchableDocumentFields, + updaterFn resource.UpdateFn, + logger *slog.Logger, +) *bleveIndex { + bi := &bleveIndex{ + key: key, + index: index, + indexStorage: newIndexType, + fields: fields, + allFields: allFields, + standard: standardSearchFields, + features: b.features, + tracing: b.tracer, + logger: logger, + updaterFn: updaterFn, + } + bi.updaterCond = sync.NewCond(&bi.updaterMu) + if b.indexMetrics != nil { + bi.updateLatency = b.indexMetrics.UpdateLatency + bi.updatedDocuments = b.indexMetrics.UpdatedDocuments + } + return bi } // BulkIndex implements resource.ResourceIndex. @@ -1094,6 +1141,154 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resourcepb.R return searchrequest, nil } +func (b *bleveIndex) stopUpdaterAndCloseIndex() error { + // Signal updater to stop. We do this by 1) setting updaterShuttingDown + sending signal, and by 2) calling cancel. + b.updaterMu.Lock() + b.updaterShutdown = true + b.updaterCond.Broadcast() + // if updater is running, cancel it. (Setting to nil is only done from updater itself in defer.) + if b.updaterCancel != nil { + b.updaterCancel() + } + b.updaterMu.Unlock() + + b.updaterWg.Wait() + // Close index only after updater is not working on it anymore. + return b.index.Close() +} + +func (b *bleveIndex) UpdateIndex(ctx context.Context, reason string) (int64, error) { + // We don't have to do anything if the index cannot be updated (typically in tests). + if b.updaterFn == nil { + return 0, nil + } + + // Use chan with buffer size 1 to ensure that we can always send the result back, even if there's no reader anymore. + req := updateRequest{reason: reason, callback: make(chan updateResult, 1)} + + // Make sure that the updater goroutine is running. + b.updaterMu.Lock() + if b.updaterShutdown { + b.updaterMu.Unlock() + return 0, fmt.Errorf("cannot update index: %w", bleve.ErrorIndexClosed) + } + + b.updaterQueue = append(b.updaterQueue, req) + + // If updater is not running, start it. + if b.updaterCancel == nil { + b.startUpdater() + } + b.updaterCond.Broadcast() // If updater is waiting for next batch, wake it up. + b.updaterMu.Unlock() + + // wait for the update to finish + select { + case <-ctx.Done(): + return 0, ctx.Err() + case ur := <-req.callback: + return ur.rv, ur.err + } +} + +// Must be called with b.updaterMu lock held. +func (b *bleveIndex) startUpdater() { + c, cancel := context.WithCancel(context.Background()) + b.updaterCancel = cancel + b.updaterWg.Add(1) + + go func() { + defer func() { + cancel() // Make sure to call this to release resources. + + b.updaterMu.Lock() + b.updaterCancel = nil + b.updaterMu.Unlock() + + b.updaterWg.Done() + }() + + b.runUpdater(c) + }() +} + +const maxWait = 5 * time.Second + +func (b *bleveIndex) runUpdater(ctx context.Context) { + for { + start := time.Now() + t := time.AfterFunc(maxWait, b.updaterCond.Broadcast) + + b.updaterMu.Lock() + for !b.updaterShutdown && ctx.Err() == nil && len(b.updaterQueue) == 0 && time.Since(start) < maxWait { + // Cond is signalled when updaterShutdown changes, updaterQueue gets new element or when timeout occurs. + b.updaterCond.Wait() + } + + shutdown := b.updaterShutdown + batch := b.updaterQueue + b.updaterQueue = nil // empty the queue for the next batch + b.updaterMu.Unlock() + + t.Stop() + + // Nothing to index after maxWait, exit the goroutine. + if len(batch) == 0 { + return + } + + if shutdown { + for _, req := range batch { + req.callback <- updateResult{err: fmt.Errorf("cannot update index: %w", bleve.ErrorIndexClosed)} + } + return + } + + // Build reasons map + reasons := map[string]int{} + for _, req := range batch { + reasons[req.reason]++ + } + + var rv int64 + var err = ctx.Err() + if err == nil { + rv, err = b.updateIndexWithLatestModifications(ctx, len(batch), reasons) + } + for _, req := range batch { + req.callback <- updateResult{rv: rv, err: err} + } + } +} + +func (b *bleveIndex) updateIndexWithLatestModifications(ctx context.Context, requests int, reasons map[string]int) (int64, error) { + ctx, span := b.tracing.Start(ctx, tracingPrexfixBleve+"updateIndexWithLatestModifications") + defer span.End() + + b.logger.Debug("Updating index", "sinceRV", b.resourceVersion, "requests", requests, "reasons", reasons) + + startTime := time.Now() + rv, docs, err := b.updaterFn(ctx, b, b.resourceVersion) + if err == nil && rv > 0 { + err = b.updateResourceVersion(rv) + } + + elapsed := time.Since(startTime) + if err == nil { + b.logger.Debug("Finished updating index", "listRV", b.resourceVersion, "duration", elapsed, "docs", docs) + + if b.updateLatency != nil { + b.updateLatency.Observe(elapsed.Seconds()) + } + if b.updatedDocuments != nil { + b.updatedDocuments.Observe(float64(docs)) + } + } else { + b.logger.Debug("Updating of index finished with error", "duration", elapsed, "err", err) + } + return rv, err +} + func safeInt64ToInt(i64 int64) (int, error) { if i64 > math.MaxInt32 || i64 < math.MinInt32 { return 0, fmt.Errorf("int64 value %d overflows int", i64) diff --git a/pkg/storage/unified/search/bleve_performance_test.go b/pkg/storage/unified/search/bleve_performance_test.go index d6cb840647e..f54298719d9 100644 --- a/pkg/storage/unified/search/bleve_performance_test.go +++ b/pkg/storage/unified/search/bleve_performance_test.go @@ -79,7 +79,7 @@ func BenchmarkBleveQuery(b *testing.B) { } } -func newTestWriter(size int, batchSize int) IndexWriter { +func newTestWriter(size int, batchSize int) resource.BuildFn { key := &resourcepb.ResourceKey{ Namespace: "default", Group: "dashboard.grafana.app", diff --git a/pkg/storage/unified/search/bleve_search_test.go b/pkg/storage/unified/search/bleve_search_test.go index 865f570389a..b61cd36111f 100644 --- a/pkg/storage/unified/search/bleve_search_test.go +++ b/pkg/storage/unified/search/bleve_search_test.go @@ -519,7 +519,7 @@ func newQueryByTitle(query string) *resourcepb.ResourceSearchRequest { } } -func newTestDashboardsIndex(t testing.TB, threshold int64, size int64, batchSize int64, writer IndexWriter) resource.ResourceIndex { +func newTestDashboardsIndex(t testing.TB, threshold int64, size int64, batchSize int64, writer resource.BuildFn) resource.ResourceIndex { key := &resourcepb.ResourceKey{ Namespace: "default", Group: "dashboard.grafana.app", @@ -551,15 +551,13 @@ func newTestDashboardsIndex(t testing.TB, threshold int64, size int64, batchSize Namespace: key.Namespace, Group: key.Group, Resource: key.Resource, - }, size, rv, info.Fields, "test", writer) + }, size, rv, info.Fields, "test", writer, nil) require.NoError(t, err) return index } -type IndexWriter func(index resource.ResourceIndex) (int64, error) - -var noop IndexWriter = func(index resource.ResourceIndex) (int64, error) { +var noop resource.BuildFn = func(index resource.ResourceIndex) (int64, error) { return 0, nil } diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index fbec10dc9f2..f48b74a5b7f 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -4,20 +4,23 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "math" "os" "path/filepath" + "sync" "testing" "time" "github.com/blevesearch/bleve/v2" + authlib "github.com/grafana/authlib/types" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - authlib "github.com/grafana/authlib/types" + "go.uber.org/atomic" + "go.uber.org/goleak" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" @@ -30,6 +33,17 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) +// This verifies that we close all indexes properly and shutdown all background goroutines from our tests. +// (Except for goroutines running specific functions. If possible we should fix this, esp. our own updateIndexSizeMetric.) +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m, + goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startEventListener.func1.1"), + goleak.IgnoreTopFunction("go.opencensus.io/stats/view.(*worker).start"), + goleak.IgnoreTopFunction("github.com/blevesearch/bleve_index_api.AnalysisWorker"), // These don't stop when index is closed. + goleak.IgnoreAnyFunction("github.com/grafana/grafana/pkg/storage/unified/search.(*bleveBackend).updateIndexSizeMetric"), // We don't have a way to stop this one yet. + ) +} + func TestBleveBackend(t *testing.T) { dashboardskey := &resourcepb.ResourceKey{ Namespace: "default", @@ -176,7 +190,7 @@ func TestBleveBackend(t *testing.T) { return 0, err } return rv, nil - }) + }, nil) require.NoError(t, err) require.NotNil(t, index) dashboardsIndex = index @@ -405,7 +419,7 @@ func TestBleveBackend(t *testing.T) { return 0, err } return rv, nil - }) + }, nil) require.NoError(t, err) require.NotNil(t, index) foldersIndex = index @@ -768,7 +782,7 @@ func TestBleveInMemoryIndexExpiration(t *testing.T) { Resource: "resource", } - builtIndex, err := backend.BuildIndex(context.Background(), ns, 1 /* below FileThreshold */, 100, nil, "test", indexTestDocs(ns, 1, 100)) + builtIndex, err := backend.BuildIndex(context.Background(), ns, 1 /* below FileThreshold */, 100, nil, "test", indexTestDocs(ns, 1, 100), nil) require.NoError(t, err) // Wait for index expiration, which is 1ns @@ -800,7 +814,7 @@ func TestBleveFileIndexExpiration(t *testing.T) { } // size=100 is above FileThreshold, this will be file-based index - builtIndex, err := backend.BuildIndex(context.Background(), ns, 100, 100, nil, "test", indexTestDocs(ns, 1, 100)) + builtIndex, err := backend.BuildIndex(context.Background(), ns, 100, 100, nil, "test", indexTestDocs(ns, 1, 100), nil) require.NoError(t, err) // Wait for index expiration, which is 1ns @@ -832,7 +846,7 @@ func TestFileIndexIsReusedOnSameSizeAndRVLessThanIndexRV(t *testing.T) { tmpDir := t.TempDir() backend1, reg1 := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - _, err := backend1.BuildIndex(context.Background(), ns, 10 /* file based */, 100, nil, "test", indexTestDocs(ns, 10, 100)) + _, err := backend1.BuildIndex(context.Background(), ns, 10 /* file based */, 100, nil, "test", indexTestDocs(ns, 10, 100), nil) require.NoError(t, err) // Verify one open index. @@ -855,7 +869,7 @@ func TestFileIndexIsReusedOnSameSizeAndRVLessThanIndexRV(t *testing.T) { // We open new backend using same directory, and run indexing with same size (10) and RV (100). This should reuse existing index, and skip indexing. backend2, reg2 := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - idx, err := backend2.BuildIndex(context.Background(), ns, 10 /* file based */, 100, nil, "test", indexTestDocs(ns, 1000, 100)) + idx, err := backend2.BuildIndex(context.Background(), ns, 10 /* file based */, 100, nil, "test", indexTestDocs(ns, 1000, 100), nil) require.NoError(t, err) // Verify that we're reusing existing index and there is only 10 documents in it, not 1000. @@ -881,7 +895,7 @@ func TestFileIndexIsReusedOnSameSizeAndRVLessThanIndexRV(t *testing.T) { // We repeat with backend3 and RV 99. This should also reuse existing index and skip indexing backend3, reg3 := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - idx, err = backend3.BuildIndex(context.Background(), ns, 10 /* file based */, 99, nil, "test", indexTestDocs(ns, 1000, 99)) + idx, err = backend3.BuildIndex(context.Background(), ns, 10 /* file based */, 99, nil, "test", indexTestDocs(ns, 1000, 99), nil) require.NoError(t, err) // Verify that we're reusing existing index and there is only 10 documents in it, not 1000. @@ -909,13 +923,13 @@ func TestFileIndexIsNotReusedOnDifferentSize(t *testing.T) { tmpDir := t.TempDir() backend1, _ := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - _, err := backend1.BuildIndex(context.Background(), ns, 10, 100, nil, "test", indexTestDocs(ns, 10, 100)) + _, err := backend1.BuildIndex(context.Background(), ns, 10, 100, nil, "test", indexTestDocs(ns, 10, 100), nil) require.NoError(t, err) backend1.CloseAllIndexes() // We open new backend using same directory, but with different size. Index should be rebuilt. backend2, _ := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - idx, err := backend2.BuildIndex(context.Background(), ns, 100, 100, nil, "test", indexTestDocs(ns, 100, 100)) + idx, err := backend2.BuildIndex(context.Background(), ns, 100, 100, nil, "test", indexTestDocs(ns, 100, 100), nil) require.NoError(t, err) // Verify that index has updated number of documents. @@ -934,13 +948,13 @@ func TestFileIndexIsNotReusedOnDifferentRV(t *testing.T) { tmpDir := t.TempDir() backend1, _ := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - _, err := backend1.BuildIndex(context.Background(), ns, 10, 100, nil, "test", indexTestDocs(ns, 10, 100)) + _, err := backend1.BuildIndex(context.Background(), ns, 10, 100, nil, "test", indexTestDocs(ns, 10, 100), nil) require.NoError(t, err) backend1.CloseAllIndexes() // We open new backend using same directory, but with different RV. Index should be rebuilt. backend2, _ := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - idx, err := backend2.BuildIndex(context.Background(), ns, 10 /* file based */, 999999, nil, "test", indexTestDocs(ns, 100, 999999)) + idx, err := backend2.BuildIndex(context.Background(), ns, 10 /* file based */, 999999, nil, "test", indexTestDocs(ns, 100, 999999), nil) require.NoError(t, err) // Verify that index has updated number of documents. @@ -972,7 +986,7 @@ func TestRebuildingIndexClosesPreviousCachedIndex(t *testing.T) { if testCase.firstInMemory { firstSize = 1 } - firstIndex, err := backend.BuildIndex(context.Background(), ns, int64(firstSize), 100, nil, "test", indexTestDocs(ns, firstSize, 100)) + firstIndex, err := backend.BuildIndex(context.Background(), ns, int64(firstSize), 100, nil, "test", indexTestDocs(ns, firstSize, 100), nil) require.NoError(t, err) if testCase.firstInMemory { @@ -988,7 +1002,7 @@ func TestRebuildingIndexClosesPreviousCachedIndex(t *testing.T) { secondSize = 1 openInMemoryIndexes = 1 } - secondIndex, err := backend.BuildIndex(context.Background(), ns, int64(secondSize), 100, nil, "test", indexTestDocs(ns, secondSize, 100)) + secondIndex, err := backend.BuildIndex(context.Background(), ns, int64(secondSize), 100, nil, "test", indexTestDocs(ns, secondSize, 100), nil) require.NoError(t, err) if testCase.secondInMemory { @@ -1030,7 +1044,7 @@ func verifyDirEntriesCount(t *testing.T, dir string, count int) { require.Len(t, ents, count) } -func indexTestDocs(ns resource.NamespacedResource, docs int, listRV int64) func(index resource.ResourceIndex) (int64, error) { +func indexTestDocs(ns resource.NamespacedResource, docs int, listRV int64) resource.BuildFn { return func(index resource.ResourceIndex) (int64, error) { var items []*resource.BulkIndexItem for i := 0; i < docs; i++ { @@ -1053,6 +1067,34 @@ func indexTestDocs(ns resource.NamespacedResource, docs int, listRV int64) func( } } +func updateTestDocs(ns resource.NamespacedResource, docs int) resource.UpdateFn { + cnt := 0 + + return func(context context.Context, index resource.ResourceIndex, sinceRV int64) (newRV int64, updatedDocs int, _ error) { + cnt++ + + var items []*resource.BulkIndexItem + for i := 0; i < docs; i++ { + items = append(items, &resource.BulkIndexItem{ + Action: resource.ActionIndex, + Doc: &resource.IndexableDocument{ + Key: &resourcepb.ResourceKey{ + Namespace: ns.Namespace, + Group: ns.Group, + Resource: ns.Resource, + Name: fmt.Sprintf("doc%d", i), + }, + Title: fmt.Sprintf("Document %d (gen_%d)", i, cnt), + }, + }) + } + + err := index.BulkIndex(&resource.BulkIndexRequest{Items: items}) + // Simulate RV increase + return sinceRV + int64(docs), docs, err + } +} + func TestCleanOldIndexes(t *testing.T) { dir := t.TempDir() @@ -1107,10 +1149,295 @@ func testBleveIndexWithFailures(t *testing.T, fileBased bool) { } _, err := backend.BuildIndex(context.Background(), ns, size, 100, nil, "test", func(index resource.ResourceIndex) (int64, error) { return 0, fmt.Errorf("fail") - }) + }, nil) require.Error(t, err) // Even though previous build of the index failed, new building of the index should work. - _, err = backend.BuildIndex(context.Background(), ns, size, 100, nil, "test", indexTestDocs(ns, int(size), 100)) + _, err = backend.BuildIndex(context.Background(), ns, size, 100, nil, "test", indexTestDocs(ns, int(size), 100), nil) require.NoError(t, err) } + +func TestIndexUpdate(t *testing.T) { + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + be, _ := setupBleveBackend(t, 5, 1*time.Minute, "") + idx, err := be.BuildIndex(t.Context(), ns, 10 /* file based */, 100, nil, "test", indexTestDocs(ns, 10, 100), updateTestDocs(ns, 5)) + require.NoError(t, err) + + resp := searchTitle(t, idx, "gen", 10, ns) + require.Equal(t, int64(0), resp.TotalHits) + + // Update index. + _, err = idx.UpdateIndex(context.Background(), "test") + require.NoError(t, err) + + // Verify that index was updated -- number of docs didn't change, but we can search "gen_1" documents now. + require.Equal(t, 10, docCount(t, idx)) + require.Equal(t, int64(5), searchTitle(t, idx, "gen_1", 10, ns).TotalHits) + + // Update index again. + _, err = idx.UpdateIndex(context.Background(), "test") + require.NoError(t, err) + // Verify that index was updated again -- we can search "gen_2" now. "gen_1" documents are gone. + require.Equal(t, 10, docCount(t, idx)) + require.Equal(t, int64(0), searchTitle(t, idx, "gen_1", 10, ns).TotalHits) + require.Equal(t, int64(5), searchTitle(t, idx, "gen_2", 10, ns).TotalHits) +} + +func TestConcurrentIndexUpdateAndBuildIndex(t *testing.T) { + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + be, _ := setupBleveBackend(t, 5, 1*time.Minute, "") + + updaterFn := func(context context.Context, index resource.ResourceIndex, sinceRV int64) (newRV int64, updatedDocs int, _ error) { + var items []*resource.BulkIndexItem + for i := 0; i < 5; i++ { + items = append(items, &resource.BulkIndexItem{ + Action: resource.ActionIndex, + Doc: &resource.IndexableDocument{ + Key: &resourcepb.ResourceKey{ + Namespace: ns.Namespace, + Group: ns.Group, + Resource: ns.Resource, + Name: fmt.Sprintf("doc%d", i), + }, + Title: fmt.Sprintf("Document %d (gen_%d)", i, 5), + }, + }) + } + + err := index.BulkIndex(&resource.BulkIndexRequest{Items: items}) + // Simulate RV increase + return sinceRV + int64(5), 5, err + } + + idx, err := be.BuildIndex(t.Context(), ns, 10 /* file based */, 100, nil, "test", indexTestDocs(ns, 10, 100), updaterFn) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _, err = idx.UpdateIndex(ctx, "test") + require.NoError(t, err) + + _, err = be.BuildIndex(t.Context(), ns, 10 /* file based */, 100, nil, "test", indexTestDocs(ns, 10, 100), updaterFn) + require.NoError(t, err) + + _, err = idx.UpdateIndex(ctx, "test") + require.Contains(t, err.Error(), bleve.ErrorIndexClosed.Error()) +} + +func TestConcurrentIndexUpdateSearchAndRebuild(t *testing.T) { + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + be, _ := setupBleveBackend(t, 5, 1*time.Minute, "") + + _, err := be.BuildIndex(t.Context(), ns, 10, 0, nil, "test", indexTestDocs(ns, 10, 100), updateTestDocs(ns, 5)) + require.NoError(t, err) + + wg := sync.WaitGroup{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + rebuilds := atomic.NewInt64(0) + updates := atomic.NewInt64(0) + searches := atomic.NewInt64(0) + const searchConcurrency = 25 + for i := 0; i < searchConcurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + for ctx.Err() == nil { + select { + case <-ctx.Done(): + return + case <-time.After(time.Duration(i) * time.Millisecond): // introduce small jitter + } + + idx, err := be.GetIndex(ctx, ns) + require.NoError(t, err) // GetIndex doesn't really return error. + + _, err = idx.UpdateIndex(ctx, "test") + if err != nil { + if errors.Is(err, bleve.ErrorIndexClosed) || errors.Is(err, context.Canceled) { + continue + } + require.NoError(t, err) + } + updates.Inc() + + resp, err := idx.Search(ctx, nil, &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Namespace: ns.Namespace, + Group: ns.Group, + Resource: ns.Resource, + }, + }, + Fields: []string{"title"}, + Query: "Document", + Limit: 10, + }, nil) + if err != nil { + if errors.Is(err, bleve.ErrorIndexClosed) || errors.Is(err, context.Canceled) { + continue + } + require.NoError(t, err) + } + require.Equal(t, int64(10), resp.TotalHits) + searches.Inc() + } + }() + } + + wg.Add(1) + go func() { + defer wg.Done() + for ctx.Err() == nil { + _, err := be.BuildIndex(t.Context(), ns, 10, 0, nil, "test", indexTestDocs(ns, 10, 100), updateTestDocs(ns, 5)) + require.NoError(t, err) + rebuilds.Inc() + } + }() + + time.Sleep(5 * time.Second) + cancel() + wg.Wait() + + fmt.Println("Updates:", updates.Load(), "searches:", searches.Load(), "rebuilds:", rebuilds.Load()) +} + +// Verify concurrent updates and searches work as expected. +func TestConcurrentIndexUpdateAndSearch(t *testing.T) { + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + be, _ := setupBleveBackend(t, 5, 1*time.Minute, "") + + idx, err := be.BuildIndex(t.Context(), ns, 10 /* file based */, 100, nil, "test", indexTestDocs(ns, 10, 100), updateTestDocs(ns, 5)) + require.NoError(t, err) + + wg := sync.WaitGroup{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // We count how many goroutines received given updated RV. We expect at least some RVs to be returned to multiple + // goroutines, if batching works. + mu := sync.Mutex{} + updatedRVs := map[int64]int{} + + const searchConcurrency = 25 + for i := 0; i < searchConcurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + prevRV := int64(0) + for ctx.Err() == nil { + // We use t.Context() here to avoid getting errors from context cancellation. + rv, err := idx.UpdateIndex(t.Context(), "test") + require.NoError(t, err) + require.Greater(t, rv, prevRV) // Each update should return new RV (that's how our update function works) + require.Equal(t, int64(10), searchTitle(t, idx, "Document", 10, ns).TotalHits) + prevRV = rv + + mu.Lock() + updatedRVs[rv]++ + mu.Unlock() + } + }() + } + + time.Sleep(1 * time.Second) + cancel() + wg.Wait() + + // Check that some RVs were updated due to requests from multiple goroutines + var rvUpdatedByMultipleGoroutines int64 + for rv, count := range updatedRVs { + if count > 1 { + rvUpdatedByMultipleGoroutines = rv + break + } + } + require.Greater(t, rvUpdatedByMultipleGoroutines, int64(0)) +} + +// Verify concurrent updates and searches work as expected. +func TestIndexUpdateWithErrors(t *testing.T) { + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + be, _ := setupBleveBackend(t, 5, 1*time.Minute, "") + + updateErr := fmt.Errorf("failed to update index") + updaterFn := func(context context.Context, index resource.ResourceIndex, sinceRV int64) (newRV int64, updatedDocs int, _ error) { + time.Sleep(100 * time.Millisecond) + return 0, 0, updateErr + } + idx, err := be.BuildIndex(t.Context(), ns, 10 /* file based */, 100, nil, "test", indexTestDocs(ns, 10, 100), updaterFn) + require.NoError(t, err) + + t.Run("update fail", func(t *testing.T) { + _, err = idx.UpdateIndex(t.Context(), "test") + require.ErrorIs(t, err, updateErr) + }) + + t.Run("update timeout", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + _, err = idx.UpdateIndex(ctx, "test") + require.ErrorIs(t, err, context.DeadlineExceeded) + }) + + t.Run("context canceled", func(t *testing.T) { + // Canceled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = idx.UpdateIndex(ctx, "test") + require.ErrorIs(t, err, context.Canceled) + }) +} + +func searchTitle(t *testing.T, idx resource.ResourceIndex, query string, limit int, ns resource.NamespacedResource) *resourcepb.ResourceSearchResponse { + resp, err := idx.Search(t.Context(), nil, &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Namespace: ns.Namespace, + Group: ns.Group, + Resource: ns.Resource, + }, + }, + Fields: []string{"title"}, + Query: query, + Limit: int64(limit), + }, nil) + require.NoError(t, err) + return resp +} + +func docCount(t *testing.T, idx resource.ResourceIndex) int { + cnt, err := idx.DocCount(context.Background(), "") + require.NoError(t, err) + return int(cnt) +} diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go index 0a68a1ec138..0093da44264 100644 --- a/pkg/storage/unified/search/options.go +++ b/pkg/storage/unified/search/options.go @@ -4,10 +4,11 @@ import ( "os" "path/filepath" + "go.opentelemetry.io/otel/trace" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" - "go.opentelemetry.io/otel/trace" ) func NewSearchOptions(features featuremgmt.FeatureToggles, cfg *setting.Cfg, tracer trace.Tracer, docs resource.DocumentBuilderSupplier, indexMetrics *resource.BleveIndexMetrics) (resource.SearchOptions, error) { diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index 6d2639007db..4037e4253c6 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -2,6 +2,7 @@ package sql import ( "context" + "fmt" "os" "strings" @@ -13,6 +14,7 @@ import ( "github.com/grafana/dskit/services" infraDB "github.com/grafana/grafana/pkg/infra/db" secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + inlinesecurevalue "github.com/grafana/grafana/pkg/registry/apis/secret/inline" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" @@ -48,6 +50,20 @@ func NewResourceServer( opts ServerOptions, ) (resource.ResourceServer, error) { apiserverCfg := opts.Cfg.SectionWithEnvOverrides("grafana-apiserver") + + if opts.SecureValues == nil && opts.Cfg != nil && opts.Cfg.SecretsManagement.GrpcClientEnable { + inlineSecureValueService, err := inlinesecurevalue.ProvideInlineSecureValueService( + opts.Cfg, + opts.Tracer, + nil, // not needed for gRPC client mode + nil, // not needed for gRPC client mode + ) + if err != nil { + return nil, fmt.Errorf("failed to create inline secure value service: %w", err) + } + opts.SecureValues = inlineSecureValueService + } + serverOptions := resource.ResourceServerOptions{ Tracer: opts.Tracer, Blob: resource.BlobConfig{ diff --git a/pkg/storage/unified/testing/benchmark.go b/pkg/storage/unified/testing/benchmark.go index dcdf113c212..964f2faeba0 100644 --- a/pkg/storage/unified/testing/benchmark.go +++ b/pkg/storage/unified/testing/benchmark.go @@ -217,7 +217,7 @@ func runSearchBackendBenchmarkWriteThroughput(ctx context.Context, backend resou size := int64(10000) // force the index to be on disk index, err := backend.BuildIndex(ctx, nr, size, 0, nil, "benchmark", func(index resource.ResourceIndex) (int64, error) { return 0, nil - }) + }, nil) if err != nil { return nil, fmt.Errorf("failed to initialize backend: %w", err) } diff --git a/pkg/storage/unified/testing/search_backend.go b/pkg/storage/unified/testing/search_backend.go index 52af572b01a..a91915f0076 100644 --- a/pkg/storage/unified/testing/search_backend.go +++ b/pkg/storage/unified/testing/search_backend.go @@ -86,7 +86,7 @@ func runTestSearchBackendBuildIndex(t *testing.T, backend resource.SearchBackend return 0, err } return 1, nil - }) + }, nil) require.NoError(t, err) require.NotNil(t, index) @@ -152,7 +152,7 @@ func runTestResourceIndex(t *testing.T, backend resource.SearchBackend, nsPrefix }) require.NoError(t, err) return int64(2), nil - }) + }, nil) require.NoError(t, err) require.NotNil(t, index) @@ -294,7 +294,7 @@ func runTestResourceIndex(t *testing.T, backend resource.SearchBackend, nsPrefix }) require.NoError(t, err) return int64(3), nil - }) + }, nil) require.NoError(t, err) require.NotNil(t, index) diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index 8ca7e3bf4a0..514781017be 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -1233,15 +1233,12 @@ "description": "ConversionStatus is the status of the conversion of the dashboard.", "type": "object", "required": [ - "failed", - "storedVersion", - "error" + "failed" ], "properties": { "error": { "description": "The error message from the conversion. Empty if the conversion has not failed.", - "type": "string", - "default": "" + "type": "string" }, "failed": { "description": "Whether from another version has failed. If true, means that the dashboard is not valid, and the caller should instead fetch the stored version.", @@ -1250,8 +1247,7 @@ }, "storedVersion": { "description": "The version which was stored when the dashboard was created / updated. Fetching this version should always succeed.", - "type": "string", - "default": "" + "type": "string" } } }, diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v1beta1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v1beta1.json index b93e2c4642e..f025701ec73 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v1beta1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v1beta1.json @@ -1063,15 +1063,12 @@ "description": "ConversionStatus is the status of the conversion of the dashboard.", "type": "object", "required": [ - "failed", - "storedVersion", - "error" + "failed" ], "properties": { "error": { "description": "The error message from the conversion. Empty if the conversion has not failed.", - "type": "string", - "default": "" + "type": "string" }, "failed": { "description": "Whether from another version has failed. If true, means that the dashboard is not valid, and the caller should instead fetch the stored version.", @@ -1080,8 +1077,7 @@ }, "storedVersion": { "description": "The version which was stored when the dashboard was created / updated. Fetching this version should always succeed.", - "type": "string", - "default": "" + "type": "string" } } }, diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json index b5e33c9e980..aab8e377285 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json @@ -1655,15 +1655,12 @@ "description": "ConversionStatus is the status of the conversion of the dashboard.", "type": "object", "required": [ - "failed", - "storedVersion", - "error" + "failed" ], "properties": { "error": { "description": "The error message from the conversion. Empty if the conversion has not failed.", - "type": "string", - "default": "" + "type": "string" }, "failed": { "description": "Whether from another version has failed. If true, means that the dashboard is not valid, and the caller should instead fetch the stored version.", @@ -1672,8 +1669,7 @@ }, "storedVersion": { "description": "The version which was stored when the dashboard was created / updated. Fetching this version should always succeed.", - "type": "string", - "default": "" + "type": "string" } } }, diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index a7989e33e4e..7b509d4ccec 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -43,6 +43,98 @@ ], "description": "list or watch objects of kind Job", "operationId": "listJob", + "parameters": [ + { + "name": "allowWatchBookmarks", + "in": "query", + "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "watch", + "in": "query", + "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], "responses": { "200": { "description": "OK", @@ -82,52 +174,285 @@ "kind": "Job" } }, + "post": { + "tags": [ + "Job" + ], + "description": "create a Job", + "operationId": "createJob", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + } + } + } + }, + "x-kubernetes-action": "post", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Job" + } + }, + "delete": { + "tags": [ + "Job" + ], + "description": "delete collection of Job", + "operationId": "deletecollectionJob", + "parameters": [ + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "deletecollection", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Job" + } + }, "parameters": [ - { - "name": "allowWatchBookmarks", - "in": "query", - "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "continue", - "in": "query", - "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "labelSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "limit", - "in": "query", - "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, { "name": "namespace", "in": "path", @@ -146,51 +471,6 @@ "type": "string", "uniqueItems": true } - }, - { - "name": "resourceVersion", - "in": "query", - "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersionMatch", - "in": "query", - "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "sendInitialEvents", - "in": "query", - "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "timeoutSeconds", - "in": "query", - "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "watch", - "in": "query", - "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", - "schema": { - "type": "boolean", - "uniqueItems": true - } } ] }, @@ -230,6 +510,330 @@ "kind": "Job" } }, + "put": { + "tags": [ + "Job" + ], + "description": "replace the specified Job", + "operationId": "replaceJob", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + } + } + } + }, + "x-kubernetes-action": "put", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Job" + } + }, + "delete": { + "tags": [ + "Job" + ], + "description": "delete a Job", + "operationId": "deleteJob", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "delete", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Job" + } + }, + "patch": { + "tags": [ + "Job" + ], + "description": "partially update the specified Job", + "operationId": "updateJob", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch).", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "force", + "in": "query", + "description": "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/apply-patch+yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/strategic-merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" + } + } + } + } + }, + "x-kubernetes-action": "patch", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "Job" + } + }, "parameters": [ { "name": "name", diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go index 3e7263d1de2..221a7c086c8 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "fmt" "math/rand" "strings" @@ -213,7 +212,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { cnnstr := postgresTestDBConnString() - p, exe, err := newPostgresPGX(context.Background(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) + p, exe, err := newPostgresPGX(t.Context(), "error", 10000, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) require.NoError(t, err) @@ -246,7 +245,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { c16_smallint smallint ); ` - _, err := p.Exec(context.Background(), sql) + _, err := p.Exec(t.Context(), sql) require.NoError(t, err) sql = ` @@ -259,7 +258,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { null ); ` - _, err = p.Exec(context.Background(), sql) + _, err = p.Exec(t.Context(), sql) require.NoError(t, err) t.Run("When doing a table query should map Postgres column types to Go types", func(t *testing.T) { @@ -274,7 +273,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -322,7 +321,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { ) ` - _, err := p.Exec(context.Background(), sql) + _, err := p.Exec(t.Context(), sql) require.NoError(t, err) type metric struct { @@ -349,7 +348,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { } for _, m := range series { - _, err := p.Exec(context.Background(), `INSERT INTO metric ("time", value) VALUES ($1, $2)`, m.Time.UTC(), m.Value) + _, err := p.Exec(t.Context(), `INSERT INTO metric ("time", value) VALUES ($1, $2)`, m.Time.UTC(), m.Value) require.NoError(t, err) } @@ -366,7 +365,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -398,6 +397,27 @@ func TestIntegrationPostgresPGX(t *testing.T) { } }) + t.Run("When doing a query without a format should default to time_series", func(t *testing.T) { + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + JSON: []byte(`{ + "rawSql": "SELECT $__timeGroup(time, '5m') AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1 " + }`), + RefID: "A", + }, + }, + } + resp, err := exe.QueryDataPGX(t.Context(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Len(t, frames, 1) + require.Len(t, frames[0].Fields, 2) + }) + t.Run("When doing a metric query using timeGroup and $__interval", func(t *testing.T) { mockInterpolate := sqleng.Interpolate sqleng.Interpolate = origInterpolate @@ -422,7 +442,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] frames := queryResult.Frames @@ -450,7 +470,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -504,7 +524,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -530,7 +550,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { } for _, m := range series { - _, err := p.Exec(context.Background(), `INSERT INTO metric ("time", value) VALUES ($1, $2)`, m.Time.UTC(), m.Value) + _, err := p.Exec(t.Context(), `INSERT INTO metric ("time", value) VALUES ($1, $2)`, m.Time.UTC(), m.Value) require.NoError(t, err) } @@ -551,7 +571,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -586,7 +606,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -614,10 +634,10 @@ func TestIntegrationPostgresPGX(t *testing.T) { ValueTwo int64 } - _, err := p.Exec(context.Background(), "DROP TABLE IF EXISTS metric_values") + _, err := p.Exec(t.Context(), "DROP TABLE IF EXISTS metric_values") require.NoError(t, err) - _, err = p.Exec(context.Background(), `CREATE TABLE metric_values ( + _, err = p.Exec(t.Context(), `CREATE TABLE metric_values ( "time" TIMESTAMP NULL, "timeInt64" BIGINT NOT NULL, "timeInt64Nullable" BIGINT NULL, "timeFloat64" DOUBLE PRECISION NOT NULL, "timeFloat64Nullable" DOUBLE PRECISION NULL, @@ -670,7 +690,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { // _, err = session.InsertMulti(series) for _, m := range series { - _, err := p.Exec(context.Background(), `INSERT INTO "metric_values" ( + _, err := p.Exec(t.Context(), `INSERT INTO "metric_values" ( time, "timeInt64", "timeInt64Nullable", "timeFloat64", "timeFloat64Nullable", @@ -703,7 +723,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -727,7 +747,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -751,7 +771,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -775,7 +795,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -799,7 +819,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -823,7 +843,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -847,7 +867,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -872,7 +892,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -896,7 +916,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -921,7 +941,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -953,7 +973,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -988,7 +1008,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1007,9 +1027,9 @@ func TestIntegrationPostgresPGX(t *testing.T) { Tags string } - _, err := p.Exec(context.Background(), "DROP TABLE IF EXISTS event") + _, err := p.Exec(t.Context(), "DROP TABLE IF EXISTS event") require.NoError(t, err) - _, err = p.Exec(context.Background(), `CREATE TABLE event (time_sec BIGINT NULL, description VARCHAR(255) NULL, tags VARCHAR(255) NULL)`) + _, err = p.Exec(t.Context(), `CREATE TABLE event (time_sec BIGINT NULL, description VARCHAR(255) NULL, tags VARCHAR(255) NULL)`) require.NoError(t, err) events := []*event{} @@ -1027,7 +1047,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { } for _, e := range events { - _, err := p.Exec(context.Background(), "INSERT INTO event (time_sec, description, tags) VALUES ($1, $2, $3)", e.TimeSec, e.Description, e.Tags) + _, err := p.Exec(t.Context(), "INSERT INTO event (time_sec, description, tags) VALUES ($1, $2, $3)", e.TimeSec, e.Description, e.Tags) require.NoError(t, err) } @@ -1048,7 +1068,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["Deploys"] @@ -1075,7 +1095,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["Tickets"] @@ -1098,7 +1118,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1123,7 +1143,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1148,7 +1168,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1174,7 +1194,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1200,7 +1220,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1226,7 +1246,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1252,7 +1272,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1279,7 +1299,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { JsonData: jsonData, DecryptedSecureJSONData: map[string]string{}, } - _, handler, err := newPostgresPGX(context.Background(), "error", 1, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) + _, handler, err := newPostgresPGX(t.Context(), "error", 1, dsInfo, cnnstr, logger, backend.DataSourceInstanceSettings{}) require.NoError(t, err) @@ -1300,7 +1320,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := handler.QueryDataPGX(context.Background(), query) + resp, err := handler.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1330,7 +1350,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := handler.QueryDataPGX(context.Background(), query) + resp, err := handler.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] require.NoError(t, queryResult.Error) @@ -1346,9 +1366,9 @@ func TestIntegrationPostgresPGX(t *testing.T) { }) t.Run("Given an empty table", func(t *testing.T) { - _, err := p.Exec(context.Background(), "DROP TABLE IF EXISTS empty_obj") + _, err := p.Exec(t.Context(), "DROP TABLE IF EXISTS empty_obj") require.NoError(t, err) - _, err = p.Exec(context.Background(), "CREATE TABLE empty_obj (empty_key VARCHAR(255) NULL, empty_val BIGINT NULL)") + _, err = p.Exec(t.Context(), "CREATE TABLE empty_obj (empty_key VARCHAR(255) NULL, empty_val BIGINT NULL)") require.NoError(t, err) t.Run("When no rows are returned, should return an empty frame", func(t *testing.T) { @@ -1368,7 +1388,7 @@ func TestIntegrationPostgresPGX(t *testing.T) { }, } - resp, err := exe.QueryDataPGX(context.Background(), query) + resp, err := exe.QueryDataPGX(t.Context(), query) require.NoError(t, err) queryResult := resp.Responses["A"] diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_pgx.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_pgx.go index df182a3119f..458f5364bc0 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_pgx.go +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_pgx.go @@ -345,13 +345,14 @@ func (e *DataSourceHandler) newProcessCfgPGX(queryContext context.Context, query qm.TimeRange.From = query.TimeRange.From.UTC() qm.TimeRange.To = query.TimeRange.To.UTC() + // Default to time_series if no format is provided switch queryJSON.Format { - case "time_series": - qm.Format = dataQueryFormatSeries case "table": qm.Format = dataQueryFormatTable + case "time_series": + fallthrough default: - panic(fmt.Sprintf("Unrecognized query model format: %q", queryJSON.Format)) + qm.Format = dataQueryFormatSeries } for i, col := range qm.columnNames { diff --git a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go index 8edddb0710a..b5f417e90b9 100644 --- a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go @@ -42,6 +42,8 @@ type TempoQuery struct { ServiceMapQuery *StringOrArrayOfString `json:"serviceMapQuery,omitempty"` // Use service.namespace in addition to service.name to uniquely identify a service. ServiceMapIncludeNamespace *bool `json:"serviceMapIncludeNamespace,omitempty"` + // Whether to use native histograms for service map queries + ServiceMapUseNativeHistograms *bool `json:"serviceMapUseNativeHistograms,omitempty"` // Defines the maximum number of traces that are returned from Tempo Limit *int64 `json:"limit,omitempty"` // Defines the maximum number of spans per spanset that are returned from Tempo diff --git a/public/app/api/clients/folder/v1beta1/hooks.test.ts b/public/app/api/clients/folder/v1beta1/hooks.test.ts index 5c3936a4847..5c7a4a2bf61 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.test.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.test.ts @@ -1,12 +1,34 @@ import { renderHook, getWrapper, waitFor } from 'test/test-utils'; +import { AppEvents } from '@grafana/data'; import { config, setBackendSrv } from '@grafana/runtime'; import { setupMockServer } from '@grafana/test-utils/server'; import { getFolderFixtures } from '@grafana/test-utils/unstable'; import { backendSrv } from 'app/core/services/backend_srv'; +import { useDeleteFoldersMutation as useDeleteFoldersMutationLegacy } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; -import { useGetFolderQueryFacade } from './hooks'; +import { useGetFolderQueryFacade, useDeleteMultipleFoldersMutationFacade } from './hooks'; +import { useDeleteFolderMutation } from './index'; + +// Mocks for the hooks used inside useGetFolderQueryFacade +jest.mock('./index', () => ({ + ...jest.requireActual('./index'), + useDeleteFolderMutation: jest.fn(), +})); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getAppEvents: jest.fn(() => ({ + publish: jest.fn(), + })), +})); +const mockGetAppEvents = jest.mocked(require('@grafana/runtime').getAppEvents); + +jest.mock('app/features/browse-dashboards/api/browseDashboardsAPI', () => ({ + ...jest.requireActual('app/features/browse-dashboards/api/browseDashboardsAPI'), + useDeleteFoldersMutation: jest.fn(), +})); setBackendSrv(backendSrv); setupMockServer(); @@ -105,3 +127,61 @@ describe('useGetFolderQueryFacade', () => { }); }); }); + +describe('useDeleteMultipleFoldersMutationFacade', () => { + const dispatchMock = jest.fn(); + const mockDeleteFolder = jest.fn(() => ({ error: undefined })); + const mockDeleteFolderLegacy = jest.fn(() => ({ error: undefined })); + const publishMock = jest.fn(); + + const oldToggleValue = config.featureToggles.foldersAppPlatformAPI; + + afterAll(() => { + config.featureToggles.foldersAppPlatformAPI = oldToggleValue; + }); + + beforeEach(() => { + mockDeleteFolder.mockClear(); + mockDeleteFolderLegacy.mockClear(); + (useDeleteFolderMutation as jest.Mock).mockReturnValue([mockDeleteFolder]); + (useDeleteFoldersMutationLegacy as jest.Mock).mockReturnValue([mockDeleteFolderLegacy]); + + // Mock useDispatch + jest.spyOn(require('../../../../types/store'), 'useDispatch').mockReturnValue(dispatchMock); + }); + + it('deletes multiple folders and publishes success alert', async () => { + mockGetAppEvents.mockReturnValue({ + publish: publishMock, + }); + config.featureToggles.foldersAppPlatformAPI = true; + const folderUIDs = ['uid1', 'uid2']; + const deleteFolders = useDeleteMultipleFoldersMutationFacade(); + await deleteFolders({ folderUIDs }); + + // Should call deleteFolder for each UID + expect(mockDeleteFolder).toHaveBeenCalledTimes(folderUIDs.length); + expect(mockDeleteFolder).toHaveBeenCalledWith({ name: 'uid1' }); + expect(mockDeleteFolder).toHaveBeenCalledWith({ name: 'uid2' }); + + // Should publish success alert + expect(publishMock).toHaveBeenCalledWith({ + type: AppEvents.alertSuccess.name, + payload: ['Folder deleted'], + }); + + // Should dispatch refreshParents + expect(dispatchMock).toHaveBeenCalled(); + }); + + it('uses legacy call when flag is false', async () => { + config.featureToggles.foldersAppPlatformAPI = false; + const folderUIDs = ['uid1', 'uid2']; + const deleteFolders = useDeleteMultipleFoldersMutationFacade(); + await deleteFolders({ folderUIDs }); + + // Should call deleteFolder for each UID + expect(mockDeleteFolderLegacy).toHaveBeenCalledTimes(1); + expect(mockDeleteFolderLegacy).toHaveBeenCalledWith({ folderUIDs }); + }); +}); diff --git a/public/app/api/clients/folder/v1beta1/hooks.ts b/public/app/api/clients/folder/v1beta1/hooks.ts index bda84c1c1da..ae7c6cd9859 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.ts @@ -6,6 +6,7 @@ import { config, getAppEvents } from '@grafana/runtime'; import { useDeleteFolderMutation as useDeleteFolderMutationLegacy, useGetFolderQuery as useGetFolderQueryLegacy, + useDeleteFoldersMutation as useDeleteFoldersMutationLegacy, } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { FolderDTO } from 'app/types/folders'; @@ -20,11 +21,12 @@ import { ManagerKind, } from '../../../../features/apiserver/types'; import { PAGE_SIZE } from '../../../../features/browse-dashboards/api/services'; -import { refetchChildren } from '../../../../features/browse-dashboards/state/actions'; +import { refetchChildren, refreshParents } from '../../../../features/browse-dashboards/state/actions'; import { GENERAL_FOLDER_UID } from '../../../../features/search/constants'; import { useDispatch } from '../../../../types/store'; import { useGetDisplayMappingQuery } from '../../iam/v0alpha1'; +import { isProvisionedFolderCheck } from './utils'; import { rootFolder, sharedWithMeFolder } from './virtualFolders'; import { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation } from './index'; @@ -190,6 +192,38 @@ export function useDeleteFolderMutationFacade() { }; } +export function useDeleteMultipleFoldersMutationFacade() { + const [deleteFolders] = useDeleteFoldersMutationLegacy(); + const [deleteFolder] = useDeleteFolderMutation(); + const dispatch = useDispatch(); + + if (!config.featureToggles.foldersAppPlatformAPI) { + return deleteFolders; + } + + return async function deleteFolders({ folderUIDs }: { folderUIDs: string[] }) { + // Delete all the folders sequentially + // TODO error handling here + for (const folderUID of folderUIDs) { + // This also shows warning alert + if (await isProvisionedFolderCheck(dispatch, folderUID)) { + continue; + } + const result = await deleteFolder({ name: folderUID }); + if (!result.error) { + // Before this was done in backend srv automatically because the old API sent a message wiht 200 request. see + // public/app/core/services/backend_srv.ts#L341-L361. New API does not do that so we do it here. + getAppEvents().publish({ + type: AppEvents.alertSuccess.name, + payload: [t('folders.api.folder-deleted-success', 'Folder deleted')], + }); + dispatch(refreshParents(folderUIDs)); + } + } + return { data: undefined }; + }; +} + function combinedState( result: ReturnType, resultParents: ReturnType, diff --git a/public/app/api/clients/folder/v1beta1/index.ts b/public/app/api/clients/folder/v1beta1/index.ts index 9e693879053..748159f1b46 100644 --- a/public/app/api/clients/folder/v1beta1/index.ts +++ b/public/app/api/clients/folder/v1beta1/index.ts @@ -1,6 +1,25 @@ import { generatedAPI } from './endpoints.gen'; -export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({}); +export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({ + endpoints: { + getFolder: { + providesTags: (result, error, arg) => (result ? [{ type: 'Folder', id: arg.name }] : []), + }, + listFolder: { + providesTags: (result) => + result + ? [ + { type: 'Folder', id: 'LIST' }, + ...result.items.map((folder) => ({ type: 'Folder' as const, id: folder.metadata?.name })).filter(Boolean), + ] + : [{ type: 'Folder', id: 'LIST' }], + }, + deleteFolder: { + // We don't want delete to invalidate getFolder tags, as that would lead to unnecessary 404s + invalidatesTags: (result, error) => (error ? [] : [{ type: 'Folder', id: 'LIST' }]), + }, + }, +}); export const { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation } = folderAPIv1beta1; diff --git a/public/app/api/clients/folder/v1beta1/utils.ts b/public/app/api/clients/folder/v1beta1/utils.ts new file mode 100644 index 00000000000..96606671723 --- /dev/null +++ b/public/app/api/clients/folder/v1beta1/utils.ts @@ -0,0 +1,31 @@ +import { AppEvents } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; + +import appEvents from '../../../../core/app_events'; +import { isProvisionedFolder } from '../../../../features/browse-dashboards/api/isProvisioned'; +import { useDispatch } from '../../../../types/store'; + +import { folderAPIv1beta1 as folderAPI } from './index'; + +export async function isProvisionedFolderCheck(dispatch: ReturnType, folderUID: string) { + if (config.featureToggles.provisioning) { + const folder = await dispatch(folderAPI.endpoints.getFolder.initiate({ name: folderUID })); + // TODO: taken from browseDashboardAPI as it is, but this error handling should be moved up to UI code. + if (folder.data && isProvisionedFolder(folder.data)) { + appEvents.publish({ + type: AppEvents.alertWarning.name, + payload: [ + t( + 'folders.api.folder-delete-error-provisioned', + 'Cannot delete provisioned folder. To remove it, delete it from the repository and synchronise to apply the changes.' + ), + ], + }); + return true; + } + return false; + } else { + return false; + } +} diff --git a/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts index da5c0bfb9b3..9768cf8c130 100644 --- a/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts +++ b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts @@ -10,12 +10,12 @@ const injectedRtkApi = api query: (queryArg) => ({ url: `/jobs`, params: { + pretty: queryArg.pretty, allowWatchBookmarks: queryArg.allowWatchBookmarks, continue: queryArg['continue'], fieldSelector: queryArg.fieldSelector, labelSelector: queryArg.labelSelector, limit: queryArg.limit, - pretty: queryArg.pretty, resourceVersion: queryArg.resourceVersion, resourceVersionMatch: queryArg.resourceVersionMatch, sendInitialEvents: queryArg.sendInitialEvents, @@ -25,6 +25,43 @@ const injectedRtkApi = api }), providesTags: ['Job'], }), + createJob: build.mutation({ + query: (queryArg) => ({ + url: `/jobs`, + method: 'POST', + body: queryArg.job, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Job'], + }), + deletecollectionJob: build.mutation({ + query: (queryArg) => ({ + url: `/jobs`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + continue: queryArg['continue'], + dryRun: queryArg.dryRun, + fieldSelector: queryArg.fieldSelector, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + }, + }), + invalidatesTags: ['Job'], + }), getJob: build.query({ query: (queryArg) => ({ url: `/jobs/${queryArg.name}`, @@ -34,6 +71,35 @@ const injectedRtkApi = api }), providesTags: ['Job'], }), + replaceJob: build.mutation({ + query: (queryArg) => ({ + url: `/jobs/${queryArg.name}`, + method: 'PUT', + body: queryArg.job, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Job'], + }), + deleteJob: build.mutation({ + query: (queryArg) => ({ + url: `/jobs/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['Job'], + }), listRepository: build.query({ query: (queryArg) => ({ url: `/repositories`, @@ -296,6 +362,8 @@ const injectedRtkApi = api export { injectedRtkApi as generatedAPI }; export type ListJobApiResponse = /** status 200 OK */ JobList; export type ListJobApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ allowWatchBookmarks?: boolean; /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". @@ -310,8 +378,6 @@ export type ListJobApiArg = { The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ limit?: number; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. Defaults to unset */ @@ -339,6 +405,72 @@ export type ListJobApiArg = { /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ watch?: boolean; }; +export type CreateJobApiResponse = /** status 200 OK */ + | Job + | /** status 201 Created */ Job + | /** status 202 Accepted */ Job; +export type CreateJobApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + job: Job; +}; +export type DeletecollectionJobApiResponse = /** status 200 OK */ Status; +export type DeletecollectionJobApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; +}; export type GetJobApiResponse = /** status 200 OK */ Job; export type GetJobApiArg = { /** name of the Job */ @@ -346,6 +478,37 @@ export type GetJobApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ pretty?: string; }; +export type ReplaceJobApiResponse = /** status 200 OK */ Job | /** status 201 Created */ Job; +export type ReplaceJobApiArg = { + /** name of the Job */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + job: Job; +}; +export type DeleteJobApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status; +export type DeleteJobApiArg = { + /** name of the Job */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; export type ListRepositoryApiResponse = /** status 200 OK */ RepositoryList; export type ListRepositoryApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ @@ -910,6 +1073,50 @@ export type JobList = { kind?: string; metadata?: ListMeta; }; +export type StatusCause = { + /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. + + Examples: + "name" - the field "name" on the current resource + "items[0].name" - the field "name" on the first array entry in "items" */ + field?: string; + /** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */ + message?: string; + /** A machine-readable description of the cause of the error. If this value is empty there is no information available. */ + reason?: string; +}; +export type StatusDetails = { + /** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */ + causes?: StatusCause[]; + /** The group attribute of the resource associated with the status StatusReason. */ + group?: string; + /** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */ + name?: string; + /** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */ + retryAfterSeconds?: number; + /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type Status = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Suggested HTTP return code for this status, 0 if not set. */ + code?: number; + /** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */ + details?: StatusDetails; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** A human-readable description of the status of this operation. */ + message?: string; + /** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + metadata?: ListMeta; + /** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */ + reason?: string; + /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ + status?: string; +}; export type InlineSecureValue = | { /** Create a secure value -- this is only used for POST/PUT */ @@ -1111,50 +1318,6 @@ export type RepositoryList = { kind?: string; metadata?: ListMeta; }; -export type StatusCause = { - /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. - - Examples: - "name" - the field "name" on the current resource - "items[0].name" - the field "name" on the first array entry in "items" */ - field?: string; - /** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */ - message?: string; - /** A machine-readable description of the cause of the error. If this value is empty there is no information available. */ - reason?: string; -}; -export type StatusDetails = { - /** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */ - causes?: StatusCause[]; - /** The group attribute of the resource associated with the status StatusReason. */ - group?: string; - /** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - kind?: string; - /** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */ - name?: string; - /** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */ - retryAfterSeconds?: number; - /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ - uid?: string; -}; -export type Status = { - /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ - apiVersion?: string; - /** Suggested HTTP return code for this status, 0 if not set. */ - code?: number; - /** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */ - details?: StatusDetails; - /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - kind?: string; - /** A human-readable description of the status of this operation. */ - message?: string; - /** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - metadata?: ListMeta; - /** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */ - reason?: string; - /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ - status?: string; -}; export type ResourceRepositoryInfo = { /** The name (identifier) */ name: string; @@ -1338,7 +1501,11 @@ export type ResourceStats = { }; export const { useListJobQuery, + useCreateJobMutation, + useDeletecollectionJobMutation, useGetJobQuery, + useReplaceJobMutation, + useDeleteJobMutation, useListRepositoryQuery, useCreateRepositoryMutation, useDeletecollectionRepositoryMutation, diff --git a/public/app/core/components/TimeSeries/utils.ts b/public/app/core/components/TimeSeries/utils.ts index 407bd92ed66..f87801d5955 100644 --- a/public/app/core/components/TimeSeries/utils.ts +++ b/public/app/core/components/TimeSeries/utils.ts @@ -59,6 +59,7 @@ for (let i = 0; i < BIN_INCRS.length; i++) { BIN_INCRS[i] = 2 ** i; } +import { DrawStyle } from '@grafana/ui'; import { UPlotConfigBuilder, UPlotConfigPrepFn, @@ -74,6 +75,7 @@ const defaultConfig: GraphFieldConfig = { drawStyle: GraphDrawStyle.Line, showPoints: VisibilityMode.Auto, axisPlacement: AxisPlacement.Auto, + showValues: false, }; export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ @@ -529,6 +531,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ softMax: customConfig.axisSoftMax, // The following properties are not used in the uPlot config, but are utilized as transport for legend config dataFrameFieldIndex: field.state?.origin, + showValues: customConfig.showValues, }); // Render thresholds in graph @@ -553,6 +556,79 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ builder.setStackingGroups(stackingGroups); + const mightShowValues = frame.fields.some((field, i) => { + if (i === 0) { + return false; + } + + const customConfig = field.config.custom ?? {}; + + return ( + customConfig.showValues && + (customConfig.drawStyle === GraphDrawStyle.Points || customConfig.showPoints !== VisibilityMode.Never) + ); + }); + + if (mightShowValues) { + // since bars style doesnt show points in Auto mode, we can't piggyback on series.points.show() + // so we make a simple density-based callback to use here + const barsShowValues = (u: uPlot) => { + let width = u.bbox.width / uPlot.pxRatio; + let count = u.data[0].length; + + // render values when each has at least 30px of width available + return width / count >= 30; + }; + + builder.addHook('draw', (u: uPlot) => { + const baseFontSize = 12; + const font = `${baseFontSize * uPlot.pxRatio}px ${theme.typography.fontFamily}`; + + const { ctx } = u; + + ctx.save(); + ctx.fillStyle = theme.colors.text.primary; + ctx.font = font; + ctx.textAlign = 'center'; + + for (let seriesIdx = 1; seriesIdx < u.data.length; seriesIdx++) { + const series = u.series[seriesIdx]; + const field = frame.fields[seriesIdx]; + + if ( + field.config.custom?.showValues && + // @ts-ignore points.show() is always callable on the instance (but may be boolean when passed to uPlot as init option) + (series.points?.show?.(u, seriesIdx) || + (field.config.custom?.drawStyle === DrawStyle.Bars && barsShowValues(u))) + ) { + const xData = u.data[0]; + const yData = u.data[seriesIdx]; + const yScale = series.scale!; + + for (let dataIdx = 0; dataIdx < yData.length; dataIdx++) { + const yVal = yData[dataIdx]; + + if (yVal != null) { + const text = formattedValueToString(field.display!(yVal)); + + const isNegative = yVal < 0; + const textOffset = isNegative ? 15 : -5; + ctx.textBaseline = isNegative ? 'top' : 'bottom'; + + const xVal = xData[dataIdx]; + const x = u.valToPos(xVal, 'x', true); + const y = u.valToPos(yVal, yScale, true); + + ctx.fillText(text, x, y + textOffset); + } + } + } + } + + ctx.restore(); + }); + } + // hook up custom/composite renderers renderers?.forEach((r) => { if (!indexByName) { diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index 19b4ab2880f..0c91b1414e8 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -6,6 +6,7 @@ import { config, getBackendSrv, isFetchError, locationService } from '@grafana/r import { Dashboard } from '@grafana/schema'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { folderAPIv1beta1 as folderAPI } from 'app/api/clients/folder/v1beta1'; +import { isProvisionedFolderCheck } from 'app/api/clients/folder/v1beta1/utils'; import { createBaseQuery, handleRequestError } from 'app/api/createBaseQuery'; import appEvents from 'app/core/app_events'; import { contextSrv } from 'app/core/core'; @@ -26,12 +27,17 @@ import { DashboardTreeSelection } from '../types'; import { isProvisionedDashboard, isProvisionedFolder } from './isProvisioned'; import { PAGE_SIZE } from './services'; -interface DeleteItemsArgs { - selectedItems: Omit; +interface DeleteFoldersArgs { + folderUIDs: string[]; } -interface MoveItemsArgs extends DeleteItemsArgs { +interface DeleteDashboardsArgs { + dashboardUIDs: string[]; +} + +interface MoveItemsArgs { destinationUID: string; + selectedItems: Omit; } export interface ImportInputs { @@ -281,27 +287,16 @@ export const browseDashboardsAPI = createApi({ }, }), - // delete *multiple* items (folders and dashboards). used in the delete modal. - deleteItems: builder.mutation({ + // delete *multiple* folders. used in the delete modal. + deleteFolders: builder.mutation({ invalidatesTags: ['getFolder'], - queryFn: async ({ selectedItems }, _api, _extraOptions, baseQuery) => { - const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]); - const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); - const pageStateManager = getDashboardScenePageStateManager(); + queryFn: async ({ folderUIDs }, _api, _extraOptions, baseQuery) => { // Delete all the folders sequentially // TODO error handling here - for (const folderUID of selectedFolders) { - if (config.featureToggles.provisioning) { - const folder = await dispatch(folderAPI.endpoints.getFolder.initiate({ name: folderUID })); - if (isProvisionedFolder(folder.data)) { - appEvents.publish({ - type: AppEvents.alertWarning.name, - payload: [ - 'Cannot delete provisioned folder. To remove it, delete it from the repository and synchronise to apply the changes.', - ], - }); - continue; - } + for (const folderUID of folderUIDs) { + // This also shows warning alert + if (await isProvisionedFolderCheck(dispatch, folderUID)) { + continue; } await baseQuery({ url: `/folders/${folderUID}`, @@ -313,9 +308,23 @@ export const browseDashboardsAPI = createApi({ }, }); } + return { data: undefined }; + }, + onQueryStarted: ({ folderUIDs }, { queryFulfilled, dispatch }) => { + queryFulfilled.then(() => { + dispatch(refreshParents(folderUIDs)); + }); + }, + }), + + // delete *multiple* dashboards. used in the delete modal. + deleteDashboards: builder.mutation({ + invalidatesTags: ['getFolder'], + queryFn: async ({ dashboardUIDs }, _api, _extraOptions, baseQuery) => { + const pageStateManager = getDashboardScenePageStateManager(); // Delete all the dashboards sequentially // TODO error handling here - for (const dashboardUID of selectedDashboards) { + for (const dashboardUID of dashboardUIDs) { if (config.featureToggles.provisioning) { const dto = await getDashboardAPI().getDashboardDTO(dashboardUID); if (isProvisionedDashboard(dto)) { @@ -346,11 +355,9 @@ export const browseDashboardsAPI = createApi({ } return { data: undefined }; }, - onQueryStarted: ({ selectedItems }, { queryFulfilled, dispatch }) => { - const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]); - const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); + onQueryStarted: ({ dashboardUIDs }, { queryFulfilled, dispatch }) => { queryFulfilled.then(() => { - dispatch(refreshParents([...selectedFolders, ...selectedDashboards])); + dispatch(refreshParents(dashboardUIDs)); }); }, }), @@ -487,7 +494,8 @@ export const browseDashboardsAPI = createApi({ export const { endpoints, useDeleteFolderMutation, - useDeleteItemsMutation, + useDeleteFoldersMutation, + useDeleteDashboardsMutation, useGetAffectedItemsQuery, useGetFolderQuery, useLazyGetFolderQuery, diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index ad0a72df516..7a1fef2634d 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -13,7 +13,8 @@ import { ShowModalReactEvent } from 'app/types/events'; import { FolderDTO } from 'app/types/folders'; import { useDispatch } from 'app/types/store'; -import { useDeleteItemsMutation, useMoveItemsMutation } from '../../api/browseDashboardsAPI'; +import { useDeleteMultipleFoldersMutationFacade } from '../../../../api/clients/folder/v1beta1/hooks'; +import { useDeleteDashboardsMutation, useMoveItemsMutation } from '../../api/browseDashboardsAPI'; import { useActionSelectionState } from '../../state/hooks'; import { setAllSelection } from '../../state/slice'; import { DashboardTreeSelection } from '../../types'; @@ -32,7 +33,8 @@ export function BrowseActions({ folderDTO }: Props) { const dispatch = useDispatch(); const selectedItems = useActionSelectionState(); - const [deleteItems] = useDeleteItemsMutation(); + const [deleteDashboards] = useDeleteDashboardsMutation(); + const deleteFolders = useDeleteMultipleFoldersMutationFacade(); const [moveItems] = useMoveItemsMutation(); const [, stateManager] = useSearchStateManager(); const provisioningEnabled = config.featureToggles.provisioning; @@ -54,7 +56,10 @@ export function BrowseActions({ folderDTO }: Props) { }; const onDelete = async () => { - await deleteItems({ selectedItems }); + const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]); + const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); + await deleteDashboards({ dashboardUIDs: selectedDashboards }); + await deleteFolders({ folderUIDs: selectedFolders }); trackAction('delete', selectedItems); onActionComplete(); }; diff --git a/public/app/features/browse-dashboards/types.ts b/public/app/features/browse-dashboards/types.ts index 5aece134d5e..960cd9713da 100644 --- a/public/app/features/browse-dashboards/types.ts +++ b/public/app/features/browse-dashboards/types.ts @@ -2,6 +2,10 @@ import { CellProps, Column, HeaderProps } from 'react-table'; import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; +/** + * Object of what is selected in the tree. It is record where keys are categories from DashboardViewItemKind and + * each category is a record where the key is the UID of the object and value is whether it is selected or not. + */ export type DashboardTreeSelection = Record> & { $all: boolean; }; diff --git a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx index 07bcf478336..bac31474da6 100644 --- a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx @@ -70,9 +70,8 @@ export function PublicDashboardScenePage({ route }: Props) { function PublicDashboardSceneRenderer({ model }: SceneComponentProps) { const [isActive, setIsActive] = useState(false); - const { controls, title } = model.useState(); + const { controls, title, body } = model.useState(); const { timePicker, refreshPicker, hideTimeControls } = controls!.useState(); - const bodyToRender = model.getBodyToRender(); const styles = useStyles2(getStyles); const conf = useGetPublicDashboardConfig(); @@ -108,7 +107,7 @@ function PublicDashboardSceneRenderer({ model }: SceneComponentProps
- +
diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 4d85840b1d4..a8a863a8c4c 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -65,7 +65,6 @@ import { isRepeatCloneOrChildOf } from '../utils/clone'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { getDashboardUrl } from '../utils/getDashboardUrl'; -import { getViewPanelUrl } from '../utils/urlBuilders'; import { getClosestVizPanel, getDashboardSceneFor, @@ -81,7 +80,6 @@ import { DashboardLayoutOrchestrator } from './DashboardLayoutOrchestrator'; import { DashboardSceneRenderer } from './DashboardSceneRenderer'; import { DashboardSceneUrlSync } from './DashboardSceneUrlSync'; import { LibraryPanelBehavior } from './LibraryPanelBehavior'; -import { ViewPanelScene } from './ViewPanelScene'; import { setupKeyboardShortcuts } from './keyboardShortcuts'; import { AutoGridItem } from './layout-auto-grid/AutoGridItem'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; @@ -129,8 +127,10 @@ export interface DashboardSceneState extends SceneObjectState { meta: Omit; /** Version of the dashboard */ version?: number; - /** Panel to view in fullscreen */ - viewPanelScene?: ViewPanelScene; + /** Panel to inspect */ + inspectPanelKey?: string; + /** Panel key to view in fullscreen */ + viewPanel?: string; /** Edit view */ editview?: DashboardEditView; /** Edit panel */ @@ -427,7 +427,7 @@ export class DashboardScene extends SceneObjectBase impleme } public getPageNav(location: H.Location, navIndex: NavIndex) { - const { meta, viewPanelScene, editPanel, title, uid } = this.state; + const { meta, viewPanel, editPanel, title, uid } = this.state; const isNew = !Boolean(uid); let pageNav: NavModelItem = { @@ -456,11 +456,14 @@ export class DashboardScene extends SceneObjectBase impleme } } - if (viewPanelScene) { + if (viewPanel) { pageNav = { text: t('dashboard-scene.dashboard-scene.text.view-panel', 'View panel'), parentItem: pageNav, - url: getViewPanelUrl(viewPanelScene.state.panelRef.resolve()), + url: locationUtil.getUrlForPartial(locationService.getLocation(), { + viewPanel: viewPanel, + editPanel: undefined, + }), }; } @@ -474,13 +477,6 @@ export class DashboardScene extends SceneObjectBase impleme return pageNav; } - /** - * Returns the body (layout) or the full view panel - */ - public getBodyToRender(): SceneObject { - return this.state.viewPanelScene ?? this.state.body; - } - public getInitialState(): DashboardSceneState | undefined { return this._initialState; } diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index 19a6916552c..c0c425da67a 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -12,14 +12,16 @@ import { DashboardEditPaneSplitter } from '../edit-pane/DashboardEditPaneSplitte import { DashboardScene } from './DashboardScene'; import { PanelSearchLayout } from './PanelSearchLayout'; +import { SoloPanelContextProvider, useDefineSoloPanelContext } from './SoloPanelContext'; export function DashboardSceneRenderer({ model }: SceneComponentProps) { const { controls, overlay, editview, + body, editPanel, - viewPanelScene, + viewPanel, panelSearch, panelsPerRow, isEditing, @@ -30,23 +32,23 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps state.navIndex); const pageNav = model.getPageNav(location, navIndex); - const bodyToRender = model.getBodyToRender(); const navModel = getNavModel(navIndex, `dashboards/${type === 'snapshot' ? 'snapshots' : 'browse'}`); const isSettingsOpen = editview !== undefined; + const soloPanelContext = useDefineSoloPanelContext(viewPanel); // Remember scroll pos when going into view panel, edit panel or settings useMemo(() => { - if (viewPanelScene || isSettingsOpen || editPanel) { + if (viewPanel || isSettingsOpen || editPanel) { model.rememberScrollPos(); } - }, [isSettingsOpen, editPanel, viewPanelScene, model]); + }, [isSettingsOpen, editPanel, viewPanel, model]); // Restore scroll pos when coming back useEffect(() => { - if (!viewPanelScene && !isSettingsOpen && !editPanel) { + if (!viewPanel && !isSettingsOpen && !editPanel) { model.restoreScrollPos(); } - }, [isSettingsOpen, editPanel, viewPanelScene, model]); + }, [isSettingsOpen, editPanel, viewPanel, model]); useEffect(() => { if (scopesContext && isEditing) { @@ -70,11 +72,19 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps; } - return ; + if (soloPanelContext) { + return ( + + + + ); + } + + return ; } return ( diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts index d89a729ca17..88ad6853dc1 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts @@ -1,21 +1,11 @@ -import { AppEvents } from '@grafana/data'; -import { LocalValueVariable, SceneQueryRunner, SceneVariableSet, VizPanel } from '@grafana/scenes'; -import appEvents from 'app/core/app_events'; +import { SceneQueryRunner, VizPanel } from '@grafana/scenes'; import { KioskMode } from 'app/types/dashboard'; import { DashboardScene } from './DashboardScene'; -import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; -import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent'; describe('DashboardSceneUrlSync', () => { describe('Given a standard scene', () => { - it('Should set viewPanelKey when url has viewPanel', () => { - const scene = buildTestScene(); - scene.urlSync?.updateFromUrl({ viewPanel: '2' }); - expect(scene.state.viewPanelScene!.getUrlKey()).toBe('panel-2'); - }); - it('Should set UNSAFE_fitPanels when url has autofitpanels', () => { const scene = buildTestScene(); scene.urlSync?.updateFromUrl({ autofitpanels: '' }); @@ -58,54 +48,11 @@ describe('DashboardSceneUrlSync', () => { const scene = buildTestScene(); scene.setState({ isEditing: false }); scene.urlSync?.updateFromUrl({ viewPanel: 'panel-1' }); - expect(scene.state.viewPanelScene).toBeDefined(); + expect(scene.state.viewPanel).toBeDefined(); scene.urlSync?.updateFromUrl({ editPanel: 'panel-1' }); expect(scene.state.editPanel).toBeDefined(); }); }); - - describe('Given a viewPanelKey with clone that is not found', () => { - const scene = buildTestScene(); - - let errorNotice = 0; - appEvents.on(AppEvents.alertError, (evt) => errorNotice++); - - scene.urlSync?.updateFromUrl({ viewPanel: 'A$panel-1' }); - - expect(scene.state.viewPanelScene).toBeUndefined(); - // Verify no error notice was shown - expect(errorNotice).toBe(0); - - // fake adding clone panel - const layout = scene.state.body as DefaultGridLayoutManager; - - layout.state.grid.setState({ - children: [ - new DashboardGridItem({ - key: 'griditem-1', - x: 0, - body: new VizPanel({ - $variables: new SceneVariableSet({ - variables: [ - new LocalValueVariable({ - name: 'server', - value: 'A', - text: 'A', - }), - ], - }), - title: 'Clone Panel A', - key: 'panel-1', - pluginId: 'table', - }), - }), - ], - }); - - // Verify it subscribes to DashboardRepeatsProcessedEvent - scene.publishEvent(new DashboardRepeatsProcessedEvent({ source: scene })); - expect(scene.state.viewPanelScene?.getUrlKey()).toBe('A$panel-1'); - }); }); function buildTestScene() { diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index fcc52e46b81..4b1db8ec977 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -1,9 +1,5 @@ -import { Unsubscribable } from 'rxjs'; - -import { AppEvents } from '@grafana/data'; -import { config, locationService } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; import { SceneObjectUrlSyncHandler, SceneObjectUrlValues, VizPanel } from '@grafana/scenes'; -import appEvents from 'app/core/app_events'; import { contextSrv } from 'app/core/core'; import { KioskMode } from 'app/types/dashboard'; @@ -11,18 +7,13 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createDashboardEditViewFor } from '../settings/utils'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; -import { containsPathIdSeparator, findVizPanelByPathId } from '../utils/pathId'; import { findEditPanel, getLibraryPanelBehavior } from '../utils/utils'; import { DashboardScene, DashboardSceneState } from './DashboardScene'; import { LibraryPanelBehavior } from './LibraryPanelBehavior'; -import { ViewPanelScene } from './ViewPanelScene'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; -import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent'; export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { - private _viewEventSub?: Unsubscribable; - constructor(private _scene: DashboardScene) {} getKeys(): string[] { @@ -34,7 +25,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { return { autofitpanels: this.getAutoFitPanels(), - viewPanel: state.viewPanelScene?.getUrlKey(), + viewPanel: state.viewPanel, editview: state.editview?.getUrlKey(), editPanel: state.editPanel?.getUrlKey() || undefined, kiosk: state.kioskMode === KioskMode.Full ? '' : undefined, @@ -52,7 +43,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { } updateFromUrl(values: SceneObjectUrlValues): void { - const { viewPanelScene, isEditing, editPanel, shareView } = this._scene.state; + const { viewPanel, isEditing, editPanel, shareView } = this._scene.state; const update: Partial = {}; if (typeof values.editview === 'string' && this._scene.canEditDashboard()) { @@ -74,25 +65,9 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { // Handle view panel state if (typeof values.viewPanel === 'string') { - const panel = findVizPanelByPathId(this._scene, values.viewPanel); - - if (!panel) { - // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet - // Here we check if the key contains the clone key so we force the repeat processing - // It doesn't matter if the element or the ancestors are clones or not, just that the key contains the clone key - if (containsPathIdSeparator(values.viewPanel)) { - this._handleViewRepeatClone(values.viewPanel); - return; - } - - appEvents.emit(AppEvents.alertError, ['Panel not found']); - locationService.partial({ viewPanel: null }); - return; - } - - update.viewPanelScene = new ViewPanelScene({ panelRef: panel.getRef() }); - } else if (viewPanelScene && values.viewPanel === null) { - update.viewPanelScene = undefined; + update.viewPanel = values.viewPanel; + } else if (viewPanel && values.viewPanel === null) { + update.viewPanel = undefined; } // Handle edit panel state @@ -105,8 +80,8 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { } // We cannot simultaneously be in edit and view panel state. - if (this._scene.state.viewPanelScene) { - this._scene.setState({ viewPanelScene: undefined }); + if (this._scene.state.viewPanel) { + update.viewPanel = undefined; } // If we are not in editing (for example after full page reload) @@ -159,21 +134,6 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { } } - private _handleViewRepeatClone(viewPanel: string) { - if (!this._viewEventSub) { - this._viewEventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => { - const panel = findVizPanelByPathId(this._scene, viewPanel); - if (panel) { - this._viewEventSub?.unsubscribe(); - this._scene.setState({ viewPanelScene: new ViewPanelScene({ panelRef: panel.getRef() }) }); - this._viewEventSub = undefined; - } - }); - - this._scene.state.body.activateRepeaters?.(); - } - } - /** * Temporary solution, with some refactoring of PanelEditor we can remove this */ diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 47fec9cf54a..18eafada208 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -66,7 +66,7 @@ NavToolbarActions.displayName = 'NavToolbarActions'; * This part is split into a separate component to help test this */ export function ToolbarActions({ dashboard }: Props) { - const { isEditing, viewPanelScene, isDirty, uid, meta, editview, editPanel, editable } = dashboard.useState(); + const { isEditing, viewPanel, isDirty, uid, meta, editview, editPanel, editable } = dashboard.useState(); const { isPlaying } = playlistSrv.useState(); const [isAddPanelMenuOpen, setIsAddPanelMenuOpen] = useState(false); @@ -75,7 +75,7 @@ export function ToolbarActions({ dashboard }: Props) { const toolbarActions: ToolbarAction[] = []; const styles = useStyles2(getStyles); const isEditingPanel = Boolean(editPanel); - const isViewingPanel = Boolean(viewPanelScene); + const isViewingPanel = Boolean(viewPanel); const isEditedPanelDirty = usePanelEditDirty(editPanel); const isEditingLibraryPanel = editPanel && isLibraryPanel(editPanel.state.panelRef.resolve()); diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index 30774d910fb..e665be6f773 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -2,6 +2,7 @@ import { getTimeZone, InterpolateFunction, LinkModel, + locationUtil, PanelMenuItem, PanelPlugin, PluginExtensionLink, @@ -37,7 +38,7 @@ import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; import { isRepeatCloneOrChildOf } from '../utils/clone'; import { DashboardInteractions } from '../utils/interactions'; -import { getEditPanelUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; +import { getEditPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor, isLibraryPanel } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; @@ -90,7 +91,10 @@ export function panelMenuBehavior(menu: VizPanelMenu) { text: t('panel.header-menu.view', `View`), iconClassName: 'eye', shortcut: 'v', - href: getViewPanelUrl(panel), + href: locationUtil.getUrlForPartial(locationService.getLocation(), { + viewPanel: panel.getPathId(), + editPanel: undefined, + }), }); } diff --git a/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx b/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx index 2cf1be9dbee..f54f85fd402 100644 --- a/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx +++ b/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx @@ -1,18 +1,13 @@ import { css } from '@emotion/css'; import classNames from 'classnames'; -import { useEffect, useState } from 'react'; +import { useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Trans } from '@grafana/i18n'; -import { SceneGridRow, VizPanel, sceneGraph } from '@grafana/scenes'; +import { VizPanel, sceneGraph } from '@grafana/scenes'; import { useStyles2 } from '@grafana/ui'; -import { forceActivateFullSceneObjectTree } from '../utils/utils'; - import { DashboardScene } from './DashboardScene'; -import { DashboardGridItem } from './layout-default/DashboardGridItem'; -import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; -import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent'; +import { SoloPanelContextProvider } from './SoloPanelContext'; export interface Props { dashboard: DashboardScene; @@ -24,60 +19,21 @@ const panelsPerRowCSSVar = '--panels-per-row'; export function PanelSearchLayout({ dashboard, panelSearch = '', panelsPerRow }: Props) { const { body } = dashboard.state; - const filteredPanels: VizPanel[] = []; const styles = useStyles2(getStyles); - const [_, setRepeatsUpdated] = useState(''); - - const bodyGrid = body instanceof DefaultGridLayoutManager ? body.state.grid : null; - - if (!bodyGrid) { - return Unsupported layout; - } - - for (const gridItem of bodyGrid.state.children) { - if (gridItem instanceof DashboardGridItem) { - filterPanels(gridItem, dashboard, panelSearch, filteredPanels, setRepeatsUpdated); - } else if (gridItem instanceof SceneGridRow) { - for (const rowItem of gridItem.state.children) { - if (rowItem instanceof DashboardGridItem) { - filterPanels(rowItem, dashboard, panelSearch, filteredPanels, setRepeatsUpdated); - } - } - } - } - - if (filteredPanels.length > 0) { - return ( -
} - > - {filteredPanels.map((panel) => ( - - ))} -
- ); - } + const soloPanelContext = useMemo(() => new SoloPanelContextValueWithSearchStringFilter(panelSearch), [panelSearch]); return ( -

- No matches found -

+
} + > + + + +
); } -function PanelSearchHit({ panel }: { panel: VizPanel }) { - useEffect(() => { - const deactivate = forceActivateFullSceneObjectTree(panel); - - return () => { - deactivate?.(); - }; - }, [panel]); - - return ; -} - function getStyles(theme: GrafanaTheme2) { return { grid: css({ @@ -96,37 +52,20 @@ function getStyles(theme: GrafanaTheme2) { }; } -function filterPanels( - gridItem: DashboardGridItem, - dashboard: DashboardScene, - searchString: string, - filteredPanels: VizPanel[], - setRepeatsUpdated: (updated: string) => void -) { - const interpolatedSearchString = sceneGraph.interpolate(dashboard, searchString).toLowerCase(); +export class SoloPanelContextValueWithSearchStringFilter { + public matchFound = false; - // activate inactive repeat panel if one of its children will be matched - if (gridItem.state.variableName && !gridItem.isActive) { - const panel = gridItem.state.body; + public constructor(private searchQuery: string) {} + + public matches(panel: VizPanel): boolean { + const interpolatedSearchString = sceneGraph.interpolate(panel, this.searchQuery).toLowerCase(); const interpolatedTitle = panel.interpolate(panel.state.title, undefined, 'text').toLowerCase(); - if (interpolatedTitle.includes(interpolatedSearchString)) { - gridItem.subscribeToEvent(DashboardRepeatsProcessedEvent, (event) => { - const source = event.payload.source; - if (source instanceof DashboardGridItem) { - setRepeatsUpdated(event.payload.source.state.key ?? ''); - } - }); - gridItem.activate(); - } - } - const panels = gridItem.state.repeatedPanels ?? [gridItem.state.body]; - for (const panel of panels) { - const interpolatedTitle = panel.interpolate(panel.state.title, undefined, 'text').toLowerCase(); - if (interpolatedTitle.includes(interpolatedSearchString)) { - filteredPanels.push(panel); + const match = interpolatedTitle.includes(interpolatedSearchString); + if (match) { + this.matchFound = true; } - } - return filteredPanels; + return match; + } } diff --git a/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx b/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx new file mode 100644 index 00000000000..2186d9b4863 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx @@ -0,0 +1,141 @@ +import React, { useContext, useEffect, useState } from 'react'; + +import { Trans } from '@grafana/i18n'; +import { VizPanel } from '@grafana/scenes'; +import { Box, Spinner } from '@grafana/ui'; + +import { DashboardScene } from './DashboardScene'; + +export interface SoloPanelContextValue { + matches: (VizPanel: VizPanel) => boolean; + matchFound: boolean; +} + +export class SoloPanelContextWithPathIdFilter implements SoloPanelContextValue { + public matchFound = false; + + public constructor(public keyPath: string) {} + + public matches(panel: VizPanel): boolean { + // Check if keyPath is just an old legacy panel id + if (/^\d+$/.test(this.keyPath)) { + if (`panel-${this.keyPath}` === panel.state.key!) { + this.matchFound = true; + return true; + } + + return false; + } + + if (this.keyPath === panel.getPathId()) { + this.matchFound = true; + return true; + } + + return false; + } +} + +export const SoloPanelContext = React.createContext(null); + +export function useDefineSoloPanelContext(keyPath?: string): SoloPanelContextValue | null { + return React.useMemo(() => { + if (!keyPath) { + return null; + } + return new SoloPanelContextWithPathIdFilter(keyPath); + }, [keyPath]); +} + +export function useSoloPanelContext() { + return useContext(SoloPanelContext); +} + +export function renderMatchingSoloPanels(soloPanelContext: SoloPanelContextValue, panels: VizPanel[]) { + const matches: React.ReactNode[] = []; + for (const panel of panels) { + if (soloPanelContext.matches(panel)) { + matches.push(); + } + } + + return <>{matches}; +} + +export function SoloPanelContextProvider({ + children, + value, + singleMatch, + dashboard, +}: { + children: React.ReactNode; + value: SoloPanelContextValue; + singleMatch: boolean; + dashboard: DashboardScene; +}) { + return ( + + {children} + + + ); +} + +export interface SoloPanelNotFoundProps { + /** + * Controls panel not found error message + */ + singleMatch: boolean; + /** + * Used to check if variables are loading + */ + dashboard: DashboardScene; +} + +export function SoloPanelNotFound({ singleMatch, dashboard }: SoloPanelNotFoundProps) { + const context = useSoloPanelContext()!; + const [state, setState] = useState({ matchFound: false, isLoading: true }); + + useEffect(() => { + // This effect fires before any child layout starts rendering and checking if their panels match the solo panel filter + // We need this polling here to check if any solo panel has matched or if any layout has marked the context as loading (for repeated panels) + const cancelTimeout = setInterval(() => { + setState({ matchFound: context.matchFound, isLoading: isAnyVariableLoading(dashboard) }); + }, 500); + + return () => clearInterval(cancelTimeout); + }, [context, dashboard]); + + if (state.matchFound || context.matchFound) { + return null; + } + + if (state.isLoading) { + return ; + } + + return ( + + {singleMatch && Panel not found} + {!singleMatch && No panels matching} + + ); +} + +function isAnyVariableLoading(scene: DashboardScene) { + const variables = scene.state.$variables; + if (!variables || !variables.isActive) { + return true; + } + + return variables.state.variables.some((variable) => variable.state.loading); +} diff --git a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts index bf3872b2603..3284dd50e51 100644 --- a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts +++ b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts @@ -13,7 +13,7 @@ import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { findVizPanelByPathId } from '../utils/pathId'; -import { getEditPanelUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; +import { getEditPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; import { getPanelIdForVizPanel } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; @@ -50,15 +50,10 @@ export function setupKeyboardShortcuts(scene: DashboardScene) { keybindings.addBinding({ key: 'v', onTrigger: withFocusedPanel(scene, (vizPanel: VizPanel) => { - if (scene.state.viewPanelScene) { - locationService.push( - locationUtil.getUrlForPartial(locationService.getLocation(), { - viewPanel: undefined, - }) - ); + if (scene.state.viewPanel) { + locationService.partial({ viewPanel: undefined }); } else { - const url = locationUtil.stripBaseFromUrl(getViewPanelUrl(vizPanel)); - locationService.push(url); + locationService.partial({ viewPanel: vizPanel.getPathId(), editPanel: undefined }); } }), }); diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx index 6f6d211777b..2235985ba7e 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx @@ -18,7 +18,6 @@ import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; import { DashboardLayoutItem } from '../types/DashboardLayoutItem'; -import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; import { getOptions } from './AutoGridItemEditor'; import { AutoGridItemRenderer } from './AutoGridItemRenderer'; @@ -130,8 +129,6 @@ export class AutoGridItem extends SceneObjectBase implements this.setState({ repeatedPanels }); this._prevRepeatValues = values; - - this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); } public getPanelCount() { diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx index b64747e26d1..12903f1e727 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx @@ -7,6 +7,7 @@ import { useStyles2 } from '@grafana/ui'; import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden'; import { useDashboardState } from '../../utils/utils'; +import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext'; import { getIsLazy } from '../layouts-shared/utils'; import { AutoGridItem } from './AutoGridItem'; @@ -19,7 +20,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps getIsLazy(preload), [preload]); const Wrapper = useMemo( @@ -77,6 +78,10 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps ); + } + return (
) { const { repeatedPanels = [], itemHeight, variableName, body } = model.useState(); + const soloPanelContext = useSoloPanelContext(); const layoutStyle = useLayoutStyle( model.getRepeatDirection(), model.getPanelCount(), @@ -16,6 +19,10 @@ export function DashboardGridItemRenderer({ model }: SceneComponentProps diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 97c04cbdd86..ad3930e1aab 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -42,6 +42,7 @@ import { getLayoutOrchestratorFor, getDashboardSceneFor, } from '../../utils/utils'; +import { useSoloPanelContext } from '../SoloPanelContext'; import { AutoGridItem } from '../layout-auto-grid/AutoGridItem'; import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions'; import { clearClipboard, getDashboardGridItemFromClipboard } from '../layouts-shared/paste'; @@ -565,6 +566,11 @@ function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps ); + } // If we are top level layout and we have no children, show empty state if (model.parent === dashboard && children.length === 0) { @@ -585,6 +591,20 @@ function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps) { + const soloPanelContext = useSoloPanelContext(); + + if (soloPanelContext) { + return model.state.children.map((child) => ); + } + + return ; +} + function getStyles(theme: GrafanaTheme2) { return { container: css({ diff --git a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts index a813fddaace..5a6553dd941 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts +++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts @@ -14,7 +14,6 @@ import { import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; -import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; interface RowRepeaterBehaviorState extends SceneObjectState { variableName: string; @@ -172,9 +171,6 @@ export class RowRepeaterBehavior extends SceneObjectBase) { const clearStyles = useStyles2(clearButtonStyles); const isTopLevel = model.parent?.parent instanceof DashboardScene; const pointerDistance = usePointerDistance(); + const soloPanelContext = useSoloPanelContext(); const myIndex = rows.findIndex((row) => row === model); @@ -45,6 +47,10 @@ export function RowItemRenderer({ model }: SceneComponentProps) { return null; } + if (soloPanelContext) { + return ; + } + return ( {(dragProvided, dragSnapshot) => ( diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx index 39681c4fa4c..0e18ffd7ef0 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx @@ -7,7 +7,6 @@ import { Spinner } from '@grafana/ui'; import { DashboardStateChangedEvent } from '../../edit-pane/shared'; import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { dashboardLog, getMultiVariableValues } from '../../utils/utils'; -import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; import { RowItem } from './RowItem'; import { RowsLayoutManager } from './RowsLayoutManager'; @@ -118,7 +117,6 @@ export function performRowRepeats(variable: MultiValueVariable, row: RowItem, co } row.setState({ repeatedRows: clonedRows }); - row.publishEvent(new DashboardRepeatsProcessedEvent({ source: row }), true); } /** diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx index 523b44c6422..b18634bdd00 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx @@ -9,6 +9,7 @@ import { Button, useStyles2 } from '@grafana/ui'; import { isRepeatCloneOrChildOf } from '../../utils/clone'; import { useDashboardState } from '../../utils/utils'; +import { useSoloPanelContext } from '../SoloPanelContext'; import { useClipboardState } from '../layouts-shared/useClipboardState'; import { RowItem } from './RowItem'; @@ -20,6 +21,11 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps ); + } const isClone = isRepeatCloneOrChildOf(model); diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeater.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeater.tsx index 16ac618b600..ae545b9488e 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeater.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeater.tsx @@ -9,7 +9,6 @@ import { Spinner, Tooltip, useStyles2 } from '@grafana/ui'; import { DashboardStateChangedEvent } from '../../edit-pane/shared'; import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { dashboardLog, getMultiVariableValues } from '../../utils/utils'; -import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; import { TabItem } from './TabItem'; import { TabsLayoutManager } from './TabsLayoutManager'; @@ -99,7 +98,6 @@ export function performTabRepeats(variable: MultiValueVariable, tab: TabItem, co const clonedTabs = createTabRepeats({ values, texts, variable, tab }); tab.setState({ repeatedTabs: clonedTabs }); - tab.publishEvent(new DashboardRepeatsProcessedEvent({ source: tab }), true); } /** diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx index a223350371b..b5a6d11a0cd 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx @@ -10,6 +10,7 @@ import { Button, TabContent, TabsBar, useStyles2 } from '@grafana/ui'; import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden'; import { isRepeatCloneOrChildOf } from '../../utils/clone'; import { getDashboardSceneFor } from '../../utils/utils'; +import { useSoloPanelContext } from '../SoloPanelContext'; import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles'; import { useClipboardState } from '../layouts-shared/useClipboardState'; @@ -26,6 +27,11 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps; + } const isClone = isRepeatCloneOrChildOf(model); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/LeftActions.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/LeftActions.tsx index 6f7f361caf6..211870823b4 100644 --- a/public/app/features/dashboard-scene/scene/new-toolbar/LeftActions.tsx +++ b/public/app/features/dashboard-scene/scene/new-toolbar/LeftActions.tsx @@ -10,10 +10,10 @@ import { StarButton } from './actions/StarButton'; import { getDynamicActions, renderActionElements } from './utils'; export const LeftActions = ({ dashboard }: { dashboard: DashboardScene }) => { - const { editview, editPanel, isEditing, uid, meta, viewPanelScene } = dashboard.useState(); + const { editview, editPanel, isEditing, uid, meta, viewPanel } = dashboard.useState(); const hasEditView = Boolean(editview); - const isViewingPanel = Boolean(viewPanelScene); + const isViewingPanel = Boolean(viewPanel); const isEditingDashboard = Boolean(isEditing); const isEditingPanel = Boolean(editPanel); const hasUid = Boolean(uid); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/RightActions.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/RightActions.tsx index 76cb969eeaf..d737abf783c 100644 --- a/public/app/features/dashboard-scene/scene/new-toolbar/RightActions.tsx +++ b/public/app/features/dashboard-scene/scene/new-toolbar/RightActions.tsx @@ -27,7 +27,7 @@ import { ToolbarActionProps } from './types'; import { getDynamicActions, renderActionElements } from './utils'; export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => { - const { editPanel, editable, editview, isEditing, uid, meta, viewPanelScene } = dashboard.useState(); + const { editPanel, editable, editview, isEditing, uid, meta, viewPanel } = dashboard.useState(); const { isPlaying } = playlistSrv.useState(); const styles = useStyles2(getStyles); @@ -37,7 +37,7 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => { const isEditingDashboard = Boolean(isEditing); const hasEditView = Boolean(editview); const isEditingPanel = Boolean(editPanel); - const isViewingPanel = Boolean(viewPanelScene); + const isViewingPanel = Boolean(viewPanel); const isEditingLibraryPanel = isEditingPanel && isLibraryPanel(editPanel!.state.panelRef.resolve()); const isShowingDashboard = !hasEditView && !isViewingPanel && !isEditingPanel; const isEditingAndShowingDashboard = isEditingDashboard && isShowingDashboard; diff --git a/public/app/features/dashboard-scene/scene/types/DashboardRepeatsProcessedEvent.ts b/public/app/features/dashboard-scene/scene/types/DashboardRepeatsProcessedEvent.ts deleted file mode 100644 index 9bf602cc171..00000000000 --- a/public/app/features/dashboard-scene/scene/types/DashboardRepeatsProcessedEvent.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { BusEventWithPayload } from '@grafana/data'; -import { SceneObject } from '@grafana/scenes'; - -export interface DashboardRepeatsProcessedEventPayload { - source: SceneObject; -} - -export class DashboardRepeatsProcessedEvent extends BusEventWithPayload { - public static type = 'dashboard-repeats-processed'; -} diff --git a/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx b/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx index 5f88d334eae..28b0cbbfecc 100644 --- a/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx +++ b/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx @@ -6,7 +6,7 @@ import { config, reportInteraction } from '@grafana/runtime'; import { Button, ConfirmModal, Modal, Space, Text, TextLink } from '@grafana/ui'; import { DeleteProvisionedDashboardDrawer } from 'app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardDrawer'; -import { useDeleteItemsMutation } from '../../browse-dashboards/api/browseDashboardsAPI'; +import { useDeleteDashboardsMutation } from '../../browse-dashboards/api/browseDashboardsAPI'; import { DashboardScene } from '../scene/DashboardScene'; interface ButtonProps { @@ -26,7 +26,7 @@ interface DeleteModalProps { export function DeleteDashboardButton({ dashboard }: ButtonProps) { const [showModal, toggleModal] = useToggle(false); - const [deleteItems] = useDeleteItemsMutation(); + const [deleteDashboards] = useDeleteDashboardsMutation(); const [, onConfirm] = useAsyncFn(async () => { reportInteraction('grafana_manage_dashboards_delete_clicked', { @@ -38,14 +38,7 @@ export function DeleteDashboardButton({ dashboard }: ButtonProps) { }); toggleModal(); if (dashboard.state.uid) { - await deleteItems({ - selectedItems: { - dashboard: { - [dashboard.state.uid]: true, - }, - folder: {}, - }, - }); + await deleteDashboards({ dashboardUIDs: [dashboard.state.uid] }); } await dashboard.onDashboardDelete(); }, [dashboard, toggleModal]); diff --git a/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx b/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx index e38fd63e5d1..f94152f3b99 100644 --- a/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx +++ b/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx @@ -4,9 +4,9 @@ import { useEffect } from 'react'; import { useParams } from 'react-router-dom-v5-compat'; import { GrafanaTheme2 } from '@grafana/data'; -import { Trans, t } from '@grafana/i18n'; +import { t } from '@grafana/i18n'; import { UrlSyncContextProvider } from '@grafana/scenes'; -import { Alert, Box, Spinner, useStyles2 } from '@grafana/ui'; +import { Alert, Box, useStyles2 } from '@grafana/ui'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; @@ -15,8 +15,7 @@ import { DashboardRoutes } from 'app/types/dashboard'; import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager'; import { DashboardScene } from '../scene/DashboardScene'; - -import { useSoloPanel } from './useSoloPanel'; +import { SoloPanelContextProvider, useDefineSoloPanelContext } from '../scene/SoloPanelContext'; export interface Props extends GrafanaRouteComponentProps {} @@ -61,30 +60,26 @@ export function SoloPanelPage({ queryParams }: Props) { export default SoloPanelPage; export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: DashboardScene; panelId: string }) { - const [panel, error] = useSoloPanel(dashboard, panelId); - const { controls } = dashboard.useState(); + const { controls, body } = dashboard.useState(); const refreshPicker = controls?.useState()?.refreshPicker; const styles = useStyles2(getStyles); + const soloPanelContext = useDefineSoloPanelContext(panelId)!; useEffect(() => { - return refreshPicker?.activate(); - }, [refreshPicker]); + const dashDeactivate = dashboard.activate(); + const refreshDeactivate = refreshPicker?.activate(); - if (error) { - return ; - } - - if (!panel) { - return ( - - Loading - - ); - } + return () => { + dashDeactivate(); + refreshDeactivate?.(); + }; + }, [dashboard, refreshPicker]); return (
- + + +
); } diff --git a/public/app/features/dashboard-scene/solo/useSoloPanel.test.tsx b/public/app/features/dashboard-scene/solo/useSoloPanel.test.tsx deleted file mode 100644 index 86b2a8a1ab6..00000000000 --- a/public/app/features/dashboard-scene/solo/useSoloPanel.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { renderHook } from '@testing-library/react'; - -import { DataSourceRef } from '@grafana/schema'; - -import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; -import { findVizPanelByKey } from '../utils/utils'; - -import { useSoloPanel } from './useSoloPanel'; - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getDataSourceSrv: () => ({ - get: async (ref: DataSourceRef) => { - // Mocking the build in Grafana data source to avoid annotations data layer errors. - return { - id: 1, - uid: '-- Grafana --', - name: 'grafana', - type: 'grafana', - meta: { - id: 'grafana', - }, - }; - }, - }), -})); - -describe('useSoloPanel', () => { - it('should return undefined panel and error when panel is not found', () => { - const { dashboard } = setup(); - const { result } = renderHook(() => useSoloPanel(dashboard, 'foo-key')); - - expect(result.current[0]).toBeUndefined(); - expect(result.current[1]).toBe('Panel not found'); - }); - - it('should return the panel when panel is found', () => { - const { dashboard } = setup(); - - const { result } = renderHook(() => useSoloPanel(dashboard, 'panel-1')); - const panel = findVizPanelByKey(dashboard, 'panel-1'); - - expect(result.current[0]).toEqual(panel); - expect(result.current[1]).toBeUndefined(); - }); - - it('should return the cloned panel when panel is found', () => { - const { dashboard } = setup(); - const { result } = renderHook(() => useSoloPanel(dashboard, 'A$panel-1')); - const panel = findVizPanelByKey(dashboard, 'panel-1'); - - expect(result.current[0]).not.toBe(panel); - expect(result.current[1]).toBeUndefined(); - }); - - it('should return error when panelId correspond to a non VizPanel', () => { - const { dashboard } = setup(); - const { result } = renderHook(() => useSoloPanel(dashboard, 'panel-2')); - - expect(result.current[0]).toBeUndefined(); - expect(result.current[1]).toBe('Panel not found'); - }); -}); - -const setup = () => { - const dashboard = transformSaveModelToScene({ dashboard: TEST_DASHBOARD, meta: {} }); - - return { dashboard }; -}; - -const TEST_DASHBOARD = { - title: 'Scenes/PanelEdit/Queries: Edit', - annotations: { - list: [], - }, - editable: true, - fiscalYearStartMonth: 0, - graphTooltip: 0, - id: 2378, - links: [], - liveNow: false, - panels: [ - { - type: 'timeseries', - datasource: 'prometheus', - fieldConfig: { - defaults: { - custom: {}, - }, - overrides: [], - }, - gridPos: { - h: 9, - w: 24, - x: 0, - y: 0, - }, - id: 1, - options: { - colorMode: 'background', - graphMode: 'area', - justifyMode: 'auto', - orientation: 'auto', - reduceOptions: { - calcs: ['lastNotNull', 'last', 'first', 'min', 'max', 'mean', 'sum', 'count'], - fields: '', - values: false, - }, - text: {}, - }, - pluginVersion: '8.0.3', - }, - { - id: 2, - type: 'row', - }, - ], - refresh: '', - schemaVersion: 39, - tags: [], - templating: { - list: [], - }, - time: { - from: 'now-6h', - to: 'now', - }, - timepicker: {}, - timezone: '', - uid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', - version: 6, - weekStart: '', -}; diff --git a/public/app/features/dashboard-scene/solo/useSoloPanel.ts b/public/app/features/dashboard-scene/solo/useSoloPanel.ts deleted file mode 100644 index 37777439b4c..00000000000 --- a/public/app/features/dashboard-scene/solo/useSoloPanel.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useState, useEffect } from 'react'; - -import { VizPanel, UrlSyncManager } from '@grafana/scenes'; - -import { DashboardScene } from '../scene/DashboardScene'; -import { DashboardRepeatsProcessedEvent } from '../scene/types/DashboardRepeatsProcessedEvent'; -import { containsPathIdSeparator, findVizPanelByPathId } from '../utils/pathId'; - -export function useSoloPanel(dashboard: DashboardScene, pathId: string): [VizPanel | undefined, string | undefined] { - const [panel, setPanel] = useState(); - const [error, setError] = useState(); - - useEffect(() => { - const urlSyncManager = new UrlSyncManager(); - urlSyncManager.initSync(dashboard); - - const cleanUp = dashboard.activate(); - - let panel: VizPanel | null = null; - try { - panel = findVizPanelByPathId(dashboard, pathId); - } catch (e) { - // do nothing, just the panel is not found or not a VizPanel - } - - if (panel) { - activateParents(panel); - setPanel(panel); - } else if (containsPathIdSeparator(pathId)) { - findRepeatClone(dashboard, pathId).then((panel) => { - if (panel) { - setPanel(panel); - } else { - setError('Panel not found'); - } - }); - } else { - setError('Panel not found'); - } - - return cleanUp; - }, [dashboard, pathId]); - - return [panel, error]; -} - -function activateParents(panel: VizPanel) { - let parent = panel.parent; - - while (parent && !parent.isActive) { - parent.activate(); - parent = parent.parent; - } -} - -function findRepeatClone(dashboard: DashboardScene, pathId: string): Promise { - return new Promise((resolve) => { - dashboard.subscribeToEvent(DashboardRepeatsProcessedEvent, () => { - const panel = findVizPanelByPathId(dashboard, pathId); - if (panel) { - resolve(panel); - } else { - // If rows are repeated they could add new panel repeaters that needs to be activated - dashboard.state.body.activateRepeaters?.(); - } - }); - - dashboard.state.body.activateRepeaters?.(); - }); -} diff --git a/public/app/features/dashboard/api/types.ts b/public/app/features/dashboard/api/types.ts index ff4037da318..5e358321a8f 100644 --- a/public/app/features/dashboard/api/types.ts +++ b/public/app/features/dashboard/api/types.ts @@ -47,12 +47,12 @@ export interface DashboardVersionError extends Error { } export class DashboardVersionError extends Error { - constructor(storedVersion: string, message = 'Dashboard version mismatch') { + constructor(storedVersion: string | undefined, message = 'Dashboard version mismatch') { super(message); this.name = 'DashboardVersionError'; this.status = 200; this.data = { - storedVersion, + storedVersion: storedVersion ?? 'unknown', message, }; } diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts index b79cfbacf9a..9a2e7406576 100644 --- a/public/app/features/dashboard/api/utils.ts +++ b/public/app/features/dashboard/api/utils.ts @@ -9,7 +9,7 @@ import { SaveDashboardCommand } from '../components/SaveDashboard/types'; import { DashboardWithAccessInfo } from './types'; -export function isV2StoredVersion(version: string): boolean { +export function isV2StoredVersion(version: string | undefined): boolean { return version === 'v2alpha1' || version === 'v2beta1'; } diff --git a/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx index 802aed03ec1..176f40ecdd9 100644 --- a/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx +++ b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx @@ -8,7 +8,7 @@ import { Modal, Button, Text, Space, TextLink } from '@grafana/ui'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { cleanUpDashboardAndVariables } from 'app/features/dashboard/state/actions'; -import { useDeleteItemsMutation } from '../../../browse-dashboards/api/browseDashboardsAPI'; +import { useDeleteDashboardsMutation } from '../../../browse-dashboards/api/browseDashboardsAPI'; import { DeleteDashboardModal as DeleteModal } from '../../../dashboard-scene/settings/DeleteDashboardButton'; type DeleteDashboardModalProps = { @@ -26,7 +26,7 @@ type Props = DeleteDashboardModalProps & ConnectedProps; const DeleteDashboardModalUnconnected = ({ hideModal, cleanUpDashboardAndVariables, dashboard }: Props) => { const isProvisioned = dashboard.meta.provisioned; - const [deleteItems] = useDeleteItemsMutation(); + const [deleteDashboards] = useDeleteDashboardsMutation(); const [, onConfirm] = useAsyncFn(async () => { reportInteraction('grafana_manage_dashboards_delete_clicked', { @@ -36,14 +36,7 @@ const DeleteDashboardModalUnconnected = ({ hideModal, cleanUpDashboardAndVariabl source: 'dashboard_settings', restore_enabled: Boolean(config.featureToggles.restoreDashboards), }); - await deleteItems({ - selectedItems: { - dashboard: { - [dashboard.uid]: true, - }, - folder: {}, - }, - }); + await deleteDashboards({ dashboardUIDs: [dashboard.uid] }); cleanUpDashboardAndVariables(); hideModal(); locationService.replace('/'); diff --git a/public/app/features/dashboard/services/dashboard-render-performance-profiling.md b/public/app/features/dashboard/services/dashboard-render-performance-profiling.md index 465fdcc4465..2320e28be16 100644 --- a/public/app/features/dashboard/services/dashboard-render-performance-profiling.md +++ b/public/app/features/dashboard/services/dashboard-render-performance-profiling.md @@ -234,6 +234,84 @@ if (frameLength > TAB_INACTIVE_THRESHOLD) { This fallback catches cases where visibility events might be missed and prevents recording of artificially long frame times (hours instead of milliseconds) that occur when `requestAnimationFrame` callbacks resume after tab reactivation. +### Profile Isolation and Overlapping Interactions + +To ensure accurate performance measurements, the `SceneRenderProfiler` implements profile isolation to handle rapid user interactions: + +#### Understanding Trailing Frame Recording + +After the main interaction completes, the profiler continues to record "trailing frames" for 2 seconds (POST_STORM_WINDOW) to capture any delayed rendering effects. This ensures complete performance measurement including: + +- Delayed DOM updates +- Asynchronous rendering operations +- Secondary effects from the initial interaction + +#### Problem: Mixed Performance Data + +When users perform rapid interactions during this 2-second trailing frame window (e.g., quickly changing time ranges or triggering a refresh), the performance data from multiple actions could be mixed into a single profile. This led to: + +- Inaccurate performance measurements +- Profile events that never completed +- Crumbs from different interactions being combined +- Trailing frames from one interaction being attributed to another + +#### Solution: Automatic Profile Cancellation + +Starting with `@grafana/scenes` v6.30.4, the profiler automatically cancels the current profile when a new interaction begins while trailing frames are still being recorded: + +```javascript +// When new profile is requested while still recording trailing frames +if (this.#trailAnimationFrameId) { + this.cancelProfile(); + this._startNewProfile(name, true); // true = forced profile +} else { + this.addCrumb(name); +} +``` + +This ensures: + +- Each interaction gets its own isolated measurement +- No mixing of performance data between different user actions +- Clean separation of interaction metrics + +#### Profile Start Types + +The profiler now distinguishes between two types of profile starts: + +1. **Clean Start**: Profile started when no other profile is active +2. **Forced Start (Interrupted)**: Profile started by cancelling a previous active profile + +This information is logged in debug mode: + +``` +SceneRenderProfiler: Profile started[forced]: {origin: "refresh", crumbs: []} +SceneRenderProfiler: Profile started[clean]: {origin: "dashboard_view", crumbs: []} +``` + +Additionally, when a profile is cancelled due to overlapping interactions: + +``` +SceneRenderProfiler: Cancelled recording frames, new profile started +``` + +#### Example Scenario + +1. User changes time range (profile starts) +2. Dashboard finishes loading after 500ms (main profile complete) +3. Profiler continues recording trailing frames to capture delayed effects +4. At 1 second, user clicks refresh button +5. Without this fix: Refresh would be added as a crumb to the time range profile +6. With this fix: Time range profile is cancelled, new refresh profile starts cleanly + +This fix is particularly important for dashboards with: + +- Auto-refresh enabled +- Slow API responses +- Rapid user interactions + +Without profile isolation, these scenarios could result in profiles that never complete and mix data from multiple unrelated interactions. + ## Related Documentation - [PR #858 - Add SceneRenderProfiler to scenes](https://github.com/grafana/scenes/pull/858) @@ -246,3 +324,4 @@ This fallback catches cases where visibility events might be missed and prevents - [PR #1209 - SceneRenderProfiler: Only capture network requests within measurement window](https://github.com/grafana/scenes/pull/1209) - [PR #1211 - SceneRenderProfiler: Improve profiler accuracy by adding cancellation and skipping inactive tabs](https://github.com/grafana/scenes/pull/1211) - [PR #1212 - SceneQueryController: Fix profiler query controller registration on scene re-activation](https://github.com/grafana/scenes/pull/1212) +- [PR #1225 - SceneRenderProfiler: Handle overlapping profiles by cancelling previous profile](https://github.com/grafana/scenes/pull/1225) diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx index ef78316b69f..32017369eec 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -216,6 +216,8 @@ export const InfiniteScroll = ({ showTime={showTime} style={style} styles={styles} + timeRange={timeRange} + timeZone={timeZone} variant={getLogLineVariant(logs, index, lastLogOfPage.current)} virtualization={virtualization} wrapLogMessage={wrapLogMessage} @@ -233,6 +235,8 @@ export const InfiniteScroll = ({ showTime, sortOrder, styles, + timeRange, + timeZone, virtualization, wrapLogMessage, ] diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index 415163d0aa6..6ea14b9862c 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; +import { CoreApp, createTheme, getDefaultTimeRange, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../mocks/logRow'; @@ -55,6 +55,8 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => { showTime: true, style: {}, styles: styles, + timeRange: getDefaultTimeRange(), + timeZone: 'browser', wrapLogMessage: true, }; }); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 699f77e0709..723f9cd54a5 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -3,7 +3,7 @@ import { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, import Highlighter from 'react-highlight-words'; import tinycolor from 'tinycolor2'; -import { findHighlightChunksInText, GrafanaTheme2, LogsDedupStrategy } from '@grafana/data'; +import { findHighlightChunksInText, GrafanaTheme2, LogsDedupStrategy, TimeRange } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Button, Icon, Tooltip } from '@grafana/ui'; @@ -31,6 +31,8 @@ export interface Props { showTime: boolean; style: CSSProperties; styles: LogLineStyles; + timeRange: TimeRange; + timeZone: string; onClick: (e: MouseEvent, log: LogListModel) => void; onOverflow?: (index: number, id: string, height?: number) => void; variant?: 'infinite-scroll'; @@ -48,6 +50,8 @@ export const LogLine = ({ onClick, onOverflow, showTime, + timeRange, + timeZone, variant, virtualization, wrapLogMessage, @@ -64,6 +68,8 @@ export const LogLine = ({ onClick={onClick} onOverflow={onOverflow} showTime={showTime} + timeRange={timeRange} + timeZone={timeZone} variant={variant} virtualization={virtualization} wrapLogMessage={wrapLogMessage} @@ -87,6 +93,8 @@ const LogLineComponent = memo( onClick, onOverflow, showTime, + timeRange, + timeZone, variant, virtualization, wrapLogMessage, @@ -244,7 +252,9 @@ const LogLineComponent = memo(
)} - {detailsMode === 'inline' && detailsShown && } + {detailsMode === 'inline' && detailsShown && ( + + )} ); } diff --git a/public/app/features/logs/components/panel/LogLineDetails.test.tsx b/public/app/features/logs/components/panel/LogLineDetails.test.tsx index 112ea2531ba..085b4e2c8cb 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.test.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { of } from 'rxjs'; import { Field, @@ -13,8 +14,10 @@ import { LogsSortOrder, DataFrame, ScopedVars, + getDefaultTimeRange, } from '@grafana/data'; import { setPluginLinksHook } from '@grafana/runtime'; +import { createTempoDatasource } from 'app/plugins/datasource/tempo/test/mocks'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../mocks/logRow'; @@ -30,13 +33,25 @@ jest.mock('@grafana/assistant', () => { }; }); +const tempoDS = createTempoDatasource(); + jest.mock('@grafana/runtime', () => { return { ...jest.requireActual('@grafana/runtime'), usePluginLinks: jest.fn().mockReturnValue({ links: [] }), + getDataSourceSrv: () => ({ + get: (uid: string) => Promise.resolve(tempoDS), + }), }; }); jest.mock('./LogListContext'); +jest.mock('app/features/explore/TraceView/TraceView', () => ({ + TraceView: () =>
Trace view
, +})); + +afterAll(() => { + jest.unmock('app/features/explore/TraceView/TraceView'); +}); const setup = ( propOverrides?: Partial, @@ -50,6 +65,8 @@ const setup = ( focusLogLine: jest.fn(), logs, onResize: jest.fn(), + timeRange: getDefaultTimeRange(), + timeZone: 'browser', ...(propOverrides || {}), }; @@ -559,6 +576,8 @@ describe('LogLineDetails', () => { containerElement: document.createElement('div'), focusLogLine: jest.fn(), logs: [logs[0]], + timeRange: getDefaultTimeRange(), + timeZone: 'browser', onResize: jest.fn(), }; @@ -606,4 +625,143 @@ describe('LogLineDetails', () => { expect(screen.getAllByText('Second log')).toHaveLength(1); }); }); + + test('Requests and shows an embedded trace', async () => { + const entry = 'traceId=1234 msg="some message"'; + const dataFrame = toDataFrame({ + fields: [ + { name: 'timestamp', config: {}, type: FieldType.time, values: [1] }, + { name: 'entry', values: [entry] }, + // As we have traceId in message already this will shadow it. + { + name: 'traceId', + values: ['1234'], + config: { links: [{ title: 'link title', url: 'localhost:3210/${__value.text}' }] }, + }, + { name: 'userId', values: ['5678'] }, + ], + }); + const log = createLogLine( + { entry, dataFrame, entryFieldIndex: 0, rowIndex: 0 }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + virtualization: undefined, + wrapLogMessage: true, + getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame, vars: ScopedVars) => { + if (field.config && field.config.links) { + return field.config.links.map((link) => { + return { + href: '/explore?left=%7B%22range%22%3A%7B%22from%22%3A%22now-15m%22%2C%22to%22%3A%22now%22%7D%2C%22datasource%22%3A%22fetpfiwe8asqoe%22%2C%22queries%22%3A%5B%7B%22query%22%3A%22abcd1234%22%2C%22queryType%22%3A%22traceql%22%7D%5D%7D', + title: 'tempo', + target: '_blank', + origin: field, + }; + }); + } + return []; + }, + } + ); + + jest.spyOn(tempoDS, 'query').mockReturnValueOnce( + of({ + data: [ + createDataFrame({ + fields: [ + { name: 'traceID', values: ['5d5d850e24d89509'], type: FieldType.string }, + { name: 'spanID', values: ['5d5d850e24d89509'], type: FieldType.string }, + ], + }), + ], + }) + ); + + setup({ logs: [log] }, undefined, { showDetails: [log] }); + + expect(screen.getByText('Links')).toBeInTheDocument(); + expect(screen.getByText('Trace')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Trace')); + + expect(screen.getByText('Trace view')).toBeInTheDocument(); + }); + + test('Shows a message if the trace cannot be retrieved', async () => { + const entry = 'traceId=1234 msg="some message"'; + const dataFrame = toDataFrame({ + fields: [ + { name: 'timestamp', config: {}, type: FieldType.time, values: [1] }, + { name: 'entry', values: [entry] }, + // As we have traceId in message already this will shadow it. + { + name: 'traceId', + values: ['1234'], + config: { links: [{ title: 'link title', url: 'localhost:3210/${__value.text}' }] }, + }, + { name: 'userId', values: ['5678'] }, + ], + }); + const log = createLogLine( + { entry, dataFrame, entryFieldIndex: 0, rowIndex: 0 }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + virtualization: undefined, + wrapLogMessage: true, + getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame, vars: ScopedVars) => { + if (field.config && field.config.links) { + return field.config.links.map((link) => { + return { + href: '/explore?left=%7B%22range%22%3A%7B%22from%22%3A%22now-15m%22%2C%22to%22%3A%22now%22%7D%2C%22datasource%22%3A%22fetpfiwe8asqoe%22%2C%22queries%22%3A%5B%7B%22query%22%3A%22abcd1234%22%2C%22queryType%22%3A%22traceql%22%7D%5D%7D', + title: 'tempo', + target: '_blank', + origin: field, + }; + }); + } + return []; + }, + } + ); + + jest.spyOn(tempoDS, 'query').mockReturnValueOnce( + of({ + data: [], + }) + ); + + setup({ logs: [log] }, undefined, { showDetails: [log] }); + + expect(screen.getByText('Links')).toBeInTheDocument(); + expect(screen.getByText('Trace')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Trace')); + + expect(screen.getByText('Could not retrieve trace.')).toBeInTheDocument(); + }); + + test('shows attribute extension links when they are available', () => { + const usePluginLinksMock = jest.fn().mockReturnValue({ + links: [ + { + type: 'link', + title: 'Open service overview for label', + path: 'https://example.com', + category: 'label', + icon: 'compass', + }, + ], + }); + setPluginLinksHook(usePluginLinksMock); + jest.requireMock('@grafana/runtime').usePluginLinks = usePluginLinksMock; + + setup(undefined, { labels: { label: 'value' } }); + + expect(screen.getByText('label')).toBeInTheDocument(); + expect(screen.getByText('value')).toBeInTheDocument(); + expect(screen.getByText('Open service overview for label')).toBeInTheDocument(); + }); }); diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index 72d827b1ebe..1a3290a262c 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -3,7 +3,7 @@ import { Resizable } from 're-resizable'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { usePrevious } from 'react-use'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, TimeRange } from '@grafana/data'; import { t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { getDragStyles, Icon, Tab, TabsBar, useStyles2 } from '@grafana/ui'; @@ -17,12 +17,14 @@ export interface Props { containerElement: HTMLDivElement; focusLogLine: (log: LogListModel) => void; logs: LogListModel[]; + timeRange: TimeRange; + timeZone: string; onResize(): void; } export type LogLineDetailsMode = 'inline' | 'sidebar'; -export const LogLineDetails = memo(({ containerElement, focusLogLine, logs, onResize }: Props) => { +export const LogLineDetails = memo(({ containerElement, focusLogLine, logs, timeRange, timeZone, onResize }: Props) => { const { detailsWidth, noInteractions, setDetailsWidth } = useLogListContext(); const styles = useStyles2(getStyles, 'sidebar'); const dragStyles = useStyles2(getDragStyles); @@ -57,83 +59,93 @@ export const LogLineDetails = memo(({ containerElement, focusLogLine, logs, onRe maxWidth={maxWidth} >
- +
); }); LogLineDetails.displayName = 'LogLineDetails'; -const LogLineDetailsTabs = memo(({ focusLogLine, logs }: Pick) => { - const { app, closeDetails, noInteractions, showDetails, toggleDetails } = useLogListContext(); - const [currentLog, setCurrentLog] = useState(showDetails[0]); - const previousShowDetails = usePrevious(showDetails); - const styles = useStyles2(getStyles, 'sidebar'); +const LogLineDetailsTabs = memo( + ({ focusLogLine, logs, timeRange, timeZone }: Pick) => { + const { app, closeDetails, noInteractions, showDetails, toggleDetails } = useLogListContext(); + const [currentLog, setCurrentLog] = useState(showDetails[0]); + const previousShowDetails = usePrevious(showDetails); + const styles = useStyles2(getStyles, 'sidebar'); - useEffect(() => { - focusLogLine(currentLog); - if (!noInteractions) { - reportInteraction('logs_log_line_details_displayed', { - mode: 'sidebar', - app, - }); - } - // Once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + useEffect(() => { + focusLogLine(currentLog); + if (!noInteractions) { + reportInteraction('logs_log_line_details_displayed', { + mode: 'sidebar', + app, + }); + } + // Once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - useEffect(() => { - if (!showDetails.length) { - closeDetails(); - return; - } - // Focus on the recently open - if (!previousShowDetails || showDetails.length > previousShowDetails.length) { - setCurrentLog(showDetails[showDetails.length - 1]); - return; - } else if (!showDetails.find((log) => log.uid === currentLog.uid)) { - setCurrentLog(showDetails[showDetails.length - 1]); - } - }, [closeDetails, currentLog.uid, previousShowDetails, showDetails]); + useEffect(() => { + if (!showDetails.length) { + closeDetails(); + return; + } + // Focus on the recently open + if (!previousShowDetails || showDetails.length > previousShowDetails.length) { + setCurrentLog(showDetails[showDetails.length - 1]); + return; + } else if (!showDetails.find((log) => log.uid === currentLog.uid)) { + setCurrentLog(showDetails[showDetails.length - 1]); + } + }, [closeDetails, currentLog.uid, previousShowDetails, showDetails]); - return ( - <> - {showDetails.length > 1 && ( - - {showDetails.map((log) => { - return ( - setCurrentLog(log)} - suffix={() => ( - toggleDetails(log)} - /> - )} - /> - ); - })} - - )} -
- -
- - ); -}); + return ( + <> + {showDetails.length > 1 && ( + + {showDetails.map((log) => { + return ( + setCurrentLog(log)} + suffix={() => ( + toggleDetails(log)} + /> + )} + /> + ); + })} + + )} +
+ +
+ + ); + } +); LogLineDetailsTabs.displayName = 'LogLineDetailsTabs'; export interface InlineLogLineDetailsProps { log: LogListModel; logs: LogListModel[]; + timeRange: TimeRange; + timeZone: string; } -export const InlineLogLineDetails = memo(({ logs, log }: InlineLogLineDetailsProps) => { +export const InlineLogLineDetails = memo(({ logs, log, timeRange, timeZone }: InlineLogLineDetailsProps) => { const { app, detailsWidth, noInteractions } = useLogListContext(); const styles = useStyles2(getStyles, 'inline'); const scrollRef = useRef(null); @@ -162,7 +174,7 @@ export const InlineLogLineDetails = memo(({ logs, log }: InlineLogLineDetailsPro
- +
diff --git a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx index fd8490f74cb..65bdeb16f4a 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { camelCase, groupBy } from 'lodash'; import { memo, startTransition, useCallback, useMemo, useRef, useState } from 'react'; -import { DataFrameType, GrafanaTheme2, store } from '@grafana/data'; +import { DataFrameType, GrafanaTheme2, store, TimeRange } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { ControlledCollapse, useStyles2 } from '@grafana/ui'; @@ -15,135 +15,193 @@ import { LogLineDetailsDisplayedFields } from './LogLineDetailsDisplayedFields'; import { LabelWithLinks, LogLineDetailsFields, LogLineDetailsLabelFields } from './LogLineDetailsFields'; import { LogLineDetailsHeader } from './LogLineDetailsHeader'; import { LogLineDetailsLog } from './LogLineDetailsLog'; +import { LogLineDetailsTrace } from './LogLineDetailsTrace'; import { useLogListContext } from './LogListContext'; +import { getTempoTraceFromLinks } from './links'; import { LogListModel } from './processing'; interface LogLineDetailsComponentProps { focusLogLine?: (log: LogListModel) => void; log: LogListModel; logs: LogListModel[]; + timeRange: TimeRange; + timeZone: string; } -export const LogLineDetailsComponent = memo(({ focusLogLine, log, logs }: LogLineDetailsComponentProps) => { - const { displayedFields, noInteractions, logOptionsStorageKey, setDisplayedFields, syntaxHighlighting } = - useLogListContext(); - const [search, setSearch] = useState(''); - const inputRef = useRef(''); - const styles = useStyles2(getStyles); - const extensionLinks = useAttributesExtensionLinks(log); - const fieldsWithLinks = useMemo(() => { - const fieldsWithLinks = log.fields.filter((f) => f.links?.length); - const displayedFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex !== log.entryFieldIndex).sort(); - const hiddenFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex === log.entryFieldIndex).sort(); - const fieldsWithLinksFromVariableMap = createLogLineLinks(hiddenFieldsWithLinks); - return { - links: displayedFieldsWithLinks, - linksFromVariableMap: fieldsWithLinksFromVariableMap, - }; - }, [log.entryFieldIndex, log.fields]); - const fieldsWithoutLinks = - log.dataFrame.meta?.type === DataFrameType.LogLines - ? // for LogLines frames (dataplane) we don't want to show any additional fields besides already extracted labels and links - [] - : // for other frames, do not show the log message unless there is a link attached - log.fields.filter((f) => f.links?.length === 0 && f.fieldIndex !== log.entryFieldIndex).sort(); - const labelsWithLinks: LabelWithLinks[] = useMemo( - () => - Object.keys(log.labels) - .sort() - .map((label) => ({ - key: label, - value: log.labels[label], - link: extensionLinks?.[label], - })), - [extensionLinks, log.labels] - ); - const groupedLabels = useMemo( - () => groupBy(labelsWithLinks, (label) => getLabelTypeFromRow(label.key, log, true) ?? ''), - [labelsWithLinks, log] - ); - const labelGroups = useMemo(() => Object.keys(groupedLabels), [groupedLabels]); +export const LogLineDetailsComponent = memo( + ({ focusLogLine, log, logs, timeRange, timeZone }: LogLineDetailsComponentProps) => { + const { displayedFields, noInteractions, logOptionsStorageKey, setDisplayedFields, syntaxHighlighting } = + useLogListContext(); + const [search, setSearch] = useState(''); + const inputRef = useRef(''); + const styles = useStyles2(getStyles); - const logLineOpen = logOptionsStorageKey - ? store.getBool(`${logOptionsStorageKey}.log-details.logLineOpen`, false) - : false; - const linksOpen = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.log-details.linksOpen`, true) : true; - const fieldsOpen = logOptionsStorageKey - ? store.getBool(`${logOptionsStorageKey}.log-details.fieldsOpen`, true) - : true; - const displayedFieldsOpen = logOptionsStorageKey - ? store.getBool(`${logOptionsStorageKey}.log-details.displayedFieldsOpen`, false) - : false; + const extensionLinks = useAttributesExtensionLinks(log); - const handleToggle = useCallback( - (option: string, isOpen: boolean) => { - store.set(`${logOptionsStorageKey}.log-details.${option}`, isOpen); - if (!noInteractions) { - reportInteraction('logs_log_line_details_section_toggled', { - section: option.replace('Open', ''), - state: isOpen ? 'open' : 'closed', - }); - } - }, - [logOptionsStorageKey, noInteractions] - ); + const fieldsWithLinks = useMemo(() => { + const fieldsWithLinks = log.fields.filter((f) => f.links?.length); + const displayedFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex !== log.entryFieldIndex).sort(); + const hiddenFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex === log.entryFieldIndex).sort(); + const fieldsWithLinksFromVariableMap = createLogLineLinks(hiddenFieldsWithLinks); + return { + links: displayedFieldsWithLinks, + linksFromVariableMap: fieldsWithLinksFromVariableMap, + }; + }, [log.entryFieldIndex, log.fields]); - const handleSearch = useCallback((newSearch: string) => { - inputRef.current = newSearch; - startTransition(() => { - setSearch(inputRef.current); - }); - }, []); + const fieldsWithoutLinks = + log.dataFrame.meta?.type === DataFrameType.LogLines + ? // for LogLines frames (dataplane) we don't want to show any additional fields besides already extracted labels and links + [] + : // for other frames, do not show the log message unless there is a link attached + log.fields.filter((f) => f.links?.length === 0 && f.fieldIndex !== log.entryFieldIndex).sort(); - const noDetails = - !fieldsWithLinks.links.length && - !fieldsWithLinks.linksFromVariableMap.length && - !labelGroups.length && - !fieldsWithoutLinks.length; + const labelsWithLinks: LabelWithLinks[] = useMemo( + () => + Object.keys(log.labels) + .sort() + .map((label) => ({ + key: label, + value: log.labels[label], + links: extensionLinks?.[label], + })), + [extensionLinks, log.labels] + ); - return ( - <> - -
- handleToggle('logLineOpen', isOpen)} - > - - - {displayedFields.length > 0 && setDisplayedFields && ( - handleToggle('displayedFieldsOpen', isOpen)} - > - - - )} - {fieldsWithLinks.links.length > 0 && ( + const trace = useMemo(() => getTempoTraceFromLinks(fieldsWithLinks.links), [fieldsWithLinks.links]); + + const groupedLabels = useMemo( + () => groupBy(labelsWithLinks, (label) => getLabelTypeFromRow(label.key, log, true) ?? ''), + [labelsWithLinks, log] + ); + const labelGroups = useMemo(() => Object.keys(groupedLabels), [groupedLabels]); + + const logLineOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.logLineOpen`, false) + : false; + const linksOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.linksOpen`, true) + : true; + const fieldsOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.fieldsOpen`, true) + : true; + const displayedFieldsOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.displayedFieldsOpen`, false) + : false; + const traceOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.traceOpen`, false) + : false; + + const handleToggle = useCallback( + (option: string, isOpen: boolean) => { + store.set(`${logOptionsStorageKey}.log-details.${option}`, isOpen); + if (!noInteractions) { + reportInteraction('logs_log_line_details_section_toggled', { + section: option.replace('Open', ''), + state: isOpen ? 'open' : 'closed', + }); + } + }, + [logOptionsStorageKey, noInteractions] + ); + + const handleSearch = useCallback((newSearch: string) => { + inputRef.current = newSearch; + startTransition(() => { + setSearch(inputRef.current); + }); + }, []); + + const noDetails = + !fieldsWithLinks.links.length && + !fieldsWithLinks.linksFromVariableMap.length && + !labelGroups.length && + !fieldsWithoutLinks.length; + + return ( + <> + +
handleToggle('linksOpen', isOpen)} + isOpen={logLineOpen} + onToggle={(isOpen: boolean) => handleToggle('logLineOpen', isOpen)} > - - + - )} - {labelGroups.map((group) => - group === '' ? ( + {displayedFields.length > 0 && setDisplayedFields && ( + handleToggle('displayedFieldsOpen', isOpen)} + > + + + )} + {fieldsWithLinks.links.length > 0 && ( + handleToggle('linksOpen', isOpen)} + > + + + + )} + {trace && ( + handleToggle('traceOpen', isOpen)} + > + + + )} + {labelGroups.map((group) => + group === '' ? ( + handleToggle('fieldsOpen', isOpen)} + > + + + + ) : ( + handleToggle(groupOptionName(group), isOpen)} + > + + + ) + )} + {!labelGroups.length && fieldsWithoutLinks.length > 0 && ( handleToggle('fieldsOpen', isOpen)} > - - ) : ( - handleToggle(groupOptionName(group), isOpen)} - > - - - ) - )} - {!labelGroups.length && fieldsWithoutLinks.length > 0 && ( - handleToggle('fieldsOpen', isOpen)} - > - - - )} - {noDetails && No fields to display.} -
- - ); -}); + )} + {noDetails && No fields to display.} +
+ + ); + } +); LogLineDetailsComponent.displayName = 'LogLineDetailsComponent'; function groupOptionName(group: string) { diff --git a/public/app/features/logs/components/panel/LogLineDetailsFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsFields.tsx index e7a503b41c2..961dbff024d 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsFields.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsFields.tsx @@ -25,14 +25,13 @@ interface LogLineDetailsFieldsProps { } export const LogLineDetailsFields = memo(({ disableActions, fields, log, logs, search }: LogLineDetailsFieldsProps) => { - if (!fields.length) { - return null; - } const styles = useStyles2(getFieldsStyles); const getLogs = useCallback(() => logs, [logs]); const filteredFields = useMemo(() => (search ? filterFields(fields, search) : fields), [fields, search]); - if (filteredFields.length === 0) { + if (!fields.length) { + return null; + } else if (filteredFields.length === 0) { return t('logs.log-line-details.search.no-results', 'No results to display.'); } @@ -73,14 +72,13 @@ interface LogLineDetailsLabelFieldsProps { } export const LogLineDetailsLabelFields = ({ fields, log, logs, search }: LogLineDetailsLabelFieldsProps) => { - if (!fields.length) { - return null; - } const styles = useStyles2(getFieldsStyles); const getLogs = useCallback(() => logs, [logs]); const filteredFields = useMemo(() => (search ? filterLabels(fields, search) : fields), [fields, search]); - if (filteredFields.length === 0) { + if (!fields.length) { + return null; + } else if (filteredFields.length === 0) { return t('logs.log-line-details.search.no-results', 'No results to display.'); } @@ -341,7 +339,7 @@ export const LogLineDetailsField = ({ } return (
-
+
-
({ }, }), link: css({ - gridColumn: 'span 3', - }), - linkNoActions: css({ - gridColumn: 'span 2', + gridColumn: '2 / 4', }), stats: css({ paddingRight: theme.spacing(1), @@ -414,7 +408,7 @@ const getFieldStyles = (theme: GrafanaTheme2) => ({ maxWidth: '50vh', }), statsColumn: css({ - gridColumn: 'span 2', + gridColumn: '2 / 4', }), valueContainer: css({ display: 'flex', diff --git a/public/app/features/logs/components/panel/LogLineDetailsTrace.tsx b/public/app/features/logs/components/panel/LogLineDetailsTrace.tsx new file mode 100644 index 00000000000..a9f80b841a4 --- /dev/null +++ b/public/app/features/logs/components/panel/LogLineDetailsTrace.tsx @@ -0,0 +1,113 @@ +import { css } from '@emotion/css'; +import { useEffect, useMemo, useState } from 'react'; +import { isObservable, lastValueFrom } from 'rxjs'; + +import { DataFrame, DataQueryRequest, DataSourceApi, GrafanaTheme2, TimeRange } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; +import { TraceView } from 'app/features/explore/TraceView/TraceView'; +import { transformDataFrames } from 'app/features/explore/TraceView/utils/transform'; +import { SearchTableType, TempoQuery } from 'app/plugins/datasource/tempo/dataquery.gen'; + +import { useLogListContext } from './LogListContext'; +import { EmbeddedInternalLink } from './links'; + +interface Props { + traceRef: EmbeddedInternalLink; + timeRange: TimeRange; + timeZone: string; +} + +export const LogLineDetailsTrace = ({ timeRange, timeZone, traceRef }: Props) => { + const [dataSource, setDataSource] = useState(null); + const [dataFrames, setDataFrames] = useState(undefined); + const { app } = useLogListContext(); + const styles = useStyles2(getStyles); + + useEffect(() => { + setDataSource(null); + getDataSourceSrv() + .get(traceRef.dsUID) + .then((dataSource) => { + if (dataSource) { + setDataSource(dataSource); + } else { + setDataFrames(null); + } + }); + }, [traceRef.dsUID]); + + useEffect(() => { + if (!dataSource) { + return; + } + setDataFrames(undefined); + const request: DataQueryRequest = { + app, + requestId: `log-details-trace-${traceRef.query}`, + targets: [ + { + query: traceRef.query, + queryType: 'traceql', + refId: `log-details-trace-${traceRef.query}`, + tableType: SearchTableType.Traces, + filters: [], + }, + ], + interval: '', + intervalMs: 0, + range: timeRange, + scopedVars: {}, + timezone: timeZone, + startTime: Date.now(), + }; + const query = dataSource.query(request); + if (isObservable(query)) { + lastValueFrom(query) + .then((response) => { + setDataFrames(response.data?.length ? response.data : null); + }) + .catch(() => { + setDataFrames(null); + }); + } + }, [app, dataSource, timeRange, timeZone, traceRef.query]); + + const traceProp = useMemo(() => (dataFrames?.length ? transformDataFrames(dataFrames[0]) : undefined), [dataFrames]); + + return ( +
+ {dataSource && Array.isArray(dataFrames) && traceProp && ( + + )} + {dataFrames === null && ( +
+ + + + {t('logs.log-line-details.trace.error-message', 'Could not retrieve trace.')} +
+ )} + {dataFrames === undefined && ( +
+ + {t('logs.log-line-details.trace.loading-message', 'Loading trace...')} +
+ )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + message: css({ + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', + }), +}); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 27e0b1ab9d8..67765c5d307 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -373,11 +373,6 @@ const LogListComponent = ({ [initialScrollPosition, permalinkedLogId, processedLogs] ); - if (!containerElement || listHeight == null) { - // Wait for container to be rendered - return null; - } - const handleLogLineClick = useCallback( (e: MouseEvent, log: LogListModel) => { if (handleTextSelection(e, log)) { @@ -403,6 +398,11 @@ const LogListComponent = ({ [debouncedScrollToItem, filteredLogs] ); + if (!containerElement || listHeight == null) { + // Wait for container to be rendered + return null; + } + return (
{showControls && } @@ -411,6 +411,8 @@ const LogListComponent = ({ containerElement={containerElement} focusLogLine={focusLogLine} logs={filteredLogs} + timeRange={timeRange} + timeZone={timeZone} onResize={handleLogDetailsResize} /> )} diff --git a/public/app/features/logs/components/panel/links.test.ts b/public/app/features/logs/components/panel/links.test.ts new file mode 100644 index 00000000000..bb3da98fc43 --- /dev/null +++ b/public/app/features/logs/components/panel/links.test.ts @@ -0,0 +1,82 @@ +import { FieldType, getDefaultTimeRange, LogsSortOrder, toDataFrame } from '@grafana/data'; +import { contextSrv } from 'app/core/services/context_srv'; +import { getFieldLinksForExplore } from 'app/features/explore/utils/links'; +import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; + +import { createLogLine } from '../mocks/logRow'; + +import { getTempoTraceFromLinks } from './links'; +import { LogListModel } from './processing'; + +describe('getTempoTraceFromLinks', () => { + let log: LogListModel; + + beforeEach(() => { + jest.spyOn(contextSrv, 'hasAccessToExplore').mockReturnValue(true); + + const getFieldLinks: GetFieldLinksFn = (field, rowIndex, dataFrame, vars) => { + return getFieldLinksForExplore({ field, rowIndex, range: getDefaultTimeRange(), dataFrame, vars }); + }; + + log = createLogLine( + { + dataFrame: toDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1] }, + { + name: 'Line', + type: FieldType.string, + values: ['log message 1 traceid=2203801e0171aa8b'], + }, + { + name: 'labels', + type: FieldType.other, + values: [ + { level: 'warn', logger: 'interceptor' }, + { method: 'POST', status: '200' }, + { kind: 'Event', stage: 'ResponseComplete' }, + ], + }, + { + name: 'link', + type: FieldType.string, + config: { + links: [ + { + internal: { + datasourceName: 'tempo', + datasourceUid: 'test', + query: { + query: '${__value.raw}', + queryType: 'traceql', + }, + }, + title: '', + url: '', + }, + ], + }, + values: ['2203801e0171aa8b'], + }, + ], + }), + }, + { + escape: false, + getFieldLinks, + order: LogsSortOrder.Descending, + timeZone: 'browser', + wrapLogMessage: true, + } + ); + }); + + test('Gets the trace information from a link', () => { + expect(getTempoTraceFromLinks(log.fields)).toEqual({ + dsUID: 'test', + query: '2203801e0171aa8b', + queryType: 'traceql', + }); + }); +}); diff --git a/public/app/features/logs/components/panel/links.ts b/public/app/features/logs/components/panel/links.ts new file mode 100644 index 00000000000..8996d6d2125 --- /dev/null +++ b/public/app/features/logs/components/panel/links.ts @@ -0,0 +1,61 @@ +import { LinkModel } from '@grafana/data'; + +import { FieldDef } from '../logParser'; + +export function getTempoTraceFromLinks(fields: FieldDef[]) { + for (const field of fields) { + if (!field.links) { + continue; + } + for (const link of field.links) { + const trace = getTempoTraceFromLink(link); + if (trace) { + return trace; + } + } + } + return null; +} + +function getTempoTraceFromLink(link: LinkModel) { + const queryData = getDataSourceAndQueryFromLink(link); + if (!queryData || queryData.queryType !== 'traceql') { + return null; + } + return queryData; +} + +export type EmbeddedInternalLink = { + dsUID: string; + query: string; + queryType: string; +}; + +function getDataSourceAndQueryFromLink(link: LinkModel): EmbeddedInternalLink | null { + if (!link.href) { + return null; + } + const paramsStrings = link.href.split('?')[1]; + if (!paramsStrings) { + return null; + } + const params = Object.values(Object.fromEntries(new URLSearchParams(paramsStrings))); + try { + const parsed = JSON.parse(params[0]); + const dsUID: string = 'datasource' in parsed && parsed.datasource ? parsed.datasource.toString() : ''; + const query: string = + 'queries' in parsed && Array.isArray(parsed.queries) && 'query' in parsed.queries[0] && parsed.queries[0].query + ? parsed.queries[0].query.toString() + : ''; + const queryType = + 'queryType' in parsed.queries[0] && parsed.queries[0].queryType ? parsed.queries[0].queryType.toString() : ''; + return dsUID && query && queryType + ? { + dsUID, + query, + queryType, + } + : null; + } catch (e) {} + return null; +} diff --git a/public/app/features/provisioning/Config/defaults.ts b/public/app/features/provisioning/Config/defaults.ts index e28b14c81fc..32c96fffc53 100644 --- a/public/app/features/provisioning/Config/defaults.ts +++ b/public/app/features/provisioning/Config/defaults.ts @@ -18,7 +18,7 @@ export function getDefaultValues(repository?: RepositorySpec): RepositoryFormDat path: 'grafana/', sync: { enabled: false, - target: 'instance', + target: 'folder', // start with folder so we can shift to instance later (without an error) intervalSeconds: 60, }, }; diff --git a/public/app/features/scopes/selector/types.ts b/public/app/features/scopes/selector/types.ts index b73b9e66bd0..c6a2a883c8f 100644 --- a/public/app/features/scopes/selector/types.ts +++ b/public/app/features/scopes/selector/types.ts @@ -33,9 +33,9 @@ export const ScopeSpecFilterSchema = z.object({ export const ScopeSpecSchema = z.object({ title: z.string(), - type: z.string(), - description: z.string(), - category: z.string(), + type: z.string().optional(), + description: z.string().optional(), + category: z.string().optional(), filters: z.array(ScopeSpecFilterSchema), }); diff --git a/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx b/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx index 47a8a0e01eb..293d4228e89 100644 --- a/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx +++ b/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx @@ -243,6 +243,8 @@ const OrganizeFieldsTransformerEditor = ({ options, input, onChange }: OrganizeF [options, onChange, uiOrderByItems] ); + const styles = useStyles2(getDraggableStyles); + // Show warning that we only apply the first frame if (input.length > 1) { return ( @@ -255,8 +257,6 @@ const OrganizeFieldsTransformerEditor = ({ options, input, onChange }: OrganizeF ); } - const styles = useStyles2(getDraggableStyles); - return ( <> diff --git a/public/app/plugins/datasource/tempo/QueryField.tsx b/public/app/plugins/datasource/tempo/QueryField.tsx index c0a47c8f79c..b3ec936518e 100644 --- a/public/app/plugins/datasource/tempo/QueryField.tsx +++ b/public/app/plugins/datasource/tempo/QueryField.tsx @@ -36,6 +36,8 @@ interface State { const DEFAULT_QUERY_TYPE: TempoQueryType = 'traceql'; class TempoQueryFieldComponent extends PureComponent { + private _isMounted = false; + constructor(props: Props) { super(props); this.state = { @@ -48,12 +50,47 @@ class TempoQueryFieldComponent extends PureComponent { // otherwise if the user changes the query type and refreshes the page, no query type will be selected // which is inconsistent with how the UI was originally when they selected the Tempo data source. async componentDidMount() { + this._isMounted = true; + if (!this.props.query.queryType || this.props.query.queryType === 'clear') { this.props.onChange({ ...this.props.query, queryType: DEFAULT_QUERY_TYPE, }); } + // TODO: Remove this automatic check for native histograms once Tempo only supports native histograms https://github.com/grafana/grafana/issues/109708 + // indentify the service map can use native histograms + const timeRange = this.props.range; + const nativeHistograms = await this.props.datasource.getNativeHistograms(timeRange); + + // Only update if component is still mounted + if (!this._isMounted) { + return; + } + + this.props.onChange({ + ...this.props.query, + serviceMapUseNativeHistograms: nativeHistograms, + }); + // Migrate to native histograms + // this will ensure that on navigating to the query option service map from a url, + // the service map will be rendered with the native histograms when + // querytype is serviceMap + // the serviceMapUseNativeHistograms is undefined + // and nativeHistograms is true + if ( + this.props.query.queryType === 'serviceMap' && + this.props.query.serviceMapUseNativeHistograms === undefined && + // switch from tempo with native histograms to tempo without native histograms + this.props.query.serviceMapUseNativeHistograms !== nativeHistograms && + nativeHistograms + ) { + this.props.onRunQuery(); + } + } + + componentWillUnmount() { + this._isMounted = false; } onClearResults = () => { diff --git a/public/app/plugins/datasource/tempo/configuration/ServiceGraphSettings.tsx b/public/app/plugins/datasource/tempo/configuration/ServiceGraphSettings.tsx index 4eebfdaffca..bc7cdea6a72 100644 --- a/public/app/plugins/datasource/tempo/configuration/ServiceGraphSettings.tsx +++ b/public/app/plugins/datasource/tempo/configuration/ServiceGraphSettings.tsx @@ -94,6 +94,7 @@ export function ServiceGraphSettings({ options, onOptionsChange }: Props) { ) : null} + {/* TODO: Remove this in favor of automatic detection of native histograms https://github.com/grafana/grafana/issues/109709 */} ); + /** + * Whether to use native histograms for service map queries + */ + serviceMapUseNativeHistograms?: boolean; /** * @deprecated Query traces by service name */ diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts index 72c82f356b0..437264cf282 100644 --- a/public/app/plugins/datasource/tempo/datasource.test.ts +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -1267,7 +1267,7 @@ describe('histogram type functionality', () => { const target = 'server="${__data.fields.target}"'; const serverSumBy = 'server'; - const links = makeHistogramLink(datasourceUid, source, target, serverSumBy); + const links = makeHistogramLink(datasourceUid, source, target, serverSumBy, false); expect(links).toHaveLength(1); expect(links[0].title).toBe('Request classic histogram'); expect(links[0].internal.query.expr).toBe( @@ -1281,7 +1281,7 @@ describe('histogram type functionality', () => { const target = 'server="${__data.fields.target}"'; const serverSumBy = 'server'; - const links = makeHistogramLink(datasourceUid, source, target, serverSumBy, 'native'); + const links = makeHistogramLink(datasourceUid, source, target, serverSumBy, true); expect(links).toHaveLength(1); expect(links[0].title).toBe('Request native histogram'); expect(links[0].internal.query.expr).toBe( @@ -1289,24 +1289,6 @@ describe('histogram type functionality', () => { ); }); - it('should create correct histogram links for both histogram types', () => { - const datasourceUid = 'prom'; - const source = 'client="${__data.fields.source}",'; - const target = 'server="${__data.fields.target}"'; - const serverSumBy = 'server'; - - const links = makeHistogramLink(datasourceUid, source, target, serverSumBy, 'both'); - expect(links).toHaveLength(2); - expect(links[0].title).toBe('Request classic histogram'); - expect(links[1].title).toBe('Request native histogram'); - expect(links[0].internal.query.expr).toBe( - 'histogram_quantile(0.9, sum(rate(traces_service_graph_request_server_seconds_bucket{client="${__data.fields.source}",server="${__data.fields.target}"}[$__rate_interval])) by (le, client, server))' - ); - expect(links[1].internal.query.expr).toBe( - 'histogram_quantile(0.9, sum(rate(traces_service_graph_request_server_seconds{client="${__data.fields.source}",server="${__data.fields.target}"}[$__rate_interval])) by (le, client, server))' - ); - }); - it('should include histogram type in field config', () => { const datasourceUid = 'prom'; const tempoDatasourceUid = 'tempo'; @@ -1321,7 +1303,7 @@ describe('histogram type functionality', () => { tempoField, sourceField, undefined, - 'native' + true ); const histogramLink = fieldConfig.links.find((link) => link.title === 'Request native histogram'); expect(histogramLink).toBeDefined(); @@ -1339,7 +1321,7 @@ describe('histogram type functionality', () => { targets: [{ serviceMapQuery: '{service="test"}' }], range: getDefaultTimeRange(), } as DataQueryRequest, - 'native' + true ); const bucketMetric = request.targets.find((t: PromQuery) => t.expr.includes('_bucket')); diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index b7c3af81613..6143950be47 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -289,6 +289,52 @@ export class TempoDatasource extends DataSourceWithBackend { + if (!this.serviceMap?.datasourceUid) { + return false; + } + + // remove _bucket from the metric name to get the native histogram metric name + const metricName = histogramMetric.replace('_bucket', ''); + + try { + // Get the Prometheus datasource instance + const promDs = await getDataSourceSrv().get(this.serviceMap.datasourceUid); + // Use provided time range or default to last hour + const from = timeRange?.from || dateTime().subtract(1, 'hour'); + const to = timeRange?.to || dateTime(); + + // Convert to Unix timestamps (seconds since epoch) + const start = Math.floor(from.valueOf() / 1000); + const end = Math.floor(to.valueOf() / 1000); + + // Use the series endpoint to check if native histogram metrics exist + // this has a 90% chance of returning correctly due to sparse data + if (!('metadataRequest' in promDs) || typeof promDs.metadataRequest !== 'function') { + return false; + } + + const seriesResult = await promDs.metadataRequest('/api/v1/series', { + 'match[]': metricName, + limit: 1, + start: start, + end: end, + }); + + // Check if any native histogram series exist + const seriesData = seriesResult?.data?.data; + if (seriesData && Array.isArray(seriesData)) { + // If the series array has any entries, native histograms exist + return seriesData.length > 0; + } + + return false; + } catch (error) { + console.warn('Failed to check for native histograms:', error); + return false; + } + } /** * Check, for the given feature, whether it is available in Grafana. @@ -539,13 +585,20 @@ export class TempoDatasource extends DataSourceWithBackend rateQuery(options, result, datasourceUid).pipe( - concatMap((result) => errorAndDurationQuery(options, result, datasourceUid, tempoDsUid, histogramType)) + concatMap((result) => + errorAndDurationQuery(options, result, datasourceUid, tempoDsUid, useNativeHistogram) + ) ) ) ) @@ -974,9 +1027,9 @@ function serviceMapQuery( request: DataQueryRequest, datasourceUid: string, tempoDatasourceUid: string, - histogramType?: string + useNativeHistogram?: boolean ): Observable { - const serviceMapRequest = makePromServiceMapRequest(request, histogramType); + const serviceMapRequest = makePromServiceMapRequest(request, useNativeHistogram); return queryPrometheus(serviceMapRequest, datasourceUid).pipe( // Just collect all the responses first before processing into node graph data @@ -1014,7 +1067,7 @@ function serviceMapQuery( '__data.fields[0]', // tempoField undefined, // sourceField { targetNamespace: '__data.fields.subtitle' }, - histogramType + useNativeHistogram ); edges.fields[0].config = getFieldConfig( @@ -1024,7 +1077,7 @@ function serviceMapQuery( '__data.fields.target', // tempoField '__data.fields.sourceName', // sourceField { targetNamespace: '__data.fields.targetNamespace', sourceNamespace: '__data.fields.sourceNamespace' }, - histogramType + useNativeHistogram ); } else { nodes.fields[0].config = getFieldConfig( @@ -1034,7 +1087,7 @@ function serviceMapQuery( '__data.fields[0]', undefined, undefined, - histogramType + useNativeHistogram ); edges.fields[0].config = getFieldConfig( datasourceUid, @@ -1043,7 +1096,7 @@ function serviceMapQuery( '__data.fields.target', '__data.fields.source', undefined, - histogramType + useNativeHistogram ); } @@ -1060,9 +1113,9 @@ function rateQuery( request: DataQueryRequest, serviceMapResponse: ServiceMapQueryResponse, datasourceUid: string, - histogramType?: string + useNativeHistogram?: boolean ): Observable { - const serviceMapRequest = makePromServiceMapRequest(request, histogramType); + const serviceMapRequest = makePromServiceMapRequest(request, useNativeHistogram); serviceMapRequest.targets = makeServiceGraphViewRequest([buildExpr(rateMetric, defaultTableFilter, request)]); return queryPrometheus(serviceMapRequest, datasourceUid).pipe( @@ -1088,7 +1141,7 @@ function errorAndDurationQuery( rateResponse: ServiceMapQueryResponseWithRates, datasourceUid: string, tempoDatasourceUid: string, - histogramType?: string + useNativeHistogram?: boolean ) { let serviceGraphViewMetrics = []; let errorRateBySpanName = ''; @@ -1114,14 +1167,14 @@ function errorAndDurationQuery( errorRateBySpanName = buildExpr(errorRateMetric, 'span_name=~"' + spanNames.join('|') + '"', request); serviceGraphViewMetrics.push(errorRateBySpanName); spanNames.map((name: string) => { - const checkedDurationMetric = histogramType === 'native' ? nativeHistogramDurationMetric : durationMetric; + const checkedDurationMetric = useNativeHistogram ? nativeHistogramDurationMetric : durationMetric; const metric = buildExpr(checkedDurationMetric, 'span_name=~"' + name + '"', request); durationsBySpanName.push(metric); serviceGraphViewMetrics.push(metric); }); } - const serviceMapRequest = makePromServiceMapRequest(request, histogramType); + const serviceMapRequest = makePromServiceMapRequest(request, useNativeHistogram); serviceMapRequest.targets = makeServiceGraphViewRequest(serviceGraphViewMetrics); return queryPrometheus(serviceMapRequest, datasourceUid).pipe( @@ -1141,7 +1194,7 @@ function errorAndDurationQuery( durationsBySpanName, datasourceUid, tempoDatasourceUid, - histogramType + useNativeHistogram ); if (serviceGraphView.fields.length === 0) { @@ -1193,7 +1246,7 @@ export function getFieldConfig( tempoField: string, sourceField?: string, namespaceFields?: { targetNamespace: string; sourceNamespace?: string }, - histogramType?: string + useNativeHistogram?: boolean ) { let source = sourceField ? `client="\${${sourceField}}",` : ''; let target = `server="\${${targetField}}"`; @@ -1219,7 +1272,7 @@ export function getFieldConfig( datasourceUid, false ), - ...makeHistogramLink(datasourceUid, source, target, serverSumBy, histogramType), + ...makeHistogramLink(datasourceUid, source, target, serverSumBy, useNativeHistogram), makePromLink( 'Failed request rate', `sum by (client, ${serverSumBy})(rate(${failedMetric}{${source}${target}}[$__rate_interval]))`, @@ -1241,7 +1294,7 @@ export function makeHistogramLink( source: string, target: string, serverSumBy: string, - histogramType?: string + useNativeHistogram?: boolean ) { const createHistogramLink = (metric: string, title: string) => makePromLink( @@ -1250,18 +1303,10 @@ export function makeHistogramLink( datasourceUid, false ); - - switch (histogramType) { - case 'both': - return [ - createHistogramLink(histogramMetric, 'Request classic histogram'), - createHistogramLink(nativeHistogramMetric, 'Request native histogram'), - ]; - case 'native': - return [createHistogramLink(nativeHistogramMetric, 'Request native histogram')]; - default: - return [createHistogramLink(histogramMetric, 'Request classic histogram')]; + if (useNativeHistogram) { + return [createHistogramLink(nativeHistogramMetric, 'Request native histogram')]; } + return [createHistogramLink(histogramMetric, 'Request classic histogram')]; } export function makeTempoLink( @@ -1372,13 +1417,13 @@ function makeTempoLinkServiceMap( export function makePromServiceMapRequest( options: DataQueryRequest, - histogramType?: string + useNativeHistogram?: boolean ): DataQueryRequest { return { ...options, targets: serviceMapMetrics .map((metric) => { - if (histogramType === 'native' && metric.includes('_bucket')) { + if (useNativeHistogram) { metric = metric.replace('_bucket', ''); } const { serviceMapQuery, serviceMapIncludeNamespace: serviceMapIncludeNamespace } = options.targets[0]; @@ -1422,7 +1467,7 @@ function getServiceGraphViewDataFrames( durationsBySpanName: string[], datasourceUid: string, tempoDatasourceUid: string, - histogramType?: string + useNativeHistogram?: boolean ) { let df: any = { fields: [] }; @@ -1547,7 +1592,7 @@ function getServiceGraphViewDataFrames( } }); if (Object.keys(durationObj).length > 0) { - const checkedDurationMetric = histogramType === 'native' ? nativeHistogramDurationMetric : durationMetric; + const checkedDurationMetric = useNativeHistogram ? nativeHistogramDurationMetric : durationMetric; df.fields.push({ ...duration[0].fields[1], name: 'Duration (p90)', diff --git a/public/app/plugins/datasource/tempo/types.ts b/public/app/plugins/datasource/tempo/types.ts index 502f64ab8a7..ef7eaae06ac 100644 --- a/public/app/plugins/datasource/tempo/types.ts +++ b/public/app/plugins/datasource/tempo/types.ts @@ -31,6 +31,7 @@ export interface TempoJsonData extends DataSourceJsonData { export interface TempoQuery extends TempoBase { queryType: TempoQueryType; + serviceMapUseNativeHistograms?: boolean; } export interface MyDataSourceOptions extends DataSourceJsonData {} diff --git a/public/app/plugins/panel/timeseries/config.ts b/public/app/plugins/panel/timeseries/config.ts index 1ad68d0584c..e88fc5b94a2 100644 --- a/public/app/plugins/panel/timeseries/config.ts +++ b/public/app/plugins/panel/timeseries/config.ts @@ -41,6 +41,7 @@ export const defaultGraphConfig: GraphFieldConfig = { axisGridShow: true, axisCenteredZero: false, axisBorderShow: false, + showValues: false, }; export type NullEditorSettings = { isTime: boolean }; @@ -208,6 +209,13 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig, isTime = true): SetFi }, showIf: (config) => config.drawStyle !== GraphDrawStyle.Points, }) + .addBooleanSwitch({ + path: 'showValues', + name: t('timeseries.config.get-graph-field-config.name-show-values', 'Show values'), + category: categoryStyles, + defaultValue: false, + showIf: (config) => config.showPoints !== VisibilityMode.Never || config.drawStyle === GraphDrawStyle.Points, + }) .addSliderInput({ path: 'pointSize', name: t('timeseries.config.get-graph-field-config.name-point-size', 'Point size'), diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 14a432fd838..48792381a81 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -6463,8 +6463,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Klikněte <2>tady a zjistěte víc o této chybě.", - "success-more-details-links": "Následně můžete začít vizualizovat data <2>vytvořením nástěnky nebo dotazováním na data v <5>Prozkoumat zobrazení.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Následně můžete začít vizualizovat data <2>vytvořením nástěnky nebo dotazováním na data v <5>Prozkoumat zobrazení." }, "data-sources": { "datasource-add-button": { @@ -11324,11 +11323,15 @@ }, "delete-repository-button": { "button-delete": "Odstranit", - "confirm-delete-repository": "Opravdu chcete odstranit konfiguraci úložiště?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Úložiště se nepodařilo odstranit", "success-repository-deleted": "Odstranění nastavení úložiště bylo zařazeno do fronty", - "title-delete-repository": "Odstranit konfiguraci úložiště", - "tooltip-delete-this-repository": "Odstranit toto úložiště" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Zpět na úložiště", @@ -13138,6 +13141,7 @@ "name-point-size": "Velikost bodu", "name-show-points": "Zobrazit body", "name-show-thresholds": "Zobrazit prahové hodnoty", + "name-show-values": "", "name-style": "Styl", "name-transform": "Transformovat", "transform-options": { diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 7cc7b3f3f18..be7c2468850 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -6421,8 +6421,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Klicken Sie <2>hier, um mehr über diesen Fehler zu erfahren.", - "success-more-details-links": "Als Nächstes können Sie damit beginnen, Daten zu visualisieren, indem Sie <2>ein Dashboard erstellen oder Daten in der <5>Explore-Ansicht abfragen.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Als Nächstes können Sie damit beginnen, Daten zu visualisieren, indem Sie <2>ein Dashboard erstellen oder Daten in der <5>Explore-Ansicht abfragen." }, "data-sources": { "datasource-add-button": { @@ -11254,11 +11253,15 @@ }, "delete-repository-button": { "button-delete": "Löschen", - "confirm-delete-repository": "Sind Sie sicher, dass Sie die Repository-Konfiguration löschen möchten?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Das Repository konnte nicht gelöscht werden", "success-repository-deleted": "Repository-Einstellungen zum Löschen in die Warteschlange gestellt", - "title-delete-repository": "Repository-Konfiguration löschen", - "tooltip-delete-this-repository": "Dieses Repository löschen" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Zurück zu den Repositorys", @@ -13056,6 +13059,7 @@ "name-point-size": "Punktgröße", "name-show-points": "Punkte zeigen", "name-show-thresholds": "Schwellenwerte anzeigen", + "name-show-values": "", "name-style": "Stil", "name-transform": "Transformieren", "transform-options": { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index f4290d1df1d..3c86d003d9a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5223,6 +5223,9 @@ "save-json-to-file": "Save JSON to file", "see-docs": "See <2>documentation for more information about provisioning." }, + "search-panel": { + "no-match": "No panels matching" + }, "share-public-dashboard-loader": { "loading-configuration": "Loading configuration" }, @@ -5495,6 +5498,9 @@ "view-json-modal": { "title-json": "JSON" }, + "view-panel": { + "not-found": "Panel not found" + }, "visualization-button": { "aria-label-change-visualization": "Change visualization", "aria-label-close": "Close options pane", @@ -6135,9 +6141,6 @@ "share-button": { "aria-label-sharedropdownmenu": "Toggle share menu" }, - "solo-panel-page": { - "loading": "Loading" - }, "support-snapshot-service": { "description": { "dashboard-troubleshoot-visualization-issues": "Dashboard JSON used to help troubleshoot visualization issues" @@ -7556,6 +7559,7 @@ }, "folders": { "api": { + "folder-delete-error-provisioned": "Cannot delete provisioned folder. To remove it, delete it from the repository and synchronise to apply the changes.", "folder-deleted-success": "Folder deleted" }, "get-loading-nav": { @@ -9533,6 +9537,12 @@ "show-context": "Show context", "show-log-line": "Show log line", "sidebar-mode": "Anchor to the right", + "trace": { + "error-message": "Could not retrieve trace.", + "error-tooltip": "The trace could have been sampled or be temporarily unavailable.", + "loading-message": "Loading trace..." + }, + "trace-section": "Trace", "unpin-line": "Unpin log" }, "log-line-menu": { @@ -10712,10 +10722,6 @@ "could-anything-matching-query": "Could not find anything matching your query" } }, - "panel-search": { - "no-matches": "No matches found", - "unsupported-layout": "Unsupported layout" - }, "panel-type-filter": { "clear-button": "Clear types", "select-aria-label": "Panel type filter", @@ -13059,6 +13065,7 @@ "name-point-size": "Point size", "name-show-points": "Show points", "name-show-thresholds": "Show thresholds", + "name-show-values": "Show values", "name-style": "Style", "name-transform": "Transform", "transform-options": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 8b9f768bbaf..5affba5d7be 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -6421,8 +6421,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Haz clic <2>aquí para obtener más información sobre este error.", - "success-more-details-links": "A continuación, puedes empezar a visualizar los datos <2>creando un panel de control o consultando los datos en la <5>vista Explorar.", - "success-more-details-links-extensions": "" + "success-more-details-links": "A continuación, puedes empezar a visualizar los datos <2>creando un panel de control o consultando los datos en la <5>vista Explorar." }, "data-sources": { "datasource-add-button": { @@ -11254,11 +11253,15 @@ }, "delete-repository-button": { "button-delete": "Eliminar", - "confirm-delete-repository": "¿Seguro que quieres eliminar la configuración del repositorio?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Error al eliminar el repositorio", "success-repository-deleted": "Ajustes del repositorio en cola para su eliminación", - "title-delete-repository": "Eliminar configuración del repositorio", - "tooltip-delete-this-repository": "Eliminar este repositorio" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Volver a los repositorios", @@ -13056,6 +13059,7 @@ "name-point-size": "Tamaño de punto", "name-show-points": "Mostrar puntos", "name-show-thresholds": "Mostrar umbrales", + "name-show-values": "", "name-style": "Estilo", "name-transform": "Transformar", "transform-options": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 286de7b491a..d80d4694ad3 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -6421,8 +6421,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Cliquez <2>ici pour en savoir plus sur cette erreur.", - "success-more-details-links": "Ensuite, vous pouvez commencer à visualiser les données en <2>créant un tableau de bord ou en interrogeant les données dans la <5>vue Explorer.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Ensuite, vous pouvez commencer à visualiser les données en <2>créant un tableau de bord ou en interrogeant les données dans la <5>vue Explorer." }, "data-sources": { "datasource-add-button": { @@ -11254,11 +11253,15 @@ }, "delete-repository-button": { "button-delete": "Supprimer", - "confirm-delete-repository": "Voulez-vous vraiment supprimer la configuration du référentiel ?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Échec de la suppression du référentiel", "success-repository-deleted": "Paramètres du référentiel mis en attente pour suppression", - "title-delete-repository": "Supprimer la configuration du référentiel", - "tooltip-delete-this-repository": "Supprimer ce référentiel" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Retour aux référentiels", @@ -13056,6 +13059,7 @@ "name-point-size": "Taille des points", "name-show-points": "Afficher les points", "name-show-thresholds": "Afficher les seuils", + "name-show-values": "", "name-style": "Style", "name-transform": "Transformer", "transform-options": { diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 190042e00aa..3d7230bb619 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -6421,8 +6421,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Ha többet szeretne megtudni erről a hibáról, kattintson <2>ide.", - "success-more-details-links": "Ezután < 2 >létrehozhat egy irányítópultot, vagy lekérdezheti az adatokat az <5>Explore nézetben.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Ezután < 2 >létrehozhat egy irányítópultot, vagy lekérdezheti az adatokat az <5>Explore nézetben." }, "data-sources": { "datasource-add-button": { @@ -11254,11 +11253,15 @@ }, "delete-repository-button": { "button-delete": "Törlés", - "confirm-delete-repository": "Biztosan törli az adattár konfigurációját?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Az adattár törlése nem sikerült", "success-repository-deleted": "Adattár-beállítások felvéve a törlési várólistára", - "title-delete-repository": "Az adattár konfigurációjának törlése", - "tooltip-delete-this-repository": "Az adattár törlése" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Vissza az adattárakhoz", @@ -13056,6 +13059,7 @@ "name-point-size": "Pontméret", "name-show-points": "Pontok megjelenítése", "name-show-thresholds": "Küszöbértékek megjelenítése", + "name-show-values": "", "name-style": "Stílus", "name-transform": "Transzformáció", "transform-options": { diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 742e8f22c4b..72d36b9f751 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -6400,8 +6400,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Klik <2>di sini untuk mempelajari selengkapnya tentang kesalahan ini.", - "success-more-details-links": "Selanjutnya, Anda dapat mulai memvisualisasikan data dengan <2>membuat dasbor, atau dengan melakukan kueri data di <5>tampilan Explore.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Selanjutnya, Anda dapat mulai memvisualisasikan data dengan <2>membuat dasbor, atau dengan melakukan kueri data di <5>tampilan Explore." }, "data-sources": { "datasource-add-button": { @@ -11219,11 +11218,15 @@ }, "delete-repository-button": { "button-delete": "Hapus", - "confirm-delete-repository": "Anda yakin ingin menghapus konfigurasi repositori?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Gagal menghapus repositori", "success-repository-deleted": "Pengaturan repositori diantrekan untuk dihapus", - "title-delete-repository": "Hapus konfigurasi repositori", - "tooltip-delete-this-repository": "Hapus repositori ini" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Kembali ke repositori", @@ -13015,6 +13018,7 @@ "name-point-size": "Ukuran poin", "name-show-points": "Tampilkan poin", "name-show-thresholds": "Tampilkan ambang batas", + "name-show-values": "", "name-style": "Gaya tampilan", "name-transform": "Ubah", "transform-options": { diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 16e5702dfdb..11358f541ce 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -6421,8 +6421,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Fai clic <2>qui per saperne di più su questo errore.", - "success-more-details-links": "Successivamente, puoi iniziare a visualizzare i dati <2>creando un dashboard o eseguendo query sui dati nella <5>vista Esplora.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Successivamente, puoi iniziare a visualizzare i dati <2>creando un dashboard o eseguendo query sui dati nella <5>vista Esplora." }, "data-sources": { "datasource-add-button": { @@ -11254,11 +11253,15 @@ }, "delete-repository-button": { "button-delete": "Elimina", - "confirm-delete-repository": "Desideri davvero eliminare la configurazione del repository?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Impossibile eliminare il repository", "success-repository-deleted": "Impostazioni del repository in coda per l'eliminazione", - "title-delete-repository": "Elimina configurazione repository", - "tooltip-delete-this-repository": "Elimina questo repository" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Torna ai repository", @@ -13056,6 +13059,7 @@ "name-point-size": "Dimensione punto", "name-show-points": "Mostra punti", "name-show-thresholds": "Mostra soglie", + "name-show-values": "", "name-style": "Stile", "name-transform": "Trasforma", "transform-options": { diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 00ba2d580a5..1b307a5cbb8 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -6400,8 +6400,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "このエラーの詳細については、<2>こちらをクリックしてください。", - "success-more-details-links": "次に、<2>ダッシュボードを構築するか、<5>Exploreビューでデータをクエリすることで、データの視覚化を開始できます。", - "success-more-details-links-extensions": "" + "success-more-details-links": "次に、<2>ダッシュボードを構築するか、<5>Exploreビューでデータをクエリすることで、データの視覚化を開始できます。" }, "data-sources": { "datasource-add-button": { @@ -11219,11 +11218,15 @@ }, "delete-repository-button": { "button-delete": "削除", - "confirm-delete-repository": "リポジトリ設定を削除してもよろしいですか?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "リポジトリの削除に失敗しました", "success-repository-deleted": "リポジトリ設定が削除待ちリストに追加されました", - "title-delete-repository": "リポジトリ設定を削除", - "tooltip-delete-this-repository": "このリポジトリを削除" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "リポジトリに戻る", @@ -13015,6 +13018,7 @@ "name-point-size": "ポイントサイズ", "name-show-points": "ポイントを表示", "name-show-thresholds": "しきい値を表示", + "name-show-values": "", "name-style": "スタイル", "name-transform": "変換", "transform-options": { diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 6894c7e80ea..28a49431922 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -6400,8 +6400,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "이 오류에 대해 자세히 알아보려면 <2>여기를 클릭하세요.", - "success-more-details-links": "그런 다음 <2>대시보드를 구축하거나 <5>탐색 보기에서 데이터를 쿼리하여 데이터 시각화를 시작할 수 있습니다.", - "success-more-details-links-extensions": "" + "success-more-details-links": "그런 다음 <2>대시보드를 구축하거나 <5>탐색 보기에서 데이터를 쿼리하여 데이터 시각화를 시작할 수 있습니다." }, "data-sources": { "datasource-add-button": { @@ -11219,11 +11218,15 @@ }, "delete-repository-button": { "button-delete": "삭제", - "confirm-delete-repository": "정말 리포지토리 구성을 삭제하시겠어요?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "리포지토리 삭제 실패", "success-repository-deleted": "리포지토리 설정이 삭제 대기열에 추가되었습니다", - "title-delete-repository": "리포지토리 구성 삭제", - "tooltip-delete-this-repository": "이 리포지토리 삭제" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "리포지토리로 돌아가기", @@ -13015,6 +13018,7 @@ "name-point-size": "포인트 크기", "name-show-points": "포인트 표시", "name-show-thresholds": "임계값 표시", + "name-show-values": "", "name-style": "스타일", "name-transform": "변환", "transform-options": { diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index 3c26fc20b6b..63f7d83aa8f 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -6421,8 +6421,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Klik <2>hier voor meer informatie over deze fout.", - "success-more-details-links": "Vervolgens kun je beginnen met het visualiseren van gegevens door <2> een dashboard te bouwen of door gegevens op te vragen in de <5> Verkennen-weergave.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Vervolgens kun je beginnen met het visualiseren van gegevens door <2> een dashboard te bouwen of door gegevens op te vragen in de <5> Verkennen-weergave." }, "data-sources": { "datasource-add-button": { @@ -11254,11 +11253,15 @@ }, "delete-repository-button": { "button-delete": "Verwijderen", - "confirm-delete-repository": "Weet je zeker dat je de repository-configuratie wilt verwijderen?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Kan de repository niet verwijderen", "success-repository-deleted": "Repository-instellingen in de wachtrij voor verwijdering", - "title-delete-repository": "Repository-configuratie verwijderen", - "tooltip-delete-this-repository": "Deze repository verwijderen" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Terug naar repositories", @@ -13056,6 +13059,7 @@ "name-point-size": "Puntgrootte", "name-show-points": "Punten tonen", "name-show-thresholds": "Drempels weergeven", + "name-show-values": "", "name-style": "Stijl", "name-transform": "Transformeren", "transform-options": { diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 190ad2c5a78..d8b667a0b21 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -6463,8 +6463,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Kliknij <2>tutaj, aby dowiedzieć się więcej o tym błędzie.", - "success-more-details-links": "Następnie możesz rozpocząć wizualizację danych, <2>tworząc pulpit lub tworząc zapytanie dotyczące danych w widoku <5>Eksploruj.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Następnie możesz rozpocząć wizualizację danych, <2>tworząc pulpit lub tworząc zapytanie dotyczące danych w widoku <5>Eksploruj." }, "data-sources": { "datasource-add-button": { @@ -11324,11 +11323,15 @@ }, "delete-repository-button": { "button-delete": "Usuń", - "confirm-delete-repository": "Czy na pewno chcesz usunąć konfigurację repozytorium?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Nie udało się usunąć repozytorium", "success-repository-deleted": "Ustawienia repozytorium zostały dodane do kolejki do usunięcia", - "title-delete-repository": "Usuń konfigurację repozytorium", - "tooltip-delete-this-repository": "Usuń to repozytorium" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Wróć do repozytoriów", @@ -13138,6 +13141,7 @@ "name-point-size": "Rozmiar punktu", "name-show-points": "Pokaż punkty", "name-show-thresholds": "Pokaż progi", + "name-show-values": "", "name-style": "Styl", "name-transform": "Przekształć", "transform-options": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 8bac7925b7f..6174c44eb6b 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -6421,8 +6421,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Clique <2>aqui para saber mais sobre esse erro.", - "success-more-details-links": "Em seguida, você pode começar a visualizar os dados <2>criando um painel de controle ou consultando os dados na <5>Visualização de exploração.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Em seguida, você pode começar a visualizar os dados <2>criando um painel de controle ou consultando os dados na <5>Visualização de exploração." }, "data-sources": { "datasource-add-button": { @@ -11254,11 +11253,15 @@ }, "delete-repository-button": { "button-delete": "Excluir", - "confirm-delete-repository": "Tem certeza de que deseja excluir a configuração do repositório?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Falha ao excluir o repositório", "success-repository-deleted": "Configurações do repositório na fila para exclusão", - "title-delete-repository": "Excluir configuração do repositório", - "tooltip-delete-this-repository": "Excluir este repositório" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Voltar para os repositórios", @@ -13056,6 +13059,7 @@ "name-point-size": "Tamanho do ponto", "name-show-points": "Exibir pontos", "name-show-thresholds": "Mostrar limites", + "name-show-values": "", "name-style": "Estilo", "name-transform": "Transformar", "transform-options": { diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 178b5ecd51a..81092ddb275 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -6421,8 +6421,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Clique <2>aqui para saber mais sobre este erro.", - "success-more-details-links": "Em seguida, pode começar a visualizar os dados <2>criando um painel de controlo ou consultando os dados na <5>vista Explorar.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Em seguida, pode começar a visualizar os dados <2>criando um painel de controlo ou consultando os dados na <5>vista Explorar." }, "data-sources": { "datasource-add-button": { @@ -11254,11 +11253,15 @@ }, "delete-repository-button": { "button-delete": "Eliminar", - "confirm-delete-repository": "Tem a certeza de que pretende eliminar a configuração do repositório?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Não foi possível eliminar o repositório", "success-repository-deleted": "Definições do repositório em fila para eliminação", - "title-delete-repository": "Eliminar configuração do repositório", - "tooltip-delete-this-repository": "Eliminar este repositório" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Voltar aos repositórios", @@ -13056,6 +13059,7 @@ "name-point-size": "Tamanho dos pontos", "name-show-points": "Mostrar os pontos", "name-show-thresholds": "Mostrar limites", + "name-show-values": "", "name-style": "Estilo", "name-transform": "Transformar", "transform-options": { diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 70126a18fe4..df1098b38d0 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -6463,8 +6463,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Чтобы узнать больше об этой ошибке, нажмите <2>здесь.", - "success-more-details-links": "Затем вы можете начать визуализировать данные, <2>создав панель или запросив данные в <5>представлении Explore.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Затем вы можете начать визуализировать данные, <2>создав панель или запросив данные в <5>представлении Explore." }, "data-sources": { "datasource-add-button": { @@ -11324,11 +11323,15 @@ }, "delete-repository-button": { "button-delete": "Удалить", - "confirm-delete-repository": "Действительно удалить конфигурацию репозитория?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Не удалось удалить репозиторий", "success-repository-deleted": "Параметры репозитория в очереди на удаление", - "title-delete-repository": "Удаление конфигурации репозитория", - "tooltip-delete-this-repository": "Удалить репозиторий" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Назад к репозиториям", @@ -13138,6 +13141,7 @@ "name-point-size": "Размер точек", "name-show-points": "Показывать точки", "name-show-thresholds": "Показывать пороговые значения", + "name-show-values": "", "name-style": "Стиль", "name-transform": "Преобразовать", "transform-options": { diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 29c343a4f31..e21872dd0ba 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -6421,8 +6421,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Klicka <2>här för att läsa mer om detta fel.", - "success-more-details-links": "Därefter kan du börja visualisera data genom att <2>bygga en instrumentpanel eller genom att fråga efter data i <5>Utforska vy.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Därefter kan du börja visualisera data genom att <2>bygga en instrumentpanel eller genom att fråga efter data i <5>Utforska vy." }, "data-sources": { "datasource-add-button": { @@ -11254,11 +11253,15 @@ }, "delete-repository-button": { "button-delete": "Ta bort", - "confirm-delete-repository": "Är du säker på att du vill radera lagringsplatskonfigurationen?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Det gick inte att radera lagringsplatsen", "success-repository-deleted": "Lagringsplatsinställningar köade för radering", - "title-delete-repository": "Radera lagringsplatskonfiguration", - "tooltip-delete-this-repository": "Radera den här lagringsplatsen" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Tillbaka till lagringsplatserna", @@ -13056,6 +13059,7 @@ "name-point-size": "Punktstorlek", "name-show-points": "Visa poäng", "name-show-thresholds": "Visa trösklar", + "name-show-values": "", "name-style": "Stil", "name-transform": "Omvandla", "transform-options": { diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 1a566e85e2a..002fb9416b7 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -6421,8 +6421,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "Bu hata hakkında daha fazla bilgi edinmek için <2>buraya tıklayın.", - "success-more-details-links": "Ardından, bir <2>pano oluşturarak veya <5>Keşfet görünümünde verileri sorgulayarak verileri görselleştirmeye başlayabilirsiniz.", - "success-more-details-links-extensions": "" + "success-more-details-links": "Ardından, bir <2>pano oluşturarak veya <5>Keşfet görünümünde verileri sorgulayarak verileri görselleştirmeye başlayabilirsiniz." }, "data-sources": { "datasource-add-button": { @@ -11254,11 +11253,15 @@ }, "delete-repository-button": { "button-delete": "Sil", - "confirm-delete-repository": "Depo yapılandırmasını silmek istediğinize emin misiniz?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "Depo silinemedi", "success-repository-deleted": "Depo ayarları silinmek üzere kuyruğa alındı", - "title-delete-repository": "Depo yapılandırmasını sil", - "tooltip-delete-this-repository": "Bu depoyu sil" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "Depolara geri dön", @@ -13056,6 +13059,7 @@ "name-point-size": "Nokta boyutu", "name-show-points": "Noktaları göster", "name-show-thresholds": "Eşikleri göster", + "name-show-values": "", "name-style": "Stil", "name-transform": "Dönüştür", "transform-options": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index f366e287212..c30c35445a1 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -6400,8 +6400,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "点击<2>此处了解有关此错误的更多信息。", - "success-more-details-links": "接下来,您可以通过<2>构建数据面板或在<5>Explore 视图中查询数据来开始可视化数据。", - "success-more-details-links-extensions": "" + "success-more-details-links": "接下来,您可以通过<2>构建数据面板或在<5>Explore 视图中查询数据来开始可视化数据。" }, "data-sources": { "datasource-add-button": { @@ -11219,11 +11218,15 @@ }, "delete-repository-button": { "button-delete": "删除", - "confirm-delete-repository": "您确定要删除存储库配置吗?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "删除存储库失败", "success-repository-deleted": "等待删除的存储库设置", - "title-delete-repository": "删除存储库配置", - "tooltip-delete-this-repository": "删除此存储库" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "回到存储库", @@ -13015,6 +13018,7 @@ "name-point-size": "点大小", "name-show-points": "显示点", "name-show-thresholds": "显示阈值", + "name-show-values": "", "name-style": "样式", "name-transform": "转换", "transform-options": { diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 16fa86d1b46..d48e7fcd3d9 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -6400,8 +6400,7 @@ }, "data-source-testing-status-page": { "error-more-details-link": "點選<2>此處,了解更多關於此錯誤的資訊。", - "success-more-details-links": "接下來,您可以透過<2>建立儀表板或在<5>瀏覽檢視中查詢資料,開始將資料視覺化。", - "success-more-details-links-extensions": "" + "success-more-details-links": "接下來,您可以透過<2>建立儀表板或在<5>瀏覽檢視中查詢資料,開始將資料視覺化。" }, "data-sources": { "datasource-add-button": { @@ -11219,11 +11218,15 @@ }, "delete-repository-button": { "button-delete": "刪除", - "confirm-delete-repository": "確定要刪除儲存庫設定嗎?", + "confirm-delete-keep-resources": "", + "confirm-delete-with-resources": "", + "delete": "", + "delete-and-keep-resources": "", + "delete-and-remove-resources": "", "error-repository-delete": "無法刪除儲存庫", "success-repository-deleted": "儲存庫設定已排入刪除佇列", - "title-delete-repository": "刪除儲存庫設定", - "tooltip-delete-this-repository": "刪除此儲存庫" + "title-delete-repository-and-resources": "", + "title-delete-repository-only": "" }, "edit-repository-page": { "back-to-repositories": "返回至儲存庫", @@ -13015,6 +13018,7 @@ "name-point-size": "點大小", "name-show-points": "顯示點", "name-show-thresholds": "顯示閾值", + "name-show-values": "", "name-style": "樣式", "name-transform": "轉變", "transform-options": { diff --git a/yarn.lock b/yarn.lock index 65b8f22dd32..dfd8656a6b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3586,11 +3586,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:^6.30.0": - version: 6.30.1 - resolution: "@grafana/scenes-react@npm:6.30.1" +"@grafana/scenes-react@npm:6.30.4": + version: 6.30.4 + resolution: "@grafana/scenes-react@npm:6.30.4" dependencies: - "@grafana/scenes": "npm:6.30.1" + "@grafana/scenes": "npm:6.30.4" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3602,13 +3602,13 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/695c61665f38f09f6b49552a32caf1fbe6a3209d9df7039fd9110dcbda9e2031e41d53b7e471021e779bee78f8f27a3978fed96f9e70a07f2157e25f96a81937 + checksum: 10/47a447459e0e432db40ebb8cbe9dbae24d4f94d6c558e53bc0ef79f21f91d219650af97fa7c9f5bcbf5ef09f34de900d6db69f22bd897465371ec9fc696f68b1 languageName: node linkType: hard -"@grafana/scenes@npm:6.30.1, @grafana/scenes@npm:^6.30.0": - version: 6.30.1 - resolution: "@grafana/scenes@npm:6.30.1" +"@grafana/scenes@npm:6.30.4": + version: 6.30.4 + resolution: "@grafana/scenes@npm:6.30.4" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3628,7 +3628,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/6c50a31330ecb674de6664d6e119e54dee7ecee50925a71870c0a08562e4b3d5614e56e34008d6710e65ef03a8220ba2e9d811804b8a001392de46ef745a2e75 + checksum: 10/7a05dc8e8a01cc3b92e0a02b0a8d3fd0af2cf420840dd25daf5939d22669212cd96a7d91bf1caf7fbae3a5530ace519c730141664fac17220869117d4ccbec0a languageName: node linkType: hard @@ -18320,8 +18320,8 @@ __metadata: "@grafana/plugin-ui": "npm:0.10.9" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:^6.30.0" - "@grafana/scenes-react": "npm:^6.30.0" + "@grafana/scenes": "npm:6.30.4" + "@grafana/scenes-react": "npm:6.30.4" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*"