diff --git a/.betterer.results b/.betterer.results index 2af5d4872d0..e1996888277 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1615,21 +1615,8 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], [0, 0, 0, "Do not use any type assertions.", "3"] ], - "public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "5"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "8"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "9"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "10"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "11"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "12"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "13"] + "public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], "public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] @@ -1682,9 +1669,6 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], - "public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] - ], "public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], @@ -2434,6 +2418,14 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "7"], [0, 0, 0, "Do not use any type assertions.", "8"] ], + "public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Do not use any type assertions.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + ], "public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], @@ -2564,9 +2556,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not re-export imported variable (\`./VersionHistoryButtons\`)", "3"], [0, 0, 0, "Do not re-export imported variable (\`./VersionHistoryComparison\`)", "4"] ], - "public/app/features/dashboard-scene/sharing/ShareExportTab.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] - ], "public/app/features/dashboard-scene/sharing/public-dashboards/ConfigPublicDashboard.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], @@ -5637,7 +5626,8 @@ exports[`better eslint`] = { ], "public/swagger/SwaggerPage.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] ], "public/swagger/index.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8b4c204f87a..99a3971e3e4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -40,7 +40,6 @@ /docs/sources/alerting/ @brendamuir /docs/sources/dashboards/ @imatwawana -/docs/sources/explore/ @grafana/observability-traces-and-profiling @lwandz13 /docs/sources/panels-visualizations/ @imatwawana /docs/sources/release-notes/ @irenerl24 @GrafanaWriter /docs/sources/upgrade-guide/ @imatwawana @@ -673,6 +672,7 @@ playwright.config.ts @grafana/plugins-platform-frontend # Kind definitions /kinds/dashboard @grafana/dashboards-squad /kinds/ @grafana/grafana-as-code +kindsv2/ @grafana/dashboards-squad # Kind system and code generation embed.go @grafana/grafana-as-code diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index a46a12134bf..8cbe3030ffe 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -8,7 +8,7 @@ on: type: string latest: required: false - default: false + default: "0" description: Mark this release as latest (`1`) or not (`0`, default) type: string dry_run: @@ -23,6 +23,7 @@ on: type: string latest: required: false + default: "0" description: Mark this release as latest (`1`) or not (`0`, default) type: string dry_run: diff --git a/.github/workflows/release-comms.yml b/.github/workflows/release-comms.yml index dc7355ef0b8..02d53057184 100644 --- a/.github/workflows/release-comms.yml +++ b/.github/workflows/release-comms.yml @@ -8,10 +8,11 @@ on: dry_run: required: false default: true + type: boolean version: required: true latest: - type: bool + type: boolean default: false pull_request: types: @@ -30,17 +31,18 @@ jobs: latest: ${{ steps.output.outputs.latest }} runs-on: ubuntu-latest steps: + # The github-release action expects a `LATEST` value of a string of either '1' or '0' - if: ${{ github.event_name == 'workflow_dispatch' }} run: | echo setting up GITHUB_ENV for ${{ github.event_name }} echo "VERSION=${{ inputs.version }}" >> $GITHUB_ENV echo "DRY_RUN=${{ inputs.dry_run }}" >> $GITHUB_ENV - echo "LATEST=${{ inputs.latest }}" >> $GITHUB_ENV + echo "LATEST=${{ inputs.latest && '1' || '0' }}" >> $GITHUB_ENV - if: ${{ github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') }} run: | echo "VERSION=$(echo ${{ github.head_ref }} | sed -e 's/release\/.*\///g')" >> $GITHUB_ENV echo "DRY_RUN=${{ contains(github.event.pull_request.labels.*.name, 'release/dry-run') }}" >> $GITHUB_ENV - echo "LATEST=${{ contains(github.event.pull_request.labels.*.name, 'release/latest') }}" >> $GITHUB_ENV + echo "LATEST=${{ contains(github.event.pull_request.labels.*.name, 'release/latest') && '1' || '0' }}" >> $GITHUB_ENV - id: output run: | echo "dry_run: $DRY_RUN" diff --git a/CHANGELOG.md b/CHANGELOG.md index af808ed98a1..4469fb9a34d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,114 @@ + + +# 11.3.1 (2024-11-19) + +### Features and enhancements + +- **Alerting:** Make context deadline on AlertNG service startup configurable [#96135](https://github.com/grafana/grafana/pull/96135), [@fayzal-g](https://github.com/fayzal-g) +- **MigrationAssistant:** Restrict dashboards, folders and datasources by the org id of the signed in user [#96345](https://github.com/grafana/grafana/pull/96345), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **User:** Check SignedInUser OrgID in RevokeInvite [#95490](https://github.com/grafana/grafana/pull/95490), [@mgyongyosi](https://github.com/mgyongyosi) + +### Bug fixes + +- **Alerting:** Fix escaping of silence matchers in utf8 mode [#95347](https://github.com/grafana/grafana/pull/95347), [@tomratcliffe](https://github.com/tomratcliffe) +- **Alerting:** Fix overflow for long receiver names [#95133](https://github.com/grafana/grafana/pull/95133), [@gillesdemey](https://github.com/gillesdemey) +- **Alerting:** Fix saving advanced mode toggle state in the alert rule editor [#95981](https://github.com/grafana/grafana/pull/95981), [@alexander-akhmetov](https://github.com/alexander-akhmetov) +- **Alerting:** Fix setting datasource uid, when datasource is string in old version [#96273](https://github.com/grafana/grafana/pull/96273), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Alerting:** Force refetch prom rules when refreshing panel [#96125](https://github.com/grafana/grafana/pull/96125), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Anonymous User:** Adds validator service for anonymous users [#94994](https://github.com/grafana/grafana/pull/94994), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Anonymous User:** Adds validator service for anonymous users (Enterprise) +- **Azure Monitor:** Support metric namespaces fallback [#95155](https://github.com/grafana/grafana/pull/95155), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Fix duplicated traces in multi-resource trace query [#95247](https://github.com/grafana/grafana/pull/95247), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Handle namespace request rejection [#95909](https://github.com/grafana/grafana/pull/95909), [@aangelisc](https://github.com/aangelisc) +- **CloudWatch:** Interpolate region in log context query [#94990](https://github.com/grafana/grafana/pull/94990), [@iwysiu](https://github.com/iwysiu) +- **Dashboard datasource:** Return annotations as series when query topic is "annotations" [#95971](https://github.com/grafana/grafana/pull/95971), [@kaydelaney](https://github.com/kaydelaney) +- **Dashboard:** Append orgId to URL [#95963](https://github.com/grafana/grafana/pull/95963), [@bfmatei](https://github.com/bfmatei) +- **Dashboards:** Fixes performance issue expanding a row [#95321](https://github.com/grafana/grafana/pull/95321), [@torkelo](https://github.com/torkelo) +- **Flame Graph:** Fix crash when it receives empty data [#96211](https://github.com/grafana/grafana/pull/96211), [@yincongcyincong](https://github.com/yincongcyincong) +- **Folders:** Add admin permissions upon creation of a folder w. SA [#95365](https://github.com/grafana/grafana/pull/95365), [@eleijonmarck](https://github.com/eleijonmarck) +- **Folders:** Don't show error pop-up if the user can't fetch the root folder [#95600](https://github.com/grafana/grafana/pull/95600), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95232](https://github.com/grafana/grafana/pull/95232), [@kalleep](https://github.com/kalleep) +- **ServerLock:** Fix pg concurrency/locking issue [#95935](https://github.com/grafana/grafana/pull/95935), [@mgyongyosi](https://github.com/mgyongyosi) +- **Service Accounts:** Run service account creation in transaction [#94803](https://github.com/grafana/grafana/pull/94803), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **Table:** Fix text wrapping applying to wrong field [#95425](https://github.com/grafana/grafana/pull/95425), [@codeincarnate](https://github.com/codeincarnate) +- **Unified Storage:** Use ssl_mode instead of sslmode [#95662](https://github.com/grafana/grafana/pull/95662), [@chaudyg](https://github.com/chaudyg) + + + + +# 11.2.4 (2024-11-19) + +### Features and enhancements + +- **Alerting:** Make context deadline on AlertNG service startup configurable [#96133](https://github.com/grafana/grafana/pull/96133), [@fayzal-g](https://github.com/fayzal-g) +- **MigrationAssistant:** Restrict dashboards, folders and datasources by the org id of the signed in user [#96344](https://github.com/grafana/grafana/pull/96344), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Transformations:** Add 'transpose' transform [#95076](https://github.com/grafana/grafana/pull/95076), [@jmdane](https://github.com/jmdane) +- **User:** Check SignedInUser OrgID in RevokeInvite [#95489](https://github.com/grafana/grafana/pull/95489), [@mgyongyosi](https://github.com/mgyongyosi) + +### Bug fixes + +- **Alerting:** Force refetch prom rules when refreshing panel [#96124](https://github.com/grafana/grafana/pull/96124), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Anonymous User:** Adds validator service for anonymous users [#94993](https://github.com/grafana/grafana/pull/94993), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Anonymous User:** Adds validator service for anonymous users (Enterprise) +- **Azure Monitor:** Support metric namespaces fallback [#95154](https://github.com/grafana/grafana/pull/95154), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Fix duplicated traces in multi-resource trace query [#95246](https://github.com/grafana/grafana/pull/95246), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Handle namespace request rejection [#95908](https://github.com/grafana/grafana/pull/95908), [@aangelisc](https://github.com/aangelisc) +- **Folders:** Add admin permissions upon creation of a folder w. SA [#95416](https://github.com/grafana/grafana/pull/95416), [@eleijonmarck](https://github.com/eleijonmarck) +- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95231](https://github.com/grafana/grafana/pull/95231), [@kalleep](https://github.com/kalleep) +- **ServerLock:** Fix pg concurrency/locking issue [#95934](https://github.com/grafana/grafana/pull/95934), [@mgyongyosi](https://github.com/mgyongyosi) +- **ServerSideExpressions:** Disable SQL Expressions to prevent RCE and LFI vulnerability [#94959](https://github.com/grafana/grafana/pull/94959), [@samjewell](https://github.com/samjewell) + + + + +# 11.1.9 (2024-11-19) + +### Features and enhancements + +- **Alerting:** Make context deadline on AlertNG service startup configurable [#96132](https://github.com/grafana/grafana/pull/96132), [@fayzal-g](https://github.com/fayzal-g) +- **User:** Check SignedInUser OrgID in RevokeInvite [#95488](https://github.com/grafana/grafana/pull/95488), [@mgyongyosi](https://github.com/mgyongyosi) + +### Bug fixes + +- **Alerting:** Force refetch prom rules when refreshing panel [#96123](https://github.com/grafana/grafana/pull/96123), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Anonymous User:** Adds validator service for anonymous users [#94992](https://github.com/grafana/grafana/pull/94992), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Anonymous User:** Adds validator service for anonymous users (Enterprise) +- **Azure Monitor:** Support metric namespaces fallback [#95153](https://github.com/grafana/grafana/pull/95153), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Fix duplicated traces in multi-resource trace query [#95245](https://github.com/grafana/grafana/pull/95245), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Handle namespace request rejection [#95907](https://github.com/grafana/grafana/pull/95907), [@aangelisc](https://github.com/aangelisc) +- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95230](https://github.com/grafana/grafana/pull/95230), [@kalleep](https://github.com/kalleep) +- **Prometheus:** Fix interpolating adhoc filters with template variables [#95977](https://github.com/grafana/grafana/pull/95977), [@cazeaux](https://github.com/cazeaux) +- **ServerLock:** Fix pg concurrency/locking issue [#95933](https://github.com/grafana/grafana/pull/95933), [@mgyongyosi](https://github.com/mgyongyosi) +- **ServerSideExpressions:** Disable SQL Expressions to prevent RCE and LFI vulnerability [#94969](https://github.com/grafana/grafana/pull/94969), [@scottlepp](https://github.com/scottlepp) + + + + +# 11.0.8 (2024-11-19) + +### Features and enhancements + +- **Alerting:** Make context deadline on AlertNG service startup configurable [#96131](https://github.com/grafana/grafana/pull/96131), [@fayzal-g](https://github.com/fayzal-g) +- **User:** Check SignedInUser OrgID in RevokeInvite [#95487](https://github.com/grafana/grafana/pull/95487), [@mgyongyosi](https://github.com/mgyongyosi) + +### Bug fixes + +- **Anonymous User:** Adds validator service for anonymous users [#95151](https://github.com/grafana/grafana/pull/95151), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Anonymous User:** Adds validator service for anonymous users (Enterprise) +- **Azure Monitor:** Support metric namespaces fallback [#95152](https://github.com/grafana/grafana/pull/95152), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Fix duplicated traces in multi-resource trace query [#95244](https://github.com/grafana/grafana/pull/95244), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Handle namespace request rejection [#95906](https://github.com/grafana/grafana/pull/95906), [@aangelisc](https://github.com/aangelisc) +- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95229](https://github.com/grafana/grafana/pull/95229), [@kalleep](https://github.com/kalleep) +- **Prometheus:** Fix interpolating adhoc filters with template variables [#95986](https://github.com/grafana/grafana/pull/95986), [@cazeaux](https://github.com/cazeaux) +- **ServerLock:** Fix pg concurrency/locking issue [#95932](https://github.com/grafana/grafana/pull/95932), [@mgyongyosi](https://github.com/mgyongyosi) +- **ServerSideExpressions:** Disable SQL Expressions to prevent RCE and LFI vulnerability [#94971](https://github.com/grafana/grafana/pull/94971), [@samjewell](https://github.com/samjewell) + + + + +# 10.4.13 (2024-11-19) + + # 11.3.0+security-01 (2024-11-12) diff --git a/Dockerfile b/Dockerfile index f66b04bd747..9c71569fc58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ COPY scripts scripts COPY emails emails -ENV NODE_ENV production +ENV NODE_ENV=production RUN yarn build FROM ${GO_IMAGE} as go-builder @@ -77,6 +77,7 @@ RUN if [[ "$BINGO" = "true" ]]; then \ COPY embed.go Makefile build.go package.json ./ COPY cue.mod cue.mod COPY kinds kinds +COPY kindsv2 kindsv2 COPY local local COPY packages/grafana-schema packages/grafana-schema COPY public/app/plugins public/app/plugins diff --git a/Makefile b/Makefile index 6929b8167d4..0298bb55d83 100644 --- a/Makefile +++ b/Makefile @@ -146,6 +146,11 @@ gen-cue: ## Do all CUE/Thema code generation go generate ./kinds/gen.go go generate ./public/app/plugins/gen.go +.PHONY: gen-cuev2 +gen-cuev2: ## Do all CUE code generation + @echo "generate code from .cue files (v2)" + go generate ./kindsv2/gen.go + .PHONY: gen-feature-toggles gen-feature-toggles: ## First go test run fails because it will re-generate the feature toggles. diff --git a/devenv/docker/blocks/collectd/Dockerfile b/devenv/docker/blocks/collectd/Dockerfile index ea046659bd1..00762cd0149 100644 --- a/devenv/docker/blocks/collectd/Dockerfile +++ b/devenv/docker/blocks/collectd/Dockerfile @@ -1,6 +1,6 @@ FROM ubuntu:xenial -ENV DEBIAN_FRONTEND noninteractive +ENV DEBIAN_FRONTEND=noninteractive RUN apt-get -y update RUN apt-get -y install collectd curl python-pip diff --git a/devenv/docker/buildcontainer/Dockerfile b/devenv/docker/buildcontainer/Dockerfile index ae85c8fbad2..a6a636439c3 100644 --- a/devenv/docker/buildcontainer/Dockerfile +++ b/devenv/docker/buildcontainer/Dockerfile @@ -2,18 +2,18 @@ FROM centos:6.6 RUN yum install -y initscripts curl tar gcc libc6-dev git -ENV GOLANG_VERSION 1.4.2 +ENV GOLANG_VERSION=1.4.2 RUN curl -sSL https://golang.org/dl/go$GOLANG_VERSION.src.tar.gz \ | tar -v -C /usr/src -xz RUN cd /usr/src/go/src && ./make.bash --no-clean 2>&1 -ENV PATH /usr/src/go/bin:$PATH +ENV PATH=/usr/src/go/bin:$PATH RUN mkdir -p /go/src /go/bin && chmod -R 777 /go -ENV GOPATH /go -ENV PATH /go/bin:$PATH +ENV GOPATH=/go +ENV PATH=/go/bin:$PATH WORKDIR /go/src/github.com/grafana/grafana diff --git a/devenv/docker/ha-test-unified-alerting/Dockerfile b/devenv/docker/ha-test-unified-alerting/Dockerfile index cc9b14e2e4e..38965c12636 100644 --- a/devenv/docker/ha-test-unified-alerting/Dockerfile +++ b/devenv/docker/ha-test-unified-alerting/Dockerfile @@ -7,6 +7,6 @@ WORKDIR /go/src/webhook RUN mkdir /tmp/logs RUN go build -o /bin webhook-listener.go -ENV PORT 8080 +ENV PORT=8080 ENTRYPOINT [ "/bin/webhook-listener" ] diff --git a/docs/sources/datasources/loki/configure-loki-data-source.md b/docs/sources/datasources/loki/configure-loki-data-source.md index b667c8e1bf8..8dc6eb1670c 100644 --- a/docs/sources/datasources/loki/configure-loki-data-source.md +++ b/docs/sources/datasources/loki/configure-loki-data-source.md @@ -114,9 +114,13 @@ Each derived field consists of the following: - **Type** - Defines the type of the derived field. It can be either: - - **Regex**: A regular expression to parse a part of the log message and capture it as the value of the new field. Can contain only one capture group. +{{% admonition type="caution" %}} +Using complex regular expressions in either type can impact browser performance when processing large volumes of logs. Consider using simpler patterns when possible. +{{% /admonition %}} - - **Label**: A label from the selected log line. This can be any type of label - indexed, parsed or structured metadata. The label's value will be used as the value of the derived field. +- **Regex**: A regular expression to parse a part of the log message and capture it as the value of the new field. Can contain only one capture group. + +- **Label**: A label from the selected log line. This can be any type of label - indexed, parsed or structured metadata. When using this type, the input will match as a regular expression against label keys, allowing you to match variations like `traceid` and `trace_id` with a single regex pattern like `trace[_]?id`. The value of the matched label will be used as the value of the derived field. - **URL/query** Sets the full link URL if the link is external, or a query for the target data source if the link is internal. You can interpolate the value from the field with the `${__value.raw}` macro. diff --git a/docs/sources/datasources/prometheus/_index.md b/docs/sources/datasources/prometheus/_index.md index 1482e497ed2..cd5cbb04ac1 100644 --- a/docs/sources/datasources/prometheus/_index.md +++ b/docs/sources/datasources/prometheus/_index.md @@ -163,17 +163,7 @@ For details about these metrics, refer to [Internal Grafana metrics](ref:set-up- ## Amazon Managed Service for Prometheus -The Prometheus data source works with Amazon Managed Service for Prometheus. - -If you use an AWS Identity and Access Management (IAM) policy to control access to your Amazon Elasticsearch Service domain, you must use AWS Signature Version 4 (AWS SigV4) to sign all requests to that domain. - -For details on AWS SigV4, refer to the [AWS documentation](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html). - -### AWS Signature Version 4 authentication - -To connect the Prometheus data source to Amazon Managed Service for Prometheus using SigV4 authentication, refer to the AWS guide to [Set up Grafana open source or Grafana Enterprise for use with AMP](https://docs.aws.amazon.com/prometheus/latest/userguide/AMP-onboard-query-standalone-grafana.html). - -If you run Grafana in an Amazon EKS cluster, follow the AWS guide to [Query using Grafana running in an Amazon EKS cluster](https://docs.aws.amazon.com/prometheus/latest/userguide/AMP-onboard-query-grafana-7.3.html). +The Prometheus data source with Amazon Managed Service for Prometheus is deprecated. Please use the [Amazon Managed service for Prometheus data source](https://grafana.com/grafana/plugins/grafana-amazonprometheus-datasource/). Migrations steps are detailed in the link. ## Azure authentication settings diff --git a/docs/sources/introduction/grafana-enterprise.md b/docs/sources/introduction/grafana-enterprise.md index 0a9a17ca79b..ccd311d14fe 100644 --- a/docs/sources/introduction/grafana-enterprise.md +++ b/docs/sources/introduction/grafana-enterprise.md @@ -34,12 +34,14 @@ Grafana Enterprise includes integrations with more ways to authenticate your use Supported auth providers: - [Auth Proxy]({{< relref "../setup-grafana/configure-security/configure-authentication/auth-proxy#team-sync-enterprise-only" >}}) -- [Azure AD OAuth]({{< relref "../setup-grafana/configure-security/configure-authentication/azuread#team-sync-enterprise-only" >}}) -- [GitHub OAuth]({{< relref "../setup-grafana/configure-security/configure-authentication/github#configure-team-synchronization" >}}) -- [GitLab OAuth]({{< relref "../setup-grafana/configure-security/configure-authentication/gitlab#configure-team-synchronization" >}}) -- [LDAP]({{< relref "../setup-grafana/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization-for-teams" >}}) -- [Okta]({{< relref "../setup-grafana/configure-security/configure-authentication/okta#configure-team-synchronization-enterprise-only" >}}) -- [SAML]({{< relref "../setup-grafana/configure-security/configure-authentication/saml#configure-team-sync" >}}) +- [Azure AD](https://grafana.com/docs/grafana//configure-security/configure-authentication/azuread#group-sync-enterprise-only) +- [Generic OAuth integration](https://grafana.com/docs/grafana//configure-security/configure-authentication/generic-oauth#configure-group-synchronization" >}}) +- [GitHub OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/github#configure-group-synchronization) +- [GitLab OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/gitlab#configure-group-synchronization) +- [Google OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/google#configure-group-synchronization) +- [LDAP](https://grafana.com/docs/grafana//configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization) +- [Okta](https://grafana.com/docs/grafana//configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only) +- [SAML](https://grafana.com/docs/grafana//configure-security/configure-authentication/saml#configure-group-synchronization) ### Enhanced LDAP integration diff --git a/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md b/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md index 553f08b452e..d0ac307a1bc 100644 --- a/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md +++ b/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md @@ -338,6 +338,7 @@ Use this transformation to select a source of data and extract content from it i - **Format** - Choose one of the following: - **JSON** - Parse JSON content from the source. - **Key+value pairs** - Parse content in the format 'a=b' or 'c:d' from the source. + - **RegExp** - Parse content using a regular expression with [named capturing group(s)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group). - **Auto** - Discover fields automatically. - **Replace All Fields** - (Optional) Select this option to hide all other fields and display only your calculated field in the visualization. - **Keep Time** - (Optional) Available only if **Replace All Fields** is true. Keeps the time field in the output. diff --git a/docs/sources/panels-visualizations/visualizations/node-graph/index.md b/docs/sources/panels-visualizations/visualizations/node-graph/index.md index f5860c717c0..e7e2b7c9dcc 100644 --- a/docs/sources/panels-visualizations/visualizations/node-graph/index.md +++ b/docs/sources/panels-visualizations/visualizations/node-graph/index.md @@ -25,7 +25,7 @@ Node graphs are useful when you need to visualize elements that are related to e Node graphs display useful information about each node, as well as the relationships between them, allowing you to visualize complex infrastructure maps, hierarchies, or execution diagrams. -![Node graph visualization](/media/docs/grafana/data-sources/tempo/query-editor/tempo-ds-query-node-graph.png 'Node graph') +![Node graph visualization](/media/docs/grafana/panels-visualizations/screenshot-node-graph-v11.3.png 'Node graph') The appearance of nodes and edges can also be customized in several ways including color, borders, and line style. @@ -79,36 +79,7 @@ Similar to the nodes dataset, the edges dataset needs one unique ID field for ea If a node lacks edge connections, it’s displayed on its own outside of the network. -## Panel options - -{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} - -## Node graph options - -Use the following options to refine your node graph visualization. - -- **Zoom mode** - Choose how the node graph should handle zoom and scroll events. - -## Nodes options - -The **Nodes** options section provides configurations for node behaviors. - -- **Main stat unit** - Choose which unit the main stat displays in the graph's nodes. -- **Secondary stat unit** - Choose which unit the secondary stat displays in the graph's nodes. -- **Arc sections** - Configure which fields define the size of the colored circle around the node and select a color for each. You can add multiple fields. - -{{< admonition type="note" >}} -Defining arc sections overrides the automatic detection of `arc__*` and `color` fields described in the **Optional fields** section of [Nodes data frame structure](#nodes-data-frame-structure). -{{< /admonition >}} - -## Edges options - -The **Edges** options section provides configurations for node edges behaviors. - -- **Main stat unit** - Choose which unit the main stat displays in the graph's edges. -- **Secondary stat unit** - Choose which unit the secondary stat displays in the graph's edges. - -## Data requirements +### Data requirements A node graph requires a specific shape of the data to be able to display its nodes and edges. This means not every data source or query can be visualized with this graph. If you want to use this as a data source developer see the section about data API. @@ -119,7 +90,7 @@ A node graph consists of _nodes_ and _edges_. Both nodes and edges can have associated metadata or statistics. The data source defines what information and values is shown, so different data sources can show different type of values or not show some values. -### Nodes +#### Nodes {{% admonition type="note" %}} Node graphs can show only 1,500 nodes. If this limit is crossed a warning will be visible in upper right corner, and some nodes will be hidden. You can expand hidden parts of the graph by clicking on the "Hidden nodes" markers in the graph. @@ -131,47 +102,88 @@ For example, you can have the percentage of errors represented by a red portion Additional details can be displayed in a context menu which is displayed when you click on the node. There also can be additional links in the context menu that can target either other parts of Grafana or any external link. -![Node graph navigation](/media/docs/grafana/data-sources/tempo/query-editor/node-graph-navigation.png 'Node graph navigation') +![Node context menu](/media/docs/grafana/panels-visualizations/screenshot-node-links-v11.3.png 'Node context menu') -### Edges +#### Edges Edges can also show statistics when you hover over the edge. Similar to nodes, you can open a context menu with additional details and links by clicking on the edge. The first data source supporting this visualization is X-Ray data source for its Service map feature. For more information, refer to the [X-Ray plugin documentation](https://grafana.com/grafana/plugins/grafana-x-ray-datasource). +## Configuration options + +{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="" >}} + +### Panel options + +{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} + +### Node graph options + +Use the following options to refine your node graph visualization. + +- **Zoom mode** - Choose how the node graph should handle zoom and scroll events. + +### Nodes options + +The **Nodes** options section provides configurations for node behaviors. + +- **Main stat unit** - Choose which unit the main stat displays in the graph's nodes. +- **Secondary stat unit** - Choose which unit the secondary stat displays in the graph's nodes. +- **Arc sections** - Configure which fields define the size of the colored circle around the node and select a color for each. You can add multiple fields. + +{{< admonition type="note" >}} +Defining arc sections overrides the automatic detection of `arc__*` and `color` fields described in the **Optional fields** section of [Nodes data frame structure](#nodes-data-frame-structure). +{{< /admonition >}} + +### Edges options + +The **Edges** options section provides configurations for node edges behaviors. + +- **Main stat unit** - Choose which unit the main stat displays in the graph's edges. +- **Secondary stat unit** - Choose which unit the secondary stat displays in the graph's edges. + +### Data links + +{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}} + +In node graphs, some data fields may have pre-configured data links. To add a different data link in those cases, use a [field override](#field-overrides). + +### Field overrides + +{{< docs/shared lookup="visualizations/overrides-options.md" source="grafana" version="" >}} + ## Navigating the node graph -You can pan and zoom in or out a node graph. +You can use pan, zoom, and other functions to navigate a node graph. ### Pan You can pan the view by clicking outside any node or edge and dragging your mouse. -### Zoom in or out +### Zoom -Use the buttons in the upper left corner or use the mouse wheel, touchpad scroll, together with either Ctrl or Cmd key to zoom in or out. +Use the buttons in the lower right corner to zoom in or out. You can also use the mouse wheel or touchpad scroll, together with either Ctrl or Cmd key to do so. -### Explore hidden nodes +### Hidden nodes The number of nodes shown at a given time is limited to maintain a reasonable visualization performance. Nodes that are not currently visible are hidden behind clickable markers that show an approximate number of hidden nodes that are connected by a particular edge. You can click on the marker to expand the graph around that node. -![Node graph exploration](/static/img/docs/node-graph/node-graph-exploration-8-0.png 'Node graph exploration') +![Node graph exploration](/media/docs/grafana/panels-visualizations/node-graph-exploration-8.0-2.png 'Node graph exploration') ### Grid view You can switch to the grid view to have a better overview of the most interesting nodes in the graph. Grid view shows nodes in a grid without edges and can be sorted by stats shown inside the node or by stats represented by the a colored border of the nodes. - - -![Node graph grid](/media/docs/grafana/data-sources/tempo/query-editor/node-graph-grid-view.png 'Node graph grid') +![Node graph grid](/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid-v11.3.png 'Node graph grid') To sort the nodes, click on the stats inside the legend. The marker next to the stat name shows which stat is currently used for sorting and sorting direction. -![Node graph legend](/static/img/docs/node-graph/node-graph-legend-8-0.png 'Node graph legend') +![Node graph legend](/media/docs/grafana/panels-visualizations/screenshot-node-graph-legend-v11.3.png 'Node graph legend') Click on the node and select "Show in Graph layout" option to switch back to graph layout and focus on the selected node, to show it in context of the full graph. -![Node graph grid to default](/static/img/docs/node-graph/node-graph-grid-to-default-8-0.png 'Node graph grid to default') +![Node graph grid to default](/media/docs/grafana/panels-visualizations/screenshot-node-graph-view-v11.3.png 'Node graph grid to default') ## Data API @@ -217,15 +229,15 @@ Required fields: Optional fields: -| Field name | Type | Description | -| ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| title | string | Name of the node visible in just under the node. | -| subtitle | string | Additional, name, type or other identifier shown under the title. | -| mainstat | string/number | First stat shown inside the node itself. It can either be a string showing the value as is or a number. If it is a number, any unit associated with that field is also shown. | -| secondarystat | string/number | Same as mainStat, but shown under it inside the node. | -| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. | -| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. | -| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behaviour depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. | -| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana built in icons are allowed (see the available icons [here](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview)). | -| nodeRadius | number | Radius value in pixels. Used to manage node size. | -| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` | +| Field name | Type | Description | +| ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| title | string | Name of the node visible in just under the node. | +| subtitle | string | Additional, name, type or other identifier shown under the title. | +| mainstat | string/number | First stat shown inside the node itself. It can either be a string showing the value as is or a number. If it is a number, any unit associated with that field is also shown. | +| secondarystat | string/number | Same as mainStat, but shown under it inside the node. | +| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. | +| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. | +| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behavior depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. | +| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview)) are allowed. | +| nodeRadius | number | Radius value in pixels. Used to manage node size. | +| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` | diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 15d4cb8adce..5537f1d1e73 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -78,6 +78,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `openSearchBackendFlowEnabled` | Enables the backend query flow for Open Search datasource plugin | Yes | | `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes | | `cloudwatchMetricInsightsCrossAccount` | Enables cross account observability for Cloudwatch Metric Insights query builder | Yes | +| `singleTopNav` | Unifies the top search bar and breadcrumb bar into one | Yes | | `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | | | `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes | | `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes | @@ -115,7 +116,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector | | `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | | `ssoSettingsLDAP` | Use the new SSO Settings API to configure LDAP | -| `singleTopNav` | Unifies the top search bar and breadcrumb bar into one | | `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | | `reportingUseRawTimeRange` | Uses the original report or dashboard time range instead of making an absolute transformation | diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md index dfbea92ba5f..4c4d8d6e81e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md @@ -353,14 +353,14 @@ This setting is ignored if multiple auth providers are configured to use auto lo auto_login = true ``` -### Team Sync (Enterprise only) +### Group sync (Enterprise only) -With Team Sync you can map your Entra ID groups to teams in Grafana so that your users will automatically be added to -the correct teams. +With group sync you can map your Entra ID groups to teams and roles in Grafana. This allows users to automatically be added to +the correct teams and be granted the correct roles in Grafana. You can reference Entra ID groups by group object ID, like `8bab1c86-8fba-33e5-2089-1d1c80ec267d`. -To learn more, refer to the [Team Sync]({{< relref "../../configure-team-sync" >}}) documentation. +To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync). ## Common troubleshooting diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/enhanced-ldap/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/enhanced-ldap/index.md index edc4057ec5f..a8992e015d6 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/enhanced-ldap/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/enhanced-ldap/index.md @@ -28,18 +28,16 @@ The enhanced LDAP integration adds additional functionality on top of the [LDAP > To control user access with role-based permissions, refer to [role-based access control]({{< relref "../../../../administration/roles-and-permissions/access-control" >}}). -## LDAP group synchronization for teams +## LDAP group synchronization -With enhanced LDAP integration, you can set up synchronization between LDAP groups and teams. This enables LDAP users that are members -of certain LDAP groups to automatically be added or removed as members to certain teams in Grafana. +With enhanced LDAP integration, you can set up synchronization between LDAP groups and Grafana teams and roles. This enables users that are members +of certain LDAP groups to automatically be added to teams and gain roles in Grafana. + +The below example shows an LDAP group member mapped to a Grafana team. ![LDAP group synchronization](/static/img/docs/enterprise/team_members_ldap.png) -Grafana keeps track of all synchronized users in teams, and you can see which users have been synchronized from LDAP in the team members list, see `LDAP` label in screenshot. -This mechanism allows Grafana to remove an existing synchronized user from a team when its LDAP group membership changes. This mechanism also allows you to manually add -a user as member of a team, and it will not be removed when the user signs in. This gives you flexibility to combine LDAP group memberships and Grafana team memberships. - -[Learn more about team sync.]({{< relref "../../configure-team-sync" >}}) +To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync).
diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md index 13fce031d97..2d6eb5fd39d 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md @@ -127,7 +127,7 @@ To integrate your OAuth2 provider with Grafana using our Generic OAuth authentic c. Enable the refresh token on the provider if required. 1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}). -1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}). +1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization" >}}). 1. Restart Grafana. You should now see a Generic OAuth login button on the login page and be able to log in or sign up with your OAuth2 provider. @@ -328,19 +328,21 @@ org_attribute_path = info.roles org_mapping = org_foo:org_foo:Viewer org_bar:org_bar:Editor *:org_baz:Editor ``` -### Configure team synchronization +## Configure group synchronization -> **Note:** Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/). +{{< admonition type="note" >}} +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). +{{< /admonition >}} -By using Team Sync, you can link your OAuth2 groups to teams within Grafana. This will automatically assign users to the appropriate teams. -Teams for each user are synchronized when the user logs in. +Grafana supports synchronization of OAuth2 groups with Grafana teams and roles. This allows automatically assigning users to the appropriate teams or automatically granting them the mapped roles. +Teams and roles get synchronized when the user logs in. Generic OAuth groups can be referenced by group ID, such as `8bab1c86-8fba-33e5-2089-1d1c80ec267d` or `myteam`. For information on configuring OAuth2 groups with Grafana using the `groups_attribute_path` configuration option, refer to [configuration options]({{< relref "#configuration-options" >}}). -To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}). +To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync). -#### Team synchronization example +#### Group attribute synchronization example Configuration: diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md index 600cc9f26e4..5792c3a4a70 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md @@ -110,7 +110,7 @@ To configure GitHub authentication with Grafana, follow these steps: Review the list of other GitHub [configuration options]({{< relref "#configuration-options" >}}) and complete them, as necessary. 1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}). -1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}). +1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization" >}}). 1. Restart Grafana. You should now see a GitHub login button on the login page and be able to log in or sign up with your GitHub accounts. @@ -216,14 +216,14 @@ allowed_domains = mycompany.com mycompany.org role_attribute_path = [login=='octocat'][0] && 'GrafanaAdmin' || 'Viewer' ``` -## Configure team synchronization +## Configure group synchronization {{< admonition type="note" >}} -Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and Grafana Cloud. +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). {{< /admonition >}} -By using Team Sync, you can map teams from your GitHub organization to teams within Grafana. This will automatically assign users to the appropriate teams. -Teams for each user are synchronized when the user logs in. +Grafana supports synchronization of teams from your GitHub organization with Grafana teams and roles. This allows automatically assigning users to the appropriate teams or granting them the mapped roles. +Teams and roles get synchronized when the user logs in. GitHub teams can be referenced in two ways: @@ -232,7 +232,7 @@ GitHub teams can be referenced in two ways: Examples: `https://github.com/orgs/grafana/teams/developers` or `@grafana/developers`. -To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}). +To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync). ## Configuration options diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md index 075047b7c49..d1503808910 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md @@ -119,7 +119,7 @@ To configure GitLab authentication with Grafana, follow these steps: a. Set `use_refresh_token` to `true` in `[auth.gitlab]` section in Grafana configuration file. 1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}). -1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}). +1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization" >}}). 1. Restart Grafana. You should now see a GitLab login button on the login page and be able to log in or sign up with your GitLab accounts. @@ -242,20 +242,20 @@ use_pkce = true use_refresh_token = true ``` -## Configure team synchronization +## Configure group synchronization -{{% admonition type="note" %}} -Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/). -{{% /admonition %}} +{{< admonition type="note" >}} +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). +{{< /admonition >}} -By using Team Sync, you can map GitLab groups to teams within Grafana. This will automatically assign users to the appropriate teams. -Teams for each user are synchronized when the user logs in. +Grafana supports synchronization of GitLab groups with Grafana teams and roles. This allows automatically assigning users to the appropriate teams or granting them the mapped roles. +Teams and roles get synchronized when the user logs in. GitLab groups are referenced by the group name. For example, `developers`. To reference a subgroup `frontend`, use `developers/frontend`. Note that in GitLab, the group or subgroup name does not always match its display name, especially if the display name contains spaces or special characters. Make sure you always use the group or subgroup name as it appears in the URL of the group or subgroup. -To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}). +To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync). ## Configuration options diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md index fed9af7ebd9..9e4740c6914 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md @@ -165,9 +165,15 @@ This setting is ignored if multiple auth providers are configured to use auto lo auto_login = true ``` -### Configure team sync for Google OAuth +### Configure group synchronization -With team sync, you can easily add users to teams by utilizing their Google groups. To set up team sync for Google OAuth, refer to the following example. +{{< admonition type="note" >}} +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). +{{< /admonition >}} + +Grafana supports syncing users to teams and roles based on their Google groups. + +To set up group sync for Google OAuth: 1. Enable the Google Cloud Identity API on your [organization's dashboard](https://console.cloud.google.com/apis/api/cloudidentity.googleapis.com/). @@ -181,10 +187,9 @@ With team sync, you can easily add users to teams by utilizing their Google grou scopes = openid email profile https://www.googleapis.com/auth/cloud-identity.groups.readonly ``` -1. Configure team sync in your Grafana team's `External group sync` tab. - The external group ID for a Google group is the group's email address, such as `dev@grafana.com`. +The external group ID for a Google group is the group's email address, such as `dev@grafana.com`. -To learn more about Team Sync, refer to [Configure Team Sync]({{< relref "../../configure-team-sync" >}}). +To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation. #### Configure allowed groups diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md index c8ed2b24a89..beffabd14f7 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md @@ -23,6 +23,10 @@ This method of authentication is useful for integrating with other systems that use JWKS but can't directly integrate with Grafana or if you want to use pass-through authentication in an app embedding Grafana. +{{% admonition type="note" %}} +Grafana does not currently support refresh tokens. +{{% /admonition %}} + ## Enable JWT To use JWT authentication: @@ -183,7 +187,8 @@ key_id = my-key-id By default, only `"exp"`, `"nbf"` and `"iat"` claims are validated. -You might also want to validate that other claims are really what you expect them to be. +Consider validating that other claims match your expectations by using the `expect_claims` configuration option. +Token claims must match exactly the values set here. ```ini # This can be seen as a required "subset" of a JWT Claims Set. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md index 0759f30428c..323e6e1614d 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md @@ -94,7 +94,7 @@ roles ``` {{% admonition type="warning" %}} -these scopes do not add group claims to the id_token. Without group claims, teamsync will not work. Teamsync is covered further down in this document. +These scopes do not add group claims to the id_token. Without group claims, group synchronization will not work. Group synchronization is covered further down in this document. {{% /admonition %}} 3. For role mapping to work with the example configuration above, @@ -106,16 +106,18 @@ editor viewer ``` -## Teamsync +## Group synchronization -{{% admonition type="note" %}} -Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/). -{{% /admonition %}} +{{< admonition type="note" >}} +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). +{{< /admonition >}} -[Teamsync]({{< relref "../../configure-team-sync" >}}) is a feature that allows you to map groups from your identity provider to Grafana teams. This is useful if you want to give your users access to specific dashboards or folders based on their group membership. +By using group synchronization, you can link your Keycloak groups to teams and roles within Grafana. This allows automatically assigning users to the appropriate teams or granting them the mapped roles. +This is useful if you want to give your users access to specific resources based on their group membership. +Teams and roles get synchronized when the user logs in. -To enable teamsync, you need to add a `groups` mapper to the client configuration in Keycloak. -This will add the `groups` claim to the id_token. You can then use the `groups` claim to map groups to teams in Grafana. +To enable group synchronization, you need to add a `groups` mapper to the client configuration in Keycloak. +This will add the `groups` claim to the id_token. You can then use the `groups` claim to map groups to teams and roles in Grafana. 1. In the client configuration, head to `Mappers` and create a mapper with the following settings: @@ -141,6 +143,8 @@ If you use nested groups containing special characters such as quotes or colons, groups_attribute_path = reverse("Global:department") ``` +To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation. + ## Enable Single Logout To enable Single Logout, you need to add the following option to the configuration of Grafana: diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md index acf3e070cdd..39d77ca3f7f 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md @@ -169,7 +169,7 @@ To integrate your Okta OIDC provider with Grafana using our Okta OIDC integratio 1. Optional: [Configure a refresh token]({{< relref "#configure-a-refresh-token" >}}). 1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}). -1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization-enterprise-only" >}}). +1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization-enterprise-only" >}}). 1. Restart Grafana. You should now see a Okta OIDC login button on the login page and be able to log in or sign up with your OIDC provider. @@ -243,20 +243,18 @@ org_attribute_path = groups org_mapping = ["Group 1:org_foo:Viewer", "Group 2:org_bar:Editor", "*:3:Editor"] ``` -### Configure team synchronization (Enterprise only) +### Configure group synchronization (Enterprise only) -{{% admonition type="note" %}} -Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud]({{< relref "../../../../introduction/grafana-cloud" >}}). -{{% /admonition %}} +{{< admonition type="note" >}} +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). +{{< /admonition >}} -By using Team Sync, you can link your Okta groups to teams within Grafana. This will automatically assign users to the appropriate teams. - -Map your Okta groups to teams in Grafana so that your users will automatically be added to -the correct teams. +By using group synchronization, you can link your Okta groups to teams and roles within Grafana. This allows automatically assigning users to the appropriate teams or granting them the mapped roles. +Teams and roles get synchronized when the user logs in. Okta groups can be referenced by group names, like `Admins` or `Editors`. -To learn more about Team Sync, refer to [Configure Team Sync]({{< relref "../../configure-team-sync" >}}). +To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation. ## Configuration options diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md index 187fd6356dc..1be5e2d993e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md @@ -113,8 +113,8 @@ Sign in to Grafana and navigate to **Administration > Authentication > Configure 1. If you wish to [map user information from SAML assertions]({{< relref "../saml#assertion-mapping" >}}), complete the **Assertion attributes mappings** section. - You also need to configure the **Groups attribute** field if you want to use team sync. Team sync automatically maps users to Grafana teams based on their SAML group membership. - Learn more about [team sync]({{< relref "../../configure-team-sync" >}}) and [configuring team sync for SAML]({{< relref "../saml#configure-team-sync" >}}). + You also need to configure the **Groups attribute** field if you want to use group synchronization. Group sync allows you to automatically map users to Grafana teams or role-based access control roles based on their SAML group membership. + To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation. 1. If you want to automatically assign users' roles based on their SAML roles, complete the **Role mapping** section. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md index 08e01f5dcc2..230edd20cb4 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/index.md @@ -410,12 +410,15 @@ This setting is ignored if multiple auth providers are configured to use auto lo auto_login = true ``` -### Configure team sync +### Configure group synchronization -To use SAML Team sync, set [`assertion_attribute_groups`]({{< relref "../../../configure-grafana/enterprise-configuration#assertion_attribute_groups" >}}) to the attribute name where you store user groups. Then Grafana will use attribute values extracted from SAML assertion to add user into the groups with the same name configured on the External group sync tab. +Group synchronization allows you to map user groups from an identity provider to Grafana teams and roles. + +To use SAML group synchronization, set [`assertion_attribute_groups`]({{< relref "../../../configure-grafana/enterprise-configuration#assertion_attribute_groups" >}}) to the attribute name where you store user groups. +Then Grafana will use attribute values extracted from SAML assertion to add user to Grafana teams and grant them roles. {{% admonition type="note" %}} -Teamsync allows you sync users from SAML to Grafana teams. It does not automatically create teams in Grafana. You need to create teams in Grafana before you can use this feature. +Team sync allows you sync users from SAML to Grafana teams. It does not automatically create teams in Grafana. You need to create teams in Grafana before you can use this feature. {{% /admonition %}} Given the following partial SAML assertion: @@ -445,12 +448,12 @@ The configuration would look like this: assertion_attribute_groups = groups ``` -The following `External Group ID`s would be valid for input in the desired team's _External group sync_ tab: +The following `External Group ID`s would be valid for configuring team sync or role sync in Grafana: - `admins_group` - `division_1` -[Learn more about Team Sync]({{< relref "../../configure-team-sync" >}}) +To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation. ### Configure role sync diff --git a/docs/sources/setup-grafana/configure-security/configure-group-attribute-sync.md b/docs/sources/setup-grafana/configure-security/configure-group-attribute-sync.md index 1377f885397..0ebe9f3db6c 100644 --- a/docs/sources/setup-grafana/configure-security/configure-group-attribute-sync.md +++ b/docs/sources/setup-grafana/configure-security/configure-group-attribute-sync.md @@ -26,6 +26,17 @@ If a group mapping is removed, the role assignment to users for the group mappin Role mappings are tied to organizations, so you can have different mappings for different organizations. +## Supported providers + +- [Azure AD](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/azuread#group-sync-enterprise-only) +- [Generic OAuth integration](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/generic-oauth#configure-group-synchronization) +- [GitHub OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/github#configure-group-synchronization) +- [GitLab OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/gitlab#configure-group-synchronization) +- [Google OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/google#configure-group-synchronization) +- [LDAP](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization) +- [Okta](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only) +- [SAML](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/saml#configure-group-synchronization) + ## Create role mappings for a new group For information about creating group mappings via the API, refer to [create group mappings reference](https://grafana.com/docs/grafana//developers/http_api/group_attribute_sync#create-group-mappings). diff --git a/docs/sources/setup-grafana/configure-security/configure-team-sync.md b/docs/sources/setup-grafana/configure-security/configure-team-sync.md index ad3fde7ab92..f5d50cff311 100644 --- a/docs/sources/setup-grafana/configure-security/configure-team-sync.md +++ b/docs/sources/setup-grafana/configure-security/configure-team-sync.md @@ -28,14 +28,15 @@ This mechanism allows Grafana to remove an existing synchronized user from a tea ## Supported providers - [Auth Proxy]({{< relref "./configure-authentication/auth-proxy#team-sync-enterprise-only" >}}) -- [Azure AD]({{< relref "./configure-authentication/azuread#team-sync-enterprise-only" >}}) -- [Generic OAuth integration]({{< relref "./configure-authentication/generic-oauth#configure-team-synchronization" >}}) -- [GitHub OAuth]({{< relref "./configure-authentication/github#configure-team-synchronization" >}}) -- [GitLab OAuth]({{< relref "./configure-authentication/gitlab#configure-team-synchronization" >}}) -- [Google OAuth]({{< relref "./configure-authentication/google#configure-team-sync-for-google-oauth" >}}) -- [LDAP]({{< relref "./configure-authentication/enhanced-ldap#ldap-group-synchronization-for-teams" >}}) -- [Okta]({{< relref "./configure-authentication/okta#configure-team-synchronization-enterprise-only" >}}) -- [SAML]({{< relref "./configure-authentication/saml#configure-team-sync" >}}) +- [Azure AD](https://grafana.com/docs/grafana//configure-authentication/azuread#group-sync-enterprise-only) +- [Azure AD](https://grafana.com/docs/grafana//configure-security/configure-authentication/azuread#group-sync-enterprise-only) +- [Generic OAuth integration](https://grafana.com/docs/grafana//configure-security/configure-authentication/generic-oauth#configure-group-synchronization) +- [GitHub OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/github#configure-group-synchronization) +- [GitLab OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/gitlab#configure-group-synchronization) +- [Google OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/google#configure-group-synchronization) +- [LDAP](https://grafana.com/docs/grafana//configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization) +- [Okta](https://grafana.com/docs/grafana//configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only) +- [SAML](https://grafana.com/docs/grafana//configure-security/configure-authentication/saml#configure-group-synchronization) ## Synchronize a Grafana team with an external group diff --git a/docs/sources/tutorials/create-alerts-with-logs/index.md b/docs/sources/tutorials/create-alerts-with-logs/index.md index 1e1de3f9646..39de7c380c2 100644 --- a/docs/sources/tutorials/create-alerts-with-logs/index.md +++ b/docs/sources/tutorials/create-alerts-with-logs/index.md @@ -82,7 +82,7 @@ To demonstrate the observation of data using the Grafana stack, download and run 1. Download and save a Docker compose file to run Grafana, Loki and Promtail. ```bash - wget https://raw.githubusercontent.com/grafana/loki/v2.8.0/production/docker-compose.yaml -O docker-compose.yaml + wget https://raw.githubusercontent.com/grafana/loki/refs/heads/main/production/docker-compose.yaml -O docker-compose.yaml ``` 2. Run the Grafana stack. @@ -234,7 +234,7 @@ In this section, we use the default options for Grafana-managed alert rule creat It should return alert instances from log lines with a status code that is not 200 (OK), and that has met the alert condition. The condition for the alert rule to fire is any occurrence that goes over the threshold of `0`. Since the Loki query has returned more than zero alert instances, the alert rule is `Firing`. - {{< figure src="/media/docs/alerting/expression-loki-alert.png" max-width="1200px" caption="Preview of a firing alert instances" >}} + {{< figure src="/media/docs/alerting/firing-loki-alert-rule.png" max-width="1200px" caption="Preview of a firing alert instances" >}} ### Set evaluation behavior diff --git a/docs/sources/upgrade-guide/when-to-upgrade/index.md b/docs/sources/upgrade-guide/when-to-upgrade/index.md index 2c56ec67047..e3ee3e3bd12 100644 --- a/docs/sources/upgrade-guide/when-to-upgrade/index.md +++ b/docs/sources/upgrade-guide/when-to-upgrade/index.md @@ -82,7 +82,11 @@ During these times, no scheduled releases will be executed. However, this does n We've enhanced our naming convention for security release versions to make it easier to clearly identify our security releases from our standard patching releases. -In the past, critical vulnerabilities triggered unscheduled releases that incremented the patch version (e.g., 10.3.0 to 10.3.1). However, we found that the naming convention for these releases didn't clearly communicate the nature of the update. For example, if there was a version change from 11.3.0 to 11.3.1, there was no indication whether it was a security fix, a bug fix, or a minor feature update. This lack of clarity led to confusion about the urgency and nature of the update. +In the past, critical vulnerabilities triggered unscheduled releases that incremented the patch version (e.g., 10.3.0 to 10.3.1). However, we found that the naming convention for these releases didn't clearly communicate the nature of the update. For example, if there was a version change from 11.3.0 to 11.3.1, there was no indication whether it was a security fix, a bug fix, or a minor feature update. This lack of clarity led to confusion about the urgency and nature of the update.
+ +{{< admonition type="note" >}} +Docker does not allow the plus sign (`+`) in image tag names. A plus sign (`+`) will be a rendered as a dash (`-`) in the docker tag. +{{< /admonition >}} Our new approach directly addresses this issue. Going forward, security releases will be appended with "+security" to indicate that the release is the indicated version PLUS the security fix. diff --git a/e2e/various-suite/verify-i18n.spec.ts b/e2e/various-suite/verify-i18n.spec.ts new file mode 100644 index 00000000000..3de37d5d863 --- /dev/null +++ b/e2e/various-suite/verify-i18n.spec.ts @@ -0,0 +1,65 @@ +import { e2e } from '../utils'; +import { fromBaseUrl } from '../utils/support/url'; + +describe('Verify i18n', () => { + const I18N_USER = 'i18n-test'; + const I18N_PASSWORD = 'i18n-test'; + + // create a new user to isolate the language changes from other tests + before(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + cy.request({ + method: 'POST', + url: fromBaseUrl('/api/admin/users'), + body: { + email: I18N_USER, + login: I18N_USER, + name: I18N_USER, + password: I18N_PASSWORD, + }, + }).then((response) => { + cy.wrap(response.body.uid).as('uid'); + }); + }); + + // remove the user created in the before hook + after(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + cy.get('@uid').then((uid) => { + cy.request({ + method: 'DELETE', + url: fromBaseUrl(`/api/admin/users/${uid}`), + }); + }); + }); + + beforeEach(() => { + e2e.flows.login(I18N_USER, I18N_PASSWORD); + }); + + // map between languages in the language picker and the corresponding translation of the 'Language' label + const languageMap: Record = { + Deutsch: 'Sprache', + English: 'Language', + Español: 'Idioma', + Français: 'Langue', + 'Português Brasileiro': 'Idioma', + '中文(简体)': '语言', + }; + + // basic test which loops through the defined languages in the picker + // and verifies that the corresponding label is translated correctly + it('loads all the languages correctly', () => { + cy.visit('/profile'); + const LANGUAGE_SELECTOR = '[id="locale-select"]'; + + cy.wrap(Object.entries(languageMap)).each(([language, label]: [string, string]) => { + cy.get(LANGUAGE_SELECTOR).should('not.be.disabled'); + cy.get(LANGUAGE_SELECTOR).click(); + cy.get(LANGUAGE_SELECTOR).clear().type(language).type('{downArrow}{enter}'); + e2e.components.UserProfile.preferencesSaveButton().click(); + cy.contains('label', label).should('be.visible'); + cy.get(LANGUAGE_SELECTOR).should('have.value', language); + }); + }); +}); diff --git a/go.mod b/go.mod index f69b277a9a8..de9c856fa8f 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( cloud.google.com/go/kms v1.18.5 // @grafana/grafana-backend-group cloud.google.com/go/storage v1.43.0 // @grafana/grafana-backend-group connectrpc.com/connect v1.17.0 // @grafana/observability-traces-and-profiling - cuelang.org/go v0.6.0-0.dev // @grafana/grafana-as-code + cuelang.org/go v0.8.2 // @grafana/grafana-as-code filippo.io/age v1.1.1 // @grafana/identity-access-team github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // @grafana/partner-datasources github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 // @grafana/grafana-backend-group @@ -75,7 +75,7 @@ require ( github.com/grafana/alerting v0.0.0-20241021123319-be61d61f71e7 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a // @grafana/identity-access-team github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d // @grafana/identity-access-team - github.com/grafana/codejen v0.0.3 // @grafana/dataviz-squad + github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d // @grafana/dataviz-squad github.com/grafana/cuetsy v0.1.11 // @grafana/grafana-as-code github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics github.com/grafana/dataplane/sdata v0.0.9 // @grafana/observability-metrics @@ -109,7 +109,7 @@ require ( github.com/hashicorp/go-version v1.7.0 // @grafana/grafana-backend-group github.com/hashicorp/golang-lru/v2 v2.0.7 // @grafana/alerting-backend github.com/hashicorp/hcl/v2 v2.17.0 // @grafana/alerting-backend - github.com/huandu/xstrings v1.3.3 // @grafana/partner-datasources + github.com/huandu/xstrings v1.5.0 // @grafana/partner-datasources github.com/influxdata/influxdb-client-go/v2 v2.13.0 // @grafana/observability-metrics github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // @grafana/grafana-app-platform-squad github.com/jmespath/go-jmespath v0.4.0 // indirect; @grafana/grafana-backend-group @@ -266,7 +266,7 @@ require ( github.com/edsrzf/mmap-go v1.1.0 // indirect github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect - github.com/emicklei/proto v1.10.0 // indirect + github.com/emicklei/proto v1.13.2 // indirect github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -382,7 +382,7 @@ require ( github.com/prometheus/common/sigv4 v0.1.0 // indirect github.com/prometheus/exporter-toolkit v0.11.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 // indirect + github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 // indirect github.com/redis/rueidis v1.0.45 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index 08dc5b9fa9c..42f22da0808 100644 --- a/go.sum +++ b/go.sum @@ -1833,8 +1833,8 @@ github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac/go.mod h1:gNh8 github.com/emicklei/go-restful/v3 v3.8.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/emicklei/proto v1.10.0 h1:pDGyFRVV5RvV+nkBK9iy3q67FBy9Xa7vwrOTE+g5aGw= -github.com/emicklei/proto v1.10.0/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= +github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY= +github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= @@ -2268,8 +2268,8 @@ github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a h1:X3mroOOXdryRY6S github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a/go.mod h1:XFhcSCEDeOgV43x41we7mRBcizDpsTScb7XOuYipQZg= github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d h1:7nZfaXdC4Xc2ocMz5/Bx/3EsaEO34KsmA2RRcuogLnc= github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A= -github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw= -github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= +github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk= +github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= github.com/grafana/cue v0.0.0-20230926092038-971951014e3f h1:TmYAMnqg3d5KYEAaT6PtTguL2GjLfvr6wnAX8Azw6tQ= github.com/grafana/cue v0.0.0-20230926092038-971951014e3f/go.mod h1:okjJBHFQFer+a41sAe2SaGm1glWS8oEb6CmJvn5Zdws= github.com/grafana/cuetsy v0.1.11 h1:I3IwBhF+UaQxRM79HnImtrAn8REGdb5M3+C4QrYHoWk= @@ -2472,8 +2472,9 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= @@ -3036,8 +3037,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/prometheus v0.52.0 h1:f7kHJgr7+zShpWdTCeKqbCWR7nKTScgLYQwRux9h1V0= github.com/prometheus/prometheus v0.52.0/go.mod h1:3z74cVsmVH0iXOR5QBjB7Pa6A0KJeEAK5A6UsmAFb1g= -github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 h1:sadMIsgmHpEOGbUs6VtHBXRR1OHevnj7hLx9ZcdNGW4= -github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c= +github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 h1:MAzmm+JtFxQwTPb1cVMLkemw2OxLy5AB/d/rxtAwGQQ= +github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= diff --git a/go.work b/go.work index 53eb16267fe..0b92b51884d 100644 --- a/go.work +++ b/go.work @@ -6,6 +6,7 @@ go 1.23.1 use ( . // skip:golangci-lint ./apps/playlist + ./kindsv2 ./pkg/aggregator ./pkg/apimachinery ./pkg/apiserver diff --git a/kindsv2/gen.go b/kindsv2/gen.go new file mode 100644 index 00000000000..ee215e3d491 --- /dev/null +++ b/kindsv2/gen.go @@ -0,0 +1,52 @@ +//go:generate go run gen.go + +package main + +import ( + "context" + "os" + + "cuelang.org/go/cue/cuecontext" + "github.com/grafana/cog" +) + +type codegenTargets struct { + schemaPath string + outputPath string +} + +func main() { + cueCtx := cuecontext.New() + + targets := []codegenTargets{ + { + schemaPath: "../packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue", + outputPath: "../packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts", + }, + } + + for _, target := range targets { + rawSchema, err := os.ReadFile(target.schemaPath) + if err != nil { + panic(err) + } + + value := cueCtx.CompileBytes(rawSchema) + if value.Err() != nil { + panic(value.Err()) + } + + codegenPipeline := cog.TypesFromSchema(). + CUEValue("dashboard", value). + Typescript() + + tsBytes, err := codegenPipeline.Run(context.Background()) + if err != nil { + panic(err) + } + + if err := os.WriteFile(target.outputPath, tsBytes, 0644); err != nil { + panic(err) + } + } +} diff --git a/kindsv2/go.mod b/kindsv2/go.mod new file mode 100644 index 00000000000..525fd0ac78c --- /dev/null +++ b/kindsv2/go.mod @@ -0,0 +1,46 @@ +module github.com/grafana/grafana/kindsv2 + +go 1.23.1 + +require ( + cuelang.org/go v0.8.2 + github.com/grafana/cog v0.0.0-20241029201114-f7f7db0c1070 +) + +require ( + cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565 // indirect + github.com/cockroachdb/apd/v3 v3.2.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/proto v1.13.2 // indirect + github.com/expr-lang/expr v1.16.9 // indirect + github.com/getkin/kin-openapi v0.128.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/invopop/yaml v0.3.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + github.com/yalue/merged_fs v1.3.0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/oauth2 v0.23.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/text v0.19.0 // indirect + golang.org/x/tools v0.26.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/kindsv2/go.sum b/kindsv2/go.sum new file mode 100644 index 00000000000..b7c257b2ad9 --- /dev/null +++ b/kindsv2/go.sum @@ -0,0 +1,94 @@ +cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565 h1:R5wwEcbEZSBmeyg91MJZTxfd7WpBo2jPof3AYjRbxwY= +cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565/go.mod h1:5A4xfTzHTXfeVJBU6RAUf+QrlfTCW+017q/QiW+sMLg= +cuelang.org/go v0.8.2 h1:vWfHI1kQlBvwkna7ktAqXjV5LUEAgU6vyMlJjvZZaDw= +cuelang.org/go v0.8.2/go.mod h1:CoDbYolfMms4BhWUlhD+t5ORnihR7wvjcfgyO9lL5FI= +github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= +github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY= +github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= +github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI= +github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/getkin/kin-openapi v0.128.0 h1:jqq3D9vC9pPq1dGcOCv7yOp1DaEe7c/T1vzcLbITSp4= +github.com/getkin/kin-openapi v0.128.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM= +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/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk= +github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= +github.com/grafana/cog v0.0.0-20241029201114-f7f7db0c1070 h1:ncAkFmIq3UbPiPhQ2CuBzdTuwnDqLsFFNqEIXKaiPGA= +github.com/grafana/cog v0.0.0-20241029201114-f7f7db0c1070/go.mod h1:FqZi9WZ/Uzvs3tvo7l+OViThCpfvu3KDGDqHCB2LNbg= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= +github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +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/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 h1:MAzmm+JtFxQwTPb1cVMLkemw2OxLy5AB/d/rxtAwGQQ= +github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yalue/merged_fs v1.3.0 h1:qCeh9tMPNy/i8cwDsQTJ5bLr6IRxbs6meakNE5O+wyY= +github.com/yalue/merged_fs v1.3.0/go.mod h1:WqqchfVYQyclV2tnR7wtRhBddzBvLVR83Cjw9BKQw0M= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/package.json b/package.json index e7cf16bafa2..042666902cf 100644 --- a/package.json +++ b/package.json @@ -279,7 +279,7 @@ "@grafana/ui": "workspace:*", "@hello-pangea/dnd": "17.0.0", "@kusto/monaco-kusto": "^10.0.0", - "@leeoniya/ufuzzy": "1.0.14", + "@leeoniya/ufuzzy": "1.0.16", "@lezer/common": "1.2.3", "@lezer/highlight": "1.2.1", "@lezer/lr": "1.4.2", diff --git a/packages/grafana-data/src/themes/registry.ts b/packages/grafana-data/src/themes/registry.ts index c6066d22075..f05ea638735 100644 --- a/packages/grafana-data/src/themes/registry.ts +++ b/packages/grafana-data/src/themes/registry.ts @@ -85,12 +85,30 @@ function createDebug(): GrafanaTheme2 { border: '#ff000088', text: '#cccc00', contrastText: '#ffff00', + shade: '#9900dd', }, secondary: { ...baseDarkColors.secondary, border: '#ff000088', text: '#cccc00', contrastText: '#ffff00', + shade: '#9900dd', + }, + info: { + ...baseDarkColors.info, + shade: '#9900dd', + }, + warning: { + ...baseDarkColors.warning, + shade: '#9900dd', + }, + success: { + ...baseDarkColors.success, + shade: '#9900dd', + }, + error: { + ...baseDarkColors.error, + shade: '#9900dd', }, action: { hover: '#9900dd', diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index 90f21ae568b..f95d90ac409 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -54,6 +54,7 @@ export interface ExploreLogsPanelState { labelFieldName?: string; // Used for logs table visualisation, contains the refId of the dataFrame that is currently visualized refId?: string; + displayedFields?: string[]; } export interface SplitOpenOptions { diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 4de626ec320..ace41d3a1be 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -46,7 +46,7 @@ "@emotion/css": "11.13.4", "@grafana/data": "11.4.0-pre", "@grafana/ui": "11.4.0-pre", - "@leeoniya/ufuzzy": "1.0.14", + "@leeoniya/ufuzzy": "1.0.16", "d3": "^7.8.5", "lodash": "4.17.21", "react": "18.2.0", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 6d241e46306..682bdbea469 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -40,12 +40,12 @@ "@floating-ui/react": "0.26.28", "@grafana/data": "11.4.0-pre", "@grafana/experimental": "2.1.2", - "@grafana/faro-web-sdk": "1.12.0", + "@grafana/faro-web-sdk": "1.12.1", "@grafana/runtime": "11.4.0-pre", "@grafana/schema": "11.4.0-pre", "@grafana/ui": "11.4.0-pre", "@hello-pangea/dnd": "17.0.0", - "@leeoniya/ufuzzy": "1.0.14", + "@leeoniya/ufuzzy": "1.0.16", "@lezer/common": "1.2.3", "@lezer/highlight": "1.2.1", "@lezer/lr": "1.4.2", diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts index 04d7293bab8..2ce4b88e85f 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -19,6 +19,7 @@ import { AdHocVariableFilter, } from '@grafana/data'; +import { reportInteraction } from '../analytics/utils'; import { config } from '../config'; import { BackendSrvRequest, @@ -355,8 +356,17 @@ class DataSourceWithBackend< headers: this.getRequestHeaders(), }) ) - .then((v: FetchResponse) => v.data) - .catch((err) => err.data); + .then((v: FetchResponse) => v.data) + .catch((err) => { + let properties: Record = { + plugin_id: this.meta?.id || '', + plugin_version: this.meta?.info?.version || '', + datasource_healthcheck_status: err?.data?.status || 'error', + datasource_healthcheck_message: err?.data?.message || '', + }; + reportInteraction('datasource_health_check_completed', properties); + return err.data; + }); } /** diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts new file mode 100644 index 00000000000..23dfe83a255 --- /dev/null +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts @@ -0,0 +1,1249 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface DashboardV2Spec { + // Unique numeric identifier for the dashboard. + // `id` is internal to a specific Grafana instance. `uid` should be used to identify a dashboard across Grafana instances. + id?: number; + // Title of dashboard. + title: string; + // Description of dashboard. + description?: string; + // Configuration of dashboard cursor sync behavior. + // "Off" for no shared crosshair or tooltip (default). + // "Crosshair" for shared crosshair. + // "Tooltip" for shared crosshair AND shared tooltip. + cursorSync: DashboardCursorSync; + // When set to true, the dashboard will redraw panels at an interval matching the pixel width. + // This will keep data "moving left" regardless of the query refresh rate. This setting helps + // avoid dashboards presenting stale live data. + liveNow?: boolean; + // When set to true, the dashboard will load all panels in the dashboard when it's loaded. + preload: boolean; + // Whether a dashboard is editable or not. + editable?: boolean; + // Links with references to other dashboards or external websites. + links: DashboardLink[]; + // Tags associated with dashboard. + tags?: string[]; + timeSettings: TimeSettingsSpec; + // Configured template variables. + variables: (QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind)[]; + // |* more element types in the future + elements: Record; + annotations: AnnotationQueryKind[]; + layout: GridLayoutKind; + // Version of the JSON schema, incremented each time a Grafana update brings + // changes to said schema. + // version: will rely on k8s resource versioning, via metadata.resorceVersion + // revision?: int // for plugins only + // gnetId?: string // ??? Wat is this used for? + schemaVersion: number; +} + +export const defaultDashboardV2Spec = (): DashboardV2Spec => ({ + title: "", + cursorSync: DashboardCursorSync.Off, + preload: false, + editable: true, + links: [], + timeSettings: defaultTimeSettingsSpec(), + variables: [], + elements: {}, + annotations: [], + layout: defaultGridLayoutKind(), + schemaVersion: 39, +}); + +export interface AnnotationPanelFilter { + // Should the specified panels be included or excluded + exclude?: boolean; + // Panel IDs that should be included or excluded + ids: number[]; +} + +export const defaultAnnotationPanelFilter = (): AnnotationPanelFilter => ({ + exclude: false, + ids: [], +}); + +// "Off" for no shared crosshair or tooltip (default). +// "Crosshair" for shared crosshair. +// "Tooltip" for shared crosshair AND shared tooltip. +export enum DashboardCursorSync { + Off = "Off", + Crosshair = "Crosshair", + Tooltip = "Tooltip", +} + +export const defaultDashboardCursorSync = (): DashboardCursorSync => (DashboardCursorSync.Off); + +// Links with references to other dashboards or external resources +export interface DashboardLink { + // Title to display with the link + title: string; + // Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + // FIXME: The type is generated as `type: DashboardLinkType | dashboardLinkType.Link;` but it should be `type: DashboardLinkType` + type: DashboardLinkType; + // Icon name to be displayed with the link + icon: string; + // Tooltip to display when the user hovers their mouse over it + tooltip: string; + // Link URL. Only required/valid if the type is link + url?: string; + // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards + tags: string[]; + // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards + asDropdown: boolean; + // If true, the link will be opened in a new tab + targetBlank: boolean; + // If true, includes current template variables values in the link as query params + includeVars: boolean; + // If true, includes current time range in the link as query params + keepTime: boolean; +} + +export const defaultDashboardLink = (): DashboardLink => ({ + title: "", + type: DashboardLinkType.Link, + icon: "", + tooltip: "", + tags: [], + asDropdown: false, + targetBlank: false, + includeVars: false, + keepTime: false, +}); + +export interface DataSourceRef { + // The plugin type-id + type?: string; + // Specific datasource instance + uid?: string; +} + +export const defaultDataSourceRef = (): DataSourceRef => ({ +}); + +// Transformations allow to manipulate data returned by a query before the system applies a visualization. +// Using transformations you can: rename fields, join time series data, perform mathematical operations across queries, +// use the output of one transformation as the input to another transformation, etc. +export interface DataTransformerConfig { + // Unique identifier of transformer + id: string; + // Disabled transformations are skipped + disabled?: boolean; + // Optional frame matcher. When missing it will be applied to all results + filter?: MatcherConfig; + // Where to pull DataFrames from as input to transformation + // replaced with common.DataTopic + topic?: "series" | "annotations" | "alertStates"; + // Options to be passed to the transformer + // Valid options depend on the transformer id + options: any; +} + +export const defaultDataTransformerConfig = (): DataTransformerConfig => ({ + id: "", + options: {}, +}); + +// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. +// Each column within this structure is called a field. A field can represent a single time series or table column. +// Field options allow you to change how the data is displayed in your visualizations. +export interface FieldConfigSource { + // Defaults are the options applied to all fields. + defaults: FieldConfig; + // Overrides are the options applied to specific fields overriding the defaults. + overrides: { + matcher: MatcherConfig; + properties: DynamicConfigValue[]; + }[]; +} + +export const defaultFieldConfigSource = (): FieldConfigSource => ({ + defaults: defaultFieldConfig(), + overrides: [], +}); + +// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. +// Each column within this structure is called a field. A field can represent a single time series or table column. +// Field options allow you to change how the data is displayed in your visualizations. +export interface FieldConfig { + // The display value for this field. This supports template variables blank is auto + displayName?: string; + // This can be used by data sources that return and explicit naming structure for values and labels + // When this property is configured, this value is used rather than the default naming strategy. + displayNameFromDS?: string; + // Human readable field metadata + description?: string; + // An explicit path to the field in the datasource. When the frame meta includes a path, + // This will default to `${frame.meta.path}/${field.name} + // + // When defined, this value can be used as an identifier within the datasource scope, and + // may be used to update the results + path?: string; + // True if data source can write a value to the path. Auth/authz are supported separately + writeable?: boolean; + // True if data source field supports ad-hoc filters + filterable?: boolean; + // Unit a field should use. The unit you select is applied to all fields except time. + // You can use the units ID availables in Grafana or a custom unit. + // Available units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts + // As custom unit, you can use the following formats: + // `suffix:` for custom unit that should go after value. + // `prefix:` for custom unit that should go before value. + // `time:` For custom date time formats type for example `time:YYYY-MM-DD`. + // `si:` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character. + // `count:` for a custom count unit. + // `currency:` for custom a currency unit. + unit?: string; + // Specify the number of decimals Grafana includes in the rendered value. + // If you leave this field blank, Grafana automatically truncates the number of decimals based on the value. + // For example 1.1234 will display as 1.12 and 100.456 will display as 100. + // To display all decimals, set the unit to `String`. + decimals?: number; + // The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + min?: number; + // The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + max?: number; + // Convert input values into a display string + mappings?: ValueMapping[]; + // Map numeric values to states + thresholds?: ThresholdsConfig; + // Panel color configuration + color?: FieldColor; + // The behavior when clicking on a result + links?: any[]; + // Alternative to empty string + noValue?: string; + // custom is specified by the FieldConfig field + // in panel plugin schemas. + custom?: Record; +} + +export const defaultFieldConfig = (): FieldConfig => ({ +}); + +export interface DynamicConfigValue { + id: string; + value?: any; +} + +export const defaultDynamicConfigValue = (): DynamicConfigValue => ({ + id: "", +}); + +// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. +// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. +export interface MatcherConfig { + // The matcher id. This is used to find the matcher implementation from registry. + id: string; + // The matcher options. This is specific to the matcher implementation. + options?: any; +} + +export const defaultMatcherConfig = (): MatcherConfig => ({ + id: "", +}); + +export interface Threshold { + value: number | null; + color: string; +} + +export const defaultThreshold = (): Threshold => ({ + value: 0, + color: "", +}); + +export enum ThresholdsMode { + Absolute = "absolute", + Percentage = "percentage", +} + +export const defaultThresholdsMode = (): ThresholdsMode => (ThresholdsMode.Absolute); + +export interface ThresholdsConfig { + mode: ThresholdsMode; + steps: Threshold[]; +} + +export const defaultThresholdsConfig = (): ThresholdsConfig => ({ + mode: ThresholdsMode.Absolute, + steps: [], +}); + +export type ValueMapping = ValueMap | RangeMap | RegexMap | SpecialValueMap; + +export const defaultValueMapping = (): ValueMapping => (defaultValueMap()); + +// Supported value mapping types +// `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. +// `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. +// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. +// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A. +export enum MappingType { + Value = "value", + Range = "range", + Regex = "regex", + Special = "special", +} + +export const defaultMappingType = (): MappingType => (MappingType.Value); + +// Maps text values to a color or different display text and color. +// For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. +export interface ValueMap { + type: "value"; + // Map with : ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } } + options: Record; +} + +export const defaultValueMap = (): ValueMap => ({ + type: "value", + options: {}, +}); + +// Maps numerical ranges to a display text and color. +// For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. +export interface RangeMap { + type: "range"; + // Range to match against and the result to apply when the value is within the range + options: { + // Min value of the range. It can be null which means -Infinity + from: number | null; + // Max value of the range. It can be null which means +Infinity + to: number | null; + // Config to apply when the value is within the range + result: ValueMappingResult; + }; +} + +export const defaultRangeMap = (): RangeMap => ({ + type: "range", + options: { + from: 0, + to: 0, + result: defaultValueMappingResult(), +}, +}); + +// Maps regular expressions to replacement text and a color. +// For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. +export interface RegexMap { + type: "regex"; + // Regular expression to match against and the result to apply when the value matches the regex + options: { + // Regular expression to match against + pattern: string; + // Config to apply when the value matches the regex + result: ValueMappingResult; + }; +} + +export const defaultRegexMap = (): RegexMap => ({ + type: "regex", + options: { + pattern: "", + result: defaultValueMappingResult(), +}, +}); + +// Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. +// See SpecialValueMatch to see the list of special values. +// For example, you can configure a special value mapping so that null values appear as N/A. +export interface SpecialValueMap { + type: "special"; + options: { + // Special value to match against + match: SpecialValueMatch; + // Config to apply when the value matches the special value + result: ValueMappingResult; + }; +} + +export const defaultSpecialValueMap = (): SpecialValueMap => ({ + type: "special", + options: { + match: SpecialValueMatch.True, + result: defaultValueMappingResult(), +}, +}); + +// Special value types supported by the `SpecialValueMap` +export enum SpecialValueMatch { + True = "true", + False = "false", + Null = "null", + NotANumber = "nan", + NullNan = "null+nan", + Empty = "empty", +} + +export const defaultSpecialValueMatch = (): SpecialValueMatch => (SpecialValueMatch.True); + +// Result used as replacement with text and color when the value matches +export interface ValueMappingResult { + // Text to display when the value matches + text?: string; + // Text to use when the value matches + color?: string; + // Icon to display when the value matches. Only specific visualizations. + icon?: string; + // Position in the mapping array. Only used internally. + index?: number; +} + +export const defaultValueMappingResult = (): ValueMappingResult => ({ +}); + +// Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. +// Continuous color interpolates a color using the percentage of a value relative to min and max. +// Accepted values are: +// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold +// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations +// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations +// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode +// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode +// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode +// `continuous-YlRd`: Continuous Yellow-Red palette mode +// `continuous-BlPu`: Continuous Blue-Purple palette mode +// `continuous-YlBl`: Continuous Yellow-Blue palette mode +// `continuous-blues`: Continuous Blue palette mode +// `continuous-reds`: Continuous Red palette mode +// `continuous-greens`: Continuous Green palette mode +// `continuous-purples`: Continuous Purple palette mode +// `shades`: Shades of a single color. Specify a single color, useful in an override rule. +// `fixed`: Fixed color mode. Specify a single color, useful in an override rule. +export enum FieldColorModeId { + Thresholds = "thresholds", + PaletteClassic = "palette-classic", + PaletteClassicByName = "palette-classic-by-name", + ContinuousGrYlRd = "continuous-GrYlRd", + ContinuousRdYlGr = "continuous-RdYlGr", + ContinuousBlYlRd = "continuous-BlYlRd", + ContinuousYlRd = "continuous-YlRd", + ContinuousBlPu = "continuous-BlPu", + ContinuousYlBl = "continuous-YlBl", + ContinuousBlues = "continuous-blues", + ContinuousReds = "continuous-reds", + ContinuousGreens = "continuous-greens", + ContinuousPurples = "continuous-purples", + Fixed = "fixed", + Shades = "shades", +} + +export const defaultFieldColorModeId = (): FieldColorModeId => (FieldColorModeId.Thresholds); + +// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value. +export enum FieldColorSeriesByMode { + Min = "min", + Max = "max", + Last = "last", +} + +export const defaultFieldColorSeriesByMode = (): FieldColorSeriesByMode => (FieldColorSeriesByMode.Min); + +// Map a field to a color. +export interface FieldColor { + // The main color scheme mode. + mode: FieldColorModeId; + // The fixed color value for fixed or shades color modes. + fixedColor?: string; + // Some visualizations need to know how to assign a series color from by value color schemes. + seriesBy?: FieldColorSeriesByMode; +} + +export const defaultFieldColor = (): FieldColor => ({ + mode: FieldColorModeId.Thresholds, +}); + +// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) +export enum DashboardLinkType { + Link = "link", + Dashboards = "dashboards", +} + +export const defaultDashboardLinkType = (): DashboardLinkType => (DashboardLinkType.Link); + +// --- Common types --- +export interface Kind { + kind: string; + spec: any; + metadata?: any; +} + +export const defaultKind = (): Kind => ({ + kind: "", + spec: {}, +}); + +// --- Kinds --- +export interface VizConfigSpec { + pluginVersion: string; + options: Record; + fieldConfig: FieldConfigSource; +} + +export const defaultVizConfigSpec = (): VizConfigSpec => ({ + pluginVersion: "", + options: {}, + fieldConfig: defaultFieldConfigSource(), +}); + +export interface VizConfigKind { + // The kind of a VizConfigKind is the plugin ID + kind: string; + spec: VizConfigSpec; +} + +export const defaultVizConfigKind = (): VizConfigKind => ({ + kind: "", + spec: defaultVizConfigSpec(), +}); + +export interface AnnotationQuerySpec { + datasource: DataSourceRef; + query: DataQueryKind; + // TODO: Should be figured out based on datasource (Grafana ds) + // builtIn?: int + // Below are currently existing options for annotation queries + enable: boolean; + filter: AnnotationPanelFilter; + hide: boolean; + iconColor: string; + name: string; +} + +export const defaultAnnotationQuerySpec = (): AnnotationQuerySpec => ({ + datasource: defaultDataSourceRef(), + query: defaultDataQueryKind(), + enable: false, + filter: defaultAnnotationPanelFilter(), + hide: false, + iconColor: "", + name: "", +}); + +export interface AnnotationQueryKind { + kind: "AnnotationQuery"; + spec: AnnotationQuerySpec; +} + +export const defaultAnnotationQueryKind = (): AnnotationQueryKind => ({ + kind: "AnnotationQuery", + spec: defaultAnnotationQuerySpec(), +}); + +export interface QueryOptionsSpec { + timeFrom?: string; + maxDataPoints?: number; + timeShift?: string; + queryCachingTTL?: number; + interval?: string; + cacheTimeout?: string; +} + +export const defaultQueryOptionsSpec = (): QueryOptionsSpec => ({ +}); + +export interface DataQueryKind { + // The kind of a DataQueryKind is the datasource type + kind: string; + spec: Record; +} + +export const defaultDataQueryKind = (): DataQueryKind => ({ + kind: "", + spec: {}, +}); + +export interface PanelQuerySpec { + query: DataQueryKind; + datasource: DataSourceRef; + refId: string; + hidden: boolean; +} + +export const defaultPanelQuerySpec = (): PanelQuerySpec => ({ + query: defaultDataQueryKind(), + datasource: defaultDataSourceRef(), + refId: "", + hidden: false, +}); + +export interface PanelQueryKind { + kind: "PanelQuery"; + spec: PanelQuerySpec; +} + +export const defaultPanelQueryKind = (): PanelQueryKind => ({ + kind: "PanelQuery", + spec: defaultPanelQuerySpec(), +}); + +export interface TransformationKind { + // The kind of a TransformationKind is the transformation ID + kind: string; + spec: DataTransformerConfig; +} + +export const defaultTransformationKind = (): TransformationKind => ({ + kind: "", + spec: defaultDataTransformerConfig(), +}); + +export interface QueryGroupSpec { + queries: PanelQueryKind[]; + transformations: TransformationKind[]; + queryOptions: QueryOptionsSpec; +} + +export const defaultQueryGroupSpec = (): QueryGroupSpec => ({ + queries: [], + transformations: [], + queryOptions: defaultQueryOptionsSpec(), +}); + +export interface QueryGroupKind { + kind: "QueryGroup"; + spec: QueryGroupSpec; +} + +export const defaultQueryGroupKind = (): QueryGroupKind => ({ + kind: "QueryGroup", + spec: defaultQueryGroupSpec(), +}); + +// Time configuration +// It defines the default time config for the time picker, the refresh picker for the specific dashboard. +export interface TimeSettingsSpec { + // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". + timezone?: string; + // Start time range for dashboard. + // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". + from: string; + // End time range for dashboard. + // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". + to: string; + // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". + // v1: refresh + autoRefresh: string; + // Interval options available in the refresh picker dropdown. + // v1: timepicker.refresh_intervals + autoRefreshIntervals: string[]; + // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. + // v1: timepicker.time_options , not exposed in the UI + quickRanges: string[]; + // Whether timepicker is visible or not. + // v1: timepicker.hidden + hideTimepicker: boolean; + // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". + weekStart: string; + // The month that the fiscal year starts on. 0 = January, 11 = December + fiscalYearStartMonth: number; + // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. + // v1: timepicker.nowDelay + nowDelay?: string; +} + +export const defaultTimeSettingsSpec = (): TimeSettingsSpec => ({ + timezone: "browser", + from: "now-6h", + to: "now", + autoRefresh: "", + autoRefreshIntervals: [ +"5s", +"10s", +"30s", +"1m", +"5m", +"15m", +"30m", +"1h", +"2h", +"1d", +], + quickRanges: [ +"5m", +"15m", +"1h", +"6h", +"12h", +"24h", +"2d", +"7d", +"30d", +], + hideTimepicker: false, + weekStart: "", + fiscalYearStartMonth: 0, +}); + +export interface GridLayoutItemSpec { + x: number; + y: number; + width: number; + height: number; + // reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference + element: ElementReference; +} + +export const defaultGridLayoutItemSpec = (): GridLayoutItemSpec => ({ + x: 0, + y: 0, + width: 0, + height: 0, + element: defaultElementReference(), +}); + +export interface GridLayoutItemKind { + kind: "GridLayoutItem"; + spec: GridLayoutItemSpec; +} + +export const defaultGridLayoutItemKind = (): GridLayoutItemKind => ({ + kind: "GridLayoutItem", + spec: defaultGridLayoutItemSpec(), +}); + +export interface GridLayoutSpec { + items: GridLayoutItemKind[]; +} + +export const defaultGridLayoutSpec = (): GridLayoutSpec => ({ + items: [], +}); + +export interface GridLayoutKind { + kind: "GridLayout"; + spec: GridLayoutSpec; +} + +export const defaultGridLayoutKind = (): GridLayoutKind => ({ + kind: "GridLayout", + spec: defaultGridLayoutSpec(), +}); + +export interface PanelSpec { + uid: string; + title: string; + description: string; + links: DashboardLink[]; + data: QueryGroupKind; + vizConfig: VizConfigKind; +} + +export const defaultPanelSpec = (): PanelSpec => ({ + uid: "", + title: "", + description: "", + links: [], + data: defaultQueryGroupKind(), + vizConfig: defaultVizConfigKind(), +}); + +export interface PanelKind { + kind: "Panel"; + spec: PanelSpec; +} + +export const defaultPanelKind = (): PanelKind => ({ + kind: "Panel", + spec: defaultPanelSpec(), +}); + +export interface ElementReference { + kind: "ElementReference"; + name: string; +} + +export const defaultElementReference = (): ElementReference => ({ + kind: "ElementReference", + name: "", +}); + +// Variable types +export type VariableValue = VariableValueSingle | VariableValueSingle[]; + +export const defaultVariableValue = (): VariableValue => (defaultVariableValueSingle()); + +export type VariableValueSingle = string | boolean | number | CustomVariableValue; + +export const defaultVariableValueSingle = (): VariableValueSingle => (""); + +// Custom formatter variable +export interface CustomFormatterVariable { + name: string; + type: VariableType; + multi: boolean; + includeAll: boolean; +} + +export const defaultCustomFormatterVariable = (): CustomFormatterVariable => ({ + name: "", + type: VariableType.Query, + multi: false, + includeAll: false, +}); + +// Custom variable value +export interface CustomVariableValue { + // The format name or function used in the expression + formatter: string | VariableCustomFormatterFn; +} + +export const defaultCustomVariableValue = (): CustomVariableValue => ({ + formatter: "", +}); + +// Custom formatter function +export interface VariableCustomFormatterFn { + value: any; + legacyVariableModel: { + name: string; + type: VariableType; + multi: boolean; + includeAll: boolean; + }; + legacyDefaultFormatter?: VariableCustomFormatterFn; +} + +export const defaultVariableCustomFormatterFn = (): VariableCustomFormatterFn => ({ + value: {}, + legacyVariableModel: { + name: "", + type: VariableType.Query, + multi: false, + includeAll: false, +}, +}); + +// Dashboard variable type +// `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. +// `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). +// `constant`: Define a hidden constant. +// `datasource`: Quickly change the data source for an entire dashboard. +// `interval`: Interval variables represent time spans. +// `textbox`: Display a free text input field with an optional default value. +// `custom`: Define the variable options manually using a comma-separated list. +// `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables +export enum VariableType { + Query = "query", + Adhoc = "adhoc", + Groupby = "groupby", + Constant = "constant", + Datasource = "datasource", + Interval = "interval", + Textbox = "textbox", + Custom = "custom", + System = "system", + Snapshot = "snapshot", +} + +export const defaultVariableType = (): VariableType => (VariableType.Query); + +// Sort variable options +// Accepted values are: +// `disabled`: No sorting +// `alphabeticalAsc`: Alphabetical ASC +// `alphabeticalDesc`: Alphabetical DESC +// `numericalAsc`: Numerical ASC +// `numericalDesc`: Numerical DESC +// `alphabeticalCaseInsensitiveAsc`: Alphabetical Case Insensitive ASC +// `alphabeticalCaseInsensitiveDesc`: Alphabetical Case Insensitive DESC +// `naturalAsc`: Natural ASC +// `naturalDesc`: Natural DESC +// VariableSort enum with default value +export enum VariableSort { + Disabled = "disabled", + AlphabeticalAsc = "alphabeticalAsc", + AlphabeticalDesc = "alphabeticalDesc", + NumericalAsc = "numericalAsc", + NumericalDesc = "numericalDesc", + AlphabeticalCaseInsensitiveAsc = "alphabeticalCaseInsensitiveAsc", + AlphabeticalCaseInsensitiveDesc = "alphabeticalCaseInsensitiveDesc", + NaturalAsc = "naturalAsc", + NaturalDesc = "naturalDesc", +} + +export const defaultVariableSort = (): VariableSort => (VariableSort.Disabled); + +// Options to config when to refresh a variable +// `never`: Never refresh the variable +// `onDashboardLoad`: Queries the data source every time the dashboard loads. +// `onTimeRangeChanged`: Queries the data source when the dashboard time range changes. +export enum VariableRefresh { + Never = "never", + OnDashboardLoad = "onDashboardLoad", + OnTimeRangeChanged = "onTimeRangeChanged", +} + +export const defaultVariableRefresh = (): VariableRefresh => (VariableRefresh.Never); + +// Determine if the variable shows on dashboard +// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing). +export enum VariableHide { + DontHide = "dontHide", + HideLabel = "hideLabel", + HideVariable = "hideVariable", +} + +export const defaultVariableHide = (): VariableHide => (VariableHide.DontHide); + +// FIXME: should we introduce this? --- Variable value option +export interface VariableValueOption { + label: string; + value: VariableValueSingle; + group?: string; +} + +export const defaultVariableValueOption = (): VariableValueOption => ({ + label: "", + value: defaultVariableValueSingle(), +}); + +// Variable option specification +export interface VariableOption { + // Whether the option is selected or not + selected?: boolean; + // Text to be displayed for the option + text: string | string[]; + // Value of the option + value: string | string[]; +} + +export const defaultVariableOption = (): VariableOption => ({ + text: "", + value: "", +}); + +// Query variable specification +export interface QueryVariableSpec { + name: string; + current: VariableOption; + label?: string; + hide: VariableHide; + refresh: VariableRefresh; + skipUrlSync: boolean; + description?: string; + datasource: DataSourceRef; + query: string | DataQueryKind; + regex: string; + sort: VariableSort; + definition?: string; + options: VariableOption[]; + multi: boolean; + includeAll: boolean; + allValue?: string; + placeholder?: string; +} + +export const defaultQueryVariableSpec = (): QueryVariableSpec => ({ + name: "", + current: { text: "", value: "", }, + hide: VariableHide.DontHide, + refresh: VariableRefresh.Never, + skipUrlSync: false, + datasource: defaultDataSourceRef(), + query: "", + regex: "", + sort: VariableSort.Disabled, + options: [], + multi: false, + includeAll: false, +}); + +// Query variable kind +export interface QueryVariableKind { + kind: "QueryVariable"; + spec: QueryVariableSpec; +} + +export const defaultQueryVariableKind = (): QueryVariableKind => ({ + kind: "QueryVariable", + spec: defaultQueryVariableSpec(), +}); + +// Text variable specification +export interface TextVariableSpec { + name: string; + current: VariableOption; + query: string; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultTextVariableSpec = (): TextVariableSpec => ({ + name: "", + current: { text: "", value: "", }, + query: "", + hide: VariableHide.DontHide, + skipUrlSync: false, +}); + +// Text variable kind +export interface TextVariableKind { + kind: "TextVariable"; + spec: TextVariableSpec; +} + +export const defaultTextVariableKind = (): TextVariableKind => ({ + kind: "TextVariable", + spec: defaultTextVariableSpec(), +}); + +// Constant variable specification +export interface ConstantVariableSpec { + name: string; + query: string; + current: VariableOption; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultConstantVariableSpec = (): ConstantVariableSpec => ({ + name: "", + query: "", + current: { text: "", value: "", }, + hide: VariableHide.DontHide, + skipUrlSync: false, +}); + +// Constant variable kind +export interface ConstantVariableKind { + kind: "ConstantVariable"; + spec: ConstantVariableSpec; +} + +export const defaultConstantVariableKind = (): ConstantVariableKind => ({ + kind: "ConstantVariable", + spec: defaultConstantVariableSpec(), +}); + +// Datasource variable specification +export interface DatasourceVariableSpec { + name: string; + pluginId: string; + refresh: VariableRefresh; + regex: string; + current: VariableOption; + defaultOptionEnabled: boolean; + options: VariableOption[]; + multi: boolean; + includeAll: boolean; + allValue?: string; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultDatasourceVariableSpec = (): DatasourceVariableSpec => ({ + name: "", + pluginId: "", + refresh: VariableRefresh.Never, + regex: "", + current: { text: "", value: "", }, + defaultOptionEnabled: false, + options: [], + multi: false, + includeAll: false, + hide: VariableHide.DontHide, + skipUrlSync: false, +}); + +// Datasource variable kind +export interface DatasourceVariableKind { + kind: "DatasourceVariable"; + spec: DatasourceVariableSpec; +} + +export const defaultDatasourceVariableKind = (): DatasourceVariableKind => ({ + kind: "DatasourceVariable", + spec: defaultDatasourceVariableSpec(), +}); + +// Interval variable specification +export interface IntervalVariableSpec { + name: string; + query: string; + current: VariableOption; + options: VariableOption[]; + auto: boolean; + auto_min: string; + auto_count: number; + refresh: VariableRefresh; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultIntervalVariableSpec = (): IntervalVariableSpec => ({ + name: "", + query: "", + current: { text: "", value: "", }, + options: [], + auto: false, + auto_min: "", + auto_count: 0, + refresh: VariableRefresh.Never, + hide: VariableHide.DontHide, + skipUrlSync: false, +}); + +// Interval variable kind +export interface IntervalVariableKind { + kind: "IntervalVariable"; + spec: IntervalVariableSpec; +} + +export const defaultIntervalVariableKind = (): IntervalVariableKind => ({ + kind: "IntervalVariable", + spec: defaultIntervalVariableSpec(), +}); + +// Custom variable specification +export interface CustomVariableSpec { + name: string; + query: string; + current: VariableOption; + options: VariableOption[]; + multi: boolean; + includeAll: boolean; + allValue?: string; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ + name: "", + query: "", + current: defaultVariableOption(), + options: [], + multi: false, + includeAll: false, + hide: VariableHide.DontHide, + skipUrlSync: false, +}); + +// Custom variable kind +export interface CustomVariableKind { + kind: "CustomVariable"; + spec: CustomVariableSpec; +} + +export const defaultCustomVariableKind = (): CustomVariableKind => ({ + kind: "CustomVariable", + spec: defaultCustomVariableSpec(), +}); + +// GroupBy variable specification +export interface GroupByVariableSpec { + name: string; + datasource: DataSourceRef; + current: VariableOption; + options: VariableOption[]; + multi: boolean; + includeAll: boolean; + allValue?: string; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultGroupByVariableSpec = (): GroupByVariableSpec => ({ + name: "", + datasource: defaultDataSourceRef(), + current: { text: "", value: "", }, + options: [], + multi: false, + includeAll: false, + hide: VariableHide.DontHide, + skipUrlSync: false, +}); + +// Group variable kind +export interface GroupByVariableKind { + kind: "GroupByVariable"; + spec: GroupByVariableSpec; +} + +export const defaultGroupByVariableKind = (): GroupByVariableKind => ({ + kind: "GroupByVariable", + spec: defaultGroupByVariableSpec(), +}); + +// Adhoc variable specification +export interface AdhocVariableSpec { + name: string; + datasource: DataSourceRef; + baseFilters: AdHocFilterWithLabels[]; + filters: AdHocFilterWithLabels[]; + defaultKeys: MetricFindValue[]; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultAdhocVariableSpec = (): AdhocVariableSpec => ({ + name: "", + datasource: defaultDataSourceRef(), + baseFilters: [], + filters: [], + defaultKeys: [], + hide: VariableHide.DontHide, + skipUrlSync: false, +}); + +// Define the MetricFindValue type +export interface MetricFindValue { + text: string; + value?: string | number; + group?: string; + expandable?: boolean; +} + +export const defaultMetricFindValue = (): MetricFindValue => ({ + text: "", +}); + +// Define the AdHocFilterWithLabels type +export interface AdHocFilterWithLabels { + key: string; + operator: string; + value: string; + values?: string[]; + keyLabel?: string; + valueLabels?: string[]; + forceEdit?: boolean; + // @deprecated + condition?: string; +} + +export const defaultAdHocFilterWithLabels = (): AdHocFilterWithLabels => ({ + key: "", + operator: "", + value: "", +}); + +// Adhoc variable kind +export interface AdhocVariableKind { + kind: "AdhocVariable"; + spec: AdhocVariableSpec; +} + +export const defaultAdhocVariableKind = (): AdhocVariableKind => ({ + kind: "AdhocVariable", + spec: defaultAdhocVariableSpec(), +}); + diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue new file mode 100644 index 00000000000..e7922f46eae --- /dev/null +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -0,0 +1,789 @@ +package dashboard + +DashboardV2Spec: { + // Unique numeric identifier for the dashboard. + // `id` is internal to a specific Grafana instance. `uid` should be used to identify a dashboard across Grafana instances. + id?: int64 + + // Title of dashboard. + title: string + + // Description of dashboard. + description?: string + + // Configuration of dashboard cursor sync behavior. + // "Off" for no shared crosshair or tooltip (default). + // "Crosshair" for shared crosshair. + // "Tooltip" for shared crosshair AND shared tooltip. + cursorSync: DashboardCursorSync + + // When set to true, the dashboard will redraw panels at an interval matching the pixel width. + // This will keep data "moving left" regardless of the query refresh rate. This setting helps + // avoid dashboards presenting stale live data. + liveNow?: bool + + // When set to true, the dashboard will load all panels in the dashboard when it's loaded. + preload: bool + + // Whether a dashboard is editable or not. + editable?: bool | *true + + // Links with references to other dashboards or external websites. + links: [...DashboardLink] + + // Tags associated with dashboard. + tags?: [...string] + + timeSettings: TimeSettingsSpec + + // Configured template variables. + variables: [...QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind] + + elements: [ElementReference.name]: PanelKind // |* more element types in the future + + annotations: [...AnnotationQueryKind] + + layout: GridLayoutKind + + // Version of the JSON schema, incremented each time a Grafana update brings + // changes to said schema. + schemaVersion: uint16 | *39 + + + // version: will rely on k8s resource versioning, via metadata.resorceVersion + // revision?: int // for plugins only + // gnetId?: string // ??? Wat is this used for? +} + + +AnnotationPanelFilter: { + // Should the specified panels be included or excluded + exclude?: bool | *false + + // Panel IDs that should be included or excluded + ids: [...uint8] +} + +// "Off" for no shared crosshair or tooltip (default). +// "Crosshair" for shared crosshair. +// "Tooltip" for shared crosshair AND shared tooltip. +DashboardCursorSync: "Off" | "Crosshair" | "Tooltip" + +// Links with references to other dashboards or external resources +DashboardLink: { + // Title to display with the link + title: string + // Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) + // FIXME: The type is generated as `type: DashboardLinkType | dashboardLinkType.Link;` but it should be `type: DashboardLinkType` + type: DashboardLinkType + // Icon name to be displayed with the link + icon: string + // Tooltip to display when the user hovers their mouse over it + tooltip: string + // Link URL. Only required/valid if the type is link + url?: string + // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards + tags: [...string] + // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards + asDropdown: bool | *false + // If true, the link will be opened in a new tab + targetBlank: bool | *false + // If true, includes current template variables values in the link as query params + includeVars: bool | *false + // If true, includes current time range in the link as query params + keepTime: bool | *false +} + +DataSourceRef: { + // The plugin type-id + type?: string + + // Specific datasource instance + uid?: string +} + +// Transformations allow to manipulate data returned by a query before the system applies a visualization. +// Using transformations you can: rename fields, join time series data, perform mathematical operations across queries, +// use the output of one transformation as the input to another transformation, etc. +DataTransformerConfig: { + // Unique identifier of transformer + id: string + // Disabled transformations are skipped + disabled?: bool + // Optional frame matcher. When missing it will be applied to all results + filter?: MatcherConfig + // Where to pull DataFrames from as input to transformation + topic?: "series" | "annotations" | "alertStates" // replaced with common.DataTopic + // Options to be passed to the transformer + // Valid options depend on the transformer id + options: _ +} + +// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. +// Each column within this structure is called a field. A field can represent a single time series or table column. +// Field options allow you to change how the data is displayed in your visualizations. +FieldConfigSource: { + // Defaults are the options applied to all fields. + defaults: FieldConfig + // Overrides are the options applied to specific fields overriding the defaults. + overrides: [...{ + matcher: MatcherConfig + properties: [...DynamicConfigValue] + }] +} + +// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. +// Each column within this structure is called a field. A field can represent a single time series or table column. +// Field options allow you to change how the data is displayed in your visualizations. +FieldConfig: { + // The display value for this field. This supports template variables blank is auto + displayName?: string + + // This can be used by data sources that return and explicit naming structure for values and labels + // When this property is configured, this value is used rather than the default naming strategy. + displayNameFromDS?: string + + // Human readable field metadata + description?: string + + // An explicit path to the field in the datasource. When the frame meta includes a path, + // This will default to `${frame.meta.path}/${field.name} + // + // When defined, this value can be used as an identifier within the datasource scope, and + // may be used to update the results + path?: string + + // True if data source can write a value to the path. Auth/authz are supported separately + writeable?: bool + + // True if data source field supports ad-hoc filters + filterable?: bool + + // Unit a field should use. The unit you select is applied to all fields except time. + // You can use the units ID availables in Grafana or a custom unit. + // Available units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts + // As custom unit, you can use the following formats: + // `suffix:` for custom unit that should go after value. + // `prefix:` for custom unit that should go before value. + // `time:` For custom date time formats type for example `time:YYYY-MM-DD`. + // `si:` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character. + // `count:` for a custom count unit. + // `currency:` for custom a currency unit. + unit?: string + + // Specify the number of decimals Grafana includes in the rendered value. + // If you leave this field blank, Grafana automatically truncates the number of decimals based on the value. + // For example 1.1234 will display as 1.12 and 100.456 will display as 100. + // To display all decimals, set the unit to `String`. + decimals?: number + + // The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + min?: number + // The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. + max?: number + + // Convert input values into a display string + mappings?: [...ValueMapping] + + // Map numeric values to states + thresholds?: ThresholdsConfig + + // Panel color configuration + color?: FieldColor + + // The behavior when clicking on a result + links?: [...] + + // Alternative to empty string + noValue?: string + + // custom is specified by the FieldConfig field + // in panel plugin schemas. + custom?: {...} +} + +DynamicConfigValue: { + id: string | *"" + value?: _ +} + +// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. +// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. +MatcherConfig: { + // The matcher id. This is used to find the matcher implementation from registry. + id: string | *"" + // The matcher options. This is specific to the matcher implementation. + options?: _ +} + +Threshold: { + value: number | null + color: string +} + +ThresholdsMode: "absolute" | "percentage" + +ThresholdsConfig: { + mode: ThresholdsMode + steps: [...Threshold] +} + +ValueMapping: ValueMap | RangeMap | RegexMap | SpecialValueMap + +// Supported value mapping types +// `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. +// `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. +// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. +// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A. +MappingType: "value" | "range" | "regex" | "special" + +// Maps text values to a color or different display text and color. +// For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number. +ValueMap: { + type: MappingType & "value" + // Map with : ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } } + options: [string]: ValueMappingResult +} + +// Maps numerical ranges to a display text and color. +// For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number. +RangeMap: { + type: MappingType & "range" + // Range to match against and the result to apply when the value is within the range + options: { + // Min value of the range. It can be null which means -Infinity + from: float64 | null + // Max value of the range. It can be null which means +Infinity + to: float64 | null + // Config to apply when the value is within the range + result: ValueMappingResult + } +} + +// Maps regular expressions to replacement text and a color. +// For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain. +RegexMap: { + type: MappingType & "regex" + // Regular expression to match against and the result to apply when the value matches the regex + options: { + // Regular expression to match against + pattern: string + // Config to apply when the value matches the regex + result: ValueMappingResult + } +} + +// Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. +// See SpecialValueMatch to see the list of special values. +// For example, you can configure a special value mapping so that null values appear as N/A. +SpecialValueMap: { + type: MappingType & "special" + options: { + // Special value to match against + match: SpecialValueMatch + // Config to apply when the value matches the special value + result: ValueMappingResult + } +} + +// Special value types supported by the `SpecialValueMap` +SpecialValueMatch: "true" | "false" | "null" | "nan" | "null+nan" | "empty" + +// Result used as replacement with text and color when the value matches +ValueMappingResult: { + // Text to display when the value matches + text?: string + // Text to use when the value matches + color?: string + // Icon to display when the value matches. Only specific visualizations. + icon?: string + // Position in the mapping array. Only used internally. + index?: int32 +} + +// Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. +// Continuous color interpolates a color using the percentage of a value relative to min and max. +// Accepted values are: +// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold +// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations +// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations +// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode +// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode +// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode +// `continuous-YlRd`: Continuous Yellow-Red palette mode +// `continuous-BlPu`: Continuous Blue-Purple palette mode +// `continuous-YlBl`: Continuous Yellow-Blue palette mode +// `continuous-blues`: Continuous Blue palette mode +// `continuous-reds`: Continuous Red palette mode +// `continuous-greens`: Continuous Green palette mode +// `continuous-purples`: Continuous Purple palette mode +// `shades`: Shades of a single color. Specify a single color, useful in an override rule. +// `fixed`: Fixed color mode. Specify a single color, useful in an override rule. +FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" + +// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value. +FieldColorSeriesByMode: "min" | "max" | "last" + +// Map a field to a color. +FieldColor: { + // The main color scheme mode. + mode: FieldColorModeId + // The fixed color value for fixed or shades color modes. + fixedColor?: string + // Some visualizations need to know how to assign a series color from by value color schemes. + seriesBy?: FieldColorSeriesByMode +} + +// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) +DashboardLinkType: "link" | "dashboards" + +// --- Common types --- +Kind: { + kind: string, + spec: _ + metadata?: _ +} + +// --- Kinds --- +VizConfigSpec: { + pluginVersion: string + options: [string]: _ + fieldConfig: FieldConfigSource +} + +VizConfigKind: { + // The kind of a VizConfigKind is the plugin ID + kind: string + spec: VizConfigSpec +} + +AnnotationQuerySpec: { + datasource: DataSourceRef + query: DataQueryKind + + // TODO: Should be figured out based on datasource (Grafana ds) + // builtIn?: int + // Below are currently existing options for annotation queries + enable: bool + filter: AnnotationPanelFilter + hide: bool + iconColor: string + name: string +} + +AnnotationQueryKind: { + kind: "AnnotationQuery" + spec: AnnotationQuerySpec +} + +QueryOptionsSpec: { + timeFrom?: string + maxDataPoints?: int + timeShift?: string + queryCachingTTL?: int + interval?: string + cacheTimeout?: string +} + +DataQueryKind: { + // The kind of a DataQueryKind is the datasource type + kind: string + spec: [string]: _ +} + +PanelQuerySpec: { + query: DataQueryKind + datasource: DataSourceRef + + refId: string + hidden: bool +} + +PanelQueryKind: { + kind: "PanelQuery" + spec: PanelQuerySpec +} + +TransformationKind: { + // The kind of a TransformationKind is the transformation ID + kind: string + spec: DataTransformerConfig +} + +QueryGroupSpec: { + queries: [...PanelQueryKind] + transformations: [...TransformationKind] + queryOptions: QueryOptionsSpec +} + +QueryGroupKind: { + kind: "QueryGroup" + spec: QueryGroupSpec +} + +// Time configuration +// It defines the default time config for the time picker, the refresh picker for the specific dashboard. +TimeSettingsSpec: { + // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". + timezone?: string | *"browser" + // Start time range for dashboard. + // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". + from: string | *"now-6h" + // End time range for dashboard. + // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z". + to: string | *"now" + // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d". + autoRefresh: string // v1: refresh + // Interval options available in the refresh picker dropdown. + autoRefreshIntervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] // v1: timepicker.refresh_intervals + // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. + quickRanges: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] // v1: timepicker.time_options , not exposed in the UI + // Whether timepicker is visible or not. + hideTimepicker: bool // v1: timepicker.hidden + // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". + weekStart: string + // The month that the fiscal year starts on. 0 = January, 11 = December + fiscalYearStartMonth: int + // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. + nowDelay?: string // v1: timepicker.nowDelay +} + +GridLayoutItemSpec: { + x: int + y: int + width: int + height: int + element: ElementReference // reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference +} + +GridLayoutItemKind: { + kind: "GridLayoutItem" + spec: GridLayoutItemSpec +} + +GridLayoutSpec: { + items: [...GridLayoutItemKind] +} + +GridLayoutKind: { + kind: "GridLayout" + spec: GridLayoutSpec +} + +PanelSpec: { + uid: string + title: string + description: string + links: [...DashboardLink] + data: QueryGroupKind + vizConfig: VizConfigKind +} + +PanelKind: { + kind: "Panel" + spec: PanelSpec +} + +ElementReference: { + kind: "ElementReference" + name: string +} + + + + +// Start FIXME: variables - in CUE PR - this are things that should be added into the cue schema +// TODO: properties such as `hide`, `skipUrlSync`, `multi` are type boolean, and in the old schema they are conditional, +// should we make them conditional in the new schema as well? or should we make them required but default to false? + +// Variable types +VariableValue: VariableValueSingle | [...VariableValueSingle] + +VariableValueSingle: string | bool | number | CustomVariableValue + +// Custom formatter variable +CustomFormatterVariable: { + name: string + type: VariableType + multi: bool + includeAll: bool +} + +// Custom variable value +CustomVariableValue: { + // The format name or function used in the expression + formatter: *null | string | VariableCustomFormatterFn +} + +// Custom formatter function +VariableCustomFormatterFn: { + value: _ + legacyVariableModel: { + name: string + type: VariableType + multi: bool + includeAll: bool + } + legacyDefaultFormatter?: VariableCustomFormatterFn +} + +// Dashboard variable type + // `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. + // `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). + // `constant`: Define a hidden constant. + // `datasource`: Quickly change the data source for an entire dashboard. + // `interval`: Interval variables represent time spans. + // `textbox`: Display a free text input field with an optional default value. + // `custom`: Define the variable options manually using a comma-separated list. + // `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables +VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | + "system" | "snapshot" + +// Sort variable options +// Accepted values are: +// `disabled`: No sorting +// `alphabeticalAsc`: Alphabetical ASC +// `alphabeticalDesc`: Alphabetical DESC +// `numericalAsc`: Numerical ASC +// `numericalDesc`: Numerical DESC +// `alphabeticalCaseInsensitiveAsc`: Alphabetical Case Insensitive ASC +// `alphabeticalCaseInsensitiveDesc`: Alphabetical Case Insensitive DESC +// `naturalAsc`: Natural ASC +// `naturalDesc`: Natural DESC +// VariableSort enum with default value +VariableSort: "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAsc" | "numericalDesc" | "alphabeticalCaseInsensitiveAsc" | "alphabeticalCaseInsensitiveDesc" | "naturalAsc" | "naturalDesc" + +// Options to config when to refresh a variable +// `never`: Never refresh the variable +// `onDashboardLoad`: Queries the data source every time the dashboard loads. +// `onTimeRangeChanged`: Queries the data source when the dashboard time range changes. +VariableRefresh: *"never" | "onDashboardLoad" | "onTimeRangeChanged" + +// Determine if the variable shows on dashboard +// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing). +VariableHide: *"dontHide" | "hideLabel" | "hideVariable" + + +// FIXME: should we introduce this? --- Variable value option +VariableValueOption: { + label: string + value: VariableValueSingle + group?: string +} + +// Variable option specification +VariableOption: { + // Whether the option is selected or not + selected?: bool + // Text to be displayed for the option + text: string | [...string] + // Value of the option +value: string | [...string] +} + +// Query variable specification +QueryVariableSpec: { + name: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + label?: string + hide: VariableHide + refresh: VariableRefresh + skipUrlSync: bool | *false + description?: string + datasource: DataSourceRef | *{} + query: string | DataQueryKind | *"" + regex: string | *"" + sort: VariableSort + definition?: string + options: [...VariableOption] | *[] + multi: bool | *false + includeAll: bool | *false + allValue?: string + placeholder?: string +} + +// Query variable kind +QueryVariableKind: { + kind: "QueryVariable" + spec: QueryVariableSpec +} + +// Text variable specification +TextVariableSpec: { + name: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + query: string | *"" + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Text variable kind +TextVariableKind: { + kind: "TextVariable" + spec: TextVariableSpec +} + +// Constant variable specification +ConstantVariableSpec: { + name: string | *"" + query: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Constant variable kind +ConstantVariableKind: { + kind: "ConstantVariable" + spec: ConstantVariableSpec +} + +// Datasource variable specification +DatasourceVariableSpec: { + name: string | *"" + pluginId: string | *"" + refresh: VariableRefresh + regex: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + defaultOptionEnabled: bool | *false + options: [...VariableOption] | *[] + multi: bool | *false + includeAll: bool | *false + allValue?: string + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Datasource variable kind +DatasourceVariableKind: { + kind: "DatasourceVariable" + spec: DatasourceVariableSpec +} + +// Interval variable specification +IntervalVariableSpec: { + name: string | *"" + query: string | *"" + current: VariableOption | *{ + text: "" + value: "" + } + options: [...VariableOption] | *[] + auto: bool | *false + auto_min: string | *"" + auto_count: int | *0 + refresh: VariableRefresh + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Interval variable kind +IntervalVariableKind: { + kind: "IntervalVariable" + spec: IntervalVariableSpec +} + +// Custom variable specification +CustomVariableSpec: { + name: string | *"" + query: string | *"" + current: VariableOption + options: [...VariableOption] | *[] + multi: bool | *false + includeAll: bool | *false + allValue?: string + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Custom variable kind +CustomVariableKind: { + kind: "CustomVariable" + spec: CustomVariableSpec +} + +// GroupBy variable specification +GroupByVariableSpec: { + name: string | *"" + datasource: DataSourceRef | *{} + current: VariableOption | *{ + text: "" + value: "" + } + options: [...VariableOption] | *[] + multi: bool | *false + includeAll: bool | *false + allValue?: string + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Group variable kind +GroupByVariableKind: { + kind: "GroupByVariable" + spec: GroupByVariableSpec +} + +// Adhoc variable specification +AdhocVariableSpec: { + name: string | *"" + datasource: DataSourceRef | *{} + baseFilters: [...AdHocFilterWithLabels] | *[] + filters: [...AdHocFilterWithLabels] | *[] + defaultKeys: [...MetricFindValue] | *[] + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +// Define the MetricFindValue type +MetricFindValue: { + text: string + value?: string | number + group?: string + expandable?: bool +} + +// Define the AdHocFilterWithLabels type +AdHocFilterWithLabels: { + key: string, + operator: string, + value: string, + values?: [...string], + keyLabel?: string, + valueLabels?: [...string], + forceEdit?: bool, + // @deprecated + condition?: string, +} + +// Adhoc variable kind +AdhocVariableKind: { + kind: "AdhocVariable" + spec: AdhocVariableSpec +} diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts new file mode 100644 index 00000000000..5b98aaf0854 --- /dev/null +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts @@ -0,0 +1,150 @@ +import { DashboardCursorSync, DashboardV2Spec } from './dashboard.gen'; + +export const handyTestingSchema: DashboardV2Spec = { + id: 1, + title: 'Default Dashboard', + description: 'This is a default dashboard', + cursorSync: DashboardCursorSync.Off, + liveNow: false, + preload: false, + editable: true, + links: [], + tags: [], + schemaVersion: 39, + timeSettings: { + timezone: 'browser', + from: 'now-6h', + to: 'now', + autoRefresh: '10s', + autoRefreshIntervals: ['10s', '1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d'], + quickRanges: ['now/d', 'now/w', 'now/M', 'now/y'], + hideTimepicker: false, + weekStart: 'sunday', + fiscalYearStartMonth: 1, + }, + + elements: { + timeSeriesTest: { + kind: 'Panel', + spec: { + title: 'Time Series Test', + description: 'This is a test panel', + uid: 'timeSeriesTest', + links: [], + data: { + kind: 'QueryGroup', + spec: { + queries: [ + { + kind: 'PanelQuery', + spec: { + query: { + kind: 'prometheus', + spec: { + query: 'up', + }, + }, + datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, + hidden: false, + refId: 'A', + }, + }, + ], + transformations: [ + { + kind: 'limit', + spec: { + id: 'limit', // id is competing w/ kind + options: { + limit: 10, + }, + }, + }, + ], + queryOptions: { + maxDataPoints: 100, + cacheTimeout: '1m', + }, + }, + }, + vizConfig: { + kind: 'timeseries', + spec: { + pluginVersion: '11.0.0', + options: {}, + fieldConfig: { + defaults: {}, + overrides: [], + }, + }, + }, + }, + }, + }, + layout: { + kind: 'GridLayout', + spec: { + items: [ + { + kind: 'GridLayoutItem', + spec: { + element: { kind: 'ElementReference', name: 'timeSeriesTest' }, + x: 0, + y: 0, + width: 12, + height: 6, + }, + }, + ], + }, + }, + variables: [], + annotations: [ + { + kind: 'AnnotationQuery', + spec: { + datasource: { type: 'datasource', uid: 'grafana' }, + query: { + kind: 'grafana', + spec: { + queryType: 'timeRegions', + matchAny: false, + timeRegion: { + from: '12:27', + fromDayOfWeek: 2, + timezone: 'browser', + to: '11:30', + toDayOfWeek: 2, + }, + }, + }, + enable: true, + filter: { + ids: [], + }, + hide: false, + iconColor: 'blue', + name: 'Grafana annotations', + }, + }, + { + kind: 'AnnotationQuery', + spec: { + datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, + query: { + kind: 'prometheus', + spec: { + query: 'up', + }, + }, + enable: true, + filter: { + ids: [], + }, + hide: false, + iconColor: 'red', + name: 'Prometheus annotations', + }, + }, + ], +}; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 7994de17e82..bbf2eda4c06 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -56,7 +56,7 @@ "@grafana/faro-web-sdk": "^1.3.6", "@grafana/schema": "11.4.0-pre", "@hello-pangea/dnd": "17.0.0", - "@leeoniya/ufuzzy": "1.0.14", + "@leeoniya/ufuzzy": "1.0.16", "@monaco-editor/react": "4.6.0", "@popperjs/core": "2.11.8", "@react-aria/dialog": "3.5.19", @@ -92,7 +92,7 @@ "react-calendar": "5.1.0", "react-colorful": "5.6.1", "react-custom-scrollbars-2": "4.5.0", - "react-dropzone": "14.2.10", + "react-dropzone": "14.3.5", "react-highlight-words": "0.20.0", "react-hook-form": "^7.49.2", "react-i18next": "^14.0.0", diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx index 1b63c36bec1..7bb767e9dbd 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx @@ -4,16 +4,16 @@ import React, { ComponentProps, useCallback, useEffect, useState } from 'react'; import { SelectableValue } from '@grafana/data'; -import { useTheme2 } from '../../themes/ThemeContext'; import { Alert } from '../Alert/Alert'; -import { Divider } from '../Divider/Divider'; import { Field } from '../Forms/Field'; -import { AsyncSelect, Select } from '../Select/Select'; +import { AsyncSelect } from '../Select/Select'; import { Combobox, ComboboxOption } from './Combobox'; import mdx from './Combobox.mdx'; -type PropsAndCustomArgs = ComponentProps & { numberOfOptions: number }; +type PropsAndCustomArgs = ComponentProps> & { + numberOfOptions: number; +}; const meta: Meta = { title: 'Forms/Combobox', @@ -27,6 +27,7 @@ const meta: Meta = { loading: undefined, invalid: undefined, width: undefined, + isClearable: false, placeholder: 'Select an option...', options: [ { label: 'Apple', value: 'apple' }, @@ -45,12 +46,6 @@ const meta: Meta = { { label: 'Honeydew', value: 'honeydew' }, { label: 'Iceberg Lettuce', value: 'iceberg-lettuce' }, { label: 'Jackfruit', value: 'jackfruit' }, - { label: '1', value: 1 }, - { label: '2', value: 2 }, - { label: '3', value: 3 }, - { label: '4', value: 4 }, - { label: '5', value: 5 }, - { label: '6', value: 6 }, ], value: 'banana', }, @@ -59,16 +54,16 @@ const meta: Meta = { decorators: [InDevDecorator], }; -const BasicWithState: StoryFn = (args) => { - const [value, setValue] = useState(args.value); - +const BasicWithState: StoryFn = (args) => { + const [value, setValue] = useState(); return ( { + onChange={(val: ComboboxOption | null) => { + // TODO: Figure out how to update value on args setValue(val?.value || null); action('onChange')(val); }} @@ -88,7 +83,7 @@ async function generateOptions(amount: number): Promise { })); } -const ManyOptionsStory: StoryFn = ({ numberOfOptions, ...args }) => { +const ManyOptionsStory: StoryFn> = ({ numberOfOptions, ...args }) => { const [value, setValue] = useState(null); const [options, setOptions] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -103,13 +98,14 @@ const ManyOptionsStory: StoryFn = ({ numberOfOptions, ...arg }, 1000); }, [numberOfOptions]); + const { onChange, ...rest } = args; return ( { + onChange={(opt: ComboboxOption | null) => { setValue(opt?.value || null); action('onChange')(opt); }} @@ -117,132 +113,6 @@ const ManyOptionsStory: StoryFn = ({ numberOfOptions, ...arg ); }; -const SelectComparisonStory: StoryFn = (args) => { - const [comboboxValue, setComboboxValue] = useState(args.value); - const theme = useTheme2(); - - if (typeof args.options === 'function') { - throw new Error('This story does not support async options'); - } - - return ( -
- - { - setComboboxValue(val?.value || null); - action('onChange')(val); - }} - /> - - - - { - setComboboxValue(val?.value || null); - action('onChange')(val); - }} - /> - - - - - - { - setComboboxValue(val?.value || null); - action('onChange')(val); - }} - /> - - - - { - setComboboxValue(val?.value || null); - action('onChange')(val); - }} - /> - -
- ); -}; - export const AutoSize: StoryObj = { args: { width: 'auto', @@ -294,6 +164,8 @@ const AsyncStory: StoryFn = (args) => { } }, []); + const { onChange, ...rest } = args; + return ( <> = (args) => { description="This tests when options have both a label and a value. Consumers are required to pass in a full ComboboxOption as a value with a label" > { + onChange={(val: ComboboxOption | null) => { action('onChange')(val); setSelectedOption(val); }} @@ -324,7 +196,7 @@ const AsyncStory: StoryFn = (args) => { placeholder="Select an option" options={loadOptionsOnlyValues} value={selectedOption?.value ?? null} - onChange={(val) => { + onChange={(val: ComboboxOption | null) => { action('onChange')(val); setSelectedOption(val); }} @@ -366,7 +238,7 @@ const AsyncStory: StoryFn = (args) => { placeholder="Select an option" options={loadOptionsWithErrors} value={selectedOption} - onChange={(val) => { + onChange={(val: ComboboxOption | null) => { action('onChange')(val); setSelectedOption(val); }} @@ -426,13 +298,6 @@ export const PositioningTest: StoryObj = { render: PositioningTestStory, }; -export const ComparisonToSelect: StoryObj = { - args: { - numberOfOptions: 100, - }, - render: SelectComparisonStory, -}; - export default meta; function InDevDecorator(Story: React.ElementType) { diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx index d45c2cc2402..ab9e63fbb3b 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.test.tsx @@ -1,5 +1,6 @@ import { act, render, screen, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import React from 'react'; import { Combobox, ComboboxOption } from './Combobox'; @@ -49,7 +50,7 @@ describe('Combobox', () => { expect(onChangeHandler).toHaveBeenCalledWith(options[0]); }); - it("shows the placeholder with the menu open when there's no value", async () => { + it('shows the placeholder with the menu open when value is null', async () => { render(); const input = screen.getByRole('combobox'); @@ -58,6 +59,15 @@ describe('Combobox', () => { expect(input).toHaveAttribute('placeholder', 'Select an option'); }); + it('shows the placeholder with the menu open when value is undefined', async () => { + render(); + + const input = screen.getByRole('combobox'); + await userEvent.click(input); + + expect(input).toHaveAttribute('placeholder', 'Select an option'); + }); + it('selects value by clicking that needs scrolling', async () => { render(); @@ -106,6 +116,73 @@ describe('Combobox', () => { expect(screen.queryByDisplayValue('Option 2')).not.toBeInTheDocument(); }); + it.each(['very valid value', '', 0])('should handle an option with %p as a value', async (val) => { + const options = [ + { label: 'Second option', value: '2' }, + { label: 'Default', value: val }, + ]; + + const ControlledCombobox = () => { + const [value, setValue] = React.useState(null); + + return ( + { + setValue(opt.value); + }} + /> + ); + }; + + render(); + + const input = screen.getByRole('combobox'); + await userEvent.click(input); + await userEvent.click(screen.getByRole('option', { name: 'Default' })); + expect(screen.queryByDisplayValue('Default')).toBeInTheDocument(); + + await userEvent.click(input); + + expect(screen.getByRole('option', { name: 'Default' })).toHaveAttribute('aria-selected', 'true'); + }); + + describe('size support', () => { + it('should require minWidth to be set with auto width', () => { + // @ts-expect-error + render(); + }); + + it('should change width when typing things with auto width', async () => { + render(); + + const input = screen.getByRole('combobox'); + const inputWrapper = screen.getByTestId('input-wrapper'); + const initialWidth = getComputedStyle(inputWrapper).width; + + fireEvent.change(input, { target: { value: 'very very long value' } }); + + const newWidth = getComputedStyle(inputWrapper).width; + + expect(initialWidth).not.toBe(newWidth); + }); + + it('should not change width when typing things with fixed width', async () => { + render(); + const input = screen.getByRole('combobox'); + + const inputWrapper = screen.getByTestId('input-wrapper'); + const initialWidth = getComputedStyle(inputWrapper).width; + + fireEvent.change(input, { target: { value: 'very very long value' } }); + + const newWidth = getComputedStyle(inputWrapper).width; + + expect(initialWidth).toBe(newWidth); + }); + }); + describe('with a value already selected', () => { it('shows an empty text input when opening the menu', async () => { const selectedValue = options[0].value; diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index 3b70f8e3f78..c3940a7fed1 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -28,7 +28,10 @@ export type ComboboxOption = { // TODO: It would be great if ComboboxOption["label"] was more generic so that if consumers do pass it in (for async), // then the onChange handler emits ComboboxOption with the label as non-undefined. interface ComboboxBaseProps - extends Omit { + extends Pick< + InputProps, + 'placeholder' | 'autoFocus' | 'id' | 'aria-labelledby' | 'disabled' | 'loading' | 'invalid' + > { /** * An `X` appears in the UI, which clears the input and sets the value to `null`. Do not use if you have no `null` case. */ @@ -38,20 +41,31 @@ interface ComboboxBaseProps */ createCustomValue?: boolean; options: Array> | ((inputValue: string) => Promise>>); - onChange: (option: ComboboxOption | null) => void; + onChange: (option: ComboboxOption) => void; /** * Most consumers should pass value in as a scalar string | number. However, sometimes with Async because we don't * have the full options loaded to match the value to, consumers may also pass in an Option with a label to display. */ - value: T | ComboboxOption | null; + value?: T | ComboboxOption | null; /** * Defaults to 100%. Number is a multiple of 8px. 'auto' will size the input to the content. * */ width?: number | 'auto'; + onBlur?: () => void; } const RECOMMENDED_ITEMS_AMOUNT = 100_000; +type ClearableConditionals = + | { + isClearable: true; + /** + * The onChange handler is called with `null` when clearing the Combobox. + */ + onChange: (option: ComboboxOption | null) => void; + } + | { isClearable?: false; onChange: (option: ComboboxOption) => void }; + type AutoSizeConditionals = | { width: 'auto'; @@ -70,13 +84,16 @@ type AutoSizeConditionals = maxWidth?: never; }; -type ComboboxProps = ComboboxBaseProps & AutoSizeConditionals; +type ComboboxProps = ComboboxBaseProps & AutoSizeConditionals & ClearableConditionals; -function itemToString(item: ComboboxOption | null) { - if (item?.label?.includes('Custom value: ')) { - return item?.value.toString(); +function itemToString(item?: ComboboxOption | null) { + if (!item) { + return ''; } - return item?.label ?? item?.value.toString() ?? ''; + if (item.label?.includes('Custom value: ')) { + return item.value.toString(); + } + return item.label ?? item.value.toString(); } function itemFilter(inputValue: string) { @@ -85,8 +102,8 @@ function itemFilter(inputValue: string) { return (item: ComboboxOption) => { return ( !inputValue || - item?.label?.toLowerCase().includes(lowerCasedInputValue) || - item?.value?.toString().toLowerCase().includes(lowerCasedInputValue) + item.label?.toLowerCase().includes(lowerCasedInputValue) || + item.value?.toString().toLowerCase().includes(lowerCasedInputValue) ); }; } @@ -109,8 +126,14 @@ export const Combobox = (props: ComboboxProps) => createCustomValue = false, id, width, + minWidth, + maxWidth, 'aria-labelledby': ariaLabelledBy, - ...restProps + autoFocus, + onBlur, + disabled, + loading, + invalid, } = props; // Value can be an actual scalar Value (string or number), or an Option (value + label), so @@ -158,7 +181,7 @@ export const Combobox = (props: ComboboxProps) => return null; } - if (value === null) { + if (valueProp === undefined || valueProp === null) { return null; } @@ -168,9 +191,13 @@ export const Combobox = (props: ComboboxProps) => } return index; - }, [options, value, isAsync]); + }, [valueProp, options, value, isAsync]); const selectedItem = useMemo(() => { + if (valueProp === undefined || valueProp === null) { + return null; + } + if (selectedItemIndex !== null && !isAsync) { return options[selectedItemIndex]; } @@ -329,7 +356,9 @@ export const Combobox = (props: ComboboxProps) => const { inputRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, rowVirtualizer.range, isOpen); - const InputComponent = width === 'auto' ? AutoSizeInput : Input; + const isAutoSize = width === 'auto'; + + const InputComponent = isAutoSize ? AutoSizeInput : Input; const suffixIcon = asyncLoading ? 'spinner' @@ -343,7 +372,13 @@ export const Combobox = (props: ComboboxProps) => return (
@@ -368,7 +403,6 @@ export const Combobox = (props: ComboboxProps) => } - {...restProps} {...getInputProps({ ref: inputRef, /* Empty onCall to avoid TS error diff --git a/packages/grafana-ui/src/components/Input/AutoSizeInput.tsx b/packages/grafana-ui/src/components/Input/AutoSizeInput.tsx index b6899a4caa3..9e7b275c120 100644 --- a/packages/grafana-ui/src/components/Input/AutoSizeInput.tsx +++ b/packages/grafana-ui/src/components/Input/AutoSizeInput.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { measureText } from '../../utils/measureText'; +import { AutoSizeInputContext } from './AutoSizeInputContext'; import { Input, Props as InputProps } from './Input'; export interface Props extends InputProps { @@ -44,34 +45,38 @@ export const AutoSizeInput = React.forwardRef((props, r }, [placeholder, value, minWidth, maxWidth]); return ( - { - if (onChange) { - onChange(event); - } - setValue(event.currentTarget.value); - }} - width={inputWidth} - onBlur={(event) => { - if (onBlur) { - onBlur(event); - } else if (onCommitChange) { - onCommitChange(event); - } - }} - onKeyDown={(event) => { - if (onKeyDown) { - onKeyDown(event); - } else if (event.key === 'Enter' && onCommitChange) { - onCommitChange(event); - } - }} - data-testid={'autosize-input'} - /> + // Used to tell Input to increase the width properly of the input to fit the text. + // See comment in Input.tsx for more details + + { + if (onChange) { + onChange(event); + } + setValue(event.currentTarget.value); + }} + onBlur={(event) => { + if (onBlur) { + onBlur(event); + } else if (onCommitChange) { + onCommitChange(event); + } + }} + onKeyDown={(event) => { + if (onKeyDown) { + onKeyDown(event); + } else if (event.key === 'Enter' && onCommitChange) { + onCommitChange(event); + } + }} + width={inputWidth} + data-testid="autosize-input" + /> + ); }); diff --git a/packages/grafana-ui/src/components/Input/AutoSizeInputContext.ts b/packages/grafana-ui/src/components/Input/AutoSizeInputContext.ts new file mode 100644 index 00000000000..328e382895b --- /dev/null +++ b/packages/grafana-ui/src/components/Input/AutoSizeInputContext.ts @@ -0,0 +1,6 @@ +import React from 'react'; + +// Used to tell Input to increase the width properly of the input to fit the text. +// See comment in Input.tsx for more details +export const AutoSizeInputContext = React.createContext(false); +AutoSizeInputContext.displayName = 'AutoSizeInputContext'; diff --git a/packages/grafana-ui/src/components/Input/Input.tsx b/packages/grafana-ui/src/components/Input/Input.tsx index 8bd92e180d8..1240569cb57 100644 --- a/packages/grafana-ui/src/components/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Input/Input.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { forwardRef, HTMLProps, ReactNode } from 'react'; +import { forwardRef, HTMLProps, ReactNode, useContext } from 'react'; import useMeasure from 'react-use/lib/useMeasure'; import { GrafanaTheme2 } from '@grafana/data'; @@ -8,6 +8,8 @@ import { stylesFactory, useTheme2 } from '../../themes'; import { getFocusStyle, sharedInputStyle } from '../Forms/commonStyles'; import { Spinner } from '../Spinner/Spinner'; +import { AutoSizeInputContext } from './AutoSizeInputContext'; + export interface Props extends Omit, 'prefix' | 'size'> { /** Sets the width to a multiple of 8px. Should only be used with inline forms. Setting width of the container is preferred in other cases.*/ width?: number; @@ -32,7 +34,17 @@ interface StyleDeps { } export const Input = forwardRef((props, ref) => { - const { className, addonAfter, addonBefore, prefix, suffix, invalid, loading, width = 0, ...restProps } = props; + const { + className, + addonAfter, + addonBefore, + prefix, + suffix: suffixProp, + invalid, + loading, + width = 0, + ...restProps + } = props; /** * Prefix & suffix are positioned absolutely within inputWrapper. We use client rects below to apply correct padding to the input * when prefix/suffix is larger than default (28px = 16px(icon) + 12px(left/right paddings)). @@ -41,13 +53,24 @@ export const Input = forwardRef((props, ref) => { const [prefixRef, prefixRect] = useMeasure(); const [suffixRef, suffixRect] = useMeasure(); + // Yes, this is gross - When Input is being wrapped by AutoSizeInput, add the suffix/prefix width to the overall width + // so the text content is not clipped. The intention is to make all the input's text appear without overflow/clipping, + // which isn't normally how width is used in this component. + // This behaviour is not controlled via a prop so we can limit API surface, and remove this as a 'breaking change' later + // if a better solution is found. + const isInAutoSizeInput = useContext(AutoSizeInputContext); + const accessoriesWidth = (prefixRect.width || 0) + (suffixRect.width || 0); + const finalWidth = isInAutoSizeInput && width ? width + accessoriesWidth / 8 : width; + const theme = useTheme2(); - const styles = getInputStyles({ theme, invalid: !!invalid, width }); + + const styles = getInputStyles({ theme, invalid: !!invalid, width: finalWidth }); + + const suffix = suffixProp || (loading && ); return ( -
+
{!!addonBefore &&
{addonBefore}
} -
{prefix && (
@@ -65,14 +88,12 @@ export const Input = forwardRef((props, ref) => { }} /> - {(suffix || loading) && ( + {suffix && (
- {loading && } {suffix}
)}
- {!!addonAfter &&
{addonAfter}
}
); diff --git a/pkg/infra/tracing/tracing.go b/pkg/infra/tracing/tracing.go index 4c6bd6545a3..5f028c6ce80 100644 --- a/pkg/infra/tracing/tracing.go +++ b/pkg/infra/tracing/tracing.go @@ -381,6 +381,15 @@ func TraceIDFromContext(ctx context.Context, requireSampled bool) string { return spanCtx.TraceID().String() } +func ServerTimingForSpan(span trace.Span) string { + spanCtx := span.SpanContext() + if !spanCtx.HasTraceID() || !spanCtx.IsValid() { + return "" + } + + return fmt.Sprintf("00-%s-%s-01", spanCtx.TraceID().String(), spanCtx.SpanID().String()) +} + // Error sets the status to error and record the error as an exception in the provided span. func Error(span trace.Span, err error) error { attr := []attribute.KeyValue{} diff --git a/pkg/middleware/request_tracing.go b/pkg/middleware/request_tracing.go index 2985fe0ad60..2f492a697f9 100644 --- a/pkg/middleware/request_tracing.go +++ b/pkg/middleware/request_tracing.go @@ -94,6 +94,14 @@ func RequestTracing(tracer tracing.Tracer) web.Middleware { ), trace.WithSpanKind(trace.SpanKindServer)) defer span.End() + // inject local root span context into the response via server-timing header + // we're doing it this early so that we can capture the root span context + // which is not available later-on. + serverTimingValue := tracing.ServerTimingForSpan(span) + if serverTimingValue != "" { + w.Header().Set("server-timing", fmt.Sprintf("traceparent;desc=\"%s\"", serverTimingValue)) + } + req = req.WithContext(ctx) // Ensure the response writer's status can be captured. diff --git a/pkg/registry/apis/dashboard/v0alpha1/register.go b/pkg/registry/apis/dashboard/v0alpha1/register.go index e6b5e1f9bd9..804d5e4e02d 100644 --- a/pkg/registry/apis/dashboard/v0alpha1/register.go +++ b/pkg/registry/apis/dashboard/v0alpha1/register.go @@ -12,6 +12,7 @@ import ( "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" + dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard" dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" @@ -114,14 +115,30 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver return err } + defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{}) + if err != nil { + return err + } + storageOpts := apistore.StorageOptions{ + InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) { + internal := &dashboardinternal.Dashboard{} + obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal) + if err != nil { + return nil, err + } + + err = scheme.Convert(obj, desiredObj, nil) + return desiredObj, err + }), + } + // Split dashboards when they are large var largeObjects apistore.LargeObjectSupport if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) { largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme) - opts.StorageOptions(dash.GroupResource(), apistore.StorageOptions{ - LargeObjectSupport: largeObjects, - }) + storageOpts.LargeObjectSupport = largeObjects } + opts.StorageOptions(dash.GroupResource(), storageOpts) storage := map[string]rest.Storage{} storage[dash.StoragePath()] = legacyStore diff --git a/pkg/registry/apis/dashboard/v1alpha1/register.go b/pkg/registry/apis/dashboard/v1alpha1/register.go index 74b0d08c684..0744ba56573 100644 --- a/pkg/registry/apis/dashboard/v1alpha1/register.go +++ b/pkg/registry/apis/dashboard/v1alpha1/register.go @@ -10,6 +10,7 @@ import ( "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" + dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard" dashboardv1alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" @@ -112,14 +113,30 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver return err } + defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{}) + if err != nil { + return err + } + storageOpts := apistore.StorageOptions{ + InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) { + internal := &dashboardinternal.Dashboard{} + obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal) + if err != nil { + return nil, err + } + + err = scheme.Convert(obj, desiredObj, nil) + return desiredObj, err + }), + } + // Split dashboards when they are large var largeObjects apistore.LargeObjectSupport if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) { largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme) - opts.StorageOptions(dash.GroupResource(), apistore.StorageOptions{ - LargeObjectSupport: largeObjects, - }) + storageOpts.LargeObjectSupport = largeObjects } + opts.StorageOptions(dash.GroupResource(), storageOpts) storage := map[string]rest.Storage{} storage[dash.StoragePath()] = legacyStore diff --git a/pkg/registry/apis/dashboard/v2alpha1/register.go b/pkg/registry/apis/dashboard/v2alpha1/register.go index 6195651cb0e..0484201f79c 100644 --- a/pkg/registry/apis/dashboard/v2alpha1/register.go +++ b/pkg/registry/apis/dashboard/v2alpha1/register.go @@ -10,6 +10,7 @@ import ( "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" + dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard" dashboardv2alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" @@ -112,14 +113,30 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver return err } + defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{}) + if err != nil { + return err + } + storageOpts := apistore.StorageOptions{ + InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) { + internal := &dashboardinternal.Dashboard{} + obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal) + if err != nil { + return nil, err + } + + err = scheme.Convert(obj, desiredObj, nil) + return desiredObj, err + }), + } + // Split dashboards when they are large var largeObjects apistore.LargeObjectSupport if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) { largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme) - opts.StorageOptions(dash.GroupResource(), apistore.StorageOptions{ - LargeObjectSupport: largeObjects, - }) + storageOpts.LargeObjectSupport = largeObjects } + opts.StorageOptions(dash.GroupResource(), storageOpts) storage := map[string]rest.Storage{} storage[dash.StoragePath()] = legacyStore diff --git a/pkg/server/module_server_test.go b/pkg/server/module_server_test.go index 7292f3cda8d..272fe56c53d 100644 --- a/pkg/server/module_server_test.go +++ b/pkg/server/module_server_test.go @@ -35,8 +35,8 @@ func TestIntegrationWillRunInstrumentationServerWhenTargetHasNoHttpServer(t *tes _, cfg := db.InitTestDBWithCfg(t) cfg.HTTPPort = "3001" - cfg.GRPCServerNetwork = "tcp" - cfg.GRPCServerAddress = "localhost:10000" + cfg.GRPCServer.Network = "tcp" + cfg.GRPCServer.Address = "localhost:10000" addStorageServerToConfig(t, cfg, dbType) cfg.Target = []string{modules.StorageServer} diff --git a/pkg/services/accesscontrol/dualwrite/collectors.go b/pkg/services/accesscontrol/dualwrite/collectors.go index a48219bab0a..97d5c6ee6f4 100644 --- a/pkg/services/accesscontrol/dualwrite/collectors.go +++ b/pkg/services/accesscontrol/dualwrite/collectors.go @@ -115,7 +115,7 @@ func folderTreeCollector(store db.DB) legacyTupleCollector { func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector { return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { query := ` - SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id + SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id, br.role as basic_role_name FROM permission p INNER JOIN role r ON p.role_id = r.id LEFT JOIN user_role ur ON r.id = ur.role_id @@ -128,12 +128,12 @@ func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector AND p.kind = ? ` type Permission struct { - RoleName string `xorm:"role_name"` - Action string `xorm:"action"` - Kind string - Identifier string - UserUID string `xorm:"user_uid"` - TeamUID string `xorm:"team_uid"` + Action string `xorm:"action"` + Kind string + Identifier string + UserUID string `xorm:"user_uid"` + TeamUID string `xorm:"team_uid"` + BasicRoleName string `xorm:"basic_role_name"` } var permissions []Permission @@ -152,10 +152,9 @@ func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector if len(p.UserUID) > 0 { subject = zanzana.NewTupleEntry(zanzana.TypeUser, p.UserUID, "") } else if len(p.TeamUID) > 0 { - subject = zanzana.NewTupleEntry(zanzana.TypeTeam, p.TeamUID, "member") + subject = zanzana.NewTupleEntry(zanzana.TypeTeam, p.TeamUID, zanzana.RelationTeamMember) } else { - // FIXME(kalleep): Unsuported role binding (org role). We need to have basic roles in place - continue + subject = zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole(p.BasicRoleName), zanzana.RelationAssignee) } tuple, ok := zanzana.TranslateToResourceTuple(subject, p.Action, p.Kind, p.Identifier) @@ -195,53 +194,7 @@ func tupleStringWithoutCondition(tuple *openfgav1.TupleKey) string { return s } -// basicRolePermissionsCollector collects permissions for basic roles -func basicRolePermissionsCollector(store db.DB) legacyTupleCollector { - return func(ctx context.Context, _ int64) (map[string]map[string]*openfgav1.TupleKey, error) { - const query = ` - SELECT r.uid as role_uid, p.action, p.kind, p.identifier - FROM permission p - INNER JOIN role r ON p.role_id = r.id - LEFT JOIN builtin_role br ON r.id = br.role_id - WHERE r.name LIKE 'basic:%' - ` - type Permission struct { - Action string `xorm:"action"` - Kind string - Identifier string - RoleUID string `xorm:"role_uid"` - } - - var permissions []Permission - err := store.WithDbSession(ctx, func(sess *db.Session) error { - return sess.SQL(query).Find(&permissions) - }) - if err != nil { - return nil, err - } - - tuples := make(map[string]map[string]*openfgav1.TupleKey) - - for _, p := range permissions { - subject := zanzana.NewTupleEntry(zanzana.TypeRole, p.RoleUID, "assignee") - - tuple, ok := zanzana.TranslateToResourceTuple(subject, p.Action, p.Kind, p.Identifier) - if !ok { - continue - } - - if tuples[tuple.Object] == nil { - tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey) - } - - tuples[tuple.Object][tuple.String()] = tuple - } - - return tuples, nil - } -} - -// basicRoleBindingsCollects collects role bindings for basic roles +// basicRoleBindingsCollector collects role bindings for basic roles func basicRoleBindingsCollector(store db.DB) legacyTupleCollector { return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { query := ` @@ -269,10 +222,8 @@ func basicRoleBindingsCollector(store db.DB) legacyTupleCollector { tuples := make(map[string]map[string]*openfgav1.TupleKey) for _, b := range bindings { - subject := zanzana.NewTupleEntry(zanzana.TypeUser, b.UserUID, "") - tuple := &openfgav1.TupleKey{ - User: subject, + User: zanzana.NewTupleEntry(zanzana.TypeUser, b.UserUID, ""), Relation: zanzana.RelationAssignee, Object: zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole(b.OrgRole), ""), } @@ -288,6 +239,144 @@ func basicRoleBindingsCollector(store db.DB) legacyTupleCollector { } } +func teamRoleBindingsCollector(store db.DB) legacyTupleCollector { + return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { + query := ` + SELECT t.uid AS team_uid, r.uid AS role_uid + FROM team_role tr + INNER JOIN team t ON tr.team_id = t.id + INNER JOIN role r ON tr.role_id = r.id + WHERE t.org_id = ? + AND r.name NOT LIKE 'managed:%' + ` + type Binding struct { + TeamUID string `xorm:"team_uid"` + RoleUID string `xorm:"role_uid"` + } + + var bindings []Binding + err := store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.SQL(query, orgID).Find(&bindings) + }) + + if err != nil { + return nil, err + } + + tuples := make(map[string]map[string]*openfgav1.TupleKey) + + for _, b := range bindings { + tuple := &openfgav1.TupleKey{ + User: zanzana.NewTupleEntry(zanzana.TypeTeam, b.TeamUID, zanzana.RelationTeamMember), + Relation: zanzana.RelationAssignee, + Object: zanzana.NewTupleEntry(zanzana.TypeRole, b.RoleUID, ""), + } + + if tuples[tuple.Object] == nil { + tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey) + } + + tuples[tuple.Object][tuple.String()] = tuple + } + + return tuples, nil + } +} + +func userRoleBindingsCollector(store db.DB) legacyTupleCollector { + return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { + query := ` + SELECT u.uid AS user_uid, r.uid AS role_uid + FROM user_role ur + INNER JOIN ` + store.GetDialect().Quote("user") + ` u ON ur.user_id = u.id + INNER JOIN role r ON ur.role_id = r.id + WHERE (ur.org_id = 0 OR ur.org_id = ?) + AND r.name NOT LIKE 'managed:%' + ` + type Binding struct { + UserUID string `xorm:"user_uid"` + RoleUID string `xorm:"role_uid"` + } + + var bindings []Binding + err := store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.SQL(query, orgID).Find(&bindings) + }) + + if err != nil { + return nil, err + } + + tuples := make(map[string]map[string]*openfgav1.TupleKey) + + for _, b := range bindings { + tuple := &openfgav1.TupleKey{ + User: zanzana.NewTupleEntry(zanzana.TypeUser, b.UserUID, ""), + Relation: zanzana.RelationAssignee, + Object: zanzana.NewTupleEntry(zanzana.TypeRole, b.RoleUID, ""), + } + + if tuples[tuple.Object] == nil { + tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey) + } + + tuples[tuple.Object][tuple.String()] = tuple + } + + return tuples, nil + } +} + +func rolePermissionsCollector(store db.DB) legacyTupleCollector { + return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { + var query = ` + SELECT r.uid as role_uid, p.action, p.kind, p.identifier + FROM permission p + INNER JOIN role r ON p.role_id = r.id + LEFT JOIN builtin_role br ON r.id = br.role_id + WHERE (r.org_id = 0 OR r.org_id = ?) + AND r.name NOT LIKE 'managed:%' + ` + + type Permission struct { + Action string `xorm:"action"` + Kind string + Identifier string + RoleUID string `xorm:"role_uid"` + } + + var permissions []Permission + err := store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.SQL(query, orgID).Find(&permissions) + }) + if err != nil { + return nil, err + } + + tuples := make(map[string]map[string]*openfgav1.TupleKey) + + for _, p := range permissions { + tuple, ok := zanzana.TranslateToResourceTuple( + zanzana.NewTupleEntry(zanzana.TypeRole, p.RoleUID, zanzana.RelationAssignee), + p.Action, + p.Kind, + p.Identifier, + ) + if !ok { + continue + } + + if tuples[tuple.Object] == nil { + tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey) + } + + tuples[tuple.Object][tuple.String()] = tuple + } + + return tuples, nil + } +} + func zanzanaCollector(relations []string) zanzanaTupleCollector { return func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) { // list will use continuation token to collect all tuples for object and relation diff --git a/pkg/services/accesscontrol/dualwrite/reconciler.go b/pkg/services/accesscontrol/dualwrite/reconciler.go index 82364c8c259..2f1fe07ec2d 100644 --- a/pkg/services/accesscontrol/dualwrite/reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/reconciler.go @@ -65,8 +65,8 @@ func NewZanzanaReconciler(cfg *setting.Cfg, client zanzana.Client, store db.DB, client, ), newResourceReconciler( - "basic role permissions", - basicRolePermissionsCollector(store), + "role permissions", + rolePermissionsCollector(store), zanzanaCollector(zanzana.FolderRelations), client, ), @@ -76,6 +76,18 @@ func NewZanzanaReconciler(cfg *setting.Cfg, client zanzana.Client, store db.DB, zanzanaCollector([]string{zanzana.RelationAssignee}), client, ), + newResourceReconciler( + "team role bindings", + teamRoleBindingsCollector(store), + zanzanaCollector([]string{zanzana.RelationAssignee}), + client, + ), + newResourceReconciler( + "user role bindings", + userRoleBindingsCollector(store), + zanzanaCollector([]string{zanzana.RelationAssignee}), + client, + ), }, } } diff --git a/pkg/services/apiserver/auth/authorizer/org_role.go b/pkg/services/apiserver/auth/authorizer/org_role.go index c79be633df6..017fb0c7b9b 100644 --- a/pkg/services/apiserver/auth/authorizer/org_role.go +++ b/pkg/services/apiserver/auth/authorizer/org_role.go @@ -37,15 +37,13 @@ func (auth orgRoleAuthorizer) Authorize(ctx context.Context, a authorizer.Attrib default: return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } - case org.RoleViewer: + case org.RoleViewer, org.RoleNone: switch a.GetVerb() { case "get", "list", "watch": return authorizer.DecisionAllow, "", nil default: return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } - case org.RoleNone: - return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } return authorizer.DecisionDeny, "", nil } diff --git a/pkg/services/apiserver/standalone/runtime.go b/pkg/services/apiserver/standalone/runtime.go index 7b8abcb6d93..f2284356d70 100644 --- a/pkg/services/apiserver/standalone/runtime.go +++ b/pkg/services/apiserver/standalone/runtime.go @@ -17,7 +17,7 @@ func (a RuntimeConfig) String() string { // Supported options are: // -// /=true|false for a specific API group and version (e.g. dashboards.grafana.app/v0alpha1=true) +// /=true|false for a specific API group and version (e.g. dashboard.grafana.app/v0alpha1=true) // api/all=true|false controls all API versions // api/ga=true|false controls all API versions of the form v[0-9]+ // api/beta=true|false controls all API versions of the form v[0-9]+beta[0-9]+ diff --git a/pkg/services/apiserver/standalone/runtime_test.go b/pkg/services/apiserver/standalone/runtime_test.go index 374920cfd0e..e07db7b62a6 100644 --- a/pkg/services/apiserver/standalone/runtime_test.go +++ b/pkg/services/apiserver/standalone/runtime_test.go @@ -8,11 +8,11 @@ import ( ) func TestReadRuntimeCOnfig(t *testing.T) { - out, err := ReadRuntimeConfig("all/all=true,dashboards.grafana.app/v0alpha1=false") + out, err := ReadRuntimeConfig("all/all=true,dashboard.grafana.app/v0alpha1=false") require.NoError(t, err) require.Equal(t, []RuntimeConfig{ {Group: "all", Version: "all", Enabled: true}, - {Group: "dashboards.grafana.app", Version: "v0alpha1", Enabled: false}, + {Group: "dashboard.grafana.app", Version: "v0alpha1", Enabled: false}, }, out) require.Equal(t, "all/all=true", fmt.Sprintf("%v", out[0])) diff --git a/pkg/services/authz/client.go b/pkg/services/authz/client.go index d0074e9e7d2..a692abdcc9c 100644 --- a/pkg/services/authz/client.go +++ b/pkg/services/authz/client.go @@ -88,7 +88,10 @@ func ProvideStandaloneAuthZClient( return nil, err } - return newGrpcLegacyClient(authCfg) + if cfg.StackID == "" { + return newGrpcLegacyClient(authCfg) + } + return newCloudLegacyClient(authCfg) } func newInProcLegacyClient(server *legacyServer) (authzlib.AccessChecker, error) { diff --git a/pkg/services/authz/mappers/rbac_mapper.go b/pkg/services/authz/mappers/rbac_mapper.go index e12e5d0ebcc..fa9a1a1a4eb 100644 --- a/pkg/services/authz/mappers/rbac_mapper.go +++ b/pkg/services/authz/mappers/rbac_mapper.go @@ -2,10 +2,10 @@ package mappers type VerbToAction map[string]string // e.g. "get" -> "read" type ResourceVerbToAction map[string]VerbToAction // e.g. "dashboards" -> VerbToAction -type GroupResourceVerbToAction map[string]ResourceVerbToAction // e.g. "dashboards.grafana.app" -> ResourceVerbToAction +type GroupResourceVerbToAction map[string]ResourceVerbToAction // e.g. "dashboard.grafana.app" -> ResourceVerbToAction type ResourceToAttribute map[string]string // e.g. "dashboards" -> "uid" -type GroupResourceToAttribute map[string]ResourceToAttribute // e.g. "dashboards.grafana.app" -> ResourceToAttribute +type GroupResourceToAttribute map[string]ResourceToAttribute // e.g. "dashboard.grafana.app" -> ResourceToAttribute type K8sRbacMapper struct { DefaultActions VerbToAction @@ -28,8 +28,8 @@ func NewK8sRbacMapper() *K8sRbacMapper { }, DefaultAttribute: "uid", Actions: GroupResourceVerbToAction{ - "dashboards.grafana.app": ResourceVerbToAction{"dashboards": VerbToAction{}}, - "folders.grafana.app": ResourceVerbToAction{"folders": VerbToAction{}}, + "dashboard.grafana.app": ResourceVerbToAction{"dashboards": VerbToAction{}}, + "folder.grafana.app": ResourceVerbToAction{"folders": VerbToAction{}}, }, } } diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go new file mode 100644 index 00000000000..c73fe9965b4 --- /dev/null +++ b/pkg/services/authz/rbac/service.go @@ -0,0 +1,37 @@ +package rbac + +import ( + authzv1 "github.com/grafana/authlib/authz/proto/v1" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/authz/rbac/store" + "github.com/grafana/grafana/pkg/storage/legacysql" +) + +type Service struct { + authzv1.UnimplementedAuthzServiceServer + authzextv1.UnimplementedAuthzExtentionServiceServer + + store *store.Store + logger log.Logger + tracer tracing.Tracer +} + +func NewService(sql legacysql.LegacyDatabaseProvider, logger log.Logger, tracer tracing.Tracer) *Service { + return &Service{ + store: store.NewStore(sql), + logger: logger, + tracer: tracer, + } +} + +// TODO: Implement Check +// func (s *Service) Check(ctx context.Context, req *authzv1.CheckRequest) (*authzv1.CheckResponse, error) { +// This needs to be done for the database provider +// ns := req.GetNamespace() +// ctx = request.WithNamespace(ctx, ns) + +// return nil, nil +// } diff --git a/pkg/services/authz/rbac/store/store.go b/pkg/services/authz/rbac/store/store.go new file mode 100644 index 00000000000..3ba0971f60a --- /dev/null +++ b/pkg/services/authz/rbac/store/store.go @@ -0,0 +1,15 @@ +package store + +import "github.com/grafana/grafana/pkg/storage/legacysql" + +// TODO (gamab): Implement GetRoles, GetTeams, GetFolders, GetPermissions + +type Store struct { + sql legacysql.LegacyDatabaseProvider +} + +func NewStore(sql legacysql.LegacyDatabaseProvider) *Store { + return &Store{ + sql: sql, + } +} diff --git a/pkg/services/authz/server.go b/pkg/services/authz/server.go index 3ac60ebc5e0..8c471b9f8d0 100644 --- a/pkg/services/authz/server.go +++ b/pkg/services/authz/server.go @@ -16,11 +16,22 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authz/mappers" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/rbac" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/grpcserver" + "github.com/grafana/grafana/pkg/storage/legacysql" ) +func RegisterRBACAuthZService(handler grpcserver.Provider, db legacysql.LegacyDatabaseProvider, tracer tracing.Tracer) { + server := rbac.NewService(db, log.New("authz-grpc-server"), tracer) + + srv := handler.GetServer() + authzv1.RegisterAuthzServiceServer(srv, server) + authzextv1.RegisterAuthzExtentionServiceServer(srv, server) +} + var _ authzv1.AuthzServiceServer = (*legacyServer)(nil) var _ grpc_auth.ServiceAuthFuncOverride = (*legacyServer)(nil) var _ authzlib.ServiceAuthorizeFuncOverride = (*legacyServer)(nil) diff --git a/pkg/services/authz/server_test.go b/pkg/services/authz/server_test.go index 4f5514b1aa2..1bd617ce6ed 100644 --- a/pkg/services/authz/server_test.go +++ b/pkg/services/authz/server_test.go @@ -60,7 +60,7 @@ func Test_legacyServer_Check(t *testing.T) { req: &authzv1.CheckRequest{ Subject: "user:1", Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", Namespace: "org-2", @@ -74,7 +74,7 @@ func Test_legacyServer_Check(t *testing.T) { req: &authzv1.CheckRequest{ Subject: "user:1", Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", Namespace: "org-2", @@ -88,7 +88,7 @@ func Test_legacyServer_Check(t *testing.T) { req: &authzv1.CheckRequest{ Subject: "user:1", Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", Namespace: "org-2", @@ -106,7 +106,7 @@ func Test_legacyServer_Check(t *testing.T) { req: &authzv1.CheckRequest{ Subject: "user:1", Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Namespace: "org-2", }, @@ -131,7 +131,7 @@ func Test_legacyServer_Check(t *testing.T) { req: &authzv1.CheckRequest{ Subject: "user:1", Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Name: "dash1", Namespace: "org-2", }, @@ -141,7 +141,7 @@ func Test_legacyServer_Check(t *testing.T) { name: "should return error when verb is not set", req: &authzv1.CheckRequest{ Subject: "user:1", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", Namespace: "org-2", @@ -152,7 +152,7 @@ func Test_legacyServer_Check(t *testing.T) { name: "should return error when subject is not set", req: &authzv1.CheckRequest{ Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", Namespace: "org-2", @@ -164,7 +164,7 @@ func Test_legacyServer_Check(t *testing.T) { req: &authzv1.CheckRequest{ Subject: "user:1", Verb: "get", - Group: "dashboards.grafana.app", + Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", Namespace: "stacks-2", diff --git a/pkg/services/authz/zanzana/common/info.go b/pkg/services/authz/zanzana/common/info.go index 3d2521875e8..ad5be54940a 100644 --- a/pkg/services/authz/zanzana/common/info.go +++ b/pkg/services/authz/zanzana/common/info.go @@ -11,14 +11,14 @@ type TypeInfo struct { } var typedResources = map[string]TypeInfo{ - NewNamespaceResourceIdent( + FormatGroupResource( folderalpha1.FolderResourceInfo.GroupResource().Group, folderalpha1.FolderResourceInfo.GroupResource().Resource, ): {Type: "folder"}, } func GetTypeInfo(group, resource string) (TypeInfo, bool) { - info, ok := typedResources[NewNamespaceResourceIdent(group, resource)] + info, ok := typedResources[FormatGroupResource(group, resource)] return info, ok } diff --git a/pkg/services/authz/zanzana/server/server.go b/pkg/services/authz/zanzana/server/server.go index 27f12cf6aad..faea365502d 100644 --- a/pkg/services/authz/zanzana/server/server.go +++ b/pkg/services/authz/zanzana/server/server.go @@ -37,8 +37,8 @@ type Server struct { } type storeInfo struct { - Id string - AuthorizationModelId string + ID string + ModelID string } type ServerOption func(s *Server) diff --git a/pkg/services/authz/zanzana/server/server_batch_check.go b/pkg/services/authz/zanzana/server/server_batch_check.go index 22b6b3ecec1..7c96df5ae79 100644 --- a/pkg/services/authz/zanzana/server/server_batch_check.go +++ b/pkg/services/authz/zanzana/server/server_batch_check.go @@ -17,48 +17,58 @@ func (s *Server) BatchCheck(ctx context.Context, r *authzextv1.BatchCheckRequest Groups: make(map[string]*authzextv1.BatchCheckGroupResource), } - subject := r.GetSubject() + store, err := s.getStoreInfo(ctx, r.GetNamespace()) + if err != nil { + return nil, err + } - for _, item := range r.Items { - groupPrefix := common.FormatGroupResource(item.GetGroup(), item.GetResource()) - allowed, err := s.batchCheckItem(ctx, subject, r.Namespace, item) + groupResourceAccess := make(map[string]bool) + + for _, item := range r.GetItems() { + res, err := s.batchCheckItem(ctx, r, item, store, groupResourceAccess) if err != nil { return nil, err } - if _, ok := batchRes.Groups[groupPrefix]; !ok { - batchRes.Groups[groupPrefix] = &authzextv1.BatchCheckGroupResource{ + groupResource := common.FormatGroupResource(item.GetGroup(), item.GetResource()) + if _, ok := batchRes.Groups[groupResource]; !ok { + batchRes.Groups[groupResource] = &authzextv1.BatchCheckGroupResource{ Items: make(map[string]bool), } } - batchRes.Groups[groupPrefix].Items[item.GetName()] = allowed + batchRes.Groups[groupResource].Items[item.GetName()] = res.GetAllowed() } return batchRes, nil } -func (s *Server) batchCheckItem(ctx context.Context, subject string, namespace string, item *authzextv1.BatchCheckItem) (bool, error) { - req := &authzv1.CheckRequest{ - Namespace: namespace, - Subject: subject, - Verb: item.GetVerb(), - Group: item.GetGroup(), - Resource: item.GetResource(), - Name: item.GetName(), - Folder: item.GetFolder(), - Subresource: item.GetSubresource(), +func (s *Server) batchCheckItem( + ctx context.Context, + r *authzextv1.BatchCheckRequest, + item *authzextv1.BatchCheckItem, + store *storeInfo, + groupResourceAccess map[string]bool, +) (*authzv1.CheckResponse, error) { + var ( + relation = common.VerbMapping[item.GetVerb()] + groupResource = common.FormatGroupResource(item.GetGroup(), item.GetResource()) + ) + + allowed, ok := groupResourceAccess[groupResource] + if !ok { + res, err := s.checkNamespace(ctx, r.GetSubject(), relation, item.GetGroup(), item.GetResource(), store) + if err != nil { + return nil, err + } + groupResourceAccess[groupResource] = res.GetAllowed() + } + + if allowed { + return &authzv1.CheckResponse{Allowed: true}, nil } - var res *authzv1.CheckResponse - var err error if info, ok := common.GetTypeInfo(item.GetGroup(), item.GetResource()); ok { - res, err = s.checkTyped(ctx, req, info) - } else { - res, err = s.checkGeneric(ctx, req) + return s.checkTyped(ctx, r.GetSubject(), relation, item.GetName(), info, store) } - if err != nil { - return false, err - } - - return res.Allowed, nil + return s.checkGeneric(ctx, r.GetSubject(), relation, item.GetGroup(), item.GetResource(), item.GetName(), item.GetFolder(), store) } diff --git a/pkg/services/authz/zanzana/server/server_batch_check_test.go b/pkg/services/authz/zanzana/server/server_batch_check_test.go index 9b64be2651f..70c65b8ed44 100644 --- a/pkg/services/authz/zanzana/server/server_batch_check_test.go +++ b/pkg/services/authz/zanzana/server/server_batch_check_test.go @@ -31,7 +31,7 @@ func newBatch(subject, group, resource string, items []*authzextv1.BatchCheckIte } func testBatchCheck(t *testing.T, server *Server) { - t.Run("user:1 should only be able to read resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) { + t.Run("user:1 should only be able to read resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) { groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) res, err := server.BatchCheck(context.Background(), newBatch("user:1", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ {Name: "1", Folder: "1"}, @@ -44,7 +44,7 @@ func testBatchCheck(t *testing.T, server *Server) { assert.False(t, res.Groups[groupPrefix].Items["2"]) }) - t.Run("user:2 should be able to read resource:dashboards.grafana.app/dashboards/{1,2} through namespace", func(t *testing.T) { + t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/{1,2} through namespace", func(t *testing.T) { groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) res, err := server.BatchCheck(context.Background(), newBatch("user:2", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ {Name: "1", Folder: "1"}, @@ -54,7 +54,7 @@ func testBatchCheck(t *testing.T, server *Server) { assert.Len(t, res.Groups[groupPrefix].Items, 2) }) - t.Run("user:3 should be able to read resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) { + t.Run("user:3 should be able to read resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) { groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) res, err := server.BatchCheck(context.Background(), newBatch("user:3", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ {Name: "1", Folder: "1"}, @@ -67,7 +67,7 @@ func testBatchCheck(t *testing.T, server *Server) { assert.False(t, res.Groups[groupPrefix].Items["2"]) }) - t.Run("user:4 should be able to read all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { + t.Run("user:4 should be able to read all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) res, err := server.BatchCheck(context.Background(), newBatch("user:4", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ {Name: "1", Folder: "1"}, @@ -82,7 +82,7 @@ func testBatchCheck(t *testing.T, server *Server) { assert.False(t, res.Groups[groupPrefix].Items["3"]) }) - t.Run("user:5 should be able to read resource:dashboards.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) { + t.Run("user:5 should be able to read resource:dashboard.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) { groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource) res, err := server.BatchCheck(context.Background(), newBatch("user:5", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{ {Name: "1", Folder: "1"}, diff --git a/pkg/services/authz/zanzana/server/server_check.go b/pkg/services/authz/zanzana/server/server_check.go index f0e78045292..cad15cb17ef 100644 --- a/pkg/services/authz/zanzana/server/server_check.go +++ b/pkg/services/authz/zanzana/server/server_check.go @@ -14,28 +14,39 @@ func (s *Server) Check(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.C ctx, span := tracer.Start(ctx, "authzServer.Check") defer span.End() - if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok { - return s.checkTyped(ctx, r, info) - } - return s.checkGeneric(ctx, r) -} - -// checkNamespace checks if subject has access through namespace -func (s *Server) checkNamespace(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) { - storeInf, err := s.getStoreInfo(ctx, r.Namespace) + store, err := s.getStoreInfo(ctx, r.GetNamespace()) if err != nil { return nil, err } relation := common.VerbMapping[r.GetVerb()] + // Check if subject has access through namespace + res, err := s.checkNamespace(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store) + if err != nil { + return nil, err + } + + if res.GetAllowed() { + return res, nil + } + + if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok { + return s.checkTyped(ctx, r.GetSubject(), relation, r.GetName(), info, store) + } + return s.checkGeneric(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), r.GetName(), r.GetFolder(), store) +} + +// checkTyped performes check on the root "namespace". If subject has access through the namespace they have access to +// every resource for that "GroupResource". +func (s *Server) checkNamespace(ctx context.Context, subject, relation, group, resource string, store *storeInfo) (*authzv1.CheckResponse, error) { res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), + User: subject, Relation: relation, - Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()), + Object: common.NewNamespaceResourceIdent(group, resource), }, }) if err != nil { @@ -45,22 +56,16 @@ func (s *Server) checkNamespace(ctx context.Context, r *authzv1.CheckRequest) (* return &authzv1.CheckResponse{Allowed: res.GetAllowed()}, nil } -func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info common.TypeInfo) (*authzv1.CheckResponse, error) { - storeInf, err := s.getStoreInfo(ctx, r.Namespace) - if err != nil { - return nil, err - } - - relation := common.VerbMapping[r.GetVerb()] - - // 1. check if subject has direct access to resource +// checkTyped performes checks on our typed resources e.g. folder. +func (s *Server) checkTyped(ctx context.Context, subject, relation, name string, info common.TypeInfo, store *storeInfo) (*authzv1.CheckResponse, error) { + // Check if subject has direct access to resource res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), + User: subject, Relation: relation, - Object: common.NewTypedIdent(info.Type, r.GetName()), + Object: common.NewTypedIdent(info.Type, name), }, }) if err != nil { @@ -71,40 +76,30 @@ func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info c return &authzv1.CheckResponse{Allowed: true}, nil } - // 2. check if subject has access through namespace - nsRes, err := s.checkNamespace(ctx, r) - if err != nil { - return nil, err - } - - return &authzv1.CheckResponse{Allowed: nsRes.GetAllowed()}, nil + return &authzv1.CheckResponse{Allowed: false}, nil } -func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) { - storeInf, err := s.getStoreInfo(ctx, r.Namespace) - if err != nil { - return nil, err - } +// checkGeneric check our generic "resource" type. +func (s *Server) checkGeneric(ctx context.Context, subject, relation, group, resource, name, folder string, store *storeInfo) (*authzv1.CheckResponse, error) { + groupResource := structpb.NewStringValue(common.FormatGroupResource(group, resource)) - relation := common.VerbMapping[r.GetVerb()] - // 1. check if subject has direct access to resource + // Check if subject has direct access to resource res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), + User: subject, Relation: relation, - Object: common.NewResourceIdent(r.GetGroup(), r.GetResource(), r.GetName()), + Object: common.NewResourceIdent(group, resource, name), }, Context: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())), + "requested_group": groupResource, }, }, }) if err != nil { - // FIXME: wrap error return nil, err } @@ -112,32 +107,22 @@ func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*au return &authzv1.CheckResponse{Allowed: true}, nil } - // 2. check if subject has access through namespace - nsRes, err := s.checkNamespace(ctx, r) - if err != nil { - return nil, err - } - - if nsRes.GetAllowed() { - return &authzv1.CheckResponse{Allowed: true}, nil - } - - if r.Folder == "" { + if folder == "" { return &authzv1.CheckResponse{Allowed: false}, nil } - // 3. check if subject has access as a sub resource for the folder + // Check if subject has access as a sub resource for the folder res, err = s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), + User: subject, Relation: common.FolderResourceRelation(relation), - Object: common.NewFolderIdent(r.GetFolder()), + Object: common.NewFolderIdent(folder), }, Context: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())), + "requested_group": groupResource, }, }, }) diff --git a/pkg/services/authz/zanzana/server/server_check_test.go b/pkg/services/authz/zanzana/server/server_check_test.go index 78fa4609dbb..72f80d5cea5 100644 --- a/pkg/services/authz/zanzana/server/server_check_test.go +++ b/pkg/services/authz/zanzana/server/server_check_test.go @@ -24,7 +24,7 @@ func testCheck(t *testing.T, server *Server) { } } - t.Run("user:1 should only be able to read resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) { + t.Run("user:1 should only be able to read resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) { res, err := server.Check(context.Background(), newRead("user:1", dashboardGroup, dashboardResource, "1", "1")) require.NoError(t, err) assert.True(t, res.GetAllowed()) @@ -35,13 +35,13 @@ func testCheck(t *testing.T, server *Server) { assert.False(t, res.GetAllowed()) }) - t.Run("user:2 should be able to read resource:dashboards.grafana.app/dashboards/1 through namespace", func(t *testing.T) { + t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/1 through namespace", func(t *testing.T) { res, err := server.Check(context.Background(), newRead("user:2", dashboardGroup, dashboardResource, "1", "1")) require.NoError(t, err) assert.True(t, res.GetAllowed()) }) - t.Run("user:3 should be able to read resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) { + t.Run("user:3 should be able to read resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) { res, err := server.Check(context.Background(), newRead("user:3", dashboardGroup, dashboardResource, "1", "1")) require.NoError(t, err) assert.True(t, res.GetAllowed()) @@ -52,7 +52,7 @@ func testCheck(t *testing.T, server *Server) { assert.False(t, res.GetAllowed()) }) - t.Run("user:4 should be able to read all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { + t.Run("user:4 should be able to read all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { res, err := server.Check(context.Background(), newRead("user:4", dashboardGroup, dashboardResource, "1", "1")) require.NoError(t, err) assert.True(t, res.GetAllowed()) @@ -71,7 +71,7 @@ func testCheck(t *testing.T, server *Server) { assert.False(t, res.GetAllowed()) }) - t.Run("user:5 should be able to read resource:dashboards.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) { + t.Run("user:5 should be able to read resource:dashboard.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) { res, err := server.Check(context.Background(), newRead("user:5", dashboardGroup, dashboardResource, "1", "1")) require.NoError(t, err) assert.True(t, res.GetAllowed()) diff --git a/pkg/services/authz/zanzana/server/server_list.go b/pkg/services/authz/zanzana/server/server_list.go index a874ac5feee..2ee7f720370 100644 --- a/pkg/services/authz/zanzana/server/server_list.go +++ b/pkg/services/authz/zanzana/server/server_list.go @@ -16,31 +16,22 @@ func (s *Server) List(ctx context.Context, r *authzextv1.ListRequest) (*authzext ctx, span := tracer.Start(ctx, "authzServer.List") defer span.End() - if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok { - return s.listTyped(ctx, r, info) - } - - return s.listGeneric(ctx, r) -} - -func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info common.TypeInfo) (*authzextv1.ListResponse, error) { - storeInf, err := s.getStoreInfo(ctx, r.Namespace) + store, err := s.getStoreInfo(ctx, r.Namespace) if err != nil { return nil, err } relation := common.VerbMapping[r.GetVerb()] - // 1. check if subject has access through namespace because then they can read all of them - res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, - TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), - Relation: relation, - Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()), - }, - }) + res, err := s.checkNamespace( + ctx, + r.GetSubject(), + relation, + r.GetGroup(), + r.GetResource(), + store, + ) + if err != nil { return nil, err } @@ -49,13 +40,21 @@ func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info return &authzextv1.ListResponse{All: true}, nil } - // 2. List all resources user has access too + if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok { + return s.listTyped(ctx, r.GetSubject(), relation, info, store) + } + + return s.listGeneric(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store) +} + +func (s *Server) listTyped(ctx context.Context, subject, relation string, info common.TypeInfo, store *storeInfo) (*authzextv1.ListResponse, error) { + // List all resources user has access too listRes, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, Type: info.Type, Relation: relation, - User: r.GetSubject(), + User: subject, }) if err != nil { return nil, err @@ -66,42 +65,19 @@ func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info }, nil } -func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*authzextv1.ListResponse, error) { - storeInf, err := s.getStoreInfo(ctx, r.Namespace) - if err != nil { - return nil, err - } +func (s *Server) listGeneric(ctx context.Context, subject, relation, group, resource string, store *storeInfo) (*authzextv1.ListResponse, error) { + groupResource := structpb.NewStringValue(common.FormatGroupResource(group, resource)) - relation := common.VerbMapping[r.GetVerb()] - - // 1. check if subject has access through namespace because then they can read all of them - res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, - TupleKey: &openfgav1.CheckRequestTupleKey{ - User: r.GetSubject(), - Relation: relation, - Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()), - }, - }) - if err != nil { - return nil, err - } - - if res.Allowed { - return &authzextv1.ListResponse{All: true}, nil - } - - // 2. List all folders subject has access to resource type in + // 1. List all folders subject has access to resource type in folders, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, Type: common.TypeFolder, Relation: common.FolderResourceRelation(relation), - User: r.GetSubject(), + User: subject, Context: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())), + "requested_group": groupResource, }, }, }) @@ -109,16 +85,16 @@ func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*a return nil, err } - // 3. List all resource directly assigned to subject + // 2. List all resource directly assigned to subject direct, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: store.ID, + AuthorizationModelId: store.ModelID, Type: common.TypeResource, Relation: relation, - User: r.GetSubject(), + User: subject, Context: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())), + "requested_group": groupResource, }, }, }) @@ -128,7 +104,7 @@ func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*a return &authzextv1.ListResponse{ Folders: folderObject(folders.GetObjects()), - Items: directObjects(r.GetGroup(), r.GetResource(), direct.GetObjects()), + Items: directObjects(group, resource, direct.GetObjects()), }, nil } diff --git a/pkg/services/authz/zanzana/server/server_list_test.go b/pkg/services/authz/zanzana/server/server_list_test.go index cc7ef17126c..778f286be3e 100644 --- a/pkg/services/authz/zanzana/server/server_list_test.go +++ b/pkg/services/authz/zanzana/server/server_list_test.go @@ -22,7 +22,7 @@ func testList(t *testing.T, server *Server) { } } - t.Run("user:1 should list resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) { + t.Run("user:1 should list resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) { res, err := server.List(context.Background(), newList("user:1", dashboardGroup, dashboardResource)) require.NoError(t, err) assert.Len(t, res.GetItems(), 1) @@ -38,7 +38,7 @@ func testList(t *testing.T, server *Server) { assert.Len(t, res.GetFolders(), 0) }) - t.Run("user:3 should be able to list resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) { + t.Run("user:3 should be able to list resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) { res, err := server.List(context.Background(), newList("user:3", dashboardGroup, dashboardResource)) require.NoError(t, err) @@ -47,7 +47,7 @@ func testList(t *testing.T, server *Server) { assert.Equal(t, res.GetItems()[0], "1") }) - t.Run("user:4 should be able to list all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { + t.Run("user:4 should be able to list all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) { res, err := server.List(context.Background(), newList("user:4", dashboardGroup, dashboardResource)) require.NoError(t, err) assert.Len(t, res.GetItems(), 0) @@ -64,7 +64,7 @@ func testList(t *testing.T, server *Server) { assert.Equal(t, second, "3") }) - t.Run("user:5 should be get list all dashboards.grafana.app/dashboards in folder 1 with set relation", func(t *testing.T) { + t.Run("user:5 should be get list all dashboard.grafana.app/dashboards in folder 1 with set relation", func(t *testing.T) { res, err := server.List(context.Background(), newList("user:5", dashboardGroup, dashboardResource)) require.NoError(t, err) assert.Len(t, res.GetItems(), 0) diff --git a/pkg/services/authz/zanzana/server/server_read.go b/pkg/services/authz/zanzana/server/server_read.go index eafecdf5f76..83fec6f15f7 100644 --- a/pkg/services/authz/zanzana/server/server_read.go +++ b/pkg/services/authz/zanzana/server/server_read.go @@ -19,7 +19,7 @@ func (s *Server) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authze } res, err := s.openfga.Read(ctx, &openfgav1.ReadRequest{ - StoreId: storeInf.Id, + StoreId: storeInf.ID, TupleKey: &openfgav1.ReadRequestTupleKey{ User: req.GetTupleKey().GetUser(), Relation: req.GetTupleKey().GetRelation(), diff --git a/pkg/services/authz/zanzana/server/server_store.go b/pkg/services/authz/zanzana/server/server_store.go index 8af1ca98ef9..7ae0e3113f6 100644 --- a/pkg/services/authz/zanzana/server/server_store.go +++ b/pkg/services/authz/zanzana/server/server_store.go @@ -30,8 +30,8 @@ func (s *Server) getStoreInfo(ctx context.Context, namespace string) (*storeInfo } info = storeInfo{ - Id: store.GetId(), - AuthorizationModelId: modelID, + ID: store.GetId(), + ModelID: modelID, } s.stores[namespace] = info diff --git a/pkg/services/authz/zanzana/server/server_test.go b/pkg/services/authz/zanzana/server/server_test.go index afa49bb6757..5e2d5efb62f 100644 --- a/pkg/services/authz/zanzana/server/server_test.go +++ b/pkg/services/authz/zanzana/server/server_test.go @@ -71,8 +71,8 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server { // seed tuples _, err = openfga.Write(context.Background(), &openfgav1.WriteRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: storeInf.ID, + AuthorizationModelId: storeInf.ModelID, Writes: &openfgav1.WriteRequestWrites{ TupleKeys: []*openfgav1.TupleKey{ common.NewResourceTuple("user:1", "read", dashboardGroup, dashboardResource, "1"), diff --git a/pkg/services/authz/zanzana/server/server_write.go b/pkg/services/authz/zanzana/server/server_write.go index a8c76c0ebe3..ec99e047123 100644 --- a/pkg/services/authz/zanzana/server/server_write.go +++ b/pkg/services/authz/zanzana/server/server_write.go @@ -29,8 +29,8 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth } writeReq := &openfgav1.WriteRequest{ - StoreId: storeInf.Id, - AuthorizationModelId: storeInf.AuthorizationModelId, + StoreId: storeInf.ID, + AuthorizationModelId: storeInf.ModelID, } if len(writeTuples) > 0 { writeReq.Writes = &openfgav1.WriteRequestWrites{ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 9024c5bfe76..5b9c896da78 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1448,9 +1448,10 @@ var ( { Name: "singleTopNav", Description: "Unifies the top search bar and breadcrumb bar into one", - Stage: FeatureStagePublicPreview, + Stage: FeatureStageGeneralAvailability, FrontendOnly: true, Owner: grafanaFrontendPlatformSquad, + Expression: "true", }, { Name: "exploreLogsShardSplitting", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index b51f005ccce..c2c16c42a24 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -190,7 +190,7 @@ dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true, newFiltersUI,experimental,@grafana/dashboards-squad,false,false,false lokiSendDashboardPanelNames,experimental,@grafana/observability-logs,false,false,false alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true -singleTopNav,preview,@grafana/grafana-frontend-platform,false,false,true +singleTopNav,GA,@grafana/grafana-frontend-platform,false,false,true exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index ccd3b13c163..31745d2b3c8 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3117,17 +3117,18 @@ { "metadata": { "name": "singleTopNav", - "resourceVersion": "1731664029942", + "resourceVersion": "1732104041490", "creationTimestamp": "2024-08-29T08:48:32Z", "annotations": { - "grafana.app/updatedTimestamp": "2024-11-15 09:47:09.942158 +0000 UTC" + "grafana.app/updatedTimestamp": "2024-11-20 12:00:41.490792 +0000 UTC" } }, "spec": { "description": "Unifies the top search bar and breadcrumb bar into one", - "stage": "preview", + "stage": "GA", "codeowner": "@grafana/grafana-frontend-platform", - "frontend": true + "frontend": true, + "expression": "true" } }, { diff --git a/pkg/services/grpcserver/interceptors/logging.go b/pkg/services/grpcserver/interceptors/logging.go index 96ceda3fdb2..2a3997a7024 100644 --- a/pkg/services/grpcserver/interceptors/logging.go +++ b/pkg/services/grpcserver/interceptors/logging.go @@ -4,11 +4,10 @@ import ( "context" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/setting" "google.golang.org/grpc" ) -func LoggingUnaryInterceptor(cfg *setting.Cfg, logger log.Logger) grpc.UnaryServerInterceptor { +func LoggingUnaryInterceptor(logger log.Logger, enabled bool) grpc.UnaryServerInterceptor { return func( ctx context.Context, req any, @@ -16,7 +15,7 @@ func LoggingUnaryInterceptor(cfg *setting.Cfg, logger log.Logger) grpc.UnaryServ handler grpc.UnaryHandler, ) (resp any, err error) { resp, err = handler(ctx, req) - if cfg.GRPCServerEnableLogging { + if enabled { ctxLogger := logger.FromContext(ctx) if err != nil { ctxLogger.Error("gRPC call", "method", info.FullMethod, "req", req, "err", err) diff --git a/pkg/services/grpcserver/service.go b/pkg/services/grpcserver/service.go index 543d3488ecb..2fa158f2153 100644 --- a/pkg/services/grpcserver/service.go +++ b/pkg/services/grpcserver/service.go @@ -34,7 +34,7 @@ type Provider interface { } type gPRCServerService struct { - cfg *setting.Cfg + cfg setting.GRPCServerSettings logger log.Logger server *grpc.Server address string @@ -44,9 +44,9 @@ type gPRCServerService struct { func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authenticator interceptors.Authenticator, tracer tracing.Tracer, registerer prometheus.Registerer) (Provider, error) { s := &gPRCServerService{ - cfg: cfg, + cfg: cfg.GRPCServer, logger: log.New("grpc-server"), - enabled: features.IsEnabledGlobally(featuremgmt.FlagGrpcServer), + enabled: features.IsEnabledGlobally(featuremgmt.FlagGrpcServer), // TODO: replace with cfg.GRPCServer.Enabled when we remove feature toggle. startedChan: make(chan struct{}), } @@ -75,7 +75,7 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authe grpc.StatsHandler(otelgrpc.NewServerHandler()), grpc.ChainUnaryInterceptor( grpcAuth.UnaryServerInterceptor(authenticator.Authenticate), - interceptors.LoggingUnaryInterceptor(s.cfg, s.logger), // needs to be registered after tracing interceptor to get trace id + interceptors.LoggingUnaryInterceptor(s.logger, s.cfg.EnableLogging), // needs to be registered after tracing interceptor to get trace id middleware.UnaryServerInstrumentInterceptor(grpcRequestDuration), ), grpc.ChainStreamInterceptor( @@ -85,16 +85,16 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authe ), } - if s.cfg.GRPCServerTLSConfig != nil { - opts = append(opts, grpc.Creds(credentials.NewTLS(cfg.GRPCServerTLSConfig))) + if s.cfg.TLSConfig != nil { + opts = append(opts, grpc.Creds(credentials.NewTLS(s.cfg.TLSConfig))) } - if s.cfg.GRPCServerMaxRecvMsgSize > 0 { - opts = append(opts, grpc.MaxRecvMsgSize(s.cfg.GRPCServerMaxRecvMsgSize)) + if s.cfg.MaxRecvMsgSize > 0 { + opts = append(opts, grpc.MaxRecvMsgSize(s.cfg.MaxRecvMsgSize)) } - if s.cfg.GRPCServerMaxSendMsgSize > 0 { - opts = append(opts, grpc.MaxSendMsgSize(s.cfg.GRPCServerMaxSendMsgSize)) + if s.cfg.MaxSendMsgSize > 0 { + opts = append(opts, grpc.MaxSendMsgSize(s.cfg.MaxSendMsgSize)) } s.server = grpc.NewServer(opts...) @@ -102,9 +102,9 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authe } func (s *gPRCServerService) Run(ctx context.Context) error { - s.logger.Info("Running GRPC server", "address", s.cfg.GRPCServerAddress, "network", s.cfg.GRPCServerNetwork, "tls", s.cfg.GRPCServerTLSConfig != nil, "max_recv_msg_size", s.cfg.GRPCServerMaxRecvMsgSize, "max_send_msg_size", s.cfg.GRPCServerMaxSendMsgSize) + s.logger.Info("Running GRPC server", "address", s.cfg.Address, "network", s.cfg.Network, "tls", s.cfg.TLSConfig != nil, "max_recv_msg_size", s.cfg.MaxRecvMsgSize, "max_send_msg_size", s.cfg.MaxSendMsgSize) - listener, err := net.Listen(s.cfg.GRPCServerNetwork, s.cfg.GRPCServerAddress) + listener, err := net.Listen(s.cfg.Network, s.cfg.Address) if err != nil { return fmt.Errorf("GRPC server: failed to listen: %w", err) } diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index fbe018a4834..ee300d6b13f 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -281,6 +281,7 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmode Limit: perPage, Filter: filter, SignedInUser: c.SignedInUser, + CountTokens: true, } serviceAccountSearch, err := api.service.SearchOrgServiceAccounts(ctx, &q) if err != nil { @@ -296,13 +297,6 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmode saIDs[saIDString] = true metadata := api.getAccessControlMetadata(c, map[string]bool{saIDString: true}) sa.AccessControl = metadata[strconv.FormatInt(sa.Id, 10)] - tokens, err := api.service.ListTokens(ctx, &serviceaccounts.GetSATokensQuery{ - OrgID: &sa.OrgId, ServiceAccountID: &sa.Id, - }) - if err != nil { - api.log.Warn("Failed to list tokens for service account", "serviceAccount", sa.Id) - } - sa.Tokens = int64(len(tokens)) } return response.JSON(http.StatusOK, serviceAccountSearch) diff --git a/pkg/services/serviceaccounts/database/store.go b/pkg/services/serviceaccounts/database/store.go index 499916a06f6..1e84657a18c 100644 --- a/pkg/services/serviceaccounts/database/store.go +++ b/pkg/services/serviceaccounts/database/store.go @@ -402,6 +402,43 @@ func (s *ServiceAccountsStoreImpl) SearchOrgServiceAccounts(ctx context.Context, if err := sess.Find(&searchResult.ServiceAccounts); err != nil { return err } + + // stop here if we don't want to count the number of tokens per service account + if !query.CountTokens { + return nil + } + + // Fetch tokens count for each service account + accountIDs := make([]int64, len(searchResult.ServiceAccounts)) + for i, serviceAccount := range searchResult.ServiceAccounts { + accountIDs[i] = serviceAccount.Id + } + + tokensSess := dbSession.Table("api_key"). + Select("service_account_id, COUNT(id) AS token_count"). + Where("org_id = ?", query.OrgID). + In("service_account_id", accountIDs). + GroupBy("service_account_id") + + type tokenCount struct { + AccountId int64 `xorm:"service_account_id"` + TokenCount int64 `xorm:"token_count"` + } + + var tokens []tokenCount + if err = tokensSess.Find(&tokens); err != nil { + return err + } + + tokenMap := make(map[int64]int64) + for _, token := range tokens { + tokenMap[token.AccountId] = token.TokenCount + } + + for i, account := range searchResult.ServiceAccounts { + searchResult.ServiceAccounts[i].Tokens = tokenMap[account.Id] + } + return nil }) if err != nil { diff --git a/pkg/services/serviceaccounts/database/store_test.go b/pkg/services/serviceaccounts/database/store_test.go index a304cfc60f7..71db51b1e7e 100644 --- a/pkg/services/serviceaccounts/database/store_test.go +++ b/pkg/services/serviceaccounts/database/store_test.go @@ -488,41 +488,86 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing t.Skip("skipping test in short mode") } + db, store := setupTestDatabase(t) + initUsers := []tests.TestUser{ - {Name: "satest-1", Role: string(org.RoleViewer), Login: "sa-1-satest-1", IsServiceAccount: true}, + {Name: "extsvc-test-1", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-1", IsServiceAccount: true}, {Name: "usertest-2", Role: string(org.RoleEditor), Login: "usertest-2", IsServiceAccount: false}, - {Name: "satest-3", Role: string(org.RoleEditor), Login: "sa-1-satest-3", IsServiceAccount: true}, - {Name: "satest-4", Role: string(org.RoleAdmin), Login: "sa-1-satest-4", IsServiceAccount: true}, + {Name: "extsvc-test-3", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-3", IsServiceAccount: true}, + {Name: "extsvc-test-4", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-4", IsServiceAccount: true}, {Name: "extsvc-test-5", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-5", IsServiceAccount: true}, - {Name: "extsvc-test-6", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-6", IsServiceAccount: true}, - {Name: "extsvc-test-7", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-7", IsServiceAccount: true}, - {Name: "extsvc-test-8", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-8", IsServiceAccount: true}, + {Name: "satest-6", Role: string(org.RoleViewer), Login: "sa-1-satest-6", IsServiceAccount: true}, + {Name: "satest-7", Role: string(org.RoleEditor), Login: "sa-1-satest-7", IsServiceAccount: true}, + {Name: "satest-8", Role: string(org.RoleAdmin), Login: "sa-1-satest-8", IsServiceAccount: true}, } - db, store := setupTestDatabase(t) - orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers) + users, orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers) + + apiKeys := []tests.TestApiKey{ + {Name: "sa-01-apikey-01", OrgId: orgID, Key: "key01", IsExpired: false, ServiceAccountID: &users[0].ID}, + {Name: "sa-01-apikey-02", OrgId: orgID, Key: "key02", IsExpired: false, ServiceAccountID: &users[0].ID}, + {Name: "sa-01-apikey-03", OrgId: orgID, Key: "key03", IsExpired: false, ServiceAccountID: &users[0].ID}, + {Name: "sa-02-apikey-01", OrgId: orgID, Key: "key04", IsExpired: false, ServiceAccountID: &users[2].ID}, + {Name: "sa-02-apikey-02", OrgId: orgID, Key: "key05", IsExpired: false, ServiceAccountID: &users[2].ID}, + {Name: "sa-03-apikey-01", OrgId: orgID, Key: "key06", IsExpired: false, ServiceAccountID: &users[3].ID}, + } + + tests.SetupApiKeys(t, db, store.cfg, apiKeys) userWithPerm := &user.SignedInUser{ OrgID: orgID, Permissions: map[int64]map[string][]string{orgID: {serviceaccounts.ActionRead: {serviceaccounts.ScopeAll}}}, } + expectedServiceAccount := func(i int, tokens int64) *serviceaccounts.ServiceAccountDTO { + return &serviceaccounts.ServiceAccountDTO{ + Id: users[i].ID, UID: users[i].UID, Name: users[i].Name, Login: users[i].Login, OrgId: orgID, Role: "None", Tokens: tokens, + } + } + tt := []struct { - desc string - query *serviceaccounts.SearchOrgServiceAccountsQuery - expectedTotal int64 // Value of the result.TotalCount - expectedCount int // Length of the result.ServiceAccounts slice - expectedErr error + desc string + query *serviceaccounts.SearchOrgServiceAccountsQuery + expectedTotal int64 // Value of the result.TotalCount + expectedServiceAccounts []*serviceaccounts.ServiceAccountDTO + expectedErr error }{ { - desc: "should list all service accounts", + desc: "should list all service accounts with tokens count", + query: &serviceaccounts.SearchOrgServiceAccountsQuery{ + OrgID: orgID, + SignedInUser: userWithPerm, + Filter: serviceaccounts.FilterIncludeAll, + CountTokens: true, + }, + expectedTotal: 7, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + expectedServiceAccount(0, 3), + expectedServiceAccount(2, 2), + expectedServiceAccount(3, 1), + expectedServiceAccount(4, 0), + expectedServiceAccount(5, 0), + expectedServiceAccount(6, 0), + expectedServiceAccount(7, 0), + }, + }, + { + desc: "should list all service accounts with no tokens count", query: &serviceaccounts.SearchOrgServiceAccountsQuery{ OrgID: orgID, SignedInUser: userWithPerm, Filter: serviceaccounts.FilterIncludeAll, }, expectedTotal: 7, - expectedCount: 7, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + expectedServiceAccount(0, 0), + expectedServiceAccount(2, 0), + expectedServiceAccount(3, 0), + expectedServiceAccount(4, 0), + expectedServiceAccount(5, 0), + expectedServiceAccount(6, 0), + expectedServiceAccount(7, 0), + }, }, { desc: "should list no service accounts without permissions", @@ -534,8 +579,8 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing }, Filter: serviceaccounts.FilterIncludeAll, }, - expectedTotal: 0, - expectedCount: 0, + expectedTotal: 0, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{}, }, { desc: "should list one service accounts with restricted permissions", @@ -551,7 +596,10 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing Filter: serviceaccounts.FilterIncludeAll, }, expectedTotal: 2, - expectedCount: 2, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + expectedServiceAccount(0, 0), + expectedServiceAccount(6, 0), + }, }, { desc: "should list only external service accounts", @@ -559,9 +607,15 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing OrgID: orgID, SignedInUser: userWithPerm, Filter: serviceaccounts.FilterOnlyExternal, + CountTokens: true, }, expectedTotal: 4, - expectedCount: 4, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + expectedServiceAccount(0, 3), + expectedServiceAccount(2, 2), + expectedServiceAccount(3, 1), + expectedServiceAccount(4, 0), + }, }, { desc: "should return service accounts with sa-1-satest login", @@ -570,9 +624,14 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing Query: "sa-1-satest", SignedInUser: userWithPerm, Filter: serviceaccounts.FilterIncludeAll, + CountTokens: true, }, expectedTotal: 3, - expectedCount: 3, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + expectedServiceAccount(5, 0), + expectedServiceAccount(6, 0), + expectedServiceAccount(7, 0), + }, }, { desc: "should only count service accounts", @@ -582,8 +641,8 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing Filter: serviceaccounts.FilterIncludeAll, CountOnly: true, }, - expectedTotal: 7, - expectedCount: 0, + expectedTotal: 7, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{}, }, { desc: "should paginate result", @@ -595,7 +654,9 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing Filter: serviceaccounts.FilterIncludeAll, }, expectedTotal: 7, - expectedCount: 1, + expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{ + expectedServiceAccount(7, 0), + }, }, } for _, tc := range tt { @@ -609,7 +670,10 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing } require.Equal(t, tc.expectedTotal, got.TotalCount) - require.Len(t, got.ServiceAccounts, tc.expectedCount) + require.Len(t, got.ServiceAccounts, len(tc.expectedServiceAccounts)) + for i, sa := range got.ServiceAccounts { + require.EqualValues(t, tc.expectedServiceAccounts[i], sa) + } }) } } @@ -628,7 +692,7 @@ func TestIntegrationServiceAccountsStoreImpl_EnableServiceAccounts(t *testing.T) } db, store := setupTestDatabase(t) - orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers) + _, orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers) fetchStates := func() map[int64]bool { sa1, err := store.RetrieveServiceAccount(ctx, &serviceaccounts.GetServiceAccountQuery{OrgID: orgID, ID: 1}) diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go index 50f0914b403..9c0182c3b9e 100644 --- a/pkg/services/serviceaccounts/models.go +++ b/pkg/services/serviceaccounts/models.go @@ -120,6 +120,7 @@ type SearchOrgServiceAccountsQuery struct { Page int Limit int CountOnly bool + CountTokens bool SignedInUser identity.Requester } diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index 66d15e35b9d..5d35b377b18 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -107,9 +107,18 @@ func SetupApiKey(t *testing.T, store db.DB, cfg *setting.Cfg, testKey TestApiKey return key } +func SetupApiKeys(t *testing.T, store db.DB, cfg *setting.Cfg, testKeys []TestApiKey) []*apikey.APIKey { + result := make([]*apikey.APIKey, len(testKeys)) + for i, testKey := range testKeys { + result[i] = SetupApiKey(t, store, cfg, testKey) + } + + return result +} + // SetupUsersServiceAccounts creates in "test org" all users or service accounts passed in parameter // To achieve this, it sets the AutoAssignOrg and AutoAssignOrgId settings. -func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, testUsers []TestUser) (orgID int64) { +func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, testUsers []TestUser) (users []user.User, orgID int64) { role := string(org.RoleNone) quotaService := quotaimpl.ProvideService(sqlStore, cfg) @@ -129,8 +138,9 @@ func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, t cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = int(org.ID) + users = make([]user.User, len(testUsers)) for i := range testUsers { - _, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{ + newUser, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{ Login: testUsers[i].Login, IsServiceAccount: testUsers[i].IsServiceAccount, DefaultOrgRole: role, @@ -138,6 +148,8 @@ func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, t OrgID: org.ID, }) require.NoError(t, err) + + users[i] = *newUser } - return org.ID + return users, org.ID } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 9715d99a427..1a4cd3f9b4d 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -5,11 +5,9 @@ package setting import ( "bytes" - "crypto/tls" "encoding/json" "errors" "fmt" - "io/fs" "net/http" "net/url" "os" @@ -480,12 +478,7 @@ type Cfg struct { Zanzana ZanzanaSettings // GRPC Server. - GRPCServerNetwork string - GRPCServerAddress string - GRPCServerTLSConfig *tls.Config - GRPCServerEnableLogging bool // log request and response of each unary gRPC call - GRPCServerMaxRecvMsgSize int - GRPCServerMaxSendMsgSize int + GRPCServer GRPCServerSettings CustomResponseHeaders map[string]string @@ -1799,71 +1792,6 @@ func (cfg *Cfg) readAlertingSettings(iniFile *ini.File) error { return nil } -func readGRPCServerSettings(cfg *Cfg, iniFile *ini.File) error { - server := iniFile.Section("grpc_server") - errPrefix := "grpc_server:" - useTLS := server.Key("use_tls").MustBool(false) - certFile := server.Key("cert_file").String() - keyFile := server.Key("cert_key").String() - if useTLS { - serverCert, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return fmt.Errorf("%s error loading X509 key pair: %w", errPrefix, err) - } - cfg.GRPCServerTLSConfig = &tls.Config{ - Certificates: []tls.Certificate{serverCert}, - ClientAuth: tls.NoClientCert, - } - } - - cfg.GRPCServerNetwork = valueAsString(server, "network", "tcp") - cfg.GRPCServerAddress = valueAsString(server, "address", "") - cfg.GRPCServerEnableLogging = server.Key("enable_logging").MustBool(false) - cfg.GRPCServerMaxRecvMsgSize = server.Key("max_recv_msg_size").MustInt(0) - cfg.GRPCServerMaxSendMsgSize = server.Key("max_send_msg_size").MustInt(0) - switch cfg.GRPCServerNetwork { - case "unix": - if cfg.GRPCServerAddress != "" { - // Explicitly provided path for unix domain socket. - if stat, err := os.Stat(cfg.GRPCServerAddress); os.IsNotExist(err) { - // File does not exist - nice, nothing to do. - } else if err != nil { - return fmt.Errorf("%s error getting stat for a file: %s", errPrefix, cfg.GRPCServerAddress) - } else { - if stat.Mode()&fs.ModeSocket == 0 { - return fmt.Errorf("%s file %s already exists and is not a unix domain socket", errPrefix, cfg.GRPCServerAddress) - } - // Unix domain socket file, should be safe to remove. - err := os.Remove(cfg.GRPCServerAddress) - if err != nil { - return fmt.Errorf("%s can't remove unix socket file: %s", errPrefix, cfg.GRPCServerAddress) - } - } - } else { - // Use temporary file path for a unix domain socket. - tf, err := os.CreateTemp("", "gf_grpc_server_api") - if err != nil { - return fmt.Errorf("%s error creating tmp file: %v", errPrefix, err) - } - unixPath := tf.Name() - if err := tf.Close(); err != nil { - return fmt.Errorf("%s error closing tmp file: %v", errPrefix, err) - } - if err := os.Remove(unixPath); err != nil { - return fmt.Errorf("%s error removing tmp file: %v", errPrefix, err) - } - cfg.GRPCServerAddress = unixPath - } - case "tcp": - if cfg.GRPCServerAddress == "" { - cfg.GRPCServerAddress = "127.0.0.1:10000" - } - default: - return fmt.Errorf("%s unsupported network %s", errPrefix, cfg.GRPCServerNetwork) - } - return nil -} - func readSnapshotsSettings(cfg *Cfg, iniFile *ini.File) error { snapshots := iniFile.Section("snapshots") diff --git a/pkg/setting/setting_grpc.go b/pkg/setting/setting_grpc.go new file mode 100644 index 00000000000..a28c262b93c --- /dev/null +++ b/pkg/setting/setting_grpc.go @@ -0,0 +1,137 @@ +package setting + +import ( + "crypto/tls" + "fmt" + "io/fs" + "os" + + "github.com/spf13/pflag" + + "gopkg.in/ini.v1" +) + +type GRPCServerSettings struct { + Enabled bool + Network string + Address string // with flags, call Process to fill this field defaults + TLSConfig *tls.Config // with flags, call Process to fill this field + EnableLogging bool // log request and response of each unary gRPC call + MaxRecvMsgSize int + MaxSendMsgSize int + + // Internal fields + useTLS bool + certFile string + keyFile string +} + +func gRPCServerSettingsError(msg string, args ...interface{}) error { + return fmt.Errorf("grpc_server: "+msg, args...) +} + +func (c *GRPCServerSettings) Process() error { + err := c.processTLSConfig() + if err != nil { + return err + } + + return c.processAddress() +} + +func (c *GRPCServerSettings) processTLSConfig() error { + if !c.useTLS { + return nil + } + serverCert, err := tls.LoadX509KeyPair(c.certFile, c.keyFile) + if err != nil { + return gRPCServerSettingsError("error loading X509 key pair: %w", err) + } + c.TLSConfig = &tls.Config{ + Certificates: []tls.Certificate{serverCert}, + ClientAuth: tls.NoClientCert, + } + return nil +} + +func (c *GRPCServerSettings) processAddress() error { + switch c.Network { + case "unix": + if c.Address != "" { + // Explicitly provided path for unix domain socket. + if stat, err := os.Stat(c.Address); os.IsNotExist(err) { + // File does not exist - nice, nothing to do. + } else if err != nil { + return gRPCServerSettingsError("error getting stat for a file: %s", c.Address) + } else { + if stat.Mode()&fs.ModeSocket == 0 { + return gRPCServerSettingsError("file %s already exists and is not a unix domain socket", c.Address) + } + // Unix domain socket file, should be safe to remove. + err := os.Remove(c.Address) + if err != nil { + return gRPCServerSettingsError("can't remove unix socket file: %s", c.Address) + } + } + } else { + // Use temporary file path for a unix domain socket. + tf, err := os.CreateTemp("", "gf_grpc_server_api") + if err != nil { + return gRPCServerSettingsError("error creating tmp file: %v", err) + } + unixPath := tf.Name() + if err := tf.Close(); err != nil { + return gRPCServerSettingsError("error closing tmp file: %v", err) + } + if err := os.Remove(unixPath); err != nil { + return gRPCServerSettingsError("error removing tmp file: %v", err) + } + c.Address = unixPath + } + return nil + case "tcp": + if c.Address == "" { + c.Address = "127.0.0.1:10000" + } + return nil + default: + return gRPCServerSettingsError("unsupported network %s", c.Network) + } +} + +func readGRPCServerSettings(cfg *Cfg, iniFile *ini.File) error { + server := iniFile.Section("grpc_server") + + // This setting can be used to replace the `FlagGrpcServer` feature flag + cfg.GRPCServer.Enabled = server.Key("enabled").MustBool(false) + + cfg.GRPCServer.useTLS = server.Key("use_tls").MustBool(false) + cfg.GRPCServer.certFile = server.Key("cert_file").String() + cfg.GRPCServer.keyFile = server.Key("cert_key").String() + + if err := cfg.GRPCServer.processTLSConfig(); err != nil { + return err + } + + cfg.GRPCServer.Network = valueAsString(server, "network", "tcp") + cfg.GRPCServer.Address = valueAsString(server, "address", "") + cfg.GRPCServer.EnableLogging = server.Key("enable_logging").MustBool(false) + cfg.GRPCServer.MaxRecvMsgSize = server.Key("max_recv_msg_size").MustInt(0) + cfg.GRPCServer.MaxSendMsgSize = server.Key("max_send_msg_size").MustInt(0) + + return cfg.GRPCServer.processAddress() +} + +func (c *GRPCServerSettings) AddFlags(fs *pflag.FlagSet) { + fs.BoolVar(&c.Enabled, "grpc-server-enabled", false, "Enable gRPC server") + fs.StringVar(&c.Network, "grpc-server-network", "tcp", "Network type for the gRPC server (tcp, unix)") + fs.StringVar(&c.Address, "grpc-server-address", "", "Address for the gRPC server") + fs.BoolVar(&c.EnableLogging, "grpc-server-enable-logging", false, "Enable logging of gRPC requests and responses") + fs.IntVar(&c.MaxRecvMsgSize, "grpc-server-max-recv-msg-size", 0, "Maximum size of a gRPC request message in bytes") + fs.IntVar(&c.MaxSendMsgSize, "grpc-server-max-send-msg-size", 0, "Maximum size of a gRPC response message in bytes") + + // Internal flags, we need to call ProcessTLSConfig + fs.BoolVar(&c.useTLS, "grpc-server-use-tls", false, "Enable TLS for the gRPC server") + fs.StringVar(&c.certFile, "grpc-server-cert-file", "", "Path to the certificate file for the gRPC server") + fs.StringVar(&c.keyFile, "grpc-server-key-file", "", "Path to the certificate key file for the gRPC server") +} diff --git a/pkg/storage/unified/apistore/store.go b/pkg/storage/unified/apistore/store.go index 8b19007dd8b..a33b602cdc1 100644 --- a/pkg/storage/unified/apistore/store.go +++ b/pkg/storage/unified/apistore/store.go @@ -44,6 +44,7 @@ var _ storage.Interface = (*Storage)(nil) // Optional settings that apply to a single resource type StorageOptions struct { LargeObjectSupport LargeObjectSupport + InternalConversion func([]byte, runtime.Object) (runtime.Object, error) } // Storage implements storage.Interface and storage resources as JSON files on disk. @@ -132,6 +133,14 @@ func (s *Storage) Versioner() storage.Versioner { return s.versioner } +func (s *Storage) convertToObject(data []byte, obj runtime.Object) (runtime.Object, error) { + if s.opts.InternalConversion != nil { + return s.opts.InternalConversion(data, obj) + } + obj, _, err := s.codec.Decode(data, nil, obj) + return obj, err +} + // Create adds a new object at a key unless it already exists. 'ttl' is time-to-live // in seconds (0 means forever). If no error is returned and out is not nil, out will be // set to the read value from database. @@ -310,7 +319,7 @@ func (s *Storage) Get(ctx context.Context, key string, opts storage.GetOptions, return resource.GetError(rsp.Error) } - _, _, err = s.codec.Decode(rsp.Value, nil, objPtr) + _, err = s.convertToObject(rsp.Value, objPtr) if err != nil { return err } @@ -360,7 +369,7 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti } for _, item := range rsp.Items { - obj, _, err := s.codec.Decode(item.Value, nil, s.newFunc()) + obj, err := s.convertToObject(item.Value, s.newFunc()) if err != nil { return err } @@ -461,7 +470,7 @@ func (s *Storage) GuaranteedUpdate( existingObj = s.newFunc() if len(rsp.Value) > 0 { created = false - _, _, err = s.codec.Decode(rsp.Value, nil, existingObj) + _, err = s.convertToObject(rsp.Value, existingObj) if err != nil { return err } diff --git a/pkg/storage/unified/client.go b/pkg/storage/unified/client.go index c1cf0a2513b..9cdd69d6a19 100644 --- a/pkg/storage/unified/client.go +++ b/pkg/storage/unified/client.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/apiserver/options" "github.com/grafana/grafana/pkg/services/authn/grpcutils" + "github.com/grafana/grafana/pkg/services/authz" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -32,6 +33,7 @@ func ProvideUnifiedStorageClient( db infraDB.DB, tracer tracing.Tracer, reg prometheus.Registerer, + authzc authz.Client, ) (resource.ResourceClient, error) { // See: apiserver.ApplyGrafanaConfig(cfg, features, o) apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver") @@ -95,7 +97,7 @@ func ProvideUnifiedStorageClient( // Use the local SQL default: - server, err := sql.NewResourceServer(ctx, db, cfg, features, tracer, reg) + server, err := sql.NewResourceServer(ctx, db, cfg, features, tracer, reg, authzc) if err != nil { return nil, err } diff --git a/pkg/storage/unified/resource/access.go b/pkg/storage/unified/resource/access.go index af6f2582d33..97781a7c9d8 100644 --- a/pkg/storage/unified/resource/access.go +++ b/pkg/storage/unified/resource/access.go @@ -2,6 +2,8 @@ package resource import ( "context" + "log/slog" + "time" "github.com/grafana/authlib/authz" "github.com/grafana/authlib/claims" @@ -24,3 +26,82 @@ func (c *staticAuthzClient) Compile(ctx context.Context, id claims.AuthInfo, req } var _ authz.AccessClient = &staticAuthzClient{} + +type groupResource map[string]map[string]interface{} + +// authzLimitedClient is a client that enforces RBAC for the limited number of groups and resources. +// This is a temporary solution until the authz service is fully implemented. +// The authz service will be responsible for enforcing RBAC. +// For now, it makes one call to the authz service for each list items. This is known to be inefficient. +type authzLimitedClient struct { + client authz.AccessChecker + // whitelist is a map of group to resources that are compatible with RBAC. + whitelist groupResource + logger *slog.Logger +} + +// NewAuthzLimitedClient creates a new authzLimitedClient. +func NewAuthzLimitedClient(client authz.AccessChecker) authz.AccessClient { + logger := slog.Default().With("logger", "limited-authz-client") + return &authzLimitedClient{ + client: client, + whitelist: groupResource{ + "dashboard.grafana.app": map[string]interface{}{"dashboards": nil}, + "folder.grafana.app": map[string]interface{}{"folders": nil}, + }, + logger: logger, + } +} + +// Check implements authz.AccessClient. +func (c authzLimitedClient) Check(ctx context.Context, id claims.AuthInfo, req authz.CheckRequest) (authz.CheckResponse, error) { + if !c.IsCompatibleWithRBAC(req.Group, req.Resource) { + c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "rbac", false, "allowed", true) + return authz.CheckResponse{Allowed: true}, nil + } + t := time.Now() + resp, err := c.client.Check(ctx, id, req) + if err != nil { + c.logger.Error("Check", "group", req.Group, "resource", req.Resource, "rbac", true, "error", err, "duration", time.Since(t)) + return resp, err + } + c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "rbac", true, "allowed", resp.Allowed, "duration", time.Since(t)) + return resp, nil +} + +// Compile implements authz.AccessClient. +func (c authzLimitedClient) Compile(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (authz.ItemChecker, error) { + return func(namespace string, name, folder string) bool { + // TODO: Implement For now we perform the check for each item. + if !c.IsCompatibleWithRBAC(req.Group, req.Resource) { + c.logger.Debug("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", false, "allowed", true) + return true + } + t := time.Now() + r, err := c.client.Check(ctx, id, authz.CheckRequest{ + Verb: "get", + Group: req.Group, + Resource: req.Resource, + Namespace: namespace, + Name: name, + Folder: folder, + }) + if err != nil { + c.logger.Error("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", true, "error", err, "duration", time.Since(t)) + return false + } + c.logger.Debug("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", true, "allowed", r.Allowed, "duration", time.Since(t)) + return r.Allowed + }, nil +} + +func (c authzLimitedClient) IsCompatibleWithRBAC(group, resource string) bool { + if _, ok := c.whitelist[group]; ok { + if _, ok := c.whitelist[group][resource]; ok { + return true + } + } + return false +} + +var _ authz.AccessClient = &authzLimitedClient{} diff --git a/pkg/storage/unified/resource/access_test.go b/pkg/storage/unified/resource/access_test.go new file mode 100644 index 00000000000..129dd28f696 --- /dev/null +++ b/pkg/storage/unified/resource/access_test.go @@ -0,0 +1,62 @@ +package resource + +import ( + "context" + "testing" + + "github.com/grafana/authlib/authz" + "github.com/stretchr/testify/assert" +) + +func TestAuthzLimitedClient_Check(t *testing.T) { + mockClient := &staticAuthzClient{allowed: false} + client := NewAuthzLimitedClient(mockClient) + + tests := []struct { + group string + resource string + expected bool + }{ + {"dashboard.grafana.app", "dashboards", false}, + {"folder.grafana.app", "folders", false}, + {"unknown.group", "unknown.resource", true}, + } + + for _, test := range tests { + req := authz.CheckRequest{ + Group: test.group, + Resource: test.resource, + } + resp, err := client.Check(context.Background(), nil, req) + assert.NoError(t, err) + assert.Equal(t, test.expected, resp.Allowed) + } +} + +func TestAuthzLimitedClient_Compile(t *testing.T) { + mockClient := &staticAuthzClient{allowed: false} + client := NewAuthzLimitedClient(mockClient) + + tests := []struct { + group string + resource string + expected bool + }{ + {"dashboard.grafana.app", "dashboards", false}, + {"folder.grafana.app", "folders", false}, + {"unknown.group", "unknown.resource", true}, + } + + for _, test := range tests { + req := authz.ListRequest{ + Group: test.group, + Resource: test.resource, + } + checker, err := client.Compile(context.Background(), nil, req) + assert.NoError(t, err) + assert.NotNil(t, checker) + + result := checker("namespace", "name", "folder") + assert.Equal(t, test.expected, result) + } +} diff --git a/pkg/storage/unified/resource/resource.pb.go b/pkg/storage/unified/resource/resource.pb.go index 1fa3f1e6c33..a247d97232c 100644 --- a/pkg/storage/unified/resource/resource.pb.go +++ b/pkg/storage/unified/resource/resource.pb.go @@ -176,6 +176,81 @@ func (HealthCheckResponse_ServingStatus) EnumDescriptor() ([]byte, []int) { return file_resource_proto_rawDescGZIP(), []int{30, 0} } +// See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for more. +// When converted to a k8s Table, this will become two fields: type and format +type ResourceTableColumnDefinition_ColumnType int32 + +const ( + ResourceTableColumnDefinition_UNKNOWN_TYPE ResourceTableColumnDefinition_ColumnType = 0 + ResourceTableColumnDefinition_STRING ResourceTableColumnDefinition_ColumnType = 1 + ResourceTableColumnDefinition_BOOLEAN ResourceTableColumnDefinition_ColumnType = 2 + ResourceTableColumnDefinition_INT32 ResourceTableColumnDefinition_ColumnType = 3 + ResourceTableColumnDefinition_INT64 ResourceTableColumnDefinition_ColumnType = 4 + ResourceTableColumnDefinition_FLOAT ResourceTableColumnDefinition_ColumnType = 5 + ResourceTableColumnDefinition_DOUBLE ResourceTableColumnDefinition_ColumnType = 6 + ResourceTableColumnDefinition_DATE ResourceTableColumnDefinition_ColumnType = 7 + ResourceTableColumnDefinition_DATE_TIME ResourceTableColumnDefinition_ColumnType = 8 + ResourceTableColumnDefinition_BINARY ResourceTableColumnDefinition_ColumnType = 9 + ResourceTableColumnDefinition_OBJECT ResourceTableColumnDefinition_ColumnType = 10 // map[string]any +) + +// Enum value maps for ResourceTableColumnDefinition_ColumnType. +var ( + ResourceTableColumnDefinition_ColumnType_name = map[int32]string{ + 0: "UNKNOWN_TYPE", + 1: "STRING", + 2: "BOOLEAN", + 3: "INT32", + 4: "INT64", + 5: "FLOAT", + 6: "DOUBLE", + 7: "DATE", + 8: "DATE_TIME", + 9: "BINARY", + 10: "OBJECT", + } + ResourceTableColumnDefinition_ColumnType_value = map[string]int32{ + "UNKNOWN_TYPE": 0, + "STRING": 1, + "BOOLEAN": 2, + "INT32": 3, + "INT64": 4, + "FLOAT": 5, + "DOUBLE": 6, + "DATE": 7, + "DATE_TIME": 8, + "BINARY": 9, + "OBJECT": 10, + } +) + +func (x ResourceTableColumnDefinition_ColumnType) Enum() *ResourceTableColumnDefinition_ColumnType { + p := new(ResourceTableColumnDefinition_ColumnType) + *p = x + return p +} + +func (x ResourceTableColumnDefinition_ColumnType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ResourceTableColumnDefinition_ColumnType) Descriptor() protoreflect.EnumDescriptor { + return file_resource_proto_enumTypes[3].Descriptor() +} + +func (ResourceTableColumnDefinition_ColumnType) Type() protoreflect.EnumType { + return &file_resource_proto_enumTypes[3] +} + +func (x ResourceTableColumnDefinition_ColumnType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ResourceTableColumnDefinition_ColumnType.Descriptor instead. +func (ResourceTableColumnDefinition_ColumnType) EnumDescriptor() ([]byte, []int) { + return file_resource_proto_rawDescGZIP(), []int{32, 0} +} + type PutBlobRequest_Method int32 const ( @@ -208,11 +283,11 @@ func (x PutBlobRequest_Method) String() string { } func (PutBlobRequest_Method) Descriptor() protoreflect.EnumDescriptor { - return file_resource_proto_enumTypes[3].Descriptor() + return file_resource_proto_enumTypes[4].Descriptor() } func (PutBlobRequest_Method) Type() protoreflect.EnumType { - return &file_resource_proto_enumTypes[3] + return &file_resource_proto_enumTypes[4] } func (x PutBlobRequest_Method) Number() protoreflect.EnumNumber { @@ -221,7 +296,7 @@ func (x PutBlobRequest_Method) Number() protoreflect.EnumNumber { // Deprecated: Use PutBlobRequest_Method.Descriptor instead. func (PutBlobRequest_Method) EnumDescriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{31, 0} + return file_resource_proto_rawDescGZIP(), []int{34, 0} } type ResourceKey struct { @@ -2377,6 +2452,275 @@ func (x *HealthCheckResponse) GetStatus() HealthCheckResponse_ServingStatus { return HealthCheckResponse_UNKNOWN } +// ResourceTable is a protobuf variation of the kubernetes Table object. +// This format allows specifying a flexible set of columns related to a given resource +type ResourceTable struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Columns describes each column in the returned items array. The number of cells per row + // will always match the number of column definitions. + Columns []*ResourceTableColumnDefinition `protobuf:"bytes,1,rep,name=columns,proto3" json:"columns,omitempty"` + // rows is the list of items in the table. + Rows []*ResourceTableRow `protobuf:"bytes,2,rep,name=rows,proto3" json:"rows,omitempty"` + // When more results exist, pass this in the next request + NextPageToken string `protobuf:"bytes,3,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + // ResourceVersion of the list response + // +optional + ResourceVersion int64 `protobuf:"varint,4,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` + // remainingItemCount is the number of subsequent items in the list which are not included in this + // list response. If the list request contained label or field selectors, then the number of + // remaining items is unknown and the field will be left unset and omitted during serialization. + // If the list is complete (either because it is not chunking or because this is the last chunk), + // then there are no more remaining items and this field will be left unset and omitted during + // serialization. + // + // The intended use of the remainingItemCount is *estimating* the size of a collection. Clients + // should not rely on the remainingItemCount to be set or to be exact. + // +optional + RemainingItemCount int64 `protobuf:"varint,5,opt,name=remaining_item_count,json=remainingItemCount,proto3" json:"remaining_item_count,omitempty"` +} + +func (x *ResourceTable) Reset() { + *x = ResourceTable{} + mi := &file_resource_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceTable) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceTable) ProtoMessage() {} + +func (x *ResourceTable) ProtoReflect() protoreflect.Message { + mi := &file_resource_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceTable.ProtoReflect.Descriptor instead. +func (*ResourceTable) Descriptor() ([]byte, []int) { + return file_resource_proto_rawDescGZIP(), []int{31} +} + +func (x *ResourceTable) GetColumns() []*ResourceTableColumnDefinition { + if x != nil { + return x.Columns + } + return nil +} + +func (x *ResourceTable) GetRows() []*ResourceTableRow { + if x != nil { + return x.Rows + } + return nil +} + +func (x *ResourceTable) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +func (x *ResourceTable) GetResourceVersion() int64 { + if x != nil { + return x.ResourceVersion + } + return 0 +} + +func (x *ResourceTable) GetRemainingItemCount() int64 { + if x != nil { + return x.RemainingItemCount + } + return 0 +} + +// TableColumnDefinition contains information about a column returned in the Table. +type ResourceTableColumnDefinition struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // name is a human readable name for the column. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Defines the column type. In k8s, this will resolve into both the type and format fields + Type ResourceTableColumnDefinition_ColumnType `protobuf:"varint,2,opt,name=type,proto3,enum=resource.ResourceTableColumnDefinition_ColumnType" json:"type,omitempty"` + // The value is an arry of given type + IsArray bool `protobuf:"varint,3,opt,name=is_array,json=isArray,proto3" json:"is_array,omitempty"` + // description is a human readable description of this column. + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + // Properties about this column (helpful for indexing and search) + Properties *ResourceTableColumnDefinition_Properties `protobuf:"bytes,5,opt,name=properties,proto3" json:"properties,omitempty"` + // priority is an integer defining the relative importance of this column compared to others. Lower + // numbers are considered higher priority. Columns that may be omitted in limited space scenarios + // should be given a higher priority. + Priority int32 `protobuf:"varint,6,opt,name=priority,proto3" json:"priority,omitempty"` +} + +func (x *ResourceTableColumnDefinition) Reset() { + *x = ResourceTableColumnDefinition{} + mi := &file_resource_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceTableColumnDefinition) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceTableColumnDefinition) ProtoMessage() {} + +func (x *ResourceTableColumnDefinition) ProtoReflect() protoreflect.Message { + mi := &file_resource_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceTableColumnDefinition.ProtoReflect.Descriptor instead. +func (*ResourceTableColumnDefinition) Descriptor() ([]byte, []int) { + return file_resource_proto_rawDescGZIP(), []int{32} +} + +func (x *ResourceTableColumnDefinition) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ResourceTableColumnDefinition) GetType() ResourceTableColumnDefinition_ColumnType { + if x != nil { + return x.Type + } + return ResourceTableColumnDefinition_UNKNOWN_TYPE +} + +func (x *ResourceTableColumnDefinition) GetIsArray() bool { + if x != nil { + return x.IsArray + } + return false +} + +func (x *ResourceTableColumnDefinition) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ResourceTableColumnDefinition) GetProperties() *ResourceTableColumnDefinition_Properties { + if x != nil { + return x.Properties + } + return nil +} + +func (x *ResourceTableColumnDefinition) GetPriority() int32 { + if x != nil { + return x.Priority + } + return 0 +} + +// TableRow is an individual row in a table. +type ResourceTableRow struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The resource referenced by this row + Key *ResourceKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // The resource version for the given values + ResourceVersion int64 `protobuf:"varint,2,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` + // Cells will be as wide as the column definitions array + // Numeric values will be encoded using big endian bytes + // All arrays will be JSON encoded + Cells [][]byte `protobuf:"bytes,3,rep,name=cells,proto3" json:"cells,omitempty"` + // This field may contains the additional information about each object based on the request. + // The value will be at least a partial object metadata, and perhaps the full object metadata. + // When this value exists, it should include both the key and the resource_version otherwise + // they may be lost in the conversion to k8s resource + // +optional + Object []byte `protobuf:"bytes,4,opt,name=object,proto3" json:"object,omitempty"` +} + +func (x *ResourceTableRow) Reset() { + *x = ResourceTableRow{} + mi := &file_resource_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceTableRow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceTableRow) ProtoMessage() {} + +func (x *ResourceTableRow) ProtoReflect() protoreflect.Message { + mi := &file_resource_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceTableRow.ProtoReflect.Descriptor instead. +func (*ResourceTableRow) Descriptor() ([]byte, []int) { + return file_resource_proto_rawDescGZIP(), []int{33} +} + +func (x *ResourceTableRow) GetKey() *ResourceKey { + if x != nil { + return x.Key + } + return nil +} + +func (x *ResourceTableRow) GetResourceVersion() int64 { + if x != nil { + return x.ResourceVersion + } + return 0 +} + +func (x *ResourceTableRow) GetCells() [][]byte { + if x != nil { + return x.Cells + } + return nil +} + +func (x *ResourceTableRow) GetObject() []byte { + if x != nil { + return x.Object + } + return nil +} + type PutBlobRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2396,7 +2740,7 @@ type PutBlobRequest struct { func (x *PutBlobRequest) Reset() { *x = PutBlobRequest{} - mi := &file_resource_proto_msgTypes[31] + mi := &file_resource_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2408,7 +2752,7 @@ func (x *PutBlobRequest) String() string { func (*PutBlobRequest) ProtoMessage() {} func (x *PutBlobRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[31] + mi := &file_resource_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2421,7 +2765,7 @@ func (x *PutBlobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PutBlobRequest.ProtoReflect.Descriptor instead. func (*PutBlobRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{31} + return file_resource_proto_rawDescGZIP(), []int{34} } func (x *PutBlobRequest) GetResource() *ResourceKey { @@ -2475,7 +2819,7 @@ type PutBlobResponse struct { func (x *PutBlobResponse) Reset() { *x = PutBlobResponse{} - mi := &file_resource_proto_msgTypes[32] + mi := &file_resource_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2487,7 +2831,7 @@ func (x *PutBlobResponse) String() string { func (*PutBlobResponse) ProtoMessage() {} func (x *PutBlobResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[32] + mi := &file_resource_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2500,7 +2844,7 @@ func (x *PutBlobResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PutBlobResponse.ProtoReflect.Descriptor instead. func (*PutBlobResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{32} + return file_resource_proto_rawDescGZIP(), []int{35} } func (x *PutBlobResponse) GetError() *ErrorResult { @@ -2566,7 +2910,7 @@ type GetBlobRequest struct { func (x *GetBlobRequest) Reset() { *x = GetBlobRequest{} - mi := &file_resource_proto_msgTypes[33] + mi := &file_resource_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2578,7 +2922,7 @@ func (x *GetBlobRequest) String() string { func (*GetBlobRequest) ProtoMessage() {} func (x *GetBlobRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[33] + mi := &file_resource_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2591,7 +2935,7 @@ func (x *GetBlobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBlobRequest.ProtoReflect.Descriptor instead. func (*GetBlobRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{33} + return file_resource_proto_rawDescGZIP(), []int{36} } func (x *GetBlobRequest) GetResource() *ResourceKey { @@ -2634,7 +2978,7 @@ type GetBlobResponse struct { func (x *GetBlobResponse) Reset() { *x = GetBlobResponse{} - mi := &file_resource_proto_msgTypes[34] + mi := &file_resource_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2646,7 +2990,7 @@ func (x *GetBlobResponse) String() string { func (*GetBlobResponse) ProtoMessage() {} func (x *GetBlobResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[34] + mi := &file_resource_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2659,7 +3003,7 @@ func (x *GetBlobResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBlobResponse.ProtoReflect.Descriptor instead. func (*GetBlobResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{34} + return file_resource_proto_rawDescGZIP(), []int{37} } func (x *GetBlobResponse) GetError() *ErrorResult { @@ -2701,7 +3045,7 @@ type WatchEvent_Resource struct { func (x *WatchEvent_Resource) Reset() { *x = WatchEvent_Resource{} - mi := &file_resource_proto_msgTypes[35] + mi := &file_resource_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2713,7 +3057,7 @@ func (x *WatchEvent_Resource) String() string { func (*WatchEvent_Resource) ProtoMessage() {} func (x *WatchEvent_Resource) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[35] + mi := &file_resource_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2743,6 +3087,92 @@ func (x *WatchEvent_Resource) GetValue() []byte { return nil } +// These values are not part of standard k8s format +// however these are useful when indexing and analyzing results +type ResourceTableColumnDefinition_Properties struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // All values in this columns should be unique + UniqueValues bool `protobuf:"varint,1,opt,name=unique_values,json=uniqueValues,proto3" json:"unique_values,omitempty"` + // The string value is free text; using text analyzers is appropriate + FreeText bool `protobuf:"varint,2,opt,name=free_text,json=freeText,proto3" json:"free_text,omitempty"` + // The value(s) are reasonable to use for search refinement + // When indexing, these values would be good to add to an index + Filterable bool `protobuf:"varint,3,opt,name=filterable,proto3" json:"filterable,omitempty"` + // When true, every value should exist + // not_null with a nil default_value should be an error + NotNull bool `protobuf:"varint,4,opt,name=not_null,json=notNull,proto3" json:"not_null,omitempty"` + // When missing, this value can be used + DefaultValue []byte `protobuf:"bytes,5,opt,name=default_value,json=defaultValue,proto3" json:"default_value,omitempty"` +} + +func (x *ResourceTableColumnDefinition_Properties) Reset() { + *x = ResourceTableColumnDefinition_Properties{} + mi := &file_resource_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceTableColumnDefinition_Properties) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceTableColumnDefinition_Properties) ProtoMessage() {} + +func (x *ResourceTableColumnDefinition_Properties) ProtoReflect() protoreflect.Message { + mi := &file_resource_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceTableColumnDefinition_Properties.ProtoReflect.Descriptor instead. +func (*ResourceTableColumnDefinition_Properties) Descriptor() ([]byte, []int) { + return file_resource_proto_rawDescGZIP(), []int{32, 0} +} + +func (x *ResourceTableColumnDefinition_Properties) GetUniqueValues() bool { + if x != nil { + return x.UniqueValues + } + return false +} + +func (x *ResourceTableColumnDefinition_Properties) GetFreeText() bool { + if x != nil { + return x.FreeText + } + return false +} + +func (x *ResourceTableColumnDefinition_Properties) GetFilterable() bool { + if x != nil { + return x.Filterable + } + return false +} + +func (x *ResourceTableColumnDefinition_Properties) GetNotNull() bool { + if x != nil { + return x.NotNull + } + return false +} + +func (x *ResourceTableColumnDefinition_Properties) GetDefaultValue() []byte { + if x != nil { + return x.DefaultValue + } + return nil +} + var File_resource_proto protoreflect.FileDescriptor var file_resource_proto_rawDesc = []byte{ @@ -3028,109 +3458,174 @@ var file_resource_proto_rawDesc = []byte{ 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x4f, 0x54, 0x5f, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x45, 0x52, - 0x56, 0x49, 0x43, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x22, 0xd3, - 0x01, 0x0a, 0x0e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, - 0x65, 0x74, 0x68, 0x6f, 0x64, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x21, 0x0a, - 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, - 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x1c, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, - 0x12, 0x08, 0x0a, 0x04, 0x47, 0x52, 0x50, 0x43, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, - 0x54, 0x50, 0x10, 0x01, 0x22, 0xc1, 0x01, 0x0a, 0x0f, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, - 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, - 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, - 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x61, 0x73, - 0x68, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x18, - 0x0a, 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x22, 0x98, 0x01, 0x0a, 0x0e, 0x47, 0x65, 0x74, - 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x29, - 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x28, 0x0a, 0x10, 0x6d, 0x75, 0x73, - 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x0e, 0x6d, 0x75, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x42, 0x79, - 0x74, 0x65, 0x73, 0x22, 0x89, 0x01, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, - 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, - 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, - 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x2a, - 0x33, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x10, 0x0a, 0x0c, 0x4e, 0x6f, 0x74, 0x4f, 0x6c, - 0x64, 0x65, 0x72, 0x54, 0x68, 0x61, 0x6e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x78, 0x61, - 0x63, 0x74, 0x10, 0x01, 0x32, 0xed, 0x02, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x15, - 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, - 0x06, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x15, 0x2e, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, - 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x05, 0x57, - 0x61, 0x74, 0x63, 0x68, 0x12, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, - 0x6e, 0x74, 0x30, 0x01, 0x32, 0xc9, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x3b, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x65, 0x61, 0x72, - 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x18, - 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, - 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x12, 0x17, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x32, 0x8b, 0x01, 0x0a, 0x09, 0x42, 0x6c, 0x6f, 0x62, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x3e, - 0x0a, 0x07, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x56, 0x49, 0x43, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x22, 0x87, + 0x02, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, + 0x12, 0x41, 0x0a, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x27, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, + 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x63, 0x6f, 0x6c, 0x75, + 0x6d, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x04, 0x72, 0x6f, 0x77, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x6f, 0x77, 0x52, 0x04, 0x72, + 0x6f, 0x77, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, + 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, + 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x49, + 0x74, 0x65, 0x6d, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xf1, 0x04, 0x0a, 0x1d, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, + 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x46, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x32, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, + 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x54, 0x79, 0x70, 0x65, + 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x61, 0x72, 0x72, + 0x61, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x41, 0x72, 0x72, 0x61, + 0x79, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x52, 0x0a, 0x0a, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, + 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, + 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, + 0x2e, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0a, 0x70, 0x72, 0x6f, + 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, + 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, + 0x69, 0x74, 0x79, 0x1a, 0xae, 0x01, 0x0a, 0x0a, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, + 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x5f, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x75, 0x6e, 0x69, 0x71, 0x75, + 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x72, 0x65, 0x65, 0x5f, + 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x66, 0x72, 0x65, 0x65, + 0x54, 0x65, 0x78, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x61, 0x62, + 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, + 0x61, 0x62, 0x6c, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x6f, 0x74, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x6e, 0x6f, 0x74, 0x4e, 0x75, 0x6c, 0x6c, 0x12, + 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x22, 0x95, 0x01, 0x0a, 0x0a, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x54, + 0x79, 0x70, 0x65, 0x12, 0x10, 0x0a, 0x0c, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x5f, 0x54, + 0x59, 0x50, 0x45, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x52, 0x49, 0x4e, 0x47, 0x10, + 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x42, 0x4f, 0x4f, 0x4c, 0x45, 0x41, 0x4e, 0x10, 0x02, 0x12, 0x09, + 0x0a, 0x05, 0x49, 0x4e, 0x54, 0x33, 0x32, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x49, 0x4e, 0x54, + 0x36, 0x34, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, 0x46, 0x4c, 0x4f, 0x41, 0x54, 0x10, 0x05, 0x12, + 0x0a, 0x0a, 0x06, 0x44, 0x4f, 0x55, 0x42, 0x4c, 0x45, 0x10, 0x06, 0x12, 0x08, 0x0a, 0x04, 0x44, + 0x41, 0x54, 0x45, 0x10, 0x07, 0x12, 0x0d, 0x0a, 0x09, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x49, + 0x4d, 0x45, 0x10, 0x08, 0x12, 0x0a, 0x0a, 0x06, 0x42, 0x49, 0x4e, 0x41, 0x52, 0x59, 0x10, 0x09, + 0x12, 0x0a, 0x0a, 0x06, 0x4f, 0x42, 0x4a, 0x45, 0x43, 0x54, 0x10, 0x0a, 0x22, 0x94, 0x01, 0x0a, + 0x10, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x6f, + 0x77, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x0c, 0x52, 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x6f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x22, 0xd3, 0x01, 0x0a, 0x0e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, + 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x06, 0x6d, 0x65, 0x74, + 0x68, 0x6f, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, - 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, - 0x0a, 0x07, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, - 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x57, - 0x0a, 0x0b, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x73, 0x12, 0x48, 0x0a, - 0x09, 0x49, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x1c, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, - 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, - 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, - 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, - 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, + 0x6f, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, + 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, + 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x1c, 0x0a, 0x06, 0x4d, + 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x08, 0x0a, 0x04, 0x47, 0x52, 0x50, 0x43, 0x10, 0x00, 0x12, + 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x01, 0x22, 0xc1, 0x01, 0x0a, 0x0f, 0x50, 0x75, + 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, + 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, + 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x12, + 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, + 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, + 0x79, 0x70, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, + 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x22, 0x98, 0x01, + 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, + 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x28, + 0x0a, 0x10, 0x6d, 0x75, 0x73, 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x62, 0x79, 0x74, + 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x6d, 0x75, 0x73, 0x74, 0x50, 0x72, + 0x6f, 0x78, 0x79, 0x42, 0x79, 0x74, 0x65, 0x73, 0x22, 0x89, 0x01, 0x0a, 0x0f, 0x47, 0x65, 0x74, + 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x63, + 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, + 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x2a, 0x33, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x10, 0x0a, 0x0c, + 0x4e, 0x6f, 0x74, 0x4f, 0x6c, 0x64, 0x65, 0x72, 0x54, 0x68, 0x61, 0x6e, 0x10, 0x00, 0x12, 0x09, + 0x0a, 0x05, 0x45, 0x78, 0x61, 0x63, 0x74, 0x10, 0x01, 0x32, 0xed, 0x02, 0x0a, 0x0d, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x52, + 0x65, 0x61, 0x64, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, + 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x3b, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x4c, 0x69, 0x73, + 0x74, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, + 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x37, 0x0a, 0x05, 0x57, 0x61, 0x74, 0x63, 0x68, 0x12, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x14, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, + 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x32, 0xc9, 0x01, 0x0a, 0x0d, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x3b, 0x0a, 0x06, 0x53, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x48, 0x69, 0x73, 0x74, + 0x6f, 0x72, 0x79, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, + 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x4f, 0x72, 0x69, 0x67, + 0x69, 0x6e, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4f, 0x72, + 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x8b, 0x01, 0x0a, 0x09, 0x42, 0x6c, 0x6f, 0x62, 0x53, 0x74, + 0x6f, 0x72, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x12, 0x18, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, + 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x12, 0x18, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, + 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x32, 0x57, 0x0a, 0x0b, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, + 0x63, 0x73, 0x12, 0x48, 0x0a, 0x09, 0x49, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, + 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, + 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, + 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x39, 0x5a, 0x37, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, + 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, + 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2f, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -3145,117 +3640,127 @@ func file_resource_proto_rawDescGZIP() []byte { return file_resource_proto_rawDescData } -var file_resource_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_resource_proto_msgTypes = make([]protoimpl.MessageInfo, 36) +var file_resource_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_resource_proto_msgTypes = make([]protoimpl.MessageInfo, 40) var file_resource_proto_goTypes = []any{ - (ResourceVersionMatch)(0), // 0: resource.ResourceVersionMatch - (WatchEvent_Type)(0), // 1: resource.WatchEvent.Type - (HealthCheckResponse_ServingStatus)(0), // 2: resource.HealthCheckResponse.ServingStatus - (PutBlobRequest_Method)(0), // 3: resource.PutBlobRequest.Method - (*ResourceKey)(nil), // 4: resource.ResourceKey - (*ResourceWrapper)(nil), // 5: resource.ResourceWrapper - (*ResourceMeta)(nil), // 6: resource.ResourceMeta - (*ErrorResult)(nil), // 7: resource.ErrorResult - (*ErrorDetails)(nil), // 8: resource.ErrorDetails - (*ErrorCause)(nil), // 9: resource.ErrorCause - (*CreateRequest)(nil), // 10: resource.CreateRequest - (*CreateResponse)(nil), // 11: resource.CreateResponse - (*UpdateRequest)(nil), // 12: resource.UpdateRequest - (*UpdateResponse)(nil), // 13: resource.UpdateResponse - (*DeleteRequest)(nil), // 14: resource.DeleteRequest - (*DeleteResponse)(nil), // 15: resource.DeleteResponse - (*ReadRequest)(nil), // 16: resource.ReadRequest - (*ReadResponse)(nil), // 17: resource.ReadResponse - (*Requirement)(nil), // 18: resource.Requirement - (*ListOptions)(nil), // 19: resource.ListOptions - (*ListRequest)(nil), // 20: resource.ListRequest - (*ListResponse)(nil), // 21: resource.ListResponse - (*WatchRequest)(nil), // 22: resource.WatchRequest - (*WatchEvent)(nil), // 23: resource.WatchEvent - (*SearchRequest)(nil), // 24: resource.SearchRequest - (*GroupBy)(nil), // 25: resource.GroupBy - (*Group)(nil), // 26: resource.Group - (*SearchResponse)(nil), // 27: resource.SearchResponse - (*HistoryRequest)(nil), // 28: resource.HistoryRequest - (*HistoryResponse)(nil), // 29: resource.HistoryResponse - (*OriginRequest)(nil), // 30: resource.OriginRequest - (*ResourceOriginInfo)(nil), // 31: resource.ResourceOriginInfo - (*OriginResponse)(nil), // 32: resource.OriginResponse - (*HealthCheckRequest)(nil), // 33: resource.HealthCheckRequest - (*HealthCheckResponse)(nil), // 34: resource.HealthCheckResponse - (*PutBlobRequest)(nil), // 35: resource.PutBlobRequest - (*PutBlobResponse)(nil), // 36: resource.PutBlobResponse - (*GetBlobRequest)(nil), // 37: resource.GetBlobRequest - (*GetBlobResponse)(nil), // 38: resource.GetBlobResponse - (*WatchEvent_Resource)(nil), // 39: resource.WatchEvent.Resource + (ResourceVersionMatch)(0), // 0: resource.ResourceVersionMatch + (WatchEvent_Type)(0), // 1: resource.WatchEvent.Type + (HealthCheckResponse_ServingStatus)(0), // 2: resource.HealthCheckResponse.ServingStatus + (ResourceTableColumnDefinition_ColumnType)(0), // 3: resource.ResourceTableColumnDefinition.ColumnType + (PutBlobRequest_Method)(0), // 4: resource.PutBlobRequest.Method + (*ResourceKey)(nil), // 5: resource.ResourceKey + (*ResourceWrapper)(nil), // 6: resource.ResourceWrapper + (*ResourceMeta)(nil), // 7: resource.ResourceMeta + (*ErrorResult)(nil), // 8: resource.ErrorResult + (*ErrorDetails)(nil), // 9: resource.ErrorDetails + (*ErrorCause)(nil), // 10: resource.ErrorCause + (*CreateRequest)(nil), // 11: resource.CreateRequest + (*CreateResponse)(nil), // 12: resource.CreateResponse + (*UpdateRequest)(nil), // 13: resource.UpdateRequest + (*UpdateResponse)(nil), // 14: resource.UpdateResponse + (*DeleteRequest)(nil), // 15: resource.DeleteRequest + (*DeleteResponse)(nil), // 16: resource.DeleteResponse + (*ReadRequest)(nil), // 17: resource.ReadRequest + (*ReadResponse)(nil), // 18: resource.ReadResponse + (*Requirement)(nil), // 19: resource.Requirement + (*ListOptions)(nil), // 20: resource.ListOptions + (*ListRequest)(nil), // 21: resource.ListRequest + (*ListResponse)(nil), // 22: resource.ListResponse + (*WatchRequest)(nil), // 23: resource.WatchRequest + (*WatchEvent)(nil), // 24: resource.WatchEvent + (*SearchRequest)(nil), // 25: resource.SearchRequest + (*GroupBy)(nil), // 26: resource.GroupBy + (*Group)(nil), // 27: resource.Group + (*SearchResponse)(nil), // 28: resource.SearchResponse + (*HistoryRequest)(nil), // 29: resource.HistoryRequest + (*HistoryResponse)(nil), // 30: resource.HistoryResponse + (*OriginRequest)(nil), // 31: resource.OriginRequest + (*ResourceOriginInfo)(nil), // 32: resource.ResourceOriginInfo + (*OriginResponse)(nil), // 33: resource.OriginResponse + (*HealthCheckRequest)(nil), // 34: resource.HealthCheckRequest + (*HealthCheckResponse)(nil), // 35: resource.HealthCheckResponse + (*ResourceTable)(nil), // 36: resource.ResourceTable + (*ResourceTableColumnDefinition)(nil), // 37: resource.ResourceTableColumnDefinition + (*ResourceTableRow)(nil), // 38: resource.ResourceTableRow + (*PutBlobRequest)(nil), // 39: resource.PutBlobRequest + (*PutBlobResponse)(nil), // 40: resource.PutBlobResponse + (*GetBlobRequest)(nil), // 41: resource.GetBlobRequest + (*GetBlobResponse)(nil), // 42: resource.GetBlobResponse + (*WatchEvent_Resource)(nil), // 43: resource.WatchEvent.Resource + (*ResourceTableColumnDefinition_Properties)(nil), // 44: resource.ResourceTableColumnDefinition.Properties } var file_resource_proto_depIdxs = []int32{ - 8, // 0: resource.ErrorResult.details:type_name -> resource.ErrorDetails - 9, // 1: resource.ErrorDetails.causes:type_name -> resource.ErrorCause - 4, // 2: resource.CreateRequest.key:type_name -> resource.ResourceKey - 7, // 3: resource.CreateResponse.error:type_name -> resource.ErrorResult - 4, // 4: resource.UpdateRequest.key:type_name -> resource.ResourceKey - 7, // 5: resource.UpdateResponse.error:type_name -> resource.ErrorResult - 4, // 6: resource.DeleteRequest.key:type_name -> resource.ResourceKey - 7, // 7: resource.DeleteResponse.error:type_name -> resource.ErrorResult - 4, // 8: resource.ReadRequest.key:type_name -> resource.ResourceKey - 7, // 9: resource.ReadResponse.error:type_name -> resource.ErrorResult - 4, // 10: resource.ListOptions.key:type_name -> resource.ResourceKey - 18, // 11: resource.ListOptions.labels:type_name -> resource.Requirement - 18, // 12: resource.ListOptions.fields:type_name -> resource.Requirement + 9, // 0: resource.ErrorResult.details:type_name -> resource.ErrorDetails + 10, // 1: resource.ErrorDetails.causes:type_name -> resource.ErrorCause + 5, // 2: resource.CreateRequest.key:type_name -> resource.ResourceKey + 8, // 3: resource.CreateResponse.error:type_name -> resource.ErrorResult + 5, // 4: resource.UpdateRequest.key:type_name -> resource.ResourceKey + 8, // 5: resource.UpdateResponse.error:type_name -> resource.ErrorResult + 5, // 6: resource.DeleteRequest.key:type_name -> resource.ResourceKey + 8, // 7: resource.DeleteResponse.error:type_name -> resource.ErrorResult + 5, // 8: resource.ReadRequest.key:type_name -> resource.ResourceKey + 8, // 9: resource.ReadResponse.error:type_name -> resource.ErrorResult + 5, // 10: resource.ListOptions.key:type_name -> resource.ResourceKey + 19, // 11: resource.ListOptions.labels:type_name -> resource.Requirement + 19, // 12: resource.ListOptions.fields:type_name -> resource.Requirement 0, // 13: resource.ListRequest.version_match:type_name -> resource.ResourceVersionMatch - 19, // 14: resource.ListRequest.options:type_name -> resource.ListOptions - 5, // 15: resource.ListResponse.items:type_name -> resource.ResourceWrapper - 7, // 16: resource.ListResponse.error:type_name -> resource.ErrorResult - 19, // 17: resource.WatchRequest.options:type_name -> resource.ListOptions + 20, // 14: resource.ListRequest.options:type_name -> resource.ListOptions + 6, // 15: resource.ListResponse.items:type_name -> resource.ResourceWrapper + 8, // 16: resource.ListResponse.error:type_name -> resource.ErrorResult + 20, // 17: resource.WatchRequest.options:type_name -> resource.ListOptions 1, // 18: resource.WatchEvent.type:type_name -> resource.WatchEvent.Type - 39, // 19: resource.WatchEvent.resource:type_name -> resource.WatchEvent.Resource - 39, // 20: resource.WatchEvent.previous:type_name -> resource.WatchEvent.Resource - 25, // 21: resource.SearchRequest.groupBy:type_name -> resource.GroupBy - 5, // 22: resource.SearchResponse.items:type_name -> resource.ResourceWrapper - 26, // 23: resource.SearchResponse.groups:type_name -> resource.Group - 4, // 24: resource.HistoryRequest.key:type_name -> resource.ResourceKey - 6, // 25: resource.HistoryResponse.items:type_name -> resource.ResourceMeta - 7, // 26: resource.HistoryResponse.error:type_name -> resource.ErrorResult - 4, // 27: resource.OriginRequest.key:type_name -> resource.ResourceKey - 4, // 28: resource.ResourceOriginInfo.key:type_name -> resource.ResourceKey - 31, // 29: resource.OriginResponse.items:type_name -> resource.ResourceOriginInfo - 7, // 30: resource.OriginResponse.error:type_name -> resource.ErrorResult + 43, // 19: resource.WatchEvent.resource:type_name -> resource.WatchEvent.Resource + 43, // 20: resource.WatchEvent.previous:type_name -> resource.WatchEvent.Resource + 26, // 21: resource.SearchRequest.groupBy:type_name -> resource.GroupBy + 6, // 22: resource.SearchResponse.items:type_name -> resource.ResourceWrapper + 27, // 23: resource.SearchResponse.groups:type_name -> resource.Group + 5, // 24: resource.HistoryRequest.key:type_name -> resource.ResourceKey + 7, // 25: resource.HistoryResponse.items:type_name -> resource.ResourceMeta + 8, // 26: resource.HistoryResponse.error:type_name -> resource.ErrorResult + 5, // 27: resource.OriginRequest.key:type_name -> resource.ResourceKey + 5, // 28: resource.ResourceOriginInfo.key:type_name -> resource.ResourceKey + 32, // 29: resource.OriginResponse.items:type_name -> resource.ResourceOriginInfo + 8, // 30: resource.OriginResponse.error:type_name -> resource.ErrorResult 2, // 31: resource.HealthCheckResponse.status:type_name -> resource.HealthCheckResponse.ServingStatus - 4, // 32: resource.PutBlobRequest.resource:type_name -> resource.ResourceKey - 3, // 33: resource.PutBlobRequest.method:type_name -> resource.PutBlobRequest.Method - 7, // 34: resource.PutBlobResponse.error:type_name -> resource.ErrorResult - 4, // 35: resource.GetBlobRequest.resource:type_name -> resource.ResourceKey - 7, // 36: resource.GetBlobResponse.error:type_name -> resource.ErrorResult - 16, // 37: resource.ResourceStore.Read:input_type -> resource.ReadRequest - 10, // 38: resource.ResourceStore.Create:input_type -> resource.CreateRequest - 12, // 39: resource.ResourceStore.Update:input_type -> resource.UpdateRequest - 14, // 40: resource.ResourceStore.Delete:input_type -> resource.DeleteRequest - 20, // 41: resource.ResourceStore.List:input_type -> resource.ListRequest - 22, // 42: resource.ResourceStore.Watch:input_type -> resource.WatchRequest - 24, // 43: resource.ResourceIndex.Search:input_type -> resource.SearchRequest - 28, // 44: resource.ResourceIndex.History:input_type -> resource.HistoryRequest - 30, // 45: resource.ResourceIndex.Origin:input_type -> resource.OriginRequest - 35, // 46: resource.BlobStore.PutBlob:input_type -> resource.PutBlobRequest - 37, // 47: resource.BlobStore.GetBlob:input_type -> resource.GetBlobRequest - 33, // 48: resource.Diagnostics.IsHealthy:input_type -> resource.HealthCheckRequest - 17, // 49: resource.ResourceStore.Read:output_type -> resource.ReadResponse - 11, // 50: resource.ResourceStore.Create:output_type -> resource.CreateResponse - 13, // 51: resource.ResourceStore.Update:output_type -> resource.UpdateResponse - 15, // 52: resource.ResourceStore.Delete:output_type -> resource.DeleteResponse - 21, // 53: resource.ResourceStore.List:output_type -> resource.ListResponse - 23, // 54: resource.ResourceStore.Watch:output_type -> resource.WatchEvent - 27, // 55: resource.ResourceIndex.Search:output_type -> resource.SearchResponse - 29, // 56: resource.ResourceIndex.History:output_type -> resource.HistoryResponse - 32, // 57: resource.ResourceIndex.Origin:output_type -> resource.OriginResponse - 36, // 58: resource.BlobStore.PutBlob:output_type -> resource.PutBlobResponse - 38, // 59: resource.BlobStore.GetBlob:output_type -> resource.GetBlobResponse - 34, // 60: resource.Diagnostics.IsHealthy:output_type -> resource.HealthCheckResponse - 49, // [49:61] is the sub-list for method output_type - 37, // [37:49] is the sub-list for method input_type - 37, // [37:37] is the sub-list for extension type_name - 37, // [37:37] is the sub-list for extension extendee - 0, // [0:37] is the sub-list for field type_name + 37, // 32: resource.ResourceTable.columns:type_name -> resource.ResourceTableColumnDefinition + 38, // 33: resource.ResourceTable.rows:type_name -> resource.ResourceTableRow + 3, // 34: resource.ResourceTableColumnDefinition.type:type_name -> resource.ResourceTableColumnDefinition.ColumnType + 44, // 35: resource.ResourceTableColumnDefinition.properties:type_name -> resource.ResourceTableColumnDefinition.Properties + 5, // 36: resource.ResourceTableRow.key:type_name -> resource.ResourceKey + 5, // 37: resource.PutBlobRequest.resource:type_name -> resource.ResourceKey + 4, // 38: resource.PutBlobRequest.method:type_name -> resource.PutBlobRequest.Method + 8, // 39: resource.PutBlobResponse.error:type_name -> resource.ErrorResult + 5, // 40: resource.GetBlobRequest.resource:type_name -> resource.ResourceKey + 8, // 41: resource.GetBlobResponse.error:type_name -> resource.ErrorResult + 17, // 42: resource.ResourceStore.Read:input_type -> resource.ReadRequest + 11, // 43: resource.ResourceStore.Create:input_type -> resource.CreateRequest + 13, // 44: resource.ResourceStore.Update:input_type -> resource.UpdateRequest + 15, // 45: resource.ResourceStore.Delete:input_type -> resource.DeleteRequest + 21, // 46: resource.ResourceStore.List:input_type -> resource.ListRequest + 23, // 47: resource.ResourceStore.Watch:input_type -> resource.WatchRequest + 25, // 48: resource.ResourceIndex.Search:input_type -> resource.SearchRequest + 29, // 49: resource.ResourceIndex.History:input_type -> resource.HistoryRequest + 31, // 50: resource.ResourceIndex.Origin:input_type -> resource.OriginRequest + 39, // 51: resource.BlobStore.PutBlob:input_type -> resource.PutBlobRequest + 41, // 52: resource.BlobStore.GetBlob:input_type -> resource.GetBlobRequest + 34, // 53: resource.Diagnostics.IsHealthy:input_type -> resource.HealthCheckRequest + 18, // 54: resource.ResourceStore.Read:output_type -> resource.ReadResponse + 12, // 55: resource.ResourceStore.Create:output_type -> resource.CreateResponse + 14, // 56: resource.ResourceStore.Update:output_type -> resource.UpdateResponse + 16, // 57: resource.ResourceStore.Delete:output_type -> resource.DeleteResponse + 22, // 58: resource.ResourceStore.List:output_type -> resource.ListResponse + 24, // 59: resource.ResourceStore.Watch:output_type -> resource.WatchEvent + 28, // 60: resource.ResourceIndex.Search:output_type -> resource.SearchResponse + 30, // 61: resource.ResourceIndex.History:output_type -> resource.HistoryResponse + 33, // 62: resource.ResourceIndex.Origin:output_type -> resource.OriginResponse + 40, // 63: resource.BlobStore.PutBlob:output_type -> resource.PutBlobResponse + 42, // 64: resource.BlobStore.GetBlob:output_type -> resource.GetBlobResponse + 35, // 65: resource.Diagnostics.IsHealthy:output_type -> resource.HealthCheckResponse + 54, // [54:66] is the sub-list for method output_type + 42, // [42:54] is the sub-list for method input_type + 42, // [42:42] is the sub-list for extension type_name + 42, // [42:42] is the sub-list for extension extendee + 0, // [0:42] is the sub-list for field type_name } func init() { file_resource_proto_init() } @@ -3268,8 +3773,8 @@ func file_resource_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_resource_proto_rawDesc, - NumEnums: 4, - NumMessages: 36, + NumEnums: 5, + NumMessages: 40, NumExtensions: 0, NumServices: 4, }, diff --git a/pkg/storage/unified/resource/resource.proto b/pkg/storage/unified/resource/resource.proto index d403865d03c..9c89f86ef1a 100644 --- a/pkg/storage/unified/resource/resource.proto +++ b/pkg/storage/unified/resource/resource.proto @@ -108,24 +108,24 @@ message ErrorDetails { } message ErrorCause { - // A machine-readable description of the cause of the error. If this value is - // empty there is no information available. - string reason = 1; - // A human-readable description of the cause of the error. This field may be - // presented as-is to a reader. - // +optional - string message = 2; - // 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" - // +optional - string field = 3; + // A machine-readable description of the cause of the error. If this value is + // empty there is no information available. + string reason = 1; + // A human-readable description of the cause of the error. This field may be + // presented as-is to a reader. + // +optional + string message = 2; + // 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" + // +optional + string field = 3; } // ---------------------------------- @@ -448,6 +448,118 @@ message HealthCheckResponse { ServingStatus status = 1; } +// ResourceTable is a protobuf variation of the kubernetes Table object. +// This format allows specifying a flexible set of columns related to a given resource +message ResourceTable { + // Columns describes each column in the returned items array. The number of cells per row + // will always match the number of column definitions. + repeated ResourceTableColumnDefinition columns = 1; + + // rows is the list of items in the table. + repeated ResourceTableRow rows = 2; + + // When more results exist, pass this in the next request + string next_page_token = 3; + + // ResourceVersion of the list response + // +optional + int64 resource_version = 4; + + // remainingItemCount is the number of subsequent items in the list which are not included in this + // list response. If the list request contained label or field selectors, then the number of + // remaining items is unknown and the field will be left unset and omitted during serialization. + // If the list is complete (either because it is not chunking or because this is the last chunk), + // then there are no more remaining items and this field will be left unset and omitted during + // serialization. + // + // The intended use of the remainingItemCount is *estimating* the size of a collection. Clients + // should not rely on the remainingItemCount to be set or to be exact. + // +optional + int64 remaining_item_count = 5; +} + +// TableColumnDefinition contains information about a column returned in the Table. +message ResourceTableColumnDefinition { + // See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for more. + // When converted to a k8s Table, this will become two fields: type and format + enum ColumnType { + UNKNOWN_TYPE = 0; + STRING = 1; + BOOLEAN = 2; + INT32 = 3; + INT64 = 4; + FLOAT = 5; + DOUBLE = 6; + DATE = 7; + DATE_TIME = 8; + BINARY = 9; + OBJECT = 10; // map[string]any + } + + // These values are not part of standard k8s format + // however these are useful when indexing and analyzing results + message Properties { + // All values in this columns should be unique + bool unique_values = 1; + + // The string value is free text; using text analyzers is appropriate + bool free_text = 2; + + // The value(s) are reasonable to use for search refinement + // When indexing, these values would be good to add to an index + bool filterable = 3; + + // When true, every value should exist + // not_null with a nil default_value should be an error + bool not_null = 4; + + // When missing, this value can be used + bytes default_value = 5; + } + + // name is a human readable name for the column. + string name = 1; + + // Defines the column type. In k8s, this will resolve into both the type and format fields + ColumnType type = 2; + + // The value is an arry of given type + bool is_array = 3; + + // description is a human readable description of this column. + string description = 4; + + // Properties about this column (helpful for indexing and search) + Properties properties = 5; + + // priority is an integer defining the relative importance of this column compared to others. Lower + // numbers are considered higher priority. Columns that may be omitted in limited space scenarios + // should be given a higher priority. + int32 priority = 6; +} + +// TableRow is an individual row in a table. +message ResourceTableRow { + // The resource referenced by this row + ResourceKey key = 1; + + // The resource version for the given values + int64 resource_version = 2; + + // Cells will be as wide as the column definitions array + // Numeric values will be encoded using big endian bytes + // All arrays will be JSON encoded + repeated bytes cells = 3; + + // This field may contains the additional information about each object based on the request. + // The value will be at least a partial object metadata, and perhaps the full object metadata. + // When this value exists, it should include both the key and the resource_version otherwise + // they may be lost in the conversion to k8s resource + // +optional + bytes object = 4; +} + + //---------------------------- // Blob Support //---------------------------- diff --git a/pkg/storage/unified/resource/table.go b/pkg/storage/unified/resource/table.go new file mode 100644 index 00000000000..a0f6574c07e --- /dev/null +++ b/pkg/storage/unified/resource/table.go @@ -0,0 +1,549 @@ +package resource + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + reflect "reflect" + "strconv" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter" +) + +// Convert the the protobuf model into k8s (will decode each value) +func (x *ResourceTable) ToK8s() (metav1.Table, error) { + table := metav1.Table{ + ListMeta: metav1.ListMeta{ + Continue: x.NextPageToken, + }, + } + if x.RemainingItemCount > 0 { + table.RemainingItemCount = &x.RemainingItemCount + } + if x.ResourceVersion > 0 { + table.ResourceVersion = strconv.FormatInt(x.ResourceVersion, 10) + } + + columnCount := len(x.Columns) + columns := make([]resourceTableColumn, columnCount) + table.ColumnDefinitions = make([]metav1.TableColumnDefinition, columnCount) + for i, c := range x.Columns { + col, err := newResourceTableColumn(c, i) + if err != nil { + return table, err + } + columns[i] = *col + table.ColumnDefinitions[i] = metav1.TableColumnDefinition{ + Name: c.Name, + Description: c.Description, + Priority: c.Priority, + Type: col.OpenAPIType, + Format: col.OpenAPIFormat, + } + } + + var err error + table.Rows = make([]metav1.TableRow, len(x.Rows)) + for i, r := range x.Rows { + row := metav1.TableRow{ + Cells: make([]interface{}, len(r.Cells)), + } + if len(r.Cells) != columnCount { + return table, fmt.Errorf("invalid cells size (have=%d, expect=%d)", len(r.Cells), columnCount) + } + + for j, v := range r.Cells { + row.Cells[j], err = columns[j].Decode(v) + if err != nil { + col := columns[j] + return table, fmt.Errorf("error decoding (row=%d, column=%d, type=%s) %w", i, j, col.def.Type.String(), err) + } + } + + // The raw object value + if r.Object != nil { + row.Object = runtime.RawExtension{ + Raw: r.Object, + } + } else if r.Key != nil { + obj := &metav1.PartialObjectMetadata{ + ObjectMeta: metav1.ObjectMeta{ + Name: r.Key.Name, + Namespace: r.Key.Namespace, + }, + } + if r.ResourceVersion > 0 { + obj.ResourceVersion = strconv.FormatInt(r.ResourceVersion, 10) + } + row.Object.Object = obj + row.Object.Raw, err = json.Marshal(obj) + if err != nil { + return table, err + } + } + table.Rows[i] = row + } + return table, err +} + +type TableBuilder struct { + ResourceTable + + lookup map[string]*resourceTableColumn + + // Just keep track of it + hasDuplicateNames bool +} + +func NewTableBuilder(cols []*ResourceTableColumnDefinition) (*TableBuilder, error) { + table := &TableBuilder{ + ResourceTable: ResourceTable{ + Columns: cols, + }, + + lookup: make(map[string]*resourceTableColumn, len(cols)), + } + var err error + for i, v := range cols { + if table.lookup[v.Name] != nil { + table.hasDuplicateNames = true + continue + } + table.lookup[v.Name], err = newResourceTableColumn(v, i) + if err != nil { + return nil, err + } + } + return table, err +} + +func (x *TableBuilder) AddRow(key *ResourceKey, rv int64, vals map[string]any) error { + row := &ResourceTableRow{ + Key: key, + ResourceVersion: rv, + Cells: make([][]byte, len(x.Columns)), + } + + for k, v := range vals { + column, ok := x.lookup[k] + if !ok { + return fmt.Errorf("unknown column: %s", k) + } + b, err := column.Encode(v) + if err != nil { + return err + } + row.Cells[column.index] = b + } + + x.Rows = append(x.Rows, row) + return nil +} + +type resourceTableColumn struct { + def *ResourceTableColumnDefinition + index int + + // Used for array indexing + reader func(iter *jsoniter.Iterator) (any, error) + writer func(v any, stream *jsoniter.Stream) error + + OpenAPIType string + OpenAPIFormat string +} + +// nolint:gocyclo +func newResourceTableColumn(def *ResourceTableColumnDefinition, index int) (*resourceTableColumn, error) { + col := &resourceTableColumn{def: def, index: index} + + // Initially ignore the array property, we wil wrap that at the end + switch def.Type { + case ResourceTableColumnDefinition_UNKNOWN_TYPE: + return nil, fmt.Errorf("unknown column type") + + case ResourceTableColumnDefinition_STRING: + col.OpenAPIType = "string" + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.ReadString() + } + + case ResourceTableColumnDefinition_BOOLEAN: + col.OpenAPIType = "boolean" + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.ReadBool() + } + + case ResourceTableColumnDefinition_INT32: + col.OpenAPIType = "number" + col.OpenAPIFormat = "int32" + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.ReadInt32() + } + + case ResourceTableColumnDefinition_INT64: + col.OpenAPIType = "number" + col.OpenAPIFormat = "int64" + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.ReadInt64() + } + + case ResourceTableColumnDefinition_DOUBLE: + col.OpenAPIType = "number" + col.OpenAPIFormat = "double" + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.ReadFloat64() + } + + case ResourceTableColumnDefinition_FLOAT: + col.OpenAPIType = "number" + col.OpenAPIFormat = "float" + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.ReadFloat32() + } + + // Encode everything we can -- the lower conversion can happen later? + case ResourceTableColumnDefinition_DATE, ResourceTableColumnDefinition_DATE_TIME: + col.OpenAPIType = "string" + col.OpenAPIFormat = "date" + if def.Type == ResourceTableColumnDefinition_DATE_TIME { + col.OpenAPIFormat = "date_time" + } + col.writer = func(v any, stream *jsoniter.Stream) error { + var t time.Time + + switch typed := v.(type) { + case time.Time: + t = typed + case *time.Time: + t = *typed + case int64: + t = time.UnixMilli(typed) + default: + return fmt.Errorf("unsupported date conversion (%t)", v) + } + + // encode as millis has fastest parsing + stream.WriteInt64(t.UnixMilli()) + return stream.Error + } + col.reader = func(iter *jsoniter.Iterator) (any, error) { + nxt, err := iter.WhatIsNext() + if err != nil { + return nil, err + } + switch nxt { + case jsoniter.NumberValue: + ts, err := iter.ReadInt64() + if err != nil { + return nil, err + } + return time.UnixMilli(ts).UTC(), nil + + default: + return nil, fmt.Errorf("unexpected JSON for date: %+v", nxt) + } + } + + case ResourceTableColumnDefinition_BINARY: + col.OpenAPIType = "binary" + col.writer = func(v any, stream *jsoniter.Stream) error { + b, ok := v.([]byte) + if !ok { + return fmt.Errorf("unexpected binary type, found: %t", v) + } + str := base64.StdEncoding.EncodeToString(b) + stream.WriteString(str) + return stream.Error + } + col.reader = func(iter *jsoniter.Iterator) (any, error) { + str, err := iter.ReadString() + if err != nil { + return nil, err + } + return base64.StdEncoding.DecodeString(str) + } + + case ResourceTableColumnDefinition_OBJECT: + col.OpenAPIType = "string" + col.OpenAPIFormat = "json" + + col.reader = func(iter *jsoniter.Iterator) (any, error) { + return iter.Read() + } + } + + return col, nil +} + +func (x *resourceTableColumn) IsNotNil() bool { + if x.def.Properties != nil { + return x.def.Properties.NotNull + } + return false +} + +// nolint:gocyclo +func (x *resourceTableColumn) Encode(v any) ([]byte, error) { + if v == nil { + if x.IsNotNil() { + return nil, fmt.Errorf("expecting non-null value") + } + return nil, nil // no types to write + } + + // Arrays will always use JSON formatting + if !x.def.IsArray { + switch x.def.Type { + case ResourceTableColumnDefinition_STRING: + { + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("expecting a string field") + } + return []byte(s), nil + } + case ResourceTableColumnDefinition_BINARY: + { + s, ok := v.([]byte) + if !ok { + return nil, fmt.Errorf("expecting a byte array") + } + return s, nil + } + case ResourceTableColumnDefinition_BOOLEAN: + { + b, ok := v.(bool) + if !ok { + switch typed := v.(type) { + case *bool: + b = *typed + case int: + b = typed != 0 + case int32: + b = typed != 0 + case int64: + b = typed != 0 + default: + return nil, fmt.Errorf("unexpected input for double field: %t", v) + } + } + if b { + return []byte{1}, nil + } + return []byte{0}, nil + } + case ResourceTableColumnDefinition_DATE_TIME, ResourceTableColumnDefinition_DATE: + { + f, ok := v.(time.Time) + if !ok { + switch typed := v.(type) { + case *time.Time: + f = *typed + case metav1.Time: + f = typed.Time + case *metav1.Time: + f = typed.Time + case int64: + f = time.UnixMilli(typed) + default: + return nil, fmt.Errorf("unexpected input for time field: %t", v) + } + } + ts := f.UnixMilli() + var buf bytes.Buffer + err := binary.Write(&buf, binary.BigEndian, ts) + return buf.Bytes(), err + } + case ResourceTableColumnDefinition_DOUBLE: + { + f, ok := v.(float64) + if !ok { + switch typed := v.(type) { + case int: + f = float64(typed) + case int64: + f = float64(typed) + case float32: + f = float64(typed) + case uint64: + f = float64(typed) + case uint: + f = float64(typed) + default: + return nil, fmt.Errorf("unexpected input for double field: %t", v) + } + } + var buf bytes.Buffer + err := binary.Write(&buf, binary.BigEndian, f) + return buf.Bytes(), err + } + case ResourceTableColumnDefinition_INT64: + { + f, ok := v.(int64) + if !ok { + switch typed := v.(type) { + case int: + f = int64(typed) + case int32: + f = int64(typed) + case float32: + f = int64(typed) + case uint64: + f = int64(typed) + case uint: + f = int64(typed) + default: + return nil, fmt.Errorf("unexpected input for int64 field: %t", v) + } + } + var buf bytes.Buffer + err := binary.Write(&buf, binary.BigEndian, f) + return buf.Bytes(), err + } + default: + // use JSON encoding below + } + } + + buff := bytes.NewBuffer(make([]byte, 0, 128)) + cfg := jsoniter.ConfigCompatibleWithStandardLibrary + stream := cfg.BorrowStream(buff) + defer cfg.ReturnStream(stream) + var err error + + writer := func(v any) error { + if v == nil { + stream.WriteNil() // only happens in an array + } else if x.writer != nil { + return x.writer(v, stream) + } else { + stream.WriteVal(v) + } + return stream.Error + } + + if x.def.IsArray { + stream.WriteArrayStart() + + switch reflect.TypeOf(v).Kind() { + case reflect.Slice, reflect.Array: + s := reflect.ValueOf(v) + for i := 0; i < s.Len(); i++ { + if i > 0 { + stream.WriteMore() + } + sub := s.Index(i).Interface() + err = writer(sub) + if err != nil { + return nil, err + } + } + default: + // single value? just write it and we will see? + err = writer(v) + if err != nil { + return nil, err + } + } + + stream.WriteArrayEnd() + } else { + err = writer(v) + } + if err != nil { + return nil, err + } + if stream.Error != nil { + return nil, stream.Error + } + + err = stream.Flush() + if err != nil { + return nil, err + } + return json.RawMessage(buff.Bytes()), nil +} + +// nolint:gocyclo +func (x *resourceTableColumn) Decode(buff []byte) (any, error) { + if len(buff) == 0 { + return nil, nil + } + if !x.def.IsArray { + switch x.def.Type { + case ResourceTableColumnDefinition_STRING: + return string(buff), nil + case ResourceTableColumnDefinition_BINARY: + return buff, nil + case ResourceTableColumnDefinition_BOOLEAN: + if len(buff) == 1 { + return buff[0] != 0, nil + } + case ResourceTableColumnDefinition_DOUBLE: + { + var f float64 + count, err := binary.Decode(buff, binary.BigEndian, &f) + if count == 8 && err == nil { + return f, nil + } + } + case ResourceTableColumnDefinition_INT64: + { + var f int64 + count, err := binary.Decode(buff, binary.BigEndian, &f) + if count == 8 && err == nil { + return f, nil + } + } + case ResourceTableColumnDefinition_DATE_TIME, ResourceTableColumnDefinition_DATE: + { + var f int64 + count, err := binary.Decode(buff, binary.BigEndian, &f) + if count == 8 && err == nil { + return time.UnixMilli(f).UTC(), nil + } + } + default: + // use JSON decoding below + } + } + + iter, err := jsoniter.ParseBytes(jsoniter.ConfigDefault, buff) + if err != nil { + return nil, err + } + + if x.def.IsArray { + vals := []any{} // it may have nulls + + for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() { + if err != nil { + return nil, err + } + v, err := x.reader(iter) + //nolint:errorlint + if err != nil && err != io.EOF { // EOF is normal when jsoniter is done + return nil, err + } + vals = append(vals, v) + } + + return vals, iter.ReadError() + } + + v, err := x.reader(iter) + //nolint:errorlint + if err == io.EOF { + err = nil + } else if err != nil { + return nil, err + } + return v, err +} diff --git a/pkg/storage/unified/resource/table_test.go b/pkg/storage/unified/resource/table_test.go new file mode 100644 index 00000000000..17c110ea18f --- /dev/null +++ b/pkg/storage/unified/resource/table_test.go @@ -0,0 +1,343 @@ +package resource + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// AssertTableSnapshot will match a ResourceTable vs the saved value +func AssertTableSnapshot(t *testing.T, path string, table *ResourceTable) { + t.Helper() + + k8sTable, err := table.ToK8s() + require.NoError(t, err, "unable to create table response", path) + actual, err := json.MarshalIndent(k8sTable, "", " ") + require.NoError(t, err, "unable to write table json", path) + + // Safe to disable, this is a test. + // nolint:gosec + expected, err := os.ReadFile(path) + if err != nil || len(expected) < 1 { + assert.Fail(t, "missing file") + } else if assert.JSONEq(t, string(expected), string(actual)) { + return // everything is OK + } + + // Write the snapshot + // Safe to disable, this is a test. + // nolint:gosec + err = os.WriteFile(path, actual, 0600) + require.NoError(t, err) + fmt.Printf("Updated table snapshot: %s\n", path) +} + +func TestTableFormat(t *testing.T) { + columns := []*ResourceTableColumnDefinition{ + { + Name: "title", + Type: ResourceTableColumnDefinition_STRING, + }, + { + Name: "stats.count", + Type: ResourceTableColumnDefinition_INT64, + }, + { + Name: "number", + Type: ResourceTableColumnDefinition_DOUBLE, + + Description: "float64 value", + }, + { + Name: "tags", + Type: ResourceTableColumnDefinition_STRING, + IsArray: true, + }, + } + + var err error + builder, err := NewTableBuilder(columns) + require.NoError(t, err) + + err = builder.AddRow(&ResourceKey{ + Namespace: "default", + Group: "ggg", + Resource: "xyz", // does not have a home in table! + Name: "aaa", + }, 10, map[string]any{ + "title": "AAA", + "number": 12345, + "tags": "one", // becomes an array + }) + require.NoError(t, err) + + err = builder.AddRow(&ResourceKey{ + Namespace: "default", + Group: "ggg", + Resource: "xyz", // does not have a home in table! + Name: "bbb", + }, 10, map[string]any{ + "title": "BBB", + "stats.count": 12345, + "tags": []string{"one", "two"}, // becomes an array + }) + require.NoError(t, err) + + // Check the snapshot + AssertTableSnapshot(t, filepath.Join("testdata", "simple-table.json"), &builder.ResourceTable) +} + +func TestColumnEncoding(t *testing.T) { + tests := []struct { + // The table definition + def *ResourceTableColumnDefinition + + // Passed to the encode function + input any + + // Expected error from input + input_err error + + // Skip the encode step + raw []byte + + // Expected output from decode + output any + + // Expected error from decode + output_err error + }{ + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_STRING, + }, + input: "aaa", // expects output to match + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_STRING, + IsArray: true, + }, + input: "bbb", + output: []any{"bbb"}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_INT64, + }, + input: 12345, + output: int64(12345), + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_INT64, + IsArray: true, + }, + input: 12345, + output: []any{int64(12345)}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_DOUBLE, + }, + input: 12345, + output: float64(12345), + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_DOUBLE, + IsArray: true, + }, + input: 12345, + output: []any{float64(12345)}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_BOOLEAN, + }, + input: true, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_BOOLEAN, + IsArray: true, + }, + input: []any{true, false, true}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_FLOAT, + }, + input: 23.4, + output: float32(23.4), + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_FLOAT, + IsArray: true, + }, + input: 23.4, + output: []any{float32(23.4)}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_INT32, + }, + input: 56, + output: int32(56), + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_INT32, + IsArray: true, + }, + input: 56, + output: []any{int32(56)}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_DATE_TIME, + }, + input: time.UnixMilli(946674000000).UTC(), + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_DATE_TIME, + IsArray: true, + }, + input: time.UnixMilli(946674000000).UTC(), + output: []any{ + time.UnixMilli(946674000000).UTC(), + }, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_DATE, + }, + input: time.UnixMilli(946674000000).UTC(), + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_DATE, + IsArray: true, + }, + input: time.UnixMilli(946674000000).UTC(), + output: []any{ + time.UnixMilli(946674000000).UTC(), + }, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_BINARY, + }, + input: []byte{1, 2, 3, 4}, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_BINARY, + IsArray: true, + }, + input: []any{ + []byte{1, 2, 3, 4}, + }, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_OBJECT, + }, + input: map[string]any{ + "hello": "world", + }, + }, + { + def: &ResourceTableColumnDefinition{ + Type: ResourceTableColumnDefinition_OBJECT, + IsArray: true, + }, + input: map[string]any{ + "hello": "world", + }, + output: []any{ + map[string]any{ + "hello": "world", + }, + }, + }, + } + + // Keep track of the types that have tests + testedTypes := make(map[ResourceTableColumnDefinition_ColumnType]bool) + testedArrays := make(map[ResourceTableColumnDefinition_ColumnType]bool) + for _, test := range tests { + var sb strings.Builder + if test.def.IsArray { + sb.WriteString("[]") + testedArrays[test.def.Type] = true + } else { + testedTypes[test.def.Type] = true + } + sb.WriteString(test.def.Type.String()) + if test.def.Name != "" { + sb.WriteString("(") + sb.WriteString(test.def.Name) + sb.WriteString(")") + } + sb.WriteString("=") + sb.WriteString(fmt.Sprintf("%v", test.input)) + + t.Run(sb.String(), func(t *testing.T) { + t.Parallel() + + col, err := newResourceTableColumn(test.def, 0) + require.NoError(t, err) + + buff := test.raw + if buff == nil { + buff, err = col.Encode(test.input) + if test.input_err != nil { + require.Equal(t, test.input_err, err) + } else { + require.NoError(t, err) + } + } + + out, err := col.Decode(buff) + if test.output_err != nil { + require.Equal(t, test.output_err, err) + } else { + require.NoError(t, err) + } + + if test.output != nil { + require.Equal(t, test.output, out) + } else { + require.Equal(t, test.input, out) + } + }) + } + + t.Run("ensure type coverage", func(t *testing.T) { + missingTypes := []string{} + missingArrays := []string{} + + // Make sure we have at least one test for each type + for i := ResourceTableColumnDefinition_STRING; i <= ResourceTableColumnDefinition_OBJECT; i++ { + if !testedTypes[i] { + missingTypes = append(missingTypes, i.String()) + } + if !testedArrays[i] { + missingArrays = append(missingArrays, i.String()) + } + } + + require.Empty(t, missingTypes, "missing tests for types") + require.Empty(t, missingArrays, "missing array tests for types") + }) +} diff --git a/pkg/storage/unified/resource/testdata/simple-table.json b/pkg/storage/unified/resource/testdata/simple-table.json new file mode 100644 index 00000000000..d95379968b7 --- /dev/null +++ b/pkg/storage/unified/resource/testdata/simple-table.json @@ -0,0 +1,72 @@ +{ + "metadata": {}, + "columnDefinitions": [ + { + "name": "title", + "type": "string", + "format": "", + "description": "", + "priority": 0 + }, + { + "name": "stats.count", + "type": "number", + "format": "int64", + "description": "", + "priority": 0 + }, + { + "name": "number", + "type": "number", + "format": "double", + "description": "float64 value", + "priority": 0 + }, + { + "name": "tags", + "type": "string", + "format": "", + "description": "", + "priority": 0 + } + ], + "rows": [ + { + "cells": [ + "AAA", + null, + 12345, + [ + "one" + ] + ], + "object": { + "metadata": { + "name": "aaa", + "namespace": "default", + "resourceVersion": "10", + "creationTimestamp": null + } + } + }, + { + "cells": [ + "BBB", + 12345, + null, + [ + "one", + "two" + ] + ], + "object": { + "metadata": { + "name": "bbb", + "namespace": "default", + "resourceVersion": "10", + "creationTimestamp": null + } + } + } + ] +} \ No newline at end of file diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index e1b730d4744..ddbbd99ec7d 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" infraDB "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/authz" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -18,7 +19,7 @@ import ( ) // Creates a new ResourceServer -func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer, reg prometheus.Registerer) (resource.ResourceServer, error) { +func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer, reg prometheus.Registerer, ac authz.Client) (resource.ResourceServer, error) { apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver") opts := resource.ResourceServerOptions{ Tracer: tracer, @@ -27,7 +28,9 @@ func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg, fea }, Reg: reg, } - + if ac != nil { + opts.AccessClient = resource.NewAuthzLimitedClient(ac) + } // Support local file blob if strings.HasPrefix(opts.Blob.URL, "./data/") { dir := strings.Replace(opts.Blob.URL, "./data", cfg.DataPath, 1) diff --git a/pkg/storage/unified/sql/service.go b/pkg/storage/unified/sql/service.go index 83105efb371..62cf7612f40 100644 --- a/pkg/storage/unified/sql/service.go +++ b/pkg/storage/unified/sql/service.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/modules" "github.com/grafana/grafana/pkg/services/authn/grpcutils" + "github.com/grafana/grafana/pkg/services/authz" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/grpcserver" "github.com/grafana/grafana/pkg/services/grpcserver/interceptors" @@ -93,7 +94,12 @@ func ProvideUnifiedStorageGrpcService( } func (s *service) start(ctx context.Context) error { - server, err := NewResourceServer(ctx, s.db, s.cfg, s.features, s.tracing, s.reg) + authzClient, err := authz.ProvideStandaloneAuthZClient(s.cfg, s.features, s.tracing) + if err != nil { + return err + } + + server, err := NewResourceServer(ctx, s.db, s.cfg, s.features, s.tracing, s.reg, authzClient) if err != nil { return err } diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index d36a9a93c7f..6381e8609e8 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -357,8 +357,8 @@ func TestClientServer(t *testing.T) { dbstore := infraDB.InitTestDB(t) cfg := setting.NewCfg() - cfg.GRPCServerAddress = "localhost:0" // get a free address - cfg.GRPCServerNetwork = "tcp" + cfg.GRPCServer.Address = "localhost:0" // get a free address + cfg.GRPCServer.Network = "tcp" features := featuremgmt.WithFeatures() diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 63272dc4f5d..0b675139d31 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -68,10 +68,10 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes listener2, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) - cfg.GRPCServerNetwork = "tcp" - cfg.GRPCServerAddress = listener2.Addr().String() - cfg.GRPCServerTLSConfig = nil - _, err = unistore.NewKey("address", cfg.GRPCServerAddress) + cfg.GRPCServer.Network = "tcp" + cfg.GRPCServer.Address = listener2.Addr().String() + cfg.GRPCServer.TLSConfig = nil + _, err = unistore.NewKey("address", cfg.GRPCServer.Address) require.NoError(t, err) // release the one we just discovered -- it will be used by the services on startup diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres.go b/pkg/tsdb/grafana-postgresql-datasource/postgres.go index 4f2c1ee3c90..66362d302e1 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres.go @@ -260,17 +260,9 @@ func (t *postgresQueryResultTransformer) TransformQueryError(_ log.Logger, err e func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { dsHandler, err := s.getDSInfo(ctx, req.PluginContext) if err != nil { - return nil, err + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()}, nil } - - err = dsHandler.Ping() - - if err != nil { - s.logger.Error("Check health failed", "error", err) - return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: dsHandler.TransformQueryError(s.logger, err).Error()}, nil - } - - return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil + return dsHandler.CheckHealth(ctx, req) } func (t *postgresQueryResultTransformer) GetConverterList() []sqlutil.StringConverter { diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go new file mode 100644 index 00000000000..0aef767f618 --- /dev/null +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go @@ -0,0 +1,62 @@ +package sqleng + +import ( + "context" + "encoding/json" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + err := e.Ping() + if err != nil { + logCheckHealthError(ctx, e.dsInfo, err, e.log) + if req.PluginContext.User.Role == "Admin" { + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()}, nil + } + errResponse := &backend.CheckHealthResult{ + Status: backend.HealthStatusError, + Message: e.TransformQueryError(e.log, err).Error(), + } + return errResponse, nil + } + return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil +} + +func logCheckHealthError(ctx context.Context, dsInfo DataSourceInfo, err error, logger log.Logger) { + configSummary := map[string]any{ + "config_url_length": len(dsInfo.URL), + "config_user_length": len(dsInfo.User), + "config_database_length": len(dsInfo.Database), + "config_json_data_database_length": len(dsInfo.JsonData.Database), + "config_max_open_conns": dsInfo.JsonData.MaxOpenConns, + "config_max_idle_conns": dsInfo.JsonData.MaxIdleConns, + "config_conn_max_life_time": dsInfo.JsonData.ConnMaxLifetime, + "config_conn_timeout": dsInfo.JsonData.ConnectionTimeout, + "config_timescaledb": dsInfo.JsonData.Timescaledb, + "config_ssl_mode": dsInfo.JsonData.Mode, + "config_tls_configuration_method": dsInfo.JsonData.ConfigurationMethod, + "config_tls_skip_verify": dsInfo.JsonData.TlsSkipVerify, + "config_timezone": dsInfo.JsonData.Timezone, + "config_time_interval": dsInfo.JsonData.TimeInterval, + "config_enable_secure_proxy": dsInfo.JsonData.SecureDSProxy, + "config_allow_clear_text_passwords": dsInfo.JsonData.AllowCleartextPasswords, + "config_authentication_type": dsInfo.JsonData.AuthenticationType, + "config_ssl_root_cert_file_length": len(dsInfo.JsonData.RootCertFile), + "config_ssl_cert_file_length": len(dsInfo.JsonData.CertFile), + "config_ssl_key_file_length": len(dsInfo.JsonData.CertKeyFile), + "config_encrypt_length": len(dsInfo.JsonData.Encrypt), + "config_server_name_length": len(dsInfo.JsonData.Servername), + "config_password_length": len(dsInfo.DecryptedSecureJSONData["password"]), + "config_tls_ca_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsCACert"]), + "config_tls_client_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsClientCert"]), + "config_tls_client_key_length": len(dsInfo.DecryptedSecureJSONData["tlsClientKey"]), + } + configSummaryJson, marshalError := json.Marshal(configSummary) + if marshalError != nil { + logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error", "plugin_id", "grafana-postgresql-datasource") + return + } + logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "plugin_id", "grafana-postgresql-datasource", "details", string(configSummaryJson)) +} diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 6b08adb8491..42b8fb5fe7c 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -123,6 +123,8 @@ func NewInstanceSettings(logger log.Logger) datasource.InstanceFactoryFunc { return nil, err } cnnstr += "&tls=" + tlsConfigString + } else if tlsConfig.InsecureSkipVerify { + cnnstr += "&tls=skip-verify" } if dsInfo.JsonData.Timezone != "" { diff --git a/pkg/tsdb/mysql/mysql_service.go b/pkg/tsdb/mysql/mysql_service.go index 439ad3cc220..a37b71cdbcc 100644 --- a/pkg/tsdb/mysql/mysql_service.go +++ b/pkg/tsdb/mysql/mysql_service.go @@ -37,7 +37,7 @@ func (s *Service) getDataSourceHandler(ctx context.Context, pluginCtx backend.Pl func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { dsHandler, err := s.getDataSourceHandler(ctx, req.PluginContext) if err != nil { - return nil, err + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()}, nil } return dsHandler.CheckHealth(ctx, req) diff --git a/pkg/tsdb/mysql/sqleng/handler_checkhealth.go b/pkg/tsdb/mysql/sqleng/handler_checkhealth.go new file mode 100644 index 00000000000..9f58da99da8 --- /dev/null +++ b/pkg/tsdb/mysql/sqleng/handler_checkhealth.go @@ -0,0 +1,64 @@ +package sqleng + +import ( + "context" + "encoding/json" + "errors" + + "github.com/go-sql-driver/mysql" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + err := e.db.Ping() + if err != nil { + logCheckHealthError(ctx, e.dsInfo, err, e.log) + if req.PluginContext.User.Role == "Admin" { + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()}, nil + } + var driverErr *mysql.MySQLError + if errors.As(err, &driverErr) { + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, driverErr).Error()}, nil + } + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, err).Error()}, nil + } + return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil +} + +func logCheckHealthError(ctx context.Context, dsInfo DataSourceInfo, err error, logger log.Logger) { + configSummary := map[string]any{ + "config_url_length": len(dsInfo.URL), + "config_user_length": len(dsInfo.User), + "config_database_length": len(dsInfo.Database), + "config_json_data_database_length": len(dsInfo.JsonData.Database), + "config_max_open_conns": dsInfo.JsonData.MaxOpenConns, + "config_max_idle_conns": dsInfo.JsonData.MaxIdleConns, + "config_conn_max_life_time": dsInfo.JsonData.ConnMaxLifetime, + "config_conn_timeout": dsInfo.JsonData.ConnectionTimeout, + "config_timescaledb": dsInfo.JsonData.Timescaledb, + "config_ssl_mode": dsInfo.JsonData.Mode, + "config_tls_configuration_method": dsInfo.JsonData.ConfigurationMethod, + "config_tls_skip_verify": dsInfo.JsonData.TlsSkipVerify, + "config_timezone": dsInfo.JsonData.Timezone, + "config_time_interval": dsInfo.JsonData.TimeInterval, + "config_enable_secure_proxy": dsInfo.JsonData.SecureDSProxy, + "config_allow_clear_text_passwords": dsInfo.JsonData.AllowCleartextPasswords, + "config_authentication_type": dsInfo.JsonData.AuthenticationType, + "config_ssl_root_cert_file_length": len(dsInfo.JsonData.RootCertFile), + "config_ssl_cert_file_length": len(dsInfo.JsonData.CertFile), + "config_ssl_key_file_length": len(dsInfo.JsonData.CertKeyFile), + "config_encrypt_length": len(dsInfo.JsonData.Encrypt), + "config_server_name_length": len(dsInfo.JsonData.Servername), + "config_password_length": len(dsInfo.DecryptedSecureJSONData["password"]), + "config_tls_ca_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsCACert"]), + "config_tls_client_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsClientCert"]), + "config_tls_client_key_length": len(dsInfo.DecryptedSecureJSONData["tlsClientKey"]), + } + configSummaryJson, marshalError := json.Marshal(configSummary) + if marshalError != nil { + logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error", "plugin_id", "mysql") + return + } + logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "plugin_id", "mysql", "details", string(configSummaryJson)) +} diff --git a/pkg/tsdb/mysql/sqleng/sql_engine.go b/pkg/tsdb/mysql/sqleng/sql_engine.go index 43ab9d47911..301e773334d 100644 --- a/pkg/tsdb/mysql/sqleng/sql_engine.go +++ b/pkg/tsdb/mysql/sqleng/sql_engine.go @@ -14,7 +14,6 @@ import ( "sync" "time" - "github.com/go-sql-driver/mysql" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/backend/log" @@ -153,19 +152,6 @@ func (e *DataSourceHandler) Dispose() { e.log.Debug("DB disposed") } -func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - err := e.db.Ping() - - if err != nil { - var driverErr *mysql.MySQLError - if errors.As(err, &driverErr) { - return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, driverErr).Error()}, nil - } - return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, err).Error()}, nil - } - return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil -} - func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { result := backend.NewQueryDataResponse() ch := make(chan DBDataResponse, len(req.Queries)) diff --git a/pkg/tsdb/tempo/tempo.go b/pkg/tsdb/tempo/tempo.go index 311993714da..96a295b7fac 100644 --- a/pkg/tsdb/tempo/tempo.go +++ b/pkg/tsdb/tempo/tempo.go @@ -109,8 +109,11 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) } func (s *Service) query(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) (*backend.DataResponse, error) { - if query.QueryType == string(dataquery.TempoQueryTypeTraceId) { + switch query.QueryType { + case string(dataquery.TempoQueryTypeTraceId): return s.getTrace(ctx, pCtx, query) + case string(dataquery.TempoQueryTypeTraceql): + return s.runTraceQlQuery(ctx, pCtx, query) } return nil, fmt.Errorf("unsupported query type: '%s' for query with refID '%s'", query.QueryType, query.RefID) } diff --git a/pkg/tsdb/tempo/trace_transform.go b/pkg/tsdb/tempo/trace_transform.go index 047db53adf0..971e9d1dd5d 100644 --- a/pkg/tsdb/tempo/trace_transform.go +++ b/pkg/tsdb/tempo/trace_transform.go @@ -62,8 +62,7 @@ func TraceToFrame(resourceSpans []*tracev11.ResourceSpans) (*data.Frame, error) data.NewField("tags", nil, []json.RawMessage{}), }, Meta: &data.FrameMeta{ - // TODO: use constant once available in the SDK - PreferredVisualization: "trace", + PreferredVisualization: data.VisTypeTrace, }, } diff --git a/pkg/tsdb/tempo/traceql/metrics.go b/pkg/tsdb/tempo/traceql/metrics.go new file mode 100644 index 00000000000..8d384089405 --- /dev/null +++ b/pkg/tsdb/tempo/traceql/metrics.go @@ -0,0 +1,76 @@ +package traceql + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/tempo/pkg/tempopb" + v1 "github.com/grafana/tempo/pkg/tempopb/common/v1" +) + +func TransformMetricsResponse(resp tempopb.QueryRangeResponse) []*data.Frame { + // prealloc frames + frames := make([]*data.Frame, len(resp.Series)) + for i, series := range resp.Series { + labels := make(data.Labels) + for _, label := range series.Labels { + labels[label.GetKey()] = metricsValueToString(label.GetValue()) + } + + name := "" + if len(series.Labels) > 0 { + if len(series.Labels) == 1 { + name = metricsValueToString(series.Labels[0].GetValue()) + } else { + var labelStrings []string + for key, val := range labels { + labelStrings = append(labelStrings, fmt.Sprintf("%s=%s", key, val)) + } + name = fmt.Sprintf("{%s}", strings.Join(labelStrings, ", ")) + } + } + + valueField := data.NewField(name, labels, []float64{}) + valueField.Config = &data.FieldConfig{ + DisplayName: name, + } + + timeField := data.NewField("time", nil, []time.Time{}) + + frame := &data.Frame{ + RefID: name, + Name: "Trace", + Fields: []*data.Field{ + timeField, + valueField, + }, + Meta: &data.FrameMeta{ + PreferredVisualization: data.VisTypeGraph, + }, + } + + for _, sample := range series.Samples { + frame.AppendRow(time.UnixMilli(sample.GetTimestampMs()), sample.GetValue()) + } + + frames[i] = frame + } + return frames +} + +func metricsValueToString(value *v1.AnyValue) string { + switch value.GetValue().(type) { + case *v1.AnyValue_DoubleValue: + return strconv.FormatFloat(value.GetDoubleValue(), 'f', -1, 64) + case *v1.AnyValue_IntValue: + return strconv.FormatInt(value.GetIntValue(), 10) + case *v1.AnyValue_StringValue: + return fmt.Sprintf("\"%s\"", value.GetStringValue()) + case *v1.AnyValue_BoolValue: + return strconv.FormatBool(value.GetBoolValue()) + } + return "" +} diff --git a/pkg/tsdb/tempo/traceql/metrics_test.go b/pkg/tsdb/tempo/traceql/metrics_test.go new file mode 100644 index 00000000000..29c95079a41 --- /dev/null +++ b/pkg/tsdb/tempo/traceql/metrics_test.go @@ -0,0 +1,115 @@ +package traceql + +import ( + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/tempo/pkg/tempopb" + v1 "github.com/grafana/tempo/pkg/tempopb/common/v1" + "github.com/stretchr/testify/assert" +) + +func TestTransformMetricsResponse_EmptyResponse(t *testing.T) { + resp := tempopb.QueryRangeResponse{} + frames := TransformMetricsResponse(resp) + assert.Empty(t, frames) +} + +func TestTransformMetricsResponse_SingleSeriesSingleLabel(t *testing.T) { + resp := tempopb.QueryRangeResponse{ + Series: []*tempopb.TimeSeries{ + { + Labels: []v1.KeyValue{ + {Key: "label1", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "value1"}}}, + }, + Samples: []tempopb.Sample{ + {TimestampMs: 1638316800000, Value: 1.23}, + }, + }, + }, + } + frames := TransformMetricsResponse(resp) + assert.Len(t, frames, 1) + assert.Equal(t, "\"value1\"", frames[0].RefID) + assert.Equal(t, "Trace", frames[0].Name) + assert.Len(t, frames[0].Fields, 2) + assert.Equal(t, "time", frames[0].Fields[0].Name) + assert.Equal(t, "\"value1\"", frames[0].Fields[1].Name) + assert.Equal(t, data.VisTypeGraph, frames[0].Meta.PreferredVisualization) + assert.Equal(t, time.UnixMilli(1638316800000), frames[0].Fields[0].At(0)) + assert.Equal(t, 1.23, frames[0].Fields[1].At(0)) +} + +func TestTransformMetricsResponse_SingleSeriesMultipleLabels(t *testing.T) { + // Skipping for now because this test is broken. + t.Skip() + + resp := tempopb.QueryRangeResponse{ + Series: []*tempopb.TimeSeries{ + { + Labels: []v1.KeyValue{ + {Key: "label1", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "value1"}}}, + {Key: "label2", Value: &v1.AnyValue{Value: &v1.AnyValue_IntValue{IntValue: 123}}}, + {Key: "label3", Value: &v1.AnyValue{Value: &v1.AnyValue_DoubleValue{DoubleValue: 123.456}}}, + {Key: "label4", Value: &v1.AnyValue{Value: &v1.AnyValue_BoolValue{BoolValue: true}}}, + }, + Samples: []tempopb.Sample{ + {TimestampMs: 1638316800000, Value: 1.23}, + }, + }, + }, + } + frames := TransformMetricsResponse(resp) + assert.Len(t, frames, 1) + assert.Equal(t, "{label1=\"value1\", label2=123, label3=123.456, label4=true}", frames[0].RefID) + assert.Equal(t, "Trace", frames[0].Name) + assert.Len(t, frames[0].Fields, 2) + assert.Equal(t, "time", frames[0].Fields[0].Name) + assert.Equal(t, "{label1=\"value1\", label2=123, label3=123.456, label4=true}", frames[0].Fields[1].Name) + assert.Equal(t, data.VisTypeGraph, frames[0].Meta.PreferredVisualization) + assert.Equal(t, time.UnixMilli(1638316800000), frames[0].Fields[0].At(0)) + assert.Equal(t, 1.23, frames[0].Fields[1].At(0)) +} + +func TestTransformMetricsResponse_MultipleSeries(t *testing.T) { + resp := tempopb.QueryRangeResponse{ + Series: []*tempopb.TimeSeries{ + { + Labels: []v1.KeyValue{ + {Key: "label1", Value: &v1.AnyValue{Value: &v1.AnyValue_StringValue{StringValue: "value1"}}}, + }, + Samples: []tempopb.Sample{ + {TimestampMs: 1638316800000, Value: 1.23}, + }, + }, + { + Labels: []v1.KeyValue{ + {Key: "label2", Value: &v1.AnyValue{Value: &v1.AnyValue_IntValue{IntValue: 456}}}, + }, + Samples: []tempopb.Sample{ + {TimestampMs: 1638316800000, Value: 4.56}, + }, + }, + }, + } + frames := TransformMetricsResponse(resp) + assert.Len(t, frames, 2) + assert.Equal(t, "\"value1\"", frames[0].RefID) + assert.Equal(t, "Trace", frames[0].Name) + assert.Len(t, frames[0].Fields, 2) + assert.Equal(t, "time", frames[0].Fields[0].Name) + assert.Equal(t, "\"value1\"", frames[0].Fields[1].Name) + assert.Equal(t, data.VisTypeGraph, frames[0].Meta.PreferredVisualization) + assert.Equal(t, time.UnixMilli(1638316800000), frames[0].Fields[0].At(0)) + assert.Equal(t, 1.23, frames[0].Fields[1].At(0)) + + assert.Equal(t, "456", frames[1].RefID) + assert.Equal(t, "Trace", frames[1].Name) + assert.Len(t, frames[1].Fields, 2) + assert.Equal(t, "time", frames[1].Fields[0].Name) + assert.Equal(t, "456", frames[1].Fields[1].Name) + assert.Equal(t, data.VisTypeGraph, frames[1].Meta.PreferredVisualization) + assert.Equal(t, time.UnixMilli(1638316800000), frames[1].Fields[0].At(0)) + assert.Equal(t, 4.56, frames[1].Fields[1].At(0)) +} diff --git a/pkg/tsdb/tempo/traceql_query.go b/pkg/tsdb/tempo/traceql_query.go new file mode 100644 index 00000000000..31dc60d6b48 --- /dev/null +++ b/pkg/tsdb/tempo/traceql_query.go @@ -0,0 +1,170 @@ +package tempo + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + + //nolint:all + "github.com/golang/protobuf/jsonpb" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" + "github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery" + "github.com/grafana/grafana/pkg/tsdb/tempo/traceql" + "github.com/grafana/tempo/pkg/tempopb" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +func (s *Service) runTraceQlQuery(ctx context.Context, pCtx backend.PluginContext, backendQuery backend.DataQuery) (*backend.DataResponse, error) { + ctxLogger := s.logger.FromContext(ctx) + ctxLogger.Debug("Running TraceQL query", "function", logEntrypoint()) + + tempoQuery := &dataquery.TempoQuery{} + err := json.Unmarshal(backendQuery.JSON, tempoQuery) + if err != nil { + ctxLogger.Error("Failed to unmarshall Tempo query model", "error", err, "function", logEntrypoint()) + return nil, err + } + + if isMetricsQuery(*tempoQuery.Query) { + return s.runTraceQlQueryMetrics(ctx, pCtx, backendQuery, tempoQuery) + } + + return s.runTraceQlQuerySearch() +} + +func (s *Service) runTraceQlQuerySearch() (*backend.DataResponse, error) { + return nil, fmt.Errorf("backend TraceQL search queries are not supported") +} + +func (s *Service) runTraceQlQueryMetrics(ctx context.Context, pCtx backend.PluginContext, backendQuery backend.DataQuery, tempoQuery *dataquery.TempoQuery) (*backend.DataResponse, error) { + ctxLogger := s.logger.FromContext(ctx) + ctxLogger.Debug("Running TraceQL Metrics query", "function", logEntrypoint()) + + ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.runTraceQLQuery", trace.WithAttributes( + attribute.String("queryType", backendQuery.QueryType), + )) + defer span.End() + + result := &backend.DataResponse{} + + dsInfo, err := s.getDSInfo(ctx, pCtx) + if err != nil { + ctxLogger.Error("Failed to get datasource information", "error", err, "function", logEntrypoint()) + return nil, err + } + + if tempoQuery.Query == nil || *tempoQuery.Query == "" { + err := fmt.Errorf("query is required") + ctxLogger.Error("Failed to validate model query", "error", err, "function", logEntrypoint()) + return result, err + } + + resp, responseBody, err := s.performMetricsQuery(ctx, dsInfo, tempoQuery, backendQuery, span) + defer func() { + if err := resp.Body.Close(); err != nil { + ctxLogger.Error("Failed to close response body", "error", err, "function", logEntrypoint()) + } + }() + if err != nil { + return result, err + } + + if resp.StatusCode != http.StatusOK { + ctxLogger.Error("Failed to execute TraceQL query", "error", err, "function", logEntrypoint()) + result.Error = fmt.Errorf("failed to execute TraceQL query: %s Status: %s Body: %s", *tempoQuery.Query, resp.Status, string(responseBody)) + span.RecordError(result.Error) + span.SetStatus(codes.Error, result.Error.Error()) + return result, nil + } + + var queryResponse tempopb.QueryRangeResponse + err = jsonpb.Unmarshal(bytes.NewReader(responseBody), &queryResponse) + + if err != nil { + ctxLogger.Error("Failed to convert response to type", "error", err, "function", logEntrypoint()) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return &backend.DataResponse{}, fmt.Errorf("failed to convert response to type: %w", err) + } + + frames := traceql.TransformMetricsResponse(queryResponse) + + result.Frames = frames + ctxLogger.Debug("Successfully performed TraceQL query", "function", logEntrypoint()) + return result, nil +} + +func (s *Service) performMetricsQuery(ctx context.Context, dsInfo *Datasource, model *dataquery.TempoQuery, query backend.DataQuery, span trace.Span) (*http.Response, []byte, error) { + ctxLogger := s.logger.FromContext(ctx) + request, err := s.createMetricsQuery(ctx, dsInfo, model, query.TimeRange.From.Unix(), query.TimeRange.To.Unix()) + + if err != nil { + ctxLogger.Error("Failed to create request", "error", err, "function", logEntrypoint()) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, nil, err + } + + resp, err := dsInfo.HTTPClient.Do(request) + if err != nil { + ctxLogger.Error("Failed to send request to Tempo", "error", err, "function", logEntrypoint()) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, nil, fmt.Errorf("failed get to tempo: %w", err) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + ctxLogger.Error("Failed to read response body", "error", err, "function", logEntrypoint()) + return nil, nil, err + } + return resp, body, nil +} + +func (s *Service) createMetricsQuery(ctx context.Context, dsInfo *Datasource, query *dataquery.TempoQuery, start int64, end int64) (*http.Request, error) { + ctxLogger := s.logger.FromContext(ctx) + + rawUrl := fmt.Sprintf("%s/api/metrics/query_range", dsInfo.URL) + searchUrl, err := url.Parse(rawUrl) + if err != nil { + ctxLogger.Error("Failed to parse URL", "url", rawUrl, "error", err, "function", logEntrypoint()) + return nil, err + } + + q := searchUrl.Query() + q.Set("q", *query.Query) + if start > 0 { + q.Set("start", strconv.FormatInt(start, 10)) + } + if end > 0 { + q.Set("end", strconv.FormatInt(end, 10)) + } + if query.Step != nil { + q.Set("step", *query.Step) + } + + searchUrl.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", searchUrl.String(), nil) + if err != nil { + ctxLogger.Error("Failed to create request", "error", err, "function", logEntrypoint()) + return nil, err + } + + req.Header.Set("Accept", "application/json") + return req, nil +} + +func isMetricsQuery(query string) bool { + match, _ := regexp.MatchString("\\|\\s*(rate|count_over_time|avg_over_time|max_over_time|min_over_time|quantile_over_time|histogram_over_time|compare)\\s*\\(", query) + return match +} diff --git a/pkg/tsdb/tempo/traceql_query_test.go b/pkg/tsdb/tempo/traceql_query_test.go new file mode 100644 index 00000000000..f8256cd0b04 --- /dev/null +++ b/pkg/tsdb/tempo/traceql_query_test.go @@ -0,0 +1,133 @@ +package tempo + +import ( + "context" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery" + "github.com/stretchr/testify/assert" +) + +func TestCreateMetricsQuery_Success(t *testing.T) { + logger := backend.NewLoggerWith("logger", "tsdb.tempo.test") + service := &Service{ + logger: logger, + } + dsInfo := &Datasource{ + URL: "http://tempo:3100", + } + queryVal := "{attribute=\"value\"}" + stepVal := "14" + query := &dataquery.TempoQuery{ + Query: &queryVal, + Step: &stepVal, + } + start := int64(1625097600) + end := int64(1625184000) + + req, err := service.createMetricsQuery(context.Background(), dsInfo, query, start, end) + assert.NoError(t, err) + assert.NotNil(t, req) + assert.Equal(t, "http://tempo:3100/api/metrics/query_range?end=1625184000&q=%7Battribute%3D%22value%22%7D&start=1625097600&step=14", req.URL.String()) + assert.Equal(t, "application/json", req.Header.Get("Accept")) +} +func TestCreateMetricsQuery_OnlyQuery(t *testing.T) { + logger := backend.NewLoggerWith("logger", "tsdb.tempo.test") + service := &Service{ + logger: logger, + } + dsInfo := &Datasource{ + URL: "http://tempo:3100", + } + queryVal := "{attribute=\"value\"}" + query := &dataquery.TempoQuery{ + Query: &queryVal, + } + + req, err := service.createMetricsQuery(context.Background(), dsInfo, query, 0, 0) + assert.NoError(t, err) + assert.NotNil(t, req) + assert.Equal(t, "http://tempo:3100/api/metrics/query_range?q=%7Battribute%3D%22value%22%7D", req.URL.String()) + assert.Equal(t, "application/json", req.Header.Get("Accept")) +} + +func TestCreateMetricsQuery_URLParseError(t *testing.T) { + logger := backend.NewLoggerWith("logger", "tsdb.tempo.test") + service := &Service{ + logger: logger, + } + dsInfo := &Datasource{ + URL: "http://[::1]:namedport", + } + queryVal := "{attribute=\"value\"}" + query := &dataquery.TempoQuery{ + Query: &queryVal, + } + start := int64(1625097600) + end := int64(1625184000) + + req, err := service.createMetricsQuery(context.Background(), dsInfo, query, start, end) + assert.Error(t, err) + assert.Nil(t, req) +} + +func TestEmptyQueryString_ReturnsFalse(t *testing.T) { + result := isMetricsQuery("") + assert.False(t, result) +} + +func TestQueryWithoutMetricsFunction_ReturnsFalse(t *testing.T) { + result := isMetricsQuery("{.some = \"random query\"} && {} >> {}") + assert.False(t, result) +} + +func TestQueryWithRateFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | rate(foo)") + assert.True(t, result) +} + +func TestQueryWithAvgOverTimeFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | avg_over_time(foo)") + assert.True(t, result) +} + +func TestQueryWithCountOverTimeFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | count_over_time(foo)") + assert.True(t, result) +} + +func TestQueryWithMaxOverTimeFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | max_over_time(foo)") + assert.True(t, result) +} + +func TestQueryWithMinOverTimeFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | min_over_time(foo)") + assert.True(t, result) +} + +func TestQueryWithQuantileOverTimeFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | quantile_over_time(foo)") + assert.True(t, result) +} + +func TestQueryWithHistogramOverTimeFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | histogram_over_time(foo)") + assert.True(t, result) +} + +func TestQueryWithCompareFunction_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | compare(foo)") + assert.True(t, result) +} + +func TestQueryWithMultipleFunctions_ReturnsTrue(t *testing.T) { + result := isMetricsQuery("{} | rate(foo) | avg_over_time(bar)") + assert.True(t, result) +} + +func TestQueryWithInvalidFunction_ReturnsFalse(t *testing.T) { + result := isMetricsQuery("{} | invalid_function(foo)") + assert.False(t, result) +} diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index dabf652a4da..462e38863a5 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -31,7 +31,9 @@ export interface Props { onConfirm?: () => Promise; } -export type State = UserPreferencesDTO; +export type State = UserPreferencesDTO & { + isLoading: boolean; +}; function getLanguageOptions(): ComboboxOption[] { const languageOptions = LANGUAGES.map((v) => ({ @@ -69,6 +71,7 @@ export class SharedPreferences extends PureComponent { this.service = new PreferencesService(props.resourceUri); this.state = { + isLoading: false, theme: '', timezone: '', weekStart: '', @@ -87,9 +90,13 @@ export class SharedPreferences extends PureComponent { } async componentDidMount() { + this.setState({ + isLoading: true, + }); const prefs = await this.service.load(); this.setState({ + isLoading: false, homeDashboardUID: prefs.homeDashboardUID, theme: prefs.theme, timezone: prefs.timezone, @@ -111,10 +118,7 @@ export class SharedPreferences extends PureComponent { } }; - onThemeChanged = (value: ComboboxOption | null) => { - if (!value) { - return; - } + onThemeChanged = (value: ComboboxOption) => { this.setState({ theme: value.value }); if (value.value) { @@ -147,7 +151,7 @@ export class SharedPreferences extends PureComponent { }; render() { - const { theme, timezone, weekStart, homeDashboardUID, language } = this.state; + const { theme, timezone, weekStart, homeDashboardUID, language, isLoading } = this.state; const { disabled } = this.props; const styles = getStyles(); const languages = getLanguageOptions(); @@ -156,7 +160,11 @@ export class SharedPreferences extends PureComponent { return (
Preferences} disabled={disabled}> - + { @@ -186,6 +196,8 @@ export class SharedPreferences extends PureComponent { @@ -198,17 +210,21 @@ export class SharedPreferences extends PureComponent { diff --git a/public/app/core/internationalization/constants.test.ts b/public/app/core/internationalization/constants.test.ts index 6dee3878b36..68defb081b3 100644 --- a/public/app/core/internationalization/constants.test.ts +++ b/public/app/core/internationalization/constants.test.ts @@ -21,7 +21,7 @@ describe('internationalization constants', () => { expect(GERMAN_GERMANY).toBe('de-DE'); expect(BRAZILIAN_PORTUGUESE).toBe('pt-BR'); expect(CHINESE_SIMPLIFIED).toBe('zh-Hans'); - expect(PSEUDO_LOCALE).toBe('pseudo-LOCALE'); + expect(PSEUDO_LOCALE).toBe('pseudo'); expect(DEFAULT_LANGUAGE).toBe(ENGLISH_US); }); diff --git a/public/app/core/internationalization/constants.ts b/public/app/core/internationalization/constants.ts index f4ab99631cc..b28e5499d92 100644 --- a/public/app/core/internationalization/constants.ts +++ b/public/app/core/internationalization/constants.ts @@ -7,7 +7,7 @@ export const SPANISH_SPAIN = 'es-ES'; export const GERMAN_GERMANY = 'de-DE'; export const BRAZILIAN_PORTUGUESE = 'pt-BR'; export const CHINESE_SIMPLIFIED = 'zh-Hans'; -export const PSEUDO_LOCALE = 'pseudo-LOCALE'; +export const PSEUDO_LOCALE = 'pseudo'; export const DEFAULT_LANGUAGE = ENGLISH_US; diff --git a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx index 38155aa917a..a34dde92bc8 100644 --- a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx @@ -3,9 +3,7 @@ import { FormProvider, useForm } from 'react-hook-form'; import { getWrapper, render, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils'; import { byRole, byTestId, byText } from 'testing-library-selector'; -import { selectors } from '@grafana/e2e-selectors/src'; import { setDataSourceSrv } from '@grafana/runtime'; -import { DashboardSearchItem, DashboardSearchItemType } from 'app/features/search/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; import { AccessControlAction } from '../../../types'; @@ -18,7 +16,7 @@ import { import { cloneRuleDefinition, CloneRuleEditor } from './CloneRuleEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { mockFeatureDiscoveryApi, mockSearchApi, setupMswServer } from './mockApi'; +import { mockFeatureDiscoveryApi, setupMswServer } from './mockApi'; import { grantUserPermissions, mockDataSource, @@ -44,12 +42,6 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({ ), })); -// For simplicity of the test we mock the NotificationPreview component -// Otherwise we would need to mock a few more HTTP api calls which are not relevant for these tests -jest.mock('./components/rule-editor/notificaton-preview/NotificationPreview', () => ({ - NotificationPreview: () =>
, -})); - jest.spyOn(AlertingQueryRunner.prototype, 'run').mockImplementation(() => Promise.resolve()); const server = setupMswServer(); @@ -58,7 +50,7 @@ const ui = { inputs: { name: byRole('textbox', { name: 'name' }), expr: byTestId('expr'), - folderContainer: byTestId(selectors.components.FolderPicker.containerV2), + folderContainer: byTestId('folder-picker'), namespace: byTestId('namespace-picker'), group: byTestId('group-picker'), annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`), @@ -84,14 +76,6 @@ describe('CloneRuleEditor', function () { it('should populate form values from the existing alert rule', async function () { setDataSourceSrv(new MockDataSourceSrv({})); - mockSearchApi(server).search([ - mockDashboardSearchItem({ - title: 'folder-one', - uid: grafanaRulerRule.grafana_alert.namespace_uid, - type: DashboardSearchItemType.DashDB, - }), - ]); - render( , { wrapper: Wrapper } @@ -105,7 +89,7 @@ describe('CloneRuleEditor', function () { await waitFor(() => { expect(ui.inputs.name.get()).toHaveValue(`${grafanaRulerRule.grafana_alert.title} (copy)`); }); - expect(ui.inputs.folderContainer.get()).toHaveTextContent('folder-one'); + expect(ui.inputs.folderContainer.get()).toHaveTextContent('Folder A'); expect(ui.inputs.group.get()).toHaveTextContent(grafanaRulerRule.grafana_alert.rule_group); expect( byRole('listitem', { @@ -147,16 +131,6 @@ describe('CloneRuleEditor', function () { rules: [originRule], }); - mockSearchApi(server).search([ - mockDashboardSearchItem({ - title: 'folder-one', - uid: '123', - type: DashboardSearchItemType.DashDB, - folderTitle: 'folder-one', - folderUid: '123', - }), - ]); - render( ) { - return { - title: '', - uid: '', - type: DashboardSearchItemType.DashDB, - url: '', - uri: '', - items: [], - tags: [], - slug: '', - isStarred: false, - ...searchItem, - }; -} diff --git a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx index cfc05cee73d..e99c56856d2 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx @@ -1,13 +1,11 @@ import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; -import { screen, waitForElementToBeRemoved } from 'test/test-utils'; +import { screen } from 'test/test-utils'; import { byText } from 'testing-library-selector'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types'; import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto'; -import { searchFolders } from '../../manage-dashboards/state/actions'; - import { discoverFeaturesByUid } from './api/buildInfo'; import { fetchRulerRulesGroup } from './api/ruler'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; @@ -30,7 +28,6 @@ jest.mock('./api/ruler', () => ({ fetchRulerRulesGroup: jest.fn(), fetchRulerRulesNamespace: jest.fn(), })); -jest.mock('../../../../app/features/manage-dashboards/state/actions'); // there's no angular scope in test and things go terribly wrong when trying to render the query editor row. // lets just skip it @@ -116,8 +113,6 @@ const dataSources = { setupDataSources(...Object.values(dataSources)); const mocks = { - // getAllDataSources: jest.mocked(config.getAllDataSources), - searchFolders: jest.mocked(searchFolders), api: { discoverFeaturesByUid: jest.mocked(discoverFeaturesByUid), fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), @@ -181,11 +176,8 @@ describe('RuleEditor cloud: checking editable data sources', () => { return null; }); - mocks.searchFolders.mockResolvedValue([]); - // render rule editor, select mimir/loki managed alerts const { user } = renderRuleEditor(); - await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); await ui.inputs.name.find(); diff --git a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx index 6465ba48b86..a3398383671 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx @@ -1,7 +1,6 @@ -import userEvent from '@testing-library/user-event'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; -import { screen, waitForElementToBeRemoved } from 'test/test-utils'; +import { screen } from 'test/test-utils'; import { selectors } from '@grafana/e2e-selectors'; import { AccessControlAction } from 'app/types'; @@ -21,8 +20,6 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({ ), })); -jest.mock('../../../../app/features/manage-dashboards/state/actions'); - jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
{actions}
, })); @@ -46,10 +43,7 @@ describe('RuleEditor cloud', () => { }); it('can create a new cloud alert', async () => { - const user = userEvent.setup(); - - renderRuleEditor(); - await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); + const { user } = renderRuleEditor(); const removeExpressionsButtons = screen.getAllByLabelText('Remove expression'); expect(removeExpressionsButtons).toHaveLength(2); diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx index d290b8f58e3..4f7776594fd 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx @@ -1,51 +1,24 @@ -import { screen, waitForElementToBeRemoved } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; +import { screen } from 'test/test-utils'; import { byRole } from 'testing-library-selector'; import { contextSrv } from 'app/core/services/context_srv'; import { mockFeatureDiscoveryApi, setupMswServer } from 'app/features/alerting/unified/mockApi'; -import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types'; import { AccessControlAction } from 'app/types'; -import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; - -import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { grantUserPermissions, mockDataSource } from './mocks'; -import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi'; +import { grafanaRulerGroup } from './mocks/grafanaRulerApi'; import { setupDataSources } from './testSetup/datasources'; import { buildInfoResponse } from './testSetup/featureDiscovery'; -import * as config from './utils/config'; - -jest.mock('./components/rule-editor/ExpressionEditor', () => ({ - ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( - onChange(e.target.value)} /> - ), -})); - -jest.mock('../../../../app/features/manage-dashboards/state/actions'); jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) =>
{actions}
, })); -// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. -// lets just skip it -jest.mock('app/features/query/components/QueryEditorRow', () => ({ - QueryEditorRow: () =>

hi

, -})); - -jest.spyOn(config, 'getAllDataSources'); - jest.setTimeout(60 * 1000); -const mocks = { - getAllDataSources: jest.mocked(config.getAllDataSources), - searchFolders: jest.mocked(searchFolders), -}; - const server = setupMswServer(); describe('RuleEditor grafana managed rules', () => { @@ -83,41 +56,17 @@ describe('RuleEditor grafana managed rules', () => { setupDataSources(dataSources.default); mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.searchFolders.mockResolvedValue([ - { - title: 'Folder A', - uid: grafanaRulerRule.grafana_alert.namespace_uid, - id: 1, - type: DashboardSearchItemType.DashDB, - }, - { - title: 'Folder B', - id: 2, - uid: 'b', - type: DashboardSearchItemType.DashDB, - }, - { - title: 'Folder / with slash', - uid: 'c', - id: 2, - type: DashboardSearchItemType.DashDB, - }, - ] as DashboardSearchHit[]); + const { user } = renderRuleEditor(); - renderRuleEditor(); - await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); - - await userEvent.type(await ui.inputs.name.find(), 'my great new rule'); - - const folderInput = await ui.inputs.folder.find(); - await clickSelectOption(folderInput, 'Folder A'); + await user.type(await ui.inputs.name.find(), 'my great new rule'); + await user.click(await screen.findByRole('button', { name: /select folder/i })); + await user.click(await screen.findByLabelText(/folder a/i)); const groupInput = await ui.inputs.group.find(); - await userEvent.click(await byRole('combobox').find(groupInput)); + await user.click(await byRole('combobox').find(groupInput)); await clickSelectOption(groupInput, grafanaRulerGroup.name); - await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); + await user.type(ui.inputs.annotationValue(1).get(), 'some description'); - await userEvent.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.saveAndExit.get()); expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully'); }); diff --git a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap index bb0d4a46ec3..4c1b0bb8b65 100644 --- a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap +++ b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap @@ -14,6 +14,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button ], "condition": "C", "folder": { + "kind": "folder", "title": "super folder", "uid": "abc", }, diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index 84d6d15bad9..ef657ef857a 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -17,7 +17,7 @@ import { } from 'app/types/unified-alerting-dto'; import { ExportFormats } from '../components/export/providers'; -import { Folder } from '../components/rule-editor/RuleFolderPicker'; +import { Folder } from '../types/rule-form'; import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource'; import { arrayKeyValuesToObject } from '../utils/labels'; import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules'; diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx index 31c3d1318d3..52039ffc562 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx @@ -11,6 +11,7 @@ import { t, Trans } from 'app/core/internationalization'; import { DashboardModel } from '../../../../dashboard/state'; import { RuleFormValues } from '../../types/rule-form'; import { Annotation, annotationLabels } from '../../utils/constants'; +import { isGrafanaManagedRuleByType } from '../../utils/rules'; import AnnotationHeaderField from './AnnotationHeaderField'; import DashboardAnnotationField from './DashboardAnnotationField'; @@ -31,6 +32,7 @@ const AnnotationsStep = () => { setValue, } = useFormContext(); const annotations = watch('annotations'); + const type = watch('type'); const { fields, append, remove } = useFieldArray({ control, name: 'annotations' }); @@ -104,10 +106,12 @@ const AnnotationsStep = () => { ); } + // when using Grafana managed rules, the annotations step is the 6th step, as we have an additional step for the configure labels and notifications + const step = isGrafanaManagedRuleByType(type) ? 6 : 5; return ( { - // fetch the ruler rules from the database so we can figure out what other "groups" are already defined - // for our folders - const { isLoading: isLoadingRulerNamespace, currentData: rulerNamespace } = - alertRuleApi.endpoints.rulerNamespace.useQuery( - { - namespace: folderUid, - rulerConfig: GRAFANA_RULER_CONFIG, - }, - { - skip: !folderUid, - refetchOnMountOrArgChange: true, - } - ); - - // There should be only one entry in the rulerNamespace object - // However it uses folder name as key, so to avoid fetching folder name, we use Object.values - const groupOptions = useMemo(() => { - if (!rulerNamespace) { - // still waiting for namespace information to be fetched - return []; - } - - const folderGroups = Object.values(rulerNamespace).flat() ?? []; - - return folderGroups - .map>((group) => { - const isProvisioned = isProvisionedGroup(group); - return { - label: group.name, - value: group.name, - description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, - // we include provisioned folders, but disable the option to select them - isDisabled: !enableProvisionedGroups ? isProvisioned : false, - isProvisioned: isProvisioned, - }; - }) - - .sort(sortByLabel); - }, [rulerNamespace, enableProvisionedGroups]); - - return { groupOptions, loading: isLoadingRulerNamespace }; -}; - -const isProvisionedGroup = (group: RulerRuleGroupDTO) => { - return group.rules.some((rule) => isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance) === true); -}; - -const sortByLabel = (a: SelectableValue, b: SelectableValue) => { - return a.label?.localeCompare(b.label ?? '') || 0; -}; - -const findGroupMatchingLabel = (group: SelectableValue, query: string) => { - return group.label?.toLowerCase().includes(query.toLowerCase()); -}; - -export function FolderAndGroup({ - groupfoldersForGrafana, - enableProvisionedGroups, -}: { - groupfoldersForGrafana?: RulerRulesConfigDTO | null; - enableProvisionedGroups: boolean; -}) { - const { - formState: { errors }, - watch, - setValue, - control, - } = useFormContext(); - - const styles = useStyles2(getStyles); - - const [folder, group, type] = watch(['folder', 'group', 'type']); - const isGrafanaRecordingRule = type ? isGrafanaRecordingRuleByType(type) : false; - - const { groupOptions, loading } = useFolderGroupOptions(folder?.uid ?? '', enableProvisionedGroups); - - const [isCreatingFolder, setIsCreatingFolder] = useState(false); - const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false); - - const onOpenFolderCreationModal = () => setIsCreatingFolder(true); - const onOpenEvaluationGroupCreationModal = () => setIsCreatingEvaluationGroup(true); - - const handleFolderCreation = (folder: Folder) => { - resetGroup(); - setValue('folder', folder); - setIsCreatingFolder(false); - }; - - const handleEvalGroupCreation = (groupName: string, evaluationInterval: string) => { - setValue('group', groupName); - setValue('evaluateEvery', evaluationInterval); - setIsCreatingEvaluationGroup(false); - }; - - const resetGroup = useCallback(() => { - setValue('group', ''); - }, [setValue]); - - const getOptions = useCallback( - async (query: string) => { - const results = query ? groupOptions.filter((group) => findGroupMatchingLabel(group, query)) : groupOptions; - return take(results, MAX_GROUP_RESULTS); - }, - [groupOptions] - ); - - const debouncedSearch = useMemo(() => { - return debounce(getOptions, 300, { leading: true }); - }, [getOptions]); - - const defaultGroupValue = group ? { value: group, label: group } : undefined; - - const evaluationDesc = isGrafanaRecordingRule - ? t('alerting.folderAndGroup.evaluation.text.recording', 'Define how often the recording rule is evaluated.') - : t('alerting.folderAndGroup.evaluation.text.alerting', 'Define how often the alert rule is evaluated.'); - - return ( -
- - { - - Folder - - } - className={styles.formInput} - error={errors.folder?.message} - data-testid="folder-picker" - > - - {(!isCreatingFolder && ( - <> - ( -
- { - field.onChange({ title, uid }); - resetGroup(); - }} - /> -
- )} - name="folder" - rules={{ - required: { value: true, message: 'Select a folder' }, - }} - /> - or - - - )) ||
Creating new folder...
} -
-
- } - {isCreatingFolder && ( - setIsCreatingFolder(false)} /> - )} -
- - -
- - ( - { - field.onChange(group.label ?? ''); - }} - isLoading={loading} - invalid={Boolean(folder) && !group && Boolean(fieldState.error)} - loadOptions={debouncedSearch} - cacheOptions - loadingMessage={'Loading groups...'} - defaultValue={defaultGroupValue} - defaultOptions={groupOptions} - getOptionLabel={(option: SelectableValue) => ( -
- {option.label} - {option.isProvisioned && ( - <> - {' '} - - - )} -
- )} - placeholder={'Select an evaluation group...'} - /> - )} - name="group" - control={control} - rules={{ - required: { value: true, message: 'Must enter a group name' }, - }} - /> -
-
- - or - - - {isCreatingEvaluationGroup && ( - setIsCreatingEvaluationGroup(false)} - groupfoldersForGrafana={groupfoldersForGrafana} - /> - )} -
-
- ); -} - -function FolderCreationModal({ - onClose, - onCreate, -}: { - onClose: () => void; - onCreate: (folder: Folder) => void; -}): React.ReactElement { - const styles = useStyles2(getStyles); - - const [title, setTitle] = useState(''); - const onSubmit = async () => { - const newFolder = await createFolder({ title: title }); - if (!newFolder.uid) { - appEvents.emit(AppEvents.alertError, ['Folder could not be created']); - return; - } - - const folder: Folder = { title: newFolder.title, uid: newFolder.uid }; - onCreate(folder); - appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']); - }; - - const error = containsSlashes(title); - - return ( - -
Create a new folder to store your rule
- - - Folder name} - error={"The folder name can't contain slashes"} - invalid={error} - > - setTitle(e.currentTarget.value)} - className={styles.formInput} - /> - - - - - - - -
- ); -} - -function EvaluationGroupCreationModal({ - onClose, - onCreate, - groupfoldersForGrafana, -}: { - onClose: () => void; - onCreate: (group: string, evaluationInterval: string) => void; - groupfoldersForGrafana?: RulerRulesConfigDTO | null; -}): React.ReactElement { - const styles = useStyles2(getStyles); - const onSubmit = () => { - onCreate(getValues('group'), getValues('evaluateEvery')); - }; - - const { watch } = useFormContext(); - - const evaluateEveryId = 'eval-every-input'; - const evaluationGroupNameId = 'new-eval-group-name'; - const [groupName, folderName, type] = watch(['group', 'folder.title', 'type']); - const isGrafanaRecordingRule = type ? isGrafanaRecordingRuleByType(type) : false; - - const groupRules = - (groupfoldersForGrafana && groupfoldersForGrafana[folderName]?.find((g) => g.name === groupName)?.rules) ?? []; - - const onCancel = () => { - onClose(); - }; - - const formAPI = useForm({ - defaultValues: { group: '', evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL }, - mode: 'onChange', - shouldFocusError: true, - }); - - const { register, handleSubmit, formState, setValue, getValues, watch: watchGroupFormValues } = formAPI; - const evaluationInterval = watchGroupFormValues('evaluateEvery'); - - const setEvaluationInterval = (interval: string) => { - setValue('evaluateEvery', interval, { shouldValidate: true }); - }; - - const modalTitle = isGrafanaRecordingRule - ? t( - 'alerting.folderAndGroup.evaluation.modal.text.recording', - 'Create a new evaluation group to use for this recording rule.' - ) - : t( - 'alerting.folderAndGroup.evaluation.modal.text.alerting', - 'Create a new evaluation group to use for this alert rule.' - ); - - return ( - -
{modalTitle}
- - -
onSubmit())}> - - Evaluation group name - - } - error={formState.errors.group?.message} - invalid={Boolean(formState.errors.group)} - > - - - - - Evaluation interval - - } - invalid={Boolean(formState.errors.evaluateEvery)} - > - (groupRules) - )} - /> - - - - - - - - - -
-
- ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - container: css({ - display: 'flex', - flexDirection: 'column', - alignItems: 'baseline', - maxWidth: `${theme.breakpoints.values.lg}px`, - justifyContent: 'space-between', - }), - formInput: css({ - flexGrow: 1, - }), - modal: css({ - width: `${theme.breakpoints.values.sm}px`, - }), - modalTitle: css({ - color: theme.colors.text.secondary, - marginBottom: theme.spacing(2), - }), -}); diff --git a/public/app/features/alerting/unified/components/rule-editor/FolderSelector.tsx b/public/app/features/alerting/unified/components/rule-editor/FolderSelector.tsx new file mode 100644 index 00000000000..e81d18e251c --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/FolderSelector.tsx @@ -0,0 +1,186 @@ +import { css } from '@emotion/css'; +import * as React from 'react'; +import { useCallback, useState } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { Button, Field, Input, Label, Modal, Stack, Text, useStyles2 } from '@grafana/ui'; +import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker'; +import { useAppNotification } from 'app/core/copy/appNotification'; +import { contextSrv } from 'app/core/services/context_srv'; +import { useNewFolderMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; +import { AccessControlAction } from 'app/types'; + +import { Trans } from '../../../../../core/internationalization/index'; +import { Folder, RuleFormValues } from '../../types/rule-form'; + +export function FolderSelector() { + const { + formState: { errors }, + setValue, + watch, + } = useFormContext(); + + const resetGroup = useCallback(() => { + setValue('group', ''); + }, [setValue]); + + const [isCreatingFolder, setIsCreatingFolder] = useState(false); + const folder = watch('folder'); + + const onOpenFolderCreationModal = () => setIsCreatingFolder(true); + + const handleFolderCreation = (folder: Folder) => { + resetGroup(); + setValue('folder', folder); + setIsCreatingFolder(false); + }; + + return ( + <> + + { + + Folder + + } + error={errors.folder?.message} + data-testid="folder-picker" + > + + {(!isCreatingFolder && ( + <> + ( +
+ { + if (uid && title) { + setValue('folder', { title, uid }); + } else { + setValue('folder', undefined); + } + + resetGroup(); + }} + /> +
+ )} + name="folder" + rules={{ + required: { value: true, message: 'Select a folder' }, + }} + /> + + or + + + + )) || ( +
+ Creating new folder + {'...'} +
+ )} +
+
+ } +
+ + {isCreatingFolder && ( + setIsCreatingFolder(false)} /> + )} + + ); +} + +function FolderCreationModal({ + onClose, + onCreate, +}: { + onClose: () => void; + onCreate: (folder: Folder) => void; +}): React.ReactElement { + const styles = useStyles2(getStyles); + const notifyApp = useAppNotification(); + const [title, setTitle] = useState(''); + const [createFolder] = useNewFolderMutation(); + + const onSubmit = async () => { + const { data, error } = await createFolder({ title }); + + if (error) { + notifyApp.error('Failed to create folder'); + } else if (data) { + onCreate({ title: data.title, uid: data.uid }); + notifyApp.success('Folder created'); + } + }; + + return ( + + + + + Create a new folder to store your alert rule in. + + + +
+ + Folder name + + } + > + setTitle(e.currentTarget.value)} + /> + + + + + + +
+
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + modal: css({ + width: `${theme.breakpoints.values.sm}px`, + }), +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index 24f13f06bbc..95612dc75f7 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -1,29 +1,113 @@ import { css } from '@emotion/css'; -import { useCallback, useEffect, useState } from 'react'; -import { Controller, RegisterOptions, useFormContext } from 'react-hook-form'; +import { debounce, take, uniqueId } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Controller, FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { Field, Icon, IconButton, Input, Label, Stack, Switch, Text, Tooltip, useStyles2 } from '@grafana/ui'; -import { Trans, t } from 'app/core/internationalization'; -import { isGrafanaAlertingRuleByType, isGrafanaRecordingRuleByType } from 'app/features/alerting/unified/utils/rules'; +import { selectors } from '@grafana/e2e-selectors'; +import { + AsyncSelect, + Box, + Button, + Field, + Icon, + IconButton, + Input, + Label, + Modal, + Stack, + Switch, + Text, + Tooltip, + useStyles2, +} from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; +import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; -import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting'; -import { LogMessages, logInfo } from '../../Analytics'; +import { logInfo, LogMessages } from '../../Analytics'; +import { alertRuleApi } from '../../api/alertRuleApi'; +import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi'; import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { RuleFormValues } from '../../types/rule-form'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form'; +import { + isGrafanaAlertingRuleByType, + isGrafanaManagedRuleByType, + isGrafanaRecordingRuleByType, + isGrafanaRulerRule, +} from '../../utils/rules'; import { parsePrometheusDuration } from '../../utils/time'; import { CollapseToggle } from '../CollapseToggle'; -import { EditCloudGroupModal } from '../rules/EditRuleGroupModal'; +import { ProvisioningBadge } from '../Provisioning'; +import { EditRuleGroupModal, evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal'; -import { FolderAndGroup, useFolderGroupOptions } from './FolderAndGroup'; +import { EvaluationGroupQuickPick } from './EvaluationGroupQuickPick'; import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker'; import { NeedHelpInfo } from './NeedHelpInfo'; import { PendingPeriodQuickPick } from './PendingPeriodQuickPick'; import { RuleEditorSection } from './RuleEditorSection'; export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds +export const MAX_GROUP_RESULTS = 1000; + +export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups: boolean) => { + // fetch the ruler rules from the database so we can figure out what other "groups" are already defined + // for our folders + const { isLoading: isLoadingRulerNamespace, currentData: rulerNamespace } = + alertRuleApi.endpoints.rulerNamespace.useQuery( + { + namespace: folderUid, + rulerConfig: GRAFANA_RULER_CONFIG, + }, + { + skip: !folderUid, + refetchOnMountOrArgChange: true, + } + ); + + // There should be only one entry in the rulerNamespace object + // However it uses folder name as key, so to avoid fetching folder name, we use Object.values + const groupOptions = useMemo(() => { + if (!rulerNamespace) { + // still waiting for namespace information to be fetched + return []; + } + + const folderGroups = Object.values(rulerNamespace).flat() ?? []; + + return folderGroups + .map>((group) => { + const isProvisioned = isProvisionedGroup(group); + return { + label: group.name, + value: group.name, + description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL, + // we include provisioned folders, but disable the option to select them + isDisabled: !enableProvisionedGroups ? isProvisioned : false, + isProvisioned: isProvisioned, + }; + }) + + .sort(sortByLabel); + }, [rulerNamespace, enableProvisionedGroups]); + + return { groupOptions, loading: isLoadingRulerNamespace }; +}; + +const isProvisionedGroup = (group: RulerRuleGroupDTO) => { + return group.rules.some((rule) => isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance) === true); +}; + +const sortByLabel = (a: SelectableValue, b: SelectableValue) => { + return a.label?.localeCompare(b.label ?? '') || 0; +}; + +const findGroupMatchingLabel = (group: SelectableValue, query: string) => { + return group.label?.toLowerCase().includes(query.toLowerCase()); +}; const forValidationOptions = (evaluateEvery: string): RegisterOptions<{ evaluateFor: string }> => ({ required: { @@ -49,7 +133,7 @@ const forValidationOptions = (evaluateEvery: string): RegisterOptions<{ evaluate return millisFor >= millisEvery ? true : t( - 'alert-rule-form.evaluation-behaviour-for.validation', + 'alerting.rule-form.evaluation-behaviour-for.validation', 'Pending period must be greater than or equal to the evaluation interval.' ); } catch (err) { @@ -60,7 +144,7 @@ const forValidationOptions = (evaluateEvery: string): RegisterOptions<{ evaluate } catch (error) { return error instanceof Error ? error.message - : t('alert-rule-form.evaluation-behaviour-for.error-parsing', 'Failed to parse duration'); + : t('alerting.rule-form.evaluation-behaviour-for.error-parsing', 'Failed to parse duration'); } }, }); @@ -75,29 +159,50 @@ const useIsNewGroup = (folder: string, group: string) => { return !groupIsInGroupOptions(group); }; -function FolderGroupAndEvaluationInterval({ +export function GrafanaEvaluationBehaviorStep({ evaluateEvery, setEvaluateEvery, + existing, enableProvisionedGroups, }: { evaluateEvery: string; setEvaluateEvery: (value: string) => void; + existing: boolean; enableProvisionedGroups: boolean; }) { const styles = useStyles2(getStyles); - const { watch, setValue, getValues } = useFormContext(); - const [isEditingGroup, setIsEditingGroup] = useState(false); + const [showErrorHandling, setShowErrorHandling] = useState(false); - const [groupName, folderUid, folderName] = watch(['group', 'folder.uid', 'folder.title']); + const { + watch, + setValue, + getValues, + formState: { errors }, + control, + } = useFormContext(); + + const [folder, group, type, isPaused, folderUid, folderName] = watch([ + 'folder', + 'group', + 'type', + 'isPaused', + 'folder.uid', + 'folder.title', + ]); + + const isGrafanaAlertingRule = isGrafanaAlertingRuleByType(type); + const isGrafanaRecordingRule = isGrafanaRecordingRuleByType(type); + const { groupOptions, loading } = useFolderGroupOptions(folder?.uid ?? '', enableProvisionedGroups); + const [isEditingGroup, setIsEditingGroup] = useState(false); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; const grafanaNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME); const existingNamespace = grafanaNamespaces.find((ns) => ns.uid === folderUid); - const existingGroup = existingNamespace?.groups.find((g) => g.name === groupName); + const existingGroup = existingNamespace?.groups.find((g) => g.name === group); - const isNewGroup = useIsNewGroup(folderUid ?? '', groupName); + const isNewGroup = useIsNewGroup(folderUid ?? '', group); useEffect(() => { if (!isNewGroup && existingGroup?.interval) { @@ -114,204 +219,166 @@ function FolderGroupAndEvaluationInterval({ const onOpenEditGroupModal = () => setIsEditingGroup(true); - const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderUid || !groupName; + const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderUid || !group; const emptyNamespace: CombinedRuleNamespace = { name: folderName, rulesSource: GRAFANA_RULES_SOURCE_NAME, groups: [], }; - const emptyGroup: CombinedRuleGroup = { name: groupName, interval: evaluateEvery, rules: [], totals: {} }; + const emptyGroup: CombinedRuleGroup = { name: group, interval: evaluateEvery, rules: [], totals: {} }; - return ( -
- - {folderName && isEditingGroup && ( - closeEditGroupModal()} - intervalEditOnly - hideFolder={true} - /> - )} - {folderName && groupName && ( -
- -
- - {getValues('group') && getValues('evaluateEvery') && ( - - - All rules in the selected group are evaluated every {{ evaluateEvery }}. - - {!isNewGroup && ( - - )} - - )} - -
-
-
- )} -
- ); -} + const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false); -function ForInput({ evaluateEvery }: { evaluateEvery: string }) { - const styles = useStyles2(getStyles); - const { - register, - formState: { errors }, - setValue, - watch, - } = useFormContext(); - - const evaluateForId = 'eval-for-input'; - const currentPendingPeriod = watch('evaluateFor'); - - const setPendingPeriod = (pendingPeriod: string) => { - setValue('evaluateFor', pendingPeriod); + const handleEvalGroupCreation = (groupName: string, evaluationInterval: string) => { + setValue('group', groupName); + setValue('evaluateEvery', evaluationInterval); + setIsCreatingEvaluationGroup(false); }; - return ( - - - Pending period - - } - className={styles.inlineField} - error={errors.evaluateFor?.message} - invalid={Boolean(errors.evaluateFor?.message) ? true : undefined} - validationMessageHorizontalOverflow={true} - > - - - - + const getOptions = useCallback( + async (query: string) => { + const results = query ? groupOptions.filter((group) => findGroupMatchingLabel(group, query)) : groupOptions; + return take(results, MAX_GROUP_RESULTS); + }, + [groupOptions] ); -} -function NeedHelpInfoForConfigureNoDataError() { - const docsLink = - 'https://grafana.com/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule/#configure-no-data-and-error-handling'; + const debouncedSearch = useMemo(() => { + return debounce(getOptions, 300, { leading: true }); + }, [getOptions]); - return ( - - - - Define the alert behavior when the evaluation fails or the query returns no data. - - - - - ); -} - -function getDescription(isGrafanaRecordingRule: boolean) { - const docsLink = 'https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/rule-evaluation/'; - - return ( - - - {isGrafanaRecordingRule ? ( - - Define how the recording rule is evaluated. - - ) : ( - - Define how the alert rule is evaluated. - - )} - - -

- - Evaluation groups are containers for evaluating alert and recording rules. - -

-

- - An evaluation group defines an evaluation interval - how often a rule is evaluated. Alert rules within - the same evaluation group are evaluated over the same evaluation interval. - -

-

- - Pending period specifies how long the threshold condition must be met before the alert starts firing. - This option helps prevent alerts from being triggered by temporary issues. - -

- - } - externalLink={docsLink} - linkText={`Read about evaluation and alert states`} - title="Alert rule evaluation" - /> -
- ); -} - -export function GrafanaEvaluationBehavior({ - evaluateEvery, - setEvaluateEvery, - existing, - enableProvisionedGroups, -}: { - evaluateEvery: string; - setEvaluateEvery: (value: string) => void; - existing: boolean; - enableProvisionedGroups: boolean; -}) { - const styles = useStyles2(getStyles); - const [showErrorHandling, setShowErrorHandling] = useState(false); - - const { watch, setValue } = useFormContext(); - - const isPaused = watch('isPaused'); - const type = watch('type'); - - const isGrafanaAlertingRule = isGrafanaAlertingRuleByType(type); - const isGrafanaRecordingRule = type ? isGrafanaRecordingRuleByType(type) : false; + const defaultGroupValue = group ? { value: group, label: group } : undefined; const pauseContentText = isGrafanaRecordingRule - ? t('alert-rule-form.pause.recording', 'Turn on to pause evaluation for this recording rule.') - : t('alert-rule-form.pause.alerting', 'Turn on to pause evaluation for this alert rule.'); + ? t('alerting.rule-form.evaluation.pause.recording', 'Turn on to pause evaluation for this recording rule.') + : t('alerting.rule-form.evaluation.pause.alerting', 'Turn on to pause evaluation for this alert rule.'); + + const onOpenEvaluationGroupCreationModal = () => setIsCreatingEvaluationGroup(true); + + const step = isGrafanaManagedRuleByType(type) ? 4 : 3; + const label = + isGrafanaManagedRuleByType(type) && !folder + ? t( + 'alerting.rule-form.evaluation.select-folder-before', + 'Select a folder before setting evaluation group and interval' + ) + : t('alerting.rule-form.evaluation.evaluation-group-and-interval', 'Evaluation group and interval'); return ( // TODO remove "and alert condition" for recording rules - + - + +
+ + ( + { + field.onChange(group.label ?? ''); + }} + isLoading={loading} + invalid={Boolean(folder) && !group && Boolean(fieldState.error)} + loadOptions={debouncedSearch} + cacheOptions + loadingMessage={'Loading groups...'} + defaultValue={defaultGroupValue} + defaultOptions={groupOptions} + getOptionLabel={(option: SelectableValue) => ( +
+ {option.label} + {option.isProvisioned && ( + <> + {' '} + + + )} +
+ )} + placeholder={'Select an evaluation group...'} + /> + )} + name="group" + control={control} + rules={{ + required: { value: true, message: 'Must enter a group name' }, + }} + /> +
+
+ + or + + + {isCreatingEvaluationGroup && ( + setIsCreatingEvaluationGroup(false)} + groupfoldersForGrafana={groupfoldersForGrafana?.result} + /> + )} +
+ + {folderName && isEditingGroup && ( + closeEditGroupModal()} + intervalEditOnly + hideFolder={true} + /> + )} + {folderName && group && ( +
+ +
+ + {getValues('group') && getValues('evaluateEvery') && ( + + + All rules in the selected group are evaluated every {{ evaluateEvery }}. + + {!isNewGroup && ( + + )} + + )} + +
+
+
+ )} {/* Show the pending period input only for Grafana alerting rules */} {isGrafanaAlertingRule && } @@ -328,7 +395,7 @@ export function GrafanaEvaluationBehavior({ value={Boolean(isPaused)} /> + } + className={styles.inlineField} + error={errors.evaluateFor?.message} + invalid={Boolean(errors.evaluateFor?.message) ? true : undefined} + validationMessageHorizontalOverflow={true} + > + + + +
+ ); +} + +function NeedHelpInfoForConfigureNoDataError() { + const docsLink = + 'https://grafana.com/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule/#configure-no-data-and-error-handling'; + + return ( + + + + Define the alert behavior when the evaluation fails or the query returns no data. + + + + + ); +} + +function getDescription(isGrafanaRecordingRule: boolean) { + const docsLink = 'https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/rule-evaluation/'; + + return ( + + + {isGrafanaRecordingRule ? ( + + Define how the recording rule is evaluated. + + ) : ( + + Define how the alert rule is evaluated. + + )} + + +

+ + Evaluation groups are containers for evaluating alert and recording rules. + +

+

+ + An evaluation group defines an evaluation interval - how often a rule is evaluated. Alert rules within + the same evaluation group are evaluated over the same evaluation interval. + +

+

+ + Pending period specifies how long the threshold condition must be met before the alert starts firing. + This option helps prevent alerts from being triggered by temporary issues. + +

+ + } + externalLink={docsLink} + linkText={`Read about evaluation and alert states`} + title="Alert rule evaluation" + /> +
+ ); +} + const getStyles = (theme: GrafanaTheme2) => ({ inlineField: css({ marginBottom: 0, }), - evaluateLabel: css({ - marginRight: theme.spacing(1), - }), evaluationContainer: css({ color: theme.colors.text.secondary, maxWidth: `${theme.breakpoints.values.sm}px`, fontSize: theme.typography.size.sm, }), - intervalChangedLabel: css({ - marginBottom: theme.spacing(1), - }), - warningIcon: css({ - justifySelf: 'center', - marginRight: theme.spacing(1), - color: theme.colors.warning.text, - }), infoIcon: css({ marginLeft: '10px', }), - warningMessage: css({ - color: theme.colors.warning.text, - }), - bold: css({ - fontWeight: 'bold', - }), - alignInterval: css({ - marginTop: theme.spacing(1), - marginLeft: `-${theme.spacing(1)}`, - }), marginTop: css({ marginTop: theme.spacing(1), }), @@ -429,4 +711,14 @@ const getStyles = (theme: GrafanaTheme2) => ({ cursor: 'pointer', fontSize: theme.typography.bodySmall.fontSize, }), + formInput: css({ + flexGrow: 1, + }), + modal: css({ + width: `${theme.breakpoints.values.sm}px`, + }), + modalTitle: css({ + color: theme.colors.text.secondary, + marginBottom: theme.spacing(2), + }), }); diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaFolderAndLabelsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaFolderAndLabelsStep.tsx new file mode 100644 index 00000000000..e91ec0cb697 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaFolderAndLabelsStep.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { Stack, Text } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { KBObjectArray, RuleFormValues } from '../../types/rule-form'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; + +import { FolderSelector } from './FolderSelector'; +import { NeedHelpInfo } from './NeedHelpInfo'; +import { RuleEditorSection } from './RuleEditorSection'; +import { LabelsEditorModal } from './labels/LabelsEditorModal'; +import { LabelsFieldInForm } from './labels/LabelsFieldInForm'; + +/** Precondition: rule is Grafana managed. + */ +export function GrafanaFolderAndLabelsStep() { + const { setValue, getValues } = useFormContext(); + const [showLabelsEditor, setShowLabelsEditor] = useState(false); + + function onCloseLabelsEditor(labelsToUpdate?: KBObjectArray) { + if (labelsToUpdate) { + setValue('labels', labelsToUpdate); + } + setShowLabelsEditor(false); + } + + function SectionDescription() { + return ( + + + + Organize your alert rule with a folder and set of labels. + + + +

+ {t( + 'alerting.rule-form.folders.help-info', + 'Folders are used for storing alert rules. You can extend the access provided by a role to alert rules and assign permissions to individual folders.' + )} +

+

+ {t( + 'alerting.rule-form.labels.help-info', + 'Labels are used to differentiate an alert from all other alerts.You can use them for searching, silencing, and routing notifications.' + )} +

+ + } + /> +
+ ); + } + + return ( + }> + + + setShowLabelsEditor(true)} /> + + + + ); +} diff --git a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx index d52f12fd042..5e63b3afed5 100644 --- a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx @@ -8,9 +8,9 @@ import { Icon, RadioButtonGroup, Stack, Text, useStyles2 } from '@grafana/ui'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { alertmanagerApi } from '../../api/alertmanagerApi'; -import { RuleFormType, RuleFormValues } from '../../types/rule-form'; +import { KBObjectArray, RuleFormType, RuleFormValues } from '../../types/rule-form'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; -import { isRecordingRuleByType } from '../../utils/rules'; +import { isGrafanaManagedRuleByType, isGrafanaRecordingRuleByType, isRecordingRuleByType } from '../../utils/rules'; import { NeedHelpInfo } from './NeedHelpInfo'; import { RuleEditorSection } from './RuleEditorSection'; @@ -45,6 +45,7 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => { const [showLabelsEditor, setShowLabelsEditor] = useState(false); const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME; + const isGrafanaManaged = isGrafanaManagedRuleByType(type); const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false; const shouldRenderpreview = type === RuleFormType.grafana; const hasInternalAlertmanagerEnabled = useHasInternalAlertmanagerEnabled(); @@ -52,25 +53,29 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => { const shouldAllowSimplifiedRouting = type === RuleFormType.grafana && simplifiedRoutingToggleEnabled && hasInternalAlertmanagerEnabled; - function onCloseLabelsEditor( - labelsToUpdate?: Array<{ - key: string; - value: string; - }> - ) { + function onCloseLabelsEditor(labelsToUpdate?: KBObjectArray) { if (labelsToUpdate) { setValue('labels', labelsToUpdate); } setShowLabelsEditor(false); } - if (!type) { + + if (isGrafanaRecordingRuleByType(type)) { return null; } + const step = !isGrafanaManaged ? 4 : 5; + + const title = isRecordingRuleByType(type) + ? 'Add labels' + : isGrafanaManaged + ? 'Configure notifications' + : 'Configure labels and notifications'; + return ( {isRecordingRuleByType(type) ? ( @@ -88,13 +93,17 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => { } fullWidth > - setShowLabelsEditor(true)} /> - + {!isGrafanaManaged && ( + <> + setShowLabelsEditor(true)} /> + + + )} {shouldAllowSimplifiedRouting && (
Notifications diff --git a/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx b/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx deleted file mode 100644 index 8d56e50778b..00000000000 --- a/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { css } from '@emotion/css'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Icon, Stack, Tooltip, useStyles2 } from '@grafana/ui'; -import { Props as FolderPickerProps, OldFolderPicker } from 'app/core/components/Select/OldFolderPicker'; -import { PermissionLevelString, SearchQueryType } from 'app/types'; - -import { CustomAdd, FolderWarning } from '../../../../../core/components/Select/OldFolderPicker'; - -export interface Folder { - title: string; - uid: string; -} - -export interface RuleFolderPickerProps extends Omit { - value?: Folder; - invalid?: boolean; -} - -const SlashesWarning = () => { - const styles = useStyles2(getStyles); - const onClick = () => window.open('https://github.com/grafana/grafana/issues/42947', '_blank'); - return ( - -
Folders with '/' character are not allowed.
- - - -
- ); -}; - -export const containsSlashes = (str: string): boolean => str.indexOf('/') !== -1; - -export function RuleFolderPicker(props: RuleFolderPickerProps) { - const { value } = props; - const warningCondition = (folderName: string) => containsSlashes(folderName); - - const folderWarning: FolderWarning = { - warningCondition: warningCondition, - warningComponent: SlashesWarning, - }; - - const customAdd: CustomAdd = { - disallowValues: true, - isAllowedValue: (value) => !containsSlashes(value), - }; - - return ( - - ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - slashNotAllowed: css({ - color: theme.colors.warning.main, - fontSize: '12px', - marginBottom: '2px', - }), - infoIcon: css({ - color: theme.colors.warning.main, - fontSize: '12px', - marginBottom: '2px', - cursor: 'pointer', - }), -}); diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 447f538136e..30f21ce9017 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -13,6 +13,8 @@ import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedR import { getRuleGroupLocationFromFormValues, getRuleGroupLocationFromRuleWithLocation, + isCloudAlertingRuleByType, + isCloudRecordingRuleByType, isCloudRulerRule, isGrafanaManagedRuleByType, isGrafanaRulerRule, @@ -60,7 +62,8 @@ import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; import { CloudEvaluationBehavior } from '../CloudEvaluationBehavior'; -import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior'; +import { GrafanaEvaluationBehaviorStep } from '../GrafanaEvaluationBehavior'; +import { GrafanaFolderAndLabelsStep } from '../GrafanaFolderAndLabelsStep'; import { NotificationsStep } from '../NotificationsStep'; import { RecordingRulesNameSpaceAndGroupStep } from '../RecordingRulesNameSpaceAndGroupStep'; import { RuleInspector } from '../RuleInspector'; @@ -296,23 +299,24 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { {showDataSourceDependantStep && ( <> {/* Step 3 */} + {isGrafanaManagedRuleByType(type) && } + + {isCloudAlertingRuleByType(type) && } + + {isCloudRecordingRuleByType(type) && } + + {/* Step 4 & 5 & 6*/} {isGrafanaManagedRuleByType(type) && ( - )} - - {type === RuleFormType.cloudAlerting && } - - {type === RuleFormType.cloudRecording && } - - {/* Step 4 & 5 */} {/* Notifications step*/} - {/* Annotations only for cloud and Grafana */} + {/* Annotations only for alerting rules */} {!isRecordingRuleByType(type) && } )} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx index e35f6da5cf2..a4bf8e6b4ba 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx @@ -24,7 +24,8 @@ import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer'; import { ExportFormats, allGrafanaExportProviders } from '../../export/providers'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; -import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior'; +import { GrafanaEvaluationBehaviorStep } from '../GrafanaEvaluationBehavior'; +import { GrafanaFolderAndLabelsStep } from '../GrafanaFolderAndLabelsStep'; import { NotificationsStep } from '../NotificationsStep'; import { QueryAndExpressionsStep } from '../query-and-alert-condition/QueryAndExpressionsStep'; @@ -90,15 +91,15 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor {/* Step 2 */} {/* Step 3-4-5 */} + - - - {/* Step 4 & 5 */} {/* Notifications step*/} {/* Annotations only for cloud and Grafana */} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx index 7b204534cda..29b50eaaec1 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx @@ -2,7 +2,7 @@ import { ReactNode } from 'react'; import { Routes, Route } from 'react-router-dom-v5-compat'; import { ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; -import { render, screen, waitForElementToBeRemoved, userEvent } from 'test/test-utils'; +import { render, screen, userEvent } from 'test/test-utils'; import { byRole } from 'testing-library-selector'; import { config } from '@grafana/runtime'; @@ -50,8 +50,8 @@ setupDataSources(dataSources.default, dataSources.am); const selectFolderAndGroup = async () => { const user = userEvent.setup(); - const folderInput = await ui.inputs.folder.find(); - await clickSelectOption(folderInput, FOLDER_TITLE_HAPPY_PATH); + await user.click(await screen.findByRole('button', { name: /select folder/i })); + await user.click(await screen.findByLabelText(FOLDER_TITLE_HAPPY_PATH)); const groupInput = await ui.inputs.group.find(); await user.click(await byRole('combobox').find(groupInput)); await clickSelectOption(groupInput, grafanaRulerGroup.name); @@ -83,11 +83,9 @@ describe('Can create a new grafana managed alert using simplified routing', () = }); it('cannot create new grafana managed alert when using simplified routing and not selecting a contact point', async () => { - const user = userEvent.setup(); const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); - renderSimplifiedRuleEditor(); - await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); + const { user } = renderSimplifiedRuleEditor(); await user.type(await ui.inputs.name.find(), 'my great new rule'); @@ -108,18 +106,15 @@ describe('Can create a new grafana managed alert using simplified routing', () = it('simplified routing is not available when Grafana AM is not enabled', async () => { setAlertmanagerChoices(AlertmanagerChoice.External, 1); renderSimplifiedRuleEditor(); - await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); expect(ui.inputs.simplifiedRouting.contactPointRouting.query()).not.toBeInTheDocument(); }); it('can create new grafana managed alert when using simplified routing and selecting a contact point', async () => { - const user = userEvent.setup(); const contactPointName = 'lotsa-emails'; const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/')); - renderSimplifiedRuleEditor(); - await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); + const { user } = renderSimplifiedRuleEditor(); await user.type(await ui.inputs.name.find(), 'my great new rule'); @@ -143,9 +138,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = testWithFeatureToggles(['alertingApiServer']); it('allows selecting a contact point when using alerting API server', async () => { - const user = userEvent.setup(); - renderSimplifiedRuleEditor(); - await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner')); + const { user } = renderSimplifiedRuleEditor(); await user.click(await ui.inputs.simplifiedRouting.contactPointRouting.find()); diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsEditorModal.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsEditorModal.tsx index a98ad871b49..e6472fa31cb 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsEditorModal.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsEditorModal.tsx @@ -1,5 +1,7 @@ import { Modal } from '@grafana/ui'; +import { KBObjectArray } from '../../../types/rule-form'; + import { LabelsSubForm } from './LabelsField'; export interface LabelsEditorModalProps { @@ -8,12 +10,7 @@ export interface LabelsEditorModalProps { key: string; value: string; }>; - onClose: ( - labelsToUodate?: Array<{ - key: string; - value: string; - }> - ) => void; + onClose: (labelsToUodate?: KBObjectArray) => void; dataSourceName: string; } export function LabelsEditorModal({ isOpen, onClose, dataSourceName, initialLabels }: LabelsEditorModalProps) { diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx index 604f7f69157..1335580837d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx @@ -9,7 +9,7 @@ import { t } from 'app/core/internationalization'; import { labelsApi } from '../../../api/labelsApi'; import { usePluginBridge } from '../../../hooks/usePluginBridge'; import { SupportedPlugin } from '../../../types/pluginBridges'; -import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; +import { KBObjectArray, RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { isPrivateLabelKey } from '../../../utils/labels'; import { isRecordingRuleByType } from '../../../utils/rules'; import AlertLabelDropdown from '../../AlertLabelDropdown'; @@ -56,12 +56,7 @@ export type LabelsSubformValues = { export interface LabelsSubFormProps { dataSourceName: string; initialLabels: Array<{ key: string; value: string }>; - onClose: ( - labelsToUodate?: Array<{ - key: string; - value: string; - }> - ) => void; + onClose: (labelsToUodate?: KBObjectArray) => void; } export function LabelsSubForm({ dataSourceName, onClose, initialLabels }: LabelsSubFormProps) { diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx index 780f8833441..89f4e72354c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx @@ -8,13 +8,13 @@ import { Labels } from '../../../../../../types/unified-alerting-dto'; import { mockApi, setupMswServer } from '../../../mockApi'; import { grantUserPermissions, mockAlertQuery } from '../../../mocks'; import { mockPreviewApiResponse } from '../../../mocks/grafanaRulerApi'; +import { Folder } from '../../../types/rule-form'; import * as dataSource from '../../../utils/datasource'; import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME, useGetAlertManagerDataSourcesByPermissionAndConfig, } from '../../../utils/datasource'; -import { Folder } from '../RuleFolderPicker'; import { NotificationPreview } from './NotificationPreview'; import NotificationPreviewByAlertManager from './NotificationPreviewByAlertManager'; diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx index 632b5192e6c..5f42608231a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx @@ -8,19 +8,16 @@ import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; import { Stack } from 'app/plugins/datasource/parca/QueryEditor/Stack'; import { AlertQuery } from 'app/types/unified-alerting-dto'; +import { Folder, KBObjectArray } from '../../../types/rule-form'; import { useGetAlertManagerDataSourcesByPermissionAndConfig } from '../../../utils/datasource'; -import { Folder } from '../RuleFolderPicker'; const NotificationPreviewByAlertManager = lazy(() => import('./NotificationPreviewByAlertManager')); interface NotificationPreviewProps { - customLabels: Array<{ - key: string; - value: string; - }>; + customLabels: KBObjectArray; alertQueries: AlertQuery[]; condition: string | null; - folder: Folder | null; + folder?: Folder; alertName?: string; alertUid?: string; } diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx index 65c65e07685..dcbc646ae03 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx @@ -14,7 +14,7 @@ import { } from '../../mocks'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; -import { EditCloudGroupModal } from './EditRuleGroupModal'; +import { EditRuleGroupModal } from './EditRuleGroupModal'; const ui = { input: { @@ -40,7 +40,7 @@ describe('EditGroupModal', () => { const group = namespace.groups[0]; - render(); + render(); expect(await ui.input.namespace.find()).toHaveAttribute('readonly'); expect(ui.input.group.get()).toHaveAttribute('readonly'); @@ -80,7 +80,7 @@ describe('EditGroupModal component on cloud alert rules', () => { const group = promNs.groups[0]; - render(); + render(); expect(await ui.input.namespace.find()).toHaveValue('prometheus-ns'); expect(ui.input.namespace.get()).not.toHaveAttribute('readonly'); @@ -99,7 +99,7 @@ describe('EditGroupModal component on cloud alert rules', () => { const group = promNs.groups[0]; - render(); + render(); expect(ui.table.query()).not.toBeInTheDocument(); expect(await ui.noRulesText.find()).toBeInTheDocument(); }); @@ -133,7 +133,7 @@ describe('EditGroupModal component on grafana-managed alert rules', () => { const grafanaGroup1 = grafanaNamespace.groups[0]; const renderWithGrafanaGroup = () => - render(); + render(); it('Should show alert table', async () => { renderWithGrafanaGroup(); diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index 2ff9d57baaf..f6c4a116005 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -178,7 +178,7 @@ export interface ModalProps { hideFolder?: boolean; } -export function EditCloudGroupModal(props: ModalProps): React.ReactElement { +export function EditRuleGroupModal(props: ModalProps): React.ReactElement { const { namespace, group, onClose, intervalEditOnly, folderUid } = props; const styles = useStyles2(getStyles); diff --git a/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx b/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx index 69faaec1d5c..69e4bc3f604 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListStateView.tsx @@ -1,16 +1,18 @@ -import { css } from '@emotion/css'; import { useMemo } from 'react'; +import { useMeasure } from 'react-use'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Counter, Pagination, Stack, useStyles2 } from '@grafana/ui'; +import { Counter, LoadingBar, Pagination, Stack } from '@grafana/ui'; import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants'; import { CombinedRule, CombinedRuleNamespace } from 'app/types/unified-alerting'; import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { usePagination } from '../../hooks/usePagination'; +import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { AlertRuleListItem } from '../../rule-list/components/AlertRuleListItem'; import { ListSection } from '../../rule-list/components/ListSection'; +import { getRulesDataSources, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { createViewLink } from '../../utils/misc'; +import { isAsyncRequestStatePending } from '../../utils/redux'; import { hashRule } from '../../utils/rule-id'; import { getRulePluginOrigin, isAlertingRule, isProvisionedRule } from '../../utils/rules'; import { calculateTotalInstances } from '../rule-viewer/RuleViewer'; @@ -24,7 +26,9 @@ interface Props { type GroupedRules = Map; export const RuleListStateView = ({ namespaces }: Props) => { - const styles = useStyles2(getStyles); + const [ref, { width }] = useMeasure(); + + const isLoading = useDataSourcesLoadingState(); const groupedRules = useMemo(() => { const result: GroupedRules = new Map([ @@ -54,10 +58,13 @@ export const RuleListStateView = ({ namespaces }: Props) => { const entries = groupedRules.entries(); return ( -
    - {Array.from(entries).map(([state, rules]) => ( - - ))} +
      + {isLoading && } + + {Array.from(entries).map(([state, rules]) => ( + + ))} +
    ); }; @@ -71,7 +78,7 @@ const STATE_TITLES: Record = { const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: CombinedRule[] }) => { const { page, pageItems, numberOfPages, onPageChange } = usePagination(rules, 1, DEFAULT_PER_PAGE_PAGINATION); - const isNotFiringState = state !== PromAlertingRuleState.Firing; + const isFiringState = state !== PromAlertingRuleState.Firing; const hasRulesMatchingState = rules.length > 0; return ( @@ -82,7 +89,7 @@ const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: C } - collapsed={isNotFiringState || hasRulesMatchingState} + collapsed={isFiringState || hasRulesMatchingState} pagination={ ({ - columnStack: css({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(1), - }), -}); +function useDataSourcesLoadingState() { + const promRules = useUnifiedAlertingSelector((state) => state.promRules); + const rulesDataSources = useMemo(getRulesDataSources, []); + + const grafanaLoading = useUnifiedAlertingSelector((state) => { + const promLoading = isAsyncRequestStatePending(state.promRules[GRAFANA_RULES_SOURCE_NAME]); + const rulerLoading = isAsyncRequestStatePending(state.rulerRules[GRAFANA_RULES_SOURCE_NAME]); + + return promLoading || rulerLoading; + }); + + const externalDataSourcesLoading = rulesDataSources.some((ds) => isAsyncRequestStatePending(promRules[ds.name])); + + const loading = grafanaLoading || externalDataSourcesLoading; + + return loading; +} diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index f8a1f6d7431..5987704636b 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -7,7 +7,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { Badge, ConfirmModal, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier, RulesSource } from 'app/types/unified-alerting'; -import { LogMessages, logInfo } from '../../Analytics'; +import { logInfo, LogMessages } from '../../Analytics'; import { featureDiscoveryApi } from '../../api/featureDiscoveryApi'; import { useDeleteRuleGroup } from '../../hooks/ruleGroup/useDeleteRuleGroup'; import { useFolder } from '../../hooks/useFolder'; @@ -23,7 +23,7 @@ import { GrafanaRuleGroupExporter } from '../export/GrafanaRuleGroupExporter'; import { decodeGrafanaNamespace } from '../expressions/util'; import { ActionIcon } from './ActionIcon'; -import { EditCloudGroupModal } from './EditRuleGroupModal'; +import { EditRuleGroupModal } from './EditRuleGroupModal'; import { ReorderCloudGroupModal } from './ReorderRuleGroupModal'; import { RuleGroupStats } from './RuleStats'; import { RulesTable } from './RulesTable'; @@ -275,7 +275,7 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: /> )} {isEditingGroup && ( - closeEditModal()} diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index 15f5bffb04b..bfcc8982e70 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo } from 'react'; import Skeleton from 'react-loading-skeleton'; import { GrafanaTheme2 } from '@grafana/data'; -import { LoadingPlaceholder, Pagination, Tooltip, useStyles2 } from '@grafana/ui'; +import { Pagination, Tooltip, useStyles2 } from '@grafana/ui'; import { CombinedRule } from 'app/types/unified-alerting'; import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants'; @@ -63,18 +63,9 @@ export const RulesTable = ({ const { pageItems, page, numberOfPages, onPageChange } = usePagination(rules, 1, DEFAULT_PER_PAGE_PAGINATION); - const [lazyLoadRules, { result: rulesWithRulerDefinitions, status: rulerRulesLoadingStatus }] = - useLazyLoadRulerRules(pageItems); - const isLoadingRulerGroup = useMemo( - () => !rulerRulesLoadingStatus || rulerRulesLoadingStatus === 'loading', - [rulerRulesLoadingStatus] - ); + const { result: rulesWithRulerDefinitions, status: rulerRulesLoadingStatus } = useLazyLoadRulerRules(pageItems); - useEffect(() => { - if (pageItems.length > 0) { - lazyLoadRules.execute(); - } - }, [lazyLoadRules, pageItems, rulerRulesLoadingStatus]); + const isLoadingRulerGroup = rulerRulesLoadingStatus === 'loading'; const items = useMemo((): RuleTableItemProps[] => { return rulesWithRulerDefinitions.map((rule, ruleIdx) => { @@ -91,10 +82,6 @@ export const RulesTable = ({ return
    {emptyMessage}
    ; } - if (isLoadingRulerGroup) { - return ; - } - const TableComponent = showGuidelines ? DynamicTableWithGuidelines : DynamicTable; return ( @@ -127,11 +114,8 @@ function useLazyLoadRulerRules(rules: CombinedRule[]) { const [fetchRulerRuleGroup] = useLazyGetRuleGroupForNamespaceQuery(); const [fetchDsFeatures] = useLazyDiscoverDsFeaturesQuery(); - return useAsync(async () => { - if (!prometheusRulesPrimary) { - return rules; - } - return Promise.all( + const [actions, state] = useAsync(async () => { + const result = Promise.all( rules.map(async (rule) => { const dsFeatures = await fetchDsFeatures( { rulesSourceName: getRulesSourceName(rule.namespace.rulesSource) }, @@ -156,7 +140,20 @@ function useLazyLoadRulerRules(rules: CombinedRule[]) { return rule; }) ); + return result; }, rules); + + useEffect(() => { + if (prometheusRulesPrimary) { + actions.execute(); + } else { + // We need to reset the actions to update the rules if they changed + // Otherwise useAsync acts like a cache and always return the first rules passed to it + actions.reset(); + } + }, [rules, actions]); + + return state; } export const getStyles = (theme: GrafanaTheme2) => ({ diff --git a/public/app/features/alerting/unified/mocks/server/handlers/folders.ts b/public/app/features/alerting/unified/mocks/server/handlers/folders.ts index 85654a1802d..a08d7fb63ba 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/folders.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/folders.ts @@ -1,9 +1,36 @@ import { HttpResponse, http } from 'msw'; import { mockFolder } from 'app/features/alerting/unified/mocks'; +import { grafanaRulerRule } from 'app/features/alerting/unified/mocks/grafanaRulerApi'; +import { FolderDTO } from 'app/types'; -export const getFolderHandler = (response = mockFolder()) => +const DEFAULT_FOLDERS: FolderDTO[] = [ + mockFolder({ + id: 1, + uid: 'uid', + title: 'title', + }), + mockFolder({ + id: 2, + uid: grafanaRulerRule.grafana_alert.namespace_uid, + title: 'Folder A', + }), + mockFolder({ + id: 3, + uid: 'NAMESPACE_UID', + title: 'Some Folder', + }), +]; + +export const getFolderHandler = (responseOverride?: FolderDTO) => http.get<{ folderUid: string }>(`/api/folders/:folderUid`, ({ request, params }) => { + const matchingFolder = DEFAULT_FOLDERS.find((folder) => folder.uid === params.folderUid); + const response = responseOverride || matchingFolder; + + if (!response) { + return HttpResponse.json({ message: 'folder not found', status: 'not-found' }, { status: 404 }); + } + const { accessControl, ...withoutAccessControl } = response; // Server only responds with ACL if query param is sent @@ -15,6 +42,16 @@ export const getFolderHandler = (response = mockFolder()) => return HttpResponse.json(response); }); -const handlers = [getFolderHandler()]; +const listFoldersHandler = (folders = DEFAULT_FOLDERS) => + http.get(`/api/folders`, () => { + const strippedFolders = folders.map(({ id, uid, title }) => { + return { id, uid, title }; + }); + // TODO: Add pagination/permission support here as required by tests + // TODO: Add parentUid logic when clicking to expand nested folders + return HttpResponse.json(strippedFolders); + }); + +const handlers = [listFoldersHandler(), getFolderHandler()]; export default handlers; diff --git a/public/app/features/alerting/unified/types/rule-form.ts b/public/app/features/alerting/unified/types/rule-form.ts index e99d56ee7e0..8314961ba5d 100644 --- a/public/app/features/alerting/unified/types/rule-form.ts +++ b/public/app/features/alerting/unified/types/rule-form.ts @@ -1,7 +1,5 @@ import { AlertQuery, GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; -import { Folder } from '../components/rule-editor/RuleFolderPicker'; - export enum RuleFormType { grafana = 'grafana-alerting', grafanaRecording = 'grafana-recording', @@ -29,6 +27,9 @@ export interface SimplifiedEditor { simplifiedQueryEditor: boolean; } +export type KVObject = { key: string; value: string }; +export type KBObjectArray = KVObject[]; + export interface RuleFormValues { // common name: string; @@ -44,7 +45,7 @@ export interface RuleFormValues { condition: string | null; // refId of the query that gets alerted on noDataState: GrafanaAlertStateDecision; execErrState: GrafanaAlertStateDecision; - folder: Folder | null; + folder: Folder | undefined; evaluateEvery: string; evaluateFor: string; isPaused?: boolean; @@ -61,3 +62,5 @@ export interface RuleFormValues { keepFiringForTimeUnit?: string; expression: string; } + +export type Folder = { title: string; uid: string }; diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 2879b2f239c..f3be44d152b 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -40,12 +40,11 @@ import { RulerRuleDTO, } from 'app/types/unified-alerting-dto'; -type KVObject = { key: string; value: string }; - import { EvalFunction } from '../../state/alertDef'; import { AlertManagerManualRouting, ContactPoint, + KVObject, RuleFormType, RuleFormValues, SimplifiedEditor, @@ -97,7 +96,7 @@ export const getDefaultFormValues = (): RuleFormValues => { group: '', // grafana - folder: null, + folder: undefined, queries: [], recordingRulesQueries: [], condition: '', @@ -756,16 +755,18 @@ export const panelToRuleFormValues = async ( } const { folderTitle, folderUid } = dashboard.meta; + const folder = + folderUid && folderTitle + ? { + kind: 'folder', + uid: folderUid, + title: folderTitle, + } + : undefined; const formValues = { type: RuleFormType.grafana, - folder: - folderUid && folderTitle - ? { - uid: folderUid, - title: folderTitle, - } - : undefined, + folder, queries, name: panel.title, condition: queries[queries.length - 1].refId, @@ -827,15 +828,18 @@ export const scenesPanelToRuleFormValues = async (vizPanel: VizPanel): Promise

    { queryFulfilled.then(async ({ data: folder }) => { await contextSrv.fetchUserPermissions(); - dispatch(notifyApp(createSuccessNotification('Folder created'))); dispatch( refetchChildren({ parentUID: parentUid, pageSize: PAGE_SIZE, }) ); - locationService.push(locationUtil.stripBaseFromUrl(folder.url)); }); }, }), diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.tsx index 204aa6c99e2..ed06d0b14a2 100644 --- a/public/app/features/browse-dashboards/components/CreateNewButton.tsx +++ b/public/app/features/browse-dashboards/components/CreateNewButton.tsx @@ -1,12 +1,14 @@ import { useState } from 'react'; import { useLocation } from 'react-router-dom-v5-compat'; -import { config, reportInteraction } from '@grafana/runtime'; +import { locationUtil } from '@grafana/data'; +import { config, locationService, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { + getImportPhrase, getNewDashboardPhrase, getNewFolderPhrase, - getImportPhrase, getNewPhrase, } from 'app/features/search/tempI18nPhrases'; import { FolderDTO } from 'app/types'; @@ -26,18 +28,30 @@ export default function CreateNewButton({ parentFolder, canCreateDashboard, canC const location = useLocation(); const [newFolder] = useNewFolderMutation(); const [showNewFolderDrawer, setShowNewFolderDrawer] = useState(false); + const notifyApp = useAppNotification(); const onCreateFolder = async (folderName: string) => { try { - await newFolder({ + const folder = await newFolder({ title: folderName, parentUid: parentFolder?.uid, }); + const depth = parentFolder?.parents ? parentFolder.parents.length + 1 : 0; reportInteraction('grafana_manage_dashboards_folder_created', { is_subfolder: Boolean(parentFolder?.uid), folder_depth: depth, }); + + if (!folder.error) { + notifyApp.success('Folder created'); + } else { + notifyApp.error('Failed to create folder'); + } + + if (folder.data) { + locationService.push(locationUtil.stripBaseFromUrl(folder.data.url)); + } } finally { setShowNewFolderDrawer(false); } diff --git a/public/app/features/correlations/utils.test.ts b/public/app/features/correlations/utils.test.ts index a42e6fd9688..f182b3c85c3 100644 --- a/public/app/features/correlations/utils.test.ts +++ b/public/app/features/correlations/utils.test.ts @@ -1,15 +1,17 @@ -import { DataFrame, DataSourceInstanceSettings, FieldType, toDataFrame } from '@grafana/data'; +import { DataFrame, DataFrameType, DataSourceInstanceSettings, FieldType, toDataFrame } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { CorrelationData } from './useCorrelations'; import { attachCorrelationsToDataFrames } from './utils'; describe('correlations utils', () => { it('attaches correlations defined in the configuration', () => { + config.featureToggles.lokiLogsDataplane = false; const { testDataFrames, correlations, refIdMap, prometheus, elastic } = setup(); attachCorrelationsToDataFrames(testDataFrames, correlations, refIdMap); - // Loki line (no links) - expect(testDataFrames[0].fields[0].config.links).toHaveLength(0); + // Loki line + expect(testDataFrames[0].fields[0].config.links).toHaveLength(1); // Loki traceId (linked to Prometheus and Elastic) expect(testDataFrames[0].fields[1].config.links).toHaveLength(2); expect(testDataFrames[0].fields[1].config.links).toMatchObject([ @@ -68,10 +70,29 @@ describe('correlations utils', () => { // Prometheus value (linked to Elastic) expect(testDataFrames[2].fields[0].config.links).toHaveLength(1); }); + + it('changes the config field if loki dataplane is being used and the correlation is pointing to the legacy body field (Line)', () => { + const originalDataplaneState = config.featureToggles.lokiLogsDataplane; + config.featureToggles.lokiLogsDataplane = true; + const { correlations, refIdMap } = setup(); + const testDataFrame = toDataFrame({ + name: 'Loki Logs', + refId: 'Loki Query', + fields: [ + { name: 'timestamp', values: [], type: FieldType.time }, + { name: 'body', values: [], type: FieldType.string }, + { name: 'traceId', values: [], type: FieldType.string }, + ], + meta: { type: DataFrameType.LogLines }, + }); + const dataFrameOut = attachCorrelationsToDataFrames([testDataFrame], [correlations[3]], refIdMap); + expect(dataFrameOut[0].fields[1].config.links).toHaveLength(1); + config.featureToggles.lokiLogsDataplane = originalDataplaneState; + }); }); function setup() { - const loki = { uid: 'loki-uid', name: 'loki' } as DataSourceInstanceSettings; + const loki = { uid: 'loki-uid', name: 'loki', meta: { id: 'loki' } } as DataSourceInstanceSettings; const elastic = { uid: 'elastic-uid', name: 'elastic' } as DataSourceInstanceSettings; const prometheus = { uid: 'prometheus-uid', name: 'prometheus' } as DataSourceInstanceSettings; @@ -86,15 +107,15 @@ function setup() { name: 'Loki Logs', refId: 'Loki Query', fields: [ - { name: 'line', values: [] }, - { name: 'traceId', values: [] }, + { name: 'Line', values: [], type: FieldType.string }, + { name: 'traceId', values: [], type: FieldType.string }, ], }), toDataFrame({ name: 'Elastic Logs', refId: 'Elastic Query', fields: [ - { name: 'line', values: [] }, + { name: 'Line', values: [] }, { name: 'traceId', values: [] }, ], }), @@ -134,7 +155,16 @@ function setup() { config: { field: 'value', target: { expr: 'target Elastic query' } }, provisioned: false, }, + { + uid: 'loki-to-loki', + label: 'logs to logs', + source: loki, + target: loki, + type: 'query', + config: { field: 'Line', target: { expr: 'target loki query' } }, + provisioned: false, + }, ]; - return { testDataFrames, correlations, refIdMap, prometheus, elastic }; + return { testDataFrames, correlations, refIdMap, loki, prometheus, elastic }; } diff --git a/public/app/features/correlations/utils.ts b/public/app/features/correlations/utils.ts index d9719c77e64..f7778bb16bb 100644 --- a/public/app/features/correlations/utils.ts +++ b/public/app/features/correlations/utils.ts @@ -1,10 +1,11 @@ import { lastValueFrom } from 'rxjs'; import { DataFrame, DataLinkConfigOrigin } from '@grafana/data'; -import { createMonitoringLogger, getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; +import { config, createMonitoringLogger, getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; import { ExploreItemState } from 'app/types'; import { formatValueName } from '../explore/PrometheusListView/ItemLabels'; +import { parseLogsFrame } from '../logs/logsFrame'; import { CreateCorrelationParams, CreateCorrelationResponse } from './types'; import { @@ -43,7 +44,7 @@ export const attachCorrelationsToDataFrames = ( } const sourceCorrelations = correlations.filter((correlation) => correlation.source.uid === dataSourceUid); - decorateDataFrameWithInternalDataLinks(dataFrame, sourceCorrelations); + decorateDataFrameWithInternalDataLinks(dataFrame, fixLokiDataplaneFields(sourceCorrelations, dataFrame)); }); return dataFrames; @@ -83,6 +84,27 @@ const decorateDataFrameWithInternalDataLinks = (dataFrame: DataFrame, correlatio }); }; +/* +If a correlation was made based on the log line field prior to the loki data plane, they would use the field "Line" + +Change it to use whatever the body field name is post-loki data plane +*/ +const fixLokiDataplaneFields = (correlations: CorrelationData[], dataFrame: DataFrame) => { + return correlations.map((correlation) => { + if ( + correlation.source.meta?.id === 'loki' && + config.featureToggles.lokiLogsDataplane === true && + correlation.config.field === 'Line' + ) { + const logsFrame = parseLogsFrame(dataFrame); + if (logsFrame != null && logsFrame.bodyField.name !== undefined) { + correlation.config.field = logsFrame?.bodyField.name; + } + } + return correlation; + }); +}; + export const getCorrelationsBySourceUIDs = async (sourceUIDs: string[]): Promise => { return lastValueFrom( getBackendSrv().fetch({ diff --git a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx index 59b38232d74..285ac26b6a0 100644 --- a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx +++ b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx @@ -92,6 +92,8 @@ export class PanelInspectDrawer extends SceneObjectBase onClose = () => { const dashboard = getDashboardSceneFor(this); + const meta = dashboard.state.meta; + locationService.push( getDashboardUrl({ uid: dashboard.state.uid, @@ -101,6 +103,7 @@ export class PanelInspectDrawer extends SceneObjectBase inspect: null, inspectTab: null, }, + isHomeDashboard: !meta.url && !meta.slug && !meta.isNew, }) ); }; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts index 65c8c623d76..fa309c8bd21 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -267,6 +267,8 @@ describe('PanelEditor', () => { // Just adding an extra stateless behavior to verify unlinking does not remvoe it const otherBehavior = jest.fn(); const panel = new VizPanel({ key: 'panel-1', pluginId: 'text', $behaviors: [libPanelBehavior, otherBehavior] }); + new DashboardGridItem({ body: panel }); + const editScene = buildPanelEditScene(panel); editScene.onConfirmUnlinkLibraryPanel(); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index b0073be67ed..c8de84d8d46 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -21,7 +21,7 @@ import { saveLibPanel } from 'app/features/library-panels/state/api'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; import { getPanelChanges } from '../saving/getDashboardChanges'; -import { DashboardGridItem, DashboardGridItemState } from '../scene/layout-default/DashboardGridItem'; +import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types'; import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { activateSceneObjectAndParentTree, @@ -54,14 +54,22 @@ export interface PanelEditorState extends SceneObjectState { export class PanelEditor extends SceneObjectBase { static Component = PanelEditorRenderer; - private _originalLayoutElementState!: DashboardGridItemState; - private _layoutElement!: DashboardGridItem; + private _layoutItemState?: SceneObjectState; + private _layoutItem: DashboardLayoutItem; private _originalSaveModel!: Panel; private _changesHaveBeenMade = false; public constructor(state: PanelEditorState) { super(state); + const panel = this.state.panelRef.resolve(); + const layoutItem = panel.parent; + if (!layoutItem || !isDashboardLayoutItem(layoutItem)) { + throw new Error('Panel must have a parent of type DashboardLayoutItem'); + } + + this._layoutItem = layoutItem; + this.setOriginalState(this.state.panelRef); this.addActivationHandler(this._activationHandler.bind(this)); } @@ -69,14 +77,12 @@ export class PanelEditor extends SceneObjectBase { private _activationHandler() { const panel = this.state.panelRef.resolve(); const deactivateParents = activateSceneObjectAndParentTree(panel); - const layoutElement = panel.parent; this.waitForPlugin(); return () => { - if (layoutElement instanceof DashboardGridItem) { - layoutElement.editingCompleted(this.state.isDirty || this._changesHaveBeenMade); - } + this._layoutItem.editingCompleted?.(this.state.isDirty || this._changesHaveBeenMade); + if (deactivateParents) { deactivateParents(); } @@ -102,11 +108,7 @@ export class PanelEditor extends SceneObjectBase { const panel = panelRef.resolve(); this._originalSaveModel = vizPanelToPanel(panel); - - if (panel.parent instanceof DashboardGridItem) { - this._originalLayoutElementState = sceneUtils.cloneSceneObjectState(panel.parent.state); - this._layoutElement = panel.parent; - } + this._layoutItemState = sceneUtils.cloneSceneObjectState(this._layoutItem.state); } /** @@ -134,9 +136,8 @@ export class PanelEditor extends SceneObjectBase { } }; - this._subs.add(panel.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); - // Repeat options live on the layout element (DashboardGridItem) - this._subs.add(this._layoutElement.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); + // Subscribe to state changes on the parent (layout item) so we do not miss state changes on the layout item + this._subs.add(this._layoutItem.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); } public getPanel(): VizPanel { @@ -145,15 +146,12 @@ export class PanelEditor extends SceneObjectBase { private gotPanelPlugin(plugin: PanelPlugin) { const panel = this.getPanel(); - const layoutElement = panel.parent; // First time initialization if (this.state.isInitializing) { this.setOriginalState(this.state.panelRef); - if (layoutElement instanceof DashboardGridItem) { - layoutElement.editingStarted(); - } + this._layoutItem.editingStarted?.(); this._setupChangeDetection(); this._updateDataPane(plugin); @@ -255,7 +253,7 @@ export class PanelEditor extends SceneObjectBase { getDashboardSceneFor(this).removePanel(panel); } else { // Revert any layout element changes - this._layoutElement.setState(this._originalLayoutElementState!); + this._layoutItem!.setState(this._layoutItemState!); } locationService.partial({ editPanel: null }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx index 3467f8c8f5e..fd373104caf 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx @@ -13,7 +13,7 @@ import { import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils'; -import { getPanelFrameCategory2 } from './getPanelFrameOptions'; +import { getPanelFrameOptions } from './getPanelFrameOptions'; interface Props { panel: VizPanel; @@ -25,13 +25,7 @@ interface Props { export const PanelOptions = React.memo(({ panel, searchQuery, listMode, data }) => { const { options, fieldConfig, _pluginInstanceState } = panel.useState(); - const layoutElement = panel.parent!; - const layoutElementState = layoutElement.useState(); - - const panelFrameOptions = useMemo( - () => getPanelFrameCategory2(panel, layoutElementState), - [panel, layoutElementState] - ); + const panelFrameOptions = useMemo(() => getPanelFrameOptions(panel), [panel]); const visualizationOptions = useMemo(() => { const plugin = panel.getPlugin(); diff --git a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx index 629430b1ecc..a75b82a2531 100644 --- a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx @@ -1,26 +1,21 @@ -import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; -import { SceneObjectState, SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; -import { DataLinksInlineEditor, Input, TextArea, Switch, RadioButtonGroup, Select } from '@grafana/ui'; +import { SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; +import { DataLinksInlineEditor, Input, TextArea, Switch } from '@grafana/ui'; import { GenAIPanelDescriptionButton } from 'app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton'; import { GenAIPanelTitleButton } from 'app/features/dashboard/components/GenAI/GenAIPanelTitleButton'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; -import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; import { VizPanelLinks } from '../scene/PanelLinks'; import { PanelTimeRange } from '../scene/PanelTimeRange'; -import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; +import { isDashboardLayoutItem } from '../scene/types'; import { vizPanelToPanel, transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getDashboardSceneFor } from '../utils/utils'; -export function getPanelFrameCategory2( - panel: VizPanel, - layoutElementState: SceneObjectState -): OptionsPaneCategoryDescriptor { +export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescriptor { const descriptor = new OptionsPaneCategoryDescriptor({ title: 'Panel options', id: 'Panel options', @@ -30,7 +25,7 @@ export function getPanelFrameCategory2( const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel); const links = panelLinksObject?.state.rawLinks ?? []; const dashboard = getDashboardSceneFor(panel); - const layoutElement = panel.parent; + const layoutElement = panel.parent!; descriptor .addItem( @@ -97,72 +92,8 @@ export function getPanelFrameCategory2( ) ); - if (layoutElement instanceof DashboardGridItem) { - const gridItem = layoutElement; - - const category = new OptionsPaneCategoryDescriptor({ - title: 'Repeat options', - id: 'Repeat options', - isOpenDefault: false, - }); - - category.addItem( - new OptionsPaneItemDescriptor({ - title: 'Repeat by variable', - description: - 'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.', - render: function renderRepeatOptions() { - return ( - gridItem.setRepeatByVariable(value)} - /> - ); - }, - }) - ); - - category.addItem( - new OptionsPaneItemDescriptor({ - title: 'Repeat direction', - showIf: () => Boolean(gridItem.state.variableName), - render: function renderRepeatOptions() { - const directionOptions: Array> = [ - { label: 'Horizontal', value: 'h' }, - { label: 'Vertical', value: 'v' }, - ]; - - return ( - gridItem.setState({ repeatDirection: value })} - /> - ); - }, - }) - ); - - category.addItem( - new OptionsPaneItemDescriptor({ - title: 'Max per row', - showIf: () => Boolean(gridItem.state.variableName && gridItem.state.repeatDirection === 'h'), - render: function renderOption() { - const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value })); - return ( - gridItem.setState({ maxPerRow: value.value })} + /> + ); +} + +function RepeatByOption({ gridItem }: OptionComponentProps) { + const { variableName } = gridItem.useState(); + + return ( + gridItem.setRepeatByVariable(value)} + /> + ); +} 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 8d7f0f7ff86..3a2d73cc777 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -370,8 +370,8 @@ export class DefaultGridLayoutManager return new DefaultGridLayoutManager({ grid: new SceneGridLayout({ children: children, - isDraggable: false, - isResizable: false, + isDraggable: true, + isResizable: true, }), }); } diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx index 18cb5aaf415..d1671c3cb23 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx @@ -1,13 +1,16 @@ import { SceneObjectState, VizPanel, SceneObjectBase, SceneObject, SceneComponentProps } from '@grafana/scenes'; import { Switch } from '@grafana/ui'; +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { DashboardLayoutItem } from '../types'; + export interface ResponsiveGridItemState extends SceneObjectState { body: VizPanel; hideWhenNoData?: boolean; } -export class ResponsiveGridItem extends SceneObjectBase { +export class ResponsiveGridItem extends SceneObjectBase implements DashboardLayoutItem { public constructor(state: ResponsiveGridItemState) { super(state); this.addActivationHandler(() => this._activationHandler()); @@ -17,8 +20,6 @@ export class ResponsiveGridItem extends SceneObjectBase if (!this.state.hideWhenNoData) { return; } - - // TODO add hide when no data logic (in a behavior probably) } public toggleHideWhenNoData() { @@ -28,20 +29,28 @@ export class ResponsiveGridItem extends SceneObjectBase /** * DashboardLayoutElement interface */ - public isDashboardLayoutElement: true = true; + public isDashboardLayoutItem: true = true; - public getOptions?(): OptionsPaneItemDescriptor[] { + public getOptions?(): OptionsPaneCategoryDescriptor { const model = this; - return [ + const category = new OptionsPaneCategoryDescriptor({ + title: 'Layout options', + id: 'layout-options', + isOpenDefault: false, + }); + + category.addItem( new OptionsPaneItemDescriptor({ title: 'Hide when no data', render: function renderTransparent() { - const { hideWhenNoData } = model.state; + const { hideWhenNoData } = model.useState(); return model.toggleHideWhenNoData()} />; }, - }), - ]; + }) + ); + + return category; } public setBody(body: SceneObject): void { diff --git a/public/app/features/dashboard-scene/scene/types.ts b/public/app/features/dashboard-scene/scene/types.ts index e65a06e533a..aa80c617045 100644 --- a/public/app/features/dashboard-scene/scene/types.ts +++ b/public/app/features/dashboard-scene/scene/types.ts @@ -1,6 +1,6 @@ import { BusEventWithPayload, RegistryItem } from '@grafana/data'; import { SceneObject, VizPanel } from '@grafana/scenes'; -import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; /** * A scene object that usually wraps an underlying layout @@ -94,17 +94,21 @@ export function isLayoutParent(obj: SceneObject): obj is LayoutParent { */ export interface DashboardLayoutItem extends SceneObject { /** - * Marks this object as a layout element + * Marks this object as a layout item */ isDashboardLayoutItem: true; /** - * Return layout elements options (like repeat, repeat direction, etc for the default DashboardGridItem) + * Return layout item options (like repeat, repeat direction, etc for the default DashboardGridItem) */ - getOptions?(): OptionsPaneItemDescriptor[]; + getOptions?(): OptionsPaneCategoryDescriptor; /** - * Only implemented by elements that wrap VizPanels + * When going into panel edit + **/ + editingStarted?(): void; + /** + * When coming out of panel edit */ - getVizPanel?(): VizPanel; + editingCompleted?(withChanges: boolean): void; } export function isDashboardLayoutItem(obj: SceneObject): obj is DashboardLayoutItem { diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap new file mode 100644 index 00000000000..e89b5470781 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -0,0 +1,319 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model schema v2 1`] = ` +{ + "annotations": [], + "cursorSync": "Crosshair", + "description": "Test Description", + "editable": true, + "elements": { + "test-panel-uid": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [], + "queryOptions": {}, + "transformations": [], + }, + }, + "description": "Test Description", + "links": [], + "title": "Test Panel", + "uid": "test-panel-uid", + "vizConfig": { + "kind": "timeseries", + "spec": { + "fieldConfig": { + "defaults": {}, + "overrides": [], + }, + "options": {}, + "pluginVersion": "7.0.0", + }, + }, + }, + }, + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "test-panel-uid", + }, + "height": 0, + "width": 0, + "x": 0, + "y": 0, + }, + }, + ], + }, + }, + "links": [ + { + "asDropdown": false, + "icon": "", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": false, + "title": "Test Link", + "tooltip": "", + "type": "link", + "url": "http://test.com", + }, + ], + "liveNow": true, + "preload": true, + "schemaVersion": 40, + "tags": [ + "tag1", + "tag2", + ], + "timeSettings": { + "autoRefresh": "5s", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + ], + "fiscalYearStartMonth": 1, + "from": "now-1h", + "hideTimepicker": false, + "nowDelay": "1m", + "quickRanges": [], + "timezone": "UTC", + "to": "now", + "weekStart": "monday", + }, + "title": "Test Dashboard", + "variables": [ + { + "kind": "QueryVariable", + "spec": { + "allValue": "*", + "current": { + "text": "text1", + "value": "value1", + }, + "datasource": { + "type": "prometheus", + "uid": "datasource1", + }, + "definition": "definition1", + "description": "A query variable", + "hide": "hideLabel", + "includeAll": true, + "label": "Query Variable", + "multi": true, + "name": "queryVar", + "options": [], + "query": "query1", + "refresh": "onDashboardLoad", + "regex": "regex1", + "skipUrlSync": false, + "sort": "alphabeticalDesc", + }, + }, + { + "kind": "CustomVariable", + "spec": { + "allValue": "All", + "current": { + "text": "option1", + "value": "option1", + }, + "description": "A custom variable", + "hide": "dontHide", + "includeAll": true, + "label": "Custom Variable", + "multi": true, + "name": "customVar", + "options": [ + { + "selected": true, + "text": "option1", + "value": "option1", + }, + { + "selected": false, + "text": "option2", + "value": "option2", + }, + ], + "query": "option1, option2", + "skipUrlSync": false, + }, + }, + { + "kind": "DatasourceVariable", + "spec": { + "allValue": undefined, + "current": { + "text": "text1", + "value": "value1", + }, + "defaultOptionEnabled": true, + "description": "A datasource variable", + "hide": "dontHide", + "includeAll": false, + "label": "Datasource Variable", + "multi": false, + "name": "datasourceVar", + "options": [], + "pluginId": "datasource1", + "refresh": "onDashboardLoad", + "regex": "regex1", + "skipUrlSync": false, + }, + }, + { + "kind": "ConstantVariable", + "spec": { + "current": { + "text": "value4", + "value": "value4", + }, + "description": "A constant variable", + "hide": "dontHide", + "label": "Constant Variable", + "name": "constantVar", + "query": "value4", + "skipUrlSync": true, + }, + }, + { + "kind": "IntervalVariable", + "spec": { + "auto": false, + "auto_count": 10, + "auto_min": "1m", + "current": { + "text": "1m", + "value": "1m", + }, + "description": "An interval variable", + "hide": "dontHide", + "label": "Interval Variable", + "name": "intervalVar", + "options": [ + { + "selected": true, + "text": "1m", + "value": "1m", + }, + { + "selected": false, + "text": "5m", + "value": "5m", + }, + { + "selected": false, + "text": "10m", + "value": "10m", + }, + ], + "query": "1m,5m,10m", + "refresh": "onTimeRangeChanged", + "skipUrlSync": false, + }, + }, + { + "kind": "TextVariable", + "spec": { + "current": { + "text": "value6", + "value": "value6", + }, + "description": "A text variable", + "hide": "dontHide", + "label": "Text Variable", + "name": "textVar", + "query": "value6", + "skipUrlSync": false, + }, + }, + { + "kind": "GroupByVariable", + "spec": { + "current": { + "text": "text7", + "value": "value7", + }, + "datasource": { + "type": "prometheus", + "uid": "datasource2", + }, + "description": "A group by variable", + "hide": "dontHide", + "includeAll": false, + "label": "Group By Variable", + "multi": false, + "name": "groupByVar", + "options": [ + { + "text": "option1", + "value": "option1", + }, + { + "text": "option2", + "value": "option2", + }, + ], + "skipUrlSync": false, + }, + }, + { + "kind": "AdhocVariable", + "spec": { + "baseFilters": [ + { + "condition": "AND", + "key": "key1", + "operator": "=", + "value": "value1", + }, + { + "condition": "OR", + "key": "key2", + "operator": "=", + "value": "value2", + }, + ], + "datasource": { + "type": "prometheus", + "uid": "datasource3", + }, + "defaultKeys": [ + { + "expandable": true, + "group": "defaultGroup1", + "text": "defaultKey1", + "value": "defaultKey1", + }, + ], + "description": "An adhoc variable", + "filters": [ + { + "condition": "AND", + "key": "key3", + "operator": "=", + "value": "value3", + }, + ], + "hide": "dontHide", + "label": "Adhoc Variable", + "name": "adhocVar", + "skipUrlSync": false, + }, + }, + ], +} +`; diff --git a/public/app/features/dashboard-scene/serialization/angularMigration.ts b/public/app/features/dashboard-scene/serialization/angularMigration.ts index d92f13cb1ed..606cf8545e4 100644 --- a/public/app/features/dashboard-scene/serialization/angularMigration.ts +++ b/public/app/features/dashboard-scene/serialization/angularMigration.ts @@ -1,4 +1,4 @@ -import { defaults } from 'lodash'; +import { defaults, cloneDeep } from 'lodash'; import { PanelModel as PanelModelFromData, PanelPlugin } from '@grafana/data'; import { autoMigrateAngular, PanelModel } from 'app/features/dashboard/state/PanelModel'; @@ -12,6 +12,17 @@ export function getAngularPanelMigrationHandler(oldModel: PanelModel) { if (!oldModel.options || Object.keys(oldModel.options).length === 0) { defaults(panel, oldModel.getOptionsToRemember()); + + // Some plugins rely on being able to access targets to set up the fieldConfig when migrating from angular. + const targetClone = cloneDeep(oldModel.targets); + Object.defineProperty(panel, 'targets', { + get: function () { + console.warn( + 'Accessing the targets property when migrating a panel plugin is deprecated. Changes to this property will be ignored.' + ); + return targetClone; + }, + }); } if (oldModel.autoMigrateFrom) { diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts index 97750a6d500..293b17a1143 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts @@ -25,7 +25,7 @@ import { } from '@grafana/scenes'; import { DataSourceRef, VariableRefresh } from '@grafana/schema'; -import { sceneVariablesSetToVariables } from './sceneVariablesSetToVariables'; +import { sceneVariablesSetToSchemaV2Variables, sceneVariablesSetToVariables } from './sceneVariablesSetToVariables'; const runRequestMock = jest.fn().mockReturnValue( of({ @@ -713,4 +713,555 @@ describe('sceneVariablesSetToVariables', () => { expect(() => sceneVariablesSetToVariables(set)).toThrow('Unsupported variable type'); }); }); + + describe('sceneVariablesSetToSchemaV2Variables', () => { + it('should handle QueryVariable', () => { + const variable = new QueryVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + value: ['selected-value'], + text: ['selected-value-text'], + datasource: { uid: 'fake-std', type: 'fake-std' }, + query: 'query', + includeAll: true, + allValue: 'test-all', + isMulti: true, + }); + + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "kind": "QueryVariable", + "spec": { + "allValue": "test-all", + "current": { + "text": [ + "selected-value-text", + ], + "value": [ + "selected-value", + ], + }, + "datasource": { + "type": "fake-std", + "uid": "fake-std", + }, + "definition": undefined, + "description": "test-desc", + "hide": "dontHide", + "includeAll": true, + "label": "test-label", + "multi": true, + "name": "test", + "options": [], + "query": "query", + "refresh": "onDashboardLoad", + "regex": "", + "skipUrlSync": false, + "sort": "disabled", + }, + } + `); + }); + + it('should handle CustomVariable', () => { + const variable = new CustomVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + value: ['test', 'test2'], + text: ['test', 'test2'], + query: 'test,test1,test2', + options: [ + { label: 'test', value: 'test' }, + { label: 'test1', value: 'test1' }, + { label: 'test2', value: 'test2' }, + ], + includeAll: true, + allValue: 'test-all', + isMulti: true, + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "kind": "CustomVariable", + "spec": { + "allValue": "test-all", + "current": { + "text": [ + "test", + "test2", + ], + "value": [ + "test", + "test2", + ], + }, + "description": "test-desc", + "hide": "dontHide", + "includeAll": true, + "label": "test-label", + "multi": true, + "name": "test", + "options": [ + { + "selected": true, + "text": "test", + "value": "test", + }, + { + "selected": false, + "text": "test1", + "value": "test1", + }, + { + "selected": true, + "text": "test2", + "value": "test2", + }, + ], + "query": "test,test1,test2", + "skipUrlSync": false, + }, + } + `); + }); + + it('should handle DatasourceVariable', () => { + const variable = new DataSourceVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + value: ['selected-ds-1', 'selected-ds-2'], + text: ['selected-ds-1-text', 'selected-ds-2-text'], + pluginId: 'fake-std', + includeAll: true, + allValue: 'test-all', + isMulti: true, + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "kind": "DatasourceVariable", + "spec": { + "allValue": "test-all", + "current": { + "text": [ + "selected-ds-1-text", + "selected-ds-2-text", + ], + "value": [ + "selected-ds-1", + "selected-ds-2", + ], + }, + "defaultOptionEnabled": false, + "description": "test-desc", + "hide": "dontHide", + "includeAll": true, + "label": "test-label", + "multi": true, + "name": "test", + "options": [], + "pluginId": "fake-std", + "refresh": "onDashboardLoad", + "regex": "", + "skipUrlSync": false, + }, + } + `); + }); + + it('should handle ConstantVariable', () => { + const variable = new ConstantVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + value: 'constant value', + skipUrlSync: true, + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "kind": "ConstantVariable", + "spec": { + "current": { + "text": "constant value", + "value": "constant value", + }, + "description": "test-desc", + "hide": "dontHide", + "label": "test-label", + "name": "test", + "query": "constant value", + "skipUrlSync": true, + }, + } + `); + }); + + it('should handle TextBoxVariable', () => { + const variable = new TextBoxVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + value: 'text value', + skipUrlSync: true, + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "kind": "TextVariable", + "spec": { + "current": { + "text": "text value", + "value": "text value", + }, + "description": "test-desc", + "hide": "dontHide", + "label": "test-label", + "name": "test", + "query": "text value", + "skipUrlSync": true, + }, + } + `); + }); + + it('should handle IntervalVariable', () => { + const variable = new IntervalVariable({ + intervals: ['1m', '2m', '3m', '1h', '1d'], + value: '1m', + refresh: VariableRefresh.onDashboardLoad, + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result[0]).toMatchInlineSnapshot(` + { + "kind": "IntervalVariable", + "spec": { + "auto": false, + "auto_count": 30, + "auto_min": "10s", + "current": { + "text": "1m", + "value": "1m", + }, + "description": undefined, + "hide": "dontHide", + "label": undefined, + "name": "", + "options": [ + { + "selected": true, + "text": "1m", + "value": "1m", + }, + { + "selected": false, + "text": "2m", + "value": "2m", + }, + { + "selected": false, + "text": "3m", + "value": "3m", + }, + { + "selected": false, + "text": "1h", + "value": "1h", + }, + { + "selected": false, + "text": "1d", + "value": "1d", + }, + ], + "query": "1m,2m,3m,1h,1d", + "refresh": "onTimeRangeChanged", + "skipUrlSync": false, + }, + } + `); + }); + + it('should handle AdHocFiltersVariable', () => { + const variable = new AdHocFiltersVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + datasource: { uid: 'fake-std', type: 'fake-std' }, + filters: [ + { + key: 'filterTest', + operator: '=', + value: 'test', + }, + ], + baseFilters: [ + { + key: 'baseFilterTest', + operator: '=', + value: 'test', + }, + ], + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "kind": "AdhocVariable", + "spec": { + "baseFilters": [ + { + "key": "baseFilterTest", + "operator": "=", + "value": "test", + }, + ], + "datasource": { + "type": "fake-std", + "uid": "fake-std", + }, + "defaultKeys": [], + "description": "test-desc", + "filters": [ + { + "key": "filterTest", + "operator": "=", + "value": "test", + }, + ], + "hide": "dontHide", + "label": "test-label", + "name": "test", + "skipUrlSync": false, + }, + } + `); + }); + + it('should handle AdHocFiltersVariable with defaultKeys', () => { + const variable = new AdHocFiltersVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + datasource: { uid: 'fake-std', type: 'fake-std' }, + defaultKeys: [ + { + text: 'some', + value: '1', + }, + { + text: 'static', + value: '2', + }, + { + text: 'keys', + value: '3', + }, + ], + filters: [ + { + key: 'filterTest', + operator: '=', + value: 'test', + }, + ], + baseFilters: [ + { + key: 'baseFilterTest', + operator: '=', + value: 'test', + }, + ], + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "kind": "AdhocVariable", + "spec": { + "baseFilters": [ + { + "key": "baseFilterTest", + "operator": "=", + "value": "test", + }, + ], + "datasource": { + "type": "fake-std", + "uid": "fake-std", + }, + "defaultKeys": [ + { + "text": "some", + "value": "1", + }, + { + "text": "static", + "value": "2", + }, + { + "text": "keys", + "value": "3", + }, + ], + "description": "test-desc", + "filters": [ + { + "key": "filterTest", + "operator": "=", + "value": "test", + }, + ], + "hide": "dontHide", + "label": "test-label", + "name": "test", + "skipUrlSync": false, + }, + } + `); + }); + + describe('when the groupByVariable feature toggle is enabled', () => { + beforeAll(() => { + config.featureToggles.groupByVariable = true; + }); + + afterAll(() => { + config.featureToggles.groupByVariable = false; + }); + + it('should handle GroupByVariable', () => { + const variable = new GroupByVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + datasource: { uid: 'fake-std', type: 'fake-std' }, + defaultOptions: [ + { + text: 'Foo', + value: 'foo', + }, + { + text: 'Bar', + value: 'bar', + }, + ], + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + const result = sceneVariablesSetToSchemaV2Variables(set); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchInlineSnapshot(` + { + "kind": "GroupByVariable", + "spec": { + "current": { + "text": [], + "value": [], + }, + "datasource": { + "type": "fake-std", + "uid": "fake-std", + }, + "description": "test-desc", + "hide": "dontHide", + "includeAll": false, + "label": "test-label", + "multi": true, + "name": "test", + "options": [ + { + "text": "Foo", + "value": "foo", + }, + { + "text": "Bar", + "value": "bar", + }, + ], + "skipUrlSync": false, + }, + } + `); + }); + }); + + describe('when the groupByVariable feature toggle is disabled', () => { + it('should not handle GroupByVariable and throw an error', () => { + const variable = new GroupByVariable({ + name: 'test', + label: 'test-label', + description: 'test-desc', + datasource: { uid: 'fake-std', type: 'fake-std' }, + defaultOptions: [ + { + text: 'Foo', + value: 'foo', + }, + { + text: 'Bar', + value: 'bar', + }, + ], + }); + const set = new SceneVariableSet({ + variables: [variable], + }); + + expect(() => sceneVariablesSetToSchemaV2Variables(set)).toThrow('Unsupported variable type'); + }); + }); + }); }); diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts index 2eacfecf359..4aff03af4ec 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts @@ -1,9 +1,34 @@ import { config } from '@grafana/runtime'; import { MultiValueVariable, SceneVariables, sceneUtils } from '@grafana/scenes'; -import { VariableHide, VariableModel, VariableOption, VariableRefresh, VariableSort } from '@grafana/schema'; +import { + VariableModel, + VariableRefresh as OldVariableRefresh, + VariableHide as OldVariableHide, + VariableSort as OldVariableSort, +} from '@grafana/schema'; +import { + AdhocVariableKind, + ConstantVariableKind, + CustomVariableKind, + DataQueryKind, + DatasourceVariableKind, + IntervalVariableKind, + QueryVariableKind, + TextVariableKind, + GroupByVariableKind, + defaultVariableHide, + VariableOption, + VariableRefresh, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; import { getIntervalsQueryFromNewIntervalModel } from '../utils/utils'; +import { getDataQueryKind, getDataQuerySpec } from './transformSceneToSaveModelSchemaV2'; +import { + transformVariableRefreshToEnum, + transformVariableHideToEnum, + transformSortVariableToEnum, +} from './transformToV2TypesUtils'; /** * Converts a SceneVariables object into an array of VariableModel objects. * @param set - The SceneVariables object containing the variables to convert. @@ -21,14 +46,14 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio label: variable.state.label, description: variable.state.description ?? undefined, skipUrlSync: Boolean(variable.state.skipUrlSync), - hide: variable.state.hide || VariableHide.dontHide, + hide: variable.state.hide || OldVariableHide.dontHide, type: variable.state.type, }; if (sceneUtils.isQueryVariable(variable)) { let options: VariableOption[] = []; // Not sure if we actually have to still support this option given // that it's not exposed in the UI - if (variable.state.refresh === VariableRefresh.never || keepQueryOptions) { + if (transformVariableRefreshToEnum(variable.state.refresh) === VariableRefresh.Never || keepQueryOptions) { options = variableValueOptionsToVariableOptions(variable.state); } variables.push({ @@ -77,7 +102,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio }, options: [], regex: variable.state.regex, - refresh: VariableRefresh.onDashboardLoad, + refresh: OldVariableRefresh.onDashboardLoad, query: variable.state.pluginId, multi: variable.state.isMulti, allValue: variable.state.allValue, @@ -94,7 +119,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio }, // @ts-expect-error query: variable.state.value, - hide: VariableHide.hideVariable, + hide: OldVariableHide.hideVariable, }); } else if (sceneUtils.isIntervalVariable(variable)) { const intervals = getIntervalsQueryFromNewIntervalModel(variable.state.intervals); @@ -162,7 +187,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio // Remove some defaults for (const variable of variables) { - if (variable.hide === VariableHide.dontHide) { + if (variable.hide === OldVariableHide.dontHide) { delete variable.hide; } @@ -178,7 +203,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio delete variable.multi; } - if (variable.sort === VariableSort.disabled) { + if (variable.sort === OldVariableSort.disabled) { delete variable.sort; } } @@ -193,3 +218,209 @@ function variableValueOptionsToVariableOptions(varState: MultiValueVariable['sta selected: Array.isArray(varState.value) ? varState.value.includes(o.value) : varState.value === o.value, })); } + +export function sceneVariablesSetToSchemaV2Variables( + set: SceneVariables, + keepQueryOptions?: boolean +): Array< + | QueryVariableKind + | TextVariableKind + | IntervalVariableKind + | DatasourceVariableKind + | CustomVariableKind + | ConstantVariableKind + | GroupByVariableKind + | AdhocVariableKind +> { + let variables: Array< + | QueryVariableKind + | TextVariableKind + | IntervalVariableKind + | DatasourceVariableKind + | CustomVariableKind + | ConstantVariableKind + | GroupByVariableKind + | AdhocVariableKind + > = []; + + for (const variable of set.state.variables) { + const commonProperties = { + name: variable.state.name, + label: variable.state.label, + description: variable.state.description ?? undefined, + skipUrlSync: Boolean(variable.state.skipUrlSync), + hide: transformVariableHideToEnum(variable.state.hide) || defaultVariableHide(), + }; + + // current: VariableOption; + const currentVariableOption: VariableOption = { + // @ts-expect-error + value: variable.state.value, + // @ts-expect-error + text: variable.state.text, + }; + + let options: VariableOption[] = []; + if (sceneUtils.isQueryVariable(variable)) { + // Not sure if we actually have to still support this option given + // that it's not exposed in the UI + if (transformVariableRefreshToEnum(variable.state.refresh) === VariableRefresh.Never || keepQueryOptions) { + options = variableValueOptionsToVariableOptions(variable.state); + } + //query: DataQueryKind | string; + const query = variable.state.query; + let dataQuery: DataQueryKind | string; + if (typeof query !== 'string') { + dataQuery = { + kind: getDataQueryKind(query), + spec: getDataQuerySpec(query), + }; + } else { + dataQuery = query; + } + const queryVariable: QueryVariableKind = { + kind: 'QueryVariable', + spec: { + ...commonProperties, + current: currentVariableOption, + options, + query: dataQuery, + definition: variable.state.definition, + datasource: variable.state.datasource || {}, + sort: transformSortVariableToEnum(variable.state.sort), + refresh: transformVariableRefreshToEnum(variable.state.refresh), + regex: variable.state.regex, + allValue: variable.state.allValue, + includeAll: variable.state.includeAll || false, + multi: variable.state.isMulti || false, + skipUrlSync: variable.state.skipUrlSync || false, + }, + }; + variables.push(queryVariable); + } else if (sceneUtils.isCustomVariable(variable)) { + options = variableValueOptionsToVariableOptions(variable.state); + const customVariable: CustomVariableKind = { + kind: 'CustomVariable', + spec: { + ...commonProperties, + current: currentVariableOption, + options, + query: variable.state.query, + multi: variable.state.isMulti || false, + allValue: variable.state.allValue, + includeAll: variable.state.includeAll ?? false, + }, + }; + variables.push(customVariable); + } else if (sceneUtils.isDataSourceVariable(variable)) { + const datasourceVariable: DatasourceVariableKind = { + kind: 'DatasourceVariable', + spec: { + ...commonProperties, + current: currentVariableOption, + options: [], + regex: variable.state.regex, + refresh: VariableRefresh.OnDashboardLoad, + pluginId: variable.state.pluginId, + defaultOptionEnabled: !!variable.state.defaultOptionEnabled, + multi: variable.state.isMulti || false, + allValue: variable.state.allValue, + includeAll: variable.state.includeAll || false, + }, + }; + variables.push(datasourceVariable); + } else if (sceneUtils.isConstantVariable(variable)) { + const constantVariable: ConstantVariableKind = { + kind: 'ConstantVariable', + spec: { + ...commonProperties, + current: { + ...currentVariableOption, + // Constant variable doesn't use text state + text: String(variable.state.value), + }, + // @ts-expect-error + query: variable.state.value, + }, + }; + variables.push(constantVariable); + } else if (sceneUtils.isIntervalVariable(variable)) { + const intervals = getIntervalsQueryFromNewIntervalModel(variable.state.intervals); + const intervalVariable: IntervalVariableKind = { + kind: 'IntervalVariable', + spec: { + ...commonProperties, + current: { + ...currentVariableOption, + // Interval variable doesn't use text state + text: variable.state.value, + }, + query: intervals, + refresh: VariableRefresh.OnTimeRangeChanged, + options: variable.state.intervals.map((interval) => ({ + value: interval, + text: interval, + selected: interval === variable.state.value, + })), + auto: variable.state.autoEnabled, + auto_min: variable.state.autoMinInterval, + auto_count: variable.state.autoStepCount, + }, + }; + variables.push(intervalVariable); + } else if (sceneUtils.isTextBoxVariable(variable)) { + const current = { + text: variable.state.value, + value: variable.state.value, + }; + + const textBoxVariable: TextVariableKind = { + kind: 'TextVariable', + spec: { + ...commonProperties, + current, + query: variable.state.value, + }, + }; + + variables.push(textBoxVariable); + } else if (sceneUtils.isGroupByVariable(variable) && config.featureToggles.groupByVariable) { + options = variableValueOptionsToVariableOptions(variable.state); + + const groupVariable: GroupByVariableKind = { + kind: 'GroupByVariable', + spec: { + ...commonProperties, + datasource: variable.state.datasource || {}, // FIXME what is the default value?, + // Only persist the statically defined options + options: + variable.state.defaultOptions?.map((option) => ({ + text: option.text, + value: String(option.value), + })) || [], + current: currentVariableOption, + multi: variable.state.isMulti || false, + includeAll: variable.state.includeAll || false, + }, + }; + variables.push(groupVariable); + } else if (sceneUtils.isAdHocVariable(variable)) { + const adhocVariable: AdhocVariableKind = { + kind: 'AdhocVariable', + spec: { + ...commonProperties, + name: variable.state.name, + datasource: variable.state.datasource || {}, //FIXME what is the default value? + baseFilters: variable.state.baseFilters || [], + filters: variable.state.filters, + defaultKeys: variable.state.defaultKeys || [], //FIXME what is the default value? + }, + }; + variables.push(adhocVariable); + } else { + throw new Error('Unsupported variable type: ' + variable.state.type); + } + } + + return variables; +} diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts new file mode 100644 index 00000000000..2d466858c3d --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -0,0 +1,285 @@ +import { VariableRefresh } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { + AdHocFiltersVariable, + behaviors, + ConstantVariable, + CustomVariable, + DataSourceVariable, + GroupByVariable, + IntervalVariable, + QueryVariable, + SceneGridLayout, + SceneRefreshPicker, + SceneTimePicker, + SceneTimeRange, + SceneVariableSet, + TextBoxVariable, + VizPanel, +} from '@grafana/scenes'; +import { + DashboardCursorSync as DashboardCursorSyncV1, + VariableHide as VariableHideV1, + VariableSort as VariableSortV1, +} from '@grafana/schema/dist/esm/index.gen'; + +import { DashboardControls } from '../scene/DashboardControls'; +import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; +import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; +import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; + +import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2'; + +function setupDashboardScene(state: DashboardSceneState): DashboardScene { + return new DashboardScene(state); +} + +describe('transformSceneToSaveModelSchemaV2', () => { + let dashboardScene: DashboardScene; + let prevFeatureToggleValue: boolean; + + beforeAll(() => { + prevFeatureToggleValue = !!config.featureToggles.groupByVariable; + config.featureToggles.groupByVariable = true; + }); + + afterAll(() => { + config.featureToggles.groupByVariable = prevFeatureToggleValue; + }); + + beforeEach(() => { + // The intention is to have a complete dashboard scene + // with all the possible properties set + dashboardScene = setupDashboardScene({ + title: 'Test Dashboard', + description: 'Test Description', + preload: true, + tags: ['tag1', 'tag2'], + uid: 'test-uid', + version: 1, + $timeRange: new SceneTimeRange({ + timeZone: 'UTC', + from: 'now-1h', + to: 'now', + weekStart: 'monday', + fiscalYearStartMonth: 1, + UNSAFE_nowDelay: '1m', + refreshOnActivate: { + afterMs: 10, + percent: 0.1, + }, + }), + controls: new DashboardControls({ + refreshPicker: new SceneRefreshPicker({ + refresh: '5s', + intervals: ['5s', '10s', '30s'], + autoEnabled: true, + autoMinInterval: '5s', + autoValue: '5s', + isOnCanvas: true, + primary: true, + withText: true, + minRefreshInterval: '5s', + }), + timePicker: new SceneTimePicker({ + isOnCanvas: true, + hidePicker: true, + }), + }), + links: [ + { + title: 'Test Link', + url: 'http://test.com', + asDropdown: false, + icon: '', + includeVars: false, + keepTime: false, + tags: [], + targetBlank: false, + tooltip: '', + type: 'link', + }, + ], + body: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + isLazy: false, + children: [ + new DashboardGridItem({ + body: new VizPanel({ + key: 'test-panel-uid', + pluginId: 'timeseries', + title: 'Test Panel', + description: 'Test Description', + hoverHeader: true, + hoverHeaderOffset: 10, + fieldConfig: { defaults: {}, overrides: [] }, + displayMode: 'transparent', + pluginVersion: '7.0.0', + $timeRange: new SceneTimeRange({ + timeZone: 'UTC', + from: 'now-3h', + to: 'now', + }), + }), + // Props related to repeatable panels + // repeatedPanels?: VizPanel[], + // variableName?: string, + // itemHeight?: number, + // repeatDirection?: RepeatDirection, + // maxPerRow?: number, + }), + ], + }), + }), + meta: {}, + $behaviors: [ + new behaviors.CursorSync({ + sync: DashboardCursorSyncV1.Crosshair, + }), + new behaviors.LiveNowTimer({ + enabled: true, + }), + ], + $variables: new SceneVariableSet({ + // Test each of the variables + variables: [ + new QueryVariable({ + name: 'queryVar', + label: 'Query Variable', + description: 'A query variable', + skipUrlSync: false, + hide: VariableHideV1.hideLabel, + value: 'value1', + text: 'text1', + query: 'query1', + definition: 'definition1', + datasource: { uid: 'datasource1', type: 'prometheus' }, + sort: VariableSortV1.alphabeticalDesc, + refresh: VariableRefresh.onDashboardLoad, + regex: 'regex1', + allValue: '*', + includeAll: true, + isMulti: true, + }), + new CustomVariable({ + name: 'customVar', + label: 'Custom Variable', + description: 'A custom variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + value: 'option1', + text: 'option1', + query: 'option1, option2', + options: [ + { label: 'option1', value: 'option1' }, + { label: 'option2', value: 'option2' }, + ], + isMulti: true, + allValue: 'All', + includeAll: true, + }), + new DataSourceVariable({ + name: 'datasourceVar', + label: 'Datasource Variable', + description: 'A datasource variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + value: 'value1', + text: 'text1', + regex: 'regex1', + pluginId: 'datasource1', + defaultOptionEnabled: true, + }), + new ConstantVariable({ + name: 'constantVar', + label: 'Constant Variable', + description: 'A constant variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + value: 'value4', + }), + new IntervalVariable({ + name: 'intervalVar', + label: 'Interval Variable', + description: 'An interval variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + value: '1m', + intervals: ['1m', '5m', '10m'], + autoEnabled: false, + autoMinInterval: '1m', + autoStepCount: 10, + }), + new TextBoxVariable({ + name: 'textVar', + label: 'Text Variable', + description: 'A text variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + value: 'value6', + }), + new GroupByVariable({ + name: 'groupByVar', + label: 'Group By Variable', + description: 'A group by variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + value: 'value7', + text: 'text7', + datasource: { uid: 'datasource2', type: 'prometheus' }, + defaultOptions: [ + { text: 'option1', value: 'option1' }, + { text: 'option2', value: 'option2' }, + ], + isMulti: false, + includeAll: false, + }), + new AdHocFiltersVariable({ + name: 'adhocVar', + label: 'Adhoc Variable', + description: 'An adhoc variable', + skipUrlSync: false, + hide: VariableHideV1.dontHide, + datasource: { uid: 'datasource3', type: 'prometheus' }, + baseFilters: [ + { + key: 'key1', + operator: '=', + value: 'value1', + condition: 'AND', + }, + { + key: 'key2', + operator: '=', + value: 'value2', + condition: 'OR', + }, + ], + filters: [ + { + key: 'key3', + operator: '=', + value: 'value3', + condition: 'AND', + }, + ], + defaultKeys: [ + { + text: 'defaultKey1', + value: 'defaultKey1', + group: 'defaultGroup1', + expandable: true, + }, + ], + }), + ], + }), + }); + }); + + it('should transform scene to save model schema v2', () => { + const result = transformSceneToSaveModelSchemaV2(dashboardScene); + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts new file mode 100644 index 00000000000..ec8ef3d1940 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -0,0 +1,472 @@ +import { behaviors, SceneDataQuery, SceneDataTransformer, SceneVariableSet, VizPanel } from '@grafana/scenes'; +import { + DashboardV2Spec, + defaultDashboardV2Spec, + defaultFieldConfigSource, + PanelKind, + PanelQueryKind, + TransformationKind, + FieldConfigSource, + DashboardLink, + DashboardCursorSync, + DataTransformerConfig, + PanelQuerySpec, + DataQueryKind, + defaultDataSourceRef, + GridLayoutItemKind, + QueryOptionsSpec, + QueryVariableKind, + TextVariableKind, + IntervalVariableKind, + DatasourceVariableKind, + CustomVariableKind, + ConstantVariableKind, + GroupByVariableKind, + AdhocVariableKind, +} from '@grafana/schema/src/schema/dashboard/v2alpha0/dashboard.gen'; +import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator'; + +import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; +import { PanelTimeRange } from '../scene/PanelTimeRange'; +import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; +import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; +import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; +import { getQueryRunnerFor } from '../utils/utils'; + +import { sceneVariablesSetToSchemaV2Variables } from './sceneVariablesSetToVariables'; +import { transformDashboardLinksToEnums, transformCursorSynctoEnum } from './transformToV2TypesUtils'; + +// FIXME: This is temporary to avoid creating partial types for all the new schema, it has some performance implications, but it's fine for now +type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnapshot = false): Partial { + const oldDash = scene.state; + const timeRange = oldDash.$timeRange!.state; + + const controlsState = oldDash.controls?.state; + const refreshPicker = controlsState?.refreshPicker; + + const dashboardSchemaV2: DeepPartial = { + //dashboard settings + title: oldDash.title, + description: oldDash.description ?? '', + cursorSync: getCursorSync(oldDash), + liveNow: getLiveNow(oldDash), + preload: oldDash.preload, + editable: oldDash.editable, + links: transformDashboardLinksToEnums(oldDash.links), + tags: oldDash.tags, + schemaVersion: DASHBOARD_SCHEMA_VERSION, + // EOF dashboard settings + + // time settings + timeSettings: { + timezone: timeRange.timeZone, + from: timeRange.from, + to: timeRange.to, + autoRefresh: refreshPicker?.state.refresh || '', + autoRefreshIntervals: refreshPicker?.state.intervals, + quickRanges: [], //FIXME is coming timepicker.time_options, + hideTimepicker: controlsState?.hideTimeControls ?? false, + weekStart: timeRange.weekStart, + fiscalYearStartMonth: timeRange.fiscalYearStartMonth, + nowDelay: timeRange.UNSAFE_nowDelay, + }, + // EOF time settings + + // variables + variables: getVariables(oldDash), + // EOF variables + + // elements + elements: getElements(oldDash), + // EOF elements + + // annotations + annotations: [], //FIXME + // EOF annotations + + // layout + layout: { + kind: 'GridLayout', + spec: { + items: getGridLayoutItems(oldDash), + }, + }, + // EOF layout + }; + + if (isDashboardSchemaV2(dashboardSchemaV2)) { + return dashboardSchemaV2; + } + console.error('Error transforming dashboard to schema v2'); + throw new Error('Error transforming dashboard to schema v2'); +} + +function getCursorSync(state: DashboardSceneState) { + const cursorSync = state.$behaviors?.find((b): b is behaviors.CursorSync => b instanceof behaviors.CursorSync)?.state + .sync; + + return transformCursorSynctoEnum(cursorSync); +} + +function getLiveNow(state: DashboardSceneState) { + const liveNow = + state.$behaviors?.find((b): b is behaviors.LiveNowTimer => b instanceof behaviors.LiveNowTimer)?.isEnabled || + undefined; + // hack for validator + if (liveNow === undefined) { + return Boolean(defaultDashboardV2Spec().liveNow); + } + return Boolean(liveNow); +} + +function getGridLayoutItems(state: DashboardSceneState, isSnapshot?: boolean): GridLayoutItemKind[] { + const body = state.body; + const elements: GridLayoutItemKind[] = []; + if (body instanceof DefaultGridLayoutManager) { + for (const child of body.state.grid.state.children) { + if (child instanceof DashboardGridItem) { + // TODO: handle panel repeater scenario + // if (child.state.variableName) { + // panels = panels.concat(panelRepeaterToPanels(child, isSnapshot)); + // } else { + elements.push(gridItemToGridLayoutItemKind(child, isSnapshot)); + // } + } + + // TODO: OLD transformer code + // if (child instanceof SceneGridRow) { + // // Skip repeat clones or when generating a snapshot + // if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) { + // continue; + // } + // gridRowToSaveModel(child, panels, isSnapshot); + // } + } + } + return elements; +} + +export function gridItemToGridLayoutItemKind(gridItem: DashboardGridItem, isSnapshot = false): GridLayoutItemKind { + let elementGridItem: GridLayoutItemKind | undefined; + let x = 0, + y = 0, + width = 0, + height = 0; + + let gridItem_ = gridItem; + + if (!(gridItem_.state.body instanceof VizPanel)) { + throw new Error('DashboardGridItem body expected to be VizPanel'); + } + + // Get the grid position and size + height = (gridItem_.state.variableName ? gridItem_.state.itemHeight : gridItem_.state.height) ?? 0; + x = gridItem_.state.x ?? 0; + y = gridItem_.state.y ?? 0; + width = gridItem_.state.width ?? 0; + + // FIXME: which name should we use for the element reference, key or something else ? + const elementName = gridItem_.state.body.state.key ?? 'DefaultName'; + elementGridItem = { + kind: 'GridLayoutItem', + spec: { + x, + y, + width: width, + height: height, + element: { + kind: 'ElementReference', + name: elementName, + }, + }, + }; + + if (!elementGridItem) { + throw new Error('Unsupported grid item type'); + } + + return elementGridItem; +} + +function getElements(state: DashboardSceneState) { + const panels = state.body.getVizPanels() ?? []; + const panelsArray = panels.reduce((acc: PanelKind[], vizPanel: VizPanel) => { + const elementSpec: PanelKind = { + kind: 'Panel', + spec: { + uid: vizPanel.state.key ?? '', // FIXME: why is key optional? + title: vizPanel.state.title, + description: vizPanel.state.description ?? '', + links: getPanelLinks(vizPanel), + data: { + kind: 'QueryGroup', + spec: { + queries: getVizPanelQueries(vizPanel), + transformations: getVizPanelTransformations(vizPanel), + queryOptions: getVizPanelQueryOptions(vizPanel), + }, + }, + vizConfig: { + kind: vizPanel.state.pluginId, + spec: { + pluginVersion: vizPanel.state.pluginVersion ?? '', + options: vizPanel.state.options, + fieldConfig: (vizPanel.state.fieldConfig as FieldConfigSource) ?? defaultFieldConfigSource(), + }, + }, + }, + }; + acc.push(elementSpec); + return acc; + }, []); + // create elements + + const elements = createElements(panelsArray); + return elements; +} + +function getPanelLinks(panel: VizPanel): DashboardLink[] { + const vizLinks = dashboardSceneGraph.getPanelLinks(panel); + if (vizLinks) { + return (vizLinks.state.rawLinks as DashboardLink[]) ?? []; + } + return []; +} + +function getVizPanelQueries(vizPanel: VizPanel): PanelQueryKind[] { + const queries: PanelQueryKind[] = []; + const queryRunner = getQueryRunnerFor(vizPanel); + const vizPanelQueries = queryRunner?.state.queries; + const datasource = queryRunner?.state.datasource; + + if (vizPanelQueries) { + vizPanelQueries.forEach((query) => { + const dataQuery: DataQueryKind = { + kind: getDataQueryKind(query), + spec: query, + }; + const querySpec: PanelQuerySpec = { + datasource: datasource ?? defaultDataSourceRef(), + query: dataQuery, + refId: query.refId, + hidden: query.hidden, + }; + queries.push({ + kind: 'PanelQuery', + spec: querySpec, + }); + }); + } + return queries; +} + +export function getDataQueryKind(query: SceneDataQuery): string { + // If the query has a datasource, use the datasource type, otherwise use 'default' + return query.datasource?.type ?? 'default'; +} + +export function getDataQuerySpec(query: SceneDataQuery): Record { + const dataQuerySpec = { + kind: getDataQueryKind(query), + spec: query, + }; + return dataQuerySpec; +} + +function getVizPanelTransformations(vizPanel: VizPanel): TransformationKind[] { + let transformations: TransformationKind[] = []; + const dataProvider = vizPanel.state.$data; + if (dataProvider instanceof SceneDataTransformer) { + const transformationList = dataProvider.state.transformations; + if (transformationList.length === 0) { + return []; + } + transformationList.forEach((transformationItem) => { + const transformation = transformationItem as DataTransformerConfig; + const transformationSpec: DataTransformerConfig = { + id: transformation.id, + disabled: transformation.disabled, + filter: { + id: transformation.filter?.id ?? '', + options: transformation.filter?.options ?? {}, + }, + topic: transformation.topic, + options: transformation.options, + }; + + transformations.push({ + kind: transformation.id, + spec: transformationSpec, + }); + }); + } + return transformations; +} + +function getVizPanelQueryOptions(vizPanel: VizPanel): QueryOptionsSpec { + let queryOptions: QueryOptionsSpec = {}; + const queryRunner = getQueryRunnerFor(vizPanel); + + if (queryRunner) { + queryOptions.maxDataPoints = queryRunner.state.maxDataPoints; + + if (queryRunner.state.cacheTimeout) { + queryOptions.cacheTimeout = queryRunner.state.cacheTimeout; + } + + if (queryRunner.state.queryCachingTTL) { + queryOptions.queryCachingTTL = queryRunner.state.queryCachingTTL; + } + if (queryRunner.state.minInterval) { + queryOptions.interval = queryRunner.state.minInterval; + } + } + + const panelTime = vizPanel.state.$timeRange; + + if (panelTime instanceof PanelTimeRange) { + queryOptions.timeFrom = panelTime.state.timeFrom; + queryOptions.timeShift = panelTime.state.timeShift; + } + return queryOptions; +} + +function createElements(panels: PanelKind[]): Record { + return panels.reduce( + (acc, panel) => { + const key = panel.spec.uid; + acc[key] = panel; + return acc; + }, + {} as Record + ); +} + +function getVariables(oldDash: DashboardSceneState) { + const variablesSet = oldDash.$variables; + + // variables is an array of all variables kind (union) + let variables: Array< + | QueryVariableKind + | TextVariableKind + | IntervalVariableKind + | DatasourceVariableKind + | CustomVariableKind + | ConstantVariableKind + | GroupByVariableKind + | AdhocVariableKind + > = []; + + if (variablesSet instanceof SceneVariableSet) { + variables = sceneVariablesSetToSchemaV2Variables(variablesSet); + } + + return variables; +} + +// Function to know if the dashboard transformed is a valid DashboardV2Spec +function isDashboardSchemaV2(dash: any): dash is DashboardV2Spec { + if (typeof dash !== 'object' || dash === null) { + return false; + } + + if (typeof dash.title !== 'string') { + return false; + } + if (typeof dash.description !== 'string') { + return false; + } + if (typeof dash.cursorSync !== 'string') { + return false; + } + if (!Object.values(DashboardCursorSync).includes(dash.cursorSync)) { + return false; + } + if (typeof dash.liveNow !== 'boolean') { + return false; + } + if (typeof dash.preload !== 'boolean') { + return false; + } + if (typeof dash.editable !== 'boolean') { + return false; + } + if (!Array.isArray(dash.links)) { + return false; + } + if (!Array.isArray(dash.tags)) { + return false; + } + + if (dash.id !== undefined && typeof dash.id !== 'number') { + return false; + } + + // Time settings + if (typeof dash.timeSettings !== 'object' || dash.timeSettings === null) { + return false; + } + if (typeof dash.timeSettings.timezone !== 'string') { + return false; + } + if (typeof dash.timeSettings.from !== 'string') { + return false; + } + if (typeof dash.timeSettings.to !== 'string') { + return false; + } + if (typeof dash.timeSettings.autoRefresh !== 'string') { + return false; + } + if (!Array.isArray(dash.timeSettings.autoRefreshIntervals)) { + return false; + } + if (!Array.isArray(dash.timeSettings.quickRanges)) { + return false; + } + if (typeof dash.timeSettings.hideTimepicker !== 'boolean') { + return false; + } + if (typeof dash.timeSettings.weekStart !== 'string') { + return false; + } + if (typeof dash.timeSettings.fiscalYearStartMonth !== 'number') { + return false; + } + if (dash.timeSettings.nowDelay !== undefined && typeof dash.timeSettings.nowDelay !== 'string') { + return false; + } + + // Other sections + if (!Array.isArray(dash.variables)) { + return false; + } + if (typeof dash.elements !== 'object' || dash.elements === null) { + return false; + } + if (!Array.isArray(dash.annotations)) { + return false; + } + + // Layout + if (typeof dash.layout !== 'object' || dash.layout === null) { + return false; + } + if (dash.layout.kind !== 'GridLayout') { + return false; + } + if (typeof dash.layout.spec !== 'object' || dash.layout.spec === null) { + return false; + } + if (!Array.isArray(dash.layout.spec.items)) { + return false; + } + + return true; +} diff --git a/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.test.ts b/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.test.ts new file mode 100644 index 00000000000..508d0746987 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.test.ts @@ -0,0 +1,108 @@ +import { DashboardLink as DashboardLinkTypeV1 } from '@grafana/schema'; +import { + DashboardCursorSync, + DashboardLinkType, + VariableRefresh, + VariableHide, + VariableSort, + defaultVariableHide, + defaultVariableSort, + defaultVariableRefresh, + defaultDashboardLinkType, + defaultDashboardCursorSync, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; + +import { + transformCursorSynctoEnum, + transformDashboardLinksToEnums, + transformVariableRefreshToEnum, + transformVariableHideToEnum, + transformSortVariableToEnum, +} from './transformToV2TypesUtils'; + +describe('transformToV2TypesUtils', () => { + describe('transformCursorSynctoEnum', () => { + it('should return the correct enum value for cursor sync', () => { + expect(transformCursorSynctoEnum(0)).toBe(DashboardCursorSync.Off); + expect(transformCursorSynctoEnum(1)).toBe(DashboardCursorSync.Crosshair); + expect(transformCursorSynctoEnum(2)).toBe(DashboardCursorSync.Tooltip); + expect(transformCursorSynctoEnum(undefined)).toBe(defaultDashboardCursorSync()); + }); + }); + + describe('transformDashboardLinksToEnums', () => { + const links: DashboardLinkTypeV1[] = [ + { + type: 'link', + asDropdown: false, + icon: '', + includeVars: false, + keepTime: false, + tags: [], + title: '', + url: '', + targetBlank: false, + tooltip: '', + }, + { + type: 'dashboards', + asDropdown: false, + icon: '', + includeVars: false, + keepTime: false, + tags: [], + title: '', + url: '', + targetBlank: false, + tooltip: '', + }, + { + // @ts-expect-error Testing invalid type + type: 'non-valid-type', + asDropdown: false, + icon: '', + includeVars: false, + keepTime: false, + tags: [], + title: '', + url: '', + targetBlank: false, + tooltip: '', + }, + ]; + + const transformedLinks = transformDashboardLinksToEnums(links); + expect(transformedLinks[0].type).toBe(DashboardLinkType.Link); + expect(transformedLinks[1].type).toBe(DashboardLinkType.Dashboards); + expect(transformedLinks[2].type).toBe(defaultDashboardLinkType()); + }); +}); + +describe('transformVariableRefreshToEnum', () => { + it('should return the correct enum value for variable refresh', () => { + expect(transformVariableRefreshToEnum(0)).toBe(VariableRefresh.Never); + expect(transformVariableRefreshToEnum(1)).toBe(VariableRefresh.OnDashboardLoad); + expect(transformVariableRefreshToEnum(2)).toBe(VariableRefresh.OnTimeRangeChanged); + expect(transformVariableRefreshToEnum(undefined)).toBe(defaultVariableRefresh()); + }); +}); + +describe('transformVariableHideToEnum', () => { + it('should return the correct enum value for variable hide', () => { + expect(transformVariableHideToEnum(0)).toBe(VariableHide.DontHide); + expect(transformVariableHideToEnum(1)).toBe(VariableHide.HideLabel); + expect(transformVariableHideToEnum(2)).toBe(VariableHide.HideVariable); + expect(transformVariableHideToEnum(undefined)).toBe(defaultVariableHide()); + }); +}); + +describe('transformSortVariableToEnum', () => { + it('should return the correct enum value for variable sort', () => { + expect(transformSortVariableToEnum(0)).toBe(VariableSort.Disabled); + expect(transformSortVariableToEnum(1)).toBe(VariableSort.AlphabeticalAsc); + expect(transformSortVariableToEnum(2)).toBe(VariableSort.AlphabeticalDesc); + expect(transformSortVariableToEnum(3)).toBe(VariableSort.NumericalAsc); + expect(transformSortVariableToEnum(4)).toBe(VariableSort.NumericalDesc); + expect(transformSortVariableToEnum(undefined)).toBe(defaultVariableSort()); + }); +}); diff --git a/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.ts b/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.ts new file mode 100644 index 00000000000..3088e5d6b15 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.ts @@ -0,0 +1,94 @@ +import { + DashboardLink as DashboardLinkV1, + VariableHide as VariableHideV1, + VariableRefresh as VariableRefreshV1, + VariableSort as VariableSortV1, + DashboardCursorSync as DashboardCursorSyncV1, + DashboardLinkType as DashboardLinkTypeV1, +} from '@grafana/schema'; +import { + DashboardCursorSync, + defaultDashboardV2Spec, + DashboardLinkType, + DashboardLink, + defaultVariableHide, + defaultVariableRefresh, + defaultVariableSort, + VariableHide, + VariableRefresh, + VariableSort, + defaultDashboardLinkType, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; + +export function transformCursorSynctoEnum(cursorSync?: DashboardCursorSyncV1): DashboardCursorSync { + switch (cursorSync) { + case 0: + return DashboardCursorSync.Off; + case 1: + return DashboardCursorSync.Crosshair; + case 2: + return DashboardCursorSync.Tooltip; + default: + return defaultDashboardV2Spec().cursorSync; + } +} + +function transformDashboardLinkTypeToEnum(linkType: DashboardLinkTypeV1): DashboardLinkType { + switch (linkType) { + case 'link': + return DashboardLinkType.Link; + case 'dashboards': + return DashboardLinkType.Dashboards; + default: + return defaultDashboardLinkType(); + } +} + +export function transformDashboardLinksToEnums(links: DashboardLinkV1[]): DashboardLink[] { + return links.map((link) => { + return { + ...link, + type: transformDashboardLinkTypeToEnum(link.type), + }; + }); +} +export function transformVariableRefreshToEnum(refresh?: VariableRefreshV1): VariableRefresh { + switch (refresh) { + case 0: + return VariableRefresh.Never; + case 1: + return VariableRefresh.OnDashboardLoad; + case 2: + return VariableRefresh.OnTimeRangeChanged; + default: + return defaultVariableRefresh(); + } +} +export function transformVariableHideToEnum(hide?: VariableHideV1): VariableHide { + switch (hide) { + case 0: + return VariableHide.DontHide; + case 1: + return VariableHide.HideLabel; + case 2: + return VariableHide.HideVariable; + default: + return defaultVariableHide(); + } +} +export function transformSortVariableToEnum(sort?: VariableSortV1): VariableSort { + switch (sort) { + case 0: + return VariableSort.Disabled; + case 1: + return VariableSort.AlphabeticalAsc; + case 2: + return VariableSort.AlphabeticalDesc; + case 3: + return VariableSort.NumericalAsc; + case 4: + return VariableSort.NumericalDesc; + default: + return defaultVariableSort(); + } +} diff --git a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx index 624452bb6fe..9833c33fc67 100644 --- a/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareExportTab.tsx @@ -2,6 +2,7 @@ import saveAs from 'file-saver'; import { useAsync } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; +import { config } from '@grafana/runtime'; import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; import { Button, ClipboardButton, CodeEditor, Field, Modal, Stack, Switch } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; @@ -10,6 +11,7 @@ import { shareDashboardType } from 'app/features/dashboard/components/ShareModal import { DashboardModel } from 'app/features/dashboard/state'; import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; +import { transformSceneToSaveModelSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2'; import { getVariablesCompatibility } from '../utils/getVariablesCompatibility'; import { DashboardInteractions } from '../utils/interactions'; import { getDashboardSceneFor } from '../utils/utils'; @@ -19,6 +21,7 @@ import { SceneShareTabState, ShareView } from './types'; export interface ShareExportTabState extends SceneShareTabState { isSharingExternally?: boolean; isViewingJSON?: boolean; + isViewingJSONSchemaV2?: boolean; } export class ShareExportTab extends SceneObjectBase implements ShareView { @@ -51,6 +54,12 @@ export class ShareExportTab extends SceneObjectBase impleme }); }; + public onViewJSONSchemaV2 = () => { + this.setState({ + isViewingJSONSchemaV2: !this.state.isViewingJSONSchemaV2, + }); + }; + public getClipboardText() { return; } @@ -72,6 +81,23 @@ export class ShareExportTab extends SceneObjectBase impleme return exportable; }; + public getExportableDashboardJsonSchemaV2 = async () => { + // const { isSharingExternally } = this.state; + const saveModel = transformSceneToSaveModelSchemaV2(getDashboardSceneFor(this)); + + // const exportable = isSharingExternally + // ? await this._exporter.makeExportable( + // new DashboardModel(saveModel, undefined, { + // getVariablesFromState: () => { + // return getVariablesCompatibility(window.__grafanaSceneContext); + // }, + // }) + // ) + // : saveModel; + + return saveModel; + }; + public onSaveAsFile = async () => { const dashboardJson = await this.getExportableDashboardJson(); const dashboardJsonPretty = JSON.stringify(dashboardJson, null, 2); @@ -94,8 +120,9 @@ export class ShareExportTab extends SceneObjectBase impleme } function ShareExportTabRenderer({ model }: SceneComponentProps) { - const { isSharingExternally, isViewingJSON, modalRef } = model.useState(); - + const { isSharingExternally, isViewingJSON, modalRef, isViewingJSONSchemaV2 } = model.useState(); + // use dashboardSchemaV2 to show new button, this is for internal testing + const shouldShowNewSchemaV2Button = config.featureToggles.dashboardSchemaV2 ?? false; const dashboardJson = useAsync(async () => { if (isViewingJSON) { const json = await model.getExportableDashboardJson(); @@ -105,11 +132,20 @@ function ShareExportTabRenderer({ model }: SceneComponentProps) return ''; }, [isViewingJSON]); + const dashboardJsonSchemaV2 = useAsync(async () => { + if (isViewingJSONSchemaV2) { + const json = await model.getExportableDashboardJsonSchemaV2(); + console.log('json v2', json); + return JSON.stringify(json, null, 2); + } + return ''; + }, [isViewingJSONSchemaV2]); + const exportExternallyTranslation = t('share-modal.export.share-externally-label', `Export for sharing externally`); return ( <> - {!isViewingJSON && ( + {!isViewingJSON && !isViewingJSONSchemaV2 && ( <>

    Export this dashboard. @@ -137,13 +173,17 @@ function ShareExportTabRenderer({ model }: SceneComponentProps) + {shouldShowNewSchemaV2Button && ( + + )} )} - {isViewingJSON && ( <> @@ -161,7 +201,12 @@ function ShareExportTabRenderer({ model }: SceneComponentProps) } if (dashboardJson.loading) { - return

    Loading...
    ; + return ( +
    + {' '} + Loading... +
    + ); } return null; @@ -186,6 +231,52 @@ function ShareExportTabRenderer({ model }: SceneComponentProps) )} + {isViewingJSONSchemaV2 && ( + <> + + {({ width }) => { + if (dashboardJsonSchemaV2.value) { + return ( + + ); + } + + if (dashboardJsonSchemaV2.loading) { + return ( +
    + Loading... +
    + ); + } + + return null; + }} +
    + + + + dashboardJson.value ?? ''} + > + Copy to Clipboard + + + + + )} ); } diff --git a/public/app/features/dashboard/api/dashboard_api.ts b/public/app/features/dashboard/api/dashboard_api.ts index 84c6f2e98f3..8d54bb66a0e 100644 --- a/public/app/features/dashboard/api/dashboard_api.ts +++ b/public/app/features/dashboard/api/dashboard_api.ts @@ -50,7 +50,7 @@ interface DashboardWithAccessInfo extends Resource; diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx index 54d84347cd0..645dc5cad16 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx @@ -11,7 +11,7 @@ import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor'; import { OptionsPaneItemOverrides } from './OptionsPaneItemOverrides'; import { OptionPaneItemOverrideInfo } from './types'; -export interface OptionsPaneItemProps { +export interface OptionsPaneItemInfo { title: string; value?: any; description?: string; @@ -19,6 +19,8 @@ export interface OptionsPaneItemProps { render: () => React.ReactElement; skipField?: boolean; showIf?: () => boolean; + /** Hook for controlling visibility */ + useShowIf?: () => boolean; overrides?: OptionPaneItemOverrideInfo[]; addon?: ReactNode; } @@ -29,74 +31,87 @@ export interface OptionsPaneItemProps { export class OptionsPaneItemDescriptor { parent!: OptionsPaneCategoryDescriptor; - constructor(public props: OptionsPaneItemProps) {} - - getLabel(searchQuery?: string): ReactNode { - const { title, description, overrides, addon } = this.props; - - if (!searchQuery) { - // Do not render label for categories with only one child - if (this.parent.props.title === title && !overrides?.length) { - return null; - } - - return ; - } - - const categories: React.ReactNode[] = []; - - if (this.parent.parent) { - categories.push(this.highlightWord(this.parent.parent.props.title, searchQuery)); - } - - if (this.parent.props.title !== title) { - categories.push(this.highlightWord(this.parent.props.title, searchQuery)); - } - - return ( - - ); - } - - highlightWord(word: string, query: string) { - return ( - - ); - } - - renderOverrides() { - const { overrides } = this.props; - if (!overrides || overrides.length === 0) { - return; - } - } + constructor(public props: OptionsPaneItemInfo) {} render(searchQuery?: string) { - const { title, description, render, showIf, skipField } = this.props; - const key = `${this.parent.props.id} ${title}`; + return ; + } - if (showIf && !showIf()) { + useShowIf() { + if (this.props.useShowIf) { + return this.props.useShowIf(); + } + + if (this.props.showIf) { + return this.props.showIf(); + } + + return true; + } +} + +interface OptionsPaneItemProps { + itemDescriptor: OptionsPaneItemDescriptor; + searchQuery?: string; +} + +function OptionsPaneItem({ itemDescriptor, searchQuery }: OptionsPaneItemProps) { + const { title, description, render, skipField } = itemDescriptor.props; + const key = `${itemDescriptor.parent.props.id} ${title}`; + const showIf = itemDescriptor.useShowIf(); + + if (!showIf) { + return null; + } + + if (skipField) { + return render(); + } + + return ( + + {render()} + + ); +} + +function renderOptionLabel(itemDescriptor: OptionsPaneItemDescriptor, searchQuery?: string): ReactNode { + const { title, description, overrides, addon } = itemDescriptor.props; + + if (!searchQuery) { + // Do not render label for categories with only one child + if (itemDescriptor.parent.props.title === title && !overrides?.length) { return null; } - if (skipField) { - return render(); - } - - return ( - - {render()} - - ); + return ; } + + const categories: React.ReactNode[] = []; + + if (itemDescriptor.parent.parent) { + categories.push(highlightWord(itemDescriptor.parent.parent.props.title, searchQuery)); + } + + if (itemDescriptor.parent.props.title !== title) { + categories.push(highlightWord(itemDescriptor.parent.props.title, searchQuery)); + } + + return ( + + ); +} + +function highlightWord(word: string, query: string) { + return ; } interface OptionPanelLabelProps { diff --git a/public/app/features/explore/Logs/Logs.test.tsx b/public/app/features/explore/Logs/Logs.test.tsx index ec8ddda9938..620f576e118 100644 --- a/public/app/features/explore/Logs/Logs.test.tsx +++ b/public/app/features/explore/Logs/Logs.test.tsx @@ -150,12 +150,12 @@ describe('Logs', () => { }, }); - const { rerender } = render( + const rendered = render( {getComponent(partialProps, dataFrame ? dataFrame : getMockLokiFrame(), logs)} ); - return { rerender, store: fakeStore }; + return { ...rendered, store: fakeStore }; }; describe('scrolling behavior', () => { @@ -428,12 +428,14 @@ describe('Logs', () => { }); it('should call createAndCopyShortLink on permalinkClick - logs', async () => { - const panelState: Partial = { logs: { id: 'not-included', visualisationType: 'logs' } }; + const panelState: Partial = { + logs: { id: 'not-included', visualisationType: 'logs', displayedFields: ['field'] }, + }; const rows = [ - makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1 }), - makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 1 }), - makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 2 }), - makeLog({ uid: '4', rowId: 'id3', timeEpochMs: 2 }), + makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1, labels: { field: '1' } }), + makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 1, labels: { field: '2' } }), + makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 2, labels: { field: '3' } }), + makeLog({ uid: '4', rowId: 'id3', timeEpochMs: 2, labels: { field: '4' } }), ]; setup({ loading: false, panelState, logRows: rows }); @@ -449,6 +451,7 @@ describe('Logs', () => { ) ); expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('visualisationType%22:%22logs')); + expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('displayedFields%22:%5B%22field')); }); it('should call createAndCopyShortLink on permalinkClick - with infinite scrolling', async () => { @@ -480,6 +483,19 @@ describe('Logs', () => { }); }); + describe('displayed fields', () => { + it('should sync displayed fields from the URL', async () => { + const panelState: Partial = { + logs: { id: 'not-included', visualisationType: 'logs', displayedFields: ['field'] }, + }; + const rows = [makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1, labels: { field: 'field value' } })]; + setup({ loading: false, panelState, logRows: rows }); + + expect(await screen.findByText('field=field value')).toBeInTheDocument(); + expect(screen.queryByText(/log message/)).not.toBeInTheDocument(); + }); + }); + describe('with table visualisation', () => { let originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation; diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index d04c45fe2fb..8da155b1142 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -10,7 +10,6 @@ import { LogRowModel, LogsMetaItem, DataFrame, - DataQuery, AbsoluteTimeRange, GrafanaTheme2, LoadingState, @@ -37,6 +36,7 @@ import { urlUtil, } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { Button, InlineField, @@ -79,7 +79,7 @@ import { LogsMetaRow } from './LogsMetaRow'; import LogsNavigation from './LogsNavigation'; import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap'; import { LogsVolumePanelList } from './LogsVolumePanelList'; -import { SETTINGS_KEYS, visualisationTypeKey } from './utils/logs'; +import { canKeepDisplayedFields, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs'; interface Props extends Themeable2 { width: number; @@ -221,6 +221,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const cancelFlippingTimer = useRef(undefined); const toggleLegendRef = useRef<(name: string, mode: SeriesVisibilityChangeMode) => void>(() => {}); const topLogsRef = useRef(null); + const prevLogsQueries = usePrevious(logsQueries); const tableHeight = getLogsTableHeight(); const styles = getStyles(theme, wrapLogMessage, tableHeight); @@ -330,6 +331,16 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { store.set(visualisationTypeKey, visualisationType); }, [panelState?.logs?.visualisationType]); + useEffect(() => { + let displayedFields: string[] = []; + if (Array.isArray(panelState?.logs?.displayedFields)) { + displayedFields = panelState?.logs?.displayedFields; + } else if (panelState?.logs?.displayedFields && typeof panelState?.logs?.displayedFields === 'object') { + displayedFields = Object.values(panelState?.logs?.displayedFields); + } + setDisplayedFields(displayedFields); + }, [panelState?.logs?.displayedFields]); + useEffect(() => { registerLogLevelsWithContentOutline(); }, [logsVolumeData?.data, hiddenLogLevels, registerLogLevelsWithContentOutline]); @@ -344,8 +355,13 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { }); useUnmount(() => { - // If we're unmounting logs (e.g. switching to another datasource), we need to remove the table specific panel state, otherwise it will persist in the explore url - if (panelState?.logs?.columns || panelState?.logs?.refId || panelState?.logs?.labelFieldName) { + // If we're unmounting logs (e.g. switching to another datasource), we need to remove the logs specific panel state, otherwise it will persist in the explore url + if ( + panelState?.logs?.columns || + panelState?.logs?.refId || + panelState?.logs?.labelFieldName || + panelState?.logs?.displayedFields + ) { dispatch( changePanelState(exploreId, 'logs', { ...panelState?.logs, @@ -353,6 +369,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { visualisationType: visualisationType, labelFieldName: undefined, refId: undefined, + displayedFields: undefined, }) ); } @@ -369,13 +386,35 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { visualisationType: logsPanelState.visualisationType ?? visualisationType, labelFieldName: logsPanelState.labelFieldName, refId: logsPanelState.refId ?? panelState?.logs?.refId, + displayedFields: logsPanelState.displayedFields ?? panelState?.logs?.displayedFields, }) ); } }, - [dispatch, exploreId, panelState?.logs?.columns, panelState?.logs?.refId, visualisationType] + [ + dispatch, + exploreId, + panelState?.logs?.columns, + panelState?.logs?.displayedFields, + panelState?.logs?.refId, + visualisationType, + ] ); + useEffect(() => { + if (!prevLogsQueries) { + // Initial load, ignore + return; + } + if (!canKeepDisplayedFields(logsQueries, prevLogsQueries)) { + setDisplayedFields([]); + updatePanelState({ + ...panelState?.logs, + displayedFields: [], + }); + } + }, [logsQueries, panelState?.logs, prevLogsQueries, updatePanelState]); + // actions const onLogRowHover = useCallback( (row?: LogRowModel) => { @@ -559,20 +598,30 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const index = displayedFields.indexOf(key); if (index === -1) { - setDisplayedFields(displayedFields.concat(key)); + const updatedDisplayedFields = displayedFields.concat(key); + setDisplayedFields(updatedDisplayedFields); + updatePanelState({ + ...panelState?.logs, + displayedFields: updatedDisplayedFields, + }); } }, - [displayedFields] + [displayedFields, panelState?.logs, updatePanelState] ); const hideField = useCallback( (key: string) => { const index = displayedFields.indexOf(key); if (index > -1) { - setDisplayedFields(displayedFields.filter((k) => key !== k)); + const updatedDisplayedFields = displayedFields.filter((k) => key !== k); + setDisplayedFields(updatedDisplayedFields); + updatePanelState({ + ...panelState?.logs, + displayedFields: updatedDisplayedFields, + }); } }, - [displayedFields] + [displayedFields, panelState?.logs, updatePanelState] ); const clearDetectedFields = useCallback(() => { @@ -658,7 +707,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!); urlState.panelsState = { ...panelState, - logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType() }, + logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields }, }; urlState.range = getPermalinkRange(row); diff --git a/public/app/features/explore/Logs/LogsMetaRow.tsx b/public/app/features/explore/Logs/LogsMetaRow.tsx index 26f59a2689a..abf22494946 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.tsx @@ -139,7 +139,7 @@ export const LogsMetaRow = memo( { label: '', value: ( - ), diff --git a/public/app/features/explore/Logs/utils/logs.ts b/public/app/features/explore/Logs/utils/logs.ts index 2aec91da980..61e4f9ce5d5 100644 --- a/public/app/features/explore/Logs/utils/logs.ts +++ b/public/app/features/explore/Logs/utils/logs.ts @@ -1,3 +1,6 @@ +import { shallowCompare } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; + export const SETTINGS_KEYS = { showLabels: 'grafana.explore.logs.showLabels', showTime: 'grafana.explore.logs.showTime', @@ -8,3 +11,15 @@ export const SETTINGS_KEYS = { }; export const visualisationTypeKey = 'grafana.explore.logs.visualisationType'; + +export const canKeepDisplayedFields = (logsQueries: DataQuery[] | undefined, prevLogsQueries: DataQuery[]): boolean => { + if (!logsQueries) { + return false; + } + for (let i = 0; i < logsQueries.length; i++) { + if (!shallowCompare(logsQueries[i], prevLogsQueries[i])) { + return false; + } + } + return true; +}; diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index e36ba898134..f837c36a2fb 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -19,11 +19,13 @@ import { DataLinkPostProcessor, ExploreUrlState, urlUtil, + DataFrameType, } from '@grafana/data'; import { getTemplateSrv, reportInteraction, VariableInterpolation } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { getTransformationVars } from 'app/features/correlations/transformations'; +import { parseDataplaneLogsFrame } from 'app/features/logs/logsFrame'; import { ExploreItemState } from 'app/types/explore'; import { getLinkSrv } from '../../panel/panellinks/link_srv'; @@ -137,6 +139,18 @@ export const getFieldLinksForExplore = (options: { text: 'Data', }; + if (dataFrame.meta?.type === DataFrameType.LogLines) { + const dataPlane = parseDataplaneLogsFrame(dataFrame); + const labels = dataPlane?.getLogFrameLabels(); + if (labels != null) { + Object.entries(labels[rowIndex]).forEach((value) => { + scopedVars[value[0]] = { + value: value[1], + }; + }); + } + } + dataFrame.fields.forEach((f) => { if (fieldDisplayValuesProxy && fieldDisplayValuesProxy[f.name]) { scopedVars[f.name] = { diff --git a/public/app/features/logs/components/InfiniteScroll.test.tsx b/public/app/features/logs/components/InfiniteScroll.test.tsx index e5e7854dd72..666932be9d9 100644 --- a/public/app/features/logs/components/InfiniteScroll.test.tsx +++ b/public/app/features/logs/components/InfiniteScroll.test.tsx @@ -74,11 +74,14 @@ function setup( wheel(-1); } } - function wheel(deltaY: number) { + function wheel(deltaY: number, timeStamp?: number) { element.scrollTop += deltaY; act(() => { const event = new WheelEvent('wheel', { deltaY }); + if (timeStamp) { + jest.spyOn(event, 'timeStamp', 'get').mockReturnValue(timeStamp); + } events['wheel'](event); }); } @@ -164,7 +167,7 @@ describe('InfiniteScroll', () => { test.each([ ['up', -5, 0], - ['down', 5, 100], + ['down', 5, 60], ])( 'Requests more logs when moving the mousewheel %s', async (_: string, deltaY: number, startPosition: number) => { @@ -273,6 +276,49 @@ describe('InfiniteScroll', () => { } ); }); + + describe('Chain of events', () => { + test('Ingnores chains of events', async () => { + const loadMoreMock = jest.fn(); + const { wheel } = setup(loadMoreMock, 57, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + + const timeStamps = [1, 2, 3, 4]; + timeStamps.forEach((timeStamp) => { + wheel(1, timeStamp); + }); + + expect(loadMoreMock).not.toHaveBeenCalled(); + }); + + test('Detects when chain of events ends', async () => { + const loadMoreMock = jest.fn(); + const { wheel } = setup(loadMoreMock, 57, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + + const timeStamps = [1, 2, 3, 600, 1]; + timeStamps.forEach((timeStamp) => { + wheel(1, timeStamp); + }); + + expect(loadMoreMock).toHaveBeenCalledTimes(1); + }); + + test('Detects when the user wants to scroll', async () => { + const loadMoreMock = jest.fn(); + const { wheel } = setup(loadMoreMock, 57, rows, order); + + expect(await screen.findByTestId('contents')).toBeInTheDocument(); + + for (let i = 0; i <= 25; i++) { + wheel(1, 399 * i + 399); + } + + expect(loadMoreMock).toHaveBeenCalledTimes(1); + }); + }); } ); diff --git a/public/app/features/logs/components/InfiniteScroll.tsx b/public/app/features/logs/components/InfiniteScroll.tsx index 66ff34e4452..d8cbeb918f3 100644 --- a/public/app/features/logs/components/InfiniteScroll.tsx +++ b/public/app/features/logs/components/InfiniteScroll.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { ReactNode, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'; import { AbsoluteTimeRange, CoreApp, LogRowModel, TimeRange } from '@grafana/data'; import { convertRawToRange, isRelativeTime, isRelativeTimeRange } from '@grafana/data/src/datetime/rangeutil'; @@ -41,6 +41,8 @@ export const InfiniteScroll = ({ const [lowerLoading, setLowerLoading] = useState(false); const rowsRef = useRef(rows); const lastScroll = useRef(scrollElement?.scrollTop || 0); + const lastEvent = useRef(null); + const countRef = useRef(0); // Reset messages when range/order/rows change useEffect(() => { @@ -84,12 +86,14 @@ export const InfiniteScroll = ({ if (!scrollElement || !loadMoreLogs || !rows.length || loading || !config.featureToggles.logsInfiniteScrolling) { return; } - event.stopImmediatePropagation(); - const scrollDirection = shouldLoadMore(event, scrollElement, lastScroll.current); + const scrollDirection = shouldLoadMore(event, lastEvent.current, countRef, scrollElement, lastScroll.current); + lastEvent.current = event; lastScroll.current = scrollElement.scrollTop; if (scrollDirection === ScrollDirection.NoScroll) { return; - } else if (scrollDirection === ScrollDirection.Top && topScrollEnabled) { + } + event.stopImmediatePropagation(); + if (scrollDirection === ScrollDirection.Top && topScrollEnabled) { scrollTop(); } else if (scrollDirection === ScrollDirection.Bottom) { scrollBottom(); @@ -215,7 +219,13 @@ enum ScrollDirection { Bottom = 1, NoScroll = 0, } -function shouldLoadMore(event: Event | WheelEvent, element: HTMLDivElement, lastScroll: number): ScrollDirection { +function shouldLoadMore( + event: Event | WheelEvent, + lastEvent: Event | WheelEvent | null, + countRef: MutableRefObject, + element: HTMLDivElement, + lastScroll: number +): ScrollDirection { // Disable behavior if there is no scroll if (element.scrollHeight <= element.clientHeight) { return ScrollDirection.NoScroll; @@ -224,13 +234,54 @@ function shouldLoadMore(event: Event | WheelEvent, element: HTMLDivElement, last if (delta === 0) { return ScrollDirection.NoScroll; } + const scrollDirection = delta < 0 ? ScrollDirection.Top : ScrollDirection.Bottom; const diff = scrollDirection === ScrollDirection.Top ? element.scrollTop : element.scrollHeight - element.scrollTop - element.clientHeight; - return diff <= 1 ? scrollDirection : ScrollDirection.NoScroll; + if (diff > 1) { + return ScrollDirection.NoScroll; + } + + if (lastEvent && shouldIgnoreChainOfEvents(event, lastEvent, countRef)) { + return ScrollDirection.NoScroll; + } + + return scrollDirection; +} + +function shouldIgnoreChainOfEvents( + event: Event | WheelEvent, + lastEvent: Event | WheelEvent, + countRef: MutableRefObject +) { + const deltaTime = event.timeStamp - lastEvent.timeStamp; + // Not a chain of events + if (deltaTime > 500) { + countRef.current = 0; + return false; + } + countRef.current++; + // Likely trackpad + if (deltaTime < 100) { + // User likely to want more results + if (countRef.current >= 180) { + countRef.current = 0; + return false; + } + return true; + } + // Likely mouse wheel + if (deltaTime < 400) { + // User likely to want more results + if (countRef.current >= 25) { + countRef.current = 0; + return false; + } + } + return true; } function getVisibleRange(rows: LogRowModel[]) { diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index 686f49e19a4..6a59f32dbfc 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -299,6 +299,8 @@ class UnThemedLogRow extends PureComponent { pinned={this.props.pinned} mouseIsOver={this.state.mouseIsOver} onBlur={this.onMouseLeave} + logRowMenuIconsBefore={logRowMenuIconsBefore} + logRowMenuIconsAfter={logRowMenuIconsAfter} /> ) : ( { expect(screen.getByText(/place=Earth/)).toBeInTheDocument(); expect(screen.getByText(/planet=Mars/)).toBeInTheDocument(); }); + + describe('With custom buttons', () => { + it('supports custom buttons before and after the default options', async () => { + const onBefore = jest.fn(); + const logRowMenuIconsBefore = [ + , + ]; + const onAfter = jest.fn(); + const logRowMenuIconsAfter = [ + , + ]; + + const { row } = setup({ logRowMenuIconsBefore, logRowMenuIconsAfter }); + + await userEvent.click(screen.getByLabelText('Addon before')); + await userEvent.click(screen.getByLabelText('Addon after')); + + expect(onBefore).toHaveBeenCalledWith(expect.anything(), row); + expect(onAfter).toHaveBeenCalledWith(expect.anything(), row); + }); + }); }); diff --git a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx index 5391dd0cce9..363fb97bb17 100644 --- a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx +++ b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { memo, useMemo } from 'react'; +import { memo, ReactNode, useMemo } from 'react'; import { LogRowModel, Field, LinkModel, DataFrame } from '@grafana/data'; @@ -21,10 +21,23 @@ export interface Props { pinned?: boolean; mouseIsOver: boolean; onBlur: () => void; + logRowMenuIconsBefore?: ReactNode[]; + logRowMenuIconsAfter?: ReactNode[]; } export const LogRowMessageDisplayedFields = memo((props: Props) => { - const { row, detectedFields, getFieldLinks, wrapLogMessage, styles, mouseIsOver, pinned, ...rest } = props; + const { + row, + detectedFields, + getFieldLinks, + wrapLogMessage, + styles, + mouseIsOver, + pinned, + logRowMenuIconsBefore, + logRowMenuIconsAfter, + ...rest + } = props; const wrapClassName = wrapLogMessage ? '' : displayedFieldsStyles.noWrap; const fields = useMemo(() => getAllFields(row, getFieldLinks), [getFieldLinks, row]); // only single key/value rows are filterable, so we only need the first field key for filtering @@ -63,6 +76,8 @@ export const LogRowMessageDisplayedFields = memo((props: Props) => { styles={styles} pinned={pinned} mouseIsOver={mouseIsOver} + addonBefore={logRowMenuIconsBefore} + addonAfter={logRowMenuIconsAfter} {...rest} /> )} diff --git a/public/app/features/logs/legacyLogsFrame.ts b/public/app/features/logs/legacyLogsFrame.ts index e6b3bbc4399..902c973a093 100644 --- a/public/app/features/logs/legacyLogsFrame.ts +++ b/public/app/features/logs/legacyLogsFrame.ts @@ -45,7 +45,7 @@ export function parseLegacyLogsFrame(frame: DataFrame): LogsFrame | null { } const timeNanosecondField = cache.getFieldByName('tsNs') ?? null; - const severityField = cache.getFieldByName('detected_level') ?? cache.getFieldByName('level') ?? null; + const severityField = cache.getFieldByName('level') ?? cache.getFieldByName('detected_level') ?? null; const idField = cache.getFieldByName('id') ?? null; // extracting the labels is done very differently for old-loki-style and simple-style diff --git a/public/app/features/logs/logsFrame.ts b/public/app/features/logs/logsFrame.ts index 513f56a3e1e..c8e00f96319 100644 --- a/public/app/features/logs/logsFrame.ts +++ b/public/app/features/logs/logsFrame.ts @@ -45,7 +45,7 @@ export function logFrameLabelsToLabels(logFrameLabels: LogFrameLabels): Labels { return result; } -function parseDataplaneLogsFrame(frame: DataFrame): LogsFrame | null { +export function parseDataplaneLogsFrame(frame: DataFrame): LogsFrame | null { const cache = new FieldCache(frame); const timestampField = getField(cache, DATAPLANE_TIMESTAMP_NAME, FieldType.time); diff --git a/public/app/features/logs/logsModel.ts b/public/app/features/logs/logsModel.ts index 9be17b8dec1..a298b7f0bb9 100644 --- a/public/app/features/logs/logsModel.ts +++ b/public/app/features/logs/logsModel.ts @@ -426,7 +426,7 @@ export function logSeriesToLogsModel( } let logLevel = LogLevel.unknown; - const logLevelKey = (logLevelField && logLevelField.values[j]) || (labels?.detected_level ?? labels?.level); + const logLevelKey = (logLevelField && logLevelField.values[j]) || (labels?.level ?? labels?.detected_level); if (typeof logLevelKey === 'number' || typeof logLevelKey === 'string') { logLevel = getLogLevelFromKey(logLevelKey); } else { @@ -643,7 +643,7 @@ function defaultExtractLevel(dataFrame: DataFrame): LogLevel { } function getLogLevelFromLabels(labels: Labels): LogLevel { - const level = labels['detected_level'] ?? labels['level'] ?? labels['lvl'] ?? labels['loglevel'] ?? ''; + const level = labels['level'] ?? labels['detected_level'] ?? labels['lvl'] ?? labels['loglevel'] ?? ''; return level ? getLogLevelFromKey(level) : LogLevel.unknown; } diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index dcae58bb515..69412411454 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -49,7 +49,13 @@ import { MetricDatasourceHelper } from './helpers/MetricDatasourceHelper'; import { reportChangeInLabelFilters } from './interactions'; import { getDeploymentEnvironments, TARGET_INFO_FILTER, totalOtelResources } from './otel/api'; import { OtelResourcesObject, OtelTargetType } from './otel/types'; -import { sortResources, getOtelJoinQuery, getOtelResourcesObject, updateOtelJoinWithGroupLeft } from './otel/util'; +import { + sortResources, + getOtelJoinQuery, + getOtelResourcesObject, + updateOtelJoinWithGroupLeft, + getProdOrDefaultOption, +} from './otel/util'; import { getOtelExperienceToggleState } from './services/store'; import { getVariablesWithOtelJoinQueryConstant, @@ -357,7 +363,8 @@ export class DataTrail extends SceneObjectBase { // We have to have a default value because custom variable requires it // we choose one default value to help filter metrics // The work flow for OTel begins with users selecting a deployment environment - const defaultDepEnv = options[0].value; // usually production + // default to production + let defaultDepEnv = getProdOrDefaultOption(options) ?? ''; // On starting the explore metrics workflow, the custom variable has no value // Even if there is state, the value is always '' // The only reference to state values are in the text diff --git a/public/app/features/trails/otel/util.ts b/public/app/features/trails/otel/util.ts index dc76fbeb623..ef935bc9017 100644 --- a/public/app/features/trails/otel/util.ts +++ b/public/app/features/trails/otel/util.ts @@ -284,3 +284,18 @@ export async function updateOtelJoinWithGroupLeft(trail: DataTrail, metric: stri otelJoinQueryVariable.setState({ value: otelJoinQuery }); } } + +/** + * Returns the option value that is like 'prod'. + * If there are no options, returns null. + * + * @param options + * @returns + */ +export function getProdOrDefaultOption(options: Array<{ value: string; label: string }>): string | null { + if (options.length === 0) { + return null; + } + + return options.find((option) => option.value.toLowerCase().indexOf('prod') > -1)?.value ?? options[0].value; +} diff --git a/public/app/features/trails/otel/utils.test.ts b/public/app/features/trails/otel/utils.test.ts index d8f72ecf587..b6edb714bbf 100644 --- a/public/app/features/trails/otel/utils.test.ts +++ b/public/app/features/trails/otel/utils.test.ts @@ -8,7 +8,14 @@ import { activateFullSceneTree } from 'app/features/dashboard-scene/utils/test-u import { DataTrail } from '../DataTrail'; import { VAR_OTEL_DEPLOYMENT_ENV, VAR_OTEL_GROUP_LEFT, VAR_OTEL_JOIN_QUERY, VAR_OTEL_RESOURCES } from '../shared'; -import { sortResources, getOtelJoinQuery, blessedList, limitOtelMatchTerms, updateOtelJoinWithGroupLeft } from './util'; +import { + sortResources, + getOtelJoinQuery, + blessedList, + limitOtelMatchTerms, + updateOtelJoinWithGroupLeft, + getProdOrDefaultOption, +} from './util'; jest.mock('./api', () => ({ totalOtelResources: jest.fn(() => ({ job: 'oteldemo', instance: 'instance' })), @@ -270,3 +277,42 @@ describe('updateOtelJoinWithGroupLeft', () => { expect(otelJoinQueryVar.getValue()).toBe(''); }); }); + +describe('getProdOrDefaultOption', () => { + it('should return the value of the option containing "prod"', () => { + const options = [ + { value: 'test1', label: 'Test 1' }, + { value: 'prod2', label: 'Prod 2' }, + { value: 'test3', label: 'Test 3' }, + ]; + expect(getProdOrDefaultOption(options)).toBe('prod2'); + }); + + it('should return the first option value if no option contains "prod"', () => { + const options = [ + { value: 'test1', label: 'Test 1' }, + { value: 'test2', label: 'Test 2' }, + { value: 'test3', label: 'Test 3' }, + ]; + expect(getProdOrDefaultOption(options)).toBe('test1'); + }); + + it('should handle case insensitivity', () => { + const options = [ + { value: 'test1', label: 'Test 1' }, + { value: 'PROD2', label: 'Prod 2' }, + { value: 'test3', label: 'Test 3' }, + ]; + expect(getProdOrDefaultOption(options)).toBe('PROD2'); + }); + + it('should return null if the options array is empty', () => { + const options: Array<{ value: string; label: string }> = []; + expect(getProdOrDefaultOption(options)).toBeNull(); + }); + + it('should return the first option value if the options array has one element', () => { + const options = [{ value: 'test1', label: 'Test 1' }]; + expect(getProdOrDefaultOption(options)).toBe('test1'); + }); +}); diff --git a/public/app/features/transformers/docs/content.ts b/public/app/features/transformers/docs/content.ts index 884b5edfb0d..dc91b1152b8 100644 --- a/public/app/features/transformers/docs/content.ts +++ b/public/app/features/transformers/docs/content.ts @@ -240,6 +240,7 @@ Use this transformation to select a source of data and extract content from it i - **Format** - Choose one of the following: - **JSON** - Parse JSON content from the source. - **Key+value pairs** - Parse content in the format 'a=b' or 'c:d' from the source. + - **RegExp** - Parse content using a regular expression with [named capturing group(s)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group). - **Auto** - Discover fields automatically. - **Replace All Fields** - (Optional) Select this option to hide all other fields and display only your calculated field in the visualization. - **Keep Time** - (Optional) Available only if **Replace All Fields** is true. Keeps the time field in the output. diff --git a/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx b/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx index 3261a77d7e2..e08b4065732 100644 --- a/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx +++ b/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx @@ -1,3 +1,5 @@ +import { ChangeEvent } from 'react'; + import { DataTransformerID, TransformerRegistryItem, @@ -7,7 +9,7 @@ import { StandardEditorsRegistryItem, TransformerCategory, } from '@grafana/data'; -import { InlineField, InlineFieldRow, Select, InlineSwitch } from '@grafana/ui'; +import { InlineField, InlineFieldRow, Select, InlineSwitch, Input } from '@grafana/ui'; import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker'; import { getTransformationContent } from '../docs/getTransformationContent'; @@ -53,6 +55,13 @@ export const extractFieldsTransformerEditor = ({ }); }; + const onRegexpChange = (e: ChangeEvent) => { + onChange({ + ...options, + regExp: e.target.value, + }); + }; + const onToggleReplace = () => { if (options.replace) { options.keepTime = false; @@ -96,7 +105,16 @@ export const extractFieldsTransformerEditor = ({ /> - {options.format === 'json' && } + {options.format === FieldExtractorID.RegExp && ( + + + + + + )} + {options.format === FieldExtractorID.JSON && ( + + )} diff --git a/public/app/features/transformers/extractFields/extractFields.ts b/public/app/features/transformers/extractFields/extractFields.ts index 2175adf4ac8..d686d262332 100644 --- a/public/app/features/transformers/extractFields/extractFields.ts +++ b/public/app/features/transformers/extractFields/extractFields.ts @@ -52,13 +52,14 @@ export function addExtractedFields(frame: DataFrame, options: ExtractFieldsOptio const count = frame.length; const names: string[] = []; // keep order const values = new Map(); + const parse = ext.getParser(options); for (let i = 0; i < count; i++) { let obj = source.values[i]; if (isString(obj)) { try { - obj = ext.parse(obj); + obj = parse(obj); } catch { obj = {}; // empty } diff --git a/public/app/features/transformers/extractFields/fieldExtractor.test.ts b/public/app/features/transformers/extractFields/fieldExtractor.test.ts index 6757acc3d90..820c3843e1a 100644 --- a/public/app/features/transformers/extractFields/fieldExtractor.test.ts +++ b/public/app/features/transformers/extractFields/fieldExtractor.test.ts @@ -3,12 +3,12 @@ import { config } from '@grafana/runtime'; import { addExtractedFields } from './extractFields'; import { fieldExtractors } from './fieldExtractors'; -import { FieldExtractorID } from './types'; +import { ExtractFieldsOptions, FieldExtractorID } from './types'; describe('Extract fields from text', () => { it('JSON extractor', async () => { const extractor = fieldExtractors.get(FieldExtractorID.JSON); - const out = extractor.parse('{"a":"148.1672","av":41923755,"c":148.25}'); + const out = extractor.getParser({})('{"a":"148.1672","av":41923755,"c":148.25}'); expect(out).toMatchInlineSnapshot(` { @@ -21,7 +21,7 @@ describe('Extract fields from text', () => { it('Test key-values with single/double quotes', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse('a="1", "b"=\'2\',c=3 x:y ;\r\nz="d and 4"'); + const out = extractor.getParser({})('a="1", "b"=\'2\',c=3 x:y ;\r\nz="d and 4"'); expect(out).toMatchInlineSnapshot(` { "a": "1", @@ -35,7 +35,7 @@ describe('Extract fields from text', () => { it('Test key-values with nested single/double quotes', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse( + const out = extractor.getParser({})( `a="1", "b"=\'2\',c=3 x:y ;\r\nz="dbl_quotes=\\"Double Quotes\\" sgl_quotes='Single Quotes'"` ); @@ -52,7 +52,7 @@ describe('Extract fields from text', () => { it('Test key-values with nested separator characters', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse(`a="1", "b"=\'2\',c=3 x:y ;\r\nz="This is; testing& validating, 1=:2"`); + const out = extractor.getParser({})(`a="1", "b"=\'2\',c=3 x:y ;\r\nz="This is; testing& validating, 1=:2"`); expect(out).toMatchInlineSnapshot(` { @@ -67,7 +67,7 @@ describe('Extract fields from text', () => { it('Test key-values where some values are null', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse(`a=, "b"=\'2\',c=3 x: `); + const out = extractor.getParser({})(`a=, "b"=\'2\',c=3 x: `); expect(out).toMatchInlineSnapshot(` { @@ -81,7 +81,7 @@ describe('Extract fields from text', () => { it('Split key+values', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse('a="1", "b"=\'2\',c=3 x:y ;\r\nz="7"'); + const out = extractor.getParser({})('a="1", "b"=\'2\',c=3 x:y ;\r\nz="7"'); expect(out).toMatchInlineSnapshot(` { "a": "1", @@ -95,7 +95,7 @@ describe('Extract fields from text', () => { it('Split URL style parameters', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse('a=b&c=d&x=123'); + const out = extractor.getParser({})('a=b&c=d&x=123'); expect(out).toMatchInlineSnapshot(` { "a": "b", @@ -107,7 +107,7 @@ describe('Extract fields from text', () => { it('Prometheus labels style (not really supported)', async () => { const extractor = fieldExtractors.get(FieldExtractorID.KeyValues); - const out = extractor.parse('{foo="bar", baz="42"}'); + const out = extractor.getParser({})('{foo="bar", baz="42"}'); expect(out).toMatchInlineSnapshot(` { "baz": "42", @@ -137,4 +137,18 @@ describe('Extract fields from text', () => { expect(newFrame.fields.length).toBe(2); expect(newFrame.fields[1].name).toBe('bar'); }); + + it('splits by regexp', async () => { + const extractor = fieldExtractors.get(FieldExtractorID.RegExp); + const opts: ExtractFieldsOptions = { regExp: '/^(?\\w+)[^\\w]+(?\\w+)$/' }; + const parse = extractor.getParser(opts); + const out = parse('abc - re30z'); + + expect(out).toMatchInlineSnapshot(` + { + "FieldA": "abc", + "FieldB": "re30z", + } + `); + }); }); diff --git a/public/app/features/transformers/extractFields/fieldExtractors.ts b/public/app/features/transformers/extractFields/fieldExtractors.ts index ba8af2b78d6..348984cd9db 100644 --- a/public/app/features/transformers/extractFields/fieldExtractors.ts +++ b/public/app/features/transformers/extractFields/fieldExtractors.ts @@ -1,20 +1,43 @@ -import { Registry, RegistryItem } from '@grafana/data'; +import { Registry, RegistryItem, stringStartsAsRegEx, stringToJsRegex } from '@grafana/data'; -import { FieldExtractorID } from './types'; +import { ExtractFieldsOptions, FieldExtractorID } from './types'; + +type Parser = (v: string) => Record | undefined; export interface FieldExtractor extends RegistryItem { - parse: (v: string) => Record | undefined; + getParser: (opts: ExtractFieldsOptions) => Parser; } const extJSON: FieldExtractor = { id: FieldExtractorID.JSON, name: 'JSON', description: 'Parse JSON string', - parse: (v: string) => { + getParser: (options) => (v: string) => { return JSON.parse(v); }, }; +const extRegExp: FieldExtractor = { + id: FieldExtractorID.RegExp, + name: 'RegExp', + description: 'Parse with RegExp', + getParser: (options) => { + let regex: RegExp | null = /(?.*)/; + + if (stringStartsAsRegEx(options.regExp!)) { + try { + regex = stringToJsRegex(options.regExp!); + } catch (error) { + if (error instanceof Error) { + console.warn(error.message); + } + } + } + + return (v: string) => v.match(regex)?.groups; + }, +}; + function parseKeyValuePairs(raw: string): Record { const buff: string[] = []; // array of characters let esc = ''; @@ -107,25 +130,29 @@ const extLabels: FieldExtractor = { id: FieldExtractorID.KeyValues, name: 'Key+value pairs', description: 'Look for a=b, c: d values in the line', - parse: parseKeyValuePairs, + getParser: (options) => parseKeyValuePairs, }; -const fmts = [extJSON, extLabels]; +const fmts = [extJSON, extLabels, extRegExp]; const extAuto: FieldExtractor = { id: FieldExtractorID.Auto, name: 'Auto', description: 'parse new fields automatically', - parse: (v: string) => { - for (const f of fmts) { - try { - const r = f.parse(v); - if (r != null) { - return r; - } - } catch {} // ignore errors - } - return undefined; + getParser: (options) => { + const parsers = fmts.map((fmt) => fmt.getParser(options)); + + return (v: string) => { + for (const parse of parsers) { + try { + const r = parse(v); + if (r != null) { + return r; + } + } catch {} // ignore errors + } + return undefined; + }; }, }; diff --git a/public/app/features/transformers/extractFields/types.ts b/public/app/features/transformers/extractFields/types.ts index e0f63aab4d1..b0204f14377 100644 --- a/public/app/features/transformers/extractFields/types.ts +++ b/public/app/features/transformers/extractFields/types.ts @@ -2,6 +2,7 @@ export enum FieldExtractorID { JSON = 'json', KeyValues = 'kvp', Auto = 'auto', + RegExp = 'regexp', } export interface JSONPath { @@ -11,6 +12,7 @@ export interface JSONPath { export interface ExtractFieldsOptions { source?: string; jsonPaths?: JSONPath[]; + regExp?: string; format?: FieldExtractorID; replace?: boolean; keepTime?: boolean; diff --git a/public/app/plugins/datasource/loki/getDerivedFields.test.ts b/public/app/plugins/datasource/loki/getDerivedFields.test.ts index 674d95cea91..7b56f5a5751 100644 --- a/public/app/plugins/datasource/loki/getDerivedFields.test.ts +++ b/public/app/plugins/datasource/loki/getDerivedFields.test.ts @@ -192,4 +192,28 @@ describe('getDerivedFields', () => { title: '', }); }); + + it('matches label keys using regex when matcherType is label', () => { + const df = createDataFrame({ + fields: [ + { name: 'labels', values: [{ traceId: 'abc' }, { traceID: 'xyz' }] }, + { name: 'line', values: ['log1', 'log2'] }, + ], + }); + const newFields = getDerivedFields(df, [ + { + matcherRegex: 'traceI(d|D)', + name: 'traceIdFromLabel', + url: 'http://localhost/${__value.raw}', + matcherType: 'label', + }, + ]); + expect(newFields.length).toBe(1); + const traceId = newFields.find((f) => f.name === 'traceIdFromLabel'); + expect(traceId!.values).toEqual(['abc', 'xyz']); + expect(traceId!.config.links![0]).toEqual({ + url: 'http://localhost/${__value.raw}', + title: '', + }); + }); }); diff --git a/public/app/plugins/datasource/loki/getDerivedFields.ts b/public/app/plugins/datasource/loki/getDerivedFields.ts index e28c697adfc..8a23e1d54ff 100644 --- a/public/app/plugins/datasource/loki/getDerivedFields.ts +++ b/public/app/plugins/datasource/loki/getDerivedFields.ts @@ -30,10 +30,11 @@ export function getDerivedFields(dataFrame: DataFrame, derivedFieldConfigs: Deri if (derivedFieldsGrouped[field.name][0].matcherType === 'label' && labelFields) { const label = labelFields.values[i]; if (label) { - // Find the key that matches both, the `matcherRegex` and the label key - const intersectingKey = Object.keys(label).find( - (key) => derivedFieldsGrouped[field.name][0].matcherRegex === key - ); + // Find the key that matches the regex pattern in `matcherRegex` + const intersectingKey = Object.keys(label).find((key) => { + const regex = new RegExp(derivedFieldsGrouped[field.name][0].matcherRegex); + return regex.test(key); + }); if (intersectingKey) { field.values.push(label[intersectingKey]); diff --git a/public/app/plugins/datasource/loki/responseUtils.ts b/public/app/plugins/datasource/loki/responseUtils.ts index dcfafdc670a..95c9112b91c 100644 --- a/public/app/plugins/datasource/loki/responseUtils.ts +++ b/public/app/plugins/datasource/loki/responseUtils.ts @@ -122,7 +122,7 @@ export function extractLevelLikeLabelFromDataFrame(frame: DataFrame): string | n // Find first level-like label for (let labels of labelsArray) { const label = Object.keys(labels).find( - (label) => label === 'detected_level' || label === 'level' || label === 'lvl' || label.includes('level') + (label) => label === 'level' || label === 'detected_level' || label === 'lvl' || label.includes('level') ); if (label) { levelLikeLabel = label; diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index ffaee3bd7cc..616a26d6505 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -51,12 +51,7 @@ import { } from './graphTransform'; import TempoLanguageProvider from './language_provider'; import { createTableFrameFromMetricsSummaryQuery, emptyResponse, MetricsSummary } from './metricsSummary'; -import { - formatTraceQLMetrics, - formatTraceQLResponse, - transformFromOTLP as transformFromOTEL, - transformTrace, -} from './resultTransformer'; +import { formatTraceQLResponse, transformFromOTLP as transformFromOTEL, transformTrace } from './resultTransformer'; import { doTempoChannelStream } from './streaming'; import { TempoJsonData, TempoQuery } from './types'; import { getErrorMessage, migrateFromSearchToTraceQLSearch } from './utils'; @@ -354,7 +349,7 @@ export class TempoDatasource extends DataSourceWithBackend, - queryValue: string - ): Observable => { - const requestData = { - query: queryValue, - start: options.range.from.unix(), - end: options.range.to.unix(), - step: options.targets[0].step, - }; - - if (!requestData.step) { - delete requestData.step; + handleTraceQlMetricsQuery = (options: DataQueryRequest): Observable => { + const validTargets = options.targets + .filter((t) => t.query) + .map( + (t): TempoQuery => ({ ...t, query: this.applyVariables(t, options.scopedVars).query, queryType: 'traceql' }) + ); + if (!validTargets.length) { + return EMPTY; } - return this._request('/api/metrics/query_range', requestData).pipe( + const request = { ...options, targets: validTargets }; + return super.query(request).pipe( map((response) => { - return { - data: formatTraceQLMetrics(queryValue, response.data), - }; + return response; }), catchError((err) => { return of({ error: { message: getErrorMessage(err.data.message) }, data: [] }); diff --git a/public/app/plugins/datasource/tempo/resultTransformer.ts b/public/app/plugins/datasource/tempo/resultTransformer.ts index 4557b1137f7..52e9424c514 100644 --- a/public/app/plugins/datasource/tempo/resultTransformer.ts +++ b/public/app/plugins/datasource/tempo/resultTransformer.ts @@ -15,7 +15,6 @@ import { FieldDTO, FieldType, getDisplayProcessor, - Labels, MutableDataFrame, toDataFrame, TraceKeyValuePair, @@ -28,15 +27,7 @@ import { getDataSourceSrv } from '@grafana/runtime'; import { SearchTableType } from './dataquery.gen'; import { createGraphFrames } from './graphTransform'; -import { - ProtoValue, - Span, - SpanAttributes, - Spanset, - TempoJsonData, - TraceqlMetricsResponse, - TraceSearchMetadata, -} from './types'; +import { Span, SpanAttributes, Spanset, TempoJsonData, TraceSearchMetadata } from './types'; function getAttributeValue(value: collectorTypes.opentelemetryProto.common.v1.AnyValue): any { if (value.stringValue) { @@ -474,56 +465,6 @@ function transformToTraceData(data: TraceSearchMetadata) { }; } -const metricsValueToString = (value: ProtoValue): string => { - if (value.stringValue) { - return `"${value.stringValue}"`; - } - return '' + (value.intValue || value.doubleValue || value.boolValue || '""'); -}; - -export function formatTraceQLMetrics(query: string, data: TraceqlMetricsResponse) { - const frames = data.series.map((series, index) => { - const labels: Labels = {}; - series.labels?.forEach((label) => { - labels[label.key] = metricsValueToString(label.value); - }); - // If it's a single series, use the query as the displayName fallback - let name = data.series.length === 1 ? query : ''; - if (series.labels) { - if (series.labels.length === 1) { - // For single label series, use the label value as the displayName to improve readability - name = metricsValueToString(series.labels[0].value); - } else { - // otherwise build a string using the label keys and values - name = `{${series.labels.map((label) => `${label.key}=${metricsValueToString(label.value)}`).join(', ')}}`; - } - } - return createDataFrame({ - refId: name || `A${index}`, - fields: [ - { - name: 'time', - type: FieldType.time, - values: series.samples.map((sample) => parseInt(sample.timestampMs, 10)), - }, - { - name: name, - labels, - type: FieldType.number, - values: series.samples.map((sample) => sample.value), - config: { - displayNameFromDS: name, - }, - }, - ], - meta: { - preferredVisualisationType: 'graph', - }, - }); - }); - return frames; -} - export function formatTraceQLResponse( data: TraceSearchMetadata[], instanceSettings: DataSourceInstanceSettings, diff --git a/public/app/plugins/datasource/tempo/types.ts b/public/app/plugins/datasource/tempo/types.ts index 31439867edd..c671a1bf15a 100644 --- a/public/app/plugins/datasource/tempo/types.ts +++ b/public/app/plugins/datasource/tempo/types.ts @@ -106,32 +106,3 @@ export type Scope = { name: string; tags: string[]; }; - -// Maps to QueryRangeResponse of tempopb https://github.com/grafana/tempo/blob/cfda98fc5cb0777963f41e0949b9ad2d24b4b5b8/pkg/tempopb/tempo.proto#L360 -export type TraceqlMetricsResponse = { - series: MetricsSeries[]; - metrics: SearchMetrics; -}; - -export type MetricsSeries = { - labels: MetricsSeriesLabel[]; - samples: MetricsSeriesSample[]; - promLabels: string; -}; - -export type MetricsSeriesLabel = { - key: string; - value: ProtoValue; -}; - -export type ProtoValue = { - stringValue?: string; - intValue?: string; - boolValue?: boolean; - doubleValue?: string; -}; - -export type MetricsSeriesSample = { - timestampMs: string; - value: number; -}; diff --git a/public/app/types/acl.ts b/public/app/types/acl.ts index c419632e955..3cbb2c110e1 100644 --- a/public/app/types/acl.ts +++ b/public/app/types/acl.ts @@ -18,5 +18,4 @@ export enum PermissionLevelString { export enum SearchQueryType { Folder = 'dash-folder', Dashboard = 'dash-db', - AlertFolder = 'dash-folder-alerting', } diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index df1b746b158..bfdd268244c 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "" - }, - "pending-period": "" - }, - "evaluation-behaviour-description1": "", - "evaluation-behaviour-description2": "", - "evaluation-behaviour-description3": "", - "evaluation-behaviour-for": { - "error-parsing": "", - "validation": "" - }, - "evaluation-behaviour-group": { - "text": "" - }, - "pause": { - "alerting": "", - "label": "", - "recording": "" - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "" - } - } - }, "alert-rules": { "firing-for": "", "next-evaluation": "", @@ -177,10 +147,6 @@ "alerting": "", "recording": "" } - }, - "text": { - "alerting": "", - "recording": "" } } }, @@ -266,6 +232,60 @@ "preview": "", "previewCondition": "" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "", + "group": { + "cancel": "", + "create": "", + "interval": "" + }, + "group-name": "", + "group-text": "", + "new-group": "", + "pause": { + "alerting": "", + "recording": "" + }, + "select-folder-before": "" + }, + "evaluation-behaviour": { + "description": { + "text": "" + }, + "info-help": { + "text": "" + }, + "pending-period": "" + }, + "evaluation-behaviour-description1": "", + "evaluation-behaviour-description2": "", + "evaluation-behaviour-description3": "", + "evaluation-behaviour-for": { + "error-parsing": "", + "validation": "" + }, + "folder": { + "cancel": "", + "create": "", + "create-folder": "", + "creating-new-folder": "", + "label": "", + "name": "", + "new-folder": "", + "new-folder-or": "" + }, + "folder-and-labels": "", + "folders": { + "help-info": "" + }, + "labels": { + "help-info": "" + }, + "pause": { + "label": "" + } + }, "rule-groups": { "delete": { "success": "" @@ -2649,9 +2669,11 @@ "back-button": "Zurück zum Export der Konfiguration", "cancel-button": "Abbrechen", "info-text": "Dieses Dashboard exportieren.", + "loading": "", "save-button": "In Datei speichern …", "share-externally-label": "Export für externe Freigabe", - "view-button": "JSON anzeigen" + "view-button": "JSON anzeigen", + "view-button-schemav2": "" }, "library": { "info": "Bibliotheks-Panel erstellen." diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index eb90b340ad5..e2fb153b789 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "Define the alert behavior when the evaluation fails or the query returns no data." - }, - "pending-period": "Pending period" - }, - "evaluation-behaviour-description1": "Evaluation groups are containers for evaluating alert and recording rules.", - "evaluation-behaviour-description2": "An evaluation group defines an evaluation interval - how often a rule is evaluated. Alert rules within the same evaluation group are evaluated over the same evaluation interval.", - "evaluation-behaviour-description3": "Pending period specifies how long the threshold condition must be met before the alert starts firing. This option helps prevent alerts from being triggered by temporary issues.", - "evaluation-behaviour-for": { - "error-parsing": "Failed to parse duration", - "validation": "Pending period must be greater than or equal to the evaluation interval." - }, - "evaluation-behaviour-group": { - "text": "All rules in the selected group are evaluated every {{evaluateEvery}}." - }, - "pause": { - "alerting": "Turn on to pause evaluation for this alert rule.", - "label": "Pause evaluation", - "recording": "Turn on to pause evaluation for this recording rule." - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "Define how the alert rule is evaluated." - } - } - }, "alert-rules": { "firing-for": "Firing for", "next-evaluation": "Next evaluation", @@ -177,10 +147,6 @@ "alerting": "Create a new evaluation group to use for this alert rule.", "recording": "Create a new evaluation group to use for this recording rule." } - }, - "text": { - "alerting": "Define how often the alert rule is evaluated.", - "recording": "Define how often the recording rule is evaluated." } } }, @@ -266,6 +232,60 @@ "preview": "Preview", "previewCondition": "Preview alert rule condition" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "Evaluation group and interval", + "group": { + "cancel": "Cancel", + "create": "Create", + "interval": "Evaluation interval" + }, + "group-name": "Evaluation group name", + "group-text": "All rules in the selected group are evaluated every {{evaluateEvery}}.", + "new-group": "New evaluation group", + "pause": { + "alerting": "Turn on to pause evaluation for this alert rule.", + "recording": "Turn on to pause evaluation for this recording rule." + }, + "select-folder-before": "Select a folder before setting evaluation group and interval" + }, + "evaluation-behaviour": { + "description": { + "text": "Define how the alert rule is evaluated." + }, + "info-help": { + "text": "Define the alert behavior when the evaluation fails or the query returns no data." + }, + "pending-period": "Pending period" + }, + "evaluation-behaviour-description1": "Evaluation groups are containers for evaluating alert and recording rules.", + "evaluation-behaviour-description2": "An evaluation group defines an evaluation interval - how often a rule is evaluated. Alert rules within the same evaluation group are evaluated over the same evaluation interval.", + "evaluation-behaviour-description3": "Pending period specifies how long the threshold condition must be met before the alert starts firing. This option helps prevent alerts from being triggered by temporary issues.", + "evaluation-behaviour-for": { + "error-parsing": "Failed to parse duration", + "validation": "Pending period must be greater than or equal to the evaluation interval." + }, + "folder": { + "cancel": "Cancel", + "create": "Create", + "create-folder": "Create a new folder to store your alert rule in.", + "creating-new-folder": "Creating new folder", + "label": "Folder", + "name": "Folder name", + "new-folder": "New folder", + "new-folder-or": "or" + }, + "folder-and-labels": "Organize your alert rule with a folder and set of labels.", + "folders": { + "help-info": "Folders are used for storing alert rules. You can extend the access provided by a role to alert rules and assign permissions to individual folders." + }, + "labels": { + "help-info": "Labels are used to differentiate an alert from all other alerts.You can use them for searching, silencing, and routing notifications." + }, + "pause": { + "label": "Pause evaluation" + } + }, "rule-groups": { "delete": { "success": "Successfully deleted rule group" @@ -2649,9 +2669,11 @@ "back-button": "Back to export config", "cancel-button": "Cancel", "info-text": "Export this dashboard.", + "loading": "Loading...", "save-button": "Save to file", "share-externally-label": "Export for sharing externally", - "view-button": "View JSON" + "view-button": "View JSON", + "view-button-schemav2": "View JSON SchemaV2" }, "library": { "info": "Create library panel." diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 77b0e9b3545..396fbd5fd87 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "" - }, - "pending-period": "" - }, - "evaluation-behaviour-description1": "", - "evaluation-behaviour-description2": "", - "evaluation-behaviour-description3": "", - "evaluation-behaviour-for": { - "error-parsing": "", - "validation": "" - }, - "evaluation-behaviour-group": { - "text": "" - }, - "pause": { - "alerting": "", - "label": "", - "recording": "" - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "" - } - } - }, "alert-rules": { "firing-for": "", "next-evaluation": "", @@ -177,10 +147,6 @@ "alerting": "", "recording": "" } - }, - "text": { - "alerting": "", - "recording": "" } } }, @@ -266,6 +232,60 @@ "preview": "", "previewCondition": "" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "", + "group": { + "cancel": "", + "create": "", + "interval": "" + }, + "group-name": "", + "group-text": "", + "new-group": "", + "pause": { + "alerting": "", + "recording": "" + }, + "select-folder-before": "" + }, + "evaluation-behaviour": { + "description": { + "text": "" + }, + "info-help": { + "text": "" + }, + "pending-period": "" + }, + "evaluation-behaviour-description1": "", + "evaluation-behaviour-description2": "", + "evaluation-behaviour-description3": "", + "evaluation-behaviour-for": { + "error-parsing": "", + "validation": "" + }, + "folder": { + "cancel": "", + "create": "", + "create-folder": "", + "creating-new-folder": "", + "label": "", + "name": "", + "new-folder": "", + "new-folder-or": "" + }, + "folder-and-labels": "", + "folders": { + "help-info": "" + }, + "labels": { + "help-info": "" + }, + "pause": { + "label": "" + } + }, "rule-groups": { "delete": { "success": "" @@ -2649,9 +2669,11 @@ "back-button": "Volver a la configuración de exportación", "cancel-button": "Cancelar", "info-text": "Exporte este panel de control.", + "loading": "", "save-button": "Guardar en archivo", "share-externally-label": "Exportar para compartir externamente", - "view-button": "Ver JSON" + "view-button": "Ver JSON", + "view-button-schemav2": "" }, "library": { "info": "Cree un panel de librería." diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 714ffa042dd..f1f127271c3 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "" - }, - "pending-period": "" - }, - "evaluation-behaviour-description1": "", - "evaluation-behaviour-description2": "", - "evaluation-behaviour-description3": "", - "evaluation-behaviour-for": { - "error-parsing": "", - "validation": "" - }, - "evaluation-behaviour-group": { - "text": "" - }, - "pause": { - "alerting": "", - "label": "", - "recording": "" - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "" - } - } - }, "alert-rules": { "firing-for": "", "next-evaluation": "", @@ -177,10 +147,6 @@ "alerting": "", "recording": "" } - }, - "text": { - "alerting": "", - "recording": "" } } }, @@ -266,6 +232,60 @@ "preview": "", "previewCondition": "" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "", + "group": { + "cancel": "", + "create": "", + "interval": "" + }, + "group-name": "", + "group-text": "", + "new-group": "", + "pause": { + "alerting": "", + "recording": "" + }, + "select-folder-before": "" + }, + "evaluation-behaviour": { + "description": { + "text": "" + }, + "info-help": { + "text": "" + }, + "pending-period": "" + }, + "evaluation-behaviour-description1": "", + "evaluation-behaviour-description2": "", + "evaluation-behaviour-description3": "", + "evaluation-behaviour-for": { + "error-parsing": "", + "validation": "" + }, + "folder": { + "cancel": "", + "create": "", + "create-folder": "", + "creating-new-folder": "", + "label": "", + "name": "", + "new-folder": "", + "new-folder-or": "" + }, + "folder-and-labels": "", + "folders": { + "help-info": "" + }, + "labels": { + "help-info": "" + }, + "pause": { + "label": "" + } + }, "rule-groups": { "delete": { "success": "" @@ -2649,9 +2669,11 @@ "back-button": "Retour à la configuration de l'exportation", "cancel-button": "Annuler", "info-text": "Exporter ce tableau de bord.", + "loading": "", "save-button": "Enregistrer dans un fichier", "share-externally-label": "Exporter pour un partage externe", - "view-button": "Afficher au format JSON" + "view-button": "Afficher au format JSON", + "view-button-schemav2": "" }, "library": { "info": "Créer un panneau de bibliothèque." diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 8950fe5a584..a471dce121f 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "Đęƒįʼnę ŧĥę äľęřŧ þęĥävįőř ŵĥęʼn ŧĥę ęväľūäŧįőʼn ƒäįľş őř ŧĥę qūęřy řęŧūřʼnş ʼnő đäŧä." - }, - "pending-period": "Pęʼnđįʼnģ pęřįőđ" - }, - "evaluation-behaviour-description1": "Ēväľūäŧįőʼn ģřőūpş äřę čőʼnŧäįʼnęřş ƒőř ęväľūäŧįʼnģ äľęřŧ äʼnđ řęčőřđįʼnģ řūľęş.", - "evaluation-behaviour-description2": "Åʼn ęväľūäŧįőʼn ģřőūp đęƒįʼnęş äʼn ęväľūäŧįőʼn įʼnŧęřväľ - ĥőŵ őƒŧęʼn ä řūľę įş ęväľūäŧęđ. Åľęřŧ řūľęş ŵįŧĥįʼn ŧĥę şämę ęväľūäŧįőʼn ģřőūp äřę ęväľūäŧęđ ővęř ŧĥę şämę ęväľūäŧįőʼn įʼnŧęřväľ.", - "evaluation-behaviour-description3": "Pęʼnđįʼnģ pęřįőđ şpęčįƒįęş ĥőŵ ľőʼnģ ŧĥę ŧĥřęşĥőľđ čőʼnđįŧįőʼn mūşŧ þę męŧ þęƒőřę ŧĥę äľęřŧ şŧäřŧş ƒįřįʼnģ. Ŧĥįş őpŧįőʼn ĥęľpş přęvęʼnŧ äľęřŧş ƒřőm þęįʼnģ ŧřįģģęřęđ þy ŧęmpőřäřy įşşūęş.", - "evaluation-behaviour-for": { - "error-parsing": "Fäįľęđ ŧő päřşę đūřäŧįőʼn", - "validation": "Pęʼnđįʼnģ pęřįőđ mūşŧ þę ģřęäŧęř ŧĥäʼn őř ęqūäľ ŧő ŧĥę ęväľūäŧįőʼn įʼnŧęřväľ." - }, - "evaluation-behaviour-group": { - "text": "Åľľ řūľęş įʼn ŧĥę şęľęčŧęđ ģřőūp äřę ęväľūäŧęđ ęvęřy {{evaluateEvery}}." - }, - "pause": { - "alerting": "Ŧūřʼn őʼn ŧő päūşę ęväľūäŧįőʼn ƒőř ŧĥįş äľęřŧ řūľę.", - "label": "Päūşę ęväľūäŧįőʼn", - "recording": "Ŧūřʼn őʼn ŧő päūşę ęväľūäŧįőʼn ƒőř ŧĥįş řęčőřđįʼnģ řūľę." - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "Đęƒįʼnę ĥőŵ ŧĥę äľęřŧ řūľę įş ęväľūäŧęđ." - } - } - }, "alert-rules": { "firing-for": "Fįřįʼnģ ƒőř", "next-evaluation": "Ńęχŧ ęväľūäŧįőʼn", @@ -177,10 +147,6 @@ "alerting": "Cřęäŧę ä ʼnęŵ ęväľūäŧįőʼn ģřőūp ŧő ūşę ƒőř ŧĥįş äľęřŧ řūľę.", "recording": "Cřęäŧę ä ʼnęŵ ęväľūäŧįőʼn ģřőūp ŧő ūşę ƒőř ŧĥįş řęčőřđįʼnģ řūľę." } - }, - "text": { - "alerting": "Đęƒįʼnę ĥőŵ őƒŧęʼn ŧĥę äľęřŧ řūľę įş ęväľūäŧęđ.", - "recording": "Đęƒįʼnę ĥőŵ őƒŧęʼn ŧĥę řęčőřđįʼnģ řūľę įş ęväľūäŧęđ." } } }, @@ -266,6 +232,60 @@ "preview": "Přęvįęŵ", "previewCondition": "Přęvįęŵ äľęřŧ řūľę čőʼnđįŧįőʼn" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "Ēväľūäŧįőʼn ģřőūp äʼnđ įʼnŧęřväľ", + "group": { + "cancel": "Cäʼnčęľ", + "create": "Cřęäŧę", + "interval": "Ēväľūäŧįőʼn įʼnŧęřväľ" + }, + "group-name": "Ēväľūäŧįőʼn ģřőūp ʼnämę", + "group-text": "Åľľ řūľęş įʼn ŧĥę şęľęčŧęđ ģřőūp äřę ęväľūäŧęđ ęvęřy {{evaluateEvery}}.", + "new-group": "Ńęŵ ęväľūäŧįőʼn ģřőūp", + "pause": { + "alerting": "Ŧūřʼn őʼn ŧő päūşę ęväľūäŧįőʼn ƒőř ŧĥįş äľęřŧ řūľę.", + "recording": "Ŧūřʼn őʼn ŧő päūşę ęväľūäŧįőʼn ƒőř ŧĥįş řęčőřđįʼnģ řūľę." + }, + "select-folder-before": "Ŝęľęčŧ ä ƒőľđęř þęƒőřę şęŧŧįʼnģ ęväľūäŧįőʼn ģřőūp äʼnđ įʼnŧęřväľ" + }, + "evaluation-behaviour": { + "description": { + "text": "Đęƒįʼnę ĥőŵ ŧĥę äľęřŧ řūľę įş ęväľūäŧęđ." + }, + "info-help": { + "text": "Đęƒįʼnę ŧĥę äľęřŧ þęĥävįőř ŵĥęʼn ŧĥę ęväľūäŧįőʼn ƒäįľş őř ŧĥę qūęřy řęŧūřʼnş ʼnő đäŧä." + }, + "pending-period": "Pęʼnđįʼnģ pęřįőđ" + }, + "evaluation-behaviour-description1": "Ēväľūäŧįőʼn ģřőūpş äřę čőʼnŧäįʼnęřş ƒőř ęväľūäŧįʼnģ äľęřŧ äʼnđ řęčőřđįʼnģ řūľęş.", + "evaluation-behaviour-description2": "Åʼn ęväľūäŧįőʼn ģřőūp đęƒįʼnęş äʼn ęväľūäŧįőʼn įʼnŧęřväľ - ĥőŵ őƒŧęʼn ä řūľę įş ęväľūäŧęđ. Åľęřŧ řūľęş ŵįŧĥįʼn ŧĥę şämę ęväľūäŧįőʼn ģřőūp äřę ęväľūäŧęđ ővęř ŧĥę şämę ęväľūäŧįőʼn įʼnŧęřväľ.", + "evaluation-behaviour-description3": "Pęʼnđįʼnģ pęřįőđ şpęčįƒįęş ĥőŵ ľőʼnģ ŧĥę ŧĥřęşĥőľđ čőʼnđįŧįőʼn mūşŧ þę męŧ þęƒőřę ŧĥę äľęřŧ şŧäřŧş ƒįřįʼnģ. Ŧĥįş őpŧįőʼn ĥęľpş přęvęʼnŧ äľęřŧş ƒřőm þęįʼnģ ŧřįģģęřęđ þy ŧęmpőřäřy įşşūęş.", + "evaluation-behaviour-for": { + "error-parsing": "Fäįľęđ ŧő päřşę đūřäŧįőʼn", + "validation": "Pęʼnđįʼnģ pęřįőđ mūşŧ þę ģřęäŧęř ŧĥäʼn őř ęqūäľ ŧő ŧĥę ęväľūäŧįőʼn įʼnŧęřväľ." + }, + "folder": { + "cancel": "Cäʼnčęľ", + "create": "Cřęäŧę", + "create-folder": "Cřęäŧę ä ʼnęŵ ƒőľđęř ŧő şŧőřę yőūř äľęřŧ řūľę įʼn.", + "creating-new-folder": "Cřęäŧįʼnģ ʼnęŵ ƒőľđęř", + "label": "Főľđęř", + "name": "Főľđęř ʼnämę", + "new-folder": "Ńęŵ ƒőľđęř", + "new-folder-or": "őř" + }, + "folder-and-labels": "Øřģäʼnįžę yőūř äľęřŧ řūľę ŵįŧĥ ä ƒőľđęř äʼnđ şęŧ őƒ ľäþęľş.", + "folders": { + "help-info": "Főľđęřş äřę ūşęđ ƒőř şŧőřįʼnģ äľęřŧ řūľęş. Ÿőū čäʼn ęχŧęʼnđ ŧĥę äččęşş přővįđęđ þy ä řőľę ŧő äľęřŧ řūľęş äʼnđ äşşįģʼn pęřmįşşįőʼnş ŧő įʼnđįvįđūäľ ƒőľđęřş." + }, + "labels": { + "help-info": "Ŀäþęľş äřę ūşęđ ŧő đįƒƒęřęʼnŧįäŧę äʼn äľęřŧ ƒřőm äľľ őŧĥęř äľęřŧş.Ÿőū čäʼn ūşę ŧĥęm ƒőř şęäřčĥįʼnģ, şįľęʼnčįʼnģ, äʼnđ řőūŧįʼnģ ʼnőŧįƒįčäŧįőʼnş." + }, + "pause": { + "label": "Päūşę ęväľūäŧįőʼn" + } + }, "rule-groups": { "delete": { "success": "Ŝūččęşşƒūľľy đęľęŧęđ řūľę ģřőūp" @@ -2649,9 +2669,11 @@ "back-button": "ßäčĸ ŧő ęχpőřŧ čőʼnƒįģ", "cancel-button": "Cäʼnčęľ", "info-text": "Ēχpőřŧ ŧĥįş đäşĥþőäřđ.", + "loading": "Ŀőäđįʼnģ...", "save-button": "Ŝävę ŧő ƒįľę", "share-externally-label": "Ēχpőřŧ ƒőř şĥäřįʼnģ ęχŧęřʼnäľľy", - "view-button": "Vįęŵ ĴŜØŃ" + "view-button": "Vįęŵ ĴŜØŃ", + "view-button-schemav2": "Vįęŵ ĴŜØŃ ŜčĥęmäV2" }, "library": { "info": "Cřęäŧę ľįþřäřy päʼnęľ." diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index f69bf84ce2e..87dbd167e74 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "" - }, - "pending-period": "" - }, - "evaluation-behaviour-description1": "", - "evaluation-behaviour-description2": "", - "evaluation-behaviour-description3": "", - "evaluation-behaviour-for": { - "error-parsing": "", - "validation": "" - }, - "evaluation-behaviour-group": { - "text": "" - }, - "pause": { - "alerting": "", - "label": "", - "recording": "" - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "" - } - } - }, "alert-rules": { "firing-for": "", "next-evaluation": "", @@ -177,10 +147,6 @@ "alerting": "", "recording": "" } - }, - "text": { - "alerting": "", - "recording": "" } } }, @@ -266,6 +232,60 @@ "preview": "", "previewCondition": "" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "", + "group": { + "cancel": "", + "create": "", + "interval": "" + }, + "group-name": "", + "group-text": "", + "new-group": "", + "pause": { + "alerting": "", + "recording": "" + }, + "select-folder-before": "" + }, + "evaluation-behaviour": { + "description": { + "text": "" + }, + "info-help": { + "text": "" + }, + "pending-period": "" + }, + "evaluation-behaviour-description1": "", + "evaluation-behaviour-description2": "", + "evaluation-behaviour-description3": "", + "evaluation-behaviour-for": { + "error-parsing": "", + "validation": "" + }, + "folder": { + "cancel": "", + "create": "", + "create-folder": "", + "creating-new-folder": "", + "label": "", + "name": "", + "new-folder": "", + "new-folder-or": "" + }, + "folder-and-labels": "", + "folders": { + "help-info": "" + }, + "labels": { + "help-info": "" + }, + "pause": { + "label": "" + } + }, "rule-groups": { "delete": { "success": "" @@ -2649,9 +2669,11 @@ "back-button": "Voltar para exportar configuração", "cancel-button": "Cancelar", "info-text": "Exportar este painel de controle.", + "loading": "", "save-button": "Salvar no arquivo", "share-externally-label": "Exportar para compartilhamento externo", - "view-button": "Visualizar JSON" + "view-button": "Visualizar JSON", + "view-button-schemav2": "" }, "library": { "info": "Criar painel de biblioteca." diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index e3e97c9e31a..a8d7a29683f 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -33,29 +33,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "info-help": { - "text": "" - }, - "pending-period": "" - }, - "evaluation-behaviour-description1": "", - "evaluation-behaviour-description2": "", - "evaluation-behaviour-description3": "", - "evaluation-behaviour-for": { - "error-parsing": "", - "validation": "" - }, - "evaluation-behaviour-group": { - "text": "" - }, - "pause": { - "alerting": "", - "label": "", - "recording": "" - } - }, "alerting": { "alert-recording-rule-form": { "evaluation-behaviour": { @@ -64,13 +41,6 @@ } } }, - "alert-rule-form": { - "evaluation-behaviour": { - "description": { - "text": "" - } - } - }, "alert-rules": { "firing-for": "", "next-evaluation": "", @@ -175,10 +145,6 @@ "alerting": "", "recording": "" } - }, - "text": { - "alerting": "", - "recording": "" } } }, @@ -262,6 +228,60 @@ "preview": "", "previewCondition": "" }, + "rule-form": { + "evaluation": { + "evaluation-group-and-interval": "", + "group": { + "cancel": "", + "create": "", + "interval": "" + }, + "group-name": "", + "group-text": "", + "new-group": "", + "pause": { + "alerting": "", + "recording": "" + }, + "select-folder-before": "" + }, + "evaluation-behaviour": { + "description": { + "text": "" + }, + "info-help": { + "text": "" + }, + "pending-period": "" + }, + "evaluation-behaviour-description1": "", + "evaluation-behaviour-description2": "", + "evaluation-behaviour-description3": "", + "evaluation-behaviour-for": { + "error-parsing": "", + "validation": "" + }, + "folder": { + "cancel": "", + "create": "", + "create-folder": "", + "creating-new-folder": "", + "label": "", + "name": "", + "new-folder": "", + "new-folder-or": "" + }, + "folder-and-labels": "", + "folders": { + "help-info": "" + }, + "labels": { + "help-info": "" + }, + "pause": { + "label": "" + } + }, "rule-groups": { "delete": { "success": "" @@ -2636,9 +2656,11 @@ "back-button": "返回到导出配置", "cancel-button": "取消", "info-text": "导出此仪表板。", + "loading": "", "save-button": "保存至文件", "share-externally-label": "导出以供外部分享", - "view-button": "查看 JSON" + "view-button": "查看 JSON", + "view-button-schemav2": "" }, "library": { "info": "创建库面板。" diff --git a/public/swagger/SwaggerPage.tsx b/public/swagger/SwaggerPage.tsx index f32033cc206..e74f411e867 100644 --- a/public/swagger/SwaggerPage.tsx +++ b/public/swagger/SwaggerPage.tsx @@ -4,7 +4,7 @@ import { useAsync } from 'react-use'; import SwaggerUI from 'swagger-ui-react'; import { createTheme, monacoLanguageRegistry, SelectableValue } from '@grafana/data'; -import { Stack, Select } from '@grafana/ui'; +import { Stack, Select, UserIcon, UserView, Button } from '@grafana/ui'; import { setMonacoEnv } from 'app/core/monacoEnv'; import { ThemeProvider } from 'app/core/utils/ConfigProvider'; @@ -49,16 +49,34 @@ export const Page = () => { return urls; }); + const [userView, setUserView] = useState(); + const namespace = useAsync(async () => { const response = await fetch('api/frontend/settings'); if (!response.ok) { console.warn('No settings found'); - return ''; + return 'default'; } const val = await response.json(); return val.namespace; }); + useAsync(async () => { + const response = await fetch('api/user'); + if (!response.ok) { + console.warn('No user found, show login button'); + return; + } + const val = await response.json(); + setUserView({ + user: { + name: val.email || val.login, + avatarUrl: val.avatarUrl, + }, + lastActiveAt: new Date(), + }); + }); + return (
    @@ -83,6 +101,15 @@ export const Page = () => { value={url} isLoading={urls.loading} /> +
    + {userView ? ( + + ) : ( + + + + )} +
    diff --git a/yarn.lock b/yarn.lock index 6810e628334..70ddccc8c11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2934,14 +2934,14 @@ __metadata: languageName: node linkType: hard -"@formatjs/ecma402-abstract@npm:2.2.3": - version: 2.2.3 - resolution: "@formatjs/ecma402-abstract@npm:2.2.3" +"@formatjs/ecma402-abstract@npm:2.2.4": + version: 2.2.4 + resolution: "@formatjs/ecma402-abstract@npm:2.2.4" dependencies: "@formatjs/fast-memoize": "npm:2.2.3" - "@formatjs/intl-localematcher": "npm:0.5.7" + "@formatjs/intl-localematcher": "npm:0.5.8" tslib: "npm:2" - checksum: 10/d39e9f0d36c296a635f52aa35e07a67b6aa90383a30a046a0508e5d730676399fd0e67188eff463fe2a4d5febc9f567af45788fdf881e070910be7eb9294dd8c + checksum: 10/f7ab8be1e93f417a4a62c7be645618fec5652d412e55beffeeeada2b74660ad86b1876e3617ef0725f1454b193255dbb37ae779d8bb17ea71643e611e7edab02 languageName: node linkType: hard @@ -2985,13 +2985,13 @@ __metadata: linkType: hard "@formatjs/intl-durationformat@npm:^0.6.0": - version: 0.6.3 - resolution: "@formatjs/intl-durationformat@npm:0.6.3" + version: 0.6.4 + resolution: "@formatjs/intl-durationformat@npm:0.6.4" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.3" - "@formatjs/intl-localematcher": "npm:0.5.7" + "@formatjs/ecma402-abstract": "npm:2.2.4" + "@formatjs/intl-localematcher": "npm:0.5.8" tslib: "npm:2" - checksum: 10/1adc77b06e2f5791aef526cc3041de339cf99bc7e68cce81490066e0b1b0124d5cbe03d9f28b6a46bdd9ba22a0363315ccb20b9d1859ee0bcea7f79e9365844a + checksum: 10/aa7c045e94322f0a5723584e79dd1395b3c08e5d1d79432748ae98c0b0c1727059bd1aa5c04090df916ff1aea768336184a2d09ecfd2ece2fb090c472bbd8250 languageName: node linkType: hard @@ -3004,12 +3004,12 @@ __metadata: languageName: node linkType: hard -"@formatjs/intl-localematcher@npm:0.5.7": - version: 0.5.7 - resolution: "@formatjs/intl-localematcher@npm:0.5.7" +"@formatjs/intl-localematcher@npm:0.5.8": + version: 0.5.8 + resolution: "@formatjs/intl-localematcher@npm:0.5.8" dependencies: tslib: "npm:2" - checksum: 10/52201f12212e7e9cba1a4f99020da587b13e44e06e03c4ccd4e5ac0829b411e73dfe0904a9039ef81eeabeea04ed8cfae9e727e6791acd0230745b7bd3ad059e + checksum: 10/760fa292762d68904c133faa5d2900167fac03ee825e04e005438ae6e979facccbb4d12bba04906193feafd705348f191e4c3079c7bf54e0b66956a1199ad1df languageName: node linkType: hard @@ -3657,32 +3657,32 @@ __metadata: languageName: node linkType: hard -"@grafana/faro-core@npm:^1.12.0, @grafana/faro-core@npm:^1.3.6": - version: 1.12.0 - resolution: "@grafana/faro-core@npm:1.12.0" +"@grafana/faro-core@npm:^1.12.1, @grafana/faro-core@npm:^1.3.6": + version: 1.12.1 + resolution: "@grafana/faro-core@npm:1.12.1" dependencies: "@opentelemetry/api": "npm:^1.9.0" "@opentelemetry/otlp-transformer": "npm:^0.53.0" - checksum: 10/beb747ad360263737e3576ad8d0dccab0828e538797032cf97eed4a3e1c31232750285afb4c3840de80bc25b17defb3ab75976c59622778131311548ebd86100 + checksum: 10/02502bc34723377f06a24ed89e4589654dd5fbeab7e9c31a19291fa088a1237de081f99ba7c5a2032d77d12a686281d16af55064cbf69fad9e52cf8a29d7c000 languageName: node linkType: hard -"@grafana/faro-web-sdk@npm:1.12.0, @grafana/faro-web-sdk@npm:^1.12.0, @grafana/faro-web-sdk@npm:^1.3.6": - version: 1.12.0 - resolution: "@grafana/faro-web-sdk@npm:1.12.0" +"@grafana/faro-web-sdk@npm:1.12.1, @grafana/faro-web-sdk@npm:^1.12.1, @grafana/faro-web-sdk@npm:^1.3.6": + version: 1.12.1 + resolution: "@grafana/faro-web-sdk@npm:1.12.1" dependencies: - "@grafana/faro-core": "npm:^1.12.0" + "@grafana/faro-core": "npm:^1.12.1" ua-parser-js: "npm:^1.0.32" web-vitals: "npm:^4.0.1" - checksum: 10/220c31d90b18150e42ab69ace544a10ca8bc3710f95d44d95dd5ea8d8318dc6938daec67a5ab0c288b0470edda373a796ea32f231c71c6fb1f248d1f8632b397 + checksum: 10/7a352846946e1cb240ff405ef88b71e710eb049f307740a1acbf64e879422a9a44956bc2c5fa6b73c8ac742090f6c96e64254470209e476653d5d4649808a7d0 languageName: node linkType: hard "@grafana/faro-web-tracing@npm:^1.8.2": - version: 1.12.0 - resolution: "@grafana/faro-web-tracing@npm:1.12.0" + version: 1.12.1 + resolution: "@grafana/faro-web-tracing@npm:1.12.1" dependencies: - "@grafana/faro-web-sdk": "npm:^1.12.0" + "@grafana/faro-web-sdk": "npm:^1.12.1" "@opentelemetry/api": "npm:^1.9.0" "@opentelemetry/context-zone": "npm:1.26.0" "@opentelemetry/core": "npm:^1.26.0" @@ -3694,7 +3694,7 @@ __metadata: "@opentelemetry/resources": "npm:^1.26.0" "@opentelemetry/sdk-trace-web": "npm:^1.26.0" "@opentelemetry/semantic-conventions": "npm:^1.27.0" - checksum: 10/287df82d928846ec35b5947a776fa066c76ccd1bceee57b68276825f3f2dffd05e003460668340bde3a0b163f420db74285bebff32cf33f954accf3a594c1702 + checksum: 10/3635d15bf22bf210d9758dfc0ac0562101ff50cccb18d006bbff282e219486e02847a6d2475e2bf76ca509a4862c533fce84c528134dfceef92a4d7985e0e746 languageName: node linkType: hard @@ -3709,7 +3709,7 @@ __metadata: "@grafana/data": "npm:11.4.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@grafana/ui": "npm:11.4.0-pre" - "@leeoniya/ufuzzy": "npm:1.0.14" + "@leeoniya/ufuzzy": "npm:1.0.16" "@rollup/plugin-node-resolve": "npm:15.3.0" "@testing-library/dom": "npm:10.4.0" "@testing-library/jest-dom": "npm:^6.1.2" @@ -3864,13 +3864,13 @@ __metadata: "@grafana/data": "npm:11.4.0-pre" "@grafana/e2e-selectors": "npm:11.4.0-pre" "@grafana/experimental": "npm:2.1.2" - "@grafana/faro-web-sdk": "npm:1.12.0" + "@grafana/faro-web-sdk": "npm:1.12.1" "@grafana/runtime": "npm:11.4.0-pre" "@grafana/schema": "npm:11.4.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@grafana/ui": "npm:11.4.0-pre" "@hello-pangea/dnd": "npm:17.0.0" - "@leeoniya/ufuzzy": "npm:1.0.14" + "@leeoniya/ufuzzy": "npm:1.0.16" "@lezer/common": "npm:1.2.3" "@lezer/highlight": "npm:1.2.1" "@lezer/lr": "npm:1.4.2" @@ -4170,7 +4170,7 @@ __metadata: "@grafana/schema": "npm:11.4.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@hello-pangea/dnd": "npm:17.0.0" - "@leeoniya/ufuzzy": "npm:1.0.14" + "@leeoniya/ufuzzy": "npm:1.0.16" "@monaco-editor/react": "npm:4.6.0" "@popperjs/core": "npm:2.11.8" "@react-aria/dialog": "npm:3.5.19" @@ -4259,7 +4259,7 @@ __metadata: react-colorful: "npm:5.6.1" react-custom-scrollbars-2: "npm:4.5.0" react-dom: "npm:18.2.0" - react-dropzone: "npm:14.2.10" + react-dropzone: "npm:14.3.5" react-highlight-words: "npm:0.20.0" react-hook-form: "npm:^7.49.2" react-i18next: "npm:^14.0.0" @@ -4869,10 +4869,10 @@ __metadata: languageName: node linkType: hard -"@leeoniya/ufuzzy@npm:1.0.14, @leeoniya/ufuzzy@npm:^1.0.14": - version: 1.0.14 - resolution: "@leeoniya/ufuzzy@npm:1.0.14" - checksum: 10/852b580a8eaaf92e2d448f5b720e3c53e4bea22187bf5e8459256677c47183321b47b8384982e15751f42da7e77a216fd86c80e6185677d8270adeab4a4fb771 +"@leeoniya/ufuzzy@npm:1.0.16, @leeoniya/ufuzzy@npm:^1.0.14": + version: 1.0.16 + resolution: "@leeoniya/ufuzzy@npm:1.0.16" + checksum: 10/b6d79f59eb972615d1eedeeb7eaa444c3a56b29955b3762a46d8f099cd22457f6a6a33c1a3ab3bdcd7307cb5bac75ec3827f1c4ed7b432dc03c90fa18cc164e4 languageName: node linkType: hard @@ -12383,7 +12383,7 @@ __metadata: languageName: node linkType: hard -"attr-accept@npm:^2.2.2, attr-accept@npm:^2.2.4": +"attr-accept@npm:^2.2.4": version: 2.2.5 resolution: "attr-accept@npm:2.2.5" checksum: 10/474b1c53e62c5b881c745d1f098196f190c8b493245e95d4b0fea9298d3acb56f551868fc12806885277e55e9d8ad3c5963e92d93456f4e4081dfc5190977bfd @@ -16951,8 +16951,8 @@ __metadata: linkType: hard "eslint-plugin-unicorn@npm:^56.0.0": - version: 56.0.0 - resolution: "eslint-plugin-unicorn@npm:56.0.0" + version: 56.0.1 + resolution: "eslint-plugin-unicorn@npm:56.0.1" dependencies: "@babel/helper-validator-identifier": "npm:^7.24.7" "@eslint-community/eslint-utils": "npm:^4.4.0" @@ -16972,7 +16972,7 @@ __metadata: strip-indent: "npm:^3.0.0" peerDependencies: eslint: ">=8.56.0" - checksum: 10/142c66c65b2fd53136727a434b0fc77e9a9f9614aebe09330aeab83b021c842c3a5f9dafe3130c0f39fbd3562e91aadcc55a9de4312639e70fe7efb475cd358e + checksum: 10/59e13ded0e6bf4eff96018f3156829044d6edc37f66d25bc8ca99ed2b44e0cd3e9959587cf5a111498ff2d267a92bed49ade4f4dad98dcd8544e9edd9f6642b2 languageName: node linkType: hard @@ -17596,15 +17596,6 @@ __metadata: languageName: node linkType: hard -"file-selector@npm:^0.6.0": - version: 0.6.0 - resolution: "file-selector@npm:0.6.0" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10/6add4098ae07fd1e9050b1e8d3fd9f128680c1d6648c0676af54ace4586e6e5bfcb8fdfa45b69e9131ffd8175bf630d54a445a5facf9be244f85b99ce309183e - languageName: node - linkType: hard - "file-selector@npm:^2.1.0": version: 2.1.0 resolution: "file-selector@npm:2.1.0" @@ -18705,7 +18696,7 @@ __metadata: "@grafana/ui": "workspace:*" "@hello-pangea/dnd": "npm:17.0.0" "@kusto/monaco-kusto": "npm:^10.0.0" - "@leeoniya/ufuzzy": "npm:1.0.14" + "@leeoniya/ufuzzy": "npm:1.0.16" "@lezer/common": "npm:1.2.3" "@lezer/highlight": "npm:1.2.1" "@lezer/lr": "npm:1.4.2" @@ -19748,11 +19739,11 @@ __metadata: linkType: hard "i18next@npm:^23.0.0, i18next@npm:^23.11.5, i18next@npm:^23.5.1": - version: 23.16.5 - resolution: "i18next@npm:23.16.5" + version: 23.16.6 + resolution: "i18next@npm:23.16.6" dependencies: "@babel/runtime": "npm:^7.23.2" - checksum: 10/842b6d35d6e0822f1c8c3c2ca8af0fec9d8d9e37a54f63cdf42c0fcc7908d980082daff6ab77ad248324d2c45a0f1fe1e40efe23ba84d950722ece36b61d647c + checksum: 10/38e8e92707c5c9eec0ba9fc46b0b036889467e738462b101478665e9aedd54570cc33bc3e6c891c2ec15ab683673a5ecacc0ae61ed5c91f2d1cd872d4c36cfc2 languageName: node linkType: hard @@ -21884,8 +21875,8 @@ __metadata: linkType: hard "knip@npm:^5.10.0": - version: 5.37.0 - resolution: "knip@npm:5.37.0" + version: 5.37.1 + resolution: "knip@npm:5.37.1" dependencies: "@nodelib/fs.walk": "npm:1.2.8" "@snyk/github-codeowners": "npm:1.1.0" @@ -21909,7 +21900,7 @@ __metadata: bin: knip: bin/knip.js knip-bun: bin/knip-bun.js - checksum: 10/f3b789d22fe9711ee01eb7c87d28836d5d1616a5d050ad0ce8a4366ca50a10cdaf42b72078edaa2f9b80d248468b4f925c9f654174652e4aed6268c9bf68a7bf + checksum: 10/e73962f7daac5eb3d275654bbf719e91001aca8d03da9d0dc303cbe0cd47000114cf99d341b377b866f815e7d6821e3fdb1fca24e0a178e8204c1adaa2f2f41c languageName: node linkType: hard @@ -27109,20 +27100,7 @@ __metadata: languageName: node linkType: hard -"react-dropzone@npm:14.2.10": - version: 14.2.10 - resolution: "react-dropzone@npm:14.2.10" - dependencies: - attr-accept: "npm:^2.2.2" - file-selector: "npm:^0.6.0" - prop-types: "npm:^15.8.1" - peerDependencies: - react: ">= 16.8 || 18.0.0" - checksum: 10/39ba14b2b34d6784164955f201bd7168b6a4b7a0b1625de743054667e5e04b8113fca565c347661adcb90de29e9b40a6036a5a4719939a80ac8999bc42779561 - languageName: node - linkType: hard - -"react-dropzone@npm:^14.2.3": +"react-dropzone@npm:14.3.5, react-dropzone@npm:^14.2.3": version: 14.3.5 resolution: "react-dropzone@npm:14.3.5" dependencies: