diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 61ab3a10b1f..465c49ee713 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -185,6 +185,7 @@ /pkg/services/search/ @grafana/grafana-search-and-storage /pkg/services/searchusers/ @grafana/grafana-search-and-storage /pkg/services/secrets/ @grafana/grafana-operator-experience-squad +/pkg/services/setting/ @grafana/grafana-backend-services-squad /pkg/services/shorturls/ @grafana/sharing-squad /pkg/services/sqlstore/ @grafana/grafana-search-and-storage /pkg/services/ssosettings/ @grafana/identity-squad diff --git a/.github/workflows/pr-go-workspace-check.yml b/.github/workflows/pr-go-workspace-check.yml index c90573d6f4e..fd08206f0b5 100644 --- a/.github/workflows/pr-go-workspace-check.yml +++ b/.github/workflows/pr-go-workspace-check.yml @@ -107,6 +107,7 @@ jobs: CURRENT_OSS_WIRE_CHECKSUM=$(sha256sum pkg/server/wire_gen.go | cut -d' ' -f1) if [ "$CURRENT_OSS_WIRE_CHECKSUM" != "${{ steps.pre_gen_oss.outputs.wire_checksum }}" ]; then OSS_WIRE_CHANGED=true + OSS_DIFF=$(git diff pkg/server/wire_gen.go) echo "Uncomitted changes detected in pkg/server/wire_gen.go" fi @@ -115,6 +116,7 @@ jobs: CURRENT_ENTERPRISE_WIRE_CHECKSUM=$(sha256sum pkg/server/enterprise_wire_gen.go | cut -d' ' -f1) if [ "$CURRENT_ENTERPRISE_WIRE_CHECKSUM" != "${{ steps.pre_gen_enterprise.outputs.wire_checksum }}" ]; then ENTERPRISE_WIRE_CHANGED=true + ENTERPRISE_DIFF=$(git diff pkg/server/enterprise_wire_gen.go) echo "Uncomitted changes detected in pkg/server/enterprise_wire_gen.go" fi fi @@ -124,6 +126,14 @@ jobs: else if [[ "${{ github.event.pull_request.head.repo.fork }}" == "false" ]]; then echo "> !!! Please synchronize the grafana OSS and grafana enterprise code bases as defined in the enterprise readme, then run 'make gen-go' in the OSS folder and commit the changes to both repositories." + if [[ "$OSS_WIRE_CHANGED" == "true" ]]; then + echo "OSS changes:" + echo "$OSS_DIFF" + fi + if [[ "$ENTERPRISE_WIRE_CHANGED" == "true" ]]; then + echo "Enterprise changes:" + echo "$ENTERPRISE_DIFF" + fi else echo "> !!! Please run 'make gen-go' and commit the changes." fi diff --git a/.gitignore b/.gitignore index d0e233d6a42..5302a698c2f 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ public/css/*.min.css .vs/ .cursor/ .devcontainer/ +.claude/ .eslintcache .stylelintcache @@ -165,6 +166,7 @@ pkg/services/quota/quotaimpl/storage/storage.json /packages/grafana-ui/.yarn/.cache /packages/grafana-ui/.storybook/static /packages/grafana-ui/unstable +/packages/grafana-flamegraph/.storybook/static /packages/**/dist /packages/**/compiled /packages/**/.rpt2_cache diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c62e18c754e..f11c15a3bef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,7 +110,7 @@ If you believe you've found a security vulnerability, please read our [security ### Suggest features -If you have an idea of how to improve Grafana, submit a [feature request](https://github.com/grafana/grafana/issues/new?template=1-feature_requests.md). +If you have an idea of how to improve Grafana, submit a [feature request](https://github.com/grafana/grafana/issues/new?template=1-feature_requests.md). To learn how to write an effective feature request, refer to [Create a feature request](contribute/create-feature-request.md). We want to make Grafana accessible to even more people. Submit an [accessibility issue](https://github.com/grafana/grafana/issues/new?template=2-accessibility.md) to help us understand what we can improve. diff --git a/Dockerfile b/Dockerfile index c07eb0360e6..5bcca3643b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -111,6 +111,7 @@ COPY apps/iam apps/iam COPY apps apps COPY kindsv2 kindsv2 COPY apps/alerting/alertenrichment apps/alerting/alertenrichment +COPY apps/alerting/historian apps/alerting/historian COPY apps/alerting/notifications apps/alerting/notifications COPY apps/alerting/rules apps/alerting/rules COPY pkg/codegen pkg/codegen diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 6611470fb8f..2db5b66ac8c 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -8,9 +8,9 @@ require ( github.com/google/go-github/v70 v70.0.0 github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 github.com/grafana/grafana v0.0.0-00010101000000-000000000000 - github.com/grafana/grafana-app-sdk v0.48.2 - github.com/grafana/grafana-app-sdk/logging v0.48.1 - github.com/grafana/grafana-plugin-sdk-go v0.283.0 + github.com/grafana/grafana-app-sdk v0.48.4 + github.com/grafana/grafana-app-sdk/logging v0.48.3 + github.com/grafana/grafana-plugin-sdk-go v0.284.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0 github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.2 @@ -43,7 +43,7 @@ replace github.com/grafana/grafana/apps/plugins => ../plugins replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 require ( - cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 // indirect @@ -104,7 +104,7 @@ require ( github.com/gchaincl/sqlhooks v1.3.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect - github.com/go-jose/go-jose/v4 v4.1.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-kit/log v0.2.1 // indirect github.com/go-ldap/ldap/v3 v3.4.4 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect @@ -256,7 +256,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect @@ -290,9 +290,9 @@ require ( golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect gonum.org/v1/gonum v0.16.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/inf.v0 v0.9.1 // indirect @@ -314,7 +314,7 @@ require ( modernc.org/sqlite v1.39.1 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect xorm.io/builder v0.3.13 // indirect ) diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 79aea07d576..eb864bd4834 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -49,8 +49,8 @@ cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJW cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= @@ -107,8 +107,8 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= @@ -266,8 +266,8 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= @@ -346,9 +346,9 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= @@ -384,8 +384,8 @@ github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkPro github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= -github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -616,16 +616,16 @@ github.com/grafana/dataplane/sdata v0.0.9 h1:AGL1LZnCUG4MnQtnWpBPbQ8ZpptaZs14w6k github.com/grafana/dataplane/sdata v0.0.9/go.mod h1:Jvs5ddpGmn6vcxT7tCTWAZ1mgi4sbcdFt9utQx5uMAU= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= -github.com/grafana/grafana-plugin-sdk-go v0.283.0 h1:G7IHshAr30rLWV9FtX3iLlFTTlBhuOkfe7xVAoIP5rE= -github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= +github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= +github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao= github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 h1:A65jWgLk4Re28gIuZcpC0aTh71JZ0ey89hKGE9h543s= @@ -1074,8 +1074,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= -github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -1134,8 +1134,6 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= -github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= @@ -1160,12 +1158,12 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/prometheus v0.61.0 h1:RyrtJzu5MAmIcbRrwg75b+w3RlZCP0vJByDVzcpAe3M= go.opentelemetry.io/contrib/bridges/prometheus v0.61.0/go.mod h1:tirr4p9NXbzjlbruiRGp53IzlYrDk5CO2fdHj0sSSaY= -go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA= -go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= go.opentelemetry.io/contrib/exporters/autoexport v0.61.0 h1:XfzKtKSrbtYk9TNCF8dkO0Y9M7IOfb4idCwBOTwGBiI= go.opentelemetry.io/contrib/exporters/autoexport v0.61.0/go.mod h1:N6otC+qXTD5bAnbK2O1f/1SXq3cX+3KYSWrkBUqG0cw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= @@ -1707,10 +1705,10 @@ google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs= google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= @@ -1742,8 +1740,8 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5 google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1863,8 +1861,8 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/alerting/alertenrichment/go.mod b/apps/alerting/alertenrichment/go.mod index c717b9bf364..7a015ce4580 100644 --- a/apps/alerting/alertenrichment/go.mod +++ b/apps/alerting/alertenrichment/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/apps/alerting/alertenrichment go 1.25.3 require ( - github.com/grafana/grafana-app-sdk v0.48.2 + github.com/grafana/grafana-app-sdk v0.48.4 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250901080157-a0280d701b28 k8s.io/apimachinery v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 @@ -36,5 +36,5 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect ) diff --git a/apps/alerting/alertenrichment/go.sum b/apps/alerting/alertenrichment/go.sum index fc4692a07a9..ba98672f90c 100644 --- a/apps/alerting/alertenrichment/go.sum +++ b/apps/alerting/alertenrichment/go.sum @@ -23,8 +23,8 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250901080157-a0280d701b28 h1:PgMfX4OPENz/iXmtDDIW9+poZY4UD0hhmXm7flVclDo= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250901080157-a0280d701b28/go.mod h1:av5N0Naq+8VV9MLF7zAkihy/mVq5UbS2EvRSJukDHlY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -114,7 +114,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/alerting/historian/Makefile b/apps/alerting/historian/Makefile new file mode 100644 index 00000000000..378b08281e6 --- /dev/null +++ b/apps/alerting/historian/Makefile @@ -0,0 +1,9 @@ +include ../../sdk.mk + +.PHONY: generate # Run Grafana App SDK code generation +generate: install-app-sdk update-app-sdk + @$(APP_SDK_BIN) generate \ + --source=./kinds/ \ + --gogenpath=./pkg/apis \ + --grouping=group \ + --defencoding=none diff --git a/apps/alerting/historian/go.mod b/apps/alerting/historian/go.mod new file mode 100644 index 00000000000..934f9ac5772 --- /dev/null +++ b/apps/alerting/historian/go.mod @@ -0,0 +1,92 @@ +module github.com/grafana/grafana/apps/alerting/historian + +go 1.25.3 + +require ( + github.com/grafana/grafana-app-sdk v0.48.4 + k8s.io/apimachinery v0.34.2 + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-test/deep v1.1.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grafana/grafana-app-sdk/logging v0.48.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/onsi/ginkgo/v2 v2.22.2 // indirect + github.com/onsi/gomega v1.36.2 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.3 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.34.2 // indirect + k8s.io/apiextensions-apiserver v0.34.2 // indirect + k8s.io/client-go v0.34.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/apps/alerting/historian/go.sum b/apps/alerting/historian/go.sum new file mode 100644 index 00000000000..d8f706b0944 --- /dev/null +++ b/apps/alerting/historian/go.sum @@ -0,0 +1,240 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= +github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +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-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +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/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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= +github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/apiextensions-apiserver v0.34.2 h1:WStKftnGeoKP4AZRz/BaAAEJvYp4mlZGN0UCv+uvsqo= +k8s.io/apiextensions-apiserver v0.34.2/go.mod h1:398CJrsgXF1wytdaanynDpJ67zG4Xq7yj91GrmYN2SE= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/alerting/historian/kinds/cue.mod/module.cue b/apps/alerting/historian/kinds/cue.mod/module.cue new file mode 100644 index 00000000000..2f45dd38aec --- /dev/null +++ b/apps/alerting/historian/kinds/cue.mod/module.cue @@ -0,0 +1,2 @@ +module: "github.com/grafana/grafana/apps/alerting/historian/kinds" +language: version: "v0.8.2" diff --git a/apps/alerting/historian/kinds/manifest.cue b/apps/alerting/historian/kinds/manifest.cue new file mode 100644 index 00000000000..6f2bfc21cb9 --- /dev/null +++ b/apps/alerting/historian/kinds/manifest.cue @@ -0,0 +1,40 @@ +package kinds + +manifest: { + appName: "alerting-historian" + groupOverride: "historian.alerting.grafana.app" + versions: { + "v0alpha1": v0alpha1 + } +} + +v0alpha1: { + kinds: [dummyv0alpha1] + + routes: { + namespaced: { + // This endpoint is an exact copy of the existing /history endpoint, + // with the exception that error responses will be Kubernetes-style, + // not Grafana-style. It will be replaced in the future with a better + // more schema-friendly API. + "/alertstate/history": { + "GET": { + response: { + body: [string]: _ + } + responseMetadata: typeMeta: false + } + } + } + } +} + +dummyv0alpha1: { + kind: "Dummy" + schema: { + // Spec is the schema of our resource. The spec should include all the user-editable information for the kind. + spec: { + dummyField: int + } + } +} \ No newline at end of file diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/constants.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/constants.go new file mode 100644 index 00000000000..85867e8de0e --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/constants.go @@ -0,0 +1,18 @@ +package v0alpha1 + +import "k8s.io/apimachinery/pkg/runtime/schema" + +const ( + // APIGroup is the API group used by all kinds in this package + APIGroup = "historian.alerting.grafana.app" + // APIVersion is the API version used by all kinds in this package + APIVersion = "v0alpha1" +) + +var ( + // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package + GroupVersion = schema.GroupVersion{ + Group: APIGroup, + Version: APIVersion, + } +) diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_client_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_client_gen.go new file mode 100644 index 00000000000..fba40cf2385 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_client_gen.go @@ -0,0 +1,99 @@ +package v0alpha1 + +import ( + "context" + + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type DummyClient struct { + client *resource.TypedClient[*Dummy, *DummyList] +} + +func NewDummyClient(client resource.Client) *DummyClient { + return &DummyClient{ + client: resource.NewTypedClient[*Dummy, *DummyList](client, DummyKind()), + } +} + +func NewDummyClientFromGenerator(generator resource.ClientGenerator) (*DummyClient, error) { + c, err := generator.ClientFor(DummyKind()) + if err != nil { + return nil, err + } + return NewDummyClient(c), nil +} + +func (c *DummyClient) Get(ctx context.Context, identifier resource.Identifier) (*Dummy, error) { + return c.client.Get(ctx, identifier) +} + +func (c *DummyClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*DummyList, error) { + return c.client.List(ctx, namespace, opts) +} + +func (c *DummyClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*DummyList, error) { + resp, err := c.client.List(ctx, namespace, resource.ListOptions{ + ResourceVersion: opts.ResourceVersion, + Limit: opts.Limit, + LabelFilters: opts.LabelFilters, + FieldSelectors: opts.FieldSelectors, + }) + if err != nil { + return nil, err + } + for resp.GetContinue() != "" { + page, err := c.client.List(ctx, namespace, resource.ListOptions{ + Continue: resp.GetContinue(), + ResourceVersion: opts.ResourceVersion, + Limit: opts.Limit, + LabelFilters: opts.LabelFilters, + FieldSelectors: opts.FieldSelectors, + }) + if err != nil { + return nil, err + } + resp.SetContinue(page.GetContinue()) + resp.SetResourceVersion(page.GetResourceVersion()) + resp.SetItems(append(resp.GetItems(), page.GetItems()...)) + } + return resp, nil +} + +func (c *DummyClient) Create(ctx context.Context, obj *Dummy, opts resource.CreateOptions) (*Dummy, error) { + // Make sure apiVersion and kind are set + obj.APIVersion = GroupVersion.Identifier() + obj.Kind = DummyKind().Kind() + return c.client.Create(ctx, obj, opts) +} + +func (c *DummyClient) Update(ctx context.Context, obj *Dummy, opts resource.UpdateOptions) (*Dummy, error) { + return c.client.Update(ctx, obj, opts) +} + +func (c *DummyClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*Dummy, error) { + return c.client.Patch(ctx, identifier, req, opts) +} + +func (c *DummyClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus DummyStatus, opts resource.UpdateOptions) (*Dummy, error) { + return c.client.Update(ctx, &Dummy{ + TypeMeta: metav1.TypeMeta{ + Kind: DummyKind().Kind(), + APIVersion: GroupVersion.Identifier(), + }, + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: opts.ResourceVersion, + Namespace: identifier.Namespace, + Name: identifier.Name, + }, + Status: newStatus, + }, resource.UpdateOptions{ + Subresource: "status", + ResourceVersion: opts.ResourceVersion, + }) +} + +func (c *DummyClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { + return c.client.Delete(ctx, identifier, opts) +} diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_codec_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_codec_gen.go new file mode 100644 index 00000000000..6512ec5d36d --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_codec_gen.go @@ -0,0 +1,28 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "encoding/json" + "io" + + "github.com/grafana/grafana-app-sdk/resource" +) + +// DummyJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding +type DummyJSONCodec struct{} + +// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` +func (*DummyJSONCodec) Read(reader io.Reader, into resource.Object) error { + return json.NewDecoder(reader).Decode(into) +} + +// Write writes JSON-encoded bytes into `writer` marshaled from `from` +func (*DummyJSONCodec) Write(writer io.Writer, from resource.Object) error { + return json.NewEncoder(writer).Encode(from) +} + +// Interface compliance checks +var _ resource.Codec = &DummyJSONCodec{} diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_metadata_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_metadata_gen.go new file mode 100644 index 00000000000..f56576b2b72 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_metadata_gen.go @@ -0,0 +1,31 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +import ( + time "time" +) + +// metadata contains embedded CommonMetadata and can be extended with custom string fields +// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here +// without external reference as using the CommonMetadata reference breaks thema codegen. +type DummyMetadata struct { + UpdateTimestamp time.Time `json:"updateTimestamp"` + CreatedBy string `json:"createdBy"` + Uid string `json:"uid"` + CreationTimestamp time.Time `json:"creationTimestamp"` + DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` + Finalizers []string `json:"finalizers"` + ResourceVersion string `json:"resourceVersion"` + Generation int64 `json:"generation"` + UpdatedBy string `json:"updatedBy"` + Labels map[string]string `json:"labels"` +} + +// NewDummyMetadata creates a new DummyMetadata object. +func NewDummyMetadata() *DummyMetadata { + return &DummyMetadata{ + Finalizers: []string{}, + Labels: map[string]string{}, + } +} diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_object_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_object_gen.go new file mode 100644 index 00000000000..827abbaa7a2 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_object_gen.go @@ -0,0 +1,319 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "fmt" + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "time" +) + +// +k8s:openapi-gen=true +type Dummy struct { + metav1.TypeMeta `json:",inline" yaml:",inline"` + metav1.ObjectMeta `json:"metadata" yaml:"metadata"` + + // Spec is the spec of the Dummy + Spec DummySpec `json:"spec" yaml:"spec"` + + Status DummyStatus `json:"status" yaml:"status"` +} + +func (o *Dummy) GetSpec() any { + return o.Spec +} + +func (o *Dummy) SetSpec(spec any) error { + cast, ok := spec.(DummySpec) + if !ok { + return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) + } + o.Spec = cast + return nil +} + +func (o *Dummy) GetSubresources() map[string]any { + return map[string]any{ + "status": o.Status, + } +} + +func (o *Dummy) GetSubresource(name string) (any, bool) { + switch name { + case "status": + return o.Status, true + default: + return nil, false + } +} + +func (o *Dummy) SetSubresource(name string, value any) error { + switch name { + case "status": + cast, ok := value.(DummyStatus) + if !ok { + return fmt.Errorf("cannot set status type %#v, not of type DummyStatus", value) + } + o.Status = cast + return nil + default: + return fmt.Errorf("subresource '%s' does not exist", name) + } +} + +func (o *Dummy) GetStaticMetadata() resource.StaticMetadata { + gvk := o.GroupVersionKind() + return resource.StaticMetadata{ + Name: o.ObjectMeta.Name, + Namespace: o.ObjectMeta.Namespace, + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + } +} + +func (o *Dummy) SetStaticMetadata(metadata resource.StaticMetadata) { + o.Name = metadata.Name + o.Namespace = metadata.Namespace + o.SetGroupVersionKind(schema.GroupVersionKind{ + Group: metadata.Group, + Version: metadata.Version, + Kind: metadata.Kind, + }) +} + +func (o *Dummy) GetCommonMetadata() resource.CommonMetadata { + dt := o.DeletionTimestamp + var deletionTimestamp *time.Time + if dt != nil { + deletionTimestamp = &dt.Time + } + // Legacy ExtraFields support + extraFields := make(map[string]any) + if o.Annotations != nil { + extraFields["annotations"] = o.Annotations + } + if o.ManagedFields != nil { + extraFields["managedFields"] = o.ManagedFields + } + if o.OwnerReferences != nil { + extraFields["ownerReferences"] = o.OwnerReferences + } + return resource.CommonMetadata{ + UID: string(o.UID), + ResourceVersion: o.ResourceVersion, + Generation: o.Generation, + Labels: o.Labels, + CreationTimestamp: o.CreationTimestamp.Time, + DeletionTimestamp: deletionTimestamp, + Finalizers: o.Finalizers, + UpdateTimestamp: o.GetUpdateTimestamp(), + CreatedBy: o.GetCreatedBy(), + UpdatedBy: o.GetUpdatedBy(), + ExtraFields: extraFields, + } +} + +func (o *Dummy) SetCommonMetadata(metadata resource.CommonMetadata) { + o.UID = types.UID(metadata.UID) + o.ResourceVersion = metadata.ResourceVersion + o.Generation = metadata.Generation + o.Labels = metadata.Labels + o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) + if metadata.DeletionTimestamp != nil { + dt := metav1.NewTime(*metadata.DeletionTimestamp) + o.DeletionTimestamp = &dt + } else { + o.DeletionTimestamp = nil + } + o.Finalizers = metadata.Finalizers + if o.Annotations == nil { + o.Annotations = make(map[string]string) + } + if !metadata.UpdateTimestamp.IsZero() { + o.SetUpdateTimestamp(metadata.UpdateTimestamp) + } + if metadata.CreatedBy != "" { + o.SetCreatedBy(metadata.CreatedBy) + } + if metadata.UpdatedBy != "" { + o.SetUpdatedBy(metadata.UpdatedBy) + } + // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields + if metadata.ExtraFields != nil { + if annotations, ok := metadata.ExtraFields["annotations"]; ok { + if cast, ok := annotations.(map[string]string); ok { + o.Annotations = cast + } + } + if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { + if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { + o.ManagedFields = cast + } + } + if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { + if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { + o.OwnerReferences = cast + } + } + } +} + +func (o *Dummy) GetCreatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/createdBy"] +} + +func (o *Dummy) SetCreatedBy(createdBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy +} + +func (o *Dummy) GetUpdateTimestamp() time.Time { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) + return parsed +} + +func (o *Dummy) SetUpdateTimestamp(updateTimestamp time.Time) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) +} + +func (o *Dummy) GetUpdatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/updatedBy"] +} + +func (o *Dummy) SetUpdatedBy(updatedBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy +} + +func (o *Dummy) Copy() resource.Object { + return resource.CopyObject(o) +} + +func (o *Dummy) DeepCopyObject() runtime.Object { + return o.Copy() +} + +func (o *Dummy) DeepCopy() *Dummy { + cpy := &Dummy{} + o.DeepCopyInto(cpy) + return cpy +} + +func (o *Dummy) DeepCopyInto(dst *Dummy) { + dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion + dst.TypeMeta.Kind = o.TypeMeta.Kind + o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) + o.Spec.DeepCopyInto(&dst.Spec) + o.Status.DeepCopyInto(&dst.Status) +} + +// Interface compliance compile-time check +var _ resource.Object = &Dummy{} + +// +k8s:openapi-gen=true +type DummyList struct { + metav1.TypeMeta `json:",inline" yaml:",inline"` + metav1.ListMeta `json:"metadata" yaml:"metadata"` + Items []Dummy `json:"items" yaml:"items"` +} + +func (o *DummyList) DeepCopyObject() runtime.Object { + return o.Copy() +} + +func (o *DummyList) Copy() resource.ListObject { + cpy := &DummyList{ + TypeMeta: o.TypeMeta, + Items: make([]Dummy, len(o.Items)), + } + o.ListMeta.DeepCopyInto(&cpy.ListMeta) + for i := 0; i < len(o.Items); i++ { + if item, ok := o.Items[i].Copy().(*Dummy); ok { + cpy.Items[i] = *item + } + } + return cpy +} + +func (o *DummyList) GetItems() []resource.Object { + items := make([]resource.Object, len(o.Items)) + for i := 0; i < len(o.Items); i++ { + items[i] = &o.Items[i] + } + return items +} + +func (o *DummyList) SetItems(items []resource.Object) { + o.Items = make([]Dummy, len(items)) + for i := 0; i < len(items); i++ { + o.Items[i] = *items[i].(*Dummy) + } +} + +func (o *DummyList) DeepCopy() *DummyList { + cpy := &DummyList{} + o.DeepCopyInto(cpy) + return cpy +} + +func (o *DummyList) DeepCopyInto(dst *DummyList) { + resource.CopyObjectInto(dst, o) +} + +// Interface compliance compile-time check +var _ resource.ListObject = &DummyList{} + +// Copy methods for all subresource types + +// DeepCopy creates a full deep copy of Spec +func (s *DummySpec) DeepCopy() *DummySpec { + cpy := &DummySpec{} + s.DeepCopyInto(cpy) + return cpy +} + +// DeepCopyInto deep copies Spec into another Spec object +func (s *DummySpec) DeepCopyInto(dst *DummySpec) { + resource.CopyObjectInto(dst, s) +} + +// DeepCopy creates a full deep copy of DummyStatus +func (s *DummyStatus) DeepCopy() *DummyStatus { + cpy := &DummyStatus{} + s.DeepCopyInto(cpy) + return cpy +} + +// DeepCopyInto deep copies DummyStatus into another DummyStatus object +func (s *DummyStatus) DeepCopyInto(dst *DummyStatus) { + resource.CopyObjectInto(dst, s) +} diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_schema_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_schema_gen.go new file mode 100644 index 00000000000..a7d3de4ed1c --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_schema_gen.go @@ -0,0 +1,34 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "github.com/grafana/grafana-app-sdk/resource" +) + +// schema is unexported to prevent accidental overwrites +var ( + schemaDummy = resource.NewSimpleSchema("historian.alerting.grafana.app", "v0alpha1", &Dummy{}, &DummyList{}, resource.WithKind("Dummy"), + resource.WithPlural("dummys"), resource.WithScope(resource.NamespacedScope)) + kindDummy = resource.Kind{ + Schema: schemaDummy, + Codecs: map[resource.KindEncoding]resource.Codec{ + resource.KindEncodingJSON: &DummyJSONCodec{}, + }, + } +) + +// Kind returns a resource.Kind for this Schema with a JSON codec +func DummyKind() resource.Kind { + return kindDummy +} + +// Schema returns a resource.SimpleSchema representation of Dummy +func DummySchema() *resource.SimpleSchema { + return schemaDummy +} + +// Interface compliance checks +var _ resource.Schema = kindDummy diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_spec_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_spec_gen.go new file mode 100644 index 00000000000..16d4eab9409 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_spec_gen.go @@ -0,0 +1,14 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// Spec is the schema of our resource. The spec should include all the user-editable information for the kind. +// +k8s:openapi-gen=true +type DummySpec struct { + DummyField int64 `json:"dummyField"` +} + +// NewDummySpec creates a new DummySpec object. +func NewDummySpec() *DummySpec { + return &DummySpec{} +} diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_status_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_status_gen.go new file mode 100644 index 00000000000..36d064053f5 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/dummy_status_gen.go @@ -0,0 +1,44 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type DummystatusOperatorState struct { + // lastEvaluation is the ResourceVersion last evaluated + LastEvaluation string `json:"lastEvaluation"` + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + State DummyStatusOperatorStateState `json:"state"` + // descriptiveState is an optional more descriptive state field which has no requirements on format + DescriptiveState *string `json:"descriptiveState,omitempty"` + // details contains any extra information that is operator-specific + Details map[string]interface{} `json:"details,omitempty"` +} + +// NewDummystatusOperatorState creates a new DummystatusOperatorState object. +func NewDummystatusOperatorState() *DummystatusOperatorState { + return &DummystatusOperatorState{} +} + +// +k8s:openapi-gen=true +type DummyStatus struct { + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + OperatorStates map[string]DummystatusOperatorState `json:"operatorStates,omitempty"` + // additionalFields is reserved for future use + AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` +} + +// NewDummyStatus creates a new DummyStatus object. +func NewDummyStatus() *DummyStatus { + return &DummyStatus{} +} + +// +k8s:openapi-gen=true +type DummyStatusOperatorStateState string + +const ( + DummyStatusOperatorStateStateSuccess DummyStatusOperatorStateState = "success" + DummyStatusOperatorStateStateInProgress DummyStatusOperatorStateState = "in_progress" + DummyStatusOperatorStateStateFailed DummyStatusOperatorStateState = "failed" +) diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatehistory_response_types_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatehistory_response_types_gen.go new file mode 100644 index 00000000000..b9f54ed4718 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatehistory_response_types_gen.go @@ -0,0 +1,15 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type GetAlertstatehistory struct { + Body map[string]interface{} `json:"body"` +} + +// NewGetAlertstatehistory creates a new GetAlertstatehistory object. +func NewGetAlertstatehistory() *GetAlertstatehistory { + return &GetAlertstatehistory{ + Body: map[string]interface{}{}, + } +} diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatequery_response_types_gen.go b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatequery_response_types_gen.go new file mode 100644 index 00000000000..90130b85cf3 --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1/getalertstatequery_response_types_gen.go @@ -0,0 +1,3 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 diff --git a/apps/alerting/historian/pkg/apis/alertinghistorian_manifest.go b/apps/alerting/historian/pkg/apis/alertinghistorian_manifest.go new file mode 100644 index 00000000000..4e6401e5b1d --- /dev/null +++ b/apps/alerting/historian/pkg/apis/alertinghistorian_manifest.go @@ -0,0 +1,175 @@ +// +// This file is generated by grafana-app-sdk +// DO NOT EDIT +// + +package apis + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/resource" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + v0alpha1 "github.com/grafana/grafana/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1" +) + +var ( + rawSchemaDummyv0alpha1 = []byte(`{"Dummy":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"description":"Spec is the schema of our resource. The spec should include all the user-editable information for the kind.","properties":{"dummyField":{"type":"integer"}},"required":["dummyField"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaDummyv0alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaDummyv0alpha1, &versionSchemaDummyv0alpha1) +) + +var appManifestData = app.ManifestData{ + AppName: "alerting-historian", + Group: "historian.alerting.grafana.app", + PreferredVersion: "v0alpha1", + Versions: []app.ManifestVersion{ + { + Name: "v0alpha1", + Served: true, + Kinds: []app.ManifestVersionKind{ + { + Kind: "Dummy", + Plural: "Dummys", + Scope: "Namespaced", + Conversion: false, + Schema: &versionSchemaDummyv0alpha1, + }, + }, + Routes: app.ManifestVersionRoutes{ + Namespaced: map[string]spec3.PathProps{ + "/alertstate/history": { + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + + OperationId: "getAlertstatehistory", + + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + Default: &spec3.Response{ + ResponseProps: spec3.ResponseProps{ + Description: "Default OK response", + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "body": { + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{}, + }, + }, + }, + }, + }, + }, + }, + }, + Required: []string{ + "body", + }, + }}, + }}, + }, + }, + }, + }}, + }, + }, + }, + }, + Cluster: map[string]spec3.PathProps{}, + Schemas: map[string]spec.Schema{}, + }, + }, + }, +} + +func LocalManifest() app.Manifest { + return app.NewEmbeddedManifest(appManifestData) +} + +func RemoteManifest() app.Manifest { + return app.NewAPIServerManifest("alerting-historian") +} + +var kindVersionToGoType = map[string]resource.Kind{ + "Dummy/v0alpha1": v0alpha1.DummyKind(), +} + +// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. +// If there is no association for the provided Kind and Version, exists will return false. +func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) { + goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)] + return goType, exists +} + +var customRouteToGoResponseType = map[string]any{ + "v0alpha1||/alertstate/history|GET": v0alpha1.GetAlertstatehistory{}, +} + +// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. +// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths. +// If there is no association for the provided kind, version, custom route path, and method, exists will return false. +// Resource routes (those without a kind) should prefix their route with "/" if the route is namespaced (otherwise the route is assumed to be cluster-scope) +func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +var customRouteToGoParamsType = map[string]runtime.Object{} + +func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +var customRouteToGoRequestBodyType = map[string]any{} + +func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +type GoTypeAssociator struct{} + +func NewGoTypeAssociator() *GoTypeAssociator { + return &GoTypeAssociator{} +} + +func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) { + return ManifestGoTypeAssociator(kind, version) +} +func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) { + return ManifestCustomRouteResponsesAssociator(kind, version, path, verb) +} +func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) { + return ManifestCustomRouteQueryAssociator(kind, version, path, verb) +} +func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) { + return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb) +} diff --git a/apps/alerting/historian/pkg/app/app.go b/apps/alerting/historian/pkg/app/app.go new file mode 100644 index 00000000000..8d5feb39ec7 --- /dev/null +++ b/apps/alerting/historian/pkg/app/app.go @@ -0,0 +1,45 @@ +package app + +import ( + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/simple" + + "github.com/grafana/grafana/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1" + "github.com/grafana/grafana/apps/alerting/historian/pkg/app/config" +) + +func New(cfg app.Config) (app.App, error) { + runtimeConfig := cfg.SpecificConfig.(config.RuntimeConfig) + + simpleConfig := simple.AppConfig{ + Name: "alerting.historian", + KubeConfig: cfg.KubeConfig, + VersionedCustomRoutes: map[string]simple.AppVersionRouteHandlers{ + "v0alpha1": { + { + Namespaced: true, + Path: "/alertstate/history", + Method: "GET", + }: runtimeConfig.GetAlertStateHistoryHandler, + }, + }, + // TODO: Remove when SDK is fixed. + ManagedKinds: []simple.AppManagedKind{ + { + Kind: v0alpha1.DummyKind(), + }, + }, + } + + a, err := simple.NewApp(simpleConfig) + if err != nil { + return nil, err + } + + err = a.ValidateManifest(cfg.ManifestData) + if err != nil { + return nil, err + } + + return a, nil +} diff --git a/apps/alerting/historian/pkg/app/config/config.go b/apps/alerting/historian/pkg/app/config/config.go new file mode 100644 index 00000000000..5a40503dea8 --- /dev/null +++ b/apps/alerting/historian/pkg/app/config/config.go @@ -0,0 +1,9 @@ +package config + +import ( + "github.com/grafana/grafana-app-sdk/simple" +) + +type RuntimeConfig struct { + GetAlertStateHistoryHandler simple.AppCustomRouteHandler +} diff --git a/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/dummy_object_gen.ts b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/dummy_object_gen.ts new file mode 100644 index 00000000000..8189d09b6fb --- /dev/null +++ b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/dummy_object_gen.ts @@ -0,0 +1,49 @@ +/* + * This file was generated by grafana-app-sdk. DO NOT EDIT. + */ +import { Spec } from './types.spec.gen'; +import { Status } from './types.status.gen'; + +export interface Metadata { + name: string; + namespace: string; + generateName?: string; + selfLink?: string; + uid?: string; + resourceVersion?: string; + generation?: number; + creationTimestamp?: string; + deletionTimestamp?: string; + deletionGracePeriodSeconds?: number; + labels?: Record; + annotations?: Record; + ownerReferences?: OwnerReference[]; + finalizers?: string[]; + managedFields?: ManagedFieldsEntry[]; +} + +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller?: boolean; + blockOwnerDeletion?: boolean; +} + +export interface ManagedFieldsEntry { + manager?: string; + operation?: string; + apiVersion?: string; + time?: string; + fieldsType?: string; + subresource?: string; +} + +export interface Dummy { + kind: string; + apiVersion: string; + metadata: Metadata; + spec: Spec; + status: Status; +} diff --git a/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.metadata.gen.ts b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.metadata.gen.ts new file mode 100644 index 00000000000..4377f3c1d08 --- /dev/null +++ b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.metadata.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// metadata contains embedded CommonMetadata and can be extended with custom string fields +// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here +// without external reference as using the CommonMetadata reference breaks thema codegen. +export interface Metadata { + updateTimestamp: string; + createdBy: string; + uid: string; + creationTimestamp: string; + deletionTimestamp?: string; + finalizers: string[]; + resourceVersion: string; + generation: number; + updatedBy: string; + labels: Record; +} + +export const defaultMetadata = (): Metadata => ({ + updateTimestamp: "", + createdBy: "", + uid: "", + creationTimestamp: "", + finalizers: [], + resourceVersion: "", + generation: 0, + updatedBy: "", + labels: {}, +}); + diff --git a/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.spec.gen.ts b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.spec.gen.ts new file mode 100644 index 00000000000..00cf31e8dda --- /dev/null +++ b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.spec.gen.ts @@ -0,0 +1,11 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// Spec is the schema of our resource. The spec should include all the user-editable information for the kind. +export interface Spec { + dummyField: number; +} + +export const defaultSpec = (): Spec => ({ + dummyField: 0, +}); + diff --git a/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.status.gen.ts b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.status.gen.ts new file mode 100644 index 00000000000..01be8df7961 --- /dev/null +++ b/apps/alerting/historian/plugin/src/generated/dummy/v0alpha1/types.status.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface OperatorState { + // lastEvaluation is the ResourceVersion last evaluated + lastEvaluation: string; + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + state: "success" | "in_progress" | "failed"; + // descriptiveState is an optional more descriptive state field which has no requirements on format + descriptiveState?: string; + // details contains any extra information that is operator-specific + details?: Record; +} + +export const defaultOperatorState = (): OperatorState => ({ + lastEvaluation: "", + state: "success", +}); + +export interface Status { + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + operatorStates?: Record; + // additionalFields is reserved for future use + additionalFields?: Record; +} + +export const defaultStatus = (): Status => ({ +}); + diff --git a/apps/alerting/notifications/go.mod b/apps/alerting/notifications/go.mod index 24c17cc900a..c0ea062e70b 100644 --- a/apps/alerting/notifications/go.mod +++ b/apps/alerting/notifications/go.mod @@ -3,8 +3,8 @@ module github.com/grafana/grafana/apps/alerting/notifications go 1.25.3 require ( - github.com/grafana/grafana-app-sdk v0.48.2 - github.com/grafana/grafana-app-sdk/logging v0.48.1 + github.com/grafana/grafana-app-sdk v0.48.4 + github.com/grafana/grafana-app-sdk/logging v0.48.3 k8s.io/apimachinery v0.34.2 k8s.io/apiserver v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 @@ -37,7 +37,6 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -62,14 +61,13 @@ require ( github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.etcd.io/etcd/api/v3 v3.6.4 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect go.etcd.io/etcd/client/v3 v3.6.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect @@ -84,7 +82,6 @@ require ( go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect @@ -93,9 +90,9 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect @@ -109,6 +106,6 @@ require ( sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/alerting/notifications/go.sum b/apps/alerting/notifications/go.sum index 9b84e11f3d6..7a8ccd1d4e8 100644 --- a/apps/alerting/notifications/go.sum +++ b/apps/alerting/notifications/go.sum @@ -71,10 +71,10 @@ 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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= @@ -187,8 +187,8 @@ go.etcd.io/etcd/server/v3 v3.6.4 h1:LsCA7CzjVt+8WGrdsnh6RhC0XqCsLkBly3ve5rTxMAU= go.etcd.io/etcd/server/v3 v3.6.4/go.mod h1:aYCL/h43yiONOv0QIR82kH/2xZ7m+IWYjzRmyQfnCAg= go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= @@ -279,13 +279,13 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -324,7 +324,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/alerting/rules/go.mod b/apps/alerting/rules/go.mod index 17aa567f077..dceeb576100 100644 --- a/apps/alerting/rules/go.mod +++ b/apps/alerting/rules/go.mod @@ -3,8 +3,8 @@ module github.com/grafana/grafana/apps/alerting/rules go 1.25.3 require ( - github.com/grafana/grafana-app-sdk v0.48.2 - github.com/grafana/grafana-app-sdk/logging v0.48.1 + github.com/grafana/grafana-app-sdk v0.48.4 + github.com/grafana/grafana-app-sdk/logging v0.48.3 github.com/prometheus/common v0.67.3 k8s.io/apimachinery v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 @@ -52,11 +52,10 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect @@ -75,9 +74,9 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -88,6 +87,6 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/alerting/rules/go.sum b/apps/alerting/rules/go.sum index 7eadc30e628..d8f706b0944 100644 --- a/apps/alerting/rules/go.sum +++ b/apps/alerting/rules/go.sum @@ -48,10 +48,10 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -128,8 +128,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= @@ -199,12 +199,12 @@ gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0 gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -234,7 +234,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/annotation/go.mod b/apps/annotation/go.mod index 988575c7ce1..5173ad8c627 100644 --- a/apps/annotation/go.mod +++ b/apps/annotation/go.mod @@ -3,8 +3,8 @@ module github.com/grafana/grafana/apps/annotation go 1.24.0 require ( - github.com/grafana/grafana-app-sdk v0.48.2 - github.com/grafana/grafana-app-sdk/logging v0.48.1 + github.com/grafana/grafana-app-sdk v0.48.4 + github.com/grafana/grafana-app-sdk/logging v0.48.3 k8s.io/apimachinery v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 ) @@ -52,11 +52,10 @@ require ( github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect @@ -75,9 +74,9 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -88,6 +87,6 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/annotation/go.sum b/apps/annotation/go.sum index 7eadc30e628..d8f706b0944 100644 --- a/apps/annotation/go.sum +++ b/apps/annotation/go.sum @@ -48,10 +48,10 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -128,8 +128,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= @@ -199,12 +199,12 @@ gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0 gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -234,7 +234,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/collections/go.mod b/apps/collections/go.mod index 281a6579c67..621440d3bfe 100644 --- a/apps/collections/go.mod +++ b/apps/collections/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/apps/collections go 1.25.3 require ( - github.com/grafana/grafana-app-sdk v0.48.2 + github.com/grafana/grafana-app-sdk v0.48.4 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.2 @@ -25,7 +25,7 @@ require ( github.com/go-test/deep v1.1.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.48.1 // indirect + github.com/grafana/grafana-app-sdk/logging v0.48.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -64,6 +64,6 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/collections/go.sum b/apps/collections/go.sum index 8d576bfaa8b..eb2916cb29a 100644 --- a/apps/collections/go.sum +++ b/apps/collections/go.sum @@ -33,10 +33,10 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 h1:X0cnaFdR+iz+sDSuoZmkryFSjOirchHe2MdKSRwBWgM= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:RRvSjHH12/PnQaXraMO65jUhVu8n59mzvhfIMBETnV4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -181,7 +181,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/correlations/go.mod b/apps/correlations/go.mod index 988f18b89d6..42072c1cb2b 100644 --- a/apps/correlations/go.mod +++ b/apps/correlations/go.mod @@ -3,8 +3,8 @@ module github.com/grafana/grafana/apps/correlations go 1.25.3 require ( - github.com/grafana/grafana-app-sdk v0.48.2 - github.com/grafana/grafana-app-sdk/logging v0.48.1 + github.com/grafana/grafana-app-sdk v0.48.4 + github.com/grafana/grafana-app-sdk/logging v0.48.3 k8s.io/apimachinery v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 ) @@ -52,11 +52,10 @@ require ( github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect @@ -75,9 +74,9 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -88,6 +87,6 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/correlations/go.sum b/apps/correlations/go.sum index 7eadc30e628..d8f706b0944 100644 --- a/apps/correlations/go.sum +++ b/apps/correlations/go.sum @@ -48,10 +48,10 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -128,8 +128,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= @@ -199,12 +199,12 @@ gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0 gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -234,7 +234,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/dashboard/go.mod b/apps/dashboard/go.mod index b281b21e4e4..56d8b7715a1 100644 --- a/apps/dashboard/go.mod +++ b/apps/dashboard/go.mod @@ -5,9 +5,9 @@ go 1.25.3 require ( cuelang.org/go v0.11.1 github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 - github.com/grafana/grafana-app-sdk v0.48.2 - github.com/grafana/grafana-app-sdk/logging v0.48.1 - github.com/grafana/grafana-plugin-sdk-go v0.283.0 + github.com/grafana/grafana-app-sdk v0.48.4 + github.com/grafana/grafana-app-sdk/logging v0.48.3 + github.com/grafana/grafana-plugin-sdk-go v0.284.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 @@ -30,7 +30,7 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect @@ -92,7 +92,7 @@ require ( github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect @@ -106,7 +106,6 @@ require ( go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.47.0 // indirect @@ -119,9 +118,9 @@ require ( golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.39.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -130,6 +129,6 @@ require ( k8s.io/klog/v2 v2.130.1 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/dashboard/go.sum b/apps/dashboard/go.sum index c0efb792788..17fbe2222d2 100644 --- a/apps/dashboard/go.sum +++ b/apps/dashboard/go.sum @@ -39,8 +39,8 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= -github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -85,12 +85,12 @@ github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGr github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= -github.com/grafana/grafana-plugin-sdk-go v0.283.0 h1:G7IHshAr30rLWV9FtX3iLlFTTlBhuOkfe7xVAoIP5rE= -github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= +github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= @@ -239,8 +239,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 h1:2pn7OzMewmYRiNtv1doZnLo3gONcnMHlFnmOR8Vgt+8= @@ -280,8 +280,6 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -337,12 +335,12 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -375,7 +373,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index 5bca05229db..5768c358c24 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -710,9 +710,9 @@ VariableCustomFormatterFn: { // `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" + "system" | "snapshot" | "switch" -VariableKind: QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind +VariableKind: QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind | SwitchVariableKind // Sort variable options // Accepted values are: @@ -970,6 +970,23 @@ AdhocVariableKind: { spec: AdhocVariableSpec } +// Switch variable specification +SwitchVariableSpec: { + name: string | *"" + current: string | *"false" + enabledValue: string | *"true" + disabledValue: string | *"false" + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +SwitchVariableKind: { + kind: "SwitchVariable" + spec: SwitchVariableSpec +} + ConditionalRenderingGroupKind: { kind: "ConditionalRenderingGroup" spec: ConditionalRenderingGroupSpec diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go index 7c5573f600a..a90eab95126 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go @@ -66,6 +66,9 @@ func ValidateDashboardSpec(obj *Dashboard, forceValidation bool) (field.ErrorLis } func formatErrorPath(path []string) string { + if len(path) <= 4 { + return strings.Join(path, ".") + } // omitting the "lineage.schemas[0].schema.spec" prefix here. return strings.Join(path[4:], ".") } diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/validation.go b/apps/dashboard/pkg/apis/dashboard/v1beta1/validation.go index b48e38ffd7b..248dc27111c 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/validation.go +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/validation.go @@ -67,6 +67,9 @@ func ValidateDashboardSpec(obj *Dashboard, forceValidation bool) (field.ErrorLis } func formatErrorPath(path []string) string { + if len(path) <= 4 { + return strings.Join(path, ".") + } // omitting the "lineage.schemas[0].schema.spec" prefix here. return strings.Join(path[4:], ".") } diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue index 76e0921eb88..9a8a621345f 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue @@ -714,9 +714,9 @@ VariableCustomFormatterFn: { // `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" + "system" | "snapshot" | "switch" -VariableKind: QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind +VariableKind: QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind | SwitchVariableKind // Sort variable options // Accepted values are: @@ -974,6 +974,23 @@ AdhocVariableKind: { spec: AdhocVariableSpec } +// Switch variable specification +SwitchVariableSpec: { + name: string | *"" + current: string | *"false" + enabledValue: string | *"true" + disabledValue: string | *"false" + label?: string + hide: VariableHide + skipUrlSync: bool | *false + description?: string +} + +SwitchVariableKind: { + kind: "SwitchVariable" + spec: SwitchVariableSpec +} + ConditionalRenderingGroupKind: { kind: "ConditionalRenderingGroup" spec: ConditionalRenderingGroupSpec diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index 37d9ae8897f..b83cbbd6fb3 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -1291,11 +1291,11 @@ func NewDashboardTimeRangeOption() *DashboardTimeRangeOption { } // +k8s:openapi-gen=true -type DashboardVariableKind = DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind +type DashboardVariableKind = DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind // NewDashboardVariableKind creates a new DashboardVariableKind object. func NewDashboardVariableKind() *DashboardVariableKind { - return NewDashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind() + return NewDashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind() } // Query variable kind @@ -1814,6 +1814,45 @@ func NewDashboardMetricFindValue() *DashboardMetricFindValue { return &DashboardMetricFindValue{} } +// +k8s:openapi-gen=true +type DashboardSwitchVariableKind struct { + Kind string `json:"kind"` + Spec DashboardSwitchVariableSpec `json:"spec"` +} + +// NewDashboardSwitchVariableKind creates a new DashboardSwitchVariableKind object. +func NewDashboardSwitchVariableKind() *DashboardSwitchVariableKind { + return &DashboardSwitchVariableKind{ + Kind: "SwitchVariable", + Spec: *NewDashboardSwitchVariableSpec(), + } +} + +// Switch variable specification +// +k8s:openapi-gen=true +type DashboardSwitchVariableSpec struct { + Name string `json:"name"` + Current string `json:"current"` + EnabledValue string `json:"enabledValue"` + DisabledValue string `json:"disabledValue"` + Label *string `json:"label,omitempty"` + Hide DashboardVariableHide `json:"hide"` + SkipUrlSync bool `json:"skipUrlSync"` + Description *string `json:"description,omitempty"` +} + +// NewDashboardSwitchVariableSpec creates a new DashboardSwitchVariableSpec object. +func NewDashboardSwitchVariableSpec() *DashboardSwitchVariableSpec { + return &DashboardSwitchVariableSpec{ + Name: "", + Current: "false", + EnabledValue: "true", + DisabledValue: "false", + Hide: DashboardVariableHideDontHide, + SkipUrlSync: false, + } +} + // +k8s:openapi-gen=true type DashboardSpec struct { Annotations []DashboardAnnotationQueryKind `json:"annotations"` @@ -2404,7 +2443,7 @@ func (resource *DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTab } // +k8s:openapi-gen=true -type DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind struct { +type DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind struct { QueryVariableKind *DashboardQueryVariableKind `json:"QueryVariableKind,omitempty"` TextVariableKind *DashboardTextVariableKind `json:"TextVariableKind,omitempty"` ConstantVariableKind *DashboardConstantVariableKind `json:"ConstantVariableKind,omitempty"` @@ -2413,15 +2452,16 @@ type DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasou CustomVariableKind *DashboardCustomVariableKind `json:"CustomVariableKind,omitempty"` GroupByVariableKind *DashboardGroupByVariableKind `json:"GroupByVariableKind,omitempty"` AdhocVariableKind *DashboardAdhocVariableKind `json:"AdhocVariableKind,omitempty"` + SwitchVariableKind *DashboardSwitchVariableKind `json:"SwitchVariableKind,omitempty"` } -// NewDashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind creates a new DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind object. -func NewDashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind() *DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind { - return &DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind{} +// NewDashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind creates a new DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind object. +func NewDashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind() *DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind { + return &DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind{} } -// MarshalJSON implements a custom JSON marshalling logic to encode `DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind` as JSON. -func (resource DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind) MarshalJSON() ([]byte, error) { +// MarshalJSON implements a custom JSON marshalling logic to encode `DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind` as JSON. +func (resource DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind) MarshalJSON() ([]byte, error) { if resource.QueryVariableKind != nil { return json.Marshal(resource.QueryVariableKind) } @@ -2446,12 +2486,15 @@ func (resource DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKin if resource.AdhocVariableKind != nil { return json.Marshal(resource.AdhocVariableKind) } + if resource.SwitchVariableKind != nil { + return json.Marshal(resource.SwitchVariableKind) + } return []byte("null"), nil } -// UnmarshalJSON implements a custom JSON unmarshalling logic to decode `DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind` from JSON. -func (resource *DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind) UnmarshalJSON(raw []byte) error { +// UnmarshalJSON implements a custom JSON unmarshalling logic to decode `DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind` from JSON. +func (resource *DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind) UnmarshalJSON(raw []byte) error { if raw == nil { return nil } @@ -2524,6 +2567,14 @@ func (resource *DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKi resource.QueryVariableKind = &dashboardQueryVariableKind return nil + case "SwitchVariable": + var dashboardSwitchVariableKind DashboardSwitchVariableKind + if err := json.Unmarshal(raw, &dashboardSwitchVariableKind); err != nil { + return err + } + + resource.SwitchVariableKind = &dashboardSwitchVariableKind + return nil case "TextVariable": var dashboardTextVariableKind DashboardTextVariableKind if err := json.Unmarshal(raw, &dashboardTextVariableKind); err != nil { diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go index a167aa6d36e..f595b8a3040 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go @@ -14,125 +14,127 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.AnnotationActions": schema_pkg_apis_dashboard_v2alpha1_AnnotationActions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.AnnotationPermission": schema_pkg_apis_dashboard_v2alpha1_AnnotationPermission(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.Dashboard": schema_pkg_apis_dashboard_v2alpha1_Dashboard(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAccess": schema_pkg_apis_dashboard_v2alpha1_DashboardAccess(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAction": schema_pkg_apis_dashboard_v2alpha1_DashboardAction(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardActionVariable": schema_pkg_apis_dashboard_v2alpha1_DashboardActionVariable(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdHocFilterWithLabels": schema_pkg_apis_dashboard_v2alpha1_DashboardAdHocFilterWithLabels(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdhocVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardAdhocVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdhocVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardAdhocVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationPanelFilter": schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationPanelFilter(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationQueryKind": schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationQueryKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationQuerySpec": schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationQuerySpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAutoGridLayoutItemKind": schema_pkg_apis_dashboard_v2alpha1_DashboardAutoGridLayoutItemKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAutoGridLayoutItemSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardAutoGridLayoutItemSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAutoGridLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardAutoGridLayoutKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAutoGridLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardAutoGridLayoutSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAutoGridRepeatOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardAutoGridRepeatOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardClient": schema_pkg_apis_dashboard_v2alpha1_DashboardClient(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingDataKind": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingDataKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingDataSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingDataSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingGroupKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingGroupSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingTimeRangeSizeKind": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingTimeRangeSizeKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingTimeRangeSizeSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingTimeRangeSizeSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConstantVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardConstantVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConstantVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardConstantVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConversionStatus": schema_pkg_apis_dashboard_v2alpha1_DashboardConversionStatus(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardCustomVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardCustomVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardCustomVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardCustomVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDashboardLink": schema_pkg_apis_dashboard_v2alpha1_DashboardDashboardLink(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDataLink": schema_pkg_apis_dashboard_v2alpha1_DashboardDataLink(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDataQueryKind": schema_pkg_apis_dashboard_v2alpha1_DashboardDataQueryKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDataSourceRef": schema_pkg_apis_dashboard_v2alpha1_DashboardDataSourceRef(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDataTransformerConfig": schema_pkg_apis_dashboard_v2alpha1_DashboardDataTransformerConfig(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDatasourceVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardDatasourceVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDatasourceVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardDatasourceVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDynamicConfigValue": schema_pkg_apis_dashboard_v2alpha1_DashboardDynamicConfigValue(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardElementReference": schema_pkg_apis_dashboard_v2alpha1_DashboardElementReference(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFetchOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardFetchOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFieldColor": schema_pkg_apis_dashboard_v2alpha1_DashboardFieldColor(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFieldConfig": schema_pkg_apis_dashboard_v2alpha1_DashboardFieldConfig(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFieldConfigSource": schema_pkg_apis_dashboard_v2alpha1_DashboardFieldConfigSource(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutItemKind": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutItemKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutItemSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutItemSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGroupByVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardGroupByVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGroupByVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardGroupByVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardInfinityOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardInfinityOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardIntervalVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardIntervalVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardIntervalVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardIntervalVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardJSONCodec": schema_pkg_apis_dashboard_v2alpha1_DashboardJSONCodec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardLibraryPanelKind": schema_pkg_apis_dashboard_v2alpha1_DashboardLibraryPanelKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardLibraryPanelKindSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardLibraryPanelKindSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardLibraryPanelRef": schema_pkg_apis_dashboard_v2alpha1_DashboardLibraryPanelRef(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardList": schema_pkg_apis_dashboard_v2alpha1_DashboardList(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardMatcherConfig": schema_pkg_apis_dashboard_v2alpha1_DashboardMatcherConfig(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardMetadata": schema_pkg_apis_dashboard_v2alpha1_DashboardMetadata(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardMetricFindValue": schema_pkg_apis_dashboard_v2alpha1_DashboardMetricFindValue(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardPanelKind": schema_pkg_apis_dashboard_v2alpha1_DashboardPanelKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardPanelKindOrLibraryPanelKind": schema_pkg_apis_dashboard_v2alpha1_DashboardPanelKindOrLibraryPanelKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardPanelQueryKind": schema_pkg_apis_dashboard_v2alpha1_DashboardPanelQueryKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardPanelQuerySpec": schema_pkg_apis_dashboard_v2alpha1_DashboardPanelQuerySpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardPanelSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardPanelSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryGroupKind": schema_pkg_apis_dashboard_v2alpha1_DashboardQueryGroupKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryGroupSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardQueryGroupSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryOptionsSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardQueryOptionsSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardQueryVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardQueryVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRangeMap": schema_pkg_apis_dashboard_v2alpha1_DashboardRangeMap(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRegexMap": schema_pkg_apis_dashboard_v2alpha1_DashboardRegexMap(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRepeatOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardRepeatOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRowRepeatOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardRowRepeatOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRowsLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardRowsLayoutKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRowsLayoutRowKind": schema_pkg_apis_dashboard_v2alpha1_DashboardRowsLayoutRowKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRowsLayoutRowSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardRowsLayoutRowSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRowsLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardRowsLayoutSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardSpecialValueMap": schema_pkg_apis_dashboard_v2alpha1_DashboardSpecialValueMap(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStatus": schema_pkg_apis_dashboard_v2alpha1_DashboardStatus(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrArrayOfString": schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrArrayOfString(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrFloat64": schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrFloat64(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardTabRepeatOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutTabKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutTabSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTextVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTextVariableKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTextVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTextVariableSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardThreshold": schema_pkg_apis_dashboard_v2alpha1_DashboardThreshold(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardThresholdsConfig": schema_pkg_apis_dashboard_v2alpha1_DashboardThresholdsConfig(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTimeRangeOption": schema_pkg_apis_dashboard_v2alpha1_DashboardTimeRangeOption(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTimeSettingsSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTimeSettingsSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTransformationKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTransformationKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1ActionStyle": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1ActionStyle(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1FieldConfigSourceOverrides": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1FieldConfigSourceOverrides(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1RangeMapOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1RangeMapOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1RegexMapOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1RegexMapOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1SpecialValueMapOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1SpecialValueMapOptions(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardValueMap": schema_pkg_apis_dashboard_v2alpha1_DashboardValueMap(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap": schema_pkg_apis_dashboard_v2alpha1_DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardValueMappingResult": schema_pkg_apis_dashboard_v2alpha1_DashboardValueMappingResult(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardVariableOption": schema_pkg_apis_dashboard_v2alpha1_DashboardVariableOption(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardVersionInfo": schema_pkg_apis_dashboard_v2alpha1_DashboardVersionInfo(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardVersionList": schema_pkg_apis_dashboard_v2alpha1_DashboardVersionList(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardVizConfigKind": schema_pkg_apis_dashboard_v2alpha1_DashboardVizConfigKind(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardVizConfigSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardVizConfigSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardWithAccessInfo": schema_pkg_apis_dashboard_v2alpha1_DashboardWithAccessInfo(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.LibraryPanel": schema_pkg_apis_dashboard_v2alpha1_LibraryPanel(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.LibraryPanelList": schema_pkg_apis_dashboard_v2alpha1_LibraryPanelList(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.LibraryPanelSpec": schema_pkg_apis_dashboard_v2alpha1_LibraryPanelSpec(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.LibraryPanelStatus": schema_pkg_apis_dashboard_v2alpha1_LibraryPanelStatus(ref), - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.VersionsQueryOptions": schema_pkg_apis_dashboard_v2alpha1_VersionsQueryOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.AnnotationActions": schema_pkg_apis_dashboard_v2alpha1_AnnotationActions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.AnnotationPermission": schema_pkg_apis_dashboard_v2alpha1_AnnotationPermission(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.Dashboard": schema_pkg_apis_dashboard_v2alpha1_Dashboard(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAccess": schema_pkg_apis_dashboard_v2alpha1_DashboardAccess(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAction": schema_pkg_apis_dashboard_v2alpha1_DashboardAction(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardActionVariable": schema_pkg_apis_dashboard_v2alpha1_DashboardActionVariable(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdHocFilterWithLabels": schema_pkg_apis_dashboard_v2alpha1_DashboardAdHocFilterWithLabels(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdhocVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardAdhocVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdhocVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardAdhocVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationPanelFilter": schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationPanelFilter(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationQueryKind": schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationQueryKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationQuerySpec": schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationQuerySpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAutoGridLayoutItemKind": schema_pkg_apis_dashboard_v2alpha1_DashboardAutoGridLayoutItemKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAutoGridLayoutItemSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardAutoGridLayoutItemSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAutoGridLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardAutoGridLayoutKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAutoGridLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardAutoGridLayoutSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAutoGridRepeatOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardAutoGridRepeatOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardClient": schema_pkg_apis_dashboard_v2alpha1_DashboardClient(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingDataKind": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingDataKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingDataSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingDataSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingGroupKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingGroupSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingTimeRangeSizeKind": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingTimeRangeSizeKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingTimeRangeSizeSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingTimeRangeSizeSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingVariableKindOrConditionalRenderingDataKindOrConditionalRenderingTimeRangeSizeKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardConditionalRenderingVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConstantVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardConstantVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConstantVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardConstantVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConversionStatus": schema_pkg_apis_dashboard_v2alpha1_DashboardConversionStatus(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardCustomVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardCustomVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardCustomVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardCustomVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDashboardLink": schema_pkg_apis_dashboard_v2alpha1_DashboardDashboardLink(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDataLink": schema_pkg_apis_dashboard_v2alpha1_DashboardDataLink(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDataQueryKind": schema_pkg_apis_dashboard_v2alpha1_DashboardDataQueryKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDataSourceRef": schema_pkg_apis_dashboard_v2alpha1_DashboardDataSourceRef(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDataTransformerConfig": schema_pkg_apis_dashboard_v2alpha1_DashboardDataTransformerConfig(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDatasourceVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardDatasourceVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDatasourceVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardDatasourceVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDynamicConfigValue": schema_pkg_apis_dashboard_v2alpha1_DashboardDynamicConfigValue(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardElementReference": schema_pkg_apis_dashboard_v2alpha1_DashboardElementReference(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFetchOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardFetchOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFieldColor": schema_pkg_apis_dashboard_v2alpha1_DashboardFieldColor(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFieldConfig": schema_pkg_apis_dashboard_v2alpha1_DashboardFieldConfig(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFieldConfigSource": schema_pkg_apis_dashboard_v2alpha1_DashboardFieldConfigSource(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutItemKind": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutItemKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutItemSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutItemSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutKindOrAutoGridLayoutKindOrTabsLayoutKindOrRowsLayoutKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGroupByVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardGroupByVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGroupByVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardGroupByVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardInfinityOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardInfinityOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardIntervalVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardIntervalVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardIntervalVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardIntervalVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardJSONCodec": schema_pkg_apis_dashboard_v2alpha1_DashboardJSONCodec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardLibraryPanelKind": schema_pkg_apis_dashboard_v2alpha1_DashboardLibraryPanelKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardLibraryPanelKindSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardLibraryPanelKindSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardLibraryPanelRef": schema_pkg_apis_dashboard_v2alpha1_DashboardLibraryPanelRef(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardList": schema_pkg_apis_dashboard_v2alpha1_DashboardList(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardMatcherConfig": schema_pkg_apis_dashboard_v2alpha1_DashboardMatcherConfig(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardMetadata": schema_pkg_apis_dashboard_v2alpha1_DashboardMetadata(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardMetricFindValue": schema_pkg_apis_dashboard_v2alpha1_DashboardMetricFindValue(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardPanelKind": schema_pkg_apis_dashboard_v2alpha1_DashboardPanelKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardPanelKindOrLibraryPanelKind": schema_pkg_apis_dashboard_v2alpha1_DashboardPanelKindOrLibraryPanelKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardPanelQueryKind": schema_pkg_apis_dashboard_v2alpha1_DashboardPanelQueryKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardPanelQuerySpec": schema_pkg_apis_dashboard_v2alpha1_DashboardPanelQuerySpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardPanelSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardPanelSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryGroupKind": schema_pkg_apis_dashboard_v2alpha1_DashboardQueryGroupKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryGroupSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardQueryGroupSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryOptionsSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardQueryOptionsSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardQueryVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardQueryVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRangeMap": schema_pkg_apis_dashboard_v2alpha1_DashboardRangeMap(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRegexMap": schema_pkg_apis_dashboard_v2alpha1_DashboardRegexMap(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRepeatOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardRepeatOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRowRepeatOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardRowRepeatOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRowsLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardRowsLayoutKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRowsLayoutRowKind": schema_pkg_apis_dashboard_v2alpha1_DashboardRowsLayoutRowKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRowsLayoutRowSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardRowsLayoutRowSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardRowsLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardRowsLayoutSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardSpecialValueMap": schema_pkg_apis_dashboard_v2alpha1_DashboardSpecialValueMap(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStatus": schema_pkg_apis_dashboard_v2alpha1_DashboardStatus(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrArrayOfString": schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrArrayOfString(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrFloat64": schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrFloat64(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardSwitchVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardSwitchVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardSwitchVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardSwitchVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardTabRepeatOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutTabKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutTabSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTextVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTextVariableKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTextVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTextVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardThreshold": schema_pkg_apis_dashboard_v2alpha1_DashboardThreshold(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardThresholdsConfig": schema_pkg_apis_dashboard_v2alpha1_DashboardThresholdsConfig(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTimeRangeOption": schema_pkg_apis_dashboard_v2alpha1_DashboardTimeRangeOption(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTimeSettingsSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTimeSettingsSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTransformationKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTransformationKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1ActionStyle": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1ActionStyle(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1FieldConfigSourceOverrides": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1FieldConfigSourceOverrides(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1RangeMapOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1RangeMapOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1RegexMapOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1RegexMapOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1SpecialValueMapOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1SpecialValueMapOptions(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardValueMap": schema_pkg_apis_dashboard_v2alpha1_DashboardValueMap(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap": schema_pkg_apis_dashboard_v2alpha1_DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardValueMappingResult": schema_pkg_apis_dashboard_v2alpha1_DashboardValueMappingResult(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardVariableOption": schema_pkg_apis_dashboard_v2alpha1_DashboardVariableOption(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardVersionInfo": schema_pkg_apis_dashboard_v2alpha1_DashboardVersionInfo(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardVersionList": schema_pkg_apis_dashboard_v2alpha1_DashboardVersionList(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardVizConfigKind": schema_pkg_apis_dashboard_v2alpha1_DashboardVizConfigKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardVizConfigSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardVizConfigSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardWithAccessInfo": schema_pkg_apis_dashboard_v2alpha1_DashboardWithAccessInfo(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.LibraryPanel": schema_pkg_apis_dashboard_v2alpha1_LibraryPanel(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.LibraryPanelList": schema_pkg_apis_dashboard_v2alpha1_LibraryPanelList(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.LibraryPanelSpec": schema_pkg_apis_dashboard_v2alpha1_LibraryPanelSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.LibraryPanelStatus": schema_pkg_apis_dashboard_v2alpha1_LibraryPanelStatus(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.VersionsQueryOptions": schema_pkg_apis_dashboard_v2alpha1_VersionsQueryOptions(ref), } } @@ -3457,7 +3459,7 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardQueryVariableKind(ref common.Re } } -func schema_pkg_apis_dashboard_v2alpha1_DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_dashboard_v2alpha1_DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -3503,11 +3505,16 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardQueryVariableKindOrTextVariable Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdhocVariableKind"), }, }, + "SwitchVariableKind": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardSwitchVariableKind"), + }, + }, }, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdhocVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConstantVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardCustomVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDatasourceVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGroupByVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardIntervalVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTextVariableKind"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdhocVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConstantVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardCustomVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDatasourceVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGroupByVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardIntervalVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardSwitchVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTextVariableKind"}, } } @@ -4064,7 +4071,7 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardSpec(ref common.ReferenceCallba Items: &spec.SchemaOrArray{ Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ - Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind"), + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind"), }, }, }, @@ -4075,7 +4082,7 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardSpec(ref common.ReferenceCallba }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationQueryKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDashboardLink", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardPanelKindOrLibraryPanelKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTimeSettingsSpec"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationQueryKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDashboardLink", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardPanelKindOrLibraryPanelKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTimeSettingsSpec"}, } } @@ -4184,6 +4191,102 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrFloat64(ref common.Refe } } +func schema_pkg_apis_dashboard_v2alpha1_DashboardSwitchVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardSwitchVariableSpec"), + }, + }, + }, + Required: []string{"kind", "spec"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardSwitchVariableSpec"}, + } +} + +func schema_pkg_apis_dashboard_v2alpha1_DashboardSwitchVariableSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Switch variable specification", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "current": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "enabledValue": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "disabledValue": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "label": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "hide": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "skipUrlSync": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "description": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"name", "current", "enabledValue", "disabledValue", "hide", "skipUrlSync"}, + }, + }, + } +} + func schema_pkg_apis_dashboard_v2alpha1_DashboardTabRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list index 25c9bf58f1c..31ac411ad28 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list @@ -55,14 +55,15 @@ API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/ap API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardIntervalVariableSpec,AutoMin API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardPanelKindOrLibraryPanelKind,LibraryPanelKind API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardPanelKindOrLibraryPanelKind,PanelKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,AdhocVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,ConstantVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,CustomVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,DatasourceVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,GroupByVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,IntervalVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,QueryVariableKind -API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind,TextVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind,AdhocVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind,ConstantVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind,CustomVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind,DatasourceVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind,GroupByVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind,IntervalVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind,QueryVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind,SwitchVariableKind +API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind,TextVariableKind API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardStringOrArrayOfString,ArrayOfString API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardStringOrArrayOfString,String API rule violation: names_match,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardStringOrFloat64,Float64 diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.variable-conversions.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.variable-conversions.json index a8d22b09eee..ae1ef7cd04e 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.variable-conversions.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.variable-conversions.json @@ -258,6 +258,32 @@ "multi": true, "skipUrlSync": false }, + { + "name": "switch_var", + "type": "switch", + "label": "Enable Feature", + "description": "Toggle feature on/off", + "query": "", + "current": { + "selected": false, + "text": "false", + "value": "false" + }, + "options": [ + { + "selected": false, + "text": "true", + "value": "true" + }, + { + "selected": true, + "text": "false", + "value": "false" + } + ], + "hide": 0, + "skipUrlSync": false + }, { "name": "legacy_string_var", "type": "query", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.variable-conversions.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.variable-conversions.v0alpha1.json index e4a5b7b74eb..4e12e6982ef 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.variable-conversions.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.variable-conversions.v0alpha1.json @@ -277,6 +277,32 @@ "skipUrlSync": false, "type": "groupby" }, + { + "current": { + "selected": false, + "text": "false", + "value": "false" + }, + "description": "Toggle feature on/off", + "hide": 0, + "label": "Enable Feature", + "name": "switch_var", + "options": [ + { + "selected": false, + "text": "true", + "value": "true" + }, + { + "selected": true, + "text": "false", + "value": "false" + } + ], + "query": "", + "skipUrlSync": false, + "type": "switch" + }, { "current": { "selected": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.variable-conversions.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.variable-conversions.v2alpha1.json index a4740089365..c7c6c646a94 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.variable-conversions.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.variable-conversions.v2alpha1.json @@ -317,6 +317,19 @@ "description": "Group results by field" } }, + { + "kind": "SwitchVariable", + "spec": { + "name": "switch_var", + "current": "false", + "enabledValue": "true", + "disabledValue": "false", + "label": "Enable Feature", + "hide": "dontHide", + "skipUrlSync": false, + "description": "Toggle feature on/off" + } + }, { "kind": "QueryVariable", "spec": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.variable-conversions.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.variable-conversions.v2beta1.json index d35e57fa601..7b1a899d7fa 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.variable-conversions.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.variable-conversions.v2beta1.json @@ -318,6 +318,19 @@ "description": "Group results by field" } }, + { + "kind": "SwitchVariable", + "spec": { + "name": "switch_var", + "current": "false", + "enabledValue": "true", + "disabledValue": "false", + "label": "Enable Feature", + "hide": "dontHide", + "skipUrlSync": false, + "description": "Toggle feature on/off" + } + }, { "kind": "QueryVariable", "spec": { diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index d20855a9509..7ef4d6ed475 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -956,6 +956,10 @@ func transformVariables(ctx context.Context, dashboard map[string]interface{}, d if textVar, err := buildTextVariable(varMap, commonProps); err == nil { variables = append(variables, textVar) } + case "switch": + if switchVar, err := buildSwitchVariable(varMap, commonProps); err == nil { + variables = append(variables, switchVar) + } case "groupby": if groupByVar, err := buildGroupByVariable(ctx, varMap, commonProps, dsIndexProvider); err == nil { variables = append(variables, groupByVar) @@ -1415,6 +1419,75 @@ func buildTextVariable(varMap map[string]interface{}, commonProps CommonVariable }, nil } +// Helper function to extract string value from an option map (value or text field) +func getOptionValue(optMap map[string]interface{}) string { + if val, ok := optMap["value"].(string); ok && val != "" { + return val + } + if val, ok := optMap["text"].(string); ok && val != "" { + return val + } + return "" +} + +// Switch Variable +func buildSwitchVariable(varMap map[string]interface{}, commonProps CommonVariableProperties) (dashv2alpha1.DashboardVariableKind, error) { + current := "" + if currentVal, exists := varMap["current"]; exists { + if currentMap, ok := currentVal.(map[string]interface{}); ok { + current = getOptionValue(currentMap) + } + } + + // In V1 the enabled value is the first value of the options array, + // while the disabled value is second one. + // (Falling back to "true" and "false" if options are not available) + enabledValue := "true" + disabledValue := "false" + + if options, ok := varMap["options"].([]interface{}); ok { + // Get enabledValue from first option + if len(options) > 0 { + if opt1, ok := options[0].(map[string]interface{}); ok { + if val := getOptionValue(opt1); val != "" { + enabledValue = val + } + } + } + // Get disabledValue from second option + if len(options) > 1 { + if opt2, ok := options[1].(map[string]interface{}); ok { + if val := getOptionValue(opt2); val != "" { + disabledValue = val + } + } + } + } + + // Set current to disabledValue if not set + if current == "" { + current = disabledValue + } + + switchVar := &dashv2alpha1.DashboardSwitchVariableKind{ + Kind: "SwitchVariable", + Spec: dashv2alpha1.DashboardSwitchVariableSpec{ + Name: commonProps.Name, + Current: current, + EnabledValue: enabledValue, + DisabledValue: disabledValue, + Label: commonProps.Label, + Description: commonProps.Description, + Hide: commonProps.Hide, + SkipUrlSync: commonProps.SkipUrlSync, + }, + } + + return dashv2alpha1.DashboardVariableKind{ + SwitchVariableKind: switchVar, + }, nil +} + // Adhoc Variable func buildAdhocVariable(ctx context.Context, varMap map[string]interface{}, commonProps CommonVariableProperties, dsIndexProvider schemaversion.DataSourceIndexProvider) (dashv2alpha1.DashboardVariableKind, error) { datasource := varMap["datasource"] diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go index e5bd1f834d3..b09198c72e2 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go @@ -733,6 +733,22 @@ func convertVariable_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableKind, } } + if in.SwitchVariableKind != nil { + out.SwitchVariableKind = &dashv2beta1.DashboardSwitchVariableKind{ + Kind: in.SwitchVariableKind.Kind, + Spec: dashv2beta1.DashboardSwitchVariableSpec{ + Name: in.SwitchVariableKind.Spec.Name, + Current: in.SwitchVariableKind.Spec.Current, + EnabledValue: in.SwitchVariableKind.Spec.EnabledValue, + DisabledValue: in.SwitchVariableKind.Spec.DisabledValue, + Label: in.SwitchVariableKind.Spec.Label, + Hide: dashv2beta1.DashboardVariableHide(in.SwitchVariableKind.Spec.Hide), + SkipUrlSync: in.SwitchVariableKind.Spec.SkipUrlSync, + Description: in.SwitchVariableKind.Spec.Description, + }, + } + } + return nil } diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1_test.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1_test.go new file mode 100644 index 00000000000..3451234cdec --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1_test.go @@ -0,0 +1,144 @@ +package conversion + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + + dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" + "github.com/grafana/grafana/apps/dashboard/pkg/migration" + migrationtestutil "github.com/grafana/grafana/apps/dashboard/pkg/migration/testutil" +) + +// TestV2alpha1ToV2beta1 tests the conversion logic for v2alpha1 to v2beta1. +func TestV2alpha1ToV2beta1(t *testing.T) { + // Initialize the migrator with test providers + dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) + + // Set up conversion scheme + scheme := runtime.NewScheme() + err := RegisterConversions(scheme, dsProvider, leProvider) + require.NoError(t, err) + + testCases := []struct { + name string + createV2alpha1 func() *dashv2alpha1.Dashboard + validateV2beta1 func(t *testing.T, v2beta1 *dashv2beta1.Dashboard) + }{ + { + name: "dashboard with switch variable", + createV2alpha1: func() *dashv2alpha1.Dashboard { + label := "Enable Feature" + description := "Toggle feature" + return &dashv2alpha1.Dashboard{ + Spec: dashv2alpha1.DashboardSpec{ + Title: "Test Dashboard", + Variables: []dashv2alpha1.DashboardVariableKind{ + { + SwitchVariableKind: &dashv2alpha1.DashboardSwitchVariableKind{ + Kind: "SwitchVariable", + Spec: dashv2alpha1.DashboardSwitchVariableSpec{ + Name: "switch_var", + Current: "false", + EnabledValue: "true", + DisabledValue: "false", + Label: &label, + Description: &description, + Hide: dashv2alpha1.DashboardVariableHideDontHide, + SkipUrlSync: false, + }, + }, + }, + }, + }, + } + }, + validateV2beta1: func(t *testing.T, v2beta1 *dashv2beta1.Dashboard) { + require.Len(t, v2beta1.Spec.Variables, 1) + variable := v2beta1.Spec.Variables[0] + require.NotNil(t, variable.SwitchVariableKind, "SwitchVariableKind should not be nil") + assert.Equal(t, "SwitchVariable", variable.SwitchVariableKind.Kind) + assert.Equal(t, "switch_var", variable.SwitchVariableKind.Spec.Name) + assert.Equal(t, "false", variable.SwitchVariableKind.Spec.Current) + assert.Equal(t, "true", variable.SwitchVariableKind.Spec.EnabledValue) + assert.Equal(t, "false", variable.SwitchVariableKind.Spec.DisabledValue) + assert.NotNil(t, variable.SwitchVariableKind.Spec.Label) + assert.Equal(t, "Enable Feature", *variable.SwitchVariableKind.Spec.Label) + assert.NotNil(t, variable.SwitchVariableKind.Spec.Description) + assert.Equal(t, "Toggle feature", *variable.SwitchVariableKind.Spec.Description) + assert.Equal(t, dashv2beta1.DashboardVariableHideDontHide, variable.SwitchVariableKind.Spec.Hide) + assert.False(t, variable.SwitchVariableKind.Spec.SkipUrlSync) + }, + }, + { + name: "dashboard with switch variable - custom values for enabled and disable states", + createV2alpha1: func() *dashv2alpha1.Dashboard { + label := "Enable Feature" + description := "Toggle feature" + return &dashv2alpha1.Dashboard{ + Spec: dashv2alpha1.DashboardSpec{ + Title: "Test Dashboard", + Variables: []dashv2alpha1.DashboardVariableKind{ + { + SwitchVariableKind: &dashv2alpha1.DashboardSwitchVariableKind{ + Kind: "SwitchVariable", + Spec: dashv2alpha1.DashboardSwitchVariableSpec{ + Name: "switch_var", + Current: "true", + EnabledValue: "enabled", + DisabledValue: "disabled", + Label: &label, + Description: &description, + Hide: dashv2alpha1.DashboardVariableHideHideLabel, + SkipUrlSync: true, + }, + }, + }, + }, + }, + } + }, + validateV2beta1: func(t *testing.T, v2beta1 *dashv2beta1.Dashboard) { + require.Len(t, v2beta1.Spec.Variables, 1) + variable := v2beta1.Spec.Variables[0] + require.NotNil(t, variable.SwitchVariableKind) + assert.Equal(t, "switch_var", variable.SwitchVariableKind.Spec.Name) + assert.Equal(t, "true", variable.SwitchVariableKind.Spec.Current) + assert.Equal(t, "enabled", variable.SwitchVariableKind.Spec.EnabledValue) + assert.Equal(t, "disabled", variable.SwitchVariableKind.Spec.DisabledValue) + assert.Equal(t, dashv2beta1.DashboardVariableHideHideLabel, variable.SwitchVariableKind.Spec.Hide) + assert.True(t, variable.SwitchVariableKind.Spec.SkipUrlSync) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create v2alpha1 dashboard + v2alpha1 := tc.createV2alpha1() + + // Collect original statistics + originalStats := collectStatsV2alpha1(v2alpha1.Spec) + + // Convert to v2beta1 + var v2beta1 dashv2beta1.Dashboard + err := scheme.Convert(v2alpha1, &v2beta1, nil) + require.NoError(t, err, "Failed to convert v2alpha1 to v2beta1") + + // Collect v2beta1 statistics + v2beta1Stats := collectStatsV2beta1(v2beta1.Spec) + + // Verify no data loss + err = detectConversionDataLoss(originalStats, v2beta1Stats, "V2alpha1", "V2beta1") + assert.NoError(t, err, "Data loss detected in conversion") + + // Run custom validation + tc.validateV2beta1(t, &v2beta1) + }) + } +} diff --git a/apps/dashboard/pkg/migration/conversion/v2beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v2beta1_to_v2alpha1.go index 580fef1b3ea..f0f06957b63 100644 --- a/apps/dashboard/pkg/migration/conversion/v2beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v2beta1_to_v2alpha1.go @@ -771,6 +771,23 @@ func convertVariable_V2beta1_to_V2alpha1(in *dashv2beta1.DashboardVariableKind, } } + if in.SwitchVariableKind != nil { + out.SwitchVariableKind = &dashv2alpha1.DashboardSwitchVariableKind{ + Kind: in.SwitchVariableKind.Kind, + Spec: dashv2alpha1.DashboardSwitchVariableSpec{ + Name: in.SwitchVariableKind.Spec.Name, + Current: in.SwitchVariableKind.Spec.Current, + EnabledValue: in.SwitchVariableKind.Spec.EnabledValue, + DisabledValue: in.SwitchVariableKind.Spec.DisabledValue, + Label: in.SwitchVariableKind.Spec.Label, + Hide: dashv2alpha1.DashboardVariableHide(in.SwitchVariableKind.Spec.Hide), + SkipUrlSync: in.SwitchVariableKind.Spec.SkipUrlSync, + Description: in.SwitchVariableKind.Spec.Description, + }, + } + return nil + } + return nil } diff --git a/apps/dashboard/pkg/migration/conversion/v2beta1_to_v2alpha1_test.go b/apps/dashboard/pkg/migration/conversion/v2beta1_to_v2alpha1_test.go index 773961a31fb..25703b56aa7 100644 --- a/apps/dashboard/pkg/migration/conversion/v2beta1_to_v2alpha1_test.go +++ b/apps/dashboard/pkg/migration/conversion/v2beta1_to_v2alpha1_test.go @@ -479,6 +479,51 @@ func TestV2beta1ToV2alpha1(t *testing.T) { assert.Equal(t, "", variable.QueryVariableKind.Spec.Query.Kind, "Empty group should result in empty kind") }, }, + { + name: "dashboard with switch variable", + createV2beta1: func() *dashv2beta1.Dashboard { + label := "Enable Feature" + description := "Toggle feature" + return &dashv2beta1.Dashboard{ + Spec: dashv2beta1.DashboardSpec{ + Title: "Test Dashboard", + Variables: []dashv2beta1.DashboardVariableKind{ + { + SwitchVariableKind: &dashv2beta1.DashboardSwitchVariableKind{ + Kind: "SwitchVariable", + Spec: dashv2beta1.DashboardSwitchVariableSpec{ + Name: "switch_var", + Current: "false", + EnabledValue: "true", + DisabledValue: "false", + Label: &label, + Description: &description, + Hide: dashv2beta1.DashboardVariableHideDontHide, + SkipUrlSync: false, + }, + }, + }, + }, + }, + } + }, + validateV2alpha1: func(t *testing.T, v2alpha1 *dashv2alpha1.Dashboard) { + require.Len(t, v2alpha1.Spec.Variables, 1) + variable := v2alpha1.Spec.Variables[0] + require.NotNil(t, variable.SwitchVariableKind, "SwitchVariableKind should not be nil") + assert.Equal(t, "SwitchVariable", variable.SwitchVariableKind.Kind) + assert.Equal(t, "switch_var", variable.SwitchVariableKind.Spec.Name) + assert.Equal(t, "false", variable.SwitchVariableKind.Spec.Current) + assert.Equal(t, "true", variable.SwitchVariableKind.Spec.EnabledValue) + assert.Equal(t, "false", variable.SwitchVariableKind.Spec.DisabledValue) + assert.NotNil(t, variable.SwitchVariableKind.Spec.Label) + assert.Equal(t, "Enable Feature", *variable.SwitchVariableKind.Spec.Label) + assert.NotNil(t, variable.SwitchVariableKind.Spec.Description) + assert.Equal(t, "Toggle feature", *variable.SwitchVariableKind.Spec.Description) + assert.Equal(t, dashv2alpha1.DashboardVariableHideDontHide, variable.SwitchVariableKind.Spec.Hide) + assert.False(t, variable.SwitchVariableKind.Spec.SkipUrlSync) + }, + }, { name: "dashboard with rows layout", createV2beta1: func() *dashv2beta1.Dashboard { diff --git a/apps/example/go.mod b/apps/example/go.mod index 66b3b93f6df..61574462d8a 100644 --- a/apps/example/go.mod +++ b/apps/example/go.mod @@ -3,8 +3,8 @@ module github.com/grafana/grafana/apps/example go 1.25.3 require ( - github.com/grafana/grafana-app-sdk v0.48.2 - github.com/grafana/grafana-app-sdk/logging v0.48.1 + github.com/grafana/grafana-app-sdk v0.48.4 + github.com/grafana/grafana-app-sdk/logging v0.48.3 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251017153501-8512b219c5fe k8s.io/apimachinery v0.34.2 k8s.io/apiserver v0.34.2 @@ -21,7 +21,7 @@ require ( github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect @@ -62,7 +62,7 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect @@ -73,7 +73,6 @@ require ( go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect @@ -82,9 +81,9 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -95,6 +94,6 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/example/go.sum b/apps/example/go.sum index aaf74c34d44..3211e083601 100644 --- a/apps/example/go.sum +++ b/apps/example/go.sum @@ -18,8 +18,8 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= -github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -56,10 +56,10 @@ github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGr github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251017153501-8512b219c5fe h1:pPoFj2bQKDBg5EyEdOU+Jn+0hQN+M775Qihk73RbdSs= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251017153501-8512b219c5fe/go.mod h1:zn/yoxKpWA2KUsxOhQbSbL8OCkF2JNLpSEHs+hQYvdM= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= @@ -140,8 +140,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= @@ -169,8 +169,6 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -213,12 +211,12 @@ gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0 gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -250,7 +248,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/folder/go.mod b/apps/folder/go.mod index 1c9249745a9..4e336935537 100644 --- a/apps/folder/go.mod +++ b/apps/folder/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/apps/folder go 1.25.3 require ( - github.com/grafana/grafana-app-sdk v0.48.2 + github.com/grafana/grafana-app-sdk v0.48.4 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e k8s.io/apimachinery v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 @@ -24,7 +24,7 @@ require ( github.com/go-test/deep v1.1.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.48.1 // indirect + github.com/grafana/grafana-app-sdk/logging v0.48.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -64,6 +64,6 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/folder/go.sum b/apps/folder/go.sum index 944e0d69721..ab70319f432 100644 --- a/apps/folder/go.sum +++ b/apps/folder/go.sum @@ -33,10 +33,10 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -181,7 +181,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 73891acd454..0821652b3d8 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -20,6 +20,8 @@ replace github.com/grafana/grafana/apps/alerting/notifications => ../alerting/no replace github.com/grafana/grafana/apps/alerting/rules => ../alerting/rules +replace github.com/grafana/grafana/apps/alerting/historian => ../alerting/historian + replace github.com/grafana/grafana/apps/correlations => ../correlations replace github.com/grafana/grafana/apps/investigations => ../investigations @@ -50,8 +52,8 @@ replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-aler require ( github.com/grafana/grafana v0.0.0-00010101000000-000000000000 - github.com/grafana/grafana-app-sdk v0.48.2 - github.com/grafana/grafana-app-sdk/logging v0.48.1 + github.com/grafana/grafana-app-sdk v0.48.4 + github.com/grafana/grafana-app-sdk/logging v0.48.3 github.com/grafana/grafana/apps/folder v0.0.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0 github.com/prometheus/client_golang v1.23.2 @@ -66,7 +68,7 @@ require ( cloud.google.com/go v0.121.4 // indirect cloud.google.com/go/auth v0.16.3 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect cloud.google.com/go/storage v1.55.0 // indirect @@ -82,7 +84,7 @@ require ( github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect @@ -137,7 +139,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect - github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect + github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect @@ -157,7 +159,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -167,7 +169,7 @@ require ( github.com/gchaincl/sqlhooks v1.3.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect - github.com/go-jose/go-jose/v4 v4.1.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-kit/log v0.2.1 // indirect github.com/go-ldap/ldap/v3 v3.4.4 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect @@ -225,7 +227,7 @@ require ( github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect github.com/grafana/grafana-aws-sdk v1.3.0 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect - github.com/grafana/grafana-plugin-sdk-go v0.283.0 // indirect + github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect github.com/grafana/grafana/apps/dashboard v0.0.0 // indirect github.com/grafana/grafana/apps/plugins v0.0.0 // indirect github.com/grafana/grafana/apps/provisioning v0.0.0 // indirect @@ -360,7 +362,7 @@ require ( github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect - github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.11.1 // indirect @@ -373,15 +375,14 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/zeebo/errs v1.4.0 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.etcd.io/etcd/api/v3 v3.6.4 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect go.etcd.io/etcd/client/v3 v3.6.4 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.61.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect go.opentelemetry.io/contrib/exporters/autoexport v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect @@ -430,9 +431,9 @@ require ( gonum.org/v1/gonum v0.16.0 // indirect google.golang.org/api v0.242.0 // indirect google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect @@ -459,7 +460,7 @@ require ( sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect xorm.io/builder v0.3.13 // indirect ) diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 4cba06c00ec..5bab810e8d0 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -53,8 +53,8 @@ cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJW cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= @@ -153,8 +153,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/FZambia/eagle v0.2.0 h1:1kQaZpJvbkvAXFRE/9K2ucBMuVqo+E29EMLYB74hIis= github.com/FZambia/eagle v0.2.0/go.mod h1:LKMYBwGYhao5sJI0TppvQ4SvvldFj9gITxrl8NvGwG0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg= @@ -405,8 +405,8 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= @@ -519,10 +519,10 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= @@ -565,8 +565,8 @@ github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkPro github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= -github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -833,10 +833,10 @@ github.com/grafana/gofpdf v0.0.0-20250307124105-3b9c5d35577f h1:5xkjl5Y/j2QefJKO github.com/grafana/gofpdf v0.0.0-20250307124105-3b9c5d35577f/go.mod h1:+O5QxOwwgP10jedZHapzXY+IPKTnzHBtIs5UUb9G+kI= github.com/grafana/gomemcache v0.0.0-20250828162811-a96f6acee2fe h1:q+QaVANzNZxvTovycpQvDTfsNZ2rHh4XIIaccMnrIR4= github.com/grafana/gomemcache v0.0.0-20250828162811-a96f6acee2fe/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= @@ -847,8 +847,8 @@ github.com/grafana/grafana-google-sdk-go v0.4.2 h1:F44hQF1y6UVJhlJPi+Mz+GCJsioVg github.com/grafana/grafana-google-sdk-go v0.4.2/go.mod h1:U73+w9DlbEtUonhQUzERwlXnzWTtfRoyrtKH8d3VY40= github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 h1:r+mU5bGMzcXCRVAuOrTn54S80qbfVkvTdUJZfSfTNbs= github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ= -github.com/grafana/grafana-plugin-sdk-go v0.283.0 h1:G7IHshAr30rLWV9FtX3iLlFTTlBhuOkfe7xVAoIP5rE= -github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= +github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= +github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b h1:6Bo65etvjQ4tStkaA5+N3A3ENbO4UAWj53TxF6g2Hdk= github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b/go.mod h1:6+wASOCN8LWt6FJ8dc0oODUBIEY5XHaE6ABi8g0mR+k= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= @@ -1452,8 +1452,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= -github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spyzhov/ajson v0.9.6 h1:iJRDaLa+GjhCDAt1yFtU/LKMtLtsNVKkxqlpvrHHlpQ= github.com/spyzhov/ajson v0.9.6/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8= github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= @@ -1543,8 +1543,6 @@ github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= -github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= @@ -1577,8 +1575,8 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/collector/featuregate v1.44.0 h1:/GeGhTD8f+FNWS7C4w1Dj0Ui9Jp4v2WAdlXyW1p3uG8= go.opentelemetry.io/collector/featuregate v1.44.0/go.mod h1:d0tiRzVYrytB6LkcYgz2ESFTv7OktRPQe0QEQcPt1L4= go.opentelemetry.io/collector/pdata v1.44.0 h1:q/EfWDDKrSaf4hjTIzyPeg1ZcCRg1Uj7VTFnGfNVdk8= @@ -1587,8 +1585,8 @@ go.opentelemetry.io/collector/semconv v0.124.0 h1:YTdo3UFwNyDQCh9DiSm2rbzAgBuwn/ go.opentelemetry.io/collector/semconv v0.124.0/go.mod h1:te6VQ4zZJO5Lp8dM2XIhDxDiL45mwX0YAQQWRQ0Qr9U= go.opentelemetry.io/contrib/bridges/prometheus v0.61.0 h1:RyrtJzu5MAmIcbRrwg75b+w3RlZCP0vJByDVzcpAe3M= go.opentelemetry.io/contrib/bridges/prometheus v0.61.0/go.mod h1:tirr4p9NXbzjlbruiRGp53IzlYrDk5CO2fdHj0sSSaY= -go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA= -go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= go.opentelemetry.io/contrib/exporters/autoexport v0.61.0 h1:XfzKtKSrbtYk9TNCF8dkO0Y9M7IOfb4idCwBOTwGBiI= go.opentelemetry.io/contrib/exporters/autoexport v0.61.0/go.mod h1:N6otC+qXTD5bAnbK2O1f/1SXq3cX+3KYSWrkBUqG0cw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= @@ -2153,10 +2151,10 @@ google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs= google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= @@ -2190,8 +2188,8 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5 google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -2315,8 +2313,8 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/investigations/go.mod b/apps/investigations/go.mod index d4f3685a2aa..6741017390f 100644 --- a/apps/investigations/go.mod +++ b/apps/investigations/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/apps/investigations go 1.25.3 require ( - github.com/grafana/grafana-app-sdk v0.48.2 + github.com/grafana/grafana-app-sdk v0.48.4 k8s.io/apimachinery v0.34.2 k8s.io/klog/v2 v2.130.1 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 @@ -31,7 +31,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.48.1 // indirect + github.com/grafana/grafana-app-sdk/logging v0.48.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -53,11 +53,10 @@ require ( github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect @@ -76,9 +75,9 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -88,6 +87,6 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/investigations/go.sum b/apps/investigations/go.sum index 7eadc30e628..d8f706b0944 100644 --- a/apps/investigations/go.sum +++ b/apps/investigations/go.sum @@ -48,10 +48,10 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -128,8 +128,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= @@ -199,12 +199,12 @@ gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0 gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -234,7 +234,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/logsdrilldown/go.mod b/apps/logsdrilldown/go.mod index 712d99405d0..f7d81af4b9d 100644 --- a/apps/logsdrilldown/go.mod +++ b/apps/logsdrilldown/go.mod @@ -5,8 +5,8 @@ go 1.24.0 toolchain go1.24.6 require ( - github.com/grafana/grafana-app-sdk v0.48.2 - github.com/grafana/grafana-app-sdk/logging v0.48.1 + github.com/grafana/grafana-app-sdk v0.48.4 + github.com/grafana/grafana-app-sdk/logging v0.48.3 k8s.io/apimachinery v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 ) @@ -54,11 +54,10 @@ require ( github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect @@ -77,9 +76,9 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -90,6 +89,6 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/logsdrilldown/go.sum b/apps/logsdrilldown/go.sum index 7eadc30e628..d8f706b0944 100644 --- a/apps/logsdrilldown/go.sum +++ b/apps/logsdrilldown/go.sum @@ -48,10 +48,10 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -128,8 +128,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= @@ -199,12 +199,12 @@ gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0 gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -234,7 +234,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/playlist/go.mod b/apps/playlist/go.mod index 8a254e980d1..bdc831e87ba 100644 --- a/apps/playlist/go.mod +++ b/apps/playlist/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/apps/playlist go 1.25.3 require ( - github.com/grafana/grafana-app-sdk v0.48.2 + github.com/grafana/grafana-app-sdk v0.48.4 k8s.io/apimachinery v0.34.2 k8s.io/client-go v0.34.2 k8s.io/klog/v2 v2.130.1 @@ -32,7 +32,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.48.1 // indirect + github.com/grafana/grafana-app-sdk/logging v0.48.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -54,11 +54,10 @@ require ( github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect @@ -77,9 +76,9 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -88,6 +87,6 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/playlist/go.sum b/apps/playlist/go.sum index 7eadc30e628..d8f706b0944 100644 --- a/apps/playlist/go.sum +++ b/apps/playlist/go.sum @@ -48,10 +48,10 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -128,8 +128,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= @@ -199,12 +199,12 @@ gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0 gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -234,7 +234,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index d2b2f2e8045..cebf6594566 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -11,8 +11,8 @@ replace github.com/grafana/grafana/pkg/apiserver => ../../pkg/apiserver require ( github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 github.com/grafana/grafana v0.0.0-00010101000000-000000000000 - github.com/grafana/grafana-app-sdk v0.48.2 - github.com/grafana/grafana-app-sdk/logging v0.48.1 + github.com/grafana/grafana-app-sdk v0.48.4 + github.com/grafana/grafana-app-sdk/logging v0.48.3 github.com/grafana/grafana/pkg/apimachinery v0.0.0 github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.2 @@ -46,7 +46,7 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-kit/log v0.2.1 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -74,7 +74,7 @@ require ( github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect github.com/grafana/grafana-aws-sdk v1.3.0 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect - github.com/grafana/grafana-plugin-sdk-go v0.283.0 // indirect + github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect @@ -146,7 +146,7 @@ require ( github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect @@ -175,9 +175,9 @@ require ( golang.org/x/tools v0.39.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect @@ -190,6 +190,6 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index 1c479c59942..de829f9be57 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -71,8 +71,8 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= -github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -161,16 +161,16 @@ github.com/grafana/dataplane/sdata v0.0.9 h1:AGL1LZnCUG4MnQtnWpBPbQ8ZpptaZs14w6k github.com/grafana/dataplane/sdata v0.0.9/go.mod h1:Jvs5ddpGmn6vcxT7tCTWAZ1mgi4sbcdFt9utQx5uMAU= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= -github.com/grafana/grafana-plugin-sdk-go v0.283.0 h1:G7IHshAr30rLWV9FtX3iLlFTTlBhuOkfe7xVAoIP5rE= -github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= +github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= +github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= @@ -412,8 +412,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 h1:2pn7OzMewmYRiNtv1doZnLo3gONcnMHlFnmOR8Vgt+8= @@ -536,12 +536,12 @@ gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuB gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -594,7 +594,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/preferences/go.mod b/apps/preferences/go.mod index cd702672de6..18bc0d81e19 100644 --- a/apps/preferences/go.mod +++ b/apps/preferences/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/apps/preferences go 1.25.3 require ( - github.com/grafana/grafana-app-sdk v0.48.2 + github.com/grafana/grafana-app-sdk v0.48.4 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 k8s.io/apimachinery v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 @@ -24,7 +24,7 @@ require ( github.com/go-test/deep v1.1.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.48.1 // indirect + github.com/grafana/grafana-app-sdk/logging v0.48.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -64,6 +64,6 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/preferences/go.sum b/apps/preferences/go.sum index 8d576bfaa8b..eb2916cb29a 100644 --- a/apps/preferences/go.sum +++ b/apps/preferences/go.sum @@ -33,10 +33,10 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 h1:X0cnaFdR+iz+sDSuoZmkryFSjOirchHe2MdKSRwBWgM= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:RRvSjHH12/PnQaXraMO65jUhVu8n59mzvhfIMBETnV4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -181,7 +181,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/provisioning/go.mod b/apps/provisioning/go.mod index 0fb19b230c8..65293ff0f33 100644 --- a/apps/provisioning/go.mod +++ b/apps/provisioning/go.mod @@ -7,7 +7,7 @@ require ( github.com/google/go-github/v70 v70.0.0 github.com/google/uuid v1.6.0 github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f - github.com/grafana/grafana-app-sdk/logging v0.48.1 + github.com/grafana/grafana-app-sdk/logging v0.48.3 github.com/grafana/grafana/apps/secret v0.0.0-20250902093454-b56b7add012f github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 github.com/grafana/nanogit v0.3.0 @@ -18,7 +18,7 @@ require ( k8s.io/apiserver v0.34.2 k8s.io/client-go v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 ) require ( @@ -29,7 +29,7 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect - github.com/go-jose/go-jose/v4 v4.1.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect @@ -44,7 +44,7 @@ require ( github.com/gorilla/mux v1.8.1 // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect - github.com/grafana/grafana-app-sdk v0.48.2 // indirect + github.com/grafana/grafana-app-sdk v0.48.4 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect @@ -62,7 +62,7 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect @@ -75,8 +75,8 @@ require ( golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/apps/provisioning/go.sum b/apps/provisioning/go.sum index db6bd3cf414..76238334303 100644 --- a/apps/provisioning/go.sum +++ b/apps/provisioning/go.sum @@ -16,8 +16,8 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= -github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= -github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -62,10 +62,10 @@ github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGr github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana/apps/secret v0.0.0-20250902093454-b56b7add012f h1:f+Z5Xpfp1WNYjUe23ginerWsHWUsRgOWrr3WGu3SlWs= github.com/grafana/grafana/apps/secret v0.0.0-20250902093454-b56b7add012f/go.mod h1:RA8mP8KVIwKXBx3Ssqa/uEBABib5LvUWYPVMxrNvnP0= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 h1:X0cnaFdR+iz+sDSuoZmkryFSjOirchHe2MdKSRwBWgM= @@ -133,8 +133,8 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= @@ -224,10 +224,10 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -260,7 +260,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/settings.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/settings.go index ee5d264fd83..3031bde77cb 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/settings.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/settings.go @@ -43,6 +43,12 @@ type RepositoryView struct { // For git, this is the target branch Branch string `json:"branch,omitempty"` + // For git, this is the target URL + URL string `json:"url,omitempty"` + + // For git, this is the target path + Path string `json:"path,omitempty"` + // The supported workflows Workflows []Workflow `json:"workflows"` } diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 18c385ce59c..9a4a99d703a 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -1690,6 +1690,20 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryView(ref common.ReferenceCa Format: "", }, }, + "url": { + SchemaProps: spec.SchemaProps{ + Description: "For git, this is the target URL", + Type: []string{"string"}, + Format: "", + }, + }, + "path": { + SchemaProps: spec.SchemaProps{ + Description: "For git, this is the target path", + Type: []string{"string"}, + Format: "", + }, + }, "workflows": { SchemaProps: spec.SchemaProps{ Description: "The supported workflows", diff --git a/apps/scope/go.mod b/apps/scope/go.mod index 9b739eb3d0b..c7d21d96f4a 100644 --- a/apps/scope/go.mod +++ b/apps/scope/go.mod @@ -39,5 +39,5 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect ) diff --git a/apps/scope/go.sum b/apps/scope/go.sum index 1796d287d60..3e1f8bc7d54 100644 --- a/apps/scope/go.sum +++ b/apps/scope/go.sum @@ -114,7 +114,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/sdk.mk b/apps/sdk.mk index 6956168a7df..62d89ed8ed3 100644 --- a/apps/sdk.mk +++ b/apps/sdk.mk @@ -1,4 +1,4 @@ -APP_SDK_VERSION = v0.48.2 +APP_SDK_VERSION = v0.48.4 APP_SDK_DIR = $(shell go env GOPATH)/bin/app-sdk-$(APP_SDK_VERSION) APP_SDK_BIN = $(APP_SDK_DIR)/grafana-app-sdk diff --git a/apps/secret/go.mod b/apps/secret/go.mod index 2ac03ea3f8b..d8de93c5cff 100644 --- a/apps/secret/go.mod +++ b/apps/secret/go.mod @@ -3,11 +3,11 @@ module github.com/grafana/grafana/apps/secret go 1.25.3 require ( - github.com/grafana/grafana-app-sdk v0.48.2 + github.com/grafana/grafana-app-sdk v0.48.4 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250710134100-1f3dc0533caf github.com/stretchr/testify v1.11.1 go.yaml.in/yaml/v3 v3.0.4 - google.golang.org/grpc v1.76.0 + google.golang.org/grpc v1.77.0 google.golang.org/protobuf v1.36.10 k8s.io/apimachinery v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 @@ -29,7 +29,7 @@ require ( github.com/go-test/deep v1.1.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.48.1 // indirect + github.com/grafana/grafana-app-sdk/logging v0.48.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -51,7 +51,6 @@ require ( github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/otel v1.38.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/net v0.47.0 // indirect @@ -60,13 +59,13 @@ require ( golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/client-go v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/secret/go.sum b/apps/secret/go.sum index 74024f8df34..a18c6f39a89 100644 --- a/apps/secret/go.sum +++ b/apps/secret/go.sum @@ -37,10 +37,10 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250710134100-1f3dc0533caf h1:BBGDHffvVNLoYQlXEpbXcxE0vbpq7pm/8OWF5I+UDZg= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250710134100-1f3dc0533caf/go.mod h1:eAlOam2uWhrsEZlOoAr7XZ9hbBP7SyYGYn31/aQAPs8= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -111,8 +111,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= @@ -168,10 +168,10 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -199,7 +199,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/shorturl/go.mod b/apps/shorturl/go.mod index b3db27ba205..eb9936ab643 100644 --- a/apps/shorturl/go.mod +++ b/apps/shorturl/go.mod @@ -3,10 +3,11 @@ module github.com/grafana/grafana/apps/shorturl go 1.25.3 require ( - github.com/grafana/grafana-app-sdk v0.48.2 - github.com/grafana/grafana-app-sdk/logging v0.48.1 + github.com/grafana/grafana-app-sdk v0.48.4 + github.com/grafana/grafana-app-sdk/logging v0.48.3 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250915132226-585b53bc7dba k8s.io/apimachinery v0.34.2 + k8s.io/apiserver v0.34.2 k8s.io/klog/v2 v2.130.1 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 ) @@ -21,7 +22,7 @@ require ( github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect @@ -62,7 +63,7 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect @@ -73,7 +74,6 @@ require ( go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect @@ -82,19 +82,18 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.2 // indirect k8s.io/apiextensions-apiserver v0.34.2 // indirect - k8s.io/apiserver v0.34.2 // indirect k8s.io/client-go v0.34.2 // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/apps/shorturl/go.sum b/apps/shorturl/go.sum index f8b394c0f37..4508fbd82a6 100644 --- a/apps/shorturl/go.sum +++ b/apps/shorturl/go.sum @@ -18,8 +18,8 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= -github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -56,10 +56,10 @@ github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGr github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250915132226-585b53bc7dba h1:Qam8QzVRsyZN39zgZ9Vj6e8PEfswvv2McnqCZ/v5NcI= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250915132226-585b53bc7dba/go.mod h1:rlJ/mmE0RQolOB2+HV3+bw+ZifHyPDQurBwZEus+Wm0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= @@ -140,8 +140,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= @@ -169,8 +169,6 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -213,12 +211,12 @@ gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0 gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -250,7 +248,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/shorturl/pkg/app/authorizer.go b/apps/shorturl/pkg/app/authorizer.go new file mode 100644 index 00000000000..4cde905414d --- /dev/null +++ b/apps/shorturl/pkg/app/authorizer.go @@ -0,0 +1,20 @@ +package app + +import ( + "context" + + "k8s.io/apiserver/pkg/authorization/authorizer" +) + +func GetAuthorizer() authorizer.Authorizer { + return authorizer.AuthorizerFunc(func( + ctx context.Context, attr authorizer.Attributes, + ) (authorized authorizer.Decision, reason string, err error) { + if !attr.IsResourceRequest() { + return authorizer.DecisionNoOpinion, "", nil + } + + // Any authenticated user can access the API + return authorizer.DecisionAllow, "", nil + }) +} diff --git a/contribute/README.md b/contribute/README.md index b5e8ec1222d..d1855d04a04 100644 --- a/contribute/README.md +++ b/contribute/README.md @@ -5,6 +5,7 @@ We're excited that you're considering making a contribution to the Grafana proje These are some good resources to explore for developers: - [Create a pull request](create-pull-request.md) +- [Create a feature request](create-feature-request.md) - [Developer guide](developer-guide.md) - [Triage issues](triage-issues.md) - [Merge a pull request](merge-pull-request.md) diff --git a/contribute/create-feature-request.md b/contribute/create-feature-request.md new file mode 100644 index 00000000000..5ee47bc66e3 --- /dev/null +++ b/contribute/create-feature-request.md @@ -0,0 +1,160 @@ +# Create a feature request + +Feature requests help us understand what you need from Grafana. This document guides you through writing effective feature requests that help maintainers understand your needs and prioritize improvements. + +## Before you begin + +We're excited to hear your ideas! Before you submit a feature request, consider these resources: + +- Read the [Code of Conduct](../CODE_OF_CONDUCT.md) to understand our community guidelines. +- Search [existing feature requests](https://github.com/grafana/grafana/issues?q=is%3Aissue+is%3Aopen+label%3Atype%2Ffeature-request) to see if someone already suggested something similar. +- Discuss your idea in the [Grafana community forums](https://community.grafana.com/) to refine it and gather feedback. + +## Your first feature request + +When you're ready to submit a feature request, use the [feature request template](https://github.com/grafana/grafana/issues/new?template=1-feature_requests.md). The template has three sections that help maintainers understand what you need and why. + +Here's an [example of how all three sections work together in an actual feature request](https://github.com/grafana/grafana/issues/105298) from the Grafana community. We'll analyze each section based on this example feature request. + +### Why is this needed + +This section describes the real problem or limitation you're facing. + +Explain what's difficult, inefficient, or impossible with the current implementation. Focus on the problem rather than proposing a solution. This helps maintainers understand your use case and potentially find better solutions. + +**What to include:** + +- The specific problem or pain point you're experiencing +- How the current behavior falls short for your workflow +- Why this matters to you and your work +- A concrete example that clarifies the issue (optional but helpful) + +**What to avoid:** + +- Jumping directly to the solution (save that for the next section) +- Vague statements like "it would be nice if..." +- Assuming maintainers know your context or workflow + +**Example of a strong answer:** + +``` +When using a datasource variable in dashboards and using the "Export" feature in a dashboard, +this will automatically create an input for the datasource(s) being used, but it will also +effectively override the use of the datasource variable in all panels. + +This makes a confusing +experience when importing the dashboard, because users are prompted for an input, but the +selected datasource won't be reflected in the datasource variable, and any changes to the +datasource variable will not have any effect on the dashboard. +``` + +**Example of a weak answer:** + +``` +Dashboard export doesn't work well with variables. +``` + +The first example clearly explains what's broken, why it's confusing, and what the specific consequences are. The second example is too vague and doesn't explain the actual problem. + +### What would you like to be added + +This section describes what you want Grafana to do differently. + +Be specific and concrete about the expected behavior. If you're suggesting a UI change, describe the interaction or include a screenshot or sketch. If it's data or API related, provide an example query or expected output. + +**What to include:** + +- Exactly what behavior you expect +- How the feature should work in practice +- Examples, screenshots, or code snippets that illustrate your idea +- Expected output or results + +**What to avoid:** + +- Vague or abstract descriptions +- Multiple unrelated features in one request (create separate requests instead) +- Implementation details unless they're critical to your request + +**Example of a strong answer:** + +``` +Ideal behavior here would be that when using the export feature, either: + +1. No inputs section is created for datasource types that are used as datasource variables. +2. IF an input is created, it should only be used to replace the currently selected value of + the datasource variable, rather than override the datasource in panels. +``` + +**Example of a weak answer:** + +``` +Fix the dashboard export feature. +``` + +The first example provides clear, actionable options for how the feature should work. The second example is too vague and doesn't specify what the fix should do. + +### Who is this feature for? + +This section describes who benefits from this feature and in what context. + +Help maintainers understand the scope and impact of your request. Be specific about user types, workflows, or scenarios where this feature matters. + +**What to include:** + +- The type of user who needs this (for example, Tempo users, dashboard editors, plugin developers) +- Whether this affects all Grafana users or only those using specific features or data sources +- The workflow or use case this feature improves (optional but helpful) + +**What to avoid:** + +- Saying "everyone" without clarifying who actually needs it +- Being overly narrow if the feature has broader appeal + +**Example of a strong answer:** + +``` +Any Grafana Dashboard users or authors that use datasource variables. +``` + +**Example of a weak answer:** + +``` +Dashboard users. +``` + +The first example identifies the specific users and the feature they use (datasource variables). The second example is too generic and doesn't clarify which users or workflow are affected. + +## Best practices for feature requests + +Follow these guidelines to increase the chances of your feature request being accepted: + +### Keep it focused + +Request one feature at a time. If you have multiple ideas, create separate feature requests for each one. This makes it easier to discuss, prioritize, and implement each feature independently. + +### Research first + +Before submitting, search for similar requests. If you find an existing request that's close to your idea, add your use case and context to that discussion instead of creating a duplicate. + +### Provide context + +The more context you provide, the better maintainers can understand your needs. Include: + +- Your environment or setup (which data sources, plugins, or features you're using) +- Your workflow or process +- Why this matters to you +- Any workarounds you've tried + +### Be open to alternatives + +Maintainers might suggest different approaches to solve your problem. Be open to these alternatives as they might be easier to implement or more maintainable in the long term. + +### Stay engaged + +After submitting your feature request, monitor the discussion. Answer questions from maintainers and provide clarification when needed. This helps move your request forward. + +## Contributing the feature yourself + +If you want to implement the feature yourself, feel free to create a pull request following the [pull request guidelines](create-pull-request.md). + +We welcome community contributions and appreciate your help making Grafana better! diff --git a/docs/sources/administration/migration-guide/cloud-migration-assistant.md b/docs/sources/administration/migration-guide/cloud-migration-assistant.md index 439e04c282d..b4a1e5b89fb 100644 --- a/docs/sources/administration/migration-guide/cloud-migration-assistant.md +++ b/docs/sources/administration/migration-guide/cloud-migration-assistant.md @@ -14,10 +14,10 @@ weight: 400 The Grafana Cloud Migration Assistant, generally available from Grafana v12.0, automatically migrates resources from your Grafana OSS/Enterprise instance to Grafana Cloud. It provides the following functionality: - Securely connect your self-managed instance to a Grafana Cloud instance. -- Seamlessly migrate resources such as dashboards, data sources, and folders to your cloud instance in a few easy steps. +- Migrate resources such as dashboards, data sources, and folders to your cloud instance in a few easy steps. - View the migration status of your resources in real-time. -Some of the benefits of the migration assistant are: +Some benefits of the migration assistant are: Ease of use : Follow the steps provided by the UI to easily migrate all your resources to Grafana Cloud without using Grafana APIs or scripts. @@ -44,7 +44,7 @@ The following resources are supported by the migration assistant: To use the Grafana migration assistant, you need: -- Grafana v11.2 or above with the `onPremToCloudMigrations` feature toggle enabled. In Grafana 11.5, this is enabled by default. For more information on how to enable a feature toggle, refer to [Configure feature toggles](https://grafana.com/docs/grafana//setup-grafana/configure-grafana/feature-toggles/#configure-feature-toggles). +- A self-managed Grafana instance version v11.2 or above with the `onPremToCloudMigrations` feature toggle enabled. In Grafana 11.5, this is enabled by default. For more information on how to enable a feature toggle, refer to [Configure feature toggles](https://grafana.com/docs/grafana//setup-grafana/configure-grafana/feature-toggles/#configure-feature-toggles). - A [Grafana Cloud Stack](https://grafana.com/docs/grafana-cloud/get-started/) you intend to migrate your resources to. - [`Admin`](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/cloud-roles/) access to the Grafana Cloud Stack. To check your access level, go to `https://grafana.com/orgs//members`. - [Grafana server administrator](https://grafana.com/docs/grafana//administration/roles-and-permissions/#grafana-server-administrators) access to your existing Grafana OSS/Enterprise instance. To check your access level, go to `https:///admin/users`. @@ -64,7 +64,7 @@ In Grafana Enterprise, the server administrator has access to the migration assi ### Grant access in Grafana Enterprise -{{< admonition type="important">}} +{{< admonition type="note" >}} You must [configure RBAC](https://grafana.com/docs/grafana//administration/roles-and-permissions/access-control/configure-rbac/) before you can grant other administrators access to the Grafana Migration Assistant. {{< /admonition >}} diff --git a/docs/sources/datasources/mssql/configure/index.md b/docs/sources/datasources/mssql/configure/index.md index 3329d585159..4d3eb60ee81 100644 --- a/docs/sources/datasources/mssql/configure/index.md +++ b/docs/sources/datasources/mssql/configure/index.md @@ -74,6 +74,21 @@ refs: destination: /docs/grafana//datasources/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana//datasources/ + configure-grafana-azure-auth: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-access/configure-authentication/azuread/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-access/configure-authentication/azuread/ + configure-grafana-azure: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#azure + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#azure + configure-grafana-azure-auth-scopes: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana --- # Configure the Microsoft SQL Server data source @@ -138,14 +153,19 @@ If you're using an older version of Microsoft SQL Server like 2008 and 2008R2, y **Authentication:** -| Authentication Type | Description | Credentials / Fields | -| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| **SQL Server Authentication** | Default method to connect to MSSQL. Use a SQL Server or Windows login in `DOMAIN\User` format. | - **Username**: SQL Server username
- **Password**: SQL Server password | -| **Windows Authentication**
(Integrated Security) | Uses the logged-in Windows user's credentials via single sign-on. Available only when SQL Server allows Windows Authentication. | No input required; uses the logged-in Windows user's credentials | -| **Windows AD**
(Username/Password) | Authenticates a domain user with their Active Directory username and password. | - **Username**: `user@example.com`
- **Password**: Active Directory password | -| **Windows AD**
(Keytab) | Authenticates a domain user using a keytab file. | - **Username**: `user@example.com`
- **Keytab file path**: Path to your keytab file | -| **Windows AD**
(Credential Cache) | Uses a Kerberos credential cache already loaded in memory (e.g., from a prior `kinit` command). No file needed. | - **Credential cache path**: Path to in-memory credential (e.g., `/tmp/krb5cc_1000`) | -| **Windows AD**
(Credential Cache File) | Authenticates a domain user using a credential cache file (`.ccache`). | - **Username**: `user@example.com`
- **Credential cache file path**: e.g., `/home/grot/cache.json` | +{{< admonition type="note" >}} +In order to use Azure AD Authentication the toggle `auth.azure_auth_enabled` must be set to `true` in the Grafana configuration file. +{{< /admonition >}} + +| Authentication Type | Description | Credentials / Fields | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **SQL Server Authentication** | Default method to connect to MSSQL. Use a SQL Server or Windows login in `DOMAIN\User` format. | - **Username**: SQL Server username
- **Password**: SQL Server password | +| **Windows Authentication**
(Integrated Security) | Uses the logged-in Windows user's credentials via single sign-on. Available only when SQL Server allows Windows Authentication. | No input required; uses the logged-in Windows user's credentials | +| **Windows AD**
(Username/Password) | Authenticates a domain user with their Active Directory username and password. | - **Username**: `user@example.com`
- **Password**: Active Directory password | +| **Windows AD**
(Keytab) | Authenticates a domain user using a keytab file. | - **Username**: `user@example.com`
- **Keytab file path**: Path to your keytab file | +| **Windows AD**
(Credential Cache) | Uses a Kerberos credential cache already loaded in memory (e.g., from a prior `kinit` command). No file needed. | - **Credential cache path**: Path to in-memory credential (e.g., `/tmp/krb5cc_1000`) | +| **Windows AD**
(Credential Cache File) | Authenticates a domain user using a credential cache file (`.ccache`). | - **Username**: `user@example.com`
- **Credential cache file path**: e.g., `/home/grot/cache.json` | +| **Azure Entra ID (formerly Azure AD) Authentication** | Authenticates the data source using Azure authentication methods. | Details on the supported authentication methods and how to configure them can be found in the [Azure authentication section](./index.md#azure-entra-id-formerly-azure-ad-authentication). | **Additional settings:** @@ -185,6 +205,123 @@ After configuring your MSSQL data source options, click **Save & test** at the b **Database Connection OK** +### Azure Entra ID (formerly Azure AD) Authentication + +The following Azure authentication methods are supported: + +- Current User authentication +- App Registration +- Managed Identity +- Azure Entra Password + +The Azure SQL Server that you are connecting to should support Azure Entra authentication to support adding the App Registration as a user in the database. For configuration details, refer to the [Azure SQL documentation](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-configure?view=azuresql&tabs=azure-portal). + +#### Current User authentication + +This is the recommended authentication mechanism when working with SQL Server instances that are hosted in Azure. It allows users to be authenticated to and query the database using their own credentials rather than long-lived credentials. + +This authentication method requires your Grafana instance to be configured with Azure Entra ID (formerly Active Directory) authentication for login. With Azure Entra ID login, this method can be used to forward the currently logged in user’s credentials to the data source. The users credentials will then be used when requesting data from the data source. For details on how to configure your Grafana instance using Azure Entra refer to the [documentation](ref:configure-grafana-azure-auth). + +{{< admonition type="note" >}} +Additional configuration is required to ensure that the App Registration used to login a user via Azure provides an access token with the permissions required by the data source. + +The App Registration must be configured to issue both **Access Tokens** and **ID Tokens**. + +1. In the Azure Portal, open the App Registration that requires configuration. +2. Select **Authentication** in the side menu. +3. Under **Implicit grant and hybrid flows** check both the **Access tokens** and **ID tokens** boxes. +4. Save the changes to ensure the App Registration is updated. + +The App Registration must also be configured with additional **API Permissions** to provide authenticated users with access to the APIs utilised by the data source. + +1. In the Azure Portal, open the App Registration that requires configuration. +1. Select **API Permissions** in the side menu. +1. Ensure the `openid`, `profile`, `email`, and `offline_access` permissions are present under the **Microsoft Graph** section. If not, they must be added. +1. Select **Add a permission** and choose the following permissions. They must be added individually. Refer to the [Azure documentation](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-configure-app-access-web-apis) for more information. + - Select **APIs my organization uses** > Search for **Azure SQL** and select it > **Delegated permissions** > `user_impersonation` > **Add permissions** + +After all permissions have been added, the Azure authentication section in Grafana must be updated. The `scopes` section must be updated to include the `.default` scope to ensure that a token with access to all APIs declared on the App Registration is requested by Grafana. After updated the scopes value should equal: `.default openid email profile`. +{{< /admonition >}} + +This method of authentication doesn't inherently support all backend functionality as a user's credentials won't be in scope. Affected functionality includes alerting, reporting, and recorded queries. Also, note that query and resource caching is disabled by default for data sources using current user authentication. + +**To enable current user authentication for Grafana:** + +1. Set the `user_identity_enabled` flag in the `[azure]` section of the [Grafana server configuration](ref:configure-grafana-azure). + + ```ini + [azure] + user_identity_enabled = true + ``` + +2. In the SQL Server data source configuration, set **Authentication** to **Azure AD Authentication** and the Azure Authentication type to **Current User**. + +### App Registration + +You must create an app registration and service principal in Azure Entra to authenticate the data source. +For configuration details, refer to the [Azure documentation for service principals](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal#get-tenant-and-app-id-values-for-signing-in). + +After the app registration has been created, make note of the tenant ID, client ID, and client secret. Take the following steps to add the app registration as a SQL user: + +1. Connect to your Azure SQL database as a user with administrative permissions (the user used here must have the ability to read your Azure Entra directory e.g. by possessing the `Directory Readers` role). +2. Run `CREATE USER [$IDENTITY_NAME] FROM EXTERNAL PROVIDER;`, substituting `IDENTITY_NAME` with the app registration name. +3. Grant the created user the appropriate level of permissions for your use-case. It is recommended that users configured for data sources only have reader permissions. + +After the appropriate permissions have been granted, configure the SQL Server data source to use the app registration: + +1. In the SQL Server data source configuration, set **Authentication** to **Azure AD Authentication** and the Azure Authentication type to **App Registration**. +2. Set the **Azure Cloud** value to the correct value. If you are using the Azure public cloud this will be **Azure**. +3. Set the **Directory (tenant) ID**, **Application (client) ID**, and **Client Secret** values to those for your app registration. + +### Managed Identity + +{{< admonition type="note" >}} +Managed Identity is available only in [Azure Managed Grafana](https://azure.microsoft.com/en-us/products/managed-grafana) or Grafana OSS/Enterprise when deployed in Azure. It is not available in Grafana Cloud. +{{< /admonition >}} + +You can use managed identity to configure SQL Server in Grafana if you host Grafana in Azure (such as an App Service or with Azure Virtual Machines) and have managed identity enabled on your VM. +This lets you securely authenticate data sources without manually configuring credentials via Azure AD App Registrations. +For details on Azure managed identities, refer to the [Azure documentation](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview). + +**To enable managed identity for Grafana:** + +1. Set the `managed_identity_enabled` flag in the `[azure]` section of the [Grafana server configuration](ref:configure-grafana-azure). + + ```ini + [azure] + managed_identity_enabled = true + ``` + +2. In the SQL Server data source configuration, set **Authentication** to **Azure AD Authentication** and the Azure Authentication type to **Managed Identity**. + + This hides the directory ID, application ID, and client secret fields, and the data source uses managed identity to authenticate to SQL Server. + +3. You can set the `managed_identity_client_id` field in the `[azure]` section of the [Grafana server configuration](ref:configure-grafana-azure) to allow a user-assigned managed identity to be used instead of the default system-assigned identity. + +Ensure that the managed identity used is added to your Azure SQL instance as a user. + +### Azure Entra Password + +{{< admonition type="warning" >}} +Azure Entra Password is not a recommended authentication mechanism as it requires configuration using a single users password. Consider an alternative authentication method such as current user authentication or app registration. +{{< /admonition >}} + +You can connect to an Azure SQL database using the username and password of a user that has permissions in the desired database. This also requires an app registration to be configured with access to the database. + +**To enable Azure Entra password for Grafana:** + +1. Set the `azure_entra_password_credentials_enabled` flag in the `[azure]` section of the [Grafana server configuration](ref:configure-grafana-azure). + + ```ini + [azure] + azure_entra_password_credentials_enabled = true + ``` + +2. In the SQL Server data source configuration, set **Authentication** to **Azure AD Authentication** and the Azure Authentication type to **Azure Entra Password**. +3. Set the **User ID** value to the username of the user in the Azure SQL database. +4. Set the **Application Client ID** to the client ID of the app registration that has been added to the Azure SQL database +5. Set the **Password** value to the password of the user in the Azure SQL database. + ### Min time interval The **Min time interval** setting defines a lower limit for the [`$__interval`](ref:add-template-variables-interval) and [`$__interval_ms`][add-template-variables-interval_ms] variables. diff --git a/docs/sources/developer-resources/api-reference/http-api/dashboard.md b/docs/sources/developer-resources/api-reference/http-api/dashboard.md index 5f28d4c54b1..2cc7aa84bb3 100644 --- a/docs/sources/developer-resources/api-reference/http-api/dashboard.md +++ b/docs/sources/developer-resources/api-reference/http-api/dashboard.md @@ -767,12 +767,12 @@ Status Codes: Deletes a dashboard via the dashboard uid. -- namespace: to read more about the namespace to use, see the [API overview](https://grafana.com/docs/grafana//developers/http_api/apis/). -- uid: the unique identifier of the dashboard to update. this will be the _name_ in the dashboard response +- **`namespace`**: To read more about the namespace to use, see the [API overview](https://grafana.com/docs/grafana//developers/http_api/apis/). +- **`uid`**: The unique identifier of the dashboard to update. This is the `metadata.name` field in the dashboard response and _not_ the `metadata.uid` field. **Required permissions** -See note in the [introduction]({{< ref "#dashboard-api" >}}) for an explanation. +See note in the [introduction](#new-dashboard-apis) for an explanation. | Action | Scope | diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index c81d24d0758..13c2b77fd2f 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -1933,6 +1933,10 @@ The initial delay before retrying a failed alert evaluation. Default is `1s`. This value is the starting point for exponential backoff. +#### `initialization_timeout` + +Allows the context deadline for the `AlertNG` service to be configurable. The default timeout is 30s. + #### `max_retry_delay` The maximum delay between retries during exponential backoff. Default is `4s`. 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 c05ecd45bd9..f0f12cce1b6 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -66,6 +66,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `grafanaAssistantInProfilesDrilldown` | Enables integration with Grafana Assistant in Profiles Drilldown | Yes | | `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes | | `tabularNumbers` | Use fixed-width numbers globally in the UI | | +| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | Yes | | `tempoSearchBackendMigration` | Run search queries through the tempo backend | | ## Public preview feature toggles @@ -95,9 +96,9 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `localeFormatPreference` | Specifies the locale so the correct format for numbers and dates can be shown | | `logsPanelControls` | Enables a control component for the logs panel in Explore | | `interactiveLearning` | Enables the interactive learning app | -| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | | `newVizSuggestions` | Enable new visualization suggestions | | `preventPanelChromeOverflow` | Restrict PanelChrome contents with overflow: hidden; | +| `newPanelPadding` | Increases panel padding globally | | `transformationsEmptyPlaceholder` | Show transformation quick-start cards in empty transformations state | ## Development feature toggles diff --git a/docs/sources/visualizations/panels-visualizations/query-transform-data/sql-expressions/index.md b/docs/sources/visualizations/panels-visualizations/query-transform-data/sql-expressions/index.md index ded072448ce..a0ba0e55fef 100644 --- a/docs/sources/visualizations/panels-visualizations/query-transform-data/sql-expressions/index.md +++ b/docs/sources/visualizations/panels-visualizations/query-transform-data/sql-expressions/index.md @@ -1,6 +1,6 @@ --- aliases: - - ../../panels-visualizations/query-transform-data/sql-expressions/ # /docs/grafana/next/panels-visualizations/query-transform-data/sql-expressions/ + - ../../../panels-visualizations/query-transform-data/sql-expressions/ # /docs/grafana/next/panels-visualizations/query-transform-data/sql-expressions/ labels: products: - cloud diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-conditional-rendering-load-change.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-conditional-rendering-load-change.spec.ts index 56c46ad8d68..a6f52738ff8 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-conditional-rendering-load-change.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-conditional-rendering-load-change.spec.ts @@ -4,6 +4,8 @@ import { test, expect, E2ESelectorGroups, DashboardPage, DashboardPageArgs } fro import testDashboard from '../dashboards/DashboardWithAllConditionalRendering.json'; +import { checkRepeatedPanelTitles } from './utils'; + test.use({ featureToggles: { kubernetesDashboards: true, @@ -93,7 +95,7 @@ test.describe('Dashboard - Conditional Rendering - Load and Change', { tag: ['@d test.afterAll(async ({ request }) => { if (uid) { - await request.delete(`/apis/dashboard.grafana.app/v1beta1/namespaces/default/dashboards/${uid}`); + await request.delete(`/apis/dashboard.grafana.app/v1beta1/namespaces/stacks-12345/dashboards/${uid}`); } }); @@ -407,4 +409,52 @@ test.describe('Dashboard - Conditional Rendering - Load and Change', { tag: ['@d await expect(getTabShowNotMatches(dashboardPage, selectors)).toBeVisible(); await expect(getTabHideNotMatches(dashboardPage, selectors)).not.toBeVisible(); }); + + test.describe('Variable repeat', () => { + const repeatOptions = ['a', 'b', 'c']; + + async function failTestDataRequestForOption(page: Page, option: string) { + await page.route(/\/api\/ds\/query\?.*\bds_type=grafana-testdata-datasource/, async (route) => { + const rawPostData = route.request().postData(); + if (!rawPostData) { + return; + } + + // the first panel query has a label set to the current variable value + if (JSON.parse(rawPostData).queries[0].labels === `key=${option}`) { + await route.fulfill({ status: 500, body: '{}' }); + } else { + await route.continue(); + } + }); + } + + test('Hide when equals, hide when no data', async ({ page, gotoDashboardPage, selectors }) => { + const dashboardPage = await loadDashboard(page, gotoDashboardPage); + + await getTab(dashboardPage, selectors, 'repeated items').click(); + + const optionForHiddenPanels = repeatOptions[0]; + + await failTestDataRequestForOption(page, optionForHiddenPanels); + + await checkRepeatedPanelTitles( + dashboardPage, + selectors, + 'Hide panel - ', + [ + `custom variable equals ${optionForHiddenPanels} (current = ${optionForHiddenPanels})`, + `no data (current = ${optionForHiddenPanels})`, + ], + true + ); + + const optionsForVisiblePanels = repeatOptions.slice(1); + + await checkRepeatedPanelTitles(dashboardPage, selectors, 'Hide panel - ', [ + ...optionsForVisiblePanels.map((o) => `custom variable equals ${optionForHiddenPanels} (current = ${o})`), + ...optionsForVisiblePanels.map((o) => `no data (current = ${o})`), + ]); + }); + }); }); diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts index ffdd52e24f6..f077f48a7e0 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts @@ -414,13 +414,13 @@ test.describe( ).toBeVisible(); // Go back to dashboard options - await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click({ force: true }); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); // Expand layouts section await page.getByLabel('Expand Group layout category').click(); // Select tabs layout - await page.getByLabel('Tabs').click(); + await page.getByLabel('layout-selection-option-Tabs').click(); await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New row'))).toBeVisible(); await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New row 1'))).toBeVisible(); @@ -518,14 +518,14 @@ test.describe( await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput) .fill('Test row 1'); - await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click(); // clear the title input to simulate no title and click away to trigger onBlur await dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('Test row 1')).click(); await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput) .fill(''); - await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click(); // title should be set to a default name await expect( @@ -543,14 +543,14 @@ test.describe( await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput) .fill('Test row 2'); - await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click(); // clear the title input to simulate no title and click away to trigger onBlur await dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('Test row 2')).click(); await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput) .fill(''); - await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click(); // title should be set to a default name + 1 to avoid duplicates await expect( @@ -755,13 +755,13 @@ test.describe( await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab 2'))).toBeVisible(); // Go back to dashboard options - await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click({ force: true }); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); // Expand layouts section await page.getByLabel('Expand Group layout category').click(); // Select rows layout - await page.getByLabel('Rows').click(); + await page.getByLabel('layout-selection-option-Rows').click(); await dashboardPage .getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New tab 1')) @@ -903,14 +903,14 @@ test.describe( await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput) .fill('Test tab 1'); - await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click(); // clear the title input to simulate no title and click away to trigger onBlur await dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('Test tab 1')).click(); await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput) .fill(''); - await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click(); // title should be set to a default name await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab'))).toBeVisible(); @@ -923,14 +923,14 @@ test.describe( await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput) .fill('Test tab 2'); - await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click(); // clear the title input to simulate no title and click away to trigger onBlur await dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('Test tab 2')).click(); await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput) .fill(''); - await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click(); // title should be set to a default name + 1 to avoid duplicates await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab 1'))).toBeVisible(); diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-outline.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-outline.spec.ts index ef38379f006..00a84a188a5 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-outline.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-outline.spec.ts @@ -21,6 +21,7 @@ test.describe( const dashboardPage = await gotoDashboardPage({ uid: PAGE_UNDER_TEST }); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click(); // Should be able to click Variables item in outline to see add variable button await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Variables')).click(); @@ -28,6 +29,8 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.addVariableButton) ).toBeVisible(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click(); + // Clicking a panel should scroll that panel in view await expect(page.getByText('Dashboard panel 48')).toBeHidden(); await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Panel #48')).click(); diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-add-panel.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-add-panel.spec.ts index ffea8243a2f..c2af96349cf 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-add-panel.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-add-panel.spec.ts @@ -22,6 +22,9 @@ test.describe( const dashboardPage = await gotoDashboardPage({ uid: PAGE_UNDER_TEST }); await expect(page.getByText(DASHBOARD_NAME)).toBeVisible(); + const undockButton = page.getByRole('button', { name: 'Undock menu' }); + await undockButton.click(); + await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); await page.evaluate(() => { diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-edit-custom-variables.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-edit-custom-variables.spec.ts index 24c61e5e960..4715dfc7128 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-edit-custom-variables.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-edit-custom-variables.spec.ts @@ -199,6 +199,7 @@ test.describe( .click(); // Open the modal editor in the side pane + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click(); await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.node('Variables')).click(); await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('foo')).click(); await openModal(dashboardPage, selectors); diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts index 087551a55cd..66d634eefa7 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts @@ -32,9 +32,9 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(3); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await page.getByLabel('Expand Panel layout category').click(); - - await page.getByLabel('Auto grid').click(); + await page.getByLabel('layout-selection-option-Auto grid').click(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) @@ -50,6 +50,7 @@ test.describe( ).toHaveCount(3); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await checkAutoGridLayoutInputs(dashboardPage, selectors); }); @@ -63,9 +64,10 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(3); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await page.getByLabel('Expand Panel layout category').click(); - await page.getByLabel('Auto grid').click(); + await page.getByLabel('layout-selection-option-Auto grid').click(); // Get initial positions - standard width should have panels on different rows const firstPanelTop = await getPanelTop(dashboardPage, selectors); @@ -98,6 +100,7 @@ test.describe( await page.reload(); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await expect( dashboardPage.getByGrafanaSelector( @@ -123,9 +126,10 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(3); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await page.getByLabel('Expand Panel layout category').click(); - await page.getByLabel('Auto grid').click(); + await page.getByLabel('layout-selection-option-Auto grid').click(); await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth) @@ -134,7 +138,7 @@ test.describe( await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth) - .fill('900'); + .fill('1100'); await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth) .blur(); @@ -148,12 +152,13 @@ test.describe( await verifyPanelsStackedVertically(dashboardPage, selectors); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await expect( dashboardPage.getByGrafanaSelector( selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth ) - ).toHaveValue('900'); + ).toHaveValue('1100'); await verifyPanelsStackedVertically(dashboardPage, selectors); @@ -180,9 +185,9 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(3); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await page.getByLabel('Expand Panel layout category').click(); - - await page.getByLabel('Auto grid').click(); + await page.getByLabel('layout-selection-option-Auto grid').click(); await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns) @@ -198,6 +203,7 @@ test.describe( await verifyPanelsStackedVertically(dashboardPage, selectors); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns) @@ -215,9 +221,9 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(3); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await page.getByLabel('Expand Panel layout category').click(); - - await page.getByLabel('Auto grid').click(); + await page.getByLabel('layout-selection-option-Auto grid').click(); const regularRowHeight = await getPanelHeight(dashboardPage, selectors); @@ -250,6 +256,7 @@ test.describe( }).toPass(); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight) @@ -270,9 +277,9 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(3); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await page.getByLabel('Expand Panel layout category').click(); - - await page.getByLabel('Auto grid').click(); + await page.getByLabel('layout-selection-option-Auto grid').click(); const regularRowHeight = await getPanelHeight(dashboardPage, selectors); @@ -303,6 +310,7 @@ test.describe( }).toPass(); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await expect( dashboardPage.getByGrafanaSelector( @@ -327,9 +335,9 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(3); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await page.getByLabel('Expand Panel layout category').click(); - - await page.getByLabel('Auto grid').click(); + await page.getByLabel('layout-selection-option-Auto grid').click(); // Set narrow column width first to ensure panels fit horizontally await dashboardPage @@ -357,6 +365,7 @@ test.describe( }).toPass(); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.fillScreen) diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-repeats-auto-grid.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-repeats-auto-grid.spec.ts index 28ca51e3b28..7cf22108e1e 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-repeats-auto-grid.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-repeats-auto-grid.spec.ts @@ -37,9 +37,10 @@ test.describe( }, () => { test('can enable repeats', async ({ dashboardPage, selectors, page }) => { - await importTestDashboard(page, selectors, 'Auto grid repeats - add repeats'); + await importTestDashboard(page, selectors, 'Auto-grid repeats - add repeats'); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await switchToAutoGrid(page); @@ -70,11 +71,12 @@ test.describe( await importTestDashboard( page, selectors, - 'Auto grid repeats - update on variable change', + 'Auto-grid repeats - update on variable change', JSON.stringify(testV2DashWithRepeats) ); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await switchToAutoGrid(page); await saveDashboard(dashboardPage, page, selectors); @@ -113,6 +115,7 @@ test.describe( ); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await switchToAutoGrid(page); @@ -138,11 +141,13 @@ test.describe( await importTestDashboard( page, selectors, - 'Auto grid repeats - update through panel editor', + 'Auto-grid repeats - update through panel editor', JSON.stringify(testV2DashWithRepeats) ); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); + await switchToAutoGrid(page); await saveDashboard(dashboardPage, page, selectors); await page.reload(); @@ -202,11 +207,13 @@ test.describe( await importTestDashboard( page, selectors, - 'Auto grid repeats - update through directly loaded panel editor', + 'Auto-grid repeats - update through directly loaded panel editor', JSON.stringify(testV2DashWithRepeats) ); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); + await switchToAutoGrid(page); await saveDashboard(dashboardPage, page, selectors); @@ -257,11 +264,12 @@ test.describe( await importTestDashboard( page, selectors, - 'Auto grid repeats - move repeated panels', + 'Auto-grid repeats - move repeated panels', JSON.stringify(testV2DashWithRepeats) ); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); await switchToAutoGrid(page); @@ -304,11 +312,13 @@ test.describe( await importTestDashboard( page, selectors, - 'Auto grid repeats - move repeated panels', + 'Auto-grid repeats - move repeated panels 2', JSON.stringify(testV2DashWithRepeats) ); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); + await switchToAutoGrid(page); await saveDashboard(dashboardPage, page, selectors); await page.reload(); @@ -332,9 +342,7 @@ test.describe( const repeatedPanelUrl = page.url(); - await dashboardPage - .getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton) - .click(); + await page.keyboard.press('Escape'); await dashboardPage .getByGrafanaSelector(selectors.components.Panels.Panel.title(`${repeatTitleBase}${repeatOptions.at(0)}`)) @@ -367,11 +375,13 @@ test.describe( await importTestDashboard( page, selectors, - 'Auto grid repeats - view embedded repeated panel', + 'Auto-grid repeats - view embedded repeated panel', JSON.stringify(testV2DashWithRepeats) ); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); + await switchToAutoGrid(page); await saveDashboard(dashboardPage, page, selectors); await page.reload(); @@ -393,11 +403,13 @@ test.describe( await importTestDashboard( page, selectors, - 'Auto grid repeats - remove repeats', + 'Auto-grid repeats - remove repeats', JSON.stringify(testV2DashWithRepeats) ); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); + await switchToAutoGrid(page); await saveDashboard(dashboardPage, page, selectors); await page.reload(); @@ -453,5 +465,5 @@ test.describe( async function switchToAutoGrid(page: Page) { await page.getByLabel('Expand Panel layout category').click(); - await page.getByLabel('Auto grid').click(); + await page.getByLabel('layout-selection-option-Auto grid').click(); } diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-repeats-custom-grid.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-repeats-custom-grid.spec.ts index 2d235571f97..8cc1f552377 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-repeats-custom-grid.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-repeats-custom-grid.spec.ts @@ -303,9 +303,7 @@ test.describe( const repeatedPanelUrl = page.url(); - await dashboardPage - .getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton) - .click(); + await page.keyboard.press('Escape'); await dashboardPage .getByGrafanaSelector(selectors.components.Panels.Panel.title(`${repeatTitleBase}${repeatOptions.at(0)}`)) diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-repeats-tabs-layout.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-repeats-tabs-layout.spec.ts index 100ec864b0d..f78bbc5e08e 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-repeats-tabs-layout.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-repeats-tabs-layout.spec.ts @@ -316,9 +316,7 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toBeVisible(); - await dashboardPage - .getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton) - .click(); + await page.keyboard.press('Escape'); // repeated panel in original tab repeat await dashboardPage @@ -341,9 +339,7 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Tab 1 - Row 2 - Panel repeat 2')) ).toBeVisible(); - await dashboardPage - .getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton) - .click(); + await page.keyboard.press('Escape'); // repeated panel in repeated tab await dashboardPage diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-title-description.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-title-description.spec.ts index 67dc17f648b..d9a82364a70 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-title-description.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-title-description.spec.ts @@ -21,11 +21,7 @@ test.describe( const dashboardPage = await gotoDashboardPage({ uid: PAGE_UNDER_TEST }); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); - - // Check that current dashboard title is visible in breadcrumb - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Breadcrumbs.breadcrumb('Annotation filtering')) - ).toBeVisible(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); const titleInput = page.locator('[aria-label="dashboard-options Title field property editor"] input'); await expect(titleInput).toHaveValue('Annotation filtering'); diff --git a/e2e-playwright/dashboard-new-layouts/utils.ts b/e2e-playwright/dashboard-new-layouts/utils.ts index da629ac8141..83508063ef5 100644 --- a/e2e-playwright/dashboard-new-layouts/utils.ts +++ b/e2e-playwright/dashboard-new-layouts/utils.ts @@ -48,7 +48,9 @@ export const flows = { }, async newEditPaneVariableClick(dashboardPage: DashboardPage, selectors: E2ESelectorGroups) { await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click(); await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Variables')).click(); + await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.dockToggle).click(); await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.addVariableButton) .click(); @@ -96,12 +98,18 @@ export async function checkRepeatedPanelTitles( dashboardPage: DashboardPage, selectors: E2ESelectorGroups, title: string, - options: Array + options: Array, + expectHidden = false ) { for (const option of options) { - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(`${title}${option}`)) - ).toBeVisible(); + const titleLocator = dashboardPage.getByGrafanaSelector( + selectors.components.Panels.Panel.title(`${title}${option}`) + ); + if (expectHidden) { + await expect(titleLocator).toBeHidden(); + } else { + await expect(titleLocator).toBeVisible(); + } } } diff --git a/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts b/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts index 48a3c00359f..6765d299e53 100644 --- a/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts @@ -9,7 +9,8 @@ const NUM_NESTED_DASHBOARDS = 60; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts b/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts index af0d65adc29..8ae318bcefa 100644 --- a/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts @@ -4,7 +4,8 @@ import testDashboard from '../dashboards/TestDashboard.json'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts index 3b3c6d26fac..a97a04a14b8 100644 --- a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts @@ -6,7 +6,8 @@ test.use({ featureToggles: { scenes: true, sharingDashboardImage: true, // Enable the export image feature - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts index 18145a6c739..428193ab5fa 100644 --- a/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts index dd420093696..b0ecf44f9f1 100644 --- a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts b/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts index 530c74c2485..0a982e148b5 100644 --- a/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts @@ -4,7 +4,8 @@ import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts b/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts index 07aacd5b3fb..20f455ea3a8 100644 --- a/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts @@ -4,7 +4,8 @@ import testDashboard from '../dashboards/DashboardLiveTest.json'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts index 5c52138ee08..fd5dc979d81 100644 --- a/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts b/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts index 82a2670ef9e..c59e323076d 100644 --- a/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts index d0a6652ccc6..3398e9aaa35 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts @@ -3,7 +3,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { scenes: true, - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts index 98d394ccc91..26f8b85d13e 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts @@ -3,7 +3,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { scenes: true, - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts index 3ac515e36e6..1a7e03d6243 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts @@ -5,7 +5,8 @@ import { SnapshotCreateResponse } from '../../public/app/features/dashboard/serv test.use({ featureToggles: { scenes: true, - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts b/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts index ded0abd61ef..78c35dc5de7 100644 --- a/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_UID = 'HYaGDGIMk'; test.use({ timezoneId: 'Pacific/Easter', featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts index 2d56cd14512..937224290b0 100644 --- a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts @@ -7,7 +7,8 @@ const TIMEZONE_DASHBOARD_UID = 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts b/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts index f7a5c4b9dec..4ffd65b83a3 100644 --- a/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts @@ -16,7 +16,8 @@ test.use({ origins: [], }, featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts b/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts index 342b912a48a..f457eddf19e 100644 --- a/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts +++ b/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts @@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/general-dashboards.spec.ts b/e2e-playwright/dashboards-suite/general-dashboards.spec.ts index 3cdd27ed954..99f84cb6d9f 100644 --- a/e2e-playwright/dashboards-suite/general-dashboards.spec.ts +++ b/e2e-playwright/dashboards-suite/general-dashboards.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/import-dashboard.spec.ts b/e2e-playwright/dashboards-suite/import-dashboard.spec.ts index 7ec014c728b..5fdca6954aa 100644 --- a/e2e-playwright/dashboards-suite/import-dashboard.spec.ts +++ b/e2e-playwright/dashboards-suite/import-dashboard.spec.ts @@ -4,7 +4,8 @@ import testDashboard from '../dashboards/TestDashboard.json'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts b/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts index c2f45459431..ca06e31528a 100644 --- a/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts +++ b/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts b/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts index 3e28a6f49bc..fa0b5fc1bfd 100644 --- a/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts b/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts index cc3bda551d9..c14a952e1d9 100644 --- a/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts @@ -52,7 +52,8 @@ async function assertPreviewValues( test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts b/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts index 988d79f78ca..cc19e67cda4 100644 --- a/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts b/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts index 0310f6c3500..d76b5291c42 100644 --- a/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts @@ -18,7 +18,8 @@ async function assertPreviewValues( test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts index 212f0ecd018..088f4bd9b12 100644 --- a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Templating - Nested Template Variables'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts b/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts index 5c49254284f..c669dc563c4 100644 --- a/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts @@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts b/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts index 920bc343275..a55f14b8643 100644 --- a/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts b/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts index 3dc9cfcbcc1..bb188c87e8d 100644 --- a/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts b/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts index 31a60079444..e31c5792062 100644 --- a/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts b/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts index 556ec524713..53290345e73 100644 --- a/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts +++ b/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts @@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/snapshot-create.spec.ts b/e2e-playwright/dashboards-suite/snapshot-create.spec.ts index 78c6a6a44c9..123aa7f3279 100644 --- a/e2e-playwright/dashboards-suite/snapshot-create.spec.ts +++ b/e2e-playwright/dashboards-suite/snapshot-create.spec.ts @@ -4,7 +4,8 @@ const DASHBOARD_UID = 'ZqZnVvFZz'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts b/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts index 813815f7f39..1d8fd32ff06 100644 --- a/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts +++ b/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts @@ -4,7 +4,8 @@ const DASHBOARD_UID = 'yBCC3aKGk'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/textbox-variables.spec.ts b/e2e-playwright/dashboards-suite/textbox-variables.spec.ts index c992456a1bc..4fb56ef8b8e 100644 --- a/e2e-playwright/dashboards-suite/textbox-variables.spec.ts +++ b/e2e-playwright/dashboards-suite/textbox-variables.spec.ts @@ -6,7 +6,8 @@ const PAGE_UNDER_TEST = 'AejrN1AMz'; test.use({ featureToggles: { - kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', + kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', + kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards/DashboardWithAllConditionalRendering.json b/e2e-playwright/dashboards/DashboardWithAllConditionalRendering.json index f0d81daa233..522a5d670ac 100644 --- a/e2e-playwright/dashboards/DashboardWithAllConditionalRendering.json +++ b/e2e-playwright/dashboards/DashboardWithAllConditionalRendering.json @@ -3308,6 +3308,170 @@ } } }, + "panel-37": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "group": "", + "kind": "DataQuery", + "spec": {}, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 37, + "links": [], + "title": "Hide panel - custom variable equals a (current = ${myCustomVariable})", + "vizConfig": { + "group": "text", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "", + "mode": "markdown" + } + }, + "version": "12.2.0-pre" + } + } + }, + "panel-38": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "PD8C576611E62080A" + }, + "group": "grafana-testdata-datasource", + "kind": "DataQuery", + "spec": { + "labels": "key=$myCustomVariable", + "scenarioId": "random_walk", + "seriesCount": 1 + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "", + "id": 38, + "links": [], + "title": "Hide panel - no data (current = ${myCustomVariable})", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "12.2.0-pre" + } + } + }, "panel-4": { "kind": "Panel", "spec": { @@ -5091,6 +5255,80 @@ }, "title": "Tab - hide - time range <7d" } + }, + { + "kind": "TabsLayoutTab", + "spec": { + "layout": { + "kind": "AutoGridLayout", + "spec": { + "columnWidthMode": "standard", + "items": [ + { + "kind": "AutoGridLayoutItem", + "spec": { + "conditionalRendering": { + "kind": "ConditionalRenderingGroup", + "spec": { + "condition": "and", + "items": [ + { + "kind": "ConditionalRenderingVariable", + "spec": { + "operator": "equals", + "value": "a", + "variable": "myCustomVariable" + } + } + ], + "visibility": "hide" + } + }, + "element": { + "kind": "ElementReference", + "name": "panel-37" + }, + "repeat": { + "mode": "variable", + "value": "myCustomVariable" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "conditionalRendering": { + "kind": "ConditionalRenderingGroup", + "spec": { + "condition": "and", + "items": [ + { + "kind": "ConditionalRenderingData", + "spec": { + "value": false + } + } + ], + "visibility": "hide" + } + }, + "element": { + "kind": "ElementReference", + "name": "panel-38" + }, + "repeat": { + "mode": "variable", + "value": "myCustomVariable" + } + } + } + ], + "maxColumnCount": 3, + "rowHeightMode": "standard" + } + }, + "title": "Tab - repeated items" + } } ] } @@ -5122,6 +5360,39 @@ "query": "", "skipUrlSync": false } + }, + { + "kind": "CustomVariable", + "spec": { + "allowCustomValue": false, + "current": { + "text": "All", + "value": "$__all" + }, + "hide": "dontHide", + "includeAll": true, + "multi": false, + "name": "myCustomVariable", + "options": [ + { + "selected": false, + "text": "a", + "value": "a" + }, + { + "selected": false, + "text": "b", + "value": "b" + }, + { + "selected": false, + "text": "c", + "value": "c" + } + ], + "query": "a, b, c", + "skipUrlSync": false + } } ] }, diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2450d3721d7..e3e822abdc4 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -567,14 +567,6 @@ "packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx": { "@typescript-eslint/no-explicit-any": { "count": 2 - }, - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, - "packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 } }, "packages/grafana-ui/src/components/Combobox/Combobox.story.tsx": { @@ -605,9 +597,6 @@ "packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx": { "@typescript-eslint/no-explicit-any": { "count": 2 - }, - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 } }, "packages/grafana-ui/src/components/DataSourceSettings/types.ts": { @@ -644,14 +633,8 @@ "@typescript-eslint/consistent-type-assertions": { "count": 2 }, - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, "no-restricted-syntax": { "count": 1 - }, - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 } }, "packages/grafana-ui/src/components/Forms/Legacy/Select/NoOptionsMessage.tsx": { @@ -660,38 +643,18 @@ } }, "packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, "no-restricted-syntax": { "count": 6 - }, - "react-prefer-function-component/react-prefer-function-component": { - "count": 3 } }, "packages/grafana-ui/src/components/Forms/Legacy/Select/SelectOption.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - }, "no-restricted-syntax": { "count": 4 } }, - "packages/grafana-ui/src/components/Forms/Legacy/Select/SelectOptionGroup.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - }, - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "packages/grafana-ui/src/components/Forms/Legacy/Switch/Switch.tsx": { "no-restricted-syntax": { "count": 3 - }, - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 } }, "packages/grafana-ui/src/components/Gauge/Gauge.tsx": { @@ -709,11 +672,6 @@ "count": 3 } }, - "packages/grafana-ui/src/components/JSONFormatter/JSONFormatter.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "packages/grafana-ui/src/components/JSONFormatter/json_explorer/json_explorer.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -838,16 +796,6 @@ "count": 1 } }, - "packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, - "packages/grafana-ui/src/components/StatsPicker/StatsPicker.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "packages/grafana-ui/src/components/Table/Cells/TableCell.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 3 @@ -933,21 +881,11 @@ "count": 1 } }, - "packages/grafana-ui/src/components/Tooltip/PopoverController.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "packages/grafana-ui/src/components/Typeahead/Typeahead.tsx": { "react-prefer-function-component/react-prefer-function-component": { "count": 2 } }, - "packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx": { "@grafana/no-aria-label-selectors": { "count": 1 @@ -1907,21 +1845,11 @@ "count": 2 } }, - "public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx": { "react-hooks/rules-of-hooks": { "count": 4 } }, - "public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx": { "no-restricted-syntax": { "count": 3 @@ -3255,11 +3183,6 @@ "count": 1 } }, - "public/app/features/teams/state/reducers.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/features/templating/fieldAccessorCache.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/eslint.config.js b/eslint.config.js index 2af812e1fe3..bd1be26465a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -132,6 +132,7 @@ module.exports = [ reportUnusedDisableDirectives: false, }, files: ['**/*.{ts,tsx,js}'], + ignores: ['packages/grafana-ui/src/components/Forms/Legacy/**'], plugins: { '@emotion': emotionPlugin, lodash: lodashPlugin, @@ -254,6 +255,9 @@ module.exports = [ name: 'grafana/jsx-a11y-overrides', files: ['**/*.tsx'], ignores: ['**/*.{spec,test}.tsx'], + plugins: { + 'jsx-a11y': jsxA11yPlugin, + }, rules: { ...jsxA11yPlugin.configs.recommended.rules, 'jsx-a11y/no-autofocus': [ @@ -278,6 +282,9 @@ module.exports = [ name: 'grafana/packages', files: ['packages/**/*.{ts,tsx}'], ignores: [], + plugins: { + import: importPlugin, + }, rules: { 'import/no-extraneous-dependencies': ['error', { includeInternal: true }], 'no-restricted-imports': [ @@ -378,6 +385,7 @@ module.exports = [ plugins: { 'testing-library': testingLibraryPlugin, 'jest-dom': jestDomPlugin, + jest: jestPlugin, }, files: [ 'public/app/features/alerting/**/__tests__/**/*.[jt]s?(x)', @@ -508,10 +516,12 @@ module.exports = [ // Old betterer rules config: { files: ['**/*.{js,jsx,ts,tsx}'], - ignores: + ignores: [ // FIXME: Remove once all enterprise issues are fixed - // we don't have a suppressions file/approach for enterprise code yet - enterpriseIgnores, + ...enterpriseIgnores, + 'packages/grafana-ui/src/components/Forms/Legacy/**', + ], rules: { '@typescript-eslint/no-explicit-any': 'error', '@grafana/no-aria-label-selectors': 'error', diff --git a/go.mod b/go.mod index 936cf97655b..371cd742c72 100644 --- a/go.mod +++ b/go.mod @@ -61,7 +61,7 @@ require ( github.com/fullstorydev/grpchan v1.1.1 // @grafana/grafana-backend-group github.com/gchaincl/sqlhooks v1.3.0 // @grafana/grafana-search-and-storage github.com/getkin/kin-openapi v0.133.0 // @grafana/grafana-app-platform-squad - github.com/go-jose/go-jose/v4 v4.1.2 // @grafana/identity-access-team + github.com/go-jose/go-jose/v4 v4.1.3 // @grafana/identity-access-team github.com/go-kit/log v0.2.1 // @grafana/grafana-backend-group github.com/go-ldap/ldap/v3 v3.4.4 // @grafana/identity-access-team github.com/go-logfmt/logfmt v0.6.1 // @grafana/oss-big-tent @@ -97,14 +97,14 @@ require ( github.com/grafana/gofpdf v0.0.0-20250307124105-3b9c5d35577f // @grafana/sharing-squad github.com/grafana/gomemcache v0.0.0-20250318131618-74242eea118d // @grafana/grafana-operator-experience-squad github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend - github.com/grafana/grafana-app-sdk v0.48.2 // @grafana/grafana-app-platform-squad - github.com/grafana/grafana-app-sdk/logging v0.48.1 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana-app-sdk v0.48.4 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana-app-sdk/logging v0.48.3 // @grafana/grafana-app-platform-squad github.com/grafana/grafana-aws-sdk v1.3.0 // @grafana/aws-datasources github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // @grafana/partner-datasources github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 // @grafana/grafana-operator-experience-squad github.com/grafana/grafana-google-sdk-go v0.4.2 // @grafana/partner-datasources github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 // @grafana/grafana-backend-group - github.com/grafana/grafana-plugin-sdk-go v0.283.0 // @grafana/plugins-platform-backend + github.com/grafana/grafana-plugin-sdk-go v0.284.0 // @grafana/plugins-platform-backend github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000 // @grafana/alerting-backend github.com/grafana/loki/v3 v3.2.1 // @grafana/observability-logs github.com/grafana/nanogit v0.3.0 // indirect; @grafana/grafana-git-ui-sync-team @@ -211,7 +211,7 @@ require ( golang.org/x/tools v0.39.0 // indirect; @grafana/grafana-as-code gonum.org/v1/gonum v0.16.0 // @grafana/oss-big-tent google.golang.org/api v0.242.0 // @grafana/grafana-backend-group - google.golang.org/grpc v1.76.0 // @grafana/plugins-platform-backend + google.golang.org/grpc v1.77.0 // @grafana/plugins-platform-backend google.golang.org/protobuf v1.36.10 // @grafana/plugins-platform-backend gopkg.in/ini.v1 v1.67.0 // @grafana/alerting-backend gopkg.in/mail.v2 v2.3.1 // @grafana/grafana-backend-group @@ -227,13 +227,14 @@ require ( modernc.org/sqlite v1.39.1 // @grafana/grafana-backend-group pgregory.net/rapid v1.2.0 // @grafana/grafana-operator-experience-squad sigs.k8s.io/randfill v1.0.0 // @grafana/grafana-app-platform-squad - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // @grafana/grafana-app-platform-squad + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // @grafana/grafana-app-platform-squad xorm.io/builder v0.3.13 // @grafana/grafana-backend-group ) require ( github.com/grafana/grafana/apps/advisor v0.0.0 // @grafana/plugins-platform-backend github.com/grafana/grafana/apps/alerting/alertenrichment v0.0.0 // @grafana/alerting-backend + github.com/grafana/grafana/apps/alerting/historian v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/alerting/notifications v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/alerting/rules v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/annotation v0.0.0 // @grafana/grafana-backend-services-squad @@ -267,6 +268,7 @@ require ( replace ( github.com/grafana/grafana/apps/advisor => ./apps/advisor github.com/grafana/grafana/apps/alerting/alertenrichment => ./apps/alerting/alertenrichment + github.com/grafana/grafana/apps/alerting/historian => ./apps/alerting/historian github.com/grafana/grafana/apps/alerting/notifications => ./apps/alerting/notifications github.com/grafana/grafana/apps/alerting/rules => ./apps/alerting/rules github.com/grafana/grafana/apps/annotation => ./apps/annotation @@ -296,7 +298,7 @@ require ( cloud.google.com/go v0.121.4 // indirect cloud.google.com/go/auth v0.16.3 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/longrunning v0.6.7 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect @@ -314,7 +316,7 @@ require ( github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect github.com/FZambia/eagle v0.2.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect @@ -389,7 +391,7 @@ require ( github.com/cheekybits/genny v1.0.0 // indirect github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // indirect github.com/cloudflare/circl v1.6.1 // indirect - github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect + github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -412,7 +414,7 @@ require ( github.com/elazarl/goproxy v1.7.2 // indirect github.com/emicklei/proto v1.13.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -581,7 +583,7 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/viper v1.21.0 // indirect - github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect @@ -599,18 +601,17 @@ require ( github.com/yudai/pp v2.0.1+incompatible // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/zclconf/go-cty v1.16.3 // indirect - github.com/zeebo/errs v1.4.0 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.etcd.io/bbolt v1.4.2 // indirect go.etcd.io/etcd/api/v3 v3.6.4 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect go.etcd.io/etcd/client/v3 v3.6.4 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/collector/featuregate v1.44.0 // indirect go.opentelemetry.io/collector/semconv v0.124.0 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.61.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect go.opentelemetry.io/contrib/exporters/autoexport v0.61.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 // indirect @@ -636,8 +637,8 @@ require ( golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect diff --git a/go.sum b/go.sum index f7c4c8e5578..ff568126e42 100644 --- a/go.sum +++ b/go.sum @@ -194,8 +194,8 @@ cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1h cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/contactcenterinsights v1.3.0/go.mod h1:Eu2oemoePuEFc/xKFPjbTuPSj0fYJcPls9TFlPNnHHY= cloud.google.com/go/contactcenterinsights v1.4.0/go.mod h1:L2YzkGbPsv+vMQMCADxJoT9YiTTnSEd6fEvCeHTYVck= cloud.google.com/go/contactcenterinsights v1.6.0/go.mod h1:IIDlT6CLcDoyv79kDv8iWxMSTZhLxSCofVV5W6YFM/w= @@ -725,8 +725,8 @@ github.com/FZambia/eagle v0.2.0 h1:1kQaZpJvbkvAXFRE/9K2ucBMuVqo+E29EMLYB74hIis= github.com/FZambia/eagle v0.2.0/go.mod h1:LKMYBwGYhao5sJI0TppvQ4SvvldFj9gITxrl8NvGwG0= github.com/FZambia/sentinel v1.0.0 h1:KJ0ryjKTZk5WMp0dXvSdNqp3lFaW1fNFuEYfrkLOYIc= github.com/FZambia/sentinel v1.0.0/go.mod h1:ytL1Am/RLlAoAXG6Kj5LNuw/TRRQrv2rt2FT26vP5gI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg= @@ -1040,8 +1040,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230310173818-32f1caf87195/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= @@ -1169,10 +1169,10 @@ github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go. github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJJM//w9BV6Fxbg2LuVd34= github.com/envoyproxy/go-control-plane v0.11.0/go.mod h1:VnHyVMpzcLvCFt9yUz1UnCwHLhwx1WguiVDV7pTG/tI= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= @@ -1232,8 +1232,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= -github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -1633,10 +1633,10 @@ github.com/grafana/gomemcache v0.0.0-20250318131618-74242eea118d h1:oXRJlb9UjVsl github.com/grafana/gomemcache v0.0.0-20250318131618-74242eea118d/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw= github.com/grafana/grafana-api-golang-client v0.27.0 h1:zIwMXcbCB4n588i3O2N6HfNcQogCNTd/vPkEXTr7zX8= github.com/grafana/grafana-api-golang-client v0.27.0/go.mod h1:uNLZEmgKtTjHBtCQMwNn3qsx2mpMb8zU+7T4Xv3NR9Y= -github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= -github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk v0.48.4 h1:t9r+Y6E7D832ZxQ2c1n0lp6cvsYKhhrAodVYzE1y0s0= +github.com/grafana/grafana-app-sdk v0.48.4/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= @@ -1647,8 +1647,8 @@ github.com/grafana/grafana-google-sdk-go v0.4.2 h1:F44hQF1y6UVJhlJPi+Mz+GCJsioVg github.com/grafana/grafana-google-sdk-go v0.4.2/go.mod h1:U73+w9DlbEtUonhQUzERwlXnzWTtfRoyrtKH8d3VY40= github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 h1:r+mU5bGMzcXCRVAuOrTn54S80qbfVkvTdUJZfSfTNbs= github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ= -github.com/grafana/grafana-plugin-sdk-go v0.283.0 h1:G7IHshAr30rLWV9FtX3iLlFTTlBhuOkfe7xVAoIP5rE= -github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= +github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= +github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b h1:6Bo65etvjQ4tStkaA5+N3A3ENbO4UAWj53TxF6g2Hdk= github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b/go.mod h1:6+wASOCN8LWt6FJ8dc0oODUBIEY5XHaE6ABi8g0mR+k= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= @@ -2454,8 +2454,8 @@ github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DM github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= -github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/spyzhov/ajson v0.9.6 h1:iJRDaLa+GjhCDAt1yFtU/LKMtLtsNVKkxqlpvrHHlpQ= github.com/spyzhov/ajson v0.9.6/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8= @@ -2589,8 +2589,6 @@ github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6 github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= -github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= @@ -2627,8 +2625,8 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/collector v0.124.0 h1:g/dfdGFhBcQI0ggGxTmGlJnJ6Yl6T2gVxQoIj4UfXCc= go.opentelemetry.io/collector/featuregate v1.44.0 h1:/GeGhTD8f+FNWS7C4w1Dj0Ui9Jp4v2WAdlXyW1p3uG8= go.opentelemetry.io/collector/featuregate v1.44.0/go.mod h1:d0tiRzVYrytB6LkcYgz2ESFTv7OktRPQe0QEQcPt1L4= @@ -2640,8 +2638,8 @@ go.opentelemetry.io/collector/semconv v0.124.0 h1:YTdo3UFwNyDQCh9DiSm2rbzAgBuwn/ go.opentelemetry.io/collector/semconv v0.124.0/go.mod h1:te6VQ4zZJO5Lp8dM2XIhDxDiL45mwX0YAQQWRQ0Qr9U= go.opentelemetry.io/contrib/bridges/prometheus v0.61.0 h1:RyrtJzu5MAmIcbRrwg75b+w3RlZCP0vJByDVzcpAe3M= go.opentelemetry.io/contrib/bridges/prometheus v0.61.0/go.mod h1:tirr4p9NXbzjlbruiRGp53IzlYrDk5CO2fdHj0sSSaY= -go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA= -go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= go.opentelemetry.io/contrib/exporters/autoexport v0.61.0 h1:XfzKtKSrbtYk9TNCF8dkO0Y9M7IOfb4idCwBOTwGBiI= go.opentelemetry.io/contrib/exporters/autoexport v0.61.0/go.mod h1:N6otC+qXTD5bAnbK2O1f/1SXq3cX+3KYSWrkBUqG0cw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= @@ -3517,15 +3515,15 @@ google.golang.org/genproto/googleapis/api v0.0.0-20230525234020-1aefcd67740a/go. google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:ylj+BE99M198VPbBh6A8d9n3w8fChvyLK3wwBOjXBFA= google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234015-3fc162c6f38a/go.mod h1:xURIpW9ES5+/GZhnV6beoEtxQrnkRGIfP5VQG2tCBLc= google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= @@ -3572,8 +3570,8 @@ google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5v google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -3763,8 +3761,8 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/go.work b/go.work index f0f5d9acc19..a33973ac23f 100644 --- a/go.work +++ b/go.work @@ -7,6 +7,7 @@ use ( . // skip:golangci-lint ./apps/advisor ./apps/alerting/alertenrichment + ./apps/alerting/historian ./apps/alerting/notifications ./apps/alerting/rules ./apps/annotation diff --git a/go.work.sum b/go.work.sum index 72f5dd7e97d..3bc86d10781 100644 --- a/go.work.sum +++ b/go.work.sum @@ -72,6 +72,7 @@ cloud.google.com/go/cloudtasks v1.13.6/go.mod h1:/IDaQqGKMixD+ayM43CfsvWF2k36Geo cloud.google.com/go/compute v1.40.0 h1:dlEzKo/BtyEGNc+SflXwwoBh52dNl/A5BaSYurT0k0k= cloud.google.com/go/compute v1.40.0/go.mod h1:P1doTJnlwurJDzIQFMp4mgU+vyCe9HU2NWTlqTfq3MY= cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= +cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= cloud.google.com/go/contactcenterinsights v1.17.3 h1:lenyU3uzHwKDveCwmpfNxHYvLS3uEBWdn+O7+rSxy+Q= cloud.google.com/go/contactcenterinsights v1.17.3/go.mod h1:7Uu2CpxS3f6XxhRdlEzYAkrChpR5P5QfcdGAFEdHOG8= cloud.google.com/go/container v1.43.0 h1:A6J92FJPfxTvyX7MHF+w4t2W9WCqvHOi9UB5SAeSy3w= @@ -896,6 +897,7 @@ github.com/grafana/grafana-plugin-sdk-go v0.277.0/go.mod h1:mAUWg68w5+1f5TLDqagI github.com/grafana/grafana-plugin-sdk-go v0.278.0/go.mod h1:+8NXT/XUJ/89GV6FxGQ366NZ3nU+cAXDMd0OUESF9H4= github.com/grafana/grafana-plugin-sdk-go v0.279.0/go.mod h1:/7oGN6Z7DGTGaLHhgIYrRr6Wvmdsb3BLw5hL4Kbjy88= github.com/grafana/grafana-plugin-sdk-go v0.280.0/go.mod h1:Z15Wiq3c4I0tzHYrLYpOqrO8u3+2RJ+HN2Q9uiZTILA= +github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA= github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c= github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d/go.mod h1:AvleS6icyPmcBjihtx5jYEvdzLmHGBp66NuE0AMR57A= @@ -1323,6 +1325,7 @@ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkq github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/common/assets v0.2.0 h1:0P5OrzoHrYBOSM1OigWL3mY8ZvV2N4zIE/5AahrSrfM= github.com/prometheus/exporter-toolkit v0.10.1-0.20230714054209-2f4150c63f97/go.mod h1:LoBCZeRh+5hX+fSULNyFnagYlQG/gBsyA/deNzROkq8= github.com/prometheus/statsd_exporter v0.21.0/go.mod h1:rbT83sZq2V+p73lHhPZfMc3MLCHmSHelCh9hSGYNLTQ= @@ -1533,6 +1536,8 @@ github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+ github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8= github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= @@ -1862,6 +1867,7 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= @@ -1890,6 +1896,7 @@ golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190921015927-1a5e07d1ff72/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= @@ -1912,6 +1919,8 @@ golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= @@ -1920,12 +1929,14 @@ golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbht golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1948,6 +1959,7 @@ golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1967,6 +1979,7 @@ golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= @@ -1989,6 +2002,7 @@ golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= @@ -2050,6 +2064,7 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA= google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= @@ -2057,6 +2072,7 @@ google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFL google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s= google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= @@ -2070,6 +2086,8 @@ google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20 h1:MLBCGN1O7GzIx+cBiwfYPwtmZ41U3Mn/cotLJciaArI= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0= +google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6 h1:ExN12ndbJ608cboPYflpTny6mXSzPrDLh0iTaVrRrds= +google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index 264ac389e42..d2cc21d31e6 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -612,8 +612,8 @@ export type GetSearchApiArg = { tags?: string[]; /** find dashboards that reference a given libraryPanel */ libraryPanel?: string; - /** permission needed for the resource (View, Edit, Admin) */ - permission?: 'View' | 'Edit' | 'Admin'; + /** permission needed for the resource (view, edit, admin) */ + permission?: 'view' | 'edit' | 'admin'; /** sortable field */ sort?: string; /** number of results to return */ diff --git a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts index 45ff0d1e91e..e0f6b885e00 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts @@ -1644,7 +1644,12 @@ const injectedRtkApi = api invalidatesTags: ['teams'], }), getTeamById: build.query({ - query: (queryArg) => ({ url: `/teams/${queryArg.teamId}` }), + query: (queryArg) => ({ + url: `/teams/${queryArg.teamId}`, + params: { + accesscontrol: queryArg.accesscontrol, + }, + }), providesTags: ['teams'], }), updateTeam: build.mutation({ @@ -3474,6 +3479,7 @@ export type DeleteTeamByIdApiArg = { export type GetTeamByIdApiResponse = /** status 200 (empty) */ TeamDto; export type GetTeamByIdApiArg = { teamId: string; + accesscontrol?: boolean; }; export type UpdateTeamApiResponse = /** status 200 An OKResponse is returned if the request was successful. */ SuccessResponseBody; @@ -6051,10 +6057,8 @@ export type SearchTeamGroupsQueryResult = { totalCount?: number; }; export type UpdateTeamCommand = { - Email?: string; - ExternalUID?: string; - ID?: number; - Name?: string; + email?: string; + name?: string; }; export type TeamMemberDto = { auth_module?: string; diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index 67bdca12d27..7ef1fc4fc91 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -1581,6 +1581,8 @@ export type RepositoryView = { branch?: string; /** The k8s name for this repository */ name: string; + /** For git, this is the target path */ + path?: string; /** When syncing, where values are saved Possible enum values: @@ -1598,6 +1600,8 @@ export type RepositoryView = { - `"gitlab"` - `"local"` */ type: 'bitbucket' | 'git' | 'github' | 'gitlab' | 'local'; + /** For git, this is the target URL */ + url?: string; /** The supported workflows */ workflows: ('branch' | 'write')[]; }; diff --git a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts index 776ad4131ae..06e6f5739b8 100644 --- a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts +++ b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts @@ -108,8 +108,6 @@ const config: ConfigFile = { ...createAPIConfig('preferences', 'v1alpha1'), ...createAPIConfig('provisioning', 'v0alpha1'), ...createAPIConfig('shorturl', 'v1beta1'), - ...createAPIConfig('shorturl', 'v1beta1'), - ...createAPIConfig('shorturl', 'v1beta1'), // PLOP_INJECT_API_CLIENT - Used by the API client generator }, }; diff --git a/packages/grafana-data/src/events/EventBus.test.ts b/packages/grafana-data/src/events/EventBus.test.ts index 6de48b5d54e..3708b47d78b 100644 --- a/packages/grafana-data/src/events/EventBus.test.ts +++ b/packages/grafana-data/src/events/EventBus.test.ts @@ -91,8 +91,10 @@ describe('EventBus', () => { it('Supports legacy events', () => { const bus = new EventBusSrv(); const events: LegacyEventPayload[] = []; - const handler = (event: LegacyEventPayload) => { - events.push(event); + const handler = (event?: LegacyEventPayload) => { + if (event) { + events.push(event); + } }; bus.on(legacyEvent, handler); @@ -111,7 +113,9 @@ describe('EventBus', () => { const newEvents: AlertSuccessEvent[] = []; bus.on(legacyEvent, (event) => { - legacyEvents.push(event); + if (event) { + legacyEvents.push(event); + } }); bus.subscribe(AlertSuccessEvent, (event) => { diff --git a/packages/grafana-data/src/events/types.ts b/packages/grafana-data/src/events/types.ts index 514b980d1b4..ce5499d00ed 100644 --- a/packages/grafana-data/src/events/types.ts +++ b/packages/grafana-data/src/events/types.ts @@ -133,12 +133,12 @@ export interface LegacyEmitter { /** * @deprecated use $on */ - off(event: AppEvent | string, handler: (payload?: T) => void): void; + off(event: AppEvent | string, handler: LegacyEventHandler): void; } /** @public */ export interface LegacyEventHandler { - (payload: T): void; + (payload?: T): void; wrapper?: (event: BusEvent) => void; } diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts index fdae270fd48..7b766227ba1 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.ts +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -381,6 +381,11 @@ export class PanelPlugin< const appender = builder.getListAppender({ pluginId: this.meta.id, name: this.meta.name, + options: {}, + fieldConfig: { + defaults: {}, + overrides: [], + }, }); const result = supplier(builder.dataSummary); diff --git a/packages/grafana-data/src/panel/suggestions/getPanelDataSummary.ts b/packages/grafana-data/src/panel/suggestions/getPanelDataSummary.ts index ea97e1705d7..021d1eb48c6 100644 --- a/packages/grafana-data/src/panel/suggestions/getPanelDataSummary.ts +++ b/packages/grafana-data/src/panel/suggestions/getPanelDataSummary.ts @@ -1,19 +1,27 @@ import { PreferredVisualisationType } from '../../types/data'; import { DataFrame, FieldType } from '../../types/dataFrame'; +import { DataFrameType } from '../../types/dataFrameTypes'; -/** - * @alpha - */ export interface PanelDataSummary { hasData?: boolean; rowCountTotal: number; + /** max number of rows in any given dataframe in the panel data */ rowCountMax: number; frameCount: number; fieldCount: number; + /** max number of fields in any given dataframe in the panel data */ + fieldCountMax: number; + /** given a field type, return the number of fields across all dataframes which match this type */ fieldCountByType: (type: FieldType) => number; + /** returns true if any fields in any frames match the field type */ hasFieldType: (type: FieldType) => boolean; - /** The first frame that set's this value */ - preferredVisualisationType?: PreferredVisualisationType; + /* returns true if any of the frames in this panel data summary have the type */ + hasDataFrameType: (type: DataFrameType) => boolean; + /* returns true if any of the frames in this panel data summary have the type */ + hasPreferredVisualisationType: (type: PreferredVisualisationType) => boolean; + + /** pass along a reference to the DataFrame array in case it's needed by the plugin */ + rawFrames?: DataFrame[]; /* --- DEPRECATED FIELDS BELOW --- */ /** @deprecated use PanelDataSummary.fieldCountByType(FieldType.number) */ @@ -23,60 +31,114 @@ export interface PanelDataSummary { /** @deprecated use PanelDataSummary.fieldCountByType(FieldType.string) */ stringFieldCount: number; /** @deprecated use PanelDataSummary.hasFieldType(FieldType.number) */ - hasNumberField?: boolean; - /** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */ hasTimeField?: boolean; + /** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */ + hasNumberField?: boolean; /** @deprecated use PanelDataSummary.hasFieldType(FieldType.string) */ hasStringField?: boolean; } +/** + * @alpha + */ +class PanelDataSummaryImpl implements PanelDataSummary { + public rowCountTotal = 0; + /** max number of rows in any single dataframe in the panel data */ + public rowCountMax = 0; + public fieldCount = 0; + /** max number of fields in any single dataframe in the panel data */ + public fieldCountMax = 0; + + private countByType: Partial> = {}; + private preferredVisualisationTypes: Set = new Set(); + private dataFrameTypes: Set = new Set(); + + public get hasData(): boolean { + return this.rowCountTotal > 0; + } + + public get frameCount(): number { + return this.rawFrames?.length ?? 0; + } + + constructor(public rawFrames?: DataFrame[]) { + this._processFrames(); + } + + private _processFrames() { + for (const frame of this.rawFrames ?? []) { + this.rowCountTotal += frame.length; + + if (frame.meta?.preferredVisualisationType) { + this.preferredVisualisationTypes.add(frame.meta.preferredVisualisationType); + } + if (frame.meta?.type) { + this.dataFrameTypes.add(frame.meta.type); + } + + for (const field of frame.fields) { + this.fieldCount++; + this.countByType[field.type] = (this.countByType[field.type] || 0) + 1; + } + + if (frame.length > this.rowCountMax) { + this.rowCountMax = frame.length; + } + if (frame.fields.length > this.fieldCountMax) { + this.fieldCountMax = frame.fields.length; + } + } + } + + public fieldCountByType(type: FieldType): number { + return this.countByType[type] ?? 0; + } + + public hasFieldType(type: FieldType): boolean { + return this.fieldCountByType(type) > 0; + } + + public hasPreferredVisualisationType(type: PreferredVisualisationType): boolean { + return this.preferredVisualisationTypes.has(type); + } + + public hasDataFrameType(type: DataFrameType): boolean { + return this.dataFrameTypes.has(type); + } + + /**** DEPRECATED ****/ + /** @deprecated use PanelDataSummary.fieldCountByType(FieldType.number) */ + public get numberFieldCount(): number { + return this.fieldCountByType(FieldType.number); + } + /** @deprecated use PanelDataSummary.fieldCountByType(FieldType.time) */ + public get timeFieldCount(): number { + return this.fieldCountByType(FieldType.time); + } + /** @deprecated use PanelDataSummary.fieldCountByType(FieldType.string) */ + public get stringFieldCount() { + return this.fieldCountByType(FieldType.string); + } + /** @deprecated use PanelDataSummary.hasFieldType(FieldType.number) */ + public get hasTimeField() { + return this.fieldCountByType(FieldType.time) > 0; + } + /** @deprecated use PanelDataSummary.hasFieldType(FieldType.time) */ + public get hasNumberField() { + return this.fieldCountByType(FieldType.number) > 0; + } + /** @deprecated use PanelDataSummary.hasFieldType(FieldType.string) */ + public get hasStringField() { + return this.fieldCountByType(FieldType.string) > 0; + } +} + /** * @alpha * given a list of dataframes, summarize attributes of those frames for features like suggestions. * @param frames - dataframes to summarize * @returns summary of the dataframes */ -export function getPanelDataSummary(frames: DataFrame[] = []): PanelDataSummary { - let rowCountTotal = 0; - let rowCountMax = 0; - let fieldCount = 0; - const countByType: Partial> = {}; - let preferredVisualisationType: PreferredVisualisationType | undefined; - - for (const frame of frames) { - rowCountTotal += frame.length; - - if (frame.meta?.preferredVisualisationType) { - preferredVisualisationType = frame.meta.preferredVisualisationType; - } - - for (const field of frame.fields) { - fieldCount++; - countByType[field.type] = (countByType[field.type] || 0) + 1; - } - - if (frame.length > rowCountMax) { - rowCountMax = frame.length; - } - } - - const fieldCountByType = (f: FieldType) => countByType[f] ?? 0; - - return { - rowCountTotal, - rowCountMax, - fieldCount, - preferredVisualisationType, - frameCount: frames.length, - hasData: rowCountTotal > 0, - hasFieldType: (f: FieldType) => fieldCountByType(f) > 0, - fieldCountByType, - // deprecated - numberFieldCount: fieldCountByType(FieldType.number), - timeFieldCount: fieldCountByType(FieldType.time), - stringFieldCount: fieldCountByType(FieldType.string), - hasTimeField: fieldCountByType(FieldType.time) > 0, - hasNumberField: fieldCountByType(FieldType.number) > 0, - hasStringField: fieldCountByType(FieldType.string) > 0, - }; +export function getPanelDataSummary(frames?: DataFrame[]): PanelDataSummary { + return new PanelDataSummaryImpl(frames); } diff --git a/packages/grafana-data/src/transformations/transformers/calculateField.ts b/packages/grafana-data/src/transformations/transformers/calculateField.ts index 733155a3f10..f1023b49502 100644 --- a/packages/grafana-data/src/transformations/transformers/calculateField.ts +++ b/packages/grafana-data/src/transformations/transformers/calculateField.ts @@ -72,7 +72,7 @@ interface IndexOptions { asPercentile: boolean; } -const defaultReduceOptions: ReduceOptions = { +const defaultNumericVizOptions: ReduceOptions = { reducer: ReducerID.sum, }; @@ -149,10 +149,10 @@ export const calculateFieldTransformer: DataTransformerInfo { } `); }); + + it('should prevent excessive densification when sparse histogram has large gaps', () => { + const result = getHistogramFields( + toDataFrame({ + meta: { + type: DataFrameType.HeatmapCells, + }, + fields: [ + { name: 'yMin', type: FieldType.number, values: [0.001, 1000] }, + { name: 'yMax', type: FieldType.number, values: [0.00101, 1010] }, + { name: 'count', type: FieldType.number, values: [10, 20] }, + ], + }) + ); + + expect(result).toBeDefined(); + expect(result!.counts[0].values.length).toBeLessThanOrEqual(1001); + }); + + it('should handle multiple observed buckets when hitting densification limit', () => { + const result = getHistogramFields( + toDataFrame({ + meta: { + type: DataFrameType.HeatmapCells, + }, + fields: [ + { name: 'yMin', type: FieldType.number, values: [0.001, 1000, 2000] }, + { name: 'yMax', type: FieldType.number, values: [0.00101, 1010, 2020] }, + { name: 'count', type: FieldType.number, values: [10, 20, 30] }, + ], + }) + ); + + expect(result).toBeDefined(); + expect(result!.counts[0].values.every((v) => !isNaN(v))).toBe(true); + }); }); describe('joinHistograms', () => { diff --git a/packages/grafana-data/src/transformations/transformers/histogram.ts b/packages/grafana-data/src/transformations/transformers/histogram.ts index 723fb07b0d8..dfd095921d2 100644 --- a/packages/grafana-data/src/transformations/transformers/histogram.ts +++ b/packages/grafana-data/src/transformations/transformers/histogram.ts @@ -210,6 +210,8 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine let denseMins: number[] = []; let denseMaxs: number[] = []; + const MAX_DENSIFIED_BUCKETS = 1000; + for (let i = 0; i < uniqueMaxs.length; i++) { let curMax = uniqueMaxs[i]; let curMin = uniqueMins[i]; @@ -223,13 +225,17 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine curMax = curMax * bucketFactor; curMin = curMin * bucketFactor; - while (curMax < nextMax * 0.999999) { + while (curMax < nextMax * 0.999999 && denseMaxs.length < MAX_DENSIFIED_BUCKETS) { denseMaxs.push(curMax); denseMins.push(curMin); curMax = curMax * bucketFactor; curMin = curMin * bucketFactor; } + + if (denseMaxs.length >= MAX_DENSIFIED_BUCKETS) { + break; + } } } @@ -238,7 +244,10 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine for (let i = 0; i < yMaxField.values.length; i++) { let max = yMaxField.values[i]; - countsByMax.set(max, countsByMax.get(max) + countField.values[i]); + let currentCount = countsByMax.get(max); + if (currentCount !== undefined) { + countsByMax.set(max, currentCount + countField.values[i]); + } } let fields = { diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index e5bf0e10a21..538e7a051e8 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -643,6 +643,7 @@ export interface MetricFindValue { value?: string | number; group?: string; expandable?: boolean; + properties?: Record; } export interface DataSourceGetDrilldownsApplicabilityOptions { diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index c3755fa954f..71fd7eae35d 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -1,4 +1,4 @@ -import { DataQuery } from '@grafana/schema'; +import { DataQuery, LogsSortOrder } from '@grafana/schema'; import { PreferredVisualisationType } from './data'; import { SelectableValue } from './select'; @@ -84,6 +84,7 @@ export interface ExploreLogsPanelState { // Used for logs table visualisation, contains the refId of the dataFrame that is currently visualized refId?: string; displayedFields?: string[]; + sortOrder?: LogsSortOrder; } export interface SplitOpenOptions { diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 0d20e44b19e..d853a64395a 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -361,6 +361,10 @@ export interface FeatureToggles { */ dashboardNewLayouts?: boolean; /** + * Use the v2 kubernetes API in the frontend for dashboards + */ + kubernetesDashboardsV2?: boolean; + /** * Enables undo/redo in dynamic dashboards */ dashboardUndoRedo?: boolean; @@ -373,6 +377,10 @@ export interface FeatureToggles { */ perPanelNonApplicableDrilldowns?: boolean; /** + * Enabled a group by action per panel + */ + panelGroupBy?: boolean; + /** * Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard */ panelFilterVariable?: boolean; @@ -1091,7 +1099,7 @@ export interface FeatureToggles { graphiteBackendMode?: boolean; /** * Enables the updated Azure Monitor resource picker - * @default false + * @default true */ azureResourcePickerUpdates?: boolean; /** @@ -1150,7 +1158,7 @@ export interface FeatureToggles { pluginStoreServiceLoading?: boolean; /** * Increases panel padding globally - * @default false + * @default true */ newPanelPadding?: boolean; /** @@ -1189,4 +1197,8 @@ export interface FeatureToggles { * @default false */ rudderstackUpgrade?: boolean; + /** + * Adds support for Kubernetes alerting historian APIs + */ + kubernetesAlertingHistorian?: boolean; } diff --git a/packages/grafana-data/src/types/suggestions.ts b/packages/grafana-data/src/types/suggestions.ts index 475495cc24a..7520cbb4b6a 100644 --- a/packages/grafana-data/src/types/suggestions.ts +++ b/packages/grafana-data/src/types/suggestions.ts @@ -75,7 +75,9 @@ export interface VisualizationSuggestion) => void; + /** @deprecated this will no longer be supported in the new Suggestions UI. */ icon?: string; + /** @deprecated this will no longer be supported in the new Suggestions UI. */ imgSrc?: string; }; } diff --git a/packages/grafana-data/src/utils/OptionsUIBuilders.ts b/packages/grafana-data/src/utils/OptionsUIBuilders.ts index b88f633ca5e..78a80efb6e1 100644 --- a/packages/grafana-data/src/utils/OptionsUIBuilders.ts +++ b/packages/grafana-data/src/utils/OptionsUIBuilders.ts @@ -1,4 +1,5 @@ import { set, cloneDeep } from 'lodash'; +import type { JSX } from 'react'; import { FieldNamePickerConfigSettings, diff --git a/packages/grafana-e2e-selectors/src/selectors/README.md b/packages/grafana-e2e-selectors/src/selectors/README.md index ef419dac1fa..134197e6a6a 100644 --- a/packages/grafana-e2e-selectors/src/selectors/README.md +++ b/packages/grafana-e2e-selectors/src/selectors/README.md @@ -16,7 +16,7 @@ const components = { A few things to keep in mind: -- Strive to use e2e selector for all components in grafana/ui. +- Strive to use e2e selectors for all components in grafana/ui. - Don't ever delete selectors. Even though a selector may not be used in the Grafana repository, it can still be used in external plugins. - Only create new selector in case you're creating a new piece of UI. If you're changing an existing piece of UI that already has a selector defined, you need to keep using that selector. Otherwise you might break plugin end-to-end tests. - Prefer using string selectors in favour of function selectors. The purpose of the selectors is to provide a canonical way to select elements. diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index e56318477c0..c643a0d7f2c 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -57,6 +57,14 @@ export const versionedComponents = { '12.1.0': 'data-testid DashboardEditPaneSplitter primary body', }, }, + Sidebar: { + closePane: { + '12.4.0': 'data-testid Sidebar close pane', + }, + dockToggle: { + '12.4.0': 'data-testid sidebar-dock-toggle', + }, + }, EditPaneHeader: { deleteButton: { '12.1.0': 'data-testid EditPaneHeader delete panel', @@ -70,9 +78,6 @@ export const versionedComponents = { duplicate: { '12.1.0': 'data-testid EditPaneHeader duplicate', }, - backButton: { - '12.1.0': 'data-testid EditPaneHeader back', - }, }, TimePicker: { openButton: { @@ -441,6 +446,9 @@ export const versionedComponents = { PanelDataErrorMessage: { '10.4.0': 'data-testid Panel data error message', }, + PanelGroupByHeaderAction: { + '12.4.0': 'data-testid Panel group by header action', + }, }, Visualization: { Graph: { diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 96d033d5b14..79422ee8db9 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -183,6 +183,14 @@ export const versionedPages = { url: { [MIN_GRAFANA_VERSION]: (uid: string) => `/d/${uid}`, }, + Sidebar: { + optionsButton: { + '12.4.0': 'data-testid Dashboard Sidebar options button', + }, + outlineButton: { + '12.4.0': 'data-testid Dashboard Sidebar outline button', + }, + }, DashNav: { nav: { [MIN_GRAFANA_VERSION]: 'Dashboard navigation', diff --git a/packages/grafana-flamegraph/.storybook/copyAssets.ts b/packages/grafana-flamegraph/.storybook/copyAssets.ts new file mode 100644 index 00000000000..ab357f9b72c --- /dev/null +++ b/packages/grafana-flamegraph/.storybook/copyAssets.ts @@ -0,0 +1,66 @@ +// This script is used to copy assets from the public folder to a temporary static folder within +// the .storybook directory. +// We selectively limit assets that are uploaded to the Storybook bucket to prevent rate limiting +// when publishing new storybook. +// Note: Storybook has a static copying feature but it copies entire directories which can contain thousands of icons. + +import fs from 'fs-extra'; +import { resolve } from 'node:path'; + +// avoid importing from @grafana/data to prevent error: window is not defined +import { availableIconsIndex, IconName } from '../../grafana-data/src/types/icon.ts'; +import { getIconSubDir } from '../../grafana-ui/src/components/Icon/utils.ts'; + +const __dirname = import.meta.dirname; + +// doesn't require uploading 1000s of unused assets. +const iconPaths = Object.keys(availableIconsIndex) + .filter((iconName) => !iconName.startsWith('fa ')) + .map((iconName) => { + const subDir = getIconSubDir(iconName as IconName, 'default'); + return { + from: `../../../public/img/icons/${subDir}/${iconName}.svg`, + to: `./static/public/build/img/icons/${subDir}/${iconName}.svg`, + }; + }); + +export function copyAssetsSync() { + const assets = [ + { + from: '../../../public/fonts', + to: './static/public/fonts', + }, + { + from: '../../../public/img/grafana_text_logo-dark.svg', + to: './static/public/img/grafana_text_logo-dark.svg', + }, + { + from: '../../../public/img/grafana_text_logo-light.svg', + to: './static/public/img/grafana_text_logo-light.svg', + }, + { + from: '../../../public/img/fav32.png', + to: './static/public/img/fav32.png', + }, + { + from: '../../../public/lib', + to: './static/public/lib', + }, + ...iconPaths, + // copy over the MSW mock service worker so we can mock requests in Storybook + { + from: '../../../public/mockServiceWorker.js', + to: './static/mockServiceWorker.js', + }, + ]; + + for (const asset of assets) { + const fromPath = resolve(__dirname, asset.from); + const toPath = resolve(__dirname, asset.to); + if (!fs.existsSync(toPath)) { + fs.copySync(fromPath, toPath, { + filter: (src) => !fs.lstatSync(src).isSymbolicLink(), + }); + } + } +} diff --git a/packages/grafana-flamegraph/.storybook/main.ts b/packages/grafana-flamegraph/.storybook/main.ts new file mode 100644 index 00000000000..5f950843923 --- /dev/null +++ b/packages/grafana-flamegraph/.storybook/main.ts @@ -0,0 +1,53 @@ +import type { StorybookConfig } from '@storybook/react-webpack5'; +import { copyAssetsSync } from './copyAssets.ts'; + +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); + +copyAssetsSync(); + +const config: StorybookConfig = { + stories: ['../src/**/*.story.tsx'], + addons: [ + { + name: '@storybook/preset-scss', + options: { + styleLoaderOptions: { + // this is required for theme switching .use() and .unuse() + injectType: 'lazyStyleTag', + }, + cssLoaderOptions: { + url: false, + importLoaders: 2, + }, + sassLoaderOptions: { + sassOptions: { + // silencing these warnings since we're planning to remove sass when angular is gone + silenceDeprecations: ['import', 'global-builtin'], + }, + }, + }, + }, + '@storybook/addon-webpack5-compiler-swc', + ], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + staticDirs: ['static'], + webpackFinal: async (config) => { + const webpack = await import('webpack'); + + // Define process.env for browser context + config.plugins?.push( + new webpack.default.DefinePlugin({ + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), + 'process.env.STORYBOOK_THEME': JSON.stringify(process.env.STORYBOOK_THEME || 'system'), + }) + ); + + return config; + }, +}; + +export default config; diff --git a/packages/grafana-flamegraph/.storybook/preview.ts b/packages/grafana-flamegraph/.storybook/preview.ts new file mode 100644 index 00000000000..00d08fccc24 --- /dev/null +++ b/packages/grafana-flamegraph/.storybook/preview.ts @@ -0,0 +1,54 @@ +import { Preview } from '@storybook/react'; + +import { getBuiltInThemes, getTimeZone, getTimeZones, GrafanaTheme2 } from '@grafana/data'; + +import { withTheme } from '../src/utils/storybook/withTheme'; +import { withTimeZone } from '../src/utils/storybook/withTimeZone'; + +const allowedExtraThemes: string[] = []; + +if (process.env.NODE_ENV === 'development') { + allowedExtraThemes.push('debug'); + allowedExtraThemes.push('desertbloom'); + allowedExtraThemes.push('gildedgrove'); + allowedExtraThemes.push('gloom'); + allowedExtraThemes.push('sapphiredusk'); + allowedExtraThemes.push('tron'); +} + +const preview: Preview = { + decorators: [withTheme(), withTimeZone()], + globalTypes: { + theme: { + name: 'Theme', + description: 'Global theme for components', + defaultValue: 'system', + toolbar: { + icon: 'paintbrush', + items: getBuiltInThemes(allowedExtraThemes).map((theme) => ({ + value: theme.id, + title: theme.name, + })), + showName: true, + }, + }, + timeZone: { + description: 'Set the timezone for the storybook preview', + defaultValue: getTimeZone(), + toolbar: { + icon: 'globe', + items: getTimeZones(true) + .filter((timezone) => !!timezone) + .map((timezone) => ({ + title: timezone, + value: timezone, + })), + }, + }, + }, + initialGlobals: { + theme: process.env.STORYBOOK_THEME || 'system', + }, +}; + +export default preview; diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 3e1c2347cd1..f4ec11c7cfa 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -36,7 +36,8 @@ "clean": "rimraf ./dist ./compiled ./package.tgz", "typecheck": "tsc --emitDeclarationOnly false --noEmit", "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js", - "postpack": "mv package.json.bak package.json" + "postpack": "mv package.json.bak package.json", + "storybook": "storybook dev -p 6006" }, "browserslist": [ "defaults", @@ -60,11 +61,15 @@ "@babel/preset-env": "7.28.0", "@babel/preset-react": "7.27.1", "@rollup/plugin-node-resolve": "16.0.1", + "@storybook/addon-webpack5-compiler-swc": "^4.0.2", + "@storybook/react": "^10.0.8", + "@storybook/react-webpack5": "^10.0.8", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "^6.1.2", "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", "@types/d3": "^7", + "@types/fs-extra": "^11", "@types/jest": "^29.5.4", "@types/lodash": "4.17.20", "@types/node": "24.10.1", @@ -73,11 +78,13 @@ "@types/tinycolor2": "1.4.6", "babel-jest": "29.7.0", "esbuild": "0.25.8", + "fs-extra": "^11.3.2", "jest": "^29.6.4", "jest-canvas-mock": "2.5.2", "rollup": "^4.22.4", "rollup-plugin-esbuild": "6.2.1", "rollup-plugin-node-externals": "^8.0.0", + "storybook": "^10.0.8", "ts-jest": "29.4.0", "ts-node": "10.9.2", "typescript": "5.9.2" diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.story.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.story.tsx new file mode 100644 index 00000000000..fdd0cd08074 --- /dev/null +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.story.tsx @@ -0,0 +1,31 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { createDataFrame } from '@grafana/data'; + +import { ColorScheme, SelectedView } from '../types'; + +import FlameGraph from './FlameGraph'; +import { CollapsedMap, FlameGraphDataContainer } from './dataTransform'; +import { data } from './testData/dataBasic'; + +const meta: Meta = { + title: 'FlameGraph', + component: FlameGraph, + args: { + rangeMin: 0, + rangeMax: 1, + textAlign: 'left', + colorScheme: ColorScheme.PackageBased, + selectedView: SelectedView.Both, + search: '', + }, +}; + +export default meta; +export const Basic: StoryObj = { + render: (args) => { + const dataContainer = new FlameGraphDataContainer(createDataFrame(data), { collapsing: false }); + + return ; + }, +}; diff --git a/packages/grafana-flamegraph/src/FlameGraph/testData/dataBasic.ts b/packages/grafana-flamegraph/src/FlameGraph/testData/dataBasic.ts new file mode 100644 index 00000000000..0d582dfa0b7 --- /dev/null +++ b/packages/grafana-flamegraph/src/FlameGraph/testData/dataBasic.ts @@ -0,0 +1,32 @@ +import { DataFrameDTO } from '@grafana/data'; + +export const data: DataFrameDTO = { + name: 'response', + refId: 'A', + // @ts-ignore + meta: { preferredVisualisationType: 'flamegraph' }, + fields: [ + { + name: 'level', + values: [0, 1, 2, 3, 2, 1], + }, + { + name: 'value', + values: [10000, 5000, 4000, 3000, 500, 3000], + config: { + unit: 'ms', + }, + }, + { + name: 'self', + values: [10000, 500, 1000, 3000, 500, 3000], + config: { + unit: 'ms', + }, + }, + { + name: 'label', + values: ['total', 'fn_1', 'fn_2', 'fn_3', 'fn_4', 'fn_5'], + }, + ], +}; diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.story.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.story.tsx new file mode 100644 index 00000000000..67fadcb0cac --- /dev/null +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.story.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { createDataFrame } from '@grafana/data'; +import { useTheme2 } from '@grafana/ui'; + +import { data } from './FlameGraph/testData/dataNestedSet'; +import FlameGraphContainer, { Props } from './FlameGraphContainer'; + +const WrappedFlameGraph = (props: Omit) => { + const theme = useTheme2(); + const df = createDataFrame(data); + return theme} />; +}; + +const meta: Meta = { + title: 'FlameGraphContainer', + render: (args) => { + return ; + }, +}; + +export default meta; +export const Basic: StoryObj = {}; diff --git a/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.story.tsx b/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.story.tsx new file mode 100644 index 00000000000..37eb7bd4c4e --- /dev/null +++ b/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.story.tsx @@ -0,0 +1,41 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { createDataFrame } from '@grafana/data'; + +import { FlameGraphDataContainer } from '../FlameGraph/dataTransform'; +import { data } from '../FlameGraph/testData/dataNestedSet'; +import { ColorScheme } from '../types'; + +import FlameGraphTopTableContainer from './FlameGraphTopTableContainer'; + +const meta: Meta = { + title: 'TopTable', + component: FlameGraphTopTableContainer, + args: { + colorScheme: ColorScheme.ValueBased, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +export const Basic: StoryObj = { + render: (args) => { + const dataContainer = new FlameGraphDataContainer(createDataFrame(data), { collapsing: true }); + + return ( + {}} + onSearch={() => {}} + onSandwich={() => {}} + /> + ); + }, +}; diff --git a/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx b/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx index b9072f42c29..dc70b7c5690 100644 --- a/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx +++ b/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx @@ -144,6 +144,7 @@ function buildFilteredTable(data: FlameGraphDataContainer, matchedLabels?: Set) => { + const theme = getThemeById(themeId); + + const css = ` + #storybook-root { + padding: ${theme.spacing(2)}; + } + + body { + background: ${theme.colors.background.primary}; + } + `; + + return ( + + + + + {children} + + ); +}; + +export const withTheme = + (): Decorator => + // eslint-disable-next-line react/display-name + (story, context) => {story()}; diff --git a/packages/grafana-flamegraph/src/utils/storybook/withTimeZone.tsx b/packages/grafana-flamegraph/src/utils/storybook/withTimeZone.tsx new file mode 100644 index 00000000000..27445b3c24e --- /dev/null +++ b/packages/grafana-flamegraph/src/utils/storybook/withTimeZone.tsx @@ -0,0 +1,12 @@ +import { Decorator } from '@storybook/react'; +import { useEffect } from 'react'; + +import { setTimeZoneResolver } from '@grafana/data'; + +export const withTimeZone = (): Decorator => (Story, context) => { + useEffect(() => { + setTimeZoneResolver(() => context.globals.timeZone ?? 'browser'); + }, [context.globals.timeZone]); + + return Story(); +}; diff --git a/packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx b/packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx index 94ea6281492..3551cdcb336 100644 --- a/packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx +++ b/packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx @@ -1,5 +1,6 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/configuration/AlertingSettingsOverhaul.tsx import { cx } from '@emotion/css'; +import type { JSX } from 'react'; import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; diff --git a/packages/grafana-prometheus/src/configuration/shared/utils.tsx b/packages/grafana-prometheus/src/configuration/shared/utils.tsx index 699d0e7f706..b0a1638cab3 100644 --- a/packages/grafana-prometheus/src/configuration/shared/utils.tsx +++ b/packages/grafana-prometheus/src/configuration/shared/utils.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx index b57a795c3b5..b62ae46f27c 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx +++ b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react'; -import React from 'react'; +import React, { type JSX } from 'react'; import { ComponentTypeWithExtensionMeta, diff --git a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts index 651d858e799..7c542121a7b 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts @@ -312,18 +312,7 @@ export const handyTestingSchema: Spec = { label: 'Custom Variable', multi: true, name: 'customVar', - options: [ - { - selected: true, - text: 'option1', - value: 'option1', - }, - { - selected: false, - text: 'option2', - value: 'option2', - }, - ], + options: [], query: 'option1, option2', skipUrlSync: false, allowCustomValue: true, @@ -490,5 +479,18 @@ export const handyTestingSchema: Spec = { allowCustomValue: true, }, }, + { + kind: 'SwitchVariable', + spec: { + name: 'switchVar', + label: 'Switch Variable', + description: 'A switch variable', + current: 'false', + enabledValue: 'true', + disabledValue: 'false', + hide: 'dontHide', + skipUrlSync: false, + }, + }, ], }; diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts index 3cb2c8aab5b..060a0e3945f 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts @@ -1050,7 +1050,7 @@ export const defaultTimeRangeOption = (): TimeRangeOption => ({ to: "now", }); -export type VariableKind = QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind; +export type VariableKind = QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind | SwitchVariableKind; export const defaultVariableKind = (): VariableKind => (defaultQueryVariableKind()); @@ -1436,6 +1436,37 @@ export const defaultMetricFindValue = (): MetricFindValue => ({ text: "", }); +export interface SwitchVariableKind { + kind: "SwitchVariable"; + spec: SwitchVariableSpec; +} + +export const defaultSwitchVariableKind = (): SwitchVariableKind => ({ + kind: "SwitchVariable", + spec: defaultSwitchVariableSpec(), +}); + +// Switch variable specification +export interface SwitchVariableSpec { + name: string; + current: string; + enabledValue: string; + disabledValue: string; + label?: string; + hide: VariableHide; + skipUrlSync: boolean; + description?: string; +} + +export const defaultSwitchVariableSpec = (): SwitchVariableSpec => ({ + name: "", + current: "false", + enabledValue: "true", + disabledValue: "false", + hide: "dontHide", + skipUrlSync: false, +}); + export interface Spec { annotations: AnnotationQueryKind[]; // Configuration of dashboard cursor sync behavior. diff --git a/packages/grafana-test-utils/src/handlers/all-handlers.ts b/packages/grafana-test-utils/src/handlers/all-handlers.ts index 5a0f1cdc140..5fa473b55d5 100644 --- a/packages/grafana-test-utils/src/handlers/all-handlers.ts +++ b/packages/grafana-test-utils/src/handlers/all-handlers.ts @@ -1,5 +1,6 @@ import { HttpHandler } from 'msw'; +import accessControlHandlers from './api/access-control/handlers'; import dashboardsHandlers from './api/dashboards/handlers'; import folderHandlers from './api/folders/handlers'; import pluginsHandlers from './api/plugins/handlers'; @@ -14,11 +15,12 @@ import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/hand const allHandlers: HttpHandler[] = [ // Legacy handlers - ...teamsHandlers, + ...accessControlHandlers, ...dashboardsHandlers, ...folderHandlers, - ...searchHandlers, ...pluginsHandlers, + ...searchHandlers, + ...teamsHandlers, ...userHandlers, // App platform handlers diff --git a/packages/grafana-test-utils/src/handlers/api/access-control/handlers.ts b/packages/grafana-test-utils/src/handlers/api/access-control/handlers.ts new file mode 100644 index 00000000000..ce5f8f43d5e --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/api/access-control/handlers.ts @@ -0,0 +1,11 @@ +import { HttpResponse, http } from 'msw'; + +const searchTeamRolesHandler = () => + http.post('/api/access-control/teams/roles/search', async () => { + // TODO: Add better mock roles response as needed + return HttpResponse.json([]); + }); + +const handlers = [searchTeamRolesHandler()]; + +export default handlers; diff --git a/packages/grafana-test-utils/src/handlers/api/teams/handlers.ts b/packages/grafana-test-utils/src/handlers/api/teams/handlers.ts index e9fb6c0a112..c8a42bf9bba 100644 --- a/packages/grafana-test-utils/src/handlers/api/teams/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/teams/handlers.ts @@ -127,6 +127,26 @@ const createTeamHandler = () => return HttpResponse.json({ message: 'Team created', teamId: 10, uid: 'aethyfifmhwcgd' }, { status: 200 }); }); +const updateTeamHandler = () => + http.put<{ uid: string }, { name: string; email: string }>('/api/teams/:uid', async ({ params, request }) => { + const teamData = mockTeamsMap.get(params.uid); + const body = await request.json(); + if (!teamData) { + return HttpResponse.json({ message: 'Not found' }, { status: 404 }); + } + const updatedTeam = { + ...teamData, + team: { + ...teamData.team, + name: body.name, + email: body.email, + }, + }; + mockTeamsMap.set(params.uid, updatedTeam); + + return HttpResponse.json({ message: 'Team updated' }); + }); + const handlers = [ teamsPreferencesHandler(), teamsGroupsHandler(), @@ -135,6 +155,7 @@ const handlers = [ getTeamHandler(), deleteTeamHandler(), createTeamHandler(), + updateTeamHandler(), ]; export default handlers; diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index 639bf41ca13..8478081182c 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -1,6 +1,6 @@ // Library import { cx } from '@emotion/css'; -import { CSSProperties, PureComponent, ReactNode } from 'react'; +import { CSSProperties, PureComponent, ReactNode, type JSX } from 'react'; import * as React from 'react'; import tinycolor from 'tinycolor2'; diff --git a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx index d13408d3271..8eee4b3ad12 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx @@ -1,4 +1,4 @@ -import { CSSProperties } from 'react'; +import { CSSProperties, type JSX } from 'react'; import * as React from 'react'; import tinycolor from 'tinycolor2'; diff --git a/packages/grafana-ui/src/components/Button/Button.test.tsx b/packages/grafana-ui/src/components/Button/Button.test.tsx index b78b9aafe5d..d82291f3263 100644 --- a/packages/grafana-ui/src/components/Button/Button.test.tsx +++ b/packages/grafana-ui/src/components/Button/Button.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { Button, LinkButton } from './Button'; diff --git a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.story.tsx b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.story.tsx index 8c107a20129..e46107b6b02 100644 --- a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.story.tsx +++ b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.story.tsx @@ -1,5 +1,6 @@ import { action } from '@storybook/addon-actions'; import { StoryFn, Meta } from '@storybook/react'; +import type { JSX } from 'react'; import { Button } from '../Button/Button'; diff --git a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.tsx b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.tsx index cd859f77256..7b63d5cf296 100644 --- a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.tsx +++ b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.tsx @@ -1,4 +1,5 @@ import { css, cx } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.test.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.test.tsx index 0cf2a5ee010..224bc6bb2b9 100644 --- a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.test.tsx +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.test.tsx @@ -1,5 +1,6 @@ import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { ClipboardButton } from './ClipboardButton'; diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.test.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.test.tsx index 817df10153d..f68093ce9f5 100644 --- a/packages/grafana-ui/src/components/Collapse/Collapse.test.tsx +++ b/packages/grafana-ui/src/components/Collapse/Collapse.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { Collapse } from './Collapse'; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx index f44b3f00fa4..5611dc95b35 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx @@ -1,11 +1,16 @@ import { css } from '@emotion/css'; -import { Component, createRef } from 'react'; -import * as React from 'react'; +import { + type ComponentType, + createElement, + type PropsWithChildren, + type ReactNode, + type RefObject, + useRef, +} from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { withTheme2 } from '../../themes/ThemeContext'; -import { stylesFactory } from '../../themes/stylesFactory'; +import { useTheme2 } from '../../themes/ThemeContext'; import { closePopover } from '../../utils/closePopover'; import { Popover } from '../Tooltip/Popover'; import { PopoverController } from '../Tooltip/PopoverController'; @@ -21,76 +26,81 @@ import { SeriesColorPickerPopover } from './SeriesColorPickerPopover'; * component as a custom trigger you will need to forward the reference to first HTMLElement child. */ type ColorPickerTriggerRenderer = (props: { - // This should be a React.RefObject but due to how object refs are defined you cannot downcast from that - // to a specific type like React.RefObject even though it would be fine in runtime. - ref: React.RefObject; + // This should be a RefObject but due to how object refs are defined you cannot downcast from that + // to a specific type like RefObject even though it would be fine in runtime. + ref: RefObject; showColorPicker: () => void; hideColorPicker: () => void; -}) => React.ReactNode; +}) => ReactNode; export const colorPickerFactory = ( - popover: React.ComponentType>, + popover: ComponentType>, displayName = 'ColorPicker' ) => { - return class ColorPicker extends Component { - static displayName = displayName; - pickerTriggerRef = createRef(); + const ColorPickerComponent = (props: T & { children?: ColorPickerTriggerRenderer }) => { + const { children, onChange, color, id } = props; + const theme = useTheme2(); + const pickerTriggerRef = useRef(null); + const styles = getStyles(theme); - render() { - const { theme, children, onChange, color, id } = this.props; - const styles = getStyles(theme); - const popoverElement = React.createElement(popover, { - ...{ ...this.props, children: null }, + const popoverElement = createElement( + popover, + { + ...props, onChange, - }); - return ( - - {(showPopper, hidePopper, popperProps) => { - return ( - <> - {this.pickerTriggerRef.current && ( - closePopover(event, hidePopper)} - /> - )} + }, + null + ); - {children ? ( - children({ - ref: this.pickerTriggerRef, - showColorPicker: showPopper, - hideColorPicker: hidePopper, - }) - ) : ( - - )} - - ); - }} - - ); - } + return ( + + {(showPopper, hidePopper, popperProps) => { + return ( + <> + {pickerTriggerRef.current && ( + closePopover(event, hidePopper)} + /> + )} + + {children ? ( + children({ + ref: pickerTriggerRef, + showColorPicker: showPopper, + hideColorPicker: hidePopper, + }) + ) : ( + + )} + + ); + }} + + ); }; + + return ColorPickerComponent; }; /** * https://developers.grafana.com/ui/latest/index.html?path=/docs/pickers-colorpicker--docs */ -export const ColorPicker = withTheme2(colorPickerFactory(ColorPickerPopover, 'ColorPicker')); -export const SeriesColorPicker = withTheme2(colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker')); +export const ColorPicker = colorPickerFactory(ColorPickerPopover, 'ColorPicker'); +export const SeriesColorPicker = colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker'); -const getStyles = stylesFactory((theme: GrafanaTheme2) => { +const getStyles = (theme: GrafanaTheme2) => { return { colorPicker: css({ position: 'absolute', @@ -102,4 +112,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { overflow: 'auto', }), }; -}); +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.tsx index 5dd4e2acb25..15a4c6f5c13 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.tsx @@ -72,7 +72,6 @@ export const ColorPickerInput = forwardRef void; -export interface ColorPickerProps extends Themeable2 { +export interface ColorPickerProps { color: string; onChange: ColorPickerChangeHandler; enableNamedColors?: boolean; @@ -29,69 +26,56 @@ export interface Props extends ColorPickerProps, PopoverContentProps { customPickers?: T; } -type PickerType = 'palette' | 'spectrum'; - export interface CustomPickersDescriptor { [key: string]: { - tabComponent: React.ComponentType; + tabComponent: ComponentType; name: string; }; } -interface State { - activePicker: PickerType | keyof T; -} +type PickerType = 'palette' | 'spectrum'; -class UnThemedColorPickerPopover extends Component, State> { - constructor(props: Props) { - super(props); - this.state = { - activePicker: 'palette', - }; - } +export const ColorPickerPopover = (props: Props) => { + const { color, onChange, enableNamedColors, customPickers } = props; + const theme = useTheme2(); + const [activePicker, setActivePicker] = useState('palette'); - handleChange = (color: string) => { - const { onChange, enableNamedColors, theme } = this.props; + const styles = getStyles(theme); + + const handleChange = (color: string) => { if (enableNamedColors) { return onChange(color); } onChange(colorManipulator.asHexString(theme.visualization.getColorByName(color))); }; - onTabChange = (tab: PickerType | keyof T) => { - return () => this.setState({ activePicker: tab }); + const onTabChange = (tab: PickerType | keyof T) => { + return () => setActivePicker(tab); }; - renderPicker = () => { - const { activePicker } = this.state; - const { color } = this.props; - - switch (activePicker) { - case 'spectrum': - return ; - case 'palette': - return ; - default: - return this.renderCustomPicker(activePicker); - } - }; - - renderCustomPicker = (tabKey: keyof T) => { - const { customPickers, color, theme } = this.props; + const renderCustomPicker = (tabKey: keyof T) => { if (!customPickers) { return null; } - return React.createElement(customPickers[tabKey].tabComponent, { + return createElement(customPickers[tabKey].tabComponent, { color, - theme, - onChange: this.handleChange, + onChange: handleChange, }); }; - renderCustomPickerTabs = () => { - const { customPickers } = this.props; + const renderPicker = () => { + switch (activePicker) { + case 'spectrum': + return ; + case 'palette': + return ; + default: + return renderCustomPicker(activePicker); + } + }; + const renderCustomPickerTabs = () => { if (!customPickers) { return null; } @@ -99,49 +83,39 @@ class UnThemedColorPickerPopover extends Comp return ( <> {Object.keys(customPickers).map((key) => { - return ; + return ; })} ); }; - render() { - const { theme } = this.props; - const { activePicker } = this.state; + return ( + + {/* + tabIndex=-1 is needed here to support highlighting text within the picker when using FocusScope + see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668 + */} +
+ + + + {renderCustomPickerTabs()} + +
{renderPicker()}
+
+
+ ); +}; - const styles = getStyles(theme); - - return ( - - {/* - tabIndex=-1 is needed here to support highlighting text within the picker when using FocusScope - see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668 - */} -
- - - - {this.renderCustomPickerTabs()} - -
{this.renderPicker()}
-
-
- ); - } -} - -export const ColorPickerPopover = withTheme2(UnThemedColorPickerPopover); -ColorPickerPopover.displayName = 'ColorPickerPopover'; - -const getStyles = stylesFactory((theme: GrafanaTheme2) => { +const getStyles = (theme: GrafanaTheme2) => { return { colorPickerPopover: css({ borderRadius: theme.shape.radius.default, @@ -165,4 +139,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { borderRadius: `${theme.shape.radius.default} ${theme.shape.radius.default} 0 0`, }), }; -}); +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx index 5fd64e37772..f3cdb9e07b1 100644 --- a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx index 6e38fa3148e..83053a9bd11 100644 --- a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx @@ -1,6 +1,5 @@ import { t } from '@grafana/i18n'; -import { withTheme2 } from '../../themes/ThemeContext'; import { InlineField } from '../Forms/InlineField'; import { InlineSwitch } from '../Switch/Switch'; import { PopoverContentProps } from '../Tooltip/types'; @@ -36,4 +35,4 @@ export const SeriesColorPickerPopover = (props: SeriesColorPickerPopoverProps) = }; // This component is to enable SeriesColorPickerPopover usage via series-color-picker-popover directive -export const SeriesColorPickerPopoverWithTheme = withTheme2(SeriesColorPickerPopover); +export const SeriesColorPickerPopoverWithTheme = SeriesColorPickerPopover; diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx index d8c06371ab9..d03621aa248 100644 --- a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx @@ -38,7 +38,7 @@ const SpectrumPalette = ({ color, onChange }: SpectrumPaletteProps) => { return (
- +
); }; diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx index b57f9d8557b..e4125db7204 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx @@ -1,5 +1,6 @@ import { css, cx } from '@emotion/css'; import * as React from 'react'; +import type { JSX } from 'react'; import { IconName } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx index 2f145e26baf..d439938d26e 100644 --- a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx +++ b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, type JSX } from 'react'; import * as React from 'react'; import { ContextMenu } from '../ContextMenu/ContextMenu'; diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index 77cd0b63579..82215940405 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { RefCallback, useCallback, useEffect, useRef } from 'react'; +import { RefCallback, useCallback, useEffect, useRef, type JSX } from 'react'; import * as React from 'react'; import Scrollbars, { positionValues } from 'react-custom-scrollbars-2'; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx index dc85dca5464..5e3b71cbe73 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { CSSProperties } from 'react'; +import { CSSProperties, type JSX } from 'react'; import * as React from 'react'; import { ActionModel, GrafanaTheme2, LinkModel } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx index 98570eb2e15..4ec3bc939c2 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx index d7d94b186c2..20a66a01564 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { uniqueId } from 'lodash'; -import { PureComponent } from 'react'; +import { memo, useState } from 'react'; import { DataSourceSettings } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; @@ -27,10 +27,6 @@ export interface Props { onChange: (config: DataSourceSettings) => void; } -export interface State { - headers: CustomHeaders; -} - interface CustomHeaderRowProps { header: CustomHeader; onReset: (id: string) => void; @@ -98,150 +94,129 @@ const CustomHeaderRow = ({ header, onBlur, onChange, onRemove, onReset }: Custom CustomHeaderRow.displayName = 'CustomHeaderRow'; -export class CustomHeadersSettings extends PureComponent { - state: State = { - headers: [], - }; - - constructor(props: Props) { - super(props); - const { jsonData, secureJsonData, secureJsonFields } = this.props.dataSourceConfig; - this.state = { - headers: Object.keys(jsonData) - .sort() - .filter((key) => key.startsWith('httpHeaderName')) - .map((key, index) => { - return { - id: uniqueId(), - name: jsonData[key], - value: secureJsonData !== undefined ? secureJsonData[key] : '', - configured: (secureJsonFields && secureJsonFields[`httpHeaderValue${index + 1}`]) || false, - }; - }), - }; - } - - updateSettings = () => { - const { headers } = this.state; +export const CustomHeadersSettings = memo(({ dataSourceConfig, onChange }) => { + const [headers, setHeaders] = useState(() => { + const { jsonData, secureJsonData, secureJsonFields } = dataSourceConfig; + return Object.keys(jsonData) + .sort() + .filter((key) => key.startsWith('httpHeaderName')) + .map((key, index) => { + return { + id: uniqueId(), + name: jsonData[key], + value: secureJsonData !== undefined ? secureJsonData[key] : '', + configured: (secureJsonFields && secureJsonFields[`httpHeaderValue${index + 1}`]) || false, + }; + }); + }); + const updateSettings = (newHeaders: CustomHeaders) => { // we remove every httpHeaderName* field const newJsonData = Object.fromEntries( - Object.entries(this.props.dataSourceConfig.jsonData).filter(([key, val]) => !key.startsWith('httpHeaderName')) + Object.entries(dataSourceConfig.jsonData).filter(([key, val]) => !key.startsWith('httpHeaderName')) ); // we remove every httpHeaderValue* field const newSecureJsonData = Object.fromEntries( - Object.entries(this.props.dataSourceConfig.secureJsonData || {}).filter( - ([key, val]) => !key.startsWith('httpHeaderValue') - ) + Object.entries(dataSourceConfig.secureJsonData || {}).filter(([key, val]) => !key.startsWith('httpHeaderValue')) ); // then we add the current httpHeader-fields - for (const [index, header] of headers.entries()) { + for (const [index, header] of newHeaders.entries()) { newJsonData[`httpHeaderName${index + 1}`] = header.name; if (!header.configured) { newSecureJsonData[`httpHeaderValue${index + 1}`] = header.value; } } - this.props.onChange({ - ...this.props.dataSourceConfig, + onChange({ + ...dataSourceConfig, jsonData: newJsonData, secureJsonData: newSecureJsonData, }); }; - onHeaderAdd = () => { - this.setState((prevState) => { - return { headers: [...prevState.headers, { id: uniqueId(), name: '', value: '', configured: false }] }; - }); + const onHeaderAdd = () => { + setHeaders((prevHeaders) => [...prevHeaders, { id: uniqueId(), name: '', value: '', configured: false }]); }; - onHeaderChange = (headerIndex: number, value: CustomHeader) => { - this.setState(({ headers }) => { - return { - headers: headers.map((item, index) => { - if (headerIndex !== index) { - return item; - } - return { ...value }; - }), - }; - }); - }; - - onHeaderReset = (headerId: string) => { - this.setState(({ headers }) => { - return { - headers: headers.map((h, i) => { - if (h.id !== headerId) { - return h; - } - return { - ...h, - value: '', - configured: false, - }; - }), - }; - }); - }; - - onHeaderRemove = (headerId: string) => { - this.setState( - ({ headers }) => ({ - headers: headers.filter((h) => h.id !== headerId), - }), - this.updateSettings + const onHeaderChange = (headerIndex: number, value: CustomHeader) => { + setHeaders((prevHeaders) => + prevHeaders.map((item, index) => { + if (headerIndex !== index) { + return item; + } + return { ...value }; + }) ); }; - render() { - const { headers } = this.state; - const { dataSourceConfig } = this.props; + const onHeaderReset = (headerId: string) => { + setHeaders((prevHeaders) => + prevHeaders.map((h) => { + if (h.id !== headerId) { + return h; + } + return { + ...h, + value: '', + configured: false, + }; + }) + ); + }; - return ( - + const onHeaderRemove = (headerId: string) => { + setHeaders((prevHeaders) => { + const newHeaders = prevHeaders.filter((h) => h.id !== headerId); + updateSettings(newHeaders); + return newHeaders; + }); + }; + + return ( + + + +
+ Custom HTTP Headers +
+
+
+
+ {headers.map((header, i) => ( + { + onHeaderChange(i, h); + }} + onBlur={() => updateSettings(headers)} + onRemove={onHeaderRemove} + onReset={onHeaderReset} + /> + ))} +
+ {!dataSourceConfig.readOnly && ( -
- Custom HTTP Headers -
+
-
- {headers.map((header, i) => ( - { - this.onHeaderChange(i, h); - }} - onBlur={this.updateSettings} - onRemove={this.onHeaderRemove} - onReset={this.onHeaderReset} - /> - ))} -
- {!dataSourceConfig.readOnly && ( - - - - - - )} -
- ); - } -} + )} +
+ ); +}); + +CustomHeadersSettings.displayName = 'CustomHeadersSettings'; export default CustomHeadersSettings; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx index d8708ddb470..0e6b9796afa 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/SecureSocksProxySettings.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.test.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.test.tsx index c7399e65a4f..51d24c5bc8e 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.test.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { dateTime, makeTimeRange, TimeRange } from '@grafana/data'; +import { dateTime, makeTimeRange, TimeRange, BootData } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { TimeRangeProvider } from './TimeRangeContext'; @@ -152,6 +152,58 @@ it('does not submit wrapping forms', async () => { expect(onSubmit).not.toHaveBeenCalled(); }); +it('shows CTRL+Z in zoom out tooltip when feature flag is disabled', async () => { + window.grafanaBootData = { + settings: { + featureToggles: { + newTimeRangeZoomShortcuts: false, + }, + }, + } as BootData; + + render( + {}} + onChange={(value) => {}} + value={value} + onMoveBackward={() => {}} + onMoveForward={() => {}} + onZoom={() => {}} + /> + ); + + const zoomButton = screen.getByLabelText('Zoom out time range'); + await userEvent.hover(zoomButton); + + expect(await screen.findByText(/CTRL\+Z/)).toBeInTheDocument(); +}); + +it('shows t - in zoom out tooltip when feature flag is enabled', async () => { + window.grafanaBootData = { + settings: { + featureToggles: { + newTimeRangeZoomShortcuts: true, + }, + }, + } as BootData; + + render( + {}} + onChange={(value) => {}} + value={value} + onMoveBackward={() => {}} + onMoveForward={() => {}} + onZoom={() => {}} + /> + ); + + const zoomButton = screen.getByLabelText('Zoom out time range'); + await userEvent.hover(zoomButton); + + expect(await screen.findByText(/t -/)).toBeInTheDocument(); +}); + describe('TimePickerTooltip', () => { beforeAll(() => { const mockIntl = { diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx index b26a6e4c36d..459a7d56e1c 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; import { useDialog } from '@react-aria/dialog'; import { FocusScope } from '@react-aria/focus'; import { useOverlay } from '@react-aria/overlays'; -import { memo, createRef, useState, useEffect } from 'react'; +import { memo, createRef, useState, useEffect, type JSX } from 'react'; import { rangeUtil, @@ -19,6 +19,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; +import { getFeatureToggle } from '../../utils/featureToggle'; import { ButtonGroup } from '../Button/ButtonGroup'; import { getModalStyles } from '../Modal/getModalStyles'; import { getPortalContainer } from '../Portal/Portal'; @@ -243,13 +244,22 @@ export function TimeRangePicker(props: TimeRangePickerProps) { TimeRangePicker.displayName = 'TimeRangePicker'; -const ZoomOutTooltip = () => ( - <> - - Time range zoom out
CTRL+Z -
- -); +const ZoomOutTooltip = () => { + const newShortcuts = getFeatureToggle('newTimeRangeZoomShortcuts'); + return ( + <> + {newShortcuts ? ( + + Time range zoom out
t - +
+ ) : ( + + Time range zoom out
CTRL+Z +
+ )} + + ); +}; export const TimePickerTooltip = ({ timeRange, timeZone }: { timeRange: TimeRange; timeZone?: TimeZone }) => { const styles = useStyles2(getLabelStyles); diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOption.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOption.tsx index cf3a2dec556..7957587508d 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOption.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOption.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; import { isString } from 'lodash'; -import { PropsWithChildren, RefCallback } from 'react'; +import { PropsWithChildren, RefCallback, type JSX } from 'react'; import * as React from 'react'; import { GrafanaTheme2, SelectableValue, getTimeZoneInfo } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/utils/useTimeSync.tsx b/packages/grafana-ui/src/components/DateTimePickers/utils/useTimeSync.tsx index 3aa15a7fb98..89d151b689f 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/utils/useTimeSync.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/utils/useTimeSync.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, type JSX } from 'react'; import { usePrevious } from 'react-use'; import { TimeRange } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/EmptySearchResult/EmptySearchResult.tsx b/packages/grafana-ui/src/components/EmptySearchResult/EmptySearchResult.tsx index 72f353b4ed7..4373ea2f94b 100644 --- a/packages/grafana-ui/src/components/EmptySearchResult/EmptySearchResult.tsx +++ b/packages/grafana-ui/src/components/EmptySearchResult/EmptySearchResult.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/FilterPill/FilterPill.test.tsx b/packages/grafana-ui/src/components/FilterPill/FilterPill.test.tsx index 1fff175840f..eae37775224 100644 --- a/packages/grafana-ui/src/components/FilterPill/FilterPill.test.tsx +++ b/packages/grafana-ui/src/components/FilterPill/FilterPill.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { FilterPill } from './FilterPill'; diff --git a/packages/grafana-ui/src/components/Forms/FieldArray.tsx b/packages/grafana-ui/src/components/Forms/FieldArray.tsx index 5c21767ab84..ad8f18b16c2 100644 --- a/packages/grafana-ui/src/components/Forms/FieldArray.tsx +++ b/packages/grafana-ui/src/components/Forms/FieldArray.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { FC, type JSX } from 'react'; import { useFieldArray, UseFieldArrayProps } from 'react-hook-form'; import { FieldArrayApi } from '../../types/forms'; diff --git a/packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx b/packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx index a45a854a045..5bcd70c2585 100644 --- a/packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx @@ -14,7 +14,7 @@ export enum LegacyInputStatus { export interface Props extends React.HTMLProps { validationEvents?: ValidationEvents; hideErrorMessage?: boolean; - inputRef?: React.LegacyRef; + inputRef?: React.Ref; // Override event props and append status as argument onBlur?: (event: React.FocusEvent, status?: LegacyInputStatus) => void; diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index 8c050c109c0..133826864ec 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -168,7 +168,9 @@ export class Gauge extends PureComponent { const gaugeElement = (
(this.canvasElement = element)} + ref={(element) => { + this.canvasElement = element; + }} /> ); diff --git a/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx b/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx index bf3e0e7f69c..52e8ed8e567 100644 --- a/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx +++ b/packages/grafana-ui/src/components/InfoBox/InfoBox.tsx @@ -1,5 +1,6 @@ import { css, cx } from '@emotion/css'; import * as React from 'react'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/JSONFormatter/JSONFormatter.tsx b/packages/grafana-ui/src/components/JSONFormatter/JSONFormatter.tsx index 089fd514418..c63634be362 100644 --- a/packages/grafana-ui/src/components/JSONFormatter/JSONFormatter.tsx +++ b/packages/grafana-ui/src/components/JSONFormatter/JSONFormatter.tsx @@ -1,4 +1,4 @@ -import { PureComponent, createRef } from 'react'; +import { memo, useRef, useEffect } from 'react'; import { JsonExplorer, JsonExplorerConfig } from './json_explorer/json_explorer'; // We have made some monkey-patching of json-formatter-js so we can't switch right now @@ -10,45 +10,32 @@ interface Props { onDidRender?: (formattedJson: {}) => void; } -export class JSONFormatter extends PureComponent { - private wrapperRef = createRef(); +export const JSONFormatter = memo( + ({ className, json, config = { animateOpen: true }, open = 3, onDidRender }) => { + const wrapperRef = useRef(null); - static defaultProps = { - open: 3, - config: { - animateOpen: true, - }, - }; + useEffect(() => { + const wrapperEl = wrapperRef.current; + if (!wrapperEl) { + return; + } - componentDidMount() { - this.renderJson(); + const formatter = new JsonExplorer(json, open, config); + const hasChildren = wrapperEl.hasChildNodes(); + + if (hasChildren && wrapperEl.lastChild) { + wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild); + } else { + wrapperEl.appendChild(formatter.render()); + } + + if (onDidRender) { + onDidRender(formatter.json); + } + }, [json, config, open, onDidRender]); + + return
; } +); - componentDidUpdate() { - this.renderJson(); - } - - renderJson = () => { - const { json, config, open, onDidRender } = this.props; - const wrapperEl = this.wrapperRef.current; - const formatter = new JsonExplorer(json, open, config); - // @ts-ignore - const hasChildren: boolean = wrapperEl.hasChildNodes(); - if (hasChildren) { - // @ts-ignore - wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild); - } else { - // @ts-ignore - wrapperEl.appendChild(formatter.render()); - } - - if (onDidRender) { - onDidRender(formatter.json); - } - }; - - render() { - const { className } = this.props; - return
; - } -} +JSONFormatter.displayName = 'JSONFormatter'; diff --git a/packages/grafana-ui/src/components/List/AbstractList.tsx b/packages/grafana-ui/src/components/List/AbstractList.tsx index 920b6cf0b5b..de1d360800f 100644 --- a/packages/grafana-ui/src/components/List/AbstractList.tsx +++ b/packages/grafana-ui/src/components/List/AbstractList.tsx @@ -1,5 +1,5 @@ import { cx, css } from '@emotion/css'; -import { PureComponent } from 'react'; +import { PureComponent, type JSX } from 'react'; import { stylesFactory } from '../../themes/stylesFactory'; diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index fc00bc9e562..aaeb2c3e426 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -2,7 +2,7 @@ import { cx } from '@emotion/css'; import { useDialog } from '@react-aria/dialog'; import { FocusScope } from '@react-aria/focus'; import { OverlayContainer, useOverlay } from '@react-aria/overlays'; -import { PropsWithChildren, useRef } from 'react'; +import { PropsWithChildren, useRef, type JSX } from 'react'; import * as React from 'react'; import { t } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/Pagination/Pagination.tsx b/packages/grafana-ui/src/components/Pagination/Pagination.tsx index c0c25d4186b..57afc826b93 100644 --- a/packages/grafana-ui/src/components/Pagination/Pagination.tsx +++ b/packages/grafana-ui/src/components/Pagination/Pagination.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { useMemo } from 'react'; +import { useMemo, type JSX } from 'react'; import { t } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx index f66ca51bb86..608795b219b 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx @@ -1,4 +1,5 @@ import { css, cx } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/QueryField/QueryField.tsx b/packages/grafana-ui/src/components/QueryField/QueryField.tsx index 053bddb3ad3..1ce8a68dc87 100644 --- a/packages/grafana-ui/src/components/QueryField/QueryField.tsx +++ b/packages/grafana-ui/src/components/QueryField/QueryField.tsx @@ -211,7 +211,9 @@ export class UnThemedQueryField extends PureComponent
(this.editor = editor!)} + ref={(editor) => { + this.editor = editor!; + }} schema={SCHEMA} autoCorrect={false} readOnly={this.props.disabled} diff --git a/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.tsx b/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.tsx index 3fb806bc162..456d5044dd3 100644 --- a/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.tsx +++ b/packages/grafana-ui/src/components/RenderUserContentAsHTML/RenderUserContentAsHTML.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes, PropsWithChildren } from 'react'; +import { HTMLAttributes, PropsWithChildren, type JSX } from 'react'; import * as React from 'react'; import { textUtil } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx b/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx index d8278697334..c0a829dc760 100644 --- a/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx +++ b/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx @@ -1,6 +1,6 @@ import { action } from '@storybook/addon-actions'; import { Meta, StoryFn } from '@storybook/react'; -import { useState } from 'react'; +import { useState, type JSX } from 'react'; import * as React from 'react'; import { Icon } from '../Icon/Icon'; diff --git a/packages/grafana-ui/src/components/Select/InputControl.tsx b/packages/grafana-ui/src/components/Select/InputControl.tsx index 4635b6a27dc..f45ba1c74b3 100644 --- a/packages/grafana-ui/src/components/Select/InputControl.tsx +++ b/packages/grafana-ui/src/components/Select/InputControl.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { forwardRef } from 'react'; +import { forwardRef, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/Select/MultiValue.tsx b/packages/grafana-ui/src/components/Select/MultiValue.tsx index 987d34a1290..31bd3d23d91 100644 --- a/packages/grafana-ui/src/components/Select/MultiValue.tsx +++ b/packages/grafana-ui/src/components/Select/MultiValue.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { t } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/Select/SelectMenu.tsx b/packages/grafana-ui/src/components/Select/SelectMenu.tsx index bfb8b17a1c1..37bd1452e91 100644 --- a/packages/grafana-ui/src/components/Select/SelectMenu.tsx +++ b/packages/grafana-ui/src/components/Select/SelectMenu.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; import { max } from 'lodash'; -import { RefCallback, useLayoutEffect, useMemo, useRef } from 'react'; +import { RefCallback, useLayoutEffect, useMemo, useRef, type JSX } from 'react'; import * as React from 'react'; import { FixedSizeList as List } from 'react-window'; diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index 3512ab9fbee..6ed93a5c0e5 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { ActionMeta as SelectActionMeta, CommonProps as ReactSelectCommonProps, diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.story.tsx b/packages/grafana-ui/src/components/Sidebar/Sidebar.story.tsx index 8590f91a8b4..175e792d451 100644 --- a/packages/grafana-ui/src/components/Sidebar/Sidebar.story.tsx +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.story.tsx @@ -62,6 +62,7 @@ export const Example: StoryFn = (args) => { position: args.position, bottomMargin: 0, edgeMargin: 0, + onClosePane: () => setOpenPane(''), }); return ( @@ -79,7 +80,7 @@ export const Example: StoryFn = (args) => { {openPane === 'settings' && ( - togglePane('')}> + @@ -88,12 +89,12 @@ export const Example: StoryFn = (args) => { )} {openPane === 'outline' && ( - togglePane('')} /> + )} {openPane === 'add' && ( - togglePane('')} /> + )} diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx b/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx index 187fbc90c4b..5e5116b3ada 100644 --- a/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx @@ -30,6 +30,7 @@ function TestSetup() { const contextValue = useSidebar({ position: 'right', hasOpenPane: openPane !== '', + onClosePane: () => setOpenPane(''), }); return ( @@ -37,7 +38,7 @@ function TestSetup() { {openPane === 'settings' && ( - setOpenPane('')} /> + )} diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx b/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx index 2d9b407a1ad..311e11f390c 100644 --- a/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx @@ -2,14 +2,17 @@ import { css, cx } from '@emotion/css'; import { ReactNode, useContext } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; +import { getPortalContainer } from '../Portal/Portal'; import { SidebarButton } from './SidebarButton'; import { SidebarPaneHeader } from './SidebarPaneHeader'; import { SidebarResizer } from './SidebarResizer'; import { SIDE_BAR_WIDTH_ICON_ONLY, SIDE_BAR_WIDTH_WITH_TEXT, SidebarContext, SidebarContextValue } from './useSidebar'; +import { useCustomClickAway } from './useSidebarClickAway'; export interface Props { children?: ReactNode; @@ -30,9 +33,20 @@ export function SidebarComp({ children, contextValue }: Props) { const style = { [position]: theme.spacing(edgeMargin), bottom: theme.spacing(bottomMargin) }; + const ref = useCustomClickAway((evt) => { + const portalContainer = getPortalContainer(); + // ignore clicks inside portal container + if (evt.target instanceof Node && portalContainer && portalContainer.contains(evt.target)) { + return; + } + if (!isDocked && hasOpenPane) { + contextValue.onClosePane?.(); + } + }); + return ( -
+
{!tabsMode && } {children}
@@ -59,8 +73,9 @@ export function SiderbarToolbar({ children }: SiderbarToolbarProps) { {context.hasOpenPane && ( )}
diff --git a/packages/grafana-ui/src/components/Sidebar/SidebarButton.tsx b/packages/grafana-ui/src/components/Sidebar/SidebarButton.tsx index f436d9630f6..9f5e0f20d71 100644 --- a/packages/grafana-ui/src/components/Sidebar/SidebarButton.tsx +++ b/packages/grafana-ui/src/components/Sidebar/SidebarButton.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { useContext } from 'react'; +import React, { ButtonHTMLAttributes, useContext } from 'react'; import { GrafanaTheme2, IconName, isIconName } from '@grafana/data'; @@ -11,38 +11,48 @@ import { Tooltip } from '../Tooltip/Tooltip'; import { SidebarContext } from './useSidebar'; -export interface Props { +export interface Props extends ButtonHTMLAttributes { icon: IconName; active?: boolean; - onClick?: () => void; - title: string; tooltip?: string; + title: string; } -export function SidebarButton({ icon, active, onClick, title, tooltip }: Props) { - const styles = useStyles2(getStyles); - const context = useContext(SidebarContext); +export const SidebarButton = React.forwardRef( + ({ icon, active, onClick, title, tooltip, ...restProps }, ref) => { + const styles = useStyles2(getStyles); + const context = useContext(SidebarContext); - if (!context) { - throw new Error('Sidebar.Button must be used within a Sidebar component'); + if (!context) { + throw new Error('Sidebar.Button must be used within a Sidebar component'); + } + + const buttonClass = cx( + styles.button, + context.compact && styles.compact, + active && styles.active, + context.position === 'left' && styles.leftButton + ); + + return ( + + + + ); } +); - const buttonClass = cx( - styles.button, - context.compact && styles.compact, - active && styles.active, - context.position === 'left' && styles.leftButton - ); - - return ( - - - - ); -} +SidebarButton.displayName = 'SidebarButton'; function renderIcon(icon: IconName | React.ReactNode, compact?: boolean) { if (!icon) { diff --git a/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx b/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx index 75393777879..e1e5e0bca59 100644 --- a/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx +++ b/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx @@ -1,32 +1,40 @@ import { css } from '@emotion/css'; -import { ReactNode } from 'react'; +import { ReactNode, useContext } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; import { IconButton } from '../IconButton/IconButton'; import { Text } from '../Text/Text'; +import { SidebarContext } from './useSidebar'; + export interface Props { children?: ReactNode; title: string; - onClose?: () => void; } -export function SidebarPaneHeader({ children, onClose, title }: Props) { +export function SidebarPaneHeader({ children, title }: Props) { const styles = useStyles2(getStyles); + const context = useContext(SidebarContext); + + if (!context) { + throw new Error('SidebarPaneHeader must be used within a Sidebar'); + } return (
- {onClose && ( + {context.onClosePane && ( )} diff --git a/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx b/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx index 03d04b2f81a..82a577bf36c 100644 --- a/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx +++ b/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx @@ -16,8 +16,10 @@ export interface SidebarContextValue { bottomMargin: number; edgeMargin: number; contentMargin: number; - onDockChange: () => void; + onToggleDock: () => void; onResize: (diff: number) => void; + /** Called when pane is closed or clicked outside of (in undocked mode) */ + onClosePane?: () => void; } export const SidebarContext: React.Context = React.createContext< @@ -35,6 +37,8 @@ export interface UseSideBarOptions { edgeMargin?: number; /** defaults to 2 grid units (16px) */ contentMargin?: number; + /** Called when pane is closed or clicked outside of (in undocked mode) */ + onClosePane?: () => void; } export const SIDE_BAR_WIDTH_ICON_ONLY = 5; @@ -48,6 +52,7 @@ export function useSidebar({ bottomMargin = 2, edgeMargin = 2, contentMargin = 2, + onClosePane, }: UseSideBarOptions): SidebarContextValue { const theme = useTheme2(); const [isDocked, setIsDocked] = React.useState(false); @@ -56,7 +61,7 @@ export function useSidebar({ // Used to accumulate drag distance to know when to change compact mode const [_, setCompactDrag] = React.useState(0); - const onDockChange = useCallback(() => setIsDocked((prev) => !prev), []); + const onToggleDock = useCallback(() => setIsDocked((prev) => !prev), []); const prop = position === 'right' ? 'paddingRight' : 'paddingLeft'; const toolbarWidth = @@ -98,7 +103,7 @@ export function useSidebar({ return { isDocked, - onDockChange, + onToggleDock, onResize, outerWrapperProps, position, @@ -109,5 +114,6 @@ export function useSidebar({ edgeMargin, bottomMargin, contentMargin, + onClosePane, }; } diff --git a/packages/grafana-ui/src/components/Sidebar/useSidebarClickAway.ts b/packages/grafana-ui/src/components/Sidebar/useSidebarClickAway.ts new file mode 100644 index 00000000000..5f1f2ccfb89 --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/useSidebarClickAway.ts @@ -0,0 +1,32 @@ +import React from 'react'; + +/** + * Cannot use the react-use useClickAway directly as it relies on mousedown event which is not ideal as the element selection uses pointerdown + * @param ref + * @param onClickAway + */ +export function useCustomClickAway(onClickAway: (evt: MouseEvent | TouchEvent) => void) { + const ref = React.useRef(null); + const refCb = React.useRef(onClickAway); + + React.useLayoutEffect(() => { + refCb.current = onClickAway; + }); + + React.useEffect(() => { + const handler = (e: MouseEvent | TouchEvent) => { + const element = ref.current; + if (element && e.target instanceof Node && !element.contains(e.target)) { + refCb.current(e); + } + }; + + document.addEventListener('pointerdown', handler); + + return () => { + document.removeEventListener('pointerdown', handler); + }; + }, []); + + return ref; +} diff --git a/packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx b/packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx index 49c3f4b8966..db0f53084d8 100644 --- a/packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx +++ b/packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx @@ -1,45 +1,33 @@ import { action } from '@storybook/addon-actions'; import { Meta, StoryFn } from '@storybook/react'; -import { PureComponent } from 'react'; +import { memo, useState } from 'react'; import { Field } from '../Forms/Field'; import { Props, StatsPicker } from './StatsPicker'; -interface State { - stats: string[]; -} +const WrapperWithState = memo(({ placeholder, allowMultiple, menuPlacement, width }) => { + const [stats, setStats] = useState([]); -class WrapperWithState extends PureComponent { - constructor(props: Props) { - super(props); - this.state = { - stats: [], - }; - } + return ( + + { + action('Picked:')(newStats); + setStats(newStats); + }} + menuPlacement={menuPlacement} + width={width} + /> + + ); +}); - render() { - const { placeholder, allowMultiple, menuPlacement, width } = this.props; - const { stats } = this.state; - - return ( - - { - action('Picked:')(stats); - this.setState({ stats }); - }} - menuPlacement={menuPlacement} - width={width} - /> - - ); - } -} +WrapperWithState.displayName = 'WrapperWithState'; const meta: Meta = { title: 'Pickers/StatsPicker', diff --git a/packages/grafana-ui/src/components/StatsPicker/StatsPicker.tsx b/packages/grafana-ui/src/components/StatsPicker/StatsPicker.tsx index 009ae565dfe..0bf185e1354 100644 --- a/packages/grafana-ui/src/components/StatsPicker/StatsPicker.tsx +++ b/packages/grafana-ui/src/components/StatsPicker/StatsPicker.tsx @@ -1,5 +1,5 @@ import { difference } from 'lodash'; -import { PureComponent } from 'react'; +import { memo, useEffect } from 'react'; import { fieldReducers, SelectableValue, FieldReducerInfo } from '@grafana/data'; @@ -18,54 +18,47 @@ export interface Props { filterOptions?: (ext: FieldReducerInfo) => boolean; } -export class StatsPicker extends PureComponent { - static defaultProps: Partial = { - allowMultiple: false, - }; +export const StatsPicker = memo( + ({ + placeholder, + onChange, + stats, + allowMultiple = false, + defaultStat, + className, + width, + menuPlacement, + inputId, + filterOptions, + }) => { + useEffect(() => { + const current = fieldReducers.list(stats); + if (current.length !== stats.length) { + const found = current.map((v) => v.id); + const notFound = difference(stats, found); + console.warn('Unknown stats', notFound, stats); + onChange(current.map((stat) => stat.id)); + } - componentDidMount() { - this.checkInput(); - } + // Make sure there is only one + if (!allowMultiple && stats.length > 1) { + console.warn('Removing extra stat', stats); + onChange([stats[0]]); + } - componentDidUpdate(prevProps: Props) { - this.checkInput(); - } + // Set the reducer from callback + if (defaultStat && stats.length < 1) { + onChange([defaultStat]); + } + }, [stats, allowMultiple, defaultStat, onChange]); - checkInput = () => { - const { stats, allowMultiple, defaultStat, onChange } = this.props; - - const current = fieldReducers.list(stats); - if (current.length !== stats.length) { - const found = current.map((v) => v.id); - const notFound = difference(stats, found); - console.warn('Unknown stats', notFound, stats); - onChange(current.map((stat) => stat.id)); - } - - // Make sure there is only one - if (!allowMultiple && stats.length > 1) { - console.warn('Removing extra stat', stats); - onChange([stats[0]]); - } - - // Set the reducer from callback - if (defaultStat && stats.length < 1) { - onChange([defaultStat]); - } - }; - - onSelectionChange = (item: SelectableValue) => { - const { onChange } = this.props; - if (Array.isArray(item)) { - onChange(item.map((v) => v.value)); - } else { - onChange(item && item.value ? [item.value] : []); - } - }; - - render() { - const { stats, allowMultiple, defaultStat, placeholder, className, menuPlacement, width, inputId, filterOptions } = - this.props; + const onSelectionChange = (item: SelectableValue) => { + if (Array.isArray(item)) { + onChange(item.map((v) => v.value)); + } else { + onChange(item && item.value ? [item.value] : []); + } + }; const select = fieldReducers.selectOptions(stats, filterOptions); return ( @@ -78,10 +71,12 @@ export class StatsPicker extends PureComponent { isSearchable={true} options={select.options} placeholder={placeholder} - onChange={this.onSelectionChange} + onChange={onSelectionChange} menuPlacement={menuPlacement} inputId={inputId} /> ); } -} +); + +StatsPicker.displayName = 'StatsPicker'; diff --git a/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.test.tsx b/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.test.tsx index c28e2b37a39..5d6a6e4984a 100644 --- a/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.test.tsx +++ b/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { IconName } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/Table/Cells/GeoCell.tsx b/packages/grafana-ui/src/components/Table/Cells/GeoCell.tsx index 16d37fc3f4e..6328b157b29 100644 --- a/packages/grafana-ui/src/components/Table/Cells/GeoCell.tsx +++ b/packages/grafana-ui/src/components/Table/Cells/GeoCell.tsx @@ -1,5 +1,6 @@ import WKT from 'ol/format/WKT'; import { Geometry } from 'ol/geom'; +import type { JSX } from 'react'; import { TableCellProps } from '../types'; diff --git a/packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx b/packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx index 6e8b678d528..c9476244bcf 100644 --- a/packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx +++ b/packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; import { isString } from 'lodash'; -import { useState } from 'react'; +import { useState, type JSX } from 'react'; import { getCellLinks } from '../../../utils/table'; import { CellActions } from '../CellActions'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index a3772785d63..798ca4e1ae0 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -2,7 +2,7 @@ import 'react-data-grid/lib/styles.css'; import { clsx } from 'clsx'; import memoize from 'micro-memoize'; -import { CSSProperties, Key, ReactNode, useCallback, useMemo, useRef, useState } from 'react'; +import { CSSProperties, Key, ReactNode, useCallback, useMemo, useRef, useState, type JSX } from 'react'; import { Cell, CellRendererProps, diff --git a/packages/grafana-ui/src/components/Tabs/Tabs.test.tsx b/packages/grafana-ui/src/components/Tabs/Tabs.test.tsx index 91b0aabce60..1caafa6ef44 100644 --- a/packages/grafana-ui/src/components/Tabs/Tabs.test.tsx +++ b/packages/grafana-ui/src/components/Tabs/Tabs.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { Tab } from './Tab'; import { TabsBar } from './TabsBar'; diff --git a/packages/grafana-ui/src/components/Tags/Tag.test.tsx b/packages/grafana-ui/src/components/Tags/Tag.test.tsx index 48fce39b162..b5535284105 100644 --- a/packages/grafana-ui/src/components/Tags/Tag.test.tsx +++ b/packages/grafana-ui/src/components/Tags/Tag.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { Tag } from './Tag'; diff --git a/packages/grafana-ui/src/components/Tags/TagList.test.tsx b/packages/grafana-ui/src/components/Tags/TagList.test.tsx index 7c0d8a0314b..f8a4a393c88 100644 --- a/packages/grafana-ui/src/components/Tags/TagList.test.tsx +++ b/packages/grafana-ui/src/components/Tags/TagList.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { TagList } from './TagList'; diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx index 41cc4e55ab4..002f15ec04b 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx @@ -11,7 +11,7 @@ import { useInteractions, } from '@floating-ui/react'; import { Placement } from '@popperjs/core'; -import { memo, cloneElement, isValidElement, useRef, useState } from 'react'; +import { memo, cloneElement, isValidElement, useRef, useState, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; diff --git a/packages/grafana-ui/src/components/Toggletip/types.ts b/packages/grafana-ui/src/components/Toggletip/types.ts index 62d0335a868..ff730df05b2 100644 --- a/packages/grafana-ui/src/components/Toggletip/types.ts +++ b/packages/grafana-ui/src/components/Toggletip/types.ts @@ -1,3 +1,4 @@ +import type { JSX } from 'react'; export interface ToggletipContentProps { /** * @deprecated diff --git a/packages/grafana-ui/src/components/Tooltip/PopoverController.tsx b/packages/grafana-ui/src/components/Tooltip/PopoverController.tsx index 7128a67528a..37e3ae49473 100644 --- a/packages/grafana-ui/src/components/Tooltip/PopoverController.tsx +++ b/packages/grafana-ui/src/components/Tooltip/PopoverController.tsx @@ -1,5 +1,5 @@ import { Placement } from '@popperjs/core'; -import { Component } from 'react'; +import { useState, useRef, useCallback, type JSX } from 'react'; import { PopoverContent } from './types'; @@ -21,37 +21,28 @@ interface Props { hideAfter?: number; } -interface State { - show: boolean; -} +const PopoverController = ({ placement = 'auto', content, children, hideAfter }: Props) => { + const [show, setShow] = useState(false); + const hideTimeoutRef = useRef | null>(null); -class PopoverController extends Component { - private hideTimeout: ReturnType | null = null; - state = { show: false }; - - showPopper = () => { - if (this.hideTimeout) { - clearTimeout(this.hideTimeout); + const showPopper = useCallback(() => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); } - this.setState({ show: true }); - }; + setShow(true); + }, []); - hidePopper = () => { - this.hideTimeout = setTimeout(() => { - this.setState({ show: false }); - }, this.props.hideAfter); - }; + const hidePopper = useCallback(() => { + hideTimeoutRef.current = setTimeout(() => { + setShow(false); + }, hideAfter); + }, [hideAfter]); - render() { - const { children, content, placement = 'auto' } = this.props; - const { show } = this.state; - - return children(this.showPopper, this.hidePopper, { - show, - placement, - content, - }); - } -} + return children(showPopper, hidePopper, { + show, + placement, + content, + }); +}; export { PopoverController }; diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index 665e2d7bf6c..59902c5c953 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -10,7 +10,7 @@ import { useInteractions, safePolygon, } from '@floating-ui/react'; -import { forwardRef, cloneElement, isValidElement, useCallback, useId, useRef, useState } from 'react'; +import { forwardRef, cloneElement, isValidElement, useCallback, useId, useRef, useState, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; diff --git a/packages/grafana-ui/src/components/Tooltip/types.ts b/packages/grafana-ui/src/components/Tooltip/types.ts index ac01b7f5409..dcdede5e19c 100644 --- a/packages/grafana-ui/src/components/Tooltip/types.ts +++ b/packages/grafana-ui/src/components/Tooltip/types.ts @@ -1,4 +1,5 @@ import { Placement } from '@floating-ui/react'; +import type { JSX } from 'react'; export interface PopoverContentProps { /** diff --git a/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx b/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx index ac53b424bdb..90b9263f55d 100644 --- a/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx +++ b/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx @@ -1,4 +1,4 @@ -import { PureComponent } from 'react'; +import { memo } from 'react'; import { getValueFormats, SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; @@ -19,58 +19,52 @@ function formatCreateLabel(input: string) { /** * https://developers.grafana.com/ui/latest/index.html?path=/docs/pickers-unitpicker--docs */ -export class UnitPicker extends PureComponent { - onChange = (value: SelectableValue) => { - this.props.onChange(value.value); - }; +export const UnitPicker = memo(({ onChange, value, width, id }) => { + // Set the current selection + let current: SelectableValue | undefined = undefined; - render() { - const { value, width, id } = this.props; + // All units + const unitGroups = getValueFormats(); - // Set the current selection - let current: SelectableValue | undefined = undefined; - - // All units - const unitGroups = getValueFormats(); - - // Need to transform the data structure to work well with Select - const groupOptions: CascaderOption[] = unitGroups.map((group) => { - const options = group.submenu.map((unit) => { - const sel = { - label: unit.text, - value: unit.value, - }; - if (unit.value === value) { - current = sel; - } - return sel; - }); - - return { - label: group.text, - value: group.text, - items: options, + // Need to transform the data structure to work well with Select + const groupOptions: CascaderOption[] = unitGroups.map((group) => { + const options = group.submenu.map((unit) => { + const sel = { + label: unit.text, + value: unit.value, }; + if (unit.value === value) { + current = sel; + } + return sel; }); - // Show the custom unit - if (value && !current) { - current = { value, label: value }; - } + return { + label: group.text, + value: group.text, + items: options, + }; + }); - return ( - - ); + // Show the custom unit + if (value && !current) { + current = { value, label: value }; } -} + + return ( + + ); +}); + +UnitPicker.displayName = 'UnitPicker'; diff --git a/packages/grafana-ui/src/components/ValuePicker/ValuePicker.test.tsx b/packages/grafana-ui/src/components/ValuePicker/ValuePicker.test.tsx index e5cf2ccce1d..42f6d57c79e 100644 --- a/packages/grafana-ui/src/components/ValuePicker/ValuePicker.test.tsx +++ b/packages/grafana-ui/src/components/ValuePicker/ValuePicker.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { ValuePicker } from './ValuePicker'; diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index 0383aa1eab1..b0f578fae79 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -1,4 +1,5 @@ import { css, cx } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/VizLegend/types.ts b/packages/grafana-ui/src/components/VizLegend/types.ts index 4032276bf6a..3c0de880792 100644 --- a/packages/grafana-ui/src/components/VizLegend/types.ts +++ b/packages/grafana-ui/src/components/VizLegend/types.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { DataFrameFieldIndex, DisplayValue } from '@grafana/data'; import { LegendDisplayMode, LegendPlacement, LineStyle } from '@grafana/schema'; diff --git a/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx b/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx index 8a42baf89d7..a1fb6c6a1c3 100644 --- a/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx +++ b/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx @@ -1,5 +1,5 @@ import { clamp } from 'lodash'; -import { PureComponent, CSSProperties } from 'react'; +import { PureComponent, CSSProperties, type JSX } from 'react'; import * as React from 'react'; import { VizOrientation } from '@grafana/data'; diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 27322714477..6bc91793236 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -29,7 +29,7 @@ const cursorDefaults: Cursor = { type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData; type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData; -type PlotState = { isPanning: false } | { isPanning: true; min: number; max: number }; +type PlotState = { isPanning: false } | { isPanning: true; min: number; max: number; isTimeRangePending?: boolean }; export class UPlotConfigBuilder { readonly uid = Math.random().toString(36).slice(2); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx index d9a6e787251..e9e471b82af 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.test.tsx @@ -137,7 +137,7 @@ describe('XAxisInteractionAreaPlugin', () => { expect(mockQueryZoom).not.toHaveBeenCalled(); }); - it('should set isPanning state during drag and clear on mouseup', () => { + it('should set isPanning state during drag and mark isTimeRangePending on mouseup', () => { setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom); xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true })); @@ -153,6 +153,20 @@ describe('XAxisInteractionAreaPlugin', () => { document.dispatchEvent(new MouseEvent('mouseup', { clientX: 350, bubbles: true })); + expect(mockConfigBuilder.setState).toHaveBeenCalledWith({ + isPanning: true, + min: expectedRange.from, + max: expectedRange.to, + isTimeRangePending: true, + }); + }); + + it('should clear isPanning state immediately for small drags below threshold', () => { + setupXAxisPan(asUPlot(mockUPlot), asConfigBuilder(mockConfigBuilder), mockQueryZoom); + + xAxisElement.dispatchEvent(new MouseEvent('mousedown', { clientX: 400, bubbles: true })); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 402, bubbles: true })); + expect(mockConfigBuilder.setState).toHaveBeenCalledWith({ isPanning: false }); }); }); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx index 694f372307d..5b70546b55a 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/XAxisInteractionAreaPlugin.tsx @@ -96,11 +96,14 @@ export const setupXAxisPan = ( xAxisEl.style.cursor = 'grab'; - config.setState({ isPanning: false }); + const isSignificantDrag = Math.abs(dragPixels) >= MIN_PAN_DIST; - if (Math.abs(dragPixels) >= MIN_PAN_DIST) { + if (isSignificantDrag) { const newRange = calculatePanRange(startMin, startMax, dragPixels, u.bbox.width); + config.setState({ isPanning: true, min: newRange.from, max: newRange.to, isTimeRangePending: true }); queryZoom(newRange); + } else { + config.setState({ isPanning: false }); } document.removeEventListener('mousemove', onMove); diff --git a/packages/grafana-ui/src/graveyard/Graph/Graph.tsx b/packages/grafana-ui/src/graveyard/Graph/Graph.tsx index 759e041decd..d926214c3cb 100644 --- a/packages/grafana-ui/src/graveyard/Graph/Graph.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/Graph.tsx @@ -1,7 +1,7 @@ // Libraries import $ from 'jquery'; import { uniqBy } from 'lodash'; -import { PureComponent } from 'react'; +import { PureComponent, type JSX } from 'react'; import * as React from 'react'; // Types @@ -384,7 +384,9 @@ export class Graph extends PureComponent {
(this.element = e)} + ref={(e) => { + this.element = e; + }} style={{ height, width }} onMouseLeave={() => { this.setState({ isTooltipVisible: false }); diff --git a/packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx index 3361b077b03..8c38dc62708 100644 --- a/packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx @@ -1,5 +1,5 @@ import { difference, isEqual } from 'lodash'; -import { Component } from 'react'; +import { Component, type JSX } from 'react'; import * as React from 'react'; import { GraphSeriesXY } from '@grafana/data'; diff --git a/pkg/aggregator/go.mod b/pkg/aggregator/go.mod index 32ba2addf5e..264fb259202 100644 --- a/pkg/aggregator/go.mod +++ b/pkg/aggregator/go.mod @@ -4,7 +4,7 @@ go 1.25.3 require ( github.com/emicklei/go-restful/v3 v3.13.0 - github.com/grafana/grafana-plugin-sdk-go v0.283.0 + github.com/grafana/grafana-plugin-sdk-go v0.284.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e github.com/grafana/grafana/pkg/semconv v0.0.0-20250514132646-acbc7b54ed9e github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 @@ -17,7 +17,7 @@ require ( k8s.io/component-base v0.34.2 k8s.io/klog/v2 v2.130.1 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 ) require ( @@ -101,7 +101,7 @@ require ( go.etcd.io/etcd/api/v3 v3.6.4 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect go.etcd.io/etcd/client/v3 v3.6.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect @@ -130,9 +130,9 @@ require ( golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.39.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/pkg/aggregator/go.sum b/pkg/aggregator/go.sum index 2f44039e35c..1c52723a0ac 100644 --- a/pkg/aggregator/go.sum +++ b/pkg/aggregator/go.sum @@ -101,8 +101,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/grafana-plugin-sdk-go v0.283.0 h1:G7IHshAr30rLWV9FtX3iLlFTTlBhuOkfe7xVAoIP5rE= -github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= +github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= +github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg= github.com/grafana/grafana/pkg/semconv v0.0.0-20250514132646-acbc7b54ed9e h1:vheR6iPO1np+G/ARjcWx9yiWd7BnTDgyTgsnMhOvx70= @@ -269,8 +269,8 @@ go.etcd.io/etcd/server/v3 v3.6.4 h1:LsCA7CzjVt+8WGrdsnh6RhC0XqCsLkBly3ve5rTxMAU= go.etcd.io/etcd/server/v3 v3.6.4/go.mod h1:aYCL/h43yiONOv0QIR82kH/2xZ7m+IWYjzRmyQfnCAg= go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 h1:2pn7OzMewmYRiNtv1doZnLo3gONcnMHlFnmOR8Vgt+8= @@ -382,13 +382,13 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -426,7 +426,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index cba7f5bc202..d7dc89d248f 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -9,6 +9,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -18,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics/metricutil" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/setting" @@ -200,6 +202,11 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response. // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response { + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "GetDataSourceByUID"), time.Since(start).Seconds()) + }() + ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID()) if err != nil { @@ -231,6 +238,11 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response { + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds()) + }() + uid := web.Params(c.Req)[":uid"] if uid == "" { @@ -361,6 +373,11 @@ func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error { // 409: conflictError // 500: internalServerError func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response { + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "AddDataSource"), time.Since(start).Seconds()) + }() + cmd := datasources.AddDataSourceCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) @@ -478,6 +495,10 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response. // 409: conflictError // 500: internalServerError func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response { + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds()) + }() cmd := datasources.UpdateDataSourceCommand{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index 517dc6e9048..e2f951b0a37 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db/dbtest" + "github.com/grafana/grafana/pkg/infra/metrics/metricutil" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" @@ -81,6 +83,19 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) { }, mockSQLStore) } +// setupDsConfigMetrics creates and registers the prometheus metrics needed for HTTPServer tests +// that call methods using dsConfigHandlerRequestsDuration. +func setupDsConfigHandlerMetrics() (prometheus.Registerer, *prometheus.HistogramVec) { + promRegister := prometheus.NewRegistry() + dsConfigHandlerRequestsDuration := metricutil.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "grafana", + Name: "ds_config_handler_requests_duration_seconds", + Help: "Duration of requests handled by datasource configuration handlers", + }, []string{"code_path", "handler"}) + promRegister.MustRegister(dsConfigHandlerRequestsDuration) + return promRegister, dsConfigHandlerRequestsDuration +} + // Adding data sources with invalid URLs should lead to an error. func TestAddDataSource_InvalidURL(t *testing.T) { sc := setupScenarioContext(t, "/api/datasources") @@ -88,6 +103,7 @@ func TestAddDataSource_InvalidURL(t *testing.T) { DataSourcesService: &dataSourcesServiceMock{}, Cfg: setting.NewCfg(), } + hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics() sc.m.Post(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response { c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{ @@ -118,6 +134,7 @@ func TestAddDataSource_URLWithoutProtocol(t *testing.T) { AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), accesscontrolService: actest.FakeService{}, } + hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics() sc := setupScenarioContext(t, "/api/datasources") @@ -143,6 +160,7 @@ func TestAddDataSource_InvalidJSONData(t *testing.T) { DataSourcesService: &dataSourcesServiceMock{}, Cfg: setting.NewCfg(), } + hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics() sc := setupScenarioContext(t, "/api/datasources") @@ -175,6 +193,7 @@ func TestUpdateDataSource_InvalidURL(t *testing.T) { DataSourcesService: &dataSourcesServiceMock{}, Cfg: setting.NewCfg(), } + hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics() sc := setupScenarioContext(t, "/api/datasources/1234") sc.m.Put(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response { @@ -199,6 +218,7 @@ func TestUpdateDataSource_InvalidJSONData(t *testing.T) { DataSourcesService: &dataSourcesServiceMock{}, Cfg: setting.NewCfg(), } + hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics() sc := setupScenarioContext(t, "/api/datasources/1234") hs.Cfg.AuthProxy.Enabled = true @@ -236,6 +256,7 @@ func TestAddDataSourceTeamHTTPHeaders(t *testing.T) { ExpectedErr: nil, }, } + hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics() sc := setupScenarioContext(t, fmt.Sprintf("/api/datasources/%s", tenantID)) hs.Cfg.AuthProxy.Enabled = true @@ -289,6 +310,7 @@ func TestUpdateDataSource_URLWithoutProtocol(t *testing.T) { AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), accesscontrolService: actest.FakeService{}, } + hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics() sc := setupScenarioContext(t, "/api/datasources/1234") @@ -429,6 +451,7 @@ func TestAPI_datasources_AccessControl(t *testing.T) { hs.DataSourcesService = &dataSourcesServiceMock{expectedDatasource: &datasources.DataSource{}} hs.accesscontrolService = actest.FakeService{} hs.Live = newTestLive(t, hs.SQLStore) + hs.promRegister, hs.dsConfigHandlerRequestsDuration = setupDsConfigHandlerMetrics() }) for _, url := range tt.urls { diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index fa0b407aa2b..f2ac32a80c6 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -203,27 +203,28 @@ type HTTPServer struct { pluginsCDNService *pluginscdn.Service managedPluginsService managedplugins.Manager - userService user.Service - tempUserService tempUser.Service - loginAttemptService loginAttempt.Service - orgService org.Service - orgDeletionService org.DeletionService - TeamService team.Service - accesscontrolService accesscontrol.Service - annotationsRepo annotations.Repository - tagService tag.Service - oauthTokenService oauthtoken.OAuthTokenService - statsService stats.Service - authnService authn.Service - starApi *starApi.API - promRegister prometheus.Registerer - promGatherer prometheus.Gatherer - clientConfigProvider grafanaapiserver.DirectRestConfigProvider - namespacer request.NamespaceMapper - anonService anonymous.Service - userVerifier user.Verifier - tlsCerts TLSCerts - htmlHandlerRequestsDuration *prometheus.HistogramVec + userService user.Service + tempUserService tempUser.Service + loginAttemptService loginAttempt.Service + orgService org.Service + orgDeletionService org.DeletionService + TeamService team.Service + accesscontrolService accesscontrol.Service + annotationsRepo annotations.Repository + tagService tag.Service + oauthTokenService oauthtoken.OAuthTokenService + statsService stats.Service + authnService authn.Service + starApi *starApi.API + promRegister prometheus.Registerer + promGatherer prometheus.Gatherer + clientConfigProvider grafanaapiserver.DirectRestConfigProvider + namespacer request.NamespaceMapper + anonService anonymous.Service + userVerifier user.Verifier + tlsCerts TLSCerts + htmlHandlerRequestsDuration *prometheus.HistogramVec + dsConfigHandlerRequestsDuration *prometheus.HistogramVec } type TLSCerts struct { @@ -382,9 +383,15 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi Name: "html_handler_requests_duration_seconds", Help: "Duration of requests handled by the index.go HTML handler", }, []string{"handler"}), + dsConfigHandlerRequestsDuration: metricutil.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "grafana", + Name: "ds_config_handler_requests_duration_seconds", + Help: "Duration of requests handled by datasource configuration handlers", + }, []string{"code_path", "handler"}), } promRegister.MustRegister(hs.htmlHandlerRequestsDuration) + promRegister.MustRegister(hs.dsConfigHandlerRequestsDuration) if hs.Listener != nil { hs.log.Debug("Using provided listener") diff --git a/pkg/apimachinery/go.mod b/pkg/apimachinery/go.mod index fdcf1562f1f..3a111a9028b 100644 --- a/pkg/apimachinery/go.mod +++ b/pkg/apimachinery/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/pkg/apimachinery go 1.25.3 require ( - github.com/go-jose/go-jose/v4 v4.1.2 + github.com/go-jose/go-jose/v4 v4.1.3 github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team github.com/stretchr/testify v1.11.1 @@ -34,27 +34,25 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect ) diff --git a/pkg/apimachinery/go.sum b/pkg/apimachinery/go.sum index 3b59ab0b8b9..42e2598d7f5 100644 --- a/pkg/apimachinery/go.sum +++ b/pkg/apimachinery/go.sum @@ -6,8 +6,8 @@ github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bF github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= -github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -77,8 +77,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= @@ -96,8 +96,6 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -130,10 +128,10 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -157,7 +155,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod index 4a3a9d7a84b..08a647a145b 100644 --- a/pkg/apiserver/go.mod +++ b/pkg/apiserver/go.mod @@ -5,7 +5,7 @@ go 1.25.3 require ( github.com/google/go-cmp v0.7.0 github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 - github.com/grafana/grafana-app-sdk/logging v0.48.1 + github.com/grafana/grafana-app-sdk/logging v0.48.3 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 @@ -17,7 +17,7 @@ require ( k8s.io/component-base v0.34.2 k8s.io/klog/v2 v2.130.1 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 ) require ( @@ -31,7 +31,7 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect @@ -71,7 +71,7 @@ require ( go.etcd.io/etcd/api/v3 v3.6.4 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect go.etcd.io/etcd/client/v3 v3.6.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect @@ -92,9 +92,9 @@ require ( golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.39.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/pkg/apiserver/go.sum b/pkg/apiserver/go.sum index 52c046f42a0..ff63cdc69ca 100644 --- a/pkg/apiserver/go.sum +++ b/pkg/apiserver/go.sum @@ -25,8 +25,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= -github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -71,8 +71,8 @@ github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGr github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= -github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= -github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana-app-sdk/logging v0.48.3 h1:72NUpGNiJXCNQz/on++YSsl38xuVYYBKv5kKQaOClX4= +github.com/grafana/grafana-app-sdk/logging v0.48.3/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= @@ -170,8 +170,8 @@ go.etcd.io/etcd/server/v3 v3.6.4 h1:LsCA7CzjVt+8WGrdsnh6RhC0XqCsLkBly3ve5rTxMAU= go.etcd.io/etcd/server/v3 v3.6.4/go.mod h1:aYCL/h43yiONOv0QIR82kH/2xZ7m+IWYjzRmyQfnCAg= go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= @@ -260,13 +260,13 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -303,7 +303,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/build/go.mod b/pkg/build/go.mod index 80952661b2b..e08dfce7d74 100644 --- a/pkg/build/go.mod +++ b/pkg/build/go.mod @@ -16,7 +16,7 @@ require ( golang.org/x/net v0.47.0 // indirect; @grafana/oss-big-tent @grafana/partner-datasources golang.org/x/sync v0.18.0 // @grafana/alerting-backend golang.org/x/text v0.31.0 // indirect; @grafana/grafana-backend-group - google.golang.org/grpc v1.76.0 // indirect; @grafana/plugins-platform-backend + google.golang.org/grpc v1.77.0 // indirect; @grafana/plugins-platform-backend google.golang.org/protobuf v1.36.10 // indirect; @grafana/plugins-platform-backend ) @@ -30,8 +30,8 @@ require ( github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect golang.org/x/sys v0.38.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect ) require ( @@ -51,7 +51,7 @@ require ( github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect diff --git a/pkg/build/go.sum b/pkg/build/go.sum index b5a55f1d488..3d794bfccf8 100644 --- a/pkg/build/go.sum +++ b/pkg/build/go.sum @@ -59,8 +59,8 @@ github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTd github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 h1:06ZeJRe5BnYXceSM9Vya83XXVaNGe3H1QqsvqRANQq8= @@ -105,12 +105,12 @@ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/expr/sql/frame_db_conv.go b/pkg/expr/sql/frame_db_conv.go index fe94ae79266..451b7c4f9a8 100644 --- a/pkg/expr/sql/frame_db_conv.go +++ b/pkg/expr/sql/frame_db_conv.go @@ -187,7 +187,7 @@ func fieldValFromRowVal(fieldType data.FieldType, val interface{}) (interface{}, case data.FieldTypeBool, data.FieldTypeNullableBool: return parseBoolFromInt8(val, nullable) - case data.FieldTypeJSON, data.FieldTypeNullableJSON: + case data.FieldTypeJSON, data.FieldTypeNullableJSON: //nolint:staticcheck switch v := val.(type) { case types.JSONDocument: raw := json.RawMessage(v.String()) diff --git a/pkg/expr/sql/frame_table.go b/pkg/expr/sql/frame_table.go index 245efc6e655..3e24457ef3b 100644 --- a/pkg/expr/sql/frame_table.go +++ b/pkg/expr/sql/frame_table.go @@ -182,7 +182,7 @@ func convertDataType(fieldType data.FieldType) mysql.Type { return types.Boolean case data.FieldTypeTime, data.FieldTypeNullableTime: return types.Timestamp - case data.FieldTypeJSON, data.FieldTypeNullableJSON: + case data.FieldTypeJSON, data.FieldTypeNullableJSON: //nolint:staticcheck return types.JSON default: fmt.Printf("------- Unsupported field type: %v", fieldType) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 719d2ab5cb5..f013d9d2bfa 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -1,6 +1,7 @@ package middleware import ( + "context" "errors" "net/http" "net/url" @@ -21,6 +22,13 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" + "github.com/open-feature/go-sdk/openfeature" +) + +var openfeatureClient = openfeature.NewDefaultClient() + +const ( + pluginPageFeatureFlagPrefix = "plugin-page-visible." ) type AuthOptions struct { @@ -146,6 +154,12 @@ func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, log return } + if !PageIsFeatureToggleEnabled(c.Req.Context(), c.Req.URL.Path) { + logger.Debug("Forbidden experimental plugin page", "plugin", pluginID, "path", c.Req.URL.Path) + accessForbidden(c) + return + } + permitted := true path := normalizeIncludePath(c.Req.URL.Path) hasAccess := ac.HasAccess(accessControl, c) @@ -294,3 +308,18 @@ func shouldForceLogin(c *contextmodel.ReqContext) bool { return forceLogin } + +// PageIsFeatureToggleEnabled checks if a page is enabled via OpenFeature feature flags. +// It returns false if the feature flag is set and set to false. +// The feature flag key format is: "plugin-page-visible." +func PageIsFeatureToggleEnabled(ctx context.Context, path string) bool { + flagKey := pluginPageFeatureFlagPrefix + filepath.Clean(path) + enabled := openfeatureClient.Boolean( + ctx, + flagKey, + true, + openfeature.TransactionContext(ctx), + ) + + return enabled +} diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go index fdca1d04ee3..19a7d68559e 100644 --- a/pkg/middleware/auth_test.go +++ b/pkg/middleware/auth_test.go @@ -1,12 +1,17 @@ package middleware import ( + "context" "errors" "fmt" "net/http" "net/http/httptest" + "sync" "testing" + "github.com/open-feature/go-sdk/openfeature" + "github.com/open-feature/go-sdk/openfeature/memprovider" + oftesting "github.com/open-feature/go-sdk/openfeature/testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -28,6 +33,8 @@ import ( "github.com/grafana/grafana/pkg/web" ) +var openfeatureTestMutex sync.Mutex + func setupAuthMiddlewareTest(t *testing.T, identity *authn.Identity, authErr error) *contexthandler.ContextHandler { return contexthandler.ProvideService(setting.NewCfg(), &authntest.FakeService{ ExpectedErr: authErr, @@ -422,6 +429,60 @@ func TestCanAdminPlugin(t *testing.T) { } } +func TestPageIsFeatureToggleEnabled(t *testing.T) { + type testCase struct { + desc string + path string + flags map[string]bool + expectedResult bool + } + + tests := []testCase{ + { + desc: "returns true when feature flag is enabled", + path: "/a/my-plugin/settings", + flags: map[string]bool{ + pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": true, + }, + expectedResult: true, + }, + { + desc: "returns false when feature flag is disabled", + path: "/a/my-plugin/settings", + flags: map[string]bool{ + pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": false, + }, + expectedResult: false, + }, + { + desc: "returns false when feature flag is disabled with trailing slash", + path: "/a/my-plugin/settings/", + flags: map[string]bool{ + pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": false, + }, + expectedResult: false, + }, + { + desc: "returns true when feature flag does not exist", + path: "/a/my-plugin/settings", + flags: map[string]bool{}, + expectedResult: true, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ctx := context.Background() + + setupTestProvider(t, tt.flags) + + result := PageIsFeatureToggleEnabled(ctx, tt.path) + + assert.Equal(t, tt.expectedResult, result) + }) + } +} + func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler { return func(c *web.Context) { reqCtx := &contextmodel.ReqContext{ @@ -437,3 +498,38 @@ func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler c.Req = c.Req.WithContext(ctxkey.Set(c.Req.Context(), reqCtx)) } } + +// setupTestProvider creates a test OpenFeature provider with the given flags. +// Uses a global lock to prevent concurrent provider changes across tests. +func setupTestProvider(t *testing.T, flags map[string]bool) oftesting.TestProvider { + t.Helper() + + // Lock to prevent concurrent provider changes + openfeatureTestMutex.Lock() + + testProvider := oftesting.NewTestProvider() + flagsMap := map[string]memprovider.InMemoryFlag{} + + for key, value := range flags { + flagsMap[key] = memprovider.InMemoryFlag{ + DefaultVariant: "defaultVariant", + Variants: map[string]any{ + "defaultVariant": value, + }, + } + } + + testProvider.UsingFlags(t, flagsMap) + + err := openfeature.SetProviderAndWait(testProvider) + require.NoError(t, err) + + t.Cleanup(func() { + testProvider.Cleanup() + _ = openfeature.SetProviderAndWait(openfeature.NoopProvider{}) + // Unlock after cleanup to allow other tests to run + openfeatureTestMutex.Unlock() + }) + + return testProvider +} diff --git a/pkg/promlib/go.mod b/pkg/promlib/go.mod index 877b00ae2e4..6aea33c1df8 100644 --- a/pkg/promlib/go.mod +++ b/pkg/promlib/go.mod @@ -4,7 +4,7 @@ go 1.25.3 require ( github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 - github.com/grafana/grafana-plugin-sdk-go v0.283.0 + github.com/grafana/grafana-plugin-sdk-go v0.284.0 github.com/grafana/grafana/apps/scope v0.0.0-20251007093103-792853df9134 github.com/json-iterator/go v1.1.12 github.com/prometheus/client_golang v1.23.2 @@ -86,7 +86,7 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect @@ -111,9 +111,9 @@ require ( golang.org/x/tools v0.39.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/api v0.242.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect - google.golang.org/grpc v1.76.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/client-go v0.34.2 // indirect @@ -122,5 +122,5 @@ require ( k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect ) diff --git a/pkg/promlib/go.sum b/pkg/promlib/go.sum index 066b92e7746..eaa9d903015 100644 --- a/pkg/promlib/go.sum +++ b/pkg/promlib/go.sum @@ -3,8 +3,8 @@ cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 h1:5YTBM8QDVIBN3sxBil89WfdAAqDZbyJTgh688DSxX5w= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.12.0 h1:wL5IEG5zb7BVv1Kv0Xm92orq+5hB5Nipn3B5tn4Rqfk= @@ -130,8 +130,8 @@ github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81 github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= -github.com/grafana/grafana-plugin-sdk-go v0.283.0 h1:G7IHshAr30rLWV9FtX3iLlFTTlBhuOkfe7xVAoIP5rE= -github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= +github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= +github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= github.com/grafana/grafana/apps/scope v0.0.0-20251007093103-792853df9134 h1:xly75v5lFNR37q+wXnwA5yU/fPW9IOSYbhFpt4tQyt8= github.com/grafana/grafana/apps/scope v0.0.0-20251007093103-792853df9134/go.mod h1:zijsUNa1zi476JJIR2Lcm/Paz1nRCno9XCp6hbS6G9o= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251007081214-26e147d01f0a h1:L7xgV9mP6MRF3L2/vDOjNR7heaBPbXPMGTDN9/jXSFQ= @@ -278,8 +278,8 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 h1:2pn7OzMewmYRiNtv1doZnLo3gONcnMHlFnmOR8Vgt+8= @@ -378,12 +378,12 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg= google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -410,7 +410,7 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index fb42543de33..ad598f62f9f 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -198,7 +198,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, } func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion { - if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) { + if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts, featuremgmt.FlagKubernetesDashboardsV2) { // If dashboards v2 is enabled, we want to use v2beta1 as the default API version. return []schema.GroupVersion{ dashv2beta1.DashboardResourceInfo.GroupVersion(), diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 6d85a66b344..19e5b71e0d9 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -136,9 +136,9 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) * ParameterProps: spec3.ParameterProps{ Name: "permission", In: "query", - Description: "permission needed for the resource (View, Edit, Admin)", + Description: "permission needed for the resource (view, edit, admin)", Required: false, - Schema: spec.StringProperty().WithEnum("View", "Edit", "Admin"), + Schema: spec.StringProperty().WithEnum("view", "edit", "admin"), }, }, { diff --git a/pkg/registry/apis/datasource/legacy_store.go b/pkg/registry/apis/datasource/legacy_store.go index 8786dc8d86e..aea7ed1daa3 100644 --- a/pkg/registry/apis/datasource/legacy_store.go +++ b/pkg/registry/apis/datasource/legacy_store.go @@ -3,7 +3,9 @@ package datasource import ( "context" "fmt" + "time" + "github.com/prometheus/client_golang/prometheus" "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -11,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" + "github.com/grafana/grafana/pkg/infra/metrics/metricutil" ) var ( @@ -26,8 +29,9 @@ var ( ) type legacyStorage struct { - datasources PluginDatasourceProvider - resourceInfo *utils.ResourceInfo + datasources PluginDatasourceProvider + resourceInfo *utils.ResourceInfo + dsConfigHandlerRequestsDuration *prometheus.HistogramVec } func (s *legacyStorage) New() runtime.Object { @@ -57,11 +61,21 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO } func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds()) + }() + return s.datasources.GetDataSource(ctx, name) } // Create implements rest.Creater. func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds()) + }() + ds, ok := obj.(*v0alpha1.DataSource) if !ok { return nil, fmt.Errorf("expected a datasource object") @@ -71,6 +85,11 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa // Update implements rest.Updater. func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds()) + }() + old, err := s.Get(ctx, name, &metav1.GetOptions{}) if err != nil { return nil, false, err @@ -107,6 +126,11 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up // Delete implements rest.GracefulDeleter. func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds()) + }() + err := s.datasources.DeleteDataSource(ctx, name) return nil, false, err } diff --git a/pkg/registry/apis/datasource/register.go b/pkg/registry/apis/datasource/register.go index 9feb46a33d4..bec8fdaec5b 100644 --- a/pkg/registry/apis/datasource/register.go +++ b/pkg/registry/apis/datasource/register.go @@ -20,6 +20,7 @@ import ( datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/promlib/models" @@ -218,6 +219,11 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver legacyStore := &legacyStorage{ datasources: b.datasources, resourceInfo: &ds, + dsConfigHandlerRequestsDuration: metricutil.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "grafana", + Name: "ds_config_handler_requests_duration_seconds", + Help: "Duration of requests handled by datasource configuration handlers", + }, []string{"code_path", "handler"}), } unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, ds, opts.OptsGetter) if err != nil { diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index a464568c12f..2417c84aed8 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -219,7 +219,9 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge } storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store) - storage[teamResource.StoragePath("groups")] = b.teamGroupsHandler + if b.teamGroupsHandler != nil { + storage[teamResource.StoragePath("groups")] = b.teamGroupsHandler + } teamBindingResource := iamv0.TeamBindingResourceInfo teamBindingUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamBindingResource, opts.OptsGetter) @@ -344,6 +346,12 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge if err != nil { return err } + if enableZanzanaSync { + b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana") + roleBindingStore.AfterCreate = b.AfterRoleBindingCreate + roleBindingStore.AfterDelete = b.AfterRoleBindingDelete + roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate + } storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore } //nolint:staticcheck // not yet migrated to OpenFeature diff --git a/pkg/registry/apis/iam/resource_permission_hooks.go b/pkg/registry/apis/iam/resource_permission_hooks.go index e725726b2ec..010bb40049b 100644 --- a/pkg/registry/apis/iam/resource_permission_hooks.go +++ b/pkg/registry/apis/iam/resource_permission_hooks.go @@ -2,7 +2,6 @@ package iam import ( "context" - "errors" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -14,8 +13,6 @@ import ( ) var ( - errEmptyName = errors.New("name cannot be empty") - defaultWriteTimeout = 15 * time.Second ) diff --git a/pkg/registry/apis/iam/role_binding_hooks.go b/pkg/registry/apis/iam/role_binding_hooks.go new file mode 100644 index 00000000000..c88c46976eb --- /dev/null +++ b/pkg/registry/apis/iam/role_binding_hooks.go @@ -0,0 +1,302 @@ +package iam + +import ( + "context" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic/registry" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" +) + +const resourceType = "rolebinding" + +// AfterRoleBindingCreate is a post-create hook that writes the role binding to Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) { + if b.zClient == nil { + return + } + + rb, ok := obj.(*iamv0.RoleBinding) + if !ok { + b.logger.Error("failed to convert object to RoleBinding type", "object", obj) + return + } + + operation := "create" + + // Grab a ticket to write to Zanzana + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(rb *iamv0.RoleBinding) { + start := time.Now() + status := "success" + + defer func() { + // Release the ticket after write is done + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + }() + + b.logger.Debug("writing role binding to zanzana", + "namespace", rb.Namespace, + "name", rb.Name, + "subject", rb.Spec.Subject.Name, + "roleRefs", rb.Spec.RoleRefs, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs)) + for _, roleRef := range rb.Spec.RoleRefs { + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: string(rb.Spec.Subject.Kind), + SubjectName: rb.Spec.Subject.Name, + RoleKind: string(roleRef.Kind), + RoleName: roleRef.Name, + }, + }, + }) + } + + if len(operations) == 0 { + return + } + + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: rb.Namespace, + Operations: operations, + }) + + if err != nil { + status = "failure" + b.logger.Error("failed to write role binding to zanzana", + "err", err, + "namespace", rb.Namespace, + "name", rb.Name, + "subject", rb.Spec.Subject.Name, + "roleRefs", rb.Spec.RoleRefs, + ) + } + }(rb.DeepCopy()) // Pass a copy of the object +} + +// AfterRoleBindingDelete is a post-delete hook that removes the role binding from Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingDelete(obj runtime.Object, _ *metav1.DeleteOptions) { + if b.zClient == nil { + return + } + + rb, ok := obj.(*iamv0.RoleBinding) + if !ok { + b.logger.Error("failed to convert object to RoleBinding type", "object", obj) + return + } + + operation := "delete" + + // Grab a ticket to write to Zanzana + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(rb *iamv0.RoleBinding) { + start := time.Now() + status := "success" + + defer func() { + // Release the ticket after write is done + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + }() + + b.logger.Debug("deleting role binding from zanzana", + "namespace", rb.Namespace, + "name", rb.Name, + "subject", rb.Spec.Subject.Name, + "roleRefs", rb.Spec.RoleRefs, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs)) + for _, roleRef := range rb.Spec.RoleRefs { + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: string(rb.Spec.Subject.Kind), + SubjectName: rb.Spec.Subject.Name, + RoleKind: string(roleRef.Kind), + RoleName: roleRef.Name, + }, + }, + }) + } + + if len(operations) == 0 { + return + } + + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: rb.Namespace, + Operations: operations, + }) + + if err != nil { + status = "failure" + b.logger.Error("failed to delete role binding from zanzana", + "err", err, + "namespace", rb.Namespace, + "name", rb.Name, + "subject", rb.Spec.Subject.Name, + "roleRefs", rb.Spec.RoleRefs, + ) + } + }(rb.DeepCopy()) // Pass a copy of the object +} + +// BeginRoleBindingUpdate is a pre-update hook that prepares zanzana updates. +// It performs the zanzana write after K8s update succeeds. +func (b *IdentityAccessManagementAPIBuilder) BeginRoleBindingUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) { + if b.zClient == nil { + return nil, nil + } + + // Extract role bindings from both old and new objects + oldRB, ok := oldObj.(*iamv0.RoleBinding) + if !ok { + return nil, nil + } + + newRB, ok := obj.(*iamv0.RoleBinding) + if !ok { + return nil, nil + } + + if oldRB.Spec.Subject.Name == newRB.Spec.Subject.Name && roleRefsEqual(oldRB.Spec.RoleRefs, newRB.Spec.RoleRefs) { + return nil, nil // No changes to the role binding + } + + if newRB.Spec.Subject.Name == "" { + b.logger.Error("invalid role binding", + "namespace", newRB.Namespace, + "name", newRB.Name, + "subject", newRB.Spec.Subject.Name, + "roleRefs", newRB.Spec.RoleRefs, + ) + return nil, nil + } + + // Return a finish function that performs the zanzana write only on success + return func(ctx context.Context, success bool) { + if !success { + return + } + + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, "update").Observe(time.Since(wait).Seconds()) + + go func() { + start := time.Now() + status := "success" + + defer func() { + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, "update", status).Observe(time.Since(start).Seconds()) + }() + + b.logger.Debug("updating role binding in zanzana", + "namespace", newRB.Namespace, + "name", newRB.Name, + "oldSubject", oldRB.Spec.Subject.Name, + "newSubject", newRB.Spec.Subject.Name, + "oldRoleRefs", oldRB.Spec.RoleRefs, + "newRoleRefs", newRB.Spec.RoleRefs, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + operations := make([]*v1.MutateOperation, 0, len(oldRB.Spec.RoleRefs)) + for _, roleRef := range oldRB.Spec.RoleRefs { + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: string(oldRB.Spec.Subject.Kind), + SubjectName: oldRB.Spec.Subject.Name, + RoleKind: string(roleRef.Kind), + RoleName: roleRef.Name, + }, + }, + }) + } + for _, roleRef := range newRB.Spec.RoleRefs { + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: string(newRB.Spec.Subject.Kind), + SubjectName: newRB.Spec.Subject.Name, + RoleKind: string(roleRef.Kind), + RoleName: roleRef.Name, + }, + }, + }) + } + + // Only make the request if there are deletes or writes + if len(operations) == 0 { + b.logger.Debug("no role bindings to update in zanzana", "namespace", newRB.Namespace, "name", newRB.Name) + return + } + + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: newRB.Namespace, + Operations: operations, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to update role binding in zanzana", + "err", err, + "namespace", newRB.Namespace, + "name", newRB.Name, + ) + } + }() + }, nil +} + +func roleRefsEqual(oldRoleRefs, newRoleRefs []iamv0.RoleBindingspecRoleRef) bool { + if len(oldRoleRefs) != len(newRoleRefs) { + return false + } + + oldRoleRefsMap := make(map[string]string) + for _, roleRef := range oldRoleRefs { + oldRoleRefsMap[roleRef.Name] = string(roleRef.Kind) + } + for _, roleRef := range newRoleRefs { + refKind, ok := oldRoleRefsMap[roleRef.Name] + if !ok { + return false + } + if refKind != string(roleRef.Kind) { + return false + } + } + return true +} diff --git a/pkg/registry/apis/iam/role_binding_hooks_test.go b/pkg/registry/apis/iam/role_binding_hooks_test.go new file mode 100644 index 00000000000..dd9646282fe --- /dev/null +++ b/pkg/registry/apis/iam/role_binding_hooks_test.go @@ -0,0 +1,448 @@ +package iam + +import ( + "context" + "slices" + "sync" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stretchr/testify/require" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" +) + +func TestAfterRoleBindingCreate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should create zanzana entry for role binding", func(t *testing.T) { + wg.Add(1) + roleBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + testRoleBinding := func(ctx context.Context, req *v1.MutateRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) + require.Equal(t, "org-1", req.Namespace) + + expectedOperation := &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-1", + }, + }, + } + + actualCreate := req.Operations[0].Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding + expectedCreate := expectedOperation.Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding + + require.Equal(t, expectedCreate.SubjectKind, actualCreate.SubjectKind) + require.Equal(t, expectedCreate.SubjectName, actualCreate.SubjectName) + require.Equal(t, expectedCreate.RoleKind, actualCreate.RoleKind) + require.Equal(t, expectedCreate.RoleName, actualCreate.RoleName) + + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBinding} + b.AfterRoleBindingCreate(&roleBinding, nil) + wg.Wait() + }) + + t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + roleBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-3", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-3", + }, + }, + }, + } + + // Should not panic or error when zClient is nil + builder.AfterRoleBindingCreate(&roleBinding, nil) + }) +} + +func TestBeginRoleBindingUpdate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should update zanzana entry when role binding changed", func(t *testing.T) { + wg.Add(1) + oldBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-foo", + }, + { + Kind: "role", + Name: "role-2", + }, + }, + }, + } + + newBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-bar", + }, + }, + }, + } + + testRoleBindingUpdate := func(ctx context.Context, req *v1.MutateRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 3) + + // Should write new binding and delete old one + require.True(t, containsOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-foo", + }, + }, + })) + + require.True(t, containsOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-bar", + }, + }, + })) + + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingUpdate} + + finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should return nil finish func when bindings are identical", func(t *testing.T) { + oldBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + newBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + writeCalled := false + testNoWriteOnNoChange := func(ctx context.Context, req *v1.MutateRequest) error { + writeCalled = true + require.Fail(t, "Write should not be called when bindings are identical") + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnNoChange} + + finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when bindings are identical + + // Verify write was never called + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when bindings are identical") + }) + + t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) { + oldBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-8", + Namespace: "org-8", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + newBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-8", + Namespace: "org-8", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "", + Name: "", // Empty name - should cause early return + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + }, + }, + } + + writeCalled := false + testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error { + writeCalled = true + require.Fail(t, "Write should not be called when new binding has empty subject name") + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnInvalidBinding} + + finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name + + // Verify write was never called + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name") + }) +} + +func TestAfterRoleBindingDelete(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should delete zanzana entry for team binding with member permission", func(t *testing.T) { + wg.Add(1) + roleBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-1", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-1", + }, + { + Kind: "role", + Name: "role-2", + }, + }, + }, + } + + testRoleBindingDelete := func(ctx context.Context, req *v1.MutateRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + // Should have deletes but no writes + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 2) + require.True(t, containsOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-1", + }, + }, + })) + require.True(t, containsOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: "user", + SubjectName: "user-1", + RoleKind: "role", + RoleName: "role-2", + }, + }, + })) + + return nil + } + + b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingDelete} + b.AfterRoleBindingDelete(&roleBinding, nil) + wg.Wait() + }) + + t.Run("should not delete from zanzana when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + roleBinding := iamv0.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.RoleBindingSpec{ + Subject: iamv0.RoleBindingspecSubject{ + Kind: "user", + Name: "user-3", + }, + RoleRefs: []iamv0.RoleBindingspecRoleRef{ + { + Kind: "role", + Name: "role-3", + }, + }, + }, + } + + // Should not panic or error when zClient is nil + builder.AfterRoleBindingDelete(&roleBinding, nil) + }) +} + +func containsOperation(operations []*v1.MutateOperation, operation *v1.MutateOperation) bool { + return slices.ContainsFunc(operations, func(o *v1.MutateOperation) bool { + switch operation.Operation.(type) { + case *v1.MutateOperation_DeleteRoleBinding: + deleteOperation := operation.Operation.(*v1.MutateOperation_DeleteRoleBinding) + deleteO, ok := o.Operation.(*v1.MutateOperation_DeleteRoleBinding) + if !ok { + return false + } + return deleteO.DeleteRoleBinding.SubjectKind == deleteOperation.DeleteRoleBinding.SubjectKind && + deleteO.DeleteRoleBinding.SubjectName == deleteOperation.DeleteRoleBinding.SubjectName && + deleteO.DeleteRoleBinding.RoleKind == deleteOperation.DeleteRoleBinding.RoleKind && + deleteO.DeleteRoleBinding.RoleName == deleteOperation.DeleteRoleBinding.RoleName + case *v1.MutateOperation_CreateRoleBinding: + createOperation := operation.Operation.(*v1.MutateOperation_CreateRoleBinding) + createO, ok := o.Operation.(*v1.MutateOperation_CreateRoleBinding) + if !ok { + return false + } + return createO.CreateRoleBinding.SubjectKind == createOperation.CreateRoleBinding.SubjectKind && + createO.CreateRoleBinding.SubjectName == createOperation.CreateRoleBinding.SubjectName && + createO.CreateRoleBinding.RoleKind == createOperation.CreateRoleBinding.RoleKind && + createO.CreateRoleBinding.RoleName == createOperation.CreateRoleBinding.RoleName + } + return false + }) +} diff --git a/pkg/registry/apis/iam/team_binding_hooks.go b/pkg/registry/apis/iam/team_binding_hooks.go index dcad58cae0a..298237df70a 100644 --- a/pkg/registry/apis/iam/team_binding_hooks.go +++ b/pkg/registry/apis/iam/team_binding_hooks.go @@ -10,42 +10,8 @@ import ( iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" - "github.com/grafana/grafana/pkg/services/authz/zanzana" ) -// convertTeamBindingToTuple converts a TeamBinding to a v1 TupleKey format -// TeamBinding represents a user's membership in a team with a specific permission level -func convertTeamBindingToTuple(tb *iamv0.TeamBinding) (*v1.TupleKey, error) { - if tb.Spec.Subject.Name == "" { - return nil, errEmptyName - } - - if tb.Spec.TeamRef.Name == "" { - return nil, errEmptyName - } - - // Map permission to relation - var relation string - switch tb.Spec.Permission { - case iamv0.TeamBindingTeamPermissionAdmin: - relation = zanzana.RelationTeamAdmin - case iamv0.TeamBindingTeamPermissionMember: - relation = zanzana.RelationTeamMember - default: - // Default to member if unknown permission - relation = zanzana.RelationTeamMember - } - - // Create tuple: user:{subjectUID} has {relation} relation to team:{teamUID} - tuple := &v1.TupleKey{ - User: zanzana.NewTupleEntry(zanzana.TypeUser, tb.Spec.Subject.Name, ""), - Relation: relation, - Object: zanzana.NewTupleEntry(zanzana.TypeTeam, tb.Spec.TeamRef.Name, ""), - } - - return tuple, nil -} - // AfterTeamBindingCreate is a post-create hook that writes the team binding to Zanzana (openFGA) func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) { if b.zClient == nil { @@ -79,20 +45,6 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime. hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() }() - tuple, err := convertTeamBindingToTuple(tb) - if err != nil { - b.logger.Error("failed to convert team binding to tuple", - "namespace", tb.Namespace, - "name", tb.Name, - "subject", tb.Spec.Subject.Name, - "teamRef", tb.Spec.TeamRef.Name, - "permission", tb.Spec.Permission, - "err", err, - ) - status = "failure" - return - } - b.logger.Debug("writing team binding to zanzana", "namespace", tb.Namespace, "name", tb.Name, @@ -104,12 +56,21 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime. ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) defer cancel() - err = b.zClient.Write(ctx, &v1.WriteRequest{ + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ Namespace: tb.Namespace, - Writes: &v1.WriteRequestWrites{ - TupleKeys: []*v1.TupleKey{tuple}, + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: tb.Spec.Subject.Name, + TeamName: tb.Spec.TeamRef.Name, + Permission: string(tb.Spec.Permission), + }, + }, + }, }, }) + if err != nil { status = "failure" b.logger.Error("failed to write team binding to zanzana", @@ -159,34 +120,28 @@ func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context. return nil, nil } - // Convert old team binding to tuple for deletion - var oldTuple *v1.TupleKey - var oldErr error - if oldTB.Spec.Subject.Name != "" && oldTB.Spec.TeamRef.Name != "" { - oldTuple, oldErr = convertTeamBindingToTuple(oldTB) - if oldErr != nil { - b.logger.Error("failed to convert old team binding to tuple", - "namespace", oldTB.Namespace, - "name", oldTB.Name, - "err", oldErr, - ) - return nil, nil - } - } - - // Convert new team binding to tuple for writing - var newTuple *v1.TupleKey - var newErr error - if newTB.Spec.Subject.Name != "" && newTB.Spec.TeamRef.Name != "" { - newTuple, newErr = convertTeamBindingToTuple(newTB) - if newErr != nil { - b.logger.Error("failed to convert new team binding to tuple", - "namespace", newTB.Namespace, - "name", newTB.Name, - "err", newErr, - ) - return nil, nil - } + operations := make([]*v1.MutateOperation, 0, 2) + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: oldTB.Spec.Subject.Name, + TeamName: oldTB.Spec.TeamRef.Name, + Permission: string(oldTB.Spec.Permission), + }, + }, + }) + operations = append(operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: newTB.Spec.Subject.Name, + TeamName: newTB.Spec.TeamRef.Name, + Permission: string(newTB.Spec.Permission), + }, + }, + }) + if len(operations) == 0 { + b.logger.Debug("no updates to team binding in zanzana", "namespace", newTB.Namespace, "name", newTB.Name) + return func(ctx context.Context, success bool) {}, nil } // Return a finish function that performs the zanzana write only on success @@ -224,57 +179,22 @@ func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context. ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) defer cancel() - // Prepare write request - req := &v1.WriteRequest{ - Namespace: newTB.Namespace, - } - - // Add delete for old tuple - if oldTuple != nil && oldErr == nil { - deleteTuple := toTupleKeysWithoutCondition([]*v1.TupleKey{oldTuple}) - req.Deletes = &v1.WriteRequestDeletes{ - TupleKeys: deleteTuple, - } - b.logger.Debug("deleting existing team binding from zanzana", - "namespace", newTB.Namespace, - "subject", oldTB.Spec.Subject.Name, - "teamRef", oldTB.Spec.TeamRef.Name, - ) - } - - // Add write for new tuple - if newTuple != nil && newErr == nil { - req.Writes = &v1.WriteRequestWrites{ - TupleKeys: []*v1.TupleKey{newTuple}, - } - b.logger.Debug("writing new team binding to zanzana", - "namespace", newTB.Namespace, - "subject", newTB.Spec.Subject.Name, - "teamRef", newTB.Spec.TeamRef.Name, - ) - } - // Only make the request if there are deletes or writes - if (req.Deletes != nil && len(req.Deletes.TupleKeys) > 0) || (req.Writes != nil && len(req.Writes.TupleKeys) > 0) { - err := b.zClient.Write(ctx, req) - if err != nil { - status = "failure" - b.logger.Error("failed to update team binding in zanzana", - "err", err, - "namespace", newTB.Namespace, - "name", newTB.Name, - ) - } else { - // Record successful tuple operations - if oldTuple != nil && oldErr == nil { - hooksTuplesCounter.WithLabelValues("teambinding", "update", "delete").Inc() - } - if newTuple != nil && newErr == nil { - hooksTuplesCounter.WithLabelValues("teambinding", "update", "write").Inc() - } - } + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ + Namespace: newTB.Namespace, + Operations: operations, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to update team binding in zanzana", + "err", err, + "namespace", newTB.Namespace, + "name", newTB.Name, + ) } else { - b.logger.Debug("no tuples to update in zanzana", "namespace", newTB.Namespace, "name", newTB.Name) + // Record successful tuple operations + hooksTuplesCounter.WithLabelValues("teambinding", "update", "delete").Inc() + hooksTuplesCounter.WithLabelValues("teambinding", "update", "write").Inc() } }() }, nil @@ -313,22 +233,6 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingDelete(obj runtime. hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() }() - tuple, err := convertTeamBindingToTuple(tb) - if err != nil { - b.logger.Error("failed to convert team binding to tuple for deletion", - "namespace", tb.Namespace, - "name", tb.Name, - "subject", tb.Spec.Subject.Name, - "teamRef", tb.Spec.TeamRef.Name, - "err", err, - ) - status = "failure" - return - } - - // Convert tuple to TupleKeyWithoutCondition for deletion - deleteTuple := toTupleKeysWithoutCondition([]*v1.TupleKey{tuple}) - b.logger.Debug("deleting team binding from zanzana", "namespace", tb.Namespace, "name", tb.Name, @@ -340,10 +244,18 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingDelete(obj runtime. ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) defer cancel() - err = b.zClient.Write(ctx, &v1.WriteRequest{ + err := b.zClient.Mutate(ctx, &v1.MutateRequest{ Namespace: tb.Namespace, - Deletes: &v1.WriteRequestDeletes{ - TupleKeys: deleteTuple, + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: tb.Spec.Subject.Name, + TeamName: tb.Spec.TeamRef.Name, + Permission: string(tb.Spec.Permission), + }, + }, + }, }, }) if err != nil { diff --git a/pkg/registry/apis/iam/team_binding_hooks_test.go b/pkg/registry/apis/iam/team_binding_hooks_test.go index 2f56ba12c87..6b41e8359da 100644 --- a/pkg/registry/apis/iam/team_binding_hooks_test.go +++ b/pkg/registry/apis/iam/team_binding_hooks_test.go @@ -2,16 +2,18 @@ package iam import ( "context" + "slices" "sync" "testing" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/stretchr/testify/require" + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/infra/log" v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" - "github.com/stretchr/testify/require" ) func TestAfterTeamBindingCreate(t *testing.T) { @@ -40,30 +42,29 @@ func TestAfterTeamBindingCreate(t *testing.T) { }, } - testMemberBinding := func(ctx context.Context, req *v1.WriteRequest) error { + testMemberBinding := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) require.Equal(t, "org-1", req.Namespace) - require.Nil(t, req.Deletes) - expectedTuple := &v1.TupleKey{ - User: "user:user-1", - Relation: "member", - Object: "team:team-1", + expectedOperation := &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-1", + Permission: "member", + }, + }, } - actualTuple := req.Writes.TupleKeys[0] - require.Equal(t, expectedTuple.User, actualTuple.User) - require.Equal(t, expectedTuple.Relation, actualTuple.Relation) - require.Equal(t, expectedTuple.Object, actualTuple.Object) - require.Nil(t, actualTuple.Condition) + require.True(t, containsTeamBindingOperation(req.Operations, expectedOperation)) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testMemberBinding} + b.zClient = &FakeZanzanaClient{mutateCallback: testMemberBinding} b.AfterTeamBindingCreate(&teamBinding, nil) wg.Wait() }) @@ -87,30 +88,29 @@ func TestAfterTeamBindingCreate(t *testing.T) { }, } - testAdminBinding := func(ctx context.Context, req *v1.WriteRequest) error { + testAdminBinding := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) require.Equal(t, "org-2", req.Namespace) - require.Nil(t, req.Deletes) - expectedTuple := &v1.TupleKey{ - User: "user:user-2", - Relation: "admin", - Object: "team:team-2", + expectedOperation := &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "user-2", + TeamName: "team-2", + Permission: "admin", + }, + }, } - actualTuple := req.Writes.TupleKeys[0] - require.Equal(t, expectedTuple.User, actualTuple.User) - require.Equal(t, expectedTuple.Relation, actualTuple.Relation) - require.Equal(t, expectedTuple.Object, actualTuple.Object) - require.Nil(t, actualTuple.Condition) + require.True(t, containsTeamBindingOperation(req.Operations, expectedOperation)) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testAdminBinding} + b.zClient = &FakeZanzanaClient{mutateCallback: testAdminBinding} b.AfterTeamBindingCreate(&teamBinding, nil) wg.Wait() }) @@ -141,40 +141,6 @@ func TestAfterTeamBindingCreate(t *testing.T) { // Should not panic or error when zClient is nil builder.AfterTeamBindingCreate(&teamBinding, nil) }) - - t.Run("should handle conversion error gracefully", func(t *testing.T) { - // TeamBinding with empty subject name should fail conversion - teamBinding := iamv0.TeamBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: "binding-4", - Namespace: "org-4", - }, - Spec: iamv0.TeamBindingSpec{ - Subject: iamv0.TeamBindingspecSubject{ - Name: "", // Empty name should cause error - }, - TeamRef: iamv0.TeamBindingTeamRef{ - Name: "team-4", - }, - Permission: iamv0.TeamBindingTeamPermissionMember, - }, - } - - writeCalled := false - testErrorHandling := func(ctx context.Context, req *v1.WriteRequest) error { - writeCalled = true - // Should not be called due to conversion error - require.Fail(t, "Write should not be called when conversion fails") - return nil - } - - b.zClient = &FakeZanzanaClient{writeCallback: testErrorHandling} - b.AfterTeamBindingCreate(&teamBinding, nil) - // Wait a bit to ensure the goroutine has time to process - // The goroutine will complete but won't call the write callback - time.Sleep(100 * time.Millisecond) - require.False(t, writeCalled, "Write callback should not be called when conversion fails") - }) } func TestBeginTeamBindingUpdate(t *testing.T) { @@ -218,33 +184,37 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - testPermissionUpdate := func(ctx context.Context, req *v1.WriteRequest) error { + testPermissionUpdate := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-1", req.Namespace) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 2) - // Should delete old member permission - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - require.Equal( - t, - req.Deletes.TupleKeys[0], - &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"}, - ) + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-1", + Permission: "member", + }, + }, + })) - // Should write new admin permission - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) - require.Equal( - t, - req.Writes.TupleKeys[0], - &v1.TupleKey{User: "user:user-1", Relation: "admin", Object: "team:team-1"}, - ) + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-1", + Permission: "admin", + }, + }, + })) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testPermissionUpdate} + b.zClient = &FakeZanzanaClient{mutateCallback: testPermissionUpdate} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) @@ -288,33 +258,36 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - testUserUpdate := func(ctx context.Context, req *v1.WriteRequest) error { + testUserUpdate := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-2", req.Namespace) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 2) - // Should delete old user binding - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - require.Equal( - t, - req.Deletes.TupleKeys[0], - &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"}, - ) - - // Should write new user binding - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) - require.Equal( - t, - req.Writes.TupleKeys[0], - &v1.TupleKey{User: "user:user-2", Relation: "member", Object: "team:team-1"}, - ) + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-1", + Permission: "member", + }, + }, + })) + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "user-2", + TeamName: "team-1", + Permission: "member", + }, + }, + })) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testUserUpdate} + b.zClient = &FakeZanzanaClient{mutateCallback: testUserUpdate} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) @@ -358,33 +331,35 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - testTeamUpdate := func(ctx context.Context, req *v1.WriteRequest) error { + testTeamUpdate := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-3", req.Namespace) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 2) - // Should delete old team binding - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - require.Equal( - t, - req.Deletes.TupleKeys[0], - &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "admin", Object: "team:team-1"}, - ) - - // Should write new team binding - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) - require.Equal( - t, - req.Writes.TupleKeys[0], - &v1.TupleKey{User: "user:user-1", Relation: "admin", Object: "team:team-2"}, - ) - + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-1", + Permission: "admin", + }, + }, + })) + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-2", + Permission: "admin", + }, + }, + })) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testTeamUpdate} + b.zClient = &FakeZanzanaClient{mutateCallback: testTeamUpdate} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) @@ -427,13 +402,13 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - testNoWriteOnFailure := func(ctx context.Context, req *v1.WriteRequest) error { + testNoMutateOnFailure := func(ctx context.Context, req *v1.MutateRequest) error { // Should not be called when success=false - require.Fail(t, "Write should not be called when update fails") + require.Fail(t, "Mutate should not be called when update fails") return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnFailure} + b.zClient = &FakeZanzanaClient{mutateCallback: testNoMutateOnFailure} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) @@ -441,7 +416,7 @@ func TestBeginTeamBindingUpdate(t *testing.T) { // Call finish function with success=false finishFunc(context.Background(), false) - // No wait needed since write should not be called + // No wait needed since mutate should not be called }) t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) { @@ -497,7 +472,7 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, Spec: iamv0.TeamBindingSpec{ Subject: iamv0.TeamBindingspecSubject{ - Name: "", // Empty name - conversion will be skipped + Name: "", // Empty name will cause server-side error on delete }, TeamRef: iamv0.TeamBindingTeamRef{ Name: "team-1", @@ -522,27 +497,42 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - testEmptyOldBinding := func(ctx context.Context, req *v1.WriteRequest) error { + testEmptyOldBinding := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-6", req.Namespace) + require.NotNil(t, req.Operations) - // Should not delete old binding (it was skipped due to empty name) - require.Nil(t, req.Deletes) + // Should have both delete and create operations + // The delete will have empty subject and fail server-side validation + require.Len(t, req.Operations, 2) - // Should write new binding - require.NotNil(t, req.Writes) - require.Len(t, req.Writes.TupleKeys, 1) - require.Equal( - t, - req.Writes.TupleKeys[0], - &v1.TupleKey{User: "user:user-2", Relation: "member", Object: "team:team-1"}, - ) + // First operation is delete with empty subject + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "", + TeamName: "team-1", + Permission: "member", + }, + }, + })) + + // Second operation is create with valid data + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "user-2", + TeamName: "team-1", + Permission: "member", + }, + }, + })) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testEmptyOldBinding} + b.zClient = &FakeZanzanaClient{mutateCallback: testEmptyOldBinding} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) @@ -585,22 +575,22 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - writeCalled := false - testNoWriteOnNoChange := func(ctx context.Context, req *v1.WriteRequest) error { - writeCalled = true - require.Fail(t, "Write should not be called when bindings are identical") + mutateCalled := false + testNoMutateOnNoChange := func(ctx context.Context, req *v1.MutateRequest) error { + mutateCalled = true + require.Fail(t, "Mutate should not be called when bindings are identical") return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnNoChange} + b.zClient = &FakeZanzanaClient{mutateCallback: testNoMutateOnNoChange} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) require.Nil(t, finishFunc) // Should return nil when bindings are identical - // Verify write was never called + // Verify mutate was never called time.Sleep(100 * time.Millisecond) - require.False(t, writeCalled, "Write callback should not be called when bindings are identical") + require.False(t, mutateCalled, "Mutate callback should not be called when bindings are identical") }) t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) { @@ -636,22 +626,22 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - writeCalled := false - testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.WriteRequest) error { - writeCalled = true - require.Fail(t, "Write should not be called when new binding has empty subject name") + mutateCalled := false + testNoMutateOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error { + mutateCalled = true + require.Fail(t, "Mutate should not be called when new binding has empty subject name") return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding} + b.zClient = &FakeZanzanaClient{mutateCallback: testNoMutateOnInvalidBinding} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name - // Verify write was never called + // Verify mutate was never called time.Sleep(100 * time.Millisecond) - require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name") + require.False(t, mutateCalled, "Mutate callback should not be called when new binding has empty subject name") }) t.Run("should return nil finish func when new binding has empty team ref name", func(t *testing.T) { @@ -687,22 +677,22 @@ func TestBeginTeamBindingUpdate(t *testing.T) { }, } - writeCalled := false - testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.WriteRequest) error { - writeCalled = true - require.Fail(t, "Write should not be called when new binding has empty team ref name") + mutateCalled := false + testNoMutateOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error { + mutateCalled = true + require.Fail(t, "Mutate should not be called when new binding has empty team ref name") return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding} + b.zClient = &FakeZanzanaClient{mutateCallback: testNoMutateOnInvalidBinding} finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) require.NoError(t, err) require.Nil(t, finishFunc) // Should return nil when new binding has empty team ref name - // Verify write was never called + // Verify mutate was never called time.Sleep(100 * time.Millisecond) - require.False(t, writeCalled, "Write callback should not be called when new binding has empty team ref name") + require.False(t, mutateCalled, "Mutate callback should not be called when new binding has empty team ref name") }) } @@ -732,26 +722,28 @@ func TestAfterTeamBindingDelete(t *testing.T) { }, } - testMemberDelete := func(ctx context.Context, req *v1.WriteRequest) error { + testMemberDelete := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-1", req.Namespace) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) - // Should have deletes but no writes - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - require.Nil(t, req.Writes) - - require.Equal( - t, - req.Deletes.TupleKeys[0], - &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"}, - ) + expectedOperation := &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "user-1", + TeamName: "team-1", + Permission: "member", + }, + }, + } + require.True(t, containsTeamBindingOperation(req.Operations, expectedOperation)) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testMemberDelete} + b.zClient = &FakeZanzanaClient{mutateCallback: testMemberDelete} b.AfterTeamBindingDelete(&teamBinding, nil) wg.Wait() }) @@ -775,26 +767,28 @@ func TestAfterTeamBindingDelete(t *testing.T) { }, } - testAdminDelete := func(ctx context.Context, req *v1.WriteRequest) error { + testAdminDelete := func(ctx context.Context, req *v1.MutateRequest) error { defer wg.Done() require.NotNil(t, req) require.Equal(t, "org-2", req.Namespace) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) - // Should have deletes but no writes - require.NotNil(t, req.Deletes) - require.Len(t, req.Deletes.TupleKeys, 1) - require.Nil(t, req.Writes) - - require.Equal( - t, - req.Deletes.TupleKeys[0], - &v1.TupleKeyWithoutCondition{User: "user:user-2", Relation: "admin", Object: "team:team-2"}, - ) + expectedOperation := &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "user-2", + TeamName: "team-2", + Permission: "admin", + }, + }, + } + require.True(t, containsTeamBindingOperation(req.Operations, expectedOperation)) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testAdminDelete} + b.zClient = &FakeZanzanaClient{mutateCallback: testAdminDelete} b.AfterTeamBindingDelete(&teamBinding, nil) wg.Wait() }) @@ -826,8 +820,9 @@ func TestAfterTeamBindingDelete(t *testing.T) { builder.AfterTeamBindingDelete(&teamBinding, nil) }) - t.Run("should handle conversion error gracefully", func(t *testing.T) { - // TeamBinding with empty team ref name should fail conversion + t.Run("should handle empty team name gracefully", func(t *testing.T) { + wg.Add(1) + // TeamBinding with empty team ref name will be sent to server which will return error teamBinding := iamv0.TeamBinding{ ObjectMeta: metav1.ObjectMeta{ Name: "binding-4", @@ -838,129 +833,60 @@ func TestAfterTeamBindingDelete(t *testing.T) { Name: "user-4", }, TeamRef: iamv0.TeamBindingTeamRef{ - Name: "", // Empty name should cause error + Name: "", // Empty name will cause server-side error }, Permission: iamv0.TeamBindingTeamPermissionMember, }, } - writeCalled := false - testErrorHandling := func(ctx context.Context, req *v1.WriteRequest) error { - writeCalled = true - // Should not be called due to conversion error - require.Fail(t, "Write should not be called when conversion fails") + testErrorHandling := func(ctx context.Context, req *v1.MutateRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Operations) + require.Len(t, req.Operations, 1) + require.Equal(t, "org-4", req.Namespace) + + // Operation will have empty team name, which would fail server-side validation + require.True(t, containsTeamBindingOperation(req.Operations, &v1.MutateOperation{ + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "user-4", + TeamName: "", + Permission: "member", + }, + }, + })) return nil } - b.zClient = &FakeZanzanaClient{writeCallback: testErrorHandling} + b.zClient = &FakeZanzanaClient{mutateCallback: testErrorHandling} b.AfterTeamBindingDelete(&teamBinding, nil) - // Wait a bit to ensure the goroutine has time to process - // The goroutine will complete but won't call the write callback - time.Sleep(100 * time.Millisecond) - require.False(t, writeCalled, "Write callback should not be called when conversion fails") + wg.Wait() }) } -func TestConvertTeamBindingToTuple(t *testing.T) { - t.Run("should convert member permission correctly", func(t *testing.T) { - tb := &iamv0.TeamBinding{ - Spec: iamv0.TeamBindingSpec{ - Subject: iamv0.TeamBindingspecSubject{ - Name: "user-1", - }, - TeamRef: iamv0.TeamBindingTeamRef{ - Name: "team-1", - }, - Permission: iamv0.TeamBindingTeamPermissionMember, - }, +func containsTeamBindingOperation(operations []*v1.MutateOperation, operation *v1.MutateOperation) bool { + return slices.ContainsFunc(operations, func(o *v1.MutateOperation) bool { + switch operation.Operation.(type) { + case *v1.MutateOperation_DeleteTeamBinding: + deleteOperation := operation.Operation.(*v1.MutateOperation_DeleteTeamBinding) + deleteO, ok := o.Operation.(*v1.MutateOperation_DeleteTeamBinding) + if !ok { + return false + } + return deleteO.DeleteTeamBinding.SubjectName == deleteOperation.DeleteTeamBinding.SubjectName && + deleteO.DeleteTeamBinding.TeamName == deleteOperation.DeleteTeamBinding.TeamName && + deleteO.DeleteTeamBinding.Permission == deleteOperation.DeleteTeamBinding.Permission + case *v1.MutateOperation_CreateTeamBinding: + createOperation := operation.Operation.(*v1.MutateOperation_CreateTeamBinding) + createO, ok := o.Operation.(*v1.MutateOperation_CreateTeamBinding) + if !ok { + return false + } + return createO.CreateTeamBinding.SubjectName == createOperation.CreateTeamBinding.SubjectName && + createO.CreateTeamBinding.TeamName == createOperation.CreateTeamBinding.TeamName && + createO.CreateTeamBinding.Permission == createOperation.CreateTeamBinding.Permission } - - tuple, err := convertTeamBindingToTuple(tb) - require.NoError(t, err) - require.NotNil(t, tuple) - require.Equal(t, "user:user-1", tuple.User) - require.Equal(t, "member", tuple.Relation) - require.Equal(t, "team:team-1", tuple.Object) - require.Nil(t, tuple.Condition) - }) - - t.Run("should convert admin permission correctly", func(t *testing.T) { - tb := &iamv0.TeamBinding{ - Spec: iamv0.TeamBindingSpec{ - Subject: iamv0.TeamBindingspecSubject{ - Name: "user-2", - }, - TeamRef: iamv0.TeamBindingTeamRef{ - Name: "team-2", - }, - Permission: iamv0.TeamBindingTeamPermissionAdmin, - }, - } - - tuple, err := convertTeamBindingToTuple(tb) - require.NoError(t, err) - require.NotNil(t, tuple) - require.Equal(t, "user:user-2", tuple.User) - require.Equal(t, "admin", tuple.Relation) - require.Equal(t, "team:team-2", tuple.Object) - require.Nil(t, tuple.Condition) - }) - - t.Run("should return error for empty subject name", func(t *testing.T) { - tb := &iamv0.TeamBinding{ - Spec: iamv0.TeamBindingSpec{ - Subject: iamv0.TeamBindingspecSubject{ - Name: "", - }, - TeamRef: iamv0.TeamBindingTeamRef{ - Name: "team-1", - }, - Permission: iamv0.TeamBindingTeamPermissionMember, - }, - } - - tuple, err := convertTeamBindingToTuple(tb) - require.Error(t, err) - require.Nil(t, tuple) - require.Equal(t, errEmptyName, err) - }) - - t.Run("should return error for empty team ref name", func(t *testing.T) { - tb := &iamv0.TeamBinding{ - Spec: iamv0.TeamBindingSpec{ - Subject: iamv0.TeamBindingspecSubject{ - Name: "user-1", - }, - TeamRef: iamv0.TeamBindingTeamRef{ - Name: "", - }, - Permission: iamv0.TeamBindingTeamPermissionMember, - }, - } - - tuple, err := convertTeamBindingToTuple(tb) - require.Error(t, err) - require.Nil(t, tuple) - require.Equal(t, errEmptyName, err) - }) - - t.Run("should default to member for unknown permission", func(t *testing.T) { - tb := &iamv0.TeamBinding{ - Spec: iamv0.TeamBindingSpec{ - Subject: iamv0.TeamBindingspecSubject{ - Name: "user-1", - }, - TeamRef: iamv0.TeamBindingTeamRef{ - Name: "team-1", - }, - Permission: "unknown", // Invalid permission - }, - } - - tuple, err := convertTeamBindingToTuple(tb) - require.NoError(t, err) - require.NotNil(t, tuple) - // Should default to member relation - require.Equal(t, "member", tuple.Relation) + return false }) } diff --git a/pkg/registry/apis/ofrep/register.go b/pkg/registry/apis/ofrep/register.go index d6a443cfee7..cd6f8f0eb29 100644 --- a/pkg/registry/apis/ofrep/register.go +++ b/pkg/registry/apis/ofrep/register.go @@ -64,6 +64,10 @@ func NewAPIBuilder(providerType string, url *url.URL, insecure bool, caFile stri } func RegisterAPIService(apiregistration builder.APIRegistrar, cfg *setting.Cfg) (*APIBuilder, error) { + if !cfg.OpenFeature.APIEnabled { + return nil, nil + } + var staticEvaluator featuremgmt.StaticFlagEvaluator // No static evaluator needed for non-static provider var err error if cfg.OpenFeature.ProviderType == setting.StaticProviderType { diff --git a/pkg/registry/apis/provisioning/routes.go b/pkg/registry/apis/provisioning/routes.go index 4ffefcdfdbb..494d47d16e7 100644 --- a/pkg/registry/apis/provisioning/routes.go +++ b/pkg/registry/apis/provisioning/routes.go @@ -172,6 +172,8 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) { for i, val := range all { branch := val.Branch() + url := val.URL() + path := val.Path() settings.Items[i] = provisioning.RepositoryView{ Name: val.Name, @@ -179,6 +181,8 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) { Type: val.Spec.Type, Target: val.Spec.Sync.Target, Branch: branch, + URL: url, + Path: path, Workflows: val.Spec.Workflows, } } diff --git a/pkg/registry/apis/secret/contracts/secure_value.go b/pkg/registry/apis/secret/contracts/secure_value.go index bfb763be3f6..09702fa7f5f 100644 --- a/pkg/registry/apis/secret/contracts/secure_value.go +++ b/pkg/registry/apis/secret/contracts/secure_value.go @@ -11,7 +11,7 @@ import ( ) // The maximum size of a secure value in bytes when written as raw input. -const SecureValueRawInputMaxSizeBytes = 24576 // 24 KiB +const SecureValueRawInputMaxSizeBytes = 24 << 10 // 24 KiB type DecryptSecureValue struct { Keeper *string @@ -47,7 +47,6 @@ type SecureValueService interface { List(ctx context.Context, namespace xkube.Namespace) (*secretv1beta1.SecureValueList, error) Update(ctx context.Context, newSecureValue *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, bool, error) Delete(ctx context.Context, namespace xkube.Namespace, name string) (*secretv1beta1.SecureValue, error) - SetKeeperAsActive(ctx context.Context, namespace xkube.Namespace, keeperName string) error } type SecureValueClient interface { diff --git a/pkg/registry/apis/secret/service/secure_value.go b/pkg/registry/apis/secret/service/secure_value.go index 91f6a2e73ff..30b7f4a625d 100644 --- a/pkg/registry/apis/secret/service/secure_value.go +++ b/pkg/registry/apis/secret/service/secure_value.go @@ -27,7 +27,6 @@ var _ contracts.SecureValueService = (*SecureValueService)(nil) type SecureValueService struct { tracer trace.Tracer accessClient claims.AccessClient - database contracts.Database secureValueMetadataStorage contracts.SecureValueMetadataStorage secureValueValidator contracts.SecureValueValidator secureValueMutator contracts.SecureValueMutator @@ -39,7 +38,6 @@ type SecureValueService struct { func ProvideSecureValueService( tracer trace.Tracer, accessClient claims.AccessClient, - database contracts.Database, secureValueMetadataStorage contracts.SecureValueMetadataStorage, secureValueValidator contracts.SecureValueValidator, secureValueMutator contracts.SecureValueMutator, @@ -50,7 +48,6 @@ func ProvideSecureValueService( return &SecureValueService{ tracer: tracer, accessClient: accessClient, - database: database, secureValueMetadataStorage: secureValueMetadataStorage, secureValueValidator: secureValueValidator, secureValueMutator: secureValueMutator, @@ -369,10 +366,3 @@ func (s *SecureValueService) Delete(ctx context.Context, namespace xkube.Namespa return sv, nil } - -func (s *SecureValueService) SetKeeperAsActive(ctx context.Context, namespace xkube.Namespace, name string) error { - if err := s.keeperMetadataStorage.SetAsActive(ctx, namespace, name); err != nil { - return fmt.Errorf("calling keeper metadata storage to set keeper as active: %w", err) - } - return nil -} diff --git a/pkg/registry/apis/secret/testutils/testutils.go b/pkg/registry/apis/secret/testutils/testutils.go index 6a5f1e40d30..37394905b79 100644 --- a/pkg/registry/apis/secret/testutils/testutils.go +++ b/pkg/registry/apis/secret/testutils/testutils.go @@ -152,7 +152,7 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { secureValueValidator := validator.ProvideSecureValueValidator() secureValueMutator := mutator.ProvideSecureValueMutator() - secureValueService := service.ProvideSecureValueService(tracer, accessClient, database, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, keeperService, nil) + secureValueService := service.ProvideSecureValueService(tracer, accessClient, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, keeperService, nil) decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer, nil) diff --git a/pkg/registry/apps/alerting/historian/handlers.go b/pkg/registry/apps/alerting/historian/handlers.go new file mode 100644 index 00000000000..f24323684f9 --- /dev/null +++ b/pkg/registry/apps/alerting/historian/handlers.go @@ -0,0 +1,60 @@ +package historian + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-plugin-sdk-go/data" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/ngalert/api" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +type Historian interface { + Query(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) +} + +type handlers struct { + historian Historian +} + +func (h handlers) GetAlertStateHistoryHandler(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error { + user, err := identity.GetRequester(ctx) + if err != nil { + return &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusUnauthorized, + Message: "authentication required", + }} + } + + query, err := api.ParseHistoryQuery(user.GetOrgID(), user, request.URL.Query()) + if err != nil { + return &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusBadRequest, + Message: err.Error(), + }} + } + + frame, err := h.historian.Query(ctx, query) + if err != nil { + return &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusInternalServerError, + Message: err.Error(), + }} + } + + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + return json.NewEncoder(writer).Encode(frame) +} diff --git a/pkg/registry/apps/alerting/historian/handlers_test.go b/pkg/registry/apps/alerting/historian/handlers_test.go new file mode 100644 index 00000000000..30a7f2ba764 --- /dev/null +++ b/pkg/registry/apps/alerting/historian/handlers_test.go @@ -0,0 +1,309 @@ +package historian + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +type mockHistorian struct { + queryFunc func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) +} + +func (m *mockHistorian) Query(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + if m.queryFunc != nil { + return m.queryFunc(ctx, query) + } + return nil, errors.New("not implemented") +} + +type mockResponseWriter struct { + *httptest.ResponseRecorder + headers http.Header +} + +func newMockResponseWriter() *mockResponseWriter { + return &mockResponseWriter{ + ResponseRecorder: httptest.NewRecorder(), + headers: make(http.Header), + } +} + +func (m *mockResponseWriter) Header() http.Header { + return m.headers +} + +func TestGetAlertStateHistoryHandler(t *testing.T) { + t.Run("returns data frame when query succeeds", func(t *testing.T) { + now := time.Now() + testFrame := data.NewFrame("test", + data.NewField("Time", nil, []time.Time{now, now.Add(time.Second)}), + data.NewField("Line", nil, []string{"alert fired", "alert resolved"}), + ) + + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + assert.Equal(t, int64(123), query.OrgID) + assert.NotNil(t, query.SignedInUser) + return testFrame, nil + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{ + OrgID: 123, + }) + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: ""}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, writer.Code) + assert.Equal(t, "application/json", writer.headers.Get("Content-Type")) + + var result *data.Frame + err = json.Unmarshal(writer.Body.Bytes(), &result) + require.NoError(t, err) + assert.Equal(t, "test", result.Name) + assert.Equal(t, 2, result.Rows()) + }) + + t.Run("passes query parameters to historian", func(t *testing.T) { + testFrame := data.NewFrame("test", + data.NewField("Time", nil, []time.Time{time.Now()}), + data.NewField("Line", nil, []string{"test"}), + ) + + var capturedQuery models.HistoryQuery + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + capturedQuery = query + return testFrame, nil + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 99}) + + params := url.Values{} + params.Set("ruleUID", "rule-123") + params.Set("dashboardUID", "dash-456") + params.Set("panelID", "7") + params.Set("from", "1000") + params.Set("to", "2000") + params.Set("limit", "50") + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: params.Encode()}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.NoError(t, err) + assert.Equal(t, "rule-123", capturedQuery.RuleUID) + assert.Equal(t, "dash-456", capturedQuery.DashboardUID) + assert.Equal(t, int64(7), capturedQuery.PanelID) + assert.Equal(t, time.Unix(1000, 0), capturedQuery.From) + assert.Equal(t, time.Unix(2000, 0), capturedQuery.To) + assert.Equal(t, 50, capturedQuery.Limit) + }) + + t.Run("handles label matchers in query", func(t *testing.T) { + testFrame := data.NewFrame("test", + data.NewField("Time", nil, []time.Time{time.Now()}), + data.NewField("Line", nil, []string{"test"}), + ) + + var capturedQuery models.HistoryQuery + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + capturedQuery = query + return testFrame, nil + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1}) + + params := url.Values{} + params.Add("labels", `env=prod`) + params.Add("labels", `region=us-west`) + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: params.Encode()}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.NoError(t, err) + if len(capturedQuery.Labels) > 0 { + assert.NotEmpty(t, capturedQuery.Labels) + } + }) + + t.Run("returns unauthorized when no user in context", func(t *testing.T) { + h := handlers{historian: &mockHistorian{}} + ctx := context.Background() + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: ""}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication required") + }) + + t.Run("returns internal error when historian query fails", func(t *testing.T) { + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + return nil, errors.New("database connection failed") + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1}) + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: ""}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.Error(t, err) + assert.Contains(t, err.Error(), "database connection failed") + }) + + t.Run("returns empty frame when no results", func(t *testing.T) { + emptyFrame := data.NewFrame("empty") + + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + return emptyFrame, nil + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1}) + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: ""}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, writer.Code) + + var result *data.Frame + err = json.Unmarshal(writer.Body.Bytes(), &result) + require.NoError(t, err) + assert.Equal(t, 0, result.Rows()) + }) + + t.Run("encodes complex data frame with multiple fields", func(t *testing.T) { + now := time.Now() + complexFrame := data.NewFrame("complex", + data.NewField("Time", nil, []time.Time{now}), + data.NewField("Line", nil, []string{"alert fired"}), + data.NewField("Value", nil, []float64{42.5}), + data.NewField("Labels", nil, []string{`{"env":"prod"}`}), + ) + + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + return complexFrame, nil + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1}) + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: ""}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, writer.Code) + + var result *data.Frame + err = json.Unmarshal(writer.Body.Bytes(), &result) + require.NoError(t, err) + assert.Equal(t, 4, len(result.Fields)) + assert.Equal(t, 1, result.Rows()) + }) +} + +func TestParseHistoryQueryIntegration(t *testing.T) { + t.Run("parses all supported query parameters", func(t *testing.T) { + testFrame := data.NewFrame("test", + data.NewField("Time", nil, []time.Time{time.Now()}), + data.NewField("Line", nil, []string{"test"}), + ) + + var capturedQuery models.HistoryQuery + mock := &mockHistorian{ + queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) { + capturedQuery = query + return testFrame, nil + }, + } + + h := handlers{historian: mock} + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 5}) + + params := url.Values{} + params.Set("ruleUID", "test-rule") + params.Set("dashboardUID", "test-dash") + params.Set("panelID", "3") + params.Set("from", "1609459200") + params.Set("to", "1609545600") + params.Set("limit", "100") + params.Set("current", "alerting") + params.Set("previous", "normal") + + writer := newMockResponseWriter() + req := &app.CustomRouteRequest{ + URL: &url.URL{RawQuery: params.Encode()}, + } + + err := h.GetAlertStateHistoryHandler(ctx, writer, req) + + require.NoError(t, err) + assert.Equal(t, int64(5), capturedQuery.OrgID) + assert.Equal(t, "test-rule", capturedQuery.RuleUID) + assert.Equal(t, "test-dash", capturedQuery.DashboardUID) + assert.Equal(t, int64(3), capturedQuery.PanelID) + assert.Equal(t, time.Unix(1609459200, 0), capturedQuery.From) + assert.Equal(t, time.Unix(1609545600, 0), capturedQuery.To) + assert.Equal(t, 100, capturedQuery.Limit) + assert.Equal(t, "alerting", capturedQuery.Current) + assert.Equal(t, "normal", capturedQuery.Previous) + }) +} diff --git a/pkg/registry/apps/alerting/historian/register.go b/pkg/registry/apps/alerting/historian/register.go new file mode 100644 index 00000000000..fb0e90e7062 --- /dev/null +++ b/pkg/registry/apps/alerting/historian/register.go @@ -0,0 +1,58 @@ +package historian + +import ( + "github.com/grafana/grafana-app-sdk/app" + appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" + "github.com/grafana/grafana-app-sdk/simple" + restclient "k8s.io/client-go/rest" + + "github.com/grafana/grafana/apps/alerting/historian/pkg/apis" + historianApp "github.com/grafana/grafana/apps/alerting/historian/pkg/app" + historianAppConfig "github.com/grafana/grafana/apps/alerting/historian/pkg/app/config" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert" + "github.com/grafana/grafana/pkg/setting" +) + +var ( + _ appsdkapiserver.AppInstaller = (*AlertingHistorianAppInstaller)(nil) +) + +type AlertingHistorianAppInstaller struct { + appsdkapiserver.AppInstaller +} + +func RegisterAppInstaller( + cfg *setting.Cfg, + ng *ngalert.AlertNG, +) (*AlertingHistorianAppInstaller, error) { + if ng.IsDisabled() { + log.New("app-registry").Info("Skipping Kubernetes Alerting Historian apiserver (historian.alerting.grafana.app): Unified Alerting is disabled") + return nil, nil + } + + installer := &AlertingHistorianAppInstaller{} + + handlers := &handlers{ + historian: ng.Api.Historian, + } + + appSpecificConfig := historianAppConfig.RuntimeConfig{ + GetAlertStateHistoryHandler: handlers.GetAlertStateHistoryHandler, + } + + provider := simple.NewAppProvider(apis.LocalManifest(), appSpecificConfig, historianApp.New) + + appConfig := app.Config{ + KubeConfig: restclient.Config{}, + ManifestData: *apis.LocalManifest().ManifestData, + SpecificConfig: appSpecificConfig, + } + + i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, &apis.GoTypeAssociator{}) + if err != nil { + return nil, err + } + installer.AppInstaller = i + return installer, nil +} diff --git a/pkg/registry/apps/apps.go b/pkg/registry/apps/apps.go index 983605dedbc..9ea31109495 100644 --- a/pkg/registry/apps/apps.go +++ b/pkg/registry/apps/apps.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/registry/apps/advisor" + "github.com/grafana/grafana/pkg/registry/apps/alerting/historian" "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" "github.com/grafana/grafana/pkg/registry/apps/annotation" @@ -42,6 +43,7 @@ func ProvideAppInstallers( annotationAppInstaller *annotation.AnnotationAppInstaller, exampleAppInstaller *example.ExampleAppInstaller, advisorAppInstaller *advisor.AdvisorAppInstaller, + alertingHistorianAppInstaller *historian.AlertingHistorianAppInstaller, ) []appsdkapiserver.AppInstaller { installers := []appsdkapiserver.AppInstaller{ playlistAppInstaller, @@ -75,6 +77,10 @@ func ProvideAppInstallers( if features.IsEnabledGlobally(featuremgmt.FlagGrafanaAdvisor) { installers = append(installers, advisorAppInstaller) } + //nolint:staticcheck // not yet migrated to OpenFeature + if features.IsEnabledGlobally(featuremgmt.FlagKubernetesAlertingHistorian) && alertingHistorianAppInstaller != nil { + installers = append(installers, alertingHistorianAppInstaller) + } return installers } diff --git a/pkg/registry/apps/apps_test.go b/pkg/registry/apps/apps_test.go index b7f6c2565e6..6a6f0c9a2aa 100644 --- a/pkg/registry/apps/apps_test.go +++ b/pkg/registry/apps/apps_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/registry/apps/advisor" + "github.com/grafana/grafana/pkg/registry/apps/alerting/historian" "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" "github.com/grafana/grafana/pkg/registry/apps/annotation" @@ -25,6 +26,8 @@ func TestProvideAppInstallers_Table(t *testing.T) { annotationAppInstaller := &annotation.AnnotationAppInstaller{} exampleAppInstaller := &example.ExampleAppInstaller{} advisorAppInstaller := &advisor.AdvisorAppInstaller{} + historianAppInstaller := &historian.AlertingHistorianAppInstaller{} + tests := []struct { name string flags []any @@ -40,7 +43,7 @@ func TestProvideAppInstallers_Table(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { features := featuremgmt.WithFeatures(tt.flags...) - got := ProvideAppInstallers(features, playlistInstaller, pluginsInstaller, nil, tt.rulesInst, correlationsAppInstaller, notificationsAppInstaller, nil, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller) + got := ProvideAppInstallers(features, playlistInstaller, pluginsInstaller, nil, tt.rulesInst, correlationsAppInstaller, notificationsAppInstaller, nil, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, historianAppInstaller) if tt.expectRulesApp { require.Contains(t, got, tt.rulesInst) } else { diff --git a/pkg/registry/apps/shorturl/register.go b/pkg/registry/apps/shorturl/register.go index c5d314ff2bc..e15950dbc56 100644 --- a/pkg/registry/apps/shorturl/register.go +++ b/pkg/registry/apps/shorturl/register.go @@ -5,6 +5,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/rest" restclient "k8s.io/client-go/rest" @@ -55,6 +56,10 @@ func RegisterAppInstaller( return installer, nil } +func (a *ShortURLAppInstaller) GetAuthorizer() authorizer.Authorizer { + return shorturlapp.GetAuthorizer() +} + func (s *ShortURLAppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) grafanarest.Storage { gvr := shorturl.ShortURLKind().GroupVersionResource() if requested.String() != gvr.String() { diff --git a/pkg/registry/apps/wireset.go b/pkg/registry/apps/wireset.go index fb403ae4df7..cc6953e5dce 100644 --- a/pkg/registry/apps/wireset.go +++ b/pkg/registry/apps/wireset.go @@ -3,6 +3,7 @@ package appregistry import ( "github.com/google/wire" + "github.com/grafana/grafana/pkg/registry/apps/alerting/historian" "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" "github.com/grafana/grafana/pkg/registry/apps/annotation" @@ -25,6 +26,7 @@ var WireSet = wire.NewSet( correlations.RegisterAppInstaller, rules.RegisterAppInstaller, notifications.RegisterAppInstaller, + historian.RegisterAppInstaller, logsdrilldown.RegisterAppInstaller, annotation.RegisterAppInstaller, example.RegisterAppInstaller, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 279a67f4134..3304a905fc0 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -80,6 +80,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/userstorage" "github.com/grafana/grafana/pkg/registry/apps" advisor2 "github.com/grafana/grafana/pkg/registry/apps/advisor" + "github.com/grafana/grafana/pkg/registry/apps/alerting/historian" notifications2 "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" "github.com/grafana/grafana/pkg/registry/apps/alerting/rules" "github.com/grafana/grafana/pkg/registry/apps/annotation" @@ -459,8 +460,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } ossDashboardStats := builders.ProvideDashboardStats() documentBuilderSupplier := search.ProvideDocumentBuilders(sqlStore, ossDashboardStats) - databaseDatabase := database4.ProvideDatabase(sqlStore, tracer) clockClock := clock.ProvideClock() + databaseDatabase := database4.ProvideDatabase(sqlStore, tracer) secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(clockClock, databaseDatabase, tracer, registerer) if err != nil { return nil, err @@ -508,7 +509,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - secureValueService := service5.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer) + secureValueService := service5.ProvideSecureValueService(tracer, accessClient, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer) inlineSecureValueSupport, err := inline.ProvideInlineSecureValueService(cfg, tracer, secureValueService, accessClient) if err != nil { return nil, err @@ -668,7 +669,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api azurePromMigrationService := promtypemigration.ProvideAzurePromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg) amazonPromMigrationService := promtypemigration.ProvideAmazonPromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg) promTypeMigrationProviderImpl := promtypemigration.ProvidePromTypeMigrationProvider(serverLockService, featureToggles, azurePromMigrationService, amazonPromMigrationService) - provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl) + provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl, serverLockService) if err != nil { return nil, err } @@ -825,7 +826,11 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller) + alertingHistorianAppInstaller, err := historian.RegisterAppInstaller(cfg, alertNG) + if err != nil { + return nil, err + } + v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) if err != nil { @@ -1107,8 +1112,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } ossDashboardStats := builders.ProvideDashboardStats() documentBuilderSupplier := search.ProvideDocumentBuilders(sqlStore, ossDashboardStats) - databaseDatabase := database4.ProvideDatabase(sqlStore, tracer) clockClock := clock.ProvideClock() + databaseDatabase := database4.ProvideDatabase(sqlStore, tracer) secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(clockClock, databaseDatabase, tracer, registerer) if err != nil { return nil, err @@ -1156,7 +1161,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - secureValueService := service5.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer) + secureValueService := service5.ProvideSecureValueService(tracer, accessClient, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer) inlineSecureValueSupport, err := inline.ProvideInlineSecureValueService(cfg, tracer, secureValueService, accessClient) if err != nil { return nil, err @@ -1312,7 +1317,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac azurePromMigrationService := promtypemigration.ProvideAzurePromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg) amazonPromMigrationService := promtypemigration.ProvideAmazonPromMigrationService(service15, inMemory, repoManager, pluginInstaller, cfg) promTypeMigrationProviderImpl := promtypemigration.ProvidePromTypeMigrationProvider(serverLockService, featureToggles, azurePromMigrationService, amazonPromMigrationService) - provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl) + provisioningServiceImpl, err := provisioning.ProvideService(accessControl, cfg, sqlStore, pluginstoreService, dBstore, serviceService, notificationService, dashboardProvisioningService, service15, correlationsService, dashboardService, folderimplService, service13, searchService, quotaService, secretsService, orgService, receiverPermissionsService, tracingService, dualwriteService, promTypeMigrationProviderImpl, serverLockService) if err != nil { return nil, err } @@ -1475,7 +1480,11 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller) + alertingHistorianAppInstaller, err := historian.RegisterAppInstaller(cfg, alertNG) + if err != nil { + return nil, err + } + v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) if err != nil { diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index ae90295c01d..603c49f3cb2 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -22,7 +22,6 @@ import ( k8srequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/generic" genericapiserver "k8s.io/apiserver/pkg/server" - serverstorage "k8s.io/apiserver/pkg/server/storage" "k8s.io/apiserver/pkg/util/openapi" k8sscheme "k8s.io/client-go/kubernetes/scheme" k8stracing "k8s.io/component-base/tracing" @@ -73,36 +72,13 @@ var PathRewriters = []filters.PathRewriter{ }, } -// GetDefaultBuildHandlerChainFuncForAggregator is a replica of GetDefaultBuildHandlerChainFunc except it skips custom routes handling -func GetDefaultBuildHandlerChainFuncForAggregator() BuildHandlerChainFunc { - return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler { - // filters.WithRequester needs to be after the K8s chain because it depends on the K8s user in context - handler := filters.WithRequester(delegateHandler) - - // Call DefaultBuildHandlerChain on the main entrypoint http.Handler - // See https://github.com/kubernetes/apiserver/blob/v0.28.0/pkg/server/config.go#L906 - // DefaultBuildHandlerChain provides many things, notably CORS, HSTS, cache-control, authz and latency tracking - handler = genericapiserver.DefaultBuildHandlerChain(handler, c) - - handler = filters.WithAcceptHeader(handler) - handler = filters.WithPathRewriters(handler, PathRewriters) - handler = k8stracing.WithTracing(handler, c.TracerProvider, "KubernetesAPI") - handler = filters.WithExtractJaegerTrace(handler) - // Configure filters.WithPanicRecovery to not crash on panic - utilruntime.ReallyCrash = false - - return handler - } -} func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc { return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler { requestHandler, err := GetCustomRoutesHandler( delegateHandler, c.LoopbackClientConfig, builders, - reg, - c.MergedResourceConfig, - ) + reg) if err != nil { panic(fmt.Sprintf("could not build the request handler for specified API builders: %s", err.Error())) } @@ -129,8 +105,6 @@ func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus. } } -// SetupConfig sets up the server config for the API server -// specify isAggregator=true, if the chain is being constructed for kube-aggregator func SetupConfig( scheme *runtime.Scheme, serverConfig *genericapiserver.RecommendedConfig, @@ -140,7 +114,6 @@ func SetupConfig( gvs []schema.GroupVersion, additionalOpenAPIDefGetters []common.GetOpenAPIDefinitions, reg prometheus.Registerer, - apiResourceConfig *serverstorage.ResourceConfig, ) error { serverConfig.AdmissionControl = NewAdmissionFromBuilders(builders) defsGetter := GetOpenAPIDefinitions(builders, additionalOpenAPIDefGetters...) @@ -153,7 +126,7 @@ func SetupConfig( openapinamer.NewDefinitionNamer(scheme, k8sscheme.Scheme)) // Add the custom routes to service discovery - serverConfig.OpenAPIV3Config.PostProcessSpec = getOpenAPIPostProcessor(buildVersion, builders, gvs, apiResourceConfig) + serverConfig.OpenAPIV3Config.PostProcessSpec = getOpenAPIPostProcessor(buildVersion, builders, gvs) serverConfig.OpenAPIV3Config.GetOperationIDAndTagsFromRoute = func(r common.Route) (string, []string, error) { meta := r.Metadata() kind := "" @@ -314,7 +287,6 @@ func InstallAPIs( features featuremgmt.FeatureToggles, dualWriterMetrics *grafanarest.DualWriterMetrics, builderMetrics *BuilderMetrics, - apiResourceConfig *serverstorage.ResourceConfig, ) error { // dual writing is only enabled when the storage type is not legacy. // this is needed to support setting a default RESTOptionsGetter for new APIs that don't @@ -429,9 +401,34 @@ func InstallAPIs( for group, buildersForGroup := range buildersGroupMap { g := genericapiserver.NewDefaultAPIGroupInfo(group, scheme, metav1.ParameterCodec, codecs) for _, b := range buildersForGroup { - if err := installAPIGroupsForBuilder(&g, group, b, apiResourceConfig, scheme, optsGetter, dualWrite, reg, optsregister, storageOpts, features); err != nil { + if err := b.UpdateAPIGroupInfo(&g, APIGroupOptions{ + Scheme: scheme, + OptsGetter: optsGetter, + DualWriteBuilder: dualWrite, + MetricsRegister: reg, + StorageOptsRegister: optsregister, + StorageOpts: storageOpts, + }); err != nil { return err } + if len(g.PrioritizedVersions) < 1 { + continue + } + + // if grafanaAPIServerWithExperimentalAPIs is not enabled, remove v0alpha1 resources unless explicitly allowed + //nolint:staticcheck // not yet migrated to OpenFeature + if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { + if resources, ok := g.VersionedResourcesStorageMap["v0alpha1"]; ok { + for name := range resources { + if !allowRegisteringResourceByInfo(b.AllowedV0Alpha1Resources(), name) { + delete(resources, name) + } + } + if len(resources) == 0 { + delete(g.VersionedResourcesStorageMap, "v0alpha1") + } + } + } } // skip installing the group if there are no resources left after filtering @@ -448,53 +445,6 @@ func InstallAPIs( return nil } -func installAPIGroupsForBuilder(g *genericapiserver.APIGroupInfo, group string, b APIGroupBuilder, apiResourceConfig *serverstorage.ResourceConfig, scheme *runtime.Scheme, - optsGetter generic.RESTOptionsGetter, dualWrite grafanarest.DualWriteBuilder, reg prometheus.Registerer, optsregister apistore.StorageOptionsRegister, - storageOpts *options.StorageOptions, features featuremgmt.FeatureToggles) error { - if err := b.UpdateAPIGroupInfo(g, APIGroupOptions{ - Scheme: scheme, - OptsGetter: optsGetter, - DualWriteBuilder: dualWrite, - MetricsRegister: reg, - StorageOptsRegister: optsregister, - StorageOpts: storageOpts, - }); err != nil { - return err - } - if len(g.PrioritizedVersions) < 1 { - return nil - } - - // filter out api groups that are disabled in APIEnablementOptions - for version := range g.VersionedResourcesStorageMap { - gvr := schema.GroupVersionResource{ - Group: group, - Version: version, - } - if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) { - klog.InfoS("Skipping storage for disabled resource", "gvr", gvr.String()) - delete(g.VersionedResourcesStorageMap, version) - } - } - - // if grafanaAPIServerWithExperimentalAPIs is not enabled, remove v0alpha1 resources unless explicitly allowed - //nolint:staticcheck // not yet migrated to OpenFeature - if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { - if resources, ok := g.VersionedResourcesStorageMap["v0alpha1"]; ok { - for name := range resources { - if !allowRegisteringResourceByInfo(b.AllowedV0Alpha1Resources(), name) { - delete(resources, name) - } - } - if len(resources) == 0 { - delete(g.VersionedResourcesStorageMap, "v0alpha1") - } - } - } - - return nil -} - // AddPostStartHooks adds post start hooks to a generic API server config func AddPostStartHooks( config *genericapiserver.RecommendedConfig, diff --git a/pkg/services/apiserver/builder/openapi.go b/pkg/services/apiserver/builder/openapi.go index 6d3b33f4baf..9cb7cc17f7c 100644 --- a/pkg/services/apiserver/builder/openapi.go +++ b/pkg/services/apiserver/builder/openapi.go @@ -9,8 +9,6 @@ import ( apiequality "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/runtime/schema" - serverstorage "k8s.io/apiserver/pkg/server/storage" - "k8s.io/klog/v2" openapi "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" spec "k8s.io/kube-openapi/pkg/validation/spec" @@ -78,7 +76,6 @@ func addBuilderRoutes( targetGroupVersion schema.GroupVersion, openAPISpec *spec3.OpenAPI, apiGroupBuilders []APIGroupBuilder, - apiResourceConfig *serverstorage.ResourceConfig, ) (*spec3.OpenAPI, error) { for _, apiGroupBuilder := range apiGroupBuilders { // Optionally include raw http handlers for all builders @@ -110,24 +107,12 @@ func addBuilderRoutes( } } } - - // filter out api groups that are disabled in APIEnablementOptions - for path := range openAPISpec.Paths.Paths { - if strings.HasPrefix(path, "/apis/"+targetGroupVersion.String()+"/") { - gv := targetGroupVersion.WithResource("") - if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gv) { - klog.InfoS("removing openapi routes for disabled resource", "gv", gv.String()) - delete(openAPISpec.Paths.Paths, path) - } - } - } - return openAPISpec, nil } // Modify the OpenAPI spec to include the additional routes. // nolint:gocyclo -func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []schema.GroupVersion, apiResourceConfig *serverstorage.ResourceConfig) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) { +func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []schema.GroupVersion) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) { return func(s *spec3.OpenAPI) (*spec3.OpenAPI, error) { if s.Paths == nil { return s, nil @@ -242,7 +227,7 @@ func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []s } } } - return addBuilderRoutes(gv, ©, builders, apiResourceConfig) + return addBuilderRoutes(gv, ©, builders) } } return s, nil diff --git a/pkg/services/apiserver/builder/request_handler.go b/pkg/services/apiserver/builder/request_handler.go index 50761f1d42c..80fbf6d0eaa 100644 --- a/pkg/services/apiserver/builder/request_handler.go +++ b/pkg/services/apiserver/builder/request_handler.go @@ -6,9 +6,7 @@ import ( "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus" - serverstorage "k8s.io/apiserver/pkg/server/storage" restclient "k8s.io/client-go/rest" - klog "k8s.io/klog/v2" "k8s.io/kube-openapi/pkg/spec3" ) @@ -16,7 +14,7 @@ type requestHandler struct { router *mux.Router } -func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer, apiResourceConfig *serverstorage.ResourceConfig) (http.Handler, error) { +func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer) (http.Handler, error) { useful := false // only true if any routes exist anywhere router := mux.NewRouter() @@ -29,12 +27,6 @@ func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient } for _, gv := range GetGroupVersions(builder) { - // filter out api groups that are disabled in APIEnablementOptions - gvr := gv.WithResource("") - if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) { - klog.InfoS("Skipping custom route handler for disabled group version", "gv", gv.String()) - continue - } routes := provider.GetAPIRoutes(gv) if routes == nil { continue diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index 605016c112d..28da416536c 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -316,11 +316,7 @@ func (s *service) start(ctx context.Context) error { s.cfg.BuildBranch, ) - apiResourceConfig := appinstaller.NewAPIResourceConfig(s.appInstallers) - // add the builder group versions to the api resource config - apiResourceConfig.EnableVersions(groupVersions...) - - if err := o.APIEnablementOptions.ApplyTo(&serverConfig.Config, apiResourceConfig, s.scheme); err != nil { + if err := o.APIEnablementOptions.ApplyTo(&serverConfig.Config, appinstaller.NewAPIResourceConfig(s.appInstallers), s.scheme); err != nil { return err } @@ -363,7 +359,6 @@ func (s *service) start(ctx context.Context) error { groupVersions, defGetters, s.metrics, - apiResourceConfig, ) if err != nil { return err @@ -405,7 +400,6 @@ func (s *service) start(ctx context.Context) error { s.features, s.dualWriterMetrics, s.builderMetrics, - apiResourceConfig, ) if err != nil { return err diff --git a/pkg/services/authz/proto/v1/extention.pb.go b/pkg/services/authz/proto/v1/extention.pb.go index 03a44978db9..deff13d5edd 100644 --- a/pkg/services/authz/proto/v1/extention.pb.go +++ b/pkg/services/authz/proto/v1/extention.pb.go @@ -123,6 +123,12 @@ type MutateOperation struct { // *MutateOperation_UpdateUserOrgRole // *MutateOperation_DeleteUserOrgRole // *MutateOperation_AddUserOrgRole + // *MutateOperation_CreateRoleBinding + // *MutateOperation_DeleteRoleBinding + // *MutateOperation_CreateTeamBinding + // *MutateOperation_DeleteTeamBinding + // *MutateOperation_CreateRole + // *MutateOperation_DeleteRole Operation isMutateOperation_Operation `protobuf_oneof:"operation"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -228,6 +234,60 @@ func (x *MutateOperation) GetAddUserOrgRole() *AddUserOrgRoleOperation { return nil } +func (x *MutateOperation) GetCreateRoleBinding() *CreateRoleBindingOperation { + if x != nil { + if x, ok := x.Operation.(*MutateOperation_CreateRoleBinding); ok { + return x.CreateRoleBinding + } + } + return nil +} + +func (x *MutateOperation) GetDeleteRoleBinding() *DeleteRoleBindingOperation { + if x != nil { + if x, ok := x.Operation.(*MutateOperation_DeleteRoleBinding); ok { + return x.DeleteRoleBinding + } + } + return nil +} + +func (x *MutateOperation) GetCreateTeamBinding() *CreateTeamBindingOperation { + if x != nil { + if x, ok := x.Operation.(*MutateOperation_CreateTeamBinding); ok { + return x.CreateTeamBinding + } + } + return nil +} + +func (x *MutateOperation) GetDeleteTeamBinding() *DeleteTeamBindingOperation { + if x != nil { + if x, ok := x.Operation.(*MutateOperation_DeleteTeamBinding); ok { + return x.DeleteTeamBinding + } + } + return nil +} + +func (x *MutateOperation) GetCreateRole() *CreateRoleOperation { + if x != nil { + if x, ok := x.Operation.(*MutateOperation_CreateRole); ok { + return x.CreateRole + } + } + return nil +} + +func (x *MutateOperation) GetDeleteRole() *DeleteRoleOperation { + if x != nil { + if x, ok := x.Operation.(*MutateOperation_DeleteRole); ok { + return x.DeleteRole + } + } + return nil +} + type isMutateOperation_Operation interface { isMutateOperation_Operation() } @@ -260,6 +320,30 @@ type MutateOperation_AddUserOrgRole struct { AddUserOrgRole *AddUserOrgRoleOperation `protobuf:"bytes,7,opt,name=add_user_org_role,json=addUserOrgRole,proto3,oneof"` } +type MutateOperation_CreateRoleBinding struct { + CreateRoleBinding *CreateRoleBindingOperation `protobuf:"bytes,8,opt,name=create_role_binding,json=createRoleBinding,proto3,oneof"` +} + +type MutateOperation_DeleteRoleBinding struct { + DeleteRoleBinding *DeleteRoleBindingOperation `protobuf:"bytes,9,opt,name=delete_role_binding,json=deleteRoleBinding,proto3,oneof"` +} + +type MutateOperation_CreateTeamBinding struct { + CreateTeamBinding *CreateTeamBindingOperation `protobuf:"bytes,10,opt,name=create_team_binding,json=createTeamBinding,proto3,oneof"` +} + +type MutateOperation_DeleteTeamBinding struct { + DeleteTeamBinding *DeleteTeamBindingOperation `protobuf:"bytes,11,opt,name=delete_team_binding,json=deleteTeamBinding,proto3,oneof"` +} + +type MutateOperation_CreateRole struct { + CreateRole *CreateRoleOperation `protobuf:"bytes,12,opt,name=create_role,json=createRole,proto3,oneof"` +} + +type MutateOperation_DeleteRole struct { + DeleteRole *DeleteRoleOperation `protobuf:"bytes,13,opt,name=delete_role,json=deleteRole,proto3,oneof"` +} + func (*MutateOperation_SetFolderParent) isMutateOperation_Operation() {} func (*MutateOperation_DeleteFolder) isMutateOperation_Operation() {} @@ -274,6 +358,18 @@ func (*MutateOperation_DeleteUserOrgRole) isMutateOperation_Operation() {} func (*MutateOperation_AddUserOrgRole) isMutateOperation_Operation() {} +func (*MutateOperation_CreateRoleBinding) isMutateOperation_Operation() {} + +func (*MutateOperation_DeleteRoleBinding) isMutateOperation_Operation() {} + +func (*MutateOperation_CreateTeamBinding) isMutateOperation_Operation() {} + +func (*MutateOperation_DeleteTeamBinding) isMutateOperation_Operation() {} + +func (*MutateOperation_CreateRole) isMutateOperation_Operation() {} + +func (*MutateOperation_DeleteRole) isMutateOperation_Operation() {} + type SetFolderParentOperation struct { state protoimpl.MessageState `protogen:"open.v1"` // UID of the folder @@ -667,6 +763,454 @@ func (x *DeleteUserOrgRoleOperation) GetRole() string { return "" } +type CreateRoleBindingOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // kind of the identity getting the permission (User/Team/ServiceAccount/BasicRole) + SubjectKind string `protobuf:"bytes,1,opt,name=subject_kind,json=subjectKind,proto3" json:"subject_kind,omitempty"` + // uid of the identity + SubjectName string `protobuf:"bytes,2,opt,name=subject_name,json=subjectName,proto3" json:"subject_name,omitempty"` + // kind of the role (Role/CoreRole/GlobalRole) + RoleKind string `protobuf:"bytes,3,opt,name=role_kind,json=roleKind,proto3" json:"role_kind,omitempty"` + // uid of the role + RoleName string `protobuf:"bytes,4,opt,name=role_name,json=roleName,proto3" json:"role_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateRoleBindingOperation) Reset() { + *x = CreateRoleBindingOperation{} + mi := &file_extention_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateRoleBindingOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateRoleBindingOperation) ProtoMessage() {} + +func (x *CreateRoleBindingOperation) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[10] + 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 CreateRoleBindingOperation.ProtoReflect.Descriptor instead. +func (*CreateRoleBindingOperation) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{10} +} + +func (x *CreateRoleBindingOperation) GetSubjectKind() string { + if x != nil { + return x.SubjectKind + } + return "" +} + +func (x *CreateRoleBindingOperation) GetSubjectName() string { + if x != nil { + return x.SubjectName + } + return "" +} + +func (x *CreateRoleBindingOperation) GetRoleKind() string { + if x != nil { + return x.RoleKind + } + return "" +} + +func (x *CreateRoleBindingOperation) GetRoleName() string { + if x != nil { + return x.RoleName + } + return "" +} + +type DeleteRoleBindingOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // kind of the identity getting the permission (User/Team/ServiceAccount/BasicRole) + SubjectKind string `protobuf:"bytes,1,opt,name=subject_kind,json=subjectKind,proto3" json:"subject_kind,omitempty"` + // uid of the identity + SubjectName string `protobuf:"bytes,2,opt,name=subject_name,json=subjectName,proto3" json:"subject_name,omitempty"` + // kind of the role (Role/CoreRole/GlobalRole) + RoleKind string `protobuf:"bytes,3,opt,name=role_kind,json=roleKind,proto3" json:"role_kind,omitempty"` + // uid of the role + RoleName string `protobuf:"bytes,4,opt,name=role_name,json=roleName,proto3" json:"role_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteRoleBindingOperation) Reset() { + *x = DeleteRoleBindingOperation{} + mi := &file_extention_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteRoleBindingOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteRoleBindingOperation) ProtoMessage() {} + +func (x *DeleteRoleBindingOperation) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[11] + 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 DeleteRoleBindingOperation.ProtoReflect.Descriptor instead. +func (*DeleteRoleBindingOperation) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{11} +} + +func (x *DeleteRoleBindingOperation) GetSubjectKind() string { + if x != nil { + return x.SubjectKind + } + return "" +} + +func (x *DeleteRoleBindingOperation) GetSubjectName() string { + if x != nil { + return x.SubjectName + } + return "" +} + +func (x *DeleteRoleBindingOperation) GetRoleKind() string { + if x != nil { + return x.RoleKind + } + return "" +} + +func (x *DeleteRoleBindingOperation) GetRoleName() string { + if x != nil { + return x.RoleName + } + return "" +} + +type CreateTeamBindingOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // uid of the identity + SubjectName string `protobuf:"bytes,1,opt,name=subject_name,json=subjectName,proto3" json:"subject_name,omitempty"` + // uid of the team + TeamName string `protobuf:"bytes,2,opt,name=team_name,json=teamName,proto3" json:"team_name,omitempty"` + // permission of the identity in the team (admin/member) + Permission string `protobuf:"bytes,3,opt,name=permission,proto3" json:"permission,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateTeamBindingOperation) Reset() { + *x = CreateTeamBindingOperation{} + mi := &file_extention_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateTeamBindingOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTeamBindingOperation) ProtoMessage() {} + +func (x *CreateTeamBindingOperation) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[12] + 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 CreateTeamBindingOperation.ProtoReflect.Descriptor instead. +func (*CreateTeamBindingOperation) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{12} +} + +func (x *CreateTeamBindingOperation) GetSubjectName() string { + if x != nil { + return x.SubjectName + } + return "" +} + +func (x *CreateTeamBindingOperation) GetTeamName() string { + if x != nil { + return x.TeamName + } + return "" +} + +func (x *CreateTeamBindingOperation) GetPermission() string { + if x != nil { + return x.Permission + } + return "" +} + +type DeleteTeamBindingOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // uid of the identity + SubjectName string `protobuf:"bytes,1,opt,name=subject_name,json=subjectName,proto3" json:"subject_name,omitempty"` + // uid of the team + TeamName string `protobuf:"bytes,2,opt,name=team_name,json=teamName,proto3" json:"team_name,omitempty"` + // permission of the identity in the team (admin/member) + Permission string `protobuf:"bytes,3,opt,name=permission,proto3" json:"permission,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteTeamBindingOperation) Reset() { + *x = DeleteTeamBindingOperation{} + mi := &file_extention_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteTeamBindingOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteTeamBindingOperation) ProtoMessage() {} + +func (x *DeleteTeamBindingOperation) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[13] + 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 DeleteTeamBindingOperation.ProtoReflect.Descriptor instead. +func (*DeleteTeamBindingOperation) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{13} +} + +func (x *DeleteTeamBindingOperation) GetSubjectName() string { + if x != nil { + return x.SubjectName + } + return "" +} + +func (x *DeleteTeamBindingOperation) GetTeamName() string { + if x != nil { + return x.TeamName + } + return "" +} + +func (x *DeleteTeamBindingOperation) GetPermission() string { + if x != nil { + return x.Permission + } + return "" +} + +type CreateRoleOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // kind of the role (Role/CoreRole/GlobalRole) + RoleKind string `protobuf:"bytes,1,opt,name=role_kind,json=roleKind,proto3" json:"role_kind,omitempty"` + // uid of the role + RoleName string `protobuf:"bytes,2,opt,name=role_name,json=roleName,proto3" json:"role_name,omitempty"` + // permissions of the role + Permissions []*RolePermission `protobuf:"bytes,3,rep,name=permissions,proto3" json:"permissions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateRoleOperation) Reset() { + *x = CreateRoleOperation{} + mi := &file_extention_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateRoleOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateRoleOperation) ProtoMessage() {} + +func (x *CreateRoleOperation) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[14] + 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 CreateRoleOperation.ProtoReflect.Descriptor instead. +func (*CreateRoleOperation) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{14} +} + +func (x *CreateRoleOperation) GetRoleKind() string { + if x != nil { + return x.RoleKind + } + return "" +} + +func (x *CreateRoleOperation) GetRoleName() string { + if x != nil { + return x.RoleName + } + return "" +} + +func (x *CreateRoleOperation) GetPermissions() []*RolePermission { + if x != nil { + return x.Permissions + } + return nil +} + +type DeleteRoleOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // kind of the role (Role/CoreRole/GlobalRole) + RoleKind string `protobuf:"bytes,1,opt,name=role_kind,json=roleKind,proto3" json:"role_kind,omitempty"` + // uid of the role + RoleName string `protobuf:"bytes,2,opt,name=role_name,json=roleName,proto3" json:"role_name,omitempty"` + // permissions of the role + Permissions []*RolePermission `protobuf:"bytes,3,rep,name=permissions,proto3" json:"permissions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteRoleOperation) Reset() { + *x = DeleteRoleOperation{} + mi := &file_extention_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteRoleOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteRoleOperation) ProtoMessage() {} + +func (x *DeleteRoleOperation) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[15] + 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 DeleteRoleOperation.ProtoReflect.Descriptor instead. +func (*DeleteRoleOperation) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{15} +} + +func (x *DeleteRoleOperation) GetRoleKind() string { + if x != nil { + return x.RoleKind + } + return "" +} + +func (x *DeleteRoleOperation) GetRoleName() string { + if x != nil { + return x.RoleName + } + return "" +} + +func (x *DeleteRoleOperation) GetPermissions() []*RolePermission { + if x != nil { + return x.Permissions + } + return nil +} + +type RolePermission struct { + state protoimpl.MessageState `protogen:"open.v1"` + Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` + Scope string `protobuf:"bytes,2,opt,name=scope,proto3" json:"scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RolePermission) Reset() { + *x = RolePermission{} + mi := &file_extention_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RolePermission) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RolePermission) ProtoMessage() {} + +func (x *RolePermission) ProtoReflect() protoreflect.Message { + mi := &file_extention_proto_msgTypes[16] + 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 RolePermission.ProtoReflect.Descriptor instead. +func (*RolePermission) Descriptor() ([]byte, []int) { + return file_extention_proto_rawDescGZIP(), []int{16} +} + +func (x *RolePermission) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +func (x *RolePermission) GetScope() string { + if x != nil { + return x.Scope + } + return "" +} + type Resource struct { state protoimpl.MessageState `protogen:"open.v1"` // group of the resource (e.g: "dashboard.grafana.app") @@ -681,7 +1225,7 @@ type Resource struct { func (x *Resource) Reset() { *x = Resource{} - mi := &file_extention_proto_msgTypes[10] + mi := &file_extention_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -693,7 +1237,7 @@ func (x *Resource) String() string { func (*Resource) ProtoMessage() {} func (x *Resource) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[10] + mi := &file_extention_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -706,7 +1250,7 @@ func (x *Resource) ProtoReflect() protoreflect.Message { // Deprecated: Use Resource.ProtoReflect.Descriptor instead. func (*Resource) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{10} + return file_extention_proto_rawDescGZIP(), []int{17} } func (x *Resource) GetGroup() string { @@ -744,7 +1288,7 @@ type Permission struct { func (x *Permission) Reset() { *x = Permission{} - mi := &file_extention_proto_msgTypes[11] + mi := &file_extention_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -756,7 +1300,7 @@ func (x *Permission) String() string { func (*Permission) ProtoMessage() {} func (x *Permission) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[11] + mi := &file_extention_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -769,7 +1313,7 @@ func (x *Permission) ProtoReflect() protoreflect.Message { // Deprecated: Use Permission.ProtoReflect.Descriptor instead. func (*Permission) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{11} + return file_extention_proto_rawDescGZIP(), []int{18} } func (x *Permission) GetKind() string { @@ -805,7 +1349,7 @@ type TupleKey struct { func (x *TupleKey) Reset() { *x = TupleKey{} - mi := &file_extention_proto_msgTypes[12] + mi := &file_extention_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -817,7 +1361,7 @@ func (x *TupleKey) String() string { func (*TupleKey) ProtoMessage() {} func (x *TupleKey) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[12] + mi := &file_extention_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -830,7 +1374,7 @@ func (x *TupleKey) ProtoReflect() protoreflect.Message { // Deprecated: Use TupleKey.ProtoReflect.Descriptor instead. func (*TupleKey) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{12} + return file_extention_proto_rawDescGZIP(), []int{19} } func (x *TupleKey) GetUser() string { @@ -871,7 +1415,7 @@ type Tuple struct { func (x *Tuple) Reset() { *x = Tuple{} - mi := &file_extention_proto_msgTypes[13] + mi := &file_extention_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -883,7 +1427,7 @@ func (x *Tuple) String() string { func (*Tuple) ProtoMessage() {} func (x *Tuple) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[13] + mi := &file_extention_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -896,7 +1440,7 @@ func (x *Tuple) ProtoReflect() protoreflect.Message { // Deprecated: Use Tuple.ProtoReflect.Descriptor instead. func (*Tuple) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{13} + return file_extention_proto_rawDescGZIP(), []int{20} } func (x *Tuple) GetKey() *TupleKey { @@ -924,7 +1468,7 @@ type TupleKeyWithoutCondition struct { func (x *TupleKeyWithoutCondition) Reset() { *x = TupleKeyWithoutCondition{} - mi := &file_extention_proto_msgTypes[14] + mi := &file_extention_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -936,7 +1480,7 @@ func (x *TupleKeyWithoutCondition) String() string { func (*TupleKeyWithoutCondition) ProtoMessage() {} func (x *TupleKeyWithoutCondition) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[14] + mi := &file_extention_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -949,7 +1493,7 @@ func (x *TupleKeyWithoutCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use TupleKeyWithoutCondition.ProtoReflect.Descriptor instead. func (*TupleKeyWithoutCondition) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{14} + return file_extention_proto_rawDescGZIP(), []int{21} } func (x *TupleKeyWithoutCondition) GetUser() string { @@ -983,7 +1527,7 @@ type RelationshipCondition struct { func (x *RelationshipCondition) Reset() { *x = RelationshipCondition{} - mi := &file_extention_proto_msgTypes[15] + mi := &file_extention_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -995,7 +1539,7 @@ func (x *RelationshipCondition) String() string { func (*RelationshipCondition) ProtoMessage() {} func (x *RelationshipCondition) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[15] + mi := &file_extention_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1008,7 +1552,7 @@ func (x *RelationshipCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use RelationshipCondition.ProtoReflect.Descriptor instead. func (*RelationshipCondition) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{15} + return file_extention_proto_rawDescGZIP(), []int{22} } func (x *RelationshipCondition) GetName() string { @@ -1037,7 +1581,7 @@ type ReadRequest struct { func (x *ReadRequest) Reset() { *x = ReadRequest{} - mi := &file_extention_proto_msgTypes[16] + mi := &file_extention_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1049,7 +1593,7 @@ func (x *ReadRequest) String() string { func (*ReadRequest) ProtoMessage() {} func (x *ReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[16] + mi := &file_extention_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1062,7 +1606,7 @@ func (x *ReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadRequest.ProtoReflect.Descriptor instead. func (*ReadRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{16} + return file_extention_proto_rawDescGZIP(), []int{23} } func (x *ReadRequest) GetNamespace() string { @@ -1104,7 +1648,7 @@ type ReadRequestTupleKey struct { func (x *ReadRequestTupleKey) Reset() { *x = ReadRequestTupleKey{} - mi := &file_extention_proto_msgTypes[17] + mi := &file_extention_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1116,7 +1660,7 @@ func (x *ReadRequestTupleKey) String() string { func (*ReadRequestTupleKey) ProtoMessage() {} func (x *ReadRequestTupleKey) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[17] + mi := &file_extention_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1129,7 +1673,7 @@ func (x *ReadRequestTupleKey) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadRequestTupleKey.ProtoReflect.Descriptor instead. func (*ReadRequestTupleKey) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{17} + return file_extention_proto_rawDescGZIP(), []int{24} } func (x *ReadRequestTupleKey) GetUser() string { @@ -1163,7 +1707,7 @@ type ReadResponse struct { func (x *ReadResponse) Reset() { *x = ReadResponse{} - mi := &file_extention_proto_msgTypes[18] + mi := &file_extention_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1175,7 +1719,7 @@ func (x *ReadResponse) String() string { func (*ReadResponse) ProtoMessage() {} func (x *ReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[18] + mi := &file_extention_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1188,7 +1732,7 @@ func (x *ReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadResponse.ProtoReflect.Descriptor instead. func (*ReadResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{18} + return file_extention_proto_rawDescGZIP(), []int{25} } func (x *ReadResponse) GetTuples() []*Tuple { @@ -1214,7 +1758,7 @@ type WriteRequestWrites struct { func (x *WriteRequestWrites) Reset() { *x = WriteRequestWrites{} - mi := &file_extention_proto_msgTypes[19] + mi := &file_extention_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1226,7 +1770,7 @@ func (x *WriteRequestWrites) String() string { func (*WriteRequestWrites) ProtoMessage() {} func (x *WriteRequestWrites) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[19] + mi := &file_extention_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1239,7 +1783,7 @@ func (x *WriteRequestWrites) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteRequestWrites.ProtoReflect.Descriptor instead. func (*WriteRequestWrites) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{19} + return file_extention_proto_rawDescGZIP(), []int{26} } func (x *WriteRequestWrites) GetTupleKeys() []*TupleKey { @@ -1258,7 +1802,7 @@ type WriteRequestDeletes struct { func (x *WriteRequestDeletes) Reset() { *x = WriteRequestDeletes{} - mi := &file_extention_proto_msgTypes[20] + mi := &file_extention_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1270,7 +1814,7 @@ func (x *WriteRequestDeletes) String() string { func (*WriteRequestDeletes) ProtoMessage() {} func (x *WriteRequestDeletes) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[20] + mi := &file_extention_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1283,7 +1827,7 @@ func (x *WriteRequestDeletes) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteRequestDeletes.ProtoReflect.Descriptor instead. func (*WriteRequestDeletes) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{20} + return file_extention_proto_rawDescGZIP(), []int{27} } func (x *WriteRequestDeletes) GetTupleKeys() []*TupleKeyWithoutCondition { @@ -1304,7 +1848,7 @@ type WriteRequest struct { func (x *WriteRequest) Reset() { *x = WriteRequest{} - mi := &file_extention_proto_msgTypes[21] + mi := &file_extention_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1316,7 +1860,7 @@ func (x *WriteRequest) String() string { func (*WriteRequest) ProtoMessage() {} func (x *WriteRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[21] + mi := &file_extention_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1329,7 +1873,7 @@ func (x *WriteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteRequest.ProtoReflect.Descriptor instead. func (*WriteRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{21} + return file_extention_proto_rawDescGZIP(), []int{28} } func (x *WriteRequest) GetNamespace() string { @@ -1361,7 +1905,7 @@ type WriteResponse struct { func (x *WriteResponse) Reset() { *x = WriteResponse{} - mi := &file_extention_proto_msgTypes[22] + mi := &file_extention_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1373,7 +1917,7 @@ func (x *WriteResponse) String() string { func (*WriteResponse) ProtoMessage() {} func (x *WriteResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[22] + mi := &file_extention_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1386,7 +1930,7 @@ func (x *WriteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteResponse.ProtoReflect.Descriptor instead. func (*WriteResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{22} + return file_extention_proto_rawDescGZIP(), []int{29} } type BatchCheckRequest struct { @@ -1400,7 +1944,7 @@ type BatchCheckRequest struct { func (x *BatchCheckRequest) Reset() { *x = BatchCheckRequest{} - mi := &file_extention_proto_msgTypes[23] + mi := &file_extention_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1412,7 +1956,7 @@ func (x *BatchCheckRequest) String() string { func (*BatchCheckRequest) ProtoMessage() {} func (x *BatchCheckRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[23] + mi := &file_extention_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1425,7 +1969,7 @@ func (x *BatchCheckRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckRequest.ProtoReflect.Descriptor instead. func (*BatchCheckRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{23} + return file_extention_proto_rawDescGZIP(), []int{30} } func (x *BatchCheckRequest) GetSubject() string { @@ -1463,7 +2007,7 @@ type BatchCheckItem struct { func (x *BatchCheckItem) Reset() { *x = BatchCheckItem{} - mi := &file_extention_proto_msgTypes[24] + mi := &file_extention_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1475,7 +2019,7 @@ func (x *BatchCheckItem) String() string { func (*BatchCheckItem) ProtoMessage() {} func (x *BatchCheckItem) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[24] + mi := &file_extention_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1488,7 +2032,7 @@ func (x *BatchCheckItem) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckItem.ProtoReflect.Descriptor instead. func (*BatchCheckItem) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{24} + return file_extention_proto_rawDescGZIP(), []int{31} } func (x *BatchCheckItem) GetVerb() string { @@ -1542,7 +2086,7 @@ type BatchCheckResponse struct { func (x *BatchCheckResponse) Reset() { *x = BatchCheckResponse{} - mi := &file_extention_proto_msgTypes[25] + mi := &file_extention_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1554,7 +2098,7 @@ func (x *BatchCheckResponse) String() string { func (*BatchCheckResponse) ProtoMessage() {} func (x *BatchCheckResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[25] + mi := &file_extention_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1567,7 +2111,7 @@ func (x *BatchCheckResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckResponse.ProtoReflect.Descriptor instead. func (*BatchCheckResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{25} + return file_extention_proto_rawDescGZIP(), []int{32} } func (x *BatchCheckResponse) GetGroups() map[string]*BatchCheckGroupResource { @@ -1586,7 +2130,7 @@ type BatchCheckGroupResource struct { func (x *BatchCheckGroupResource) Reset() { *x = BatchCheckGroupResource{} - mi := &file_extention_proto_msgTypes[26] + mi := &file_extention_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1598,7 +2142,7 @@ func (x *BatchCheckGroupResource) String() string { func (*BatchCheckGroupResource) ProtoMessage() {} func (x *BatchCheckGroupResource) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[26] + mi := &file_extention_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1611,7 +2155,7 @@ func (x *BatchCheckGroupResource) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchCheckGroupResource.ProtoReflect.Descriptor instead. func (*BatchCheckGroupResource) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{26} + return file_extention_proto_rawDescGZIP(), []int{33} } func (x *BatchCheckGroupResource) GetItems() map[string]bool { @@ -1631,7 +2175,7 @@ type QueryRequest struct { func (x *QueryRequest) Reset() { *x = QueryRequest{} - mi := &file_extention_proto_msgTypes[27] + mi := &file_extention_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1643,7 +2187,7 @@ func (x *QueryRequest) String() string { func (*QueryRequest) ProtoMessage() {} func (x *QueryRequest) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[27] + mi := &file_extention_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1656,7 +2200,7 @@ func (x *QueryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryRequest.ProtoReflect.Descriptor instead. func (*QueryRequest) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{27} + return file_extention_proto_rawDescGZIP(), []int{34} } func (x *QueryRequest) GetNamespace() string { @@ -1685,7 +2229,7 @@ type QueryResponse struct { func (x *QueryResponse) Reset() { *x = QueryResponse{} - mi := &file_extention_proto_msgTypes[28] + mi := &file_extention_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1697,7 +2241,7 @@ func (x *QueryResponse) String() string { func (*QueryResponse) ProtoMessage() {} func (x *QueryResponse) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[28] + mi := &file_extention_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1710,7 +2254,7 @@ func (x *QueryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryResponse.ProtoReflect.Descriptor instead. func (*QueryResponse) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{28} + return file_extention_proto_rawDescGZIP(), []int{35} } func (x *QueryResponse) GetResult() isQueryResponse_Result { @@ -1751,7 +2295,7 @@ type QueryOperation struct { func (x *QueryOperation) Reset() { *x = QueryOperation{} - mi := &file_extention_proto_msgTypes[29] + mi := &file_extention_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1763,7 +2307,7 @@ func (x *QueryOperation) String() string { func (*QueryOperation) ProtoMessage() {} func (x *QueryOperation) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[29] + mi := &file_extention_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1776,7 +2320,7 @@ func (x *QueryOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryOperation.ProtoReflect.Descriptor instead. func (*QueryOperation) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{29} + return file_extention_proto_rawDescGZIP(), []int{36} } func (x *QueryOperation) GetOperation() isQueryOperation_Operation { @@ -1815,7 +2359,7 @@ type GetFolderParentsQuery struct { func (x *GetFolderParentsQuery) Reset() { *x = GetFolderParentsQuery{} - mi := &file_extention_proto_msgTypes[30] + mi := &file_extention_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1827,7 +2371,7 @@ func (x *GetFolderParentsQuery) String() string { func (*GetFolderParentsQuery) ProtoMessage() {} func (x *GetFolderParentsQuery) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[30] + mi := &file_extention_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1840,7 +2384,7 @@ func (x *GetFolderParentsQuery) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFolderParentsQuery.ProtoReflect.Descriptor instead. func (*GetFolderParentsQuery) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{30} + return file_extention_proto_rawDescGZIP(), []int{37} } func (x *GetFolderParentsQuery) GetFolder() string { @@ -1860,7 +2404,7 @@ type GetFolderParentsResult struct { func (x *GetFolderParentsResult) Reset() { *x = GetFolderParentsResult{} - mi := &file_extention_proto_msgTypes[31] + mi := &file_extention_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1872,7 +2416,7 @@ func (x *GetFolderParentsResult) String() string { func (*GetFolderParentsResult) ProtoMessage() {} func (x *GetFolderParentsResult) ProtoReflect() protoreflect.Message { - mi := &file_extention_proto_msgTypes[31] + mi := &file_extention_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1885,7 +2429,7 @@ func (x *GetFolderParentsResult) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFolderParentsResult.ProtoReflect.Descriptor instead. func (*GetFolderParentsResult) Descriptor() ([]byte, []int) { - return file_extention_proto_rawDescGZIP(), []int{31} + return file_extention_proto_rawDescGZIP(), []int{38} } func (x *GetFolderParentsResult) GetParentUids() []string { @@ -1914,7 +2458,7 @@ var file_extention_proto_rawDesc = string([]byte{ 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x10, 0x0a, 0x0e, 0x4d, 0x75, 0x74, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xa8, 0x05, 0x0a, 0x0f, 0x4d, + 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xc8, 0x09, 0x0a, 0x0f, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x5a, 0x0a, 0x11, 0x73, 0x65, 0x74, 0x5f, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x61, 0x75, 0x74, 0x68, @@ -1956,7 +2500,41 @@ var file_extention_proto_rawDesc = string([]byte{ 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x0e, 0x61, 0x64, 0x64, 0x55, 0x73, 0x65, - 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, + 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x60, 0x0a, 0x13, 0x63, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x5f, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x62, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, + 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x11, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, + 0x6f, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x60, 0x0a, 0x13, 0x64, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x62, 0x69, 0x6e, 0x64, 0x69, 0x6e, + 0x67, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, + 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, + 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, + 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x11, 0x64, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x60, 0x0a, 0x13, + 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x65, 0x61, 0x6d, 0x5f, 0x62, 0x69, 0x6e, 0x64, + 0x69, 0x6e, 0x67, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x43, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, + 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x11, 0x63, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x60, + 0x0a, 0x13, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x74, 0x65, 0x61, 0x6d, 0x5f, 0x62, 0x69, + 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, + 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, + 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x11, 0x64, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, + 0x12, 0x4a, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x72, 0x6f, 0x6c, 0x65, 0x18, + 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, + 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, + 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x4a, 0x0a, 0x0b, + 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, + 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, + 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x0a, 0x64, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x73, 0x0a, 0x18, 0x53, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, @@ -2003,198 +2581,257 @@ var file_extention_proto_rawDesc = string([]byte{ 0x73, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x50, 0x0a, 0x08, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, - 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x48, 0x0a, - 0x0a, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6b, - 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, - 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x76, 0x65, 0x72, 0x62, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x76, 0x65, 0x72, 0x62, 0x22, 0x9b, 0x01, 0x0a, 0x08, 0x54, 0x75, 0x70, 0x6c, - 0x65, 0x4b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x47, 0x0a, 0x09, - 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x29, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, - 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x68, 0x69, - 0x70, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x64, - 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x71, 0x0a, 0x05, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x12, 0x2e, - 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, - 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, - 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x62, 0x0a, 0x18, 0x54, 0x75, 0x70, 0x6c, - 0x65, 0x4b, 0x65, 0x79, 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x43, 0x6f, 0x6e, 0x64, 0x69, - 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x5e, 0x0a, 0x15, - 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x68, 0x69, 0x70, 0x43, 0x6f, 0x6e, 0x64, - 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, 0x31, 0x0a, 0x07, 0x63, 0x6f, 0x6e, - 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, - 0x75, 0x63, 0x74, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0xda, 0x01, 0x0a, - 0x0b, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, - 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x44, 0x0a, 0x09, 0x74, 0x75, - 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, - 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x75, - 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, - 0x12, 0x38, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x49, 0x6e, 0x74, 0x33, 0x32, 0x56, 0x61, 0x6c, 0x75, 0x65, - 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x6f, - 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x5d, 0x0a, 0x13, 0x52, 0x65, 0x61, - 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, - 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x70, 0x0a, 0x0c, 0x52, 0x65, 0x61, 0x64, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x31, 0x0a, 0x06, 0x74, 0x75, 0x70, 0x6c, - 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, - 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, - 0x70, 0x6c, 0x65, 0x52, 0x06, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x63, - 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x51, 0x0a, 0x12, 0x57, 0x72, - 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x57, 0x72, 0x69, 0x74, 0x65, 0x73, - 0x12, 0x3b, 0x0a, 0x0a, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, - 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, - 0x65, 0x79, 0x52, 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x73, 0x22, 0x62, 0x0a, - 0x13, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x0a, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, - 0x79, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, - 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, - 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x43, 0x6f, 0x6e, - 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, - 0x73, 0x22, 0xaf, 0x01, 0x0a, 0x0c, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x12, 0x3e, 0x0a, 0x06, 0x77, 0x72, 0x69, 0x74, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, - 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x57, 0x72, 0x69, 0x74, 0x65, 0x73, 0x52, 0x06, 0x77, 0x72, 0x69, 0x74, 0x65, 0x73, - 0x12, 0x41, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, - 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, - 0x74, 0x65, 0x73, 0x22, 0x0f, 0x0a, 0x0d, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x85, 0x01, 0x0a, 0x11, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, - 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, - 0x63, 0x65, 0x12, 0x38, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, - 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, - 0x6b, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0xa4, 0x01, 0x0a, - 0x0e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x74, 0x65, 0x6d, 0x12, - 0x12, 0x0a, 0x04, 0x76, 0x65, 0x72, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x76, - 0x65, 0x72, 0x62, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x75, 0x62, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, - 0x73, 0x75, 0x62, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, - 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, - 0x64, 0x65, 0x72, 0x22, 0xc8, 0x01, 0x0a, 0x12, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, - 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4a, 0x0a, 0x06, 0x67, 0x72, - 0x6f, 0x75, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x61, 0x75, 0x74, - 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, - 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, - 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x1a, 0x66, 0x0a, 0x0b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x41, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, - 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, - 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xa1, - 0x01, 0x0a, 0x17, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, - 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x69, 0x74, - 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x36, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, - 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x1a, 0x38, 0x0a, 0x0a, 0x49, 0x74, 0x65, 0x6d, - 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, - 0x38, 0x01, 0x22, 0x6e, 0x0a, 0x0c, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x12, 0x40, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, - 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x70, - 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x22, 0x6e, 0x0a, 0x0d, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0e, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x61, - 0x72, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, - 0x2e, 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, - 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x48, 0x00, 0x52, 0x0d, 0x66, 0x6f, 0x6c, 0x64, 0x65, - 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x42, 0x08, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, - 0x6c, 0x74, 0x22, 0x78, 0x0a, 0x0e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x70, 0x65, 0x72, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x59, 0x0a, 0x12, 0x67, 0x65, 0x74, 0x5f, 0x66, 0x6f, 0x6c, 0x64, - 0x65, 0x72, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x29, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, - 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, - 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x51, 0x75, 0x65, 0x72, 0x79, 0x48, 0x00, 0x52, 0x10, 0x67, - 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x42, - 0x0b, 0x0a, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x2f, 0x0a, 0x15, - 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, - 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0x39, 0x0a, - 0x16, 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, - 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x61, 0x72, 0x65, 0x6e, - 0x74, 0x5f, 0x75, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x61, - 0x72, 0x65, 0x6e, 0x74, 0x55, 0x69, 0x64, 0x73, 0x32, 0xac, 0x03, 0x0a, 0x15, 0x41, 0x75, 0x74, - 0x68, 0x7a, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x12, 0x5b, 0x0a, 0x0a, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, - 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, - 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, - 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, - 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x49, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x1a, + 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, + 0x67, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x21, 0x0a, + 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, + 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, 0x0a, + 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x1a, 0x44, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, + 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x21, 0x0a, 0x0c, + 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, 0x0a, 0x09, + 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x7c, 0x0a, 0x1a, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, + 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, + 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, + 0x61, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, + 0x65, 0x61, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x65, 0x72, + 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x7c, 0x0a, 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x54, 0x65, 0x61, 0x6d, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x4f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x65, 0x61, 0x6d, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x65, 0x61, + 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x95, 0x01, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, + 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, + 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, + 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x44, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, + 0x31, 0x2e, 0x52, 0x6f, 0x6c, 0x65, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x95, 0x01, + 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x4f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6b, 0x69, + 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4b, 0x69, + 0x6e, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x44, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, + 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6c, 0x65, 0x50, 0x65, + 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x3e, 0x0a, 0x0e, 0x52, 0x6f, 0x6c, 0x65, 0x50, 0x65, 0x72, + 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x73, 0x63, 0x6f, 0x70, 0x65, 0x22, 0x50, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x48, 0x0a, 0x0a, 0x50, 0x65, 0x72, 0x6d, 0x69, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x76, 0x65, 0x72, 0x62, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x76, 0x65, 0x72, + 0x62, 0x22, 0x9b, 0x01, 0x0a, 0x08, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x12, + 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, + 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, + 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x47, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, + 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x68, 0x69, 0x70, 0x43, 0x6f, 0x6e, 0x64, 0x69, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x22, + 0x71, 0x0a, 0x05, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x12, 0x2e, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, + 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, + 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x22, 0x62, 0x0a, 0x18, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x57, 0x69, + 0x74, 0x68, 0x6f, 0x75, 0x74, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, + 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, + 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, + 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x5e, 0x0a, 0x15, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x68, 0x69, 0x70, 0x43, 0x6f, 0x6e, 0x64, 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, 0x31, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x07, 0x63, + 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x22, 0xda, 0x01, 0x0a, 0x0b, 0x52, 0x65, 0x61, 0x64, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x12, 0x44, 0x0a, 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, + 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, - 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, - 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, - 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x57, 0x72, - 0x69, 0x74, 0x65, 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, - 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, - 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x06, 0x4d, 0x75, 0x74, 0x61, - 0x74, 0x65, 0x12, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, - 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, - 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, - 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x51, 0x75, 0x65, - 0x72, 0x79, 0x12, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, - 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, - 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x38, 0x5a, 0x36, 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, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x73, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, - 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, + 0x52, 0x08, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x70, 0x61, + 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x49, 0x6e, 0x74, 0x33, 0x32, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, + 0x53, 0x69, 0x7a, 0x65, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x22, 0x5d, 0x0a, 0x13, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, + 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, + 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x22, 0x70, 0x0a, 0x0c, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x31, 0x0a, 0x06, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x52, 0x06, 0x74, + 0x75, 0x70, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x51, 0x0a, 0x12, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x57, 0x72, 0x69, 0x74, 0x65, 0x73, 0x12, 0x3b, 0x0a, 0x0a, 0x74, 0x75, + 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, + 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, + 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x09, 0x74, 0x75, + 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x73, 0x22, 0x62, 0x0a, 0x13, 0x57, 0x72, 0x69, 0x74, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x12, 0x4b, + 0x0a, 0x0a, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x5f, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, + 0x57, 0x69, 0x74, 0x68, 0x6f, 0x75, 0x74, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, + 0x52, 0x09, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x4b, 0x65, 0x79, 0x73, 0x22, 0xaf, 0x01, 0x0a, 0x0c, + 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, + 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x3e, 0x0a, 0x06, 0x77, 0x72, + 0x69, 0x74, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x61, 0x75, 0x74, + 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, + 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x57, 0x72, 0x69, 0x74, + 0x65, 0x73, 0x52, 0x06, 0x77, 0x72, 0x69, 0x74, 0x65, 0x73, 0x12, 0x41, 0x0a, 0x07, 0x64, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, + 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x44, 0x65, 0x6c, + 0x65, 0x74, 0x65, 0x73, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x22, 0x0f, 0x0a, + 0x0d, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x85, + 0x01, 0x0a, 0x11, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1c, + 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x38, 0x0a, 0x05, + 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, + 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x74, 0x65, 0x6d, 0x52, + 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0xa4, 0x01, 0x0a, 0x0e, 0x42, 0x61, 0x74, 0x63, 0x68, + 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x12, 0x0a, 0x04, 0x76, 0x65, 0x72, + 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x76, 0x65, 0x72, 0x62, 0x12, 0x14, 0x0a, + 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x75, 0x62, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0xc8, 0x01, + 0x0a, 0x12, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4a, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, + 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, + 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x47, 0x72, 0x6f, + 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x1a, 0x66, 0x0a, 0x0b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x41, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x2b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, + 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xa1, 0x01, 0x0a, 0x17, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x36, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, + 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, + 0x65, 0x63, 0x6b, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x69, 0x74, 0x65, + 0x6d, 0x73, 0x1a, 0x38, 0x0a, 0x0a, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x6e, 0x0a, 0x0c, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, + 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x40, 0x0a, 0x09, 0x6f, 0x70, + 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, + 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, + 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x52, 0x09, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x6e, 0x0a, 0x0d, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, + 0x0e, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, + 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x6f, + 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x48, 0x00, 0x52, 0x0d, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, + 0x74, 0x73, 0x42, 0x08, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x22, 0x78, 0x0a, 0x0e, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x59, + 0x0a, 0x12, 0x67, 0x65, 0x74, 0x5f, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x72, + 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x61, 0x75, 0x74, + 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, + 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x48, 0x00, 0x52, 0x10, 0x67, 0x65, 0x74, 0x46, 0x6f, 0x6c, 0x64, + 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x42, 0x0b, 0x0a, 0x09, 0x6f, 0x70, 0x65, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x2f, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x46, 0x6f, 0x6c, + 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, + 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0x39, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x46, 0x6f, + 0x6c, 0x64, 0x65, 0x72, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x75, 0x69, 0x64, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x55, 0x69, + 0x64, 0x73, 0x32, 0xac, 0x03, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x7a, 0x45, 0x78, 0x74, 0x65, + 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5b, 0x0a, 0x0a, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, + 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, + 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x04, 0x52, 0x65, 0x61, + 0x64, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, + 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x20, 0x2e, + 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, + 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, + 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x06, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x12, 0x21, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, + 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, + 0x6e, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x05, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x20, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x76, + 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, + 0x2e, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x2e, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, + 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x42, 0x38, 0x5a, 0x36, 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, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x61, 0x75, 0x74, + 0x68, 0x7a, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, }) var ( @@ -2209,7 +2846,7 @@ func file_extention_proto_rawDescGZIP() []byte { return file_extention_proto_rawDescData } -var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 34) +var file_extention_proto_msgTypes = make([]protoimpl.MessageInfo, 41) var file_extention_proto_goTypes = []any{ (*MutateRequest)(nil), // 0: authz.extention.v1.MutateRequest (*MutateResponse)(nil), // 1: authz.extention.v1.MutateResponse @@ -2221,33 +2858,40 @@ var file_extention_proto_goTypes = []any{ (*AddUserOrgRoleOperation)(nil), // 7: authz.extention.v1.AddUserOrgRoleOperation (*UpdateUserOrgRoleOperation)(nil), // 8: authz.extention.v1.UpdateUserOrgRoleOperation (*DeleteUserOrgRoleOperation)(nil), // 9: authz.extention.v1.DeleteUserOrgRoleOperation - (*Resource)(nil), // 10: authz.extention.v1.Resource - (*Permission)(nil), // 11: authz.extention.v1.Permission - (*TupleKey)(nil), // 12: authz.extention.v1.TupleKey - (*Tuple)(nil), // 13: authz.extention.v1.Tuple - (*TupleKeyWithoutCondition)(nil), // 14: authz.extention.v1.TupleKeyWithoutCondition - (*RelationshipCondition)(nil), // 15: authz.extention.v1.RelationshipCondition - (*ReadRequest)(nil), // 16: authz.extention.v1.ReadRequest - (*ReadRequestTupleKey)(nil), // 17: authz.extention.v1.ReadRequestTupleKey - (*ReadResponse)(nil), // 18: authz.extention.v1.ReadResponse - (*WriteRequestWrites)(nil), // 19: authz.extention.v1.WriteRequestWrites - (*WriteRequestDeletes)(nil), // 20: authz.extention.v1.WriteRequestDeletes - (*WriteRequest)(nil), // 21: authz.extention.v1.WriteRequest - (*WriteResponse)(nil), // 22: authz.extention.v1.WriteResponse - (*BatchCheckRequest)(nil), // 23: authz.extention.v1.BatchCheckRequest - (*BatchCheckItem)(nil), // 24: authz.extention.v1.BatchCheckItem - (*BatchCheckResponse)(nil), // 25: authz.extention.v1.BatchCheckResponse - (*BatchCheckGroupResource)(nil), // 26: authz.extention.v1.BatchCheckGroupResource - (*QueryRequest)(nil), // 27: authz.extention.v1.QueryRequest - (*QueryResponse)(nil), // 28: authz.extention.v1.QueryResponse - (*QueryOperation)(nil), // 29: authz.extention.v1.QueryOperation - (*GetFolderParentsQuery)(nil), // 30: authz.extention.v1.GetFolderParentsQuery - (*GetFolderParentsResult)(nil), // 31: authz.extention.v1.GetFolderParentsResult - nil, // 32: authz.extention.v1.BatchCheckResponse.GroupsEntry - nil, // 33: authz.extention.v1.BatchCheckGroupResource.ItemsEntry - (*timestamppb.Timestamp)(nil), // 34: google.protobuf.Timestamp - (*structpb.Struct)(nil), // 35: google.protobuf.Struct - (*wrapperspb.Int32Value)(nil), // 36: google.protobuf.Int32Value + (*CreateRoleBindingOperation)(nil), // 10: authz.extention.v1.CreateRoleBindingOperation + (*DeleteRoleBindingOperation)(nil), // 11: authz.extention.v1.DeleteRoleBindingOperation + (*CreateTeamBindingOperation)(nil), // 12: authz.extention.v1.CreateTeamBindingOperation + (*DeleteTeamBindingOperation)(nil), // 13: authz.extention.v1.DeleteTeamBindingOperation + (*CreateRoleOperation)(nil), // 14: authz.extention.v1.CreateRoleOperation + (*DeleteRoleOperation)(nil), // 15: authz.extention.v1.DeleteRoleOperation + (*RolePermission)(nil), // 16: authz.extention.v1.RolePermission + (*Resource)(nil), // 17: authz.extention.v1.Resource + (*Permission)(nil), // 18: authz.extention.v1.Permission + (*TupleKey)(nil), // 19: authz.extention.v1.TupleKey + (*Tuple)(nil), // 20: authz.extention.v1.Tuple + (*TupleKeyWithoutCondition)(nil), // 21: authz.extention.v1.TupleKeyWithoutCondition + (*RelationshipCondition)(nil), // 22: authz.extention.v1.RelationshipCondition + (*ReadRequest)(nil), // 23: authz.extention.v1.ReadRequest + (*ReadRequestTupleKey)(nil), // 24: authz.extention.v1.ReadRequestTupleKey + (*ReadResponse)(nil), // 25: authz.extention.v1.ReadResponse + (*WriteRequestWrites)(nil), // 26: authz.extention.v1.WriteRequestWrites + (*WriteRequestDeletes)(nil), // 27: authz.extention.v1.WriteRequestDeletes + (*WriteRequest)(nil), // 28: authz.extention.v1.WriteRequest + (*WriteResponse)(nil), // 29: authz.extention.v1.WriteResponse + (*BatchCheckRequest)(nil), // 30: authz.extention.v1.BatchCheckRequest + (*BatchCheckItem)(nil), // 31: authz.extention.v1.BatchCheckItem + (*BatchCheckResponse)(nil), // 32: authz.extention.v1.BatchCheckResponse + (*BatchCheckGroupResource)(nil), // 33: authz.extention.v1.BatchCheckGroupResource + (*QueryRequest)(nil), // 34: authz.extention.v1.QueryRequest + (*QueryResponse)(nil), // 35: authz.extention.v1.QueryResponse + (*QueryOperation)(nil), // 36: authz.extention.v1.QueryOperation + (*GetFolderParentsQuery)(nil), // 37: authz.extention.v1.GetFolderParentsQuery + (*GetFolderParentsResult)(nil), // 38: authz.extention.v1.GetFolderParentsResult + nil, // 39: authz.extention.v1.BatchCheckResponse.GroupsEntry + nil, // 40: authz.extention.v1.BatchCheckGroupResource.ItemsEntry + (*timestamppb.Timestamp)(nil), // 41: google.protobuf.Timestamp + (*structpb.Struct)(nil), // 42: google.protobuf.Struct + (*wrapperspb.Int32Value)(nil), // 43: google.protobuf.Int32Value } var file_extention_proto_depIdxs = []int32{ 2, // 0: authz.extention.v1.MutateRequest.operations:type_name -> authz.extention.v1.MutateOperation @@ -2258,43 +2902,51 @@ var file_extention_proto_depIdxs = []int32{ 8, // 5: authz.extention.v1.MutateOperation.update_user_org_role:type_name -> authz.extention.v1.UpdateUserOrgRoleOperation 9, // 6: authz.extention.v1.MutateOperation.delete_user_org_role:type_name -> authz.extention.v1.DeleteUserOrgRoleOperation 7, // 7: authz.extention.v1.MutateOperation.add_user_org_role:type_name -> authz.extention.v1.AddUserOrgRoleOperation - 10, // 8: authz.extention.v1.CreatePermissionOperation.resource:type_name -> authz.extention.v1.Resource - 11, // 9: authz.extention.v1.CreatePermissionOperation.permission:type_name -> authz.extention.v1.Permission - 10, // 10: authz.extention.v1.DeletePermissionOperation.resource:type_name -> authz.extention.v1.Resource - 11, // 11: authz.extention.v1.DeletePermissionOperation.permission:type_name -> authz.extention.v1.Permission - 15, // 12: authz.extention.v1.TupleKey.condition:type_name -> authz.extention.v1.RelationshipCondition - 12, // 13: authz.extention.v1.Tuple.key:type_name -> authz.extention.v1.TupleKey - 34, // 14: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp - 35, // 15: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct - 17, // 16: authz.extention.v1.ReadRequest.tuple_key:type_name -> authz.extention.v1.ReadRequestTupleKey - 36, // 17: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value - 13, // 18: authz.extention.v1.ReadResponse.tuples:type_name -> authz.extention.v1.Tuple - 12, // 19: authz.extention.v1.WriteRequestWrites.tuple_keys:type_name -> authz.extention.v1.TupleKey - 14, // 20: authz.extention.v1.WriteRequestDeletes.tuple_keys:type_name -> authz.extention.v1.TupleKeyWithoutCondition - 19, // 21: authz.extention.v1.WriteRequest.writes:type_name -> authz.extention.v1.WriteRequestWrites - 20, // 22: authz.extention.v1.WriteRequest.deletes:type_name -> authz.extention.v1.WriteRequestDeletes - 24, // 23: authz.extention.v1.BatchCheckRequest.items:type_name -> authz.extention.v1.BatchCheckItem - 32, // 24: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry - 33, // 25: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry - 29, // 26: authz.extention.v1.QueryRequest.operation:type_name -> authz.extention.v1.QueryOperation - 31, // 27: authz.extention.v1.QueryResponse.folder_parents:type_name -> authz.extention.v1.GetFolderParentsResult - 30, // 28: authz.extention.v1.QueryOperation.get_folder_parents:type_name -> authz.extention.v1.GetFolderParentsQuery - 26, // 29: authz.extention.v1.BatchCheckResponse.GroupsEntry.value:type_name -> authz.extention.v1.BatchCheckGroupResource - 23, // 30: authz.extention.v1.AuthzExtentionService.BatchCheck:input_type -> authz.extention.v1.BatchCheckRequest - 16, // 31: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest - 21, // 32: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest - 0, // 33: authz.extention.v1.AuthzExtentionService.Mutate:input_type -> authz.extention.v1.MutateRequest - 27, // 34: authz.extention.v1.AuthzExtentionService.Query:input_type -> authz.extention.v1.QueryRequest - 25, // 35: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse - 18, // 36: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse - 22, // 37: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse - 1, // 38: authz.extention.v1.AuthzExtentionService.Mutate:output_type -> authz.extention.v1.MutateResponse - 28, // 39: authz.extention.v1.AuthzExtentionService.Query:output_type -> authz.extention.v1.QueryResponse - 35, // [35:40] is the sub-list for method output_type - 30, // [30:35] is the sub-list for method input_type - 30, // [30:30] is the sub-list for extension type_name - 30, // [30:30] is the sub-list for extension extendee - 0, // [0:30] is the sub-list for field type_name + 10, // 8: authz.extention.v1.MutateOperation.create_role_binding:type_name -> authz.extention.v1.CreateRoleBindingOperation + 11, // 9: authz.extention.v1.MutateOperation.delete_role_binding:type_name -> authz.extention.v1.DeleteRoleBindingOperation + 12, // 10: authz.extention.v1.MutateOperation.create_team_binding:type_name -> authz.extention.v1.CreateTeamBindingOperation + 13, // 11: authz.extention.v1.MutateOperation.delete_team_binding:type_name -> authz.extention.v1.DeleteTeamBindingOperation + 14, // 12: authz.extention.v1.MutateOperation.create_role:type_name -> authz.extention.v1.CreateRoleOperation + 15, // 13: authz.extention.v1.MutateOperation.delete_role:type_name -> authz.extention.v1.DeleteRoleOperation + 17, // 14: authz.extention.v1.CreatePermissionOperation.resource:type_name -> authz.extention.v1.Resource + 18, // 15: authz.extention.v1.CreatePermissionOperation.permission:type_name -> authz.extention.v1.Permission + 17, // 16: authz.extention.v1.DeletePermissionOperation.resource:type_name -> authz.extention.v1.Resource + 18, // 17: authz.extention.v1.DeletePermissionOperation.permission:type_name -> authz.extention.v1.Permission + 16, // 18: authz.extention.v1.CreateRoleOperation.permissions:type_name -> authz.extention.v1.RolePermission + 16, // 19: authz.extention.v1.DeleteRoleOperation.permissions:type_name -> authz.extention.v1.RolePermission + 22, // 20: authz.extention.v1.TupleKey.condition:type_name -> authz.extention.v1.RelationshipCondition + 19, // 21: authz.extention.v1.Tuple.key:type_name -> authz.extention.v1.TupleKey + 41, // 22: authz.extention.v1.Tuple.timestamp:type_name -> google.protobuf.Timestamp + 42, // 23: authz.extention.v1.RelationshipCondition.context:type_name -> google.protobuf.Struct + 24, // 24: authz.extention.v1.ReadRequest.tuple_key:type_name -> authz.extention.v1.ReadRequestTupleKey + 43, // 25: authz.extention.v1.ReadRequest.page_size:type_name -> google.protobuf.Int32Value + 20, // 26: authz.extention.v1.ReadResponse.tuples:type_name -> authz.extention.v1.Tuple + 19, // 27: authz.extention.v1.WriteRequestWrites.tuple_keys:type_name -> authz.extention.v1.TupleKey + 21, // 28: authz.extention.v1.WriteRequestDeletes.tuple_keys:type_name -> authz.extention.v1.TupleKeyWithoutCondition + 26, // 29: authz.extention.v1.WriteRequest.writes:type_name -> authz.extention.v1.WriteRequestWrites + 27, // 30: authz.extention.v1.WriteRequest.deletes:type_name -> authz.extention.v1.WriteRequestDeletes + 31, // 31: authz.extention.v1.BatchCheckRequest.items:type_name -> authz.extention.v1.BatchCheckItem + 39, // 32: authz.extention.v1.BatchCheckResponse.groups:type_name -> authz.extention.v1.BatchCheckResponse.GroupsEntry + 40, // 33: authz.extention.v1.BatchCheckGroupResource.items:type_name -> authz.extention.v1.BatchCheckGroupResource.ItemsEntry + 36, // 34: authz.extention.v1.QueryRequest.operation:type_name -> authz.extention.v1.QueryOperation + 38, // 35: authz.extention.v1.QueryResponse.folder_parents:type_name -> authz.extention.v1.GetFolderParentsResult + 37, // 36: authz.extention.v1.QueryOperation.get_folder_parents:type_name -> authz.extention.v1.GetFolderParentsQuery + 33, // 37: authz.extention.v1.BatchCheckResponse.GroupsEntry.value:type_name -> authz.extention.v1.BatchCheckGroupResource + 30, // 38: authz.extention.v1.AuthzExtentionService.BatchCheck:input_type -> authz.extention.v1.BatchCheckRequest + 23, // 39: authz.extention.v1.AuthzExtentionService.Read:input_type -> authz.extention.v1.ReadRequest + 28, // 40: authz.extention.v1.AuthzExtentionService.Write:input_type -> authz.extention.v1.WriteRequest + 0, // 41: authz.extention.v1.AuthzExtentionService.Mutate:input_type -> authz.extention.v1.MutateRequest + 34, // 42: authz.extention.v1.AuthzExtentionService.Query:input_type -> authz.extention.v1.QueryRequest + 32, // 43: authz.extention.v1.AuthzExtentionService.BatchCheck:output_type -> authz.extention.v1.BatchCheckResponse + 25, // 44: authz.extention.v1.AuthzExtentionService.Read:output_type -> authz.extention.v1.ReadResponse + 29, // 45: authz.extention.v1.AuthzExtentionService.Write:output_type -> authz.extention.v1.WriteResponse + 1, // 46: authz.extention.v1.AuthzExtentionService.Mutate:output_type -> authz.extention.v1.MutateResponse + 35, // 47: authz.extention.v1.AuthzExtentionService.Query:output_type -> authz.extention.v1.QueryResponse + 43, // [43:48] is the sub-list for method output_type + 38, // [38:43] is the sub-list for method input_type + 38, // [38:38] is the sub-list for extension type_name + 38, // [38:38] is the sub-list for extension extendee + 0, // [0:38] is the sub-list for field type_name } func init() { file_extention_proto_init() } @@ -2310,11 +2962,17 @@ func file_extention_proto_init() { (*MutateOperation_UpdateUserOrgRole)(nil), (*MutateOperation_DeleteUserOrgRole)(nil), (*MutateOperation_AddUserOrgRole)(nil), + (*MutateOperation_CreateRoleBinding)(nil), + (*MutateOperation_DeleteRoleBinding)(nil), + (*MutateOperation_CreateTeamBinding)(nil), + (*MutateOperation_DeleteTeamBinding)(nil), + (*MutateOperation_CreateRole)(nil), + (*MutateOperation_DeleteRole)(nil), } - file_extention_proto_msgTypes[28].OneofWrappers = []any{ + file_extention_proto_msgTypes[35].OneofWrappers = []any{ (*QueryResponse_FolderParents)(nil), } - file_extention_proto_msgTypes[29].OneofWrappers = []any{ + file_extention_proto_msgTypes[36].OneofWrappers = []any{ (*QueryOperation_GetFolderParents)(nil), } type x struct{} @@ -2323,7 +2981,7 @@ func file_extention_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_extention_proto_rawDesc), len(file_extention_proto_rawDesc)), NumEnums: 0, - NumMessages: 34, + NumMessages: 41, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/services/authz/proto/v1/extention.proto b/pkg/services/authz/proto/v1/extention.proto index 2a11b8c5b97..cb59e349f64 100644 --- a/pkg/services/authz/proto/v1/extention.proto +++ b/pkg/services/authz/proto/v1/extention.proto @@ -34,6 +34,12 @@ message MutateOperation { UpdateUserOrgRoleOperation update_user_org_role = 5; DeleteUserOrgRoleOperation delete_user_org_role = 6; AddUserOrgRoleOperation add_user_org_role = 7; + CreateRoleBindingOperation create_role_binding = 8; + DeleteRoleBindingOperation delete_role_binding = 9; + CreateTeamBindingOperation create_team_binding = 10; + DeleteTeamBindingOperation delete_team_binding = 11; + CreateRoleOperation create_role = 12; + DeleteRoleOperation delete_role = 13; } } @@ -87,6 +93,69 @@ message DeleteUserOrgRoleOperation { string role = 2; } +message CreateRoleBindingOperation { + // kind of the identity getting the permission (User/Team/ServiceAccount/BasicRole) + string subject_kind = 1; + // uid of the identity + string subject_name = 2; + // kind of the role (Role/CoreRole/GlobalRole) + string role_kind = 3; + // uid of the role + string role_name = 4; +} + +message DeleteRoleBindingOperation { + // kind of the identity getting the permission (User/Team/ServiceAccount/BasicRole) + string subject_kind = 1; + // uid of the identity + string subject_name = 2; + // kind of the role (Role/CoreRole/GlobalRole) + string role_kind = 3; + // uid of the role + string role_name = 4; +} + +message CreateTeamBindingOperation { + // uid of the identity + string subject_name = 1; + // uid of the team + string team_name = 2; + // permission of the identity in the team (admin/member) + string permission = 3; +} + +message DeleteTeamBindingOperation { + // uid of the identity + string subject_name = 1; + // uid of the team + string team_name = 2; + // permission of the identity in the team (admin/member) + string permission = 3; +} + +message CreateRoleOperation { + // kind of the role (Role/CoreRole/GlobalRole) + string role_kind = 1; + // uid of the role + string role_name = 2; + // permissions of the role + repeated RolePermission permissions = 3; +} + +message DeleteRoleOperation { + // kind of the role (Role/CoreRole/GlobalRole) + string role_kind = 1; + // uid of the role + string role_name = 2; + // permissions of the role + repeated RolePermission permissions = 3; +} + +message RolePermission { + string action = 1; + string scope = 2; +} + message Resource { // group of the resource (e.g: "dashboard.grafana.app") string group = 1; diff --git a/pkg/services/authz/zanzana/common/tuple.go b/pkg/services/authz/zanzana/common/tuple.go index 79efce7373d..b1b6499dcd2 100644 --- a/pkg/services/authz/zanzana/common/tuple.go +++ b/pkg/services/authz/zanzana/common/tuple.go @@ -447,6 +447,14 @@ func ToOpenFGATuples(tuples []*authzextv1.Tuple) []*openfgav1.Tuple { return result } +func ToOpenFGADeleteTupleKey(tuples *openfgav1.TupleKey) *openfgav1.TupleKeyWithoutCondition { + return &openfgav1.TupleKeyWithoutCondition{ + User: tuples.GetUser(), + Relation: tuples.GetRelation(), + Object: tuples.GetObject(), + } +} + func AddRenderContext(req *openfgav1.CheckRequest) { if req.ContextualTuples == nil { req.ContextualTuples = &openfgav1.ContextualTupleKeys{} diff --git a/pkg/services/authz/zanzana/server/server_mutate.go b/pkg/services/authz/zanzana/server/server_mutate.go index 3a691ed6e12..cdd8c5b2f50 100644 --- a/pkg/services/authz/zanzana/server/server_mutate.go +++ b/pkg/services/authz/zanzana/server/server_mutate.go @@ -15,6 +15,9 @@ const ( OperationGroupFolder OperationGroup = "folder" OperationGroupPermission OperationGroup = "permission" OperationGroupUserOrgRole OperationGroup = "user_org_role" + OperationGroupRoleBinding OperationGroup = "role_binding" + OperationGroupTeamBinding OperationGroup = "team_binding" + OperationGroupRole OperationGroup = "role" ) func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) { @@ -63,6 +66,18 @@ func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au if err := s.mutateOrgRoles(ctx, storeInf, operations); err != nil { return nil, fmt.Errorf("failed to mutate org roles: %w", err) } + case OperationGroupRoleBinding: + if err := s.mutateRoleBindings(ctx, storeInf, operations); err != nil { + return nil, fmt.Errorf("failed to mutate role bindings: %w", err) + } + case OperationGroupTeamBinding: + if err := s.mutateTeamBindings(ctx, storeInf, operations); err != nil { + return nil, fmt.Errorf("failed to mutate team bindings: %w", err) + } + case OperationGroupRole: + if err := s.mutateRoles(ctx, storeInf, operations); err != nil { + return nil, fmt.Errorf("failed to mutate roles: %w", err) + } default: s.logger.Warn("unsupported operation group", "operationGroup", operationGroup) } @@ -79,6 +94,12 @@ func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, e return OperationGroupPermission, nil case *authzextv1.MutateOperation_UpdateUserOrgRole, *authzextv1.MutateOperation_DeleteUserOrgRole, *authzextv1.MutateOperation_AddUserOrgRole: return OperationGroupUserOrgRole, nil + case *authzextv1.MutateOperation_CreateRoleBinding, *authzextv1.MutateOperation_DeleteRoleBinding: + return OperationGroupRoleBinding, nil + case *authzextv1.MutateOperation_CreateTeamBinding, *authzextv1.MutateOperation_DeleteTeamBinding: + return OperationGroupTeamBinding, nil + case *authzextv1.MutateOperation_CreateRole, *authzextv1.MutateOperation_DeleteRole: + return OperationGroupRole, nil } return OperationGroup(""), errors.New("unsupported mutate operation type") } diff --git a/pkg/services/authz/zanzana/server/server_mutate_rolebindings.go b/pkg/services/authz/zanzana/server/server_mutate_rolebindings.go new file mode 100644 index 00000000000..3b18566bee2 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_mutate_rolebindings.go @@ -0,0 +1,99 @@ +package server + +import ( + "context" + "fmt" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + zanzana "github.com/grafana/grafana/pkg/services/authz/zanzana/common" +) + +func (s *Server) mutateRoleBindings(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error { + ctx, span := s.tracer.Start(ctx, "server.mutateRoleBindings") + defer span.End() + + writeTuples := make([]*openfgav1.TupleKey, 0) + deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0) + + for _, operation := range operations { + switch op := operation.Operation.(type) { + case *authzextv1.MutateOperation_CreateRoleBinding: + r := op.CreateRoleBinding + tuple, err := s.getRoleBindingTuple(ctx, r.SubjectKind, r.SubjectName, r.RoleName) + if err != nil { + return err + } + writeTuples = append(writeTuples, tuple) + case *authzextv1.MutateOperation_DeleteRoleBinding: + r := op.DeleteRoleBinding + tuple, err := s.getRoleBindingTuple(ctx, r.SubjectKind, r.SubjectName, r.RoleName) + if err != nil { + return err + } + writeTuple := &openfgav1.TupleKeyWithoutCondition{ + User: tuple.User, + Relation: tuple.Relation, + Object: tuple.Object, + } + deleteTuples = append(deleteTuples, writeTuple) + default: + s.logger.Debug("unsupported mutate operation", "operation", op) + } + } + + writeReq := &openfgav1.WriteRequest{ + StoreId: store.ID, + AuthorizationModelId: store.ModelID, + } + if len(writeTuples) > 0 { + writeReq.Writes = &openfgav1.WriteRequestWrites{ + TupleKeys: writeTuples, + OnDuplicate: "ignore", + } + } + if len(deleteTuples) > 0 { + writeReq.Deletes = &openfgav1.WriteRequestDeletes{ + TupleKeys: deleteTuples, + OnMissing: "ignore", + } + } + + _, err := s.openfga.Write(ctx, writeReq) + if err != nil { + s.logger.Error("failed to write resource role binding tuples", "error", err) + return err + } + + return nil +} + +func (s *Server) getRoleBindingTuple(ctx context.Context, subjectKind string, subjectName string, roleName string) (*openfgav1.TupleKey, error) { + zanzanaType := "" + subjectRelation := "" + + switch subjectKind { + case string(iamv0.RoleBindingSpecSubjectKindUser): + zanzanaType = zanzana.TypeUser + case string(iamv0.RoleBindingSpecSubjectKindTeam): + zanzanaType = zanzana.TypeTeam + subjectRelation = zanzana.RelationTeamMember + case string(iamv0.RoleBindingSpecSubjectKindServiceAccount): + zanzanaType = zanzana.TypeServiceAccount + case string(iamv0.RoleBindingSpecSubjectKindBasicRole): + zanzanaType = zanzana.TypeRole + subjectRelation = zanzana.RelationAssignee + default: + return nil, fmt.Errorf("invalid subject kind: %s", subjectKind) + } + + tuple := &openfgav1.TupleKey{ + User: zanzana.NewTupleEntry(zanzanaType, subjectName, subjectRelation), + Relation: zanzana.RelationAssignee, + Object: zanzana.NewTupleEntry(zanzana.TypeRole, roleName, ""), + } + + return tuple, nil +} diff --git a/pkg/services/authz/zanzana/server/server_mutate_rolebindings_test.go b/pkg/services/authz/zanzana/server/server_mutate_rolebindings_test.go new file mode 100644 index 00000000000..87db285c360 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_mutate_rolebindings_test.go @@ -0,0 +1,106 @@ +package server + +import ( + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/require" + + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana/common" +) + +func setupMutateRoleBindings(t *testing.T, srv *Server) *Server { + t.Helper() + + // seed tuples + tuples := []*openfgav1.TupleKey{ + common.NewTuple("user:1", common.RelationAssignee, "role:foo_viewer"), + } + + return setupOpenFGADatabase(t, srv, tuples) +} + +func testMutateRoleBindings(t *testing.T, srv *Server) { + setupMutateRoleBindings(t, srv) + + t.Run("should update user role and delete old role", func(t *testing.T) { + _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + Namespace: "default", + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: "User", + SubjectName: "1", + RoleKind: "Role", + RoleName: "foo_editor", + }, + }, + }, + { + Operation: &v1.MutateOperation_DeleteRoleBinding{ + DeleteRoleBinding: &v1.DeleteRoleBindingOperation{ + SubjectKind: "User", + SubjectName: "1", + RoleKind: "Role", + RoleName: "foo_viewer", + }, + }, + }, + }, + }) + require.NoError(t, err) + + res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{ + Namespace: "default", + TupleKey: &v1.ReadRequestTupleKey{ + Relation: common.RelationAssignee, + Object: "role:foo_editor", + }, + }) + require.NoError(t, err) + require.Len(t, res.Tuples, 1) + require.Equal(t, "user:1", res.Tuples[0].Key.User) + + res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{ + Namespace: "default", + TupleKey: &v1.ReadRequestTupleKey{ + Relation: common.RelationAssignee, + Object: "role:foo_viewer", + }, + }) + require.NoError(t, err) + require.Len(t, res.Tuples, 0) + }) + + t.Run("should assign role to basic role", func(t *testing.T) { + _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + Namespace: "default", + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_CreateRoleBinding{ + CreateRoleBinding: &v1.CreateRoleBindingOperation{ + SubjectKind: "BasicRole", + SubjectName: "basic_viewer", + RoleKind: "Role", + RoleName: "foo_bar", + }, + }, + }, + }, + }) + require.NoError(t, err) + + res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{ + Namespace: "default", + TupleKey: &v1.ReadRequestTupleKey{ + Relation: common.RelationAssignee, + Object: "role:foo_bar", + }, + }) + require.NoError(t, err) + require.Len(t, res.Tuples, 1) + require.Equal(t, "role:basic_viewer#assignee", res.Tuples[0].Key.User) + }) +} diff --git a/pkg/services/authz/zanzana/server/server_mutate_roles.go b/pkg/services/authz/zanzana/server/server_mutate_roles.go new file mode 100644 index 00000000000..4c19b1fd288 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_mutate_roles.go @@ -0,0 +1,108 @@ +package server + +import ( + "context" + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana" + "github.com/grafana/grafana/pkg/services/authz/zanzana/common" +) + +func (s *Server) mutateRoles(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error { + ctx, span := s.tracer.Start(ctx, "server.mutateRoles") + defer span.End() + + writeTuples := make([]*openfgav1.TupleKey, 0) + deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0) + + for _, operation := range operations { + switch op := operation.Operation.(type) { + case *authzextv1.MutateOperation_CreateRole: + tuples, err := convertRoleToTuples(op.CreateRole.RoleName, op.CreateRole.Permissions) + if err != nil { + return err + } + writeTuples = append(writeTuples, tuples...) + case *authzextv1.MutateOperation_DeleteRole: + tuples, err := convertRoleToTuples(op.DeleteRole.RoleName, op.DeleteRole.Permissions) + if err != nil { + return err + } + deletes := make([]*openfgav1.TupleKeyWithoutCondition, 0, len(tuples)) + for _, tuple := range tuples { + deletes = append(deletes, common.ToOpenFGADeleteTupleKey(tuple)) + } + deleteTuples = append(deleteTuples, deletes...) + default: + s.logger.Debug("unsupported mutate operation", "operation", op) + } + } + + writeReq := &openfgav1.WriteRequest{ + StoreId: store.ID, + AuthorizationModelId: store.ModelID, + } + if len(writeTuples) > 0 { + writeReq.Writes = &openfgav1.WriteRequestWrites{ + TupleKeys: writeTuples, + OnDuplicate: "ignore", + } + } + if len(deleteTuples) > 0 { + writeReq.Deletes = &openfgav1.WriteRequestDeletes{ + TupleKeys: deleteTuples, + OnMissing: "ignore", + } + } + + _, err := s.openfga.Write(ctx, writeReq) + if err != nil { + s.logger.Error("failed to write resource role binding tuples", "error", err) + return err + } + + return nil +} + +// convertRoleToTuples converts role and its permissions (action/scope) to v1 TupleKey format +// using the shared zanzana.ConvertRolePermissionsToTuples utility and common.ToAuthzExtTupleKeys +func convertRoleToTuples(roleUID string, permissions []*authzextv1.RolePermission) ([]*openfgav1.TupleKey, error) { + // Convert to zanzana.RolePermission + rolePerms := make([]zanzana.RolePermission, 0, len(permissions)) + for _, perm := range permissions { + // Split the scope to get kind, attribute, identifier + kind, _, identifier := splitScope(perm.Scope) + rolePerms = append(rolePerms, zanzana.RolePermission{ + Action: perm.Action, + Kind: kind, + Identifier: identifier, + }) + } + + // Translate to Zanzana tuples + tuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, rolePerms) + if err != nil { + return nil, err + } + + return tuples, nil +} + +func splitScope(scope string) (string, string, string) { + if scope == "" { + return "", "", "" + } + + fragments := strings.Split(scope, ":") + switch l := len(fragments); l { + case 1: // Splitting a wildcard scope "*" -> kind: "*"; attribute: "*"; identifier: "*" + return fragments[0], fragments[0], fragments[0] + case 2: // Splitting a wildcard scope with specified kind "dashboards:*" -> kind: "dashboards"; attribute: "*"; identifier: "*" + return fragments[0], fragments[1], fragments[1] + default: // Splitting a scope with all fields specified "dashboards:uid:my_dash" -> kind: "dashboards"; attribute: "uid"; identifier: "my_dash" + return fragments[0], fragments[1], strings.Join(fragments[2:], ":") + } +} diff --git a/pkg/services/authz/zanzana/server/server_mutate_roles_test.go b/pkg/services/authz/zanzana/server/server_mutate_roles_test.go new file mode 100644 index 00000000000..1128c0c0f72 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_mutate_roles_test.go @@ -0,0 +1,77 @@ +package server + +import ( + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/require" + + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana/common" +) + +func setupMutateRoles(t *testing.T, srv *Server) *Server { + t.Helper() + + // seed tuples + tuples := []*openfgav1.TupleKey{ + common.NewTuple("role:foo_viewer#assignee", "view", "group_resource:dashboard.grafana.app/dashboards"), + } + + return setupOpenFGADatabase(t, srv, tuples) +} + +func testMutateRoles(t *testing.T, srv *Server) { + setupMutateRoles(t, srv) + + t.Run("should update role and delete old role permissions", func(t *testing.T) { + _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + Namespace: "default", + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_CreateRole{ + CreateRole: &v1.CreateRoleOperation{ + RoleName: "foo_viewer", + RoleKind: "Role", + Permissions: []*v1.RolePermission{ + { + Action: "dashboards:edit", + Scope: "dashboards:*", + }, + }, + }, + }, + }, + { + Operation: &v1.MutateOperation_DeleteRole{ + DeleteRole: &v1.DeleteRoleOperation{ + RoleName: "foo_viewer", + RoleKind: "Role", + Permissions: []*v1.RolePermission{ + { + Action: "dashboards:view", + Scope: "dashboards:*", + }, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{ + Namespace: "default", + TupleKey: &v1.ReadRequestTupleKey{ + User: "role:foo_viewer#assignee", + Relation: "edit", + Object: "group_resource:", + }, + }) + require.NoError(t, err) + require.Len(t, res.Tuples, 1) + require.Equal(t, "role:foo_viewer#assignee", res.Tuples[0].Key.User) + require.Equal(t, "group_resource:dashboard.grafana.app/dashboards", res.Tuples[0].Key.Object) + require.Equal(t, "edit", res.Tuples[0].Key.Relation) + }) +} diff --git a/pkg/services/authz/zanzana/server/server_mutate_teambindings.go b/pkg/services/authz/zanzana/server/server_mutate_teambindings.go new file mode 100644 index 00000000000..81e1c9cb437 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_mutate_teambindings.go @@ -0,0 +1,98 @@ +package server + +import ( + "context" + "errors" + "fmt" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + zanzana "github.com/grafana/grafana/pkg/services/authz/zanzana/common" +) + +func (s *Server) mutateTeamBindings(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error { + ctx, span := s.tracer.Start(ctx, "server.mutateTeamBindings") + defer span.End() + + writeTuples := make([]*openfgav1.TupleKey, 0) + deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0) + + for _, operation := range operations { + switch op := operation.Operation.(type) { + case *authzextv1.MutateOperation_CreateTeamBinding: + tuple, err := s.getTeamBindingTuple(ctx, op.CreateTeamBinding.GetSubjectName(), op.CreateTeamBinding.GetTeamName(), op.CreateTeamBinding.GetPermission()) + if err != nil { + return err + } + writeTuples = append(writeTuples, tuple) + case *authzextv1.MutateOperation_DeleteTeamBinding: + tuple, err := s.getTeamBindingTuple(ctx, op.DeleteTeamBinding.GetSubjectName(), op.DeleteTeamBinding.GetTeamName(), op.DeleteTeamBinding.GetPermission()) + if err != nil { + return err + } + deleteTuple := &openfgav1.TupleKeyWithoutCondition{ + User: tuple.User, + Relation: tuple.Relation, + Object: tuple.Object, + } + deleteTuples = append(deleteTuples, deleteTuple) + default: + s.logger.Debug("unsupported mutate operation", "operation", op) + } + } + + writeReq := &openfgav1.WriteRequest{ + StoreId: store.ID, + AuthorizationModelId: store.ModelID, + } + if len(writeTuples) > 0 { + writeReq.Writes = &openfgav1.WriteRequestWrites{ + TupleKeys: writeTuples, + OnDuplicate: "ignore", + } + } + if len(deleteTuples) > 0 { + writeReq.Deletes = &openfgav1.WriteRequestDeletes{ + TupleKeys: deleteTuples, + OnMissing: "ignore", + } + } + + _, err := s.openfga.Write(ctx, writeReq) + if err != nil { + s.logger.Error("failed to write resource role binding tuples", "error", err) + return err + } + + return nil +} + +func (s *Server) getTeamBindingTuple(ctx context.Context, subject string, team string, permission string) (*openfgav1.TupleKey, error) { + if subject == "" { + return nil, errors.New("subject name cannot be empty") + } + + if team == "" { + return nil, errors.New("team name cannot be empty") + } + + relation := "" + switch permission { + case string(iamv0.TeamBindingTeamPermissionAdmin): + relation = zanzana.RelationTeamAdmin + case string(iamv0.TeamBindingTeamPermissionMember): + relation = zanzana.RelationTeamMember + default: + return nil, fmt.Errorf("unknown team permission '%s', expected member or admin", permission) + } + + tuple := &openfgav1.TupleKey{ + User: zanzana.NewTupleEntry(zanzana.TypeUser, subject, ""), + Relation: relation, + Object: zanzana.NewTupleEntry(zanzana.TypeTeam, team, ""), + } + + return tuple, nil +} diff --git a/pkg/services/authz/zanzana/server/server_mutate_teambindings_test.go b/pkg/services/authz/zanzana/server/server_mutate_teambindings_test.go new file mode 100644 index 00000000000..5103b142fc5 --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_mutate_teambindings_test.go @@ -0,0 +1,74 @@ +package server + +import ( + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/require" + + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana/common" +) + +func setupMutateTeamBindings(t *testing.T, srv *Server) *Server { + t.Helper() + + // seed tuples + tuples := []*openfgav1.TupleKey{ + common.NewTuple("user:1", common.RelationTeamMember, "team:foo"), + } + + return setupOpenFGADatabase(t, srv, tuples) +} + +func testMutateTeamBindings(t *testing.T, srv *Server) { + setupMutateTeamBindings(t, srv) + + t.Run("should update user team binding and delete old team binding", func(t *testing.T) { + _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + Namespace: "default", + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_CreateTeamBinding{ + CreateTeamBinding: &v1.CreateTeamBindingOperation{ + SubjectName: "1", + TeamName: "foo", + Permission: "admin", + }, + }, + }, + { + Operation: &v1.MutateOperation_DeleteTeamBinding{ + DeleteTeamBinding: &v1.DeleteTeamBindingOperation{ + SubjectName: "1", + TeamName: "foo", + Permission: "member", + }, + }, + }, + }, + }) + require.NoError(t, err) + + res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{ + Namespace: "default", + TupleKey: &v1.ReadRequestTupleKey{ + Relation: common.RelationTeamAdmin, + Object: "team:foo", + }, + }) + require.NoError(t, err) + require.Len(t, res.Tuples, 1) + require.Equal(t, "user:1", res.Tuples[0].Key.User) + + res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{ + Namespace: "default", + TupleKey: &v1.ReadRequestTupleKey{ + Relation: common.RelationTeamMember, + Object: "team:foo", + }, + }) + require.NoError(t, err) + require.Len(t, res.Tuples, 0) + }) +} diff --git a/pkg/services/authz/zanzana/server/server_test.go b/pkg/services/authz/zanzana/server/server_test.go index df4f90cf85f..14e8f629729 100644 --- a/pkg/services/authz/zanzana/server/server_test.go +++ b/pkg/services/authz/zanzana/server/server_test.go @@ -136,6 +136,18 @@ func TestIntegrationServer(t *testing.T) { t.Run("test query folders", func(t *testing.T) { testQueryFolders(t, srv) }) + + t.Run("test mutate role bindings", func(t *testing.T) { + testMutateRoleBindings(t, srv) + }) + + t.Run("test mutate team bindings", func(t *testing.T) { + testMutateTeamBindings(t, srv) + }) + + t.Run("test mutate roles", func(t *testing.T) { + testMutateRoles(t, srv) + }) } func setupOpenFGAServer(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server { diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 5725cdf193b..35ac47afd9a 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -2016,6 +2016,26 @@ func TestSearchDashboardsThroughK8sRaw(t *testing.T) { _, err := service.searchDashboardsThroughK8s(ctx, query) require.NoError(t, err) }) + + t.Run("search will request legacy dashboard ID", func(t *testing.T) { + ctx := context.Background() + k8sCliMock := new(client.MockK8sHandler) + service := &DashboardServiceImpl{k8sclient: k8sCliMock} + query := &dashboards.FindPersistedDashboardsQuery{ + ManagedBy: utils.ManagerKindClassicFP, //nolint:staticcheck + OrgId: 1, + } + k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") + k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resourcepb.ResourceSearchRequest) bool { + return slices.Contains(req.Fields, "grafana.app/deprecatedInternalID") && + slices.Contains(req.Fields, "labels.grafana.app/deprecatedInternalID") + })).Return(&resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{}, + TotalHits: 0, + }, nil) + _, err := service.searchDashboardsThroughK8s(ctx, query) + require.NoError(t, err) + }) } func TestSearchProvisionedDashboardsThroughK8sRaw(t *testing.T) { diff --git a/pkg/services/dashboards/service/search/search.go b/pkg/services/dashboards/service/search/search.go index 79e61eab99a..e1ce9e9132d 100644 --- a/pkg/services/dashboards/service/search/search.go +++ b/pkg/services/dashboards/service/search/search.go @@ -40,6 +40,9 @@ var ( resource.SEARCH_FIELD_SOURCE_PATH, resource.SEARCH_FIELD_SOURCE_CHECKSUM, resource.SEARCH_FIELD_SOURCE_TIME, + // below is needed to determine whether a provisioned dashboard exists or not + resource.SEARCH_FIELD_LEGACY_ID, + resource.SEARCH_FIELD_LABELS + "." + resource.SEARCH_FIELD_LEGACY_ID, } ) diff --git a/pkg/services/datasources/fakes/fake_datasource_service.go b/pkg/services/datasources/fakes/fake_datasource_service.go index 19e7bbb43a5..7637657f7cf 100644 --- a/pkg/services/datasources/fakes/fake_datasource_service.go +++ b/pkg/services/datasources/fakes/fake_datasource_service.go @@ -15,6 +15,9 @@ type FakeDataSourceService struct { lastID int64 DataSources []*datasources.DataSource SimulatePluginFailure bool + + // UID -> Headers + DataSourceHeaders map[string]http.Header } var _ datasources.DataSourceService = &FakeDataSourceService{} @@ -152,5 +155,5 @@ func (s *FakeDataSourceService) DecryptedPassword(ctx context.Context, ds *datas } func (s *FakeDataSourceService) CustomHeaders(ctx context.Context, ds *datasources.DataSource) (http.Header, error) { - return nil, nil + return s.DataSourceHeaders[ds.UID], nil } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 65f94c24eac..c112e0b77a0 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -579,6 +579,13 @@ var ( FrontendOnly: false, // The restore backend feature changes behavior based on this flag Owner: grafanaDashboardsSquad, }, + { + Name: "kubernetesDashboardsV2", + Description: "Use the v2 kubernetes API in the frontend for dashboards", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaDashboardsSquad, + }, { Name: "dashboardUndoRedo", Description: "Enables undo/redo in dynamic dashboards", @@ -600,6 +607,13 @@ var ( FrontendOnly: true, Owner: grafanaDashboardsSquad, }, + { + Name: "panelGroupBy", + Description: "Enabled a group by action per panel", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDashboardsSquad, + }, { Name: "panelFilterVariable", Description: "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard", @@ -1315,7 +1329,7 @@ var ( Name: "elasticsearchImprovedParsing", Description: "Enables less memory intensive Elasticsearch result parsing", Stage: FeatureStageExperimental, - Owner: awsDatasourcesSquad, + Owner: grafanaPartnerPluginsSquad, }, { Name: "datasourceConnectionsTab", @@ -1801,10 +1815,10 @@ var ( { Name: "azureResourcePickerUpdates", Description: "Enables the updated Azure Monitor resource picker", - Stage: FeatureStagePublicPreview, + Stage: FeatureStageGeneralAvailability, FrontendOnly: true, Owner: grafanaPartnerPluginsSquad, - Expression: "false", + Expression: "true", }, { Name: "prometheusTypeMigration", @@ -1895,10 +1909,10 @@ var ( { Name: "newPanelPadding", Description: "Increases panel padding globally", - Stage: FeatureStageExperimental, - FrontendOnly: false, + Stage: FeatureStagePublicPreview, + FrontendOnly: true, Owner: grafanaDashboardsSquad, - Expression: "false", + Expression: "true", }, { Name: "onlyStoreActionSets", @@ -1963,6 +1977,13 @@ var ( RequiresRestart: false, HideFromDocs: false, }, + { + Name: "kubernetesAlertingHistorian", + Description: "Adds support for Kubernetes alerting historian APIs", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + RequiresRestart: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 17fdf27f33d..1382204ce0f 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -80,9 +80,11 @@ dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true dashboardScene,GA,@grafana/dashboards-squad,false,false,true dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false +kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true +panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true @@ -181,7 +183,7 @@ k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false teamHttpHeadersTempo,experimental,@grafana/identity-access-team,false,false,false grafanaAdvisor,privatePreview,@grafana/plugins-platform-backend,false,false,false -elasticsearchImprovedParsing,experimental,@grafana/aws-datasources,false,false,false +elasticsearchImprovedParsing,experimental,@grafana/partner-datasources,false,false,false datasourceConnectionsTab,privatePreview,@grafana/plugins-platform-backend,false,false,true fetchRulesUsingPost,experimental,@grafana/alerting-squad,false,false,false newLogsPanel,GA,@grafana/observability-logs,false,false,true @@ -245,7 +247,7 @@ teamFolders,experimental,@grafana/grafana-search-navigate-organise,false,false,f interactiveLearning,preview,@grafana/pathfinder,false,false,false alertingTriage,experimental,@grafana/alerting-squad,false,false,false graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,false -azureResourcePickerUpdates,preview,@grafana/partner-datasources,false,false,true +azureResourcePickerUpdates,GA,@grafana/partner-datasources,false,false,true prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false @@ -257,7 +259,7 @@ newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false -newPanelPadding,experimental,@grafana/dashboards-squad,false,false,false +newPanelPadding,preview,@grafana/dashboards-squad,false,false,true onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false @@ -266,3 +268,4 @@ transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true ttlPluginInstanceManager,experimental,@grafana/plugins-platform-backend,false,false,true lokiQueryLimitsContext,experimental,@grafana/observability-logs,false,false,true rudderstackUpgrade,experimental,@grafana/grafana-frontend-platform,false,false,true +kubernetesAlertingHistorian,experimental,@grafana/alerting-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index b88204db708..3183c587efd 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -259,6 +259,10 @@ const ( // Enables experimental new dashboard layouts FlagDashboardNewLayouts = "dashboardNewLayouts" + // FlagKubernetesDashboardsV2 + // Use the v2 kubernetes API in the frontend for dashboards + FlagKubernetesDashboardsV2 = "kubernetesDashboardsV2" + // FlagPdfTables // Enables generating table data as PDF in reporting FlagPdfTables = "pdfTables" @@ -742,10 +746,6 @@ const ( // Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded FlagPluginStoreServiceLoading = "pluginStoreServiceLoading" - // FlagNewPanelPadding - // Increases panel padding globally - FlagNewPanelPadding = "newPanelPadding" - // FlagOnlyStoreActionSets // When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission FlagOnlyStoreActionSets = "onlyStoreActionSets" @@ -761,4 +761,8 @@ const ( // FlagAwsDatasourcesHttpProxy // Enables http proxy settings for aws datasources FlagAwsDatasourcesHttpProxy = "awsDatasourcesHttpProxy" + + // FlagKubernetesAlertingHistorian + // Adds support for Kubernetes alerting historian APIs + FlagKubernetesAlertingHistorian = "kubernetesAlertingHistorian" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 1093b643232..8451faca5d0 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -551,7 +551,6 @@ "description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -565,7 +564,6 @@ "description": "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -768,16 +766,19 @@ { "metadata": { "name": "azureResourcePickerUpdates", - "resourceVersion": "1763734583253", + "resourceVersion": "1764153435365", "creationTimestamp": "2025-07-31T22:56:50Z", - "deletionTimestamp": "2025-08-01T11:30:17Z" + "deletionTimestamp": "2025-08-01T11:30:17Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-26 10:37:15.365919 +0000 UTC" + } }, "spec": { "description": "Enables the updated Azure Monitor resource picker", - "stage": "preview", + "stage": "GA", "codeowner": "@grafana/partner-datasources", "frontend": true, - "expression": "false" + "expression": "true" } }, { @@ -1213,13 +1214,16 @@ { "metadata": { "name": "elasticsearchImprovedParsing", - "resourceVersion": "1763734583253", - "creationTimestamp": "2025-01-15T17:05:54Z" + "resourceVersion": "1764260048941", + "creationTimestamp": "2025-01-15T17:05:54Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-27 16:14:08.941633 +0000 UTC" + } }, "spec": { "description": "Enables less memory intensive Elasticsearch result parsing", "stage": "experimental", - "codeowner": "@grafana/aws-datasources" + "codeowner": "@grafana/partner-datasources" } }, { @@ -1789,6 +1793,19 @@ "requiresRestart": true } }, + { + "metadata": { + "name": "kubernetesAlertingHistorian", + "resourceVersion": "1764257713773", + "creationTimestamp": "2025-11-27T15:35:13Z" + }, + "spec": { + "description": "Adds support for Kubernetes alerting historian APIs", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true + } + }, { "metadata": { "name": "kubernetesAlertingRules", @@ -1910,6 +1927,18 @@ "expression": "true" } }, + { + "metadata": { + "name": "kubernetesDashboardsV2", + "resourceVersion": "1764236054307", + "creationTimestamp": "2025-11-27T09:34:14Z" + }, + "spec": { + "description": "Use the v2 kubernetes API in the frontend for dashboards", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad" + } + }, { "metadata": { "name": "kubernetesExternalGroupMapping", @@ -2361,14 +2390,18 @@ { "metadata": { "name": "newPanelPadding", - "resourceVersion": "1763734583253", - "creationTimestamp": "2025-11-12T15:40:46Z" + "resourceVersion": "1764168915089", + "creationTimestamp": "2025-11-12T15:40:46Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-26 14:55:15.089551 +0000 UTC" + } }, "spec": { "description": "Increases panel padding globally", - "stage": "experimental", + "stage": "preview", "codeowner": "@grafana/dashboards-squad", - "expression": "false" + "frontend": true, + "expression": "true" } }, { @@ -2479,6 +2512,19 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "panelGroupBy", + "resourceVersion": "1764257043719", + "creationTimestamp": "2025-11-27T15:24:03Z" + }, + "spec": { + "description": "Enabled a group by action per panel", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, { "metadata": { "name": "panelTimeSettings", @@ -3542,6 +3588,22 @@ "expression": "true" } }, + { + "metadata": { + "name": "v2DashboardAPIVersion", + "resourceVersion": "1762457740470", + "creationTimestamp": "2025-11-06T19:22:05Z", + "deletionTimestamp": "2025-11-27T09:34:14Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-06 19:35:40.470587 +0000 UTC" + } + }, + "spec": { + "description": "Enables the v2 dashboard API version", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad" + } + }, { "metadata": { "name": "vizActionsAuth", @@ -3584,4 +3646,4 @@ } } ] -} \ No newline at end of file +} diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html index 2eb4d82dce3..b0364fcbac9 100644 --- a/pkg/services/frontend/index.html +++ b/pkg/services/frontend/index.html @@ -79,6 +79,7 @@ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + line-height: 1; /* prevent shift when css loads in that changes the body line-height */ } .fs-variant-loader, .fs-variant-error, .fs-custom-domain-error { @@ -239,6 +240,30 @@ const CHECK_INTERVAL = 1 * 1000; + function getCookie(name) { + const cookies = document.cookie.split(";").map(c => c.trim()); + + for (const cookie of cookies) { + if (cookie.startsWith(name + "=")) { + return cookie.substring(name.length + 1); + } + } + + return null; + } + + function getSessionExpiration() { + const value = getCookie("grafana_session_expiry") || "0"; + const realExpiresSeconds = parseInt(value, 10); + const expiresSeconds = Math.max(realExpiresSeconds - 10, 0); // Rotate 10s before the real expiration + const expiration = new Date(expiresSeconds * 1000); + return expiration; + } + + async function rotateSession() { + await fetch('/api/user/auth-tokens/rotate', { method: 'POST' }); + } + /** * Fetches boot data from the server. If it returns undefined, it should be retried later. * Will return a rejected promise on unrecoverable errors. @@ -295,6 +320,19 @@ function loadBootData() { return new Promise((resolve, reject) => { const attemptFetch = async () => { + try { + const sessionExpiration = getSessionExpiration(); + const now = new Date(); + + // If the session has expired, don't continue trying to fetch boot data + if (now >= sessionExpiration) { + await rotateSession(); + } + } catch (error) { + // Just ignore any errors in session rotation. The user can just log in again. + console.warn("Failed to rotate session", error); + } + try { const bootData = await fetchBootData(); diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 4f8015f5687..b9b0edac970 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -128,6 +129,10 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode } if include.Type == "page" { + if !middleware.PageIsFeatureToggleEnabled(c.Req.Context(), include.Path) { + s.log.Debug("Skipping page", "plugin", plugin.ID, "path", include.Path) + continue + } link := &navtree.NavLink{ Text: include.Name, Icon: include.Icon, diff --git a/pkg/services/ngalert/api/api_ruler_history.go b/pkg/services/ngalert/api/api_ruler_history.go index 8e32b776274..51c96b2591f 100644 --- a/pkg/services/ngalert/api/api_ruler_history.go +++ b/pkg/services/ngalert/api/api_ruler_history.go @@ -4,11 +4,14 @@ import ( "context" "fmt" "net/http" + "net/url" + "strconv" "strings" "time" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/ngalert/eval" @@ -24,55 +27,64 @@ type HistorySrv struct { hist Historian } -const labelQueryPrefix = "labels_" - func (srv *HistorySrv) RouteQueryStateHistory(c *contextmodel.ReqContext) response.Response { - from := c.QueryInt64("from") - to := c.QueryInt64("to") - limit := c.QueryInt("limit") - ruleUID := c.Query("ruleUID") - dashUID := c.Query("dashboardUID") - panelID := c.QueryInt64("panelID") - - previous := c.Query("previous") - if previous != "" { - _, err := eval.ParseStateString(previous) - if err != nil { - return ErrResp(http.StatusBadRequest, fmt.Errorf("invalid previous state filter: %w", err), "") - } + query, err := ParseHistoryQuery(c.OrgID, c.SignedInUser, c.Req.URL.Query()) + if err != nil { + return ErrResp(http.StatusBadRequest, err, "") } - current := c.Query("current") - if current != "" { - _, err := eval.ParseStateString(current) - if err != nil { - return ErrResp(http.StatusBadRequest, fmt.Errorf("invalid current state filter: %w", err), "") - } - } - - labels := make(map[string]string) - for k, v := range c.Req.URL.Query() { - if strings.HasPrefix(k, labelQueryPrefix) { - labels[k[len(labelQueryPrefix):]] = v[0] - } - } - - query := models.HistoryQuery{ - RuleUID: ruleUID, - OrgID: c.GetOrgID(), - DashboardUID: dashUID, - PanelID: panelID, - Previous: previous, - Current: current, - SignedInUser: c.SignedInUser, - From: time.Unix(from, 0), - To: time.Unix(to, 0), - Limit: limit, - Labels: labels, - } frame, err := srv.hist.Query(c.Req.Context(), query) if err != nil { return ErrResp(http.StatusInternalServerError, err, "") } return response.JSON(http.StatusOK, frame) } + +const labelQueryPrefix = "labels_" + +// ParseHistoryQuery parses a HistoryQuery from request parameters. +func ParseHistoryQuery(orgID int64, user identity.Requester, query url.Values) (models.HistoryQuery, error) { + from, _ := strconv.ParseInt(query.Get("from"), 10, 64) + to, _ := strconv.ParseInt(query.Get("to"), 10, 64) + limit, _ := strconv.Atoi(query.Get("limit")) + ruleUID := query.Get("ruleUID") + dashUID := query.Get("dashboardUID") + panelID, _ := strconv.ParseInt(query.Get("panelID"), 10, 64) + + previous := query.Get("previous") + if previous != "" { + _, err := eval.ParseStateString(previous) + if err != nil { + return models.HistoryQuery{}, fmt.Errorf("invalid previous state filter: %w", err) + } + } + + current := query.Get("current") + if current != "" { + _, err := eval.ParseStateString(current) + if err != nil { + return models.HistoryQuery{}, fmt.Errorf("invalid current state filter: %w", err) + } + } + + labels := make(map[string]string) + for k, v := range query { + if strings.HasPrefix(k, labelQueryPrefix) { + labels[k[len(labelQueryPrefix):]] = v[0] + } + } + + return models.HistoryQuery{ + RuleUID: ruleUID, + OrgID: orgID, + DashboardUID: dashUID, + PanelID: panelID, + Previous: previous, + Current: current, + SignedInUser: user, + From: time.Unix(from, 0), + To: time.Unix(to, 0), + Limit: limit, + Labels: labels, + }, nil +} diff --git a/pkg/services/ngalert/models/alert_query.go b/pkg/services/ngalert/models/alert_query.go index 9d25d8cfa71..f86925eaa9b 100644 --- a/pkg/services/ngalert/models/alert_query.go +++ b/pkg/services/ngalert/models/alert_query.go @@ -165,6 +165,21 @@ func (aq *AlertQuery) setMaxDatapoints() error { return nil } +// setRefID sets the model refId if it's missing or invalid +func (aq *AlertQuery) setRefID() error { + if aq.modelProps == nil { + err := aq.setModelProps() + if err != nil { + return err + } + } + + if refID, ok := aq.modelProps["refId"].(string); !ok || refID != aq.RefID { + aq.modelProps["refId"] = aq.RefID + } + return nil +} + func (aq *AlertQuery) GetMaxDatapoints() (int64, error) { err := aq.setMaxDatapoints() if err != nil { @@ -256,6 +271,11 @@ func (aq *AlertQuery) GetModel() ([]byte, error) { return nil, err } + err = aq.setRefID() + if err != nil { + return nil, err + } + err = aq.setIntervalMS() if err != nil { return nil, err diff --git a/pkg/services/ngalert/writer/datasourcewriter.go b/pkg/services/ngalert/writer/datasourcewriter.go index 19af0db0a5b..cc7036974e7 100644 --- a/pkg/services/ngalert/writer/datasourcewriter.go +++ b/pkg/services/ngalert/writer/datasourcewriter.go @@ -205,11 +205,23 @@ func (w *DatasourceWriter) makeWriter(ctx context.Context, orgID int64, dsUID st return nil, err } + // We need to add the writer headers (valid for any data source) and any data-source-specific headers. headers := make(http.Header) for k, v := range w.cfg.CustomHeaders { headers.Add(k, v) } + dsHeaders, err := w.datasources.CustomHeaders(ctx, ds) + if err != nil { + return nil, fmt.Errorf("failed to get headers for data source: %w", err) + } + + for k, values := range dsHeaders { + for _, v := range values { + headers.Add(k, v) + } + } + var backend backendType if dsUID == string(grafanaCloudPromType) { backend = grafanaCloudPromType diff --git a/pkg/services/ngalert/writer/datasourcewriter_test.go b/pkg/services/ngalert/writer/datasourcewriter_test.go index f06d2d95de4..349d8b9191a 100644 --- a/pkg/services/ngalert/writer/datasourcewriter_test.go +++ b/pkg/services/ngalert/writer/datasourcewriter_test.go @@ -56,13 +56,14 @@ func (m *mockHTTPClientProvider) New(options ...sdkhttpclient.Options) (*http.Cl type testDataSources struct { dsfakes.FakeDataSourceService - prom1, prom2, prom3 *TestRemoteWriteTarget + prom1, prom2, prom3, prom4 *TestRemoteWriteTarget } func (t *testDataSources) Reset() { t.prom1.Reset() t.prom2.Reset() t.prom3.Reset() + t.prom4.Reset() } func setupDataSources(t *testing.T) *testDataSources { @@ -70,7 +71,9 @@ func setupDataSources(t *testing.T) *testDataSources { prom1: NewTestRemoteWriteTarget(t), prom2: NewTestRemoteWriteTarget(t), prom3: NewTestRemoteWriteTarget(t), + prom4: NewTestRemoteWriteTarget(t), } + res.DataSourceHeaders = make(map[string]http.Header) t.Cleanup(func() { res.prom1.Close() @@ -81,6 +84,9 @@ func setupDataSources(t *testing.T) *testDataSources { t.Cleanup(func() { res.prom3.Close() }) + t.Cleanup(func() { + res.prom4.Close() + }) p1, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ Name: "prom-1", @@ -107,7 +113,7 @@ func setupDataSources(t *testing.T) *testDataSources { Type: datasources.DS_LOKI, }) - // Add a third Prometheus datasource that uses PDC + // Add a third Prometheus datasource that uses PDC. p3, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ Name: "prom-3", UID: "prom-3", @@ -123,6 +129,21 @@ func setupDataSources(t *testing.T) *testDataSources { require.True(t, p3.IsSecureSocksDSProxyEnabled()) + // Add a fourth Prometheus datasource with headers in the JSON config. + p4, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ + Name: "prom-4", + UID: "prom-4", + Type: datasources.DS_PROMETHEUS, + JsonData: simplejson.MustJson([]byte(`{"prometheusType":"Prometheus"}`)), + }) + p4.URL = res.prom4.srv.URL + res.prom4.ExpectedPath = "/api/v1/write" + res.DataSourceHeaders["prom-4"] = http.Header{ + "X-Scope-OrgID": []string{"test-user"}, + "X-Test-Header": []string{"test-value"}, + "X-Double-Header": []string{"one", "two", "three"}, + } + return res } @@ -204,6 +225,45 @@ func TestDatasourceWriter(t *testing.T) { assert.Equal(t, headers[header2], testDS.prom1.LastHeaders.Get(header2)) }) + t.Run("when data source headers are configured, they are passed to the request", func(t *testing.T) { + testDS.Reset() + overwrittenHeader := "X-Test-Header" + cHeaders := map[string]string{ + "X-Custom-Header": "test-value", + "X-Another-Header": "another-value", + overwrittenHeader: "overwritten", // Data source headers should be overwritten by custom headers. + } + + cfg = DatasourceWriterConfig{ + Timeout: time.Second * 5, + DefaultDatasourceUID: "prom-1", + CustomHeaders: cHeaders, + } + writer = NewDatasourceWriter(cfg, testDS, httpclient.NewProvider(), pluginContextProvider, clock.New(), log.New("test"), met) + + uid := "prom-4" + err := writer.WriteDatasource(context.Background(), uid, "metric", time.Now(), frames, 1, map[string]string{}) + require.NoError(t, err) + + dsHeaders := testDS.DataSourceHeaders[uid] + require.Len(t, dsHeaders, 3) + + // We're confirming we have a data source header with the same name but different value. + // This one should not be sent in the request. + require.NotEmpty(t, dsHeaders[overwrittenHeader]) + require.NotEqual(t, dsHeaders[overwrittenHeader], cHeaders[overwrittenHeader]) + + // All headers (except for the one that was overwritten) should have been used. + for k, vv := range dsHeaders { + if k != overwrittenHeader { + assert.Equal(t, vv, testDS.prom4.LastHeaders.Values(k)) + } + } + for k, v := range cHeaders { + assert.Equal(t, v, testDS.prom4.LastHeaders.Get(k)) + } + }) + t.Run("when PDC is enabled proxy options are passed to HTTP client provider", func(t *testing.T) { testDS.Reset() diff --git a/pkg/services/ngalert/writer/testing.go b/pkg/services/ngalert/writer/testing.go index 91b1bdb6b10..8b764657d6e 100644 --- a/pkg/services/ngalert/writer/testing.go +++ b/pkg/services/ngalert/writer/testing.go @@ -1,6 +1,7 @@ package writer import ( + "fmt" "io" "net/http" "net/http/httptest" @@ -37,7 +38,7 @@ func NewTestRemoteWriteTarget(t *testing.T) *TestRemoteWriteTarget { handler := func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != target.ExpectedPath { - require.Fail(t, "Received unexpected request for endpoint %s", r.URL.Path) + require.Fail(t, fmt.Sprintf("Received unexpected request for endpoint %s", r.URL.Path)) } target.mtx.Lock() diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go index e0c24c6ab07..72b980e4198 100644 --- a/pkg/services/provisioning/dashboards/dashboard.go +++ b/pkg/services/provisioning/dashboards/dashboard.go @@ -2,6 +2,7 @@ package dashboards import ( "context" + "errors" "fmt" "os" "time" @@ -9,10 +10,12 @@ import ( dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" folderV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/provisioning/utils" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" ) @@ -28,7 +31,7 @@ type DashboardProvisioner interface { } // DashboardProvisionerFactory creates DashboardProvisioners based on input -type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service) (DashboardProvisioner, error) +type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService, *setting.Cfg, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service, *serverlock.ServerLockService) (DashboardProvisioner, error) // Provisioner is responsible for syncing dashboard from disk to Grafana's database. type Provisioner struct { @@ -38,6 +41,8 @@ type Provisioner struct { duplicateValidator duplicateValidator provisioner dashboards.DashboardProvisioningService dual dualwrite.Service + serverLock *serverlock.ServerLockService + cfg *setting.Cfg } func (provider *Provisioner) HasDashboardSources() bool { @@ -45,7 +50,7 @@ func (provider *Provisioner) HasDashboardSources() bool { } // New returns a new DashboardProvisioner -func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgService org.Service, dashboardStore utils.DashboardStore, folderService folder.Service, dual dualwrite.Service) (DashboardProvisioner, error) { +func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, cfg *setting.Cfg, orgService org.Service, dashboardStore utils.DashboardStore, folderService folder.Service, dual dualwrite.Service, serverLockService *serverlock.ServerLockService) (DashboardProvisioner, error) { logger := log.New("provisioning.dashboard") cfgReader := &configReader{path: configDirectory, log: logger, orgExists: utils.NewOrgExistsChecker(orgService)} configs, err := cfgReader.readConfig(ctx) @@ -78,6 +83,8 @@ func New(ctx context.Context, configDirectory string, provisioner dashboards.Das duplicateValidator: newDuplicateValidator(logger, fileReaders), provisioner: provisioner, dual: dual, + serverLock: serverLockService, + cfg: cfg, } return d, nil @@ -95,23 +102,53 @@ func (provider *Provisioner) Provision(ctx context.Context) error { } } - provider.log.Info("starting to provision dashboards") + var errProvisioning error - for _, reader := range provider.fileReaders { - if err := reader.walkDisk(ctx); err != nil { - if os.IsNotExist(err) { - // don't stop the provisioning service in case the folder is missing. The folder can appear after the startup - provider.log.Warn("Failed to provision config", "name", reader.Cfg.Name, "error", err) - return nil - } - - return fmt.Errorf("failed to provision config %v: %w", reader.Cfg.Name, err) + // retry obtaining the lock for 20 attempts + retryOpt := func(attempts int) error { + if attempts < 20 { + return nil } + return errors.New("retries exhausted") } - provider.duplicateValidator.validate() - provider.log.Info("finished to provision dashboards") - return nil + lockTimeConfig := serverlock.LockTimeConfig{ + // if a replica crashes while holding the lock, other replicas can obtain the + // lock after this duration (15s default value, might be configured via config file) + MaxInterval: time.Duration(provider.cfg.ClassicProvisioningDashboardsServerLockMaxIntervalSeconds) * time.Second, + + // wait beetween 100ms and 1s before retrying to obtain the lock (default values, might be configured via config file) + MinWait: time.Duration(provider.cfg.ClassicProvisioningDashboardsServerLockMinWaitMs) * time.Millisecond, + MaxWait: time.Duration(provider.cfg.ClassicProvisioningDashboardsServerLockMaxWaitMs) * time.Millisecond, + } + + // this means that if we fail to obtain the lock after ~10 seconds, we return an error + lockErr := provider.serverLock.LockExecuteAndReleaseWithRetries(ctx, "provisioning_dashboards", lockTimeConfig, func(ctx context.Context) { + provider.log.Info("starting to provision dashboards") + + for _, reader := range provider.fileReaders { + if err := reader.walkDisk(ctx); err != nil { + if os.IsNotExist(err) { + // don't stop the provisioning service in case the folder is missing. The folder can appear after the startup + provider.log.Warn("Failed to provision config", "name", reader.Cfg.Name, "error", err) + return + } + + errProvisioning = fmt.Errorf("failed to provision config %v: %w", reader.Cfg.Name, err) + return + } + } + + provider.duplicateValidator.validate() + provider.log.Info("finished to provision dashboards") + }, retryOpt) + + if lockErr != nil { + provider.log.Error("Failed to obtain dashboard provisioning lock", "error", lockErr) + return lockErr + } + + return errProvisioning } // CleanUpOrphanedDashboards deletes provisioned dashboards missing a linked reader. diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index c79a9625d50..bc7c53e0cb2 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/dskit/services" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -64,6 +65,7 @@ func ProvideService( tracer tracing.Tracer, dual dualwrite.Service, promTypeMigrationProvider promtypemigration.PromTypeMigrationProvider, + serverLockService *serverlock.ServerLockService, ) (*ProvisioningServiceImpl, error) { s := &ProvisioningServiceImpl{ Cfg: cfg, @@ -92,6 +94,7 @@ func ProvideService( tracer: tracer, migratePrometheusType: promTypeMigrationProvider.Run, dual: dual, + serverLock: serverLockService, } s.NamedService = services.NewBasicService(s.starting, s.running, nil).WithName(ServiceName) @@ -166,7 +169,7 @@ func (ps *ProvisioningServiceImpl) running(ctx context.Context) error { func (ps *ProvisioningServiceImpl) setDashboardProvisioner() error { dashboardPath := filepath.Join(ps.Cfg.ProvisioningPath, "dashboards") - dashProvisioner, err := ps.newDashboardProvisioner(context.Background(), dashboardPath, ps.dashboardProvisioningService, ps.orgService, ps.dashboardService, ps.folderService, ps.dual) + dashProvisioner, err := ps.newDashboardProvisioner(context.Background(), dashboardPath, ps.dashboardProvisioningService, ps.Cfg, ps.orgService, ps.dashboardService, ps.folderService, ps.dual, ps.serverLock) if err != nil { return fmt.Errorf("%v: %w", "Failed to create provisioner", err) } @@ -242,6 +245,7 @@ type ProvisioningServiceImpl struct { resourcePermissions accesscontrol.ReceiverPermissionsService tracer tracing.Tracer dual dualwrite.Service + serverLock *serverlock.ServerLockService migratePrometheusType func(context.Context) error } diff --git a/pkg/services/provisioning/provisioning_test.go b/pkg/services/provisioning/provisioning_test.go index 8b513ba321e..b94d7beac37 100644 --- a/pkg/services/provisioning/provisioning_test.go +++ b/pkg/services/provisioning/provisioning_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/serverlock" dashboardstore "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" @@ -20,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/provisioning/datasources" "github.com/grafana/grafana/pkg/services/provisioning/utils" "github.com/grafana/grafana/pkg/services/searchV2" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" ) @@ -160,7 +162,7 @@ func setup(t *testing.T) *serviceTestStruct { searchStub := searchV2.NewStubSearchService() service, err := newProvisioningServiceImpl( - func(context.Context, string, dashboardstore.DashboardProvisioningService, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service) (dashboards.DashboardProvisioner, error) { + func(context.Context, string, dashboardstore.DashboardProvisioningService, *setting.Cfg, org.Service, utils.DashboardStore, folder.Service, dualwrite.Service, *serverlock.ServerLockService) (dashboards.DashboardProvisioner, error) { serviceTest.dashboardProvisionerInstantiations++ return serviceTest.mock, nil }, diff --git a/pkg/services/searchV2/bluge.go b/pkg/services/searchV2/bluge.go index d1fdeac5424..bc670677fb6 100644 --- a/pkg/services/searchV2/bluge.go +++ b/pkg/services/searchV2/bluge.go @@ -513,9 +513,9 @@ func doSearchQuery( fName := data.NewFieldFromFieldType(data.FieldTypeString, 0) fURL := data.NewFieldFromFieldType(data.FieldTypeString, 0) fLocation := data.NewFieldFromFieldType(data.FieldTypeString, 0) - fTags := data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0) + fTags := data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0) //nolint:staticcheck fDSUIDs := data.NewFieldFromFieldType(data.FieldTypeJSON, 0) - fExplain := data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0) + fExplain := data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0) //nolint:staticcheck fScore.Name = "score" fUID.Name = "uid" diff --git a/pkg/services/setting/service.go b/pkg/services/setting/service.go new file mode 100644 index 00000000000..5afc9e22159 --- /dev/null +++ b/pkg/services/setting/service.go @@ -0,0 +1,384 @@ +package setting + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + "gopkg.in/ini.v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + utilnet "k8s.io/apimachinery/pkg/util/net" + "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/client-go/dynamic" + clientrest "k8s.io/client-go/rest" + "k8s.io/client-go/transport" + + authlib "github.com/grafana/authlib/authn" + logging "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/semconv" +) + +var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/setting") + +const LogPrefix = "setting.service" + +const DefaultPageSize = int64(500) +const DefaultQPS = float32(10) +const DefaultBurst = 25 + +const ( + ApiGroup = "setting.grafana.app" + apiVersion = "v0alpha1" + resource = "settings" + kind = "Setting" + listKind = "SettingList" +) + +var settingGroupVersion = schema.GroupVersionResource{ + Group: ApiGroup, + Version: apiVersion, + Resource: resource, +} + +var settingGroupListKind = map[schema.GroupVersionResource]string{ + settingGroupVersion: listKind, +} + +type remoteSettingServiceMetrics struct { + listDuration *prometheus.HistogramVec + listResultSize *prometheus.HistogramVec +} + +// Service retrieves configuration settings from a remote settings service. +// +// The service uses label selectors to filter settings. Settings are labeled with +// "section" and "key" labels matching their spec fields. +// +// Example - Select all settings: +// +// ctx := request.WithNamespace(context.Background(), "my-namespace") +// ini, err := service.ListAsIni(ctx, metav1.LabelSelector{}) +// +// Example - Select settings from specific sections: +// +// selector := metav1.LabelSelector{ +// MatchExpressions: []metav1.LabelSelectorRequirement{ +// { +// Key: "section", +// Operator: metav1.LabelSelectorOpIn, +// Values: []string{"database", "server"}, +// }, +// }, +// } +// ini, err := service.ListAsIni(ctx, selector) +// +// Example - Select settings from a single section with specific labels: +// +// selector := metav1.LabelSelector{ +// MatchLabels: map[string]string{ +// "section": "database", +// }, +// } +// settings, err := service.List(ctx, selector) +type Service interface { + prometheus.Collector + // ListAsIni retrieves settings filtered by a label selector from the namespace in context + // and returns them as an ini.File. + // + // The namespace must be present in the context, ie: via request.WithNamespace. + // An empty selector returns all settings in the namespace. + ListAsIni(ctx context.Context, selector metav1.LabelSelector) (*ini.File, error) + + // List retrieves settings filtered by a label selector from the namespace in context + // and returns them as a slice of Setting structs. + // + // The namespace must be present in the context, ie: via request.WithNamespace. + // An empty selector returns all settings in the namespace. + List(ctx context.Context, selector metav1.LabelSelector) ([]*Setting, error) +} + +type remoteSettingService struct { + dynamicClient dynamic.Interface + log logging.Logger + pageSize int64 + metrics remoteSettingServiceMetrics +} + +var _ Service = (*remoteSettingService)(nil) +var _ prometheus.Collector = (*remoteSettingService)(nil) + +// Config configures a Service. +type Config struct { + // URL is the base URL for the remote settings service (required). + URL string + // TokenExchangeClient authenticates requests (required if WrapTransport is not set). + TokenExchangeClient *authlib.TokenExchangeClient + // WrapTransport wraps the HTTP transport for authentication. + // Takes precedence over TokenExchangeClient when both are set. + // At least one of WrapTransport or TokenExchangeClient is required. + WrapTransport transport.WrapperFunc + // TLSClientConfig configures TLS for the client connection. + TLSClientConfig clientrest.TLSClientConfig + // QPS limits requests per second (defaults to DefaultQPS). + QPS float32 + // Burst allows request bursts above QPS (defaults to DefaultBurst). + Burst int + // PageSize sets the number of items per API page (defaults to DefaultPageSize). + PageSize int64 +} + +// Setting represents the parsed spec of a Setting resource. +type Setting struct { + // Setting section + Section string `json:"section"` + // Setting key + Key string `json:"key"` + // Setting value + Value string `json:"value"` +} + +// New creates a Service from the provided configuration. +func New(config Config) (Service, error) { + log := logging.New(LogPrefix) + dynamicClient, err := getDynamicClient(config, log) + if err != nil { + return nil, err + } + pageSize := DefaultPageSize + if config.PageSize > 0 { + pageSize = config.PageSize + } + + metrics := initMetrics() + + return &remoteSettingService{ + dynamicClient: dynamicClient, + pageSize: pageSize, + log: log, + metrics: metrics, + }, nil +} + +func (m *remoteSettingService) ListAsIni(ctx context.Context, labelSelector metav1.LabelSelector) (*ini.File, error) { + namespace, ok := request.NamespaceFrom(ctx) + ns := semconv.GrafanaNamespaceName(namespace) + ctx, span := tracer.Start(ctx, "remoteSettingService.ListAsIni", + trace.WithAttributes(ns)) + defer span.End() + + if !ok || namespace == "" { + return nil, tracing.Errorf(span, "missing namespace in context") + } + + settings, err := m.List(ctx, labelSelector) + if err != nil { + return nil, err + } + iniFile, err := m.toIni(settings) + if err != nil { + return nil, tracing.Error(span, err) + } + return iniFile, nil +} + +func (m *remoteSettingService) List(ctx context.Context, labelSelector metav1.LabelSelector) ([]*Setting, error) { + namespace, ok := request.NamespaceFrom(ctx) + ns := semconv.GrafanaNamespaceName(namespace) + ctx, span := tracer.Start(ctx, "remoteSettingService.List", + trace.WithAttributes(ns)) + defer span.End() + if !ok || namespace == "" { + return nil, tracing.Errorf(span, "missing namespace in context") + } + log := m.log.FromContext(ctx).New(ns.Key, ns.Value, "function", "remoteSettingService.List", "traceId", span.SpanContext().TraceID()) + + startTime := time.Now() + var status string + defer func() { + duration := time.Since(startTime).Seconds() + m.metrics.listDuration.WithLabelValues(status).Observe(duration) + }() + + selector, err := metav1.LabelSelectorAsSelector(&labelSelector) + if err != nil { + status = "error" + return nil, tracing.Error(span, err) + } + if selector.Empty() { + log.Debug("empty selector. Fetching all settings") + } + + var allSettings []*Setting + var continueToken string + hasNext := true + totalPages := 0 + // Using an upper limit to prevent infinite loops + for hasNext && totalPages < 1000 { + totalPages++ + opts := metav1.ListOptions{ + Limit: m.pageSize, + Continue: continueToken, + } + if !selector.Empty() { + opts.LabelSelector = selector.String() + } + + settingsList, lErr := m.dynamicClient.Resource(settingGroupVersion).Namespace(namespace).List(ctx, opts) + if lErr != nil { + status = "error" + return nil, tracing.Error(span, lErr) + } + for i := range settingsList.Items { + setting, pErr := parseSettingResource(&settingsList.Items[i]) + if pErr != nil { + status = "error" + return nil, tracing.Error(span, pErr) + } + allSettings = append(allSettings, setting) + } + continueToken = settingsList.GetContinue() + if continueToken == "" { + hasNext = false + } + } + + status = "success" + m.metrics.listResultSize.WithLabelValues(status).Observe(float64(len(allSettings))) + + return allSettings, nil +} + +func parseSettingResource(setting *unstructured.Unstructured) (*Setting, error) { + spec, found, err := unstructured.NestedMap(setting.Object, "spec") + if err != nil { + return nil, fmt.Errorf("failed to get spec from setting: %w", err) + } + if !found { + return nil, fmt.Errorf("spec not found in setting %s", setting.GetName()) + } + + var result Setting + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(spec, &result); err != nil { + return nil, fmt.Errorf("failed to convert spec to Setting: %w", err) + } + + return &result, nil +} + +func (m *remoteSettingService) toIni(settings []*Setting) (*ini.File, error) { + conf := ini.Empty() + for _, setting := range settings { + if !conf.HasSection(setting.Section) { + _, _ = conf.NewSection(setting.Section) + } + _, err := conf.Section(setting.Section).NewKey(setting.Key, setting.Value) + if err != nil { + return nil, err + } + } + return conf, nil +} + +func getDynamicClient(config Config, log logging.Logger) (dynamic.Interface, error) { + if config.URL == "" { + return nil, fmt.Errorf("URL cannot be empty") + } + if config.WrapTransport == nil && config.TokenExchangeClient == nil { + return nil, fmt.Errorf("must set either TokenExchangeClient or WrapTransport") + } + + wrapTransport := config.WrapTransport + if config.WrapTransport == nil { + log.Debug("using default wrapTransport with TokenExchangeClient") + wrapTransport = func(rt http.RoundTripper) http.RoundTripper { + return &authRoundTripper{ + tokenClient: config.TokenExchangeClient, + transport: rt, + } + } + } + + qps := DefaultQPS + if config.QPS > 0 { + qps = config.QPS + } + + burst := DefaultBurst + if config.Burst > 0 { + burst = config.Burst + } + + return dynamic.NewForConfig(&clientrest.Config{ + Host: config.URL, + WrapTransport: wrapTransport, + TLSClientConfig: config.TLSClientConfig, + QPS: qps, + Burst: burst, + }) +} + +// authRoundTripper wraps an HTTP transport with token-based authentication. +type authRoundTripper struct { + tokenClient *authlib.TokenExchangeClient + transport http.RoundTripper +} + +var _ http.RoundTripper = (*authRoundTripper)(nil) + +func (a *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + token, err := a.tokenClient.Exchange(req.Context(), authlib.TokenExchangeRequest{ + Audiences: []string{ApiGroup}, + Namespace: "*", + }) + if err != nil { + return nil, fmt.Errorf("failed to exchange token: %w", err) + } + req = utilnet.CloneRequest(req) + + req.Header.Set("X-Access-Token", fmt.Sprintf("Bearer %s", token.Token)) + return a.transport.RoundTrip(req) +} + +func initMetrics() remoteSettingServiceMetrics { + metrics := remoteSettingServiceMetrics{ + listDuration: prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: "settings", + Subsystem: "service", + Name: "list_settings_duration_seconds", + Help: "Duration of remote settings service List operations", + NativeHistogramBucketFactor: 1.1, + }, + []string{"status"}, // status: "success" or "error" + ), + listResultSize: prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: "settings", + Subsystem: "service", + Name: "list_settings_result_size", + Help: "Number of settings returned by remote settings service List operations", + NativeHistogramBucketFactor: 1.1, + }, + []string{"status"}, // status: "success" or "error" + ), + } + return metrics +} + +func (m *remoteSettingService) Describe(descs chan<- *prometheus.Desc) { + m.metrics.listDuration.Describe(descs) + m.metrics.listResultSize.Describe(descs) +} + +func (m *remoteSettingService) Collect(metrics chan<- prometheus.Metric) { + m.metrics.listDuration.Collect(metrics) + m.metrics.listResultSize.Collect(metrics) +} diff --git a/pkg/services/setting/service_test.go b/pkg/services/setting/service_test.go new file mode 100644 index 00000000000..949ccc73a86 --- /dev/null +++ b/pkg/services/setting/service_test.go @@ -0,0 +1,542 @@ +package setting + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/client-go/dynamic/fake" + k8testing "k8s.io/client-go/testing" + + authlib "github.com/grafana/authlib/authn" + "github.com/grafana/grafana/pkg/infra/log" +) + +func TestRemoteSettingService_ListAsIni(t *testing.T) { + t.Run("should filter settings by label selector", func(t *testing.T) { + // Create multiple settings, only some matching the selector + setting1 := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "type", Value: "postgres"}) + setting2 := newUnstructuredSetting("test-namespace", Setting{Section: "server", Key: "port", Value: "3000"}) + setting3 := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "host", Value: "localhost"}) + + client := newTestClient(500, setting1, setting2, setting3) + + // Create a selector that should match only database settings + selector := metav1.LabelSelector{ + MatchLabels: map[string]string{ + "section": "database", + }, + } + + ctx := request.WithNamespace(context.Background(), "test-namespace") + result, err := client.ListAsIni(ctx, selector) + + require.NoError(t, err) + assert.NotNil(t, result) + // Should only have database settings, not server settings + assert.True(t, result.HasSection("database")) + assert.Equal(t, "postgres", result.Section("database").Key("type").String()) + assert.Equal(t, "localhost", result.Section("database").Key("host").String()) + // Should NOT have server settings + assert.False(t, result.HasSection("server")) + }) + + t.Run("should return all settings with empty selector", func(t *testing.T) { + // Create multiple settings across different sections + setting1 := newUnstructuredSetting("test-namespace", Setting{Section: "server", Key: "port", Value: "3000"}) + setting2 := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "type", Value: "mysql"}) + + client := newTestClient(500, setting1, setting2) + + // Empty selector should select everything + selector := metav1.LabelSelector{} + + ctx := request.WithNamespace(context.Background(), "test-namespace") + result, err := client.ListAsIni(ctx, selector) + + require.NoError(t, err) + assert.NotNil(t, result) + // Should have all settings from all sections + assert.True(t, result.HasSection("server")) + assert.Equal(t, "3000", result.Section("server").Key("port").String()) + assert.True(t, result.HasSection("database")) + assert.Equal(t, "mysql", result.Section("database").Key("type").String()) + }) +} + +func TestRemoteSettingService_List(t *testing.T) { + t.Run("should handle single page response", func(t *testing.T) { + setting := newUnstructuredSetting("test-namespace", Setting{Section: "server", Key: "port", Value: "3000"}) + + client := newTestClient(500, setting) + + ctx := request.WithNamespace(context.Background(), "test-namespace") + result, err := client.List(ctx, metav1.LabelSelector{}) + + require.NoError(t, err) + assert.Len(t, result, 1) + + spec := result[0] + assert.Equal(t, "server", spec.Section) + assert.Equal(t, "port", spec.Key) + assert.Equal(t, "3000", spec.Value) + }) + + t.Run("should handle multiple pages", func(t *testing.T) { + totalPages := 3 + pageSize := 5 + + pages := make([][]*unstructured.Unstructured, totalPages) + for pageNum := 0; pageNum < totalPages; pageNum++ { + for idx := 0; idx < pageSize; idx++ { + item := newUnstructuredSetting( + "test-namespace", + Setting{ + Section: fmt.Sprintf("section-%d", pageNum), + Key: fmt.Sprintf("key-%d", idx), + Value: fmt.Sprintf("val-%d-%d", pageNum, idx), + }, + ) + pages[pageNum] = append(pages[pageNum], item) + } + } + + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind) + listCallCount := 0 + dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) { + listCallCount++ + + continueToken := fmt.Sprintf("continue-%d", listCallCount) + if listCallCount == totalPages { + continueToken = "" + } + + if listCallCount <= totalPages { + list := &unstructured.UnstructuredList{ + Object: map[string]interface{}{ + "apiVersion": ApiGroup + "/" + apiVersion, + "kind": listKind, + }, + } + list.SetContinue(continueToken) + for _, item := range pages[listCallCount-1] { + list.Items = append(list.Items, *item) + } + return true, list, nil + } + + return false, nil, nil + }) + + client := &remoteSettingService{ + dynamicClient: dynamicClient, + pageSize: int64(pageSize), + log: log.NewNopLogger(), + metrics: initMetrics(), + } + + ctx := request.WithNamespace(context.Background(), "test-namespace") + result, err := client.List(ctx, metav1.LabelSelector{}) + + require.NoError(t, err) + assert.Len(t, result, totalPages*pageSize) + assert.Equal(t, totalPages, listCallCount) + }) + + t.Run("should pass label selector when provided", func(t *testing.T) { + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind) + dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) { + listAction := action.(k8testing.ListActionImpl) + assert.Equal(t, "app=grafana", listAction.ListOptions.LabelSelector) + return true, &unstructured.UnstructuredList{}, nil + }) + + client := &remoteSettingService{ + dynamicClient: dynamicClient, + pageSize: 500, + log: log.NewNopLogger(), + metrics: initMetrics(), + } + + ctx := request.WithNamespace(context.Background(), "test-namespace") + _, err := client.List(ctx, metav1.LabelSelector{MatchLabels: map[string]string{"app": "grafana"}}) + + require.NoError(t, err) + }) + + t.Run("should stop pagination at 1000 pages", func(t *testing.T) { + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind) + listCallCount := 0 + dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) { + listCallCount++ + // Always return a continue token to simulate infinite pagination + list := &unstructured.UnstructuredList{} + list.SetContinue("continue-forever") + return true, list, nil + }) + + client := &remoteSettingService{ + dynamicClient: dynamicClient, + pageSize: 10, + log: log.NewNopLogger(), + metrics: initMetrics(), + } + + ctx := request.WithNamespace(context.Background(), "test-namespace") + _, err := client.List(ctx, metav1.LabelSelector{}) + + require.NoError(t, err) + assert.Equal(t, 1000, listCallCount, "Should stop at 1000 pages to prevent infinite loops") + }) + + t.Run("should return error when parsing setting fails", func(t *testing.T) { + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind) + dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) { + // Return a malformed setting without spec + list := &unstructured.UnstructuredList{ + Object: map[string]interface{}{ + "apiVersion": ApiGroup + "/" + apiVersion, + "kind": listKind, + }, + } + malformedSetting := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": ApiGroup + "/" + apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": "malformed", + "namespace": "test-namespace", + }, + // Missing spec + }, + } + list.Items = append(list.Items, *malformedSetting) + return true, list, nil + }) + + client := &remoteSettingService{ + dynamicClient: dynamicClient, + pageSize: 500, + log: log.NewNopLogger(), + metrics: initMetrics(), + } + + ctx := request.WithNamespace(context.Background(), "test-namespace") + result, err := client.List(ctx, metav1.LabelSelector{}) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "spec not found") + }) +} + +func TestParseSettingResource(t *testing.T) { + t.Run("should parse valid setting resource", func(t *testing.T) { + setting := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "type", Value: "postgres"}) + + result, err := parseSettingResource(setting) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "database", result.Section) + assert.Equal(t, "type", result.Key) + assert.Equal(t, "postgres", result.Value) + }) + + t.Run("should return error when spec is missing", func(t *testing.T) { + setting := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": ApiGroup + "/" + apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": "test-setting", + "namespace": "test-namespace", + }, + // No spec + }, + } + + result, err := parseSettingResource(setting) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "spec not found") + }) +} + +func TestRemoteSettingService_ToIni(t *testing.T) { + t.Run("should convert settings to ini format", func(t *testing.T) { + settings := []*Setting{ + {Section: "database", Key: "type", Value: "postgres"}, + {Section: "database", Key: "host", Value: "localhost"}, + {Section: "server", Key: "http_port", Value: "3000"}, + } + + client := &remoteSettingService{ + pageSize: 500, + log: log.NewNopLogger(), + } + + result, err := client.toIni(settings) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.HasSection("database")) + assert.True(t, result.HasSection("server")) + assert.Equal(t, "postgres", result.Section("database").Key("type").String()) + assert.Equal(t, "localhost", result.Section("database").Key("host").String()) + assert.Equal(t, "3000", result.Section("server").Key("http_port").String()) + }) + + t.Run("should handle empty settings list", func(t *testing.T) { + var settings []*Setting + + client := &remoteSettingService{ + pageSize: 500, + log: log.NewNopLogger(), + } + + result, err := client.toIni(settings) + + require.NoError(t, err) + assert.NotNil(t, result) + sections := result.Sections() + assert.Len(t, sections, 1) // Only default section + }) + + t.Run("should create section if it does not exist", func(t *testing.T) { + settings := []*Setting{ + {Section: "new_section", Key: "new_key", Value: "new_value"}, + } + + client := &remoteSettingService{ + pageSize: 500, + log: log.NewNopLogger(), + } + + result, err := client.toIni(settings) + + require.NoError(t, err) + assert.True(t, result.HasSection("new_section")) + assert.Equal(t, "new_value", result.Section("new_section").Key("new_key").String()) + }) + + t.Run("should handle multiple keys in same section", func(t *testing.T) { + settings := []*Setting{ + {Section: "auth", Key: "disable_login_form", Value: "false"}, + {Section: "auth", Key: "disable_signout_menu", Value: "true"}, + } + + client := &remoteSettingService{ + pageSize: 500, + log: log.NewNopLogger(), + } + + result, err := client.toIni(settings) + + require.NoError(t, err) + assert.True(t, result.HasSection("auth")) + authSection := result.Section("auth") + assert.Equal(t, "false", authSection.Key("disable_login_form").String()) + assert.Equal(t, "true", authSection.Key("disable_signout_menu").String()) + }) +} + +func TestNew(t *testing.T) { + t.Run("should create client with default page size", func(t *testing.T) { + config := Config{ + URL: "https://example.com", + WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt }, + } + + client, err := New(config) + + require.NoError(t, err) + assert.NotNil(t, client) + remoteClient := client.(*remoteSettingService) + assert.Equal(t, DefaultPageSize, remoteClient.pageSize) + }) + + t.Run("should create client with custom page size", func(t *testing.T) { + config := Config{ + URL: "https://example.com", + WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt }, + PageSize: 100, + } + + client, err := New(config) + + require.NoError(t, err) + assert.NotNil(t, client) + remoteClient := client.(*remoteSettingService) + assert.Equal(t, int64(100), remoteClient.pageSize) + }) + + t.Run("should use default page size when zero is provided", func(t *testing.T) { + config := Config{ + URL: "https://example.com", + WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt }, + PageSize: 0, + } + + client, err := New(config) + + require.NoError(t, err) + assert.NotNil(t, client) + remoteClient := client.(*remoteSettingService) + assert.Equal(t, DefaultPageSize, remoteClient.pageSize) + }) + + t.Run("should return error when config is invalid", func(t *testing.T) { + config := Config{ + URL: "", // Invalid: empty URL + } + + client, err := New(config) + + require.Error(t, err) + assert.Nil(t, client) + assert.Contains(t, err.Error(), "URL cannot be empty") + }) +} + +func TestGetDynamicClient(t *testing.T) { + logger := log.NewNopLogger() + + t.Run("should return error when SettingServiceURL is empty", func(t *testing.T) { + config := Config{ + URL: "", + WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt }, + } + + client, err := getDynamicClient(config, logger) + + require.Error(t, err) + assert.Nil(t, client) + assert.Contains(t, err.Error(), "URL cannot be empty") + }) + + t.Run("should return error when both TokenExchangeClient and WrapTransport are nil", func(t *testing.T) { + config := Config{ + URL: "https://example.com", + TokenExchangeClient: nil, + WrapTransport: nil, + } + + client, err := getDynamicClient(config, logger) + + require.Error(t, err) + assert.Nil(t, client) + assert.Contains(t, err.Error(), "must set either TokenExchangeClient or WrapTransport") + }) + + t.Run("should create client with WrapTransport", func(t *testing.T) { + config := Config{ + URL: "https://example.com", + WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt }, + } + + client, err := getDynamicClient(config, logger) + + require.NoError(t, err) + assert.NotNil(t, client) + }) + + t.Run("should not fail when QPS and Burst are not provided", func(t *testing.T) { + config := Config{ + URL: "https://example.com", + WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt }, + } + + client, err := getDynamicClient(config, logger) + + require.NoError(t, err) + assert.NotNil(t, client) + }) + + t.Run("should not fail when custom QPS and Burst are provided", func(t *testing.T) { + config := Config{ + URL: "https://example.com", + WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt }, + QPS: 10.0, + Burst: 20, + } + + client, err := getDynamicClient(config, logger) + + require.NoError(t, err) + assert.NotNil(t, client) + }) + + t.Run("should use WrapTransport when both WrapTransport and TokenExchangeClient are provided", func(t *testing.T) { + wrapTransportCalled := false + tokenExchangeClient := &authlib.TokenExchangeClient{} + + config := Config{ + URL: "https://example.com", + TokenExchangeClient: tokenExchangeClient, + WrapTransport: func(rt http.RoundTripper) http.RoundTripper { + wrapTransportCalled = true + return rt + }, + } + + client, err := getDynamicClient(config, logger) + + require.NoError(t, err) + assert.NotNil(t, client) + assert.True(t, wrapTransportCalled, "WrapTransport should be called and take precedence over TokenExchangeClient") + }) +} + +// Helper function to create an unstructured Setting object for tests +func newUnstructuredSetting(namespace string, spec Setting) *unstructured.Unstructured { + // Generate resource name in the format {section}--{key} + name := fmt.Sprintf("%s--%s", spec.Section, spec.Key) + + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": ApiGroup + "/" + apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": name, + "namespace": namespace, + }, + "spec": map[string]interface{}{ + "section": spec.Section, + "key": spec.Key, + "value": spec.Value, + }, + }, + } + // Always set section and key labels + obj.SetLabels(map[string]string{ + "section": spec.Section, + "key": spec.Key, + }) + return obj +} + +// Helper function to create a test client with the dynamic fake client +func newTestClient(pageSize int64, objects ...runtime.Object) *remoteSettingService { + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind, objects...) + + return &remoteSettingService{ + dynamicClient: dynamicClient, + pageSize: pageSize, + log: log.NewNopLogger(), + metrics: initMetrics(), + } +} diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index d4842bee884..1fb8d06d313 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -581,10 +581,16 @@ func TestMain(m *testing.M) { // nolint:staticcheck testSQLStore.cfg.IsFeatureToggleEnabled = features.IsEnabledGlobally - if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil { - return nil, err + skipTruncate := false + if skip, present := os.LookupEnv("SKIP_DB_TRUNCATE"); present { + skipTruncate = strings.ToLower(skip) == "true" + } + if !skipTruncate { + if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil { + return nil, err + } + testSQLStore.engine.ResetSequenceGenerator() } - testSQLStore.engine.ResetSequenceGenerator() if err := testSQLStore.Reset(); err != nil { return nil, err diff --git a/pkg/services/team/model.go b/pkg/services/team/model.go index 4d01c0a3d0b..3696505139a 100644 --- a/pkg/services/team/model.go +++ b/pkg/services/team/model.go @@ -47,11 +47,11 @@ type CreateTeamCommand struct { } type UpdateTeamCommand struct { - ID int64 - Name string - Email string - ExternalUID string - OrgID int64 `json:"-"` + ID int64 `json:"-"` + Name string `json:"name"` + Email string `json:"email"` + ExternalUID string `json:"-"` + OrgID int64 `json:"-"` } type DeleteTeamCommand struct { diff --git a/pkg/services/team/teamapi/team.go b/pkg/services/team/teamapi/team.go index 9fca7089d2b..230d4ab0f2d 100644 --- a/pkg/services/team/teamapi/team.go +++ b/pkg/services/team/teamapi/team.go @@ -312,6 +312,10 @@ type GetTeamByIDParams struct { // in:path // required:true TeamID string `json:"team_id"` + // in:query + // required:false + // default: false + AccessControl bool `json:"accesscontrol"` } // swagger:parameters deleteTeamByID diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index a8b48c67f29..f6c0b3d3f19 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -150,6 +150,11 @@ type Cfg struct { PluginsPath string EnterpriseLicensePath string + // Classic Provisioning settings + ClassicProvisioningDashboardsServerLockMaxIntervalSeconds int64 + ClassicProvisioningDashboardsServerLockMinWaitMs int64 + ClassicProvisioningDashboardsServerLockMaxWaitMs int64 + // SMTP email settings Smtp SmtpSettings @@ -610,6 +615,8 @@ type Cfg struct { HttpsSkipVerify bool ResourceServerJoinRingTimeout time.Duration EnableSearch bool + OverridesFilePath string + OverridesReloadInterval time.Duration // Secrets Management SecretsManagement SecretsManagerSettings @@ -1221,6 +1228,8 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { return err } + cfg.readClassicProvisioningSettings(iniFile) + // read dashboard settings dashboards := iniFile.Section("dashboards") cfg.DashboardVersionsToKeep = dashboards.Key("versions_to_keep").MustInt(20) @@ -2107,6 +2116,12 @@ func (cfg *Cfg) readLiveSettings(iniFile *ini.File) error { return nil } +func (cfg *Cfg) readClassicProvisioningSettings(iniFile *ini.File) { + cfg.ClassicProvisioningDashboardsServerLockMinWaitMs = iniFile.Section("classic_provisioning").Key("dashboards_server_lock_min_wait_ms").MustInt64(100) + cfg.ClassicProvisioningDashboardsServerLockMaxWaitMs = iniFile.Section("classic_provisioning").Key("dashboards_server_lock_max_wait_ms").MustInt64(1000) + cfg.ClassicProvisioningDashboardsServerLockMaxIntervalSeconds = iniFile.Section("classic_provisioning").Key("dashboards_server_lock_max_interval_seconds").MustInt64(15) +} + func (cfg *Cfg) readProvisioningSettings(iniFile *ini.File) error { provisioning := valueAsString(iniFile.Section("paths"), "provisioning", "") cfg.ProvisioningPath = makeAbsolute(provisioning, cfg.HomePath) diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 29cb5d0270a..3623228fe8b 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -94,6 +94,10 @@ func (cfg *Cfg) setUnifiedStorageConfig() { cfg.HttpsSkipVerify = section.Key("https_skip_verify").MustBool(false) cfg.ResourceServerJoinRingTimeout = section.Key("resource_server_join_ring_timeout").MustDuration(10 * time.Second) + // quotas/limits config + cfg.OverridesFilePath = section.Key("overrides_path").String() + cfg.OverridesReloadInterval = section.Key("overrides_reload_period").MustDuration(30 * time.Second) + cfg.MaxFileIndexAge = section.Key("max_file_index_age").MustDuration(0) cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("") } diff --git a/pkg/storage/secret/metadata/secure_value_test.go b/pkg/storage/secret/metadata/secure_value_test.go index 0eba9bbf9ce..dafd96fc3ad 100644 --- a/pkg/storage/secret/metadata/secure_value_test.go +++ b/pkg/storage/secret/metadata/secure_value_test.go @@ -618,7 +618,7 @@ func TestSecureValueServiceExampleBased(t *testing.T) { }, "actor-uid") require.NoError(t, err) - require.NoError(t, sut.SecureValueService.SetKeeperAsActive(t.Context(), xkube.Namespace(k1.Namespace), k1.Name)) + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(k1.Namespace), k1.Name)) value := secretv1beta1.NewExposedSecureValue("v1") sv1, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(&secretv1beta1.SecureValue{ @@ -643,7 +643,7 @@ func TestSecureValueServiceExampleBased(t *testing.T) { }, }, "actor-uid") require.NoError(t, err) - require.NoError(t, sut.SecureValueService.SetKeeperAsActive(t.Context(), xkube.Namespace(k2.Namespace), k2.Name)) + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(k2.Namespace), k2.Name)) // - Read secure value created with inactive keeper readSv, err := sut.SecureValueService.Read(t.Context(), xkube.Namespace(sv1.Namespace), sv1.Name) diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index 034147d84d9..7131178e8a1 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -202,30 +202,58 @@ then run: kubectl --kubeconfig=./grafana.kubeconfig create -f folder-generate.yaml ``` -### Run as a GRPC service +### Run as a separate GRPC service -#### Start GRPC storage-server +It is recommended to use a separate config file for the storage-server. Create a file `conf/storage-server.ini` with the following content: -Make sure you have the gRPC address in the `[grafana-apiserver]` section of your config file: ```ini +app_mode = development + +target = storage-server + +[database] +type = mysql +host = 127.0.0.1:3306 +name = unified-storage +user = root +password = rootpass +skip_migrations = true +ensure_default_org_and_user = false + +[grpc_server] +network = "tcp" +address = "127.0.0.1:10000" + [grafana-apiserver] -; your gRPC server address -address = localhost:10000 -``` +storage_type = unified -You also need the `[grpc_server_authentication]` section to authenticate incoming requests: -```ini [grpc_server_authentication] -; http url to Grafana's signing keys to validate incoming id tokens -signing_keys_url = http://localhost:3000/api/signing-keys/keys +signing_keys_url = http://localhost:3011/api/signing-keys/keys mode = "on-prem" + +[feature_toggles] +kubernetesDashboards = true +kubernetesFolders = true +unifiedStorage = true +unifiedStorageHistoryPruner = true +unifiedStorageSearch = true +unifiedStorageSearchPermissionFiltering = false +unifiedStorageSearchSprinkles = false + +[unified_storage] +enable_search = true +https_skip_verify = true ``` -This currently only works with a separate database configuration (see previous section). +You should also have a MySQL database running. You can create one with our docker blocks by running: +```bash +make devenv sources=mysql +``` +The database credentials in the example above will work with the default mysql docker block. You'll also need to create a database named `unified-storage`. Start the storage-server with: ```sh -GF_DEFAULT_TARGET=storage-server ./bin/grafana server target +./bin/grafana server target --config conf/storage-server.ini ``` The GRPC service will listen on port 10000 diff --git a/pkg/storage/unified/apistore/restoptions.go b/pkg/storage/unified/apistore/restoptions.go index b48fbc6deaf..d880c663f29 100644 --- a/pkg/storage/unified/apistore/restoptions.go +++ b/pkg/storage/unified/apistore/restoptions.go @@ -54,6 +54,9 @@ func NewRESTOptionsGetterMemory(originalStorageConfig storagebackend.Config, sec // Create BadgerDB with in-memory mode db, err := badger.Open(badger.DefaultOptions(""). WithInMemory(true). + WithMemTableSize(256 << 10). // 256KB memtable size + WithValueThreshold(16 << 10). // 16KB threshold for storing values in LSM vs value log + WithNumMemtables(2). // Keep only 2 memtables in memory WithLogger(nil)) if err != nil { return nil, err diff --git a/pkg/storage/unified/client.go b/pkg/storage/unified/client.go index 07165937199..45130c8a6a6 100644 --- a/pkg/storage/unified/client.go +++ b/pkg/storage/unified/client.go @@ -211,6 +211,19 @@ func newClient(opts options.StorageOptions, serverOptions.QOSQueue = queue } + // only enable if an overrides file path is provided + if cfg.OverridesFilePath != "" { + overridesSvc, err := resource.NewOverridesService(ctx, cfg.Logger, reg, tracer, resource.ReloadOptions{ + FilePath: cfg.OverridesFilePath, + ReloadPeriod: cfg.OverridesReloadInterval, + }) + if err != nil { + return nil, err + } + + serverOptions.OverridesService = overridesSvc + } + server, err := sql.NewResourceServer(serverOptions) if err != nil { return nil, err diff --git a/pkg/storage/unified/migrations/folders_dashboards_test.go b/pkg/storage/unified/migrations/folders_dashboards_test.go new file mode 100644 index 00000000000..27b1c0e8ba6 --- /dev/null +++ b/pkg/storage/unified/migrations/folders_dashboards_test.go @@ -0,0 +1,208 @@ +package migrations_test + +import ( + "fmt" + "net/http" + "testing" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// foldersAndDashboardsTestCase tests the "folders-dashboards" ResourceMigration +type foldersAndDashboardsTestCase struct { + parentFolderUID string + childFolderUID string + dashboardUID string + libPanelUID string +} + +// newFoldersAndDashboardsTestCase creates a test case for the compound folders+dashboards migrator +func newFoldersAndDashboardsTestCase() resourceMigratorTestCase { + return &foldersAndDashboardsTestCase{ + parentFolderUID: "parent-folder-uid", + childFolderUID: "child-folder-uid", + dashboardUID: "", // Will be generated during setup + libPanelUID: "", // Will be generated during setup + } +} + +func (tc *foldersAndDashboardsTestCase) name() string { + return "folders-dashboards" +} + +func (tc *foldersAndDashboardsTestCase) resources() []schema.GroupVersionResource { + return []schema.GroupVersionResource{ + { + Group: "folder.grafana.app", + Version: "v1beta1", + Resource: "folders", + }, + { + Group: "dashboard.grafana.app", + Version: "v1beta1", + Resource: "dashboards", + }, + } +} + +func (tc *foldersAndDashboardsTestCase) setup(t *testing.T, helper *apis.K8sTestHelper) { + t.Helper() + + // Create parent folder + parent := createTestFolder(t, helper, tc.parentFolderUID, "parent-folder", "") + + // Create child folder (nested under parent) + child := createTestFolder(t, helper, tc.childFolderUID, "child-folder", parent.UID) + + // Create library panel in child folder + tc.libPanelUID = createTestLibraryPanel(t, helper, "Test Library Panel", child.UID) + + // Create dashboard with library panel in child folder + tc.dashboardUID = createTestDashboardWithLibraryPanel(t, helper, "dashboard-with-library-panel", + tc.libPanelUID, "Test LP in dashboard", child.UID) +} + +func (tc *foldersAndDashboardsTestCase) verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool) { + t.Helper() + + // Build maps of UIDs by resource type + folderUIDs := []string{tc.parentFolderUID, tc.childFolderUID} + dashboardUIDs := []string{tc.dashboardUID} + + expectedFolderCount := 0 + if shouldExist { + expectedFolderCount = len(folderUIDs) + } + orgID := helper.Org1.OrgID + namespace := authlib.OrgNamespaceFormatter(orgID) + + // Verify folders + folderCli := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: namespace, + GVR: schema.GroupVersionResource{ + Group: "folder.grafana.app", + Version: "v1beta1", + Resource: "folders", + }, + }) + verifyResourceCount(t, folderCli, expectedFolderCount) + for _, uid := range folderUIDs { + verifyResource(t, folderCli, uid, shouldExist) + } + + // Verify dashboards + expectedDashboardCount := 0 + if shouldExist { + expectedDashboardCount = len(dashboardUIDs) + } + dashboardCli := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: namespace, + GVR: schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v1beta1", + Resource: "dashboards", + }, + }) + verifyResourceCount(t, dashboardCli, expectedDashboardCount) + for _, uid := range dashboardUIDs { + verifyResource(t, dashboardCli, uid, shouldExist) + } +} + +// createTestFolder creates a folder with specified UID and optional parent +func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, uid, title, parentUID string) *folder.Folder { + t.Helper() + + payload := fmt.Sprintf(`{ + "title": "%s", + "uid": "%s"`, title, uid) + + if parentUID != "" { + payload += fmt.Sprintf(`, + "parentUid": "%s"`, parentUID) + } + + payload += "}" + + folderCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(payload), + }, &folder.Folder{}) + + require.NotNil(t, folderCreate.Result) + require.Equal(t, uid, folderCreate.Result.UID) + + return folderCreate.Result +} + +// createTestLibraryPanel creates a library panel in a folder +func createTestLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, name, folderUID string) string { + t.Helper() + + libPanelPayload := fmt.Sprintf(`{ + "kind": 1, + "name": "%s", + "folderUid": "%s", + "model": { + "type": "text", + "title": "%s" + } + }`, name, folderUID, name) + + libCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/library-elements", + Body: []byte(libPanelPayload), + }, &map[string]interface{}{}) + + require.NotNil(t, libCreate.Response) + require.Equal(t, http.StatusOK, libCreate.Response.StatusCode) + + libPanelUID := (*libCreate.Result)["result"].(map[string]interface{})["uid"].(string) + require.NotEmpty(t, libPanelUID) + + return libPanelUID +} + +// createTestDashboardWithLibraryPanel creates a dashboard that uses a library panel +func createTestDashboardWithLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, dashTitle, libPanelUID, libPanelName, folderUID string) string { + t.Helper() + + dashPayload := fmt.Sprintf(`{ + "dashboard": { + "title": "%s", + "panels": [{ + "id": 1, + "libraryPanel": { + "uid": "%s", + "name": "%s" + } + }] + }, + "folderUid": "%s", + "overwrite": false + }`, dashTitle, libPanelUID, libPanelName, folderUID) + + dashCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/dashboards/db", + Body: []byte(dashPayload), + }, &map[string]interface{}{}) + + require.NotNil(t, dashCreate.Response) + require.Equal(t, http.StatusOK, dashCreate.Response.StatusCode) + + dashUID := (*dashCreate.Result)["uid"].(string) + require.NotEmpty(t, dashUID) + return dashUID +} diff --git a/pkg/storage/unified/migrations/migrator_test.go b/pkg/storage/unified/migrations/migrator_test.go new file mode 100644 index 00000000000..e75ed26b9e3 --- /dev/null +++ b/pkg/storage/unified/migrations/migrator_test.go @@ -0,0 +1,210 @@ +package migrations_test + +import ( + "context" + "fmt" + "os" + "testing" + + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util/testutil" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +// resourceMigratorTestCase defines the interface for testing a resource migrator. +type resourceMigratorTestCase interface { + // name returns the test case name + name() string + // resources returns the GVRs that this migrator handles + resources() []schema.GroupVersionResource + // setup creates test resources in legacy storage (Mode0) + setup(t *testing.T, helper *apis.K8sTestHelper) + // verify checks that resources exist (or don't exist) in unified storage + verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool) +} + +// TestIntegrationMigrations verifies that legacy storage data is correctly migrated to unified storage. +// The test follows a three-step process: +// Step 1: inserts legacy data (migration disabled at startup) +// Step 2: verifies that the data is not in unified storage +// Step 3: migration runs at startup, and the test verifies that the data is in unified storage +func TestIntegrationMigrations(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + migrationTestCases := []resourceMigratorTestCase{ + newFoldersAndDashboardsTestCase(), + } + + runMigrationTestSuite(t, migrationTestCases) +} + +// runMigrationTestSuite executes the migration test suite for the given test cases +func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) { + if db.IsTestDbSQLite() { + // Share the same SQLite DB file between steps + tmpDir := t.TempDir() + dbPath := tmpDir + "/shared-migration-test-suite.db" + + oldVal := os.Getenv("SQLITE_TEST_DB") + require.NoError(t, os.Setenv("SQLITE_TEST_DB", dbPath)) + t.Cleanup(func() { + if oldVal == "" { + _ = os.Unsetenv("SQLITE_TEST_DB") + } else { + _ = os.Setenv("SQLITE_TEST_DB", oldVal) + } + }) + t.Logf("Using shared database path: %s", dbPath) + } + + // Store UIDs created by each test case + type testCaseState struct { + tc resourceMigratorTestCase + } + testStates := make([]testCaseState, len(testCases)) + for i, tc := range testCases { + testStates[i].tc = tc + } + + // reuse org users throughout the tests + var org1 *apis.OrgUsers + var orgB *apis.OrgUsers + t.Run("Step 1: Create data in legacy", func(t *testing.T) { + // Enforce Mode0 for all migrated resources + unifiedConfig := make(map[string]setting.UnifiedStorageConfig) + for _, tc := range testCases { + for _, gvr := range tc.resources() { + resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group) + unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{ + DualWriterMode: grafanarest.Mode0, + } + } + } + + // Set up test environment with Mode0 (writes only to legacy) + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: true, + DisableDBCleanup: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: unifiedConfig, + }) + t.Cleanup(helper.Shutdown) + org1 = &helper.Org1 + orgB = &helper.OrgB + + for i := range testStates { + state := &testStates[i] + t.Run(state.tc.name(), func(t *testing.T) { + state.tc.setup(t, helper) + // Verify resources were created in legacy storage + state.tc.verify(t, helper, true) + }) + } + }) + + // Set SKIP_DB_TRUNCATE to not truncate the data created in Step 1 + oldSkipTruncate := os.Getenv("SKIP_DB_TRUNCATE") + require.NoError(t, os.Setenv("SKIP_DB_TRUNCATE", "true")) + t.Cleanup(func() { + if oldSkipTruncate == "" { + _ = os.Unsetenv("SKIP_DB_TRUNCATE") + } else { + _ = os.Setenv("SKIP_DB_TRUNCATE", oldSkipTruncate) + } + }) + + t.Run("Step 2: Verify data is NOT in unified storage before the migration", func(t *testing.T) { + // Build unified storage config for Mode5 + unifiedConfig := make(map[string]setting.UnifiedStorageConfig) + for _, tc := range testCases { + for _, gvr := range tc.resources() { + resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group) + unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{ + DualWriterMode: grafanarest.Mode5, + } + } + } + + helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{ + GrafanaOpts: testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: true, + DisableDBCleanup: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: unifiedConfig, + }, + Org1Users: org1, + OrgBUsers: orgB, + }) + t.Cleanup(helper.Shutdown) + + for _, state := range testStates { + t.Run(state.tc.name(), func(t *testing.T) { + // Verify resources don't exist in unified storage yet + state.tc.verify(t, helper, false) + }) + } + }) + + t.Run("Step 3: verify data is migrated to unified storage", func(t *testing.T) { + // Migrations will run automatically at startup and mode 5 is enforced by the config + helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{ + GrafanaOpts: testinfra.GrafanaOpts{ + // EnableLog: true, + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: false, // Run migrations at startup + APIServerStorageType: "unified", + }, + Org1Users: org1, + OrgBUsers: orgB, + }) + t.Cleanup(helper.Shutdown) + + for _, state := range testStates { + t.Run(state.tc.name(), func(t *testing.T) { + // Verify resources now exist in unified storage after migration + state.tc.verify(t, helper, true) + }) + } + }) +} + +// verifyResourceCount verifies that the expected number of resources exist in K8s storage +func verifyResourceCount(t *testing.T, client *apis.K8sResourceClient, expectedCount int) { + t.Helper() + + l, err := client.Resource.List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + + resources, err := meta.ExtractList(l) + require.NoError(t, err) + require.Equal(t, expectedCount, len(resources)) +} + +// verifyResource verifies that a resource with the given UID exists in K8s storage +func verifyResource(t *testing.T, client *apis.K8sResourceClient, uid string, shouldExist bool) { + t.Helper() + + _, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) + if shouldExist { + require.NoError(t, err) + } else { + require.Error(t, err) + } +} diff --git a/pkg/storage/unified/migrations/resource_migration.go b/pkg/storage/unified/migrations/resource_migration.go index 281ee1c85e3..5bbed9c5722 100644 --- a/pkg/storage/unified/migrations/resource_migration.go +++ b/pkg/storage/unified/migrations/resource_migration.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/util/xorm" "k8s.io/apimachinery/pkg/runtime/schema" @@ -72,6 +73,17 @@ func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) erro m.log.Info("Starting migration for all organizations", "org_count", len(orgs), "resources", m.resources) + if mg.Dialect.DriverName() == migrator.SQLite { + // reuse transaction in SQLite to avoid "database is locked" errors + tx, err := sess.Tx() + if err != nil { + m.log.Error("Failed to get transaction from session", "error", err) + return fmt.Errorf("failed to get transaction: %w", err) + } + ctx = resource.ContextWithTransaction(ctx, tx.Tx) + m.log.Info("Stored migrator transaction in context for bulk operations (SQLite compatibility)") + } + for _, org := range orgs { if err := m.migrateOrg(ctx, sess, org); err != nil { return err @@ -107,6 +119,10 @@ func (m *ResourceMigration) migrateOrg(ctx context.Context, sess *xorm.Session, m.log.Error("Migration failed", "org_id", org.ID, "error", err, "duration", time.Since(startTime)) return fmt.Errorf("migration failed for org %d (%s): %w", org.ID, org.Name, err) } + if response.Error != nil { + m.log.Error("Migration reported error", "org_id", org.ID, "error", response.Error.String(), "duration", time.Since(startTime)) + return fmt.Errorf("migration failed for org %d (%s): %w", org.ID, org.Name, fmt.Errorf("migration error: %s", response.Error.Message)) + } // Validate the migration results if err := m.validateMigration(migrationCtx, sess, response); err != nil { diff --git a/pkg/storage/unified/migrations/service.go b/pkg/storage/unified/migrations/service.go index 1a78f837066..23b8c2706ae 100644 --- a/pkg/storage/unified/migrations/service.go +++ b/pkg/storage/unified/migrations/service.go @@ -85,8 +85,13 @@ func RegisterMigrations( // Run all registered migrations (blocking) sec := cfg.Raw.Section("database") + migrationLocking := sec.Key("migration_locking").MustBool(true) + if mg.Dialect.DriverName() == sqlstoremigrator.SQLite { + // disable migration locking for SQLite to avoid "database is locked" errors in the bulk operations + migrationLocking = false + } if err := mg.RunMigrations(ctx, - sec.Key("migration_locking").MustBool(true), + migrationLocking, sec.Key("locking_attempt_timeout_sec").MustInt()); err != nil { return fmt.Errorf("unified storage data migration failed: %w", err) } @@ -98,12 +103,14 @@ func RegisterMigrations( func registerDashboardAndFolderMigration(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { folders := schema.GroupResource{Group: "folder.grafana.app", Resource: "folders"} dashboards := schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"} + driverName := mg.Dialect.DriverName() folderCountValidator := NewCountValidator( client, folders, "dashboard", "org_id = ? and is_folder = true", + driverName, ) dashboardCountValidator := NewCountValidator( @@ -111,9 +118,10 @@ func registerDashboardAndFolderMigration(mg *sqlstoremigrator.Migrator, migrator dashboards, "dashboard", "org_id = ? and is_folder = false", + driverName, ) - folderTreeValidator := NewFolderTreeValidator(client, folders) + folderTreeValidator := NewFolderTreeValidator(client, folders, driverName) dashboardsAndFolders := NewResourceMigration( migrator, diff --git a/pkg/storage/unified/migrations/validator.go b/pkg/storage/unified/migrations/validator.go index 7a973976402..0bdc67cc205 100644 --- a/pkg/storage/unified/migrations/validator.go +++ b/pkg/storage/unified/migrations/validator.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/util/xorm" "k8s.io/apimachinery/pkg/runtime/schema" @@ -57,6 +58,7 @@ type CountValidator struct { resource schema.GroupResource table string whereClause string + driverName string } func NewCountValidator( @@ -64,6 +66,7 @@ func NewCountValidator( resource schema.GroupResource, table string, whereClause string, + driverName string, ) Validator { return &CountValidator{ name: "CountValidator", @@ -71,6 +74,7 @@ func NewCountValidator( resource: resource, table: table, whereClause: whereClause, + driverName: driverName, } } @@ -120,22 +124,32 @@ func (v *CountValidator) Validate(ctx context.Context, sess *xorm.Session, respo return fmt.Errorf("failed to count %s: %w", v.table, err) } - // Get unified storage count using GetStats API - statsResp, err := v.client.GetStats(ctx, &resourcepb.ResourceStatsRequest{ - Namespace: summary.Namespace, - Kinds: []string{fmt.Sprintf("%s/%s", summary.Group, summary.Resource)}, - }) - if err != nil { - return fmt.Errorf("failed to get stats for %s/%s in namespace %s: %w", - summary.Group, summary.Resource, summary.Namespace, err) - } - - // Find the count for this specific resource type var unifiedCount int64 - for _, stat := range statsResp.Stats { - if stat.Group == summary.Group && stat.Resource == summary.Resource { - unifiedCount = stat.Count - break + if v.driverName == migrator.SQLite { + unifiedCount, err = sess.Table("resource"). + Where("namespace = ? AND `group` = ? AND resource = ?", + summary.Namespace, summary.Group, summary.Resource). + Count() + if err != nil { + return fmt.Errorf("failed to count resource table for %s/%s in namespace %s: %w", + summary.Group, summary.Resource, summary.Namespace, err) + } + } else { + // Get unified storage count using GetStats API + statsResp, err := v.client.GetStats(ctx, &resourcepb.ResourceStatsRequest{ + Namespace: summary.Namespace, + Kinds: []string{fmt.Sprintf("%s/%s", summary.Group, summary.Resource)}, + }) + if err != nil { + return fmt.Errorf("failed to get stats for %s/%s in namespace %s: %w", + summary.Group, summary.Resource, summary.Namespace, err) + } + // Find the count for this specific resource type + for _, stat := range statsResp.Stats { + if stat.Group == summary.Group && stat.Resource == summary.Resource { + unifiedCount = stat.Count + break + } } } @@ -162,19 +176,22 @@ func (v *CountValidator) Validate(ctx context.Context, sess *xorm.Session, respo } type FolderTreeValidator struct { - name string - client resourcepb.ResourceIndexClient - resource schema.GroupResource + name string + client resourcepb.ResourceIndexClient + resource schema.GroupResource + driverName string } func NewFolderTreeValidator( client resourcepb.ResourceIndexClient, resource schema.GroupResource, + driverName string, ) Validator { return &FolderTreeValidator{ - name: "FolderTreeValidator", - client: client, - resource: resource, + name: "FolderTreeValidator", + client: client, + resource: resource, + driverName: driverName, } } @@ -185,6 +202,12 @@ type legacyFolder struct { Title string `xorm:"title"` } +type unifiedFolder struct { + GUID string `xorm:"guid"` + Name string `xorm:"name"` + Folder string `xorm:"folder"` +} + func (v *FolderTreeValidator) Name() string { return v.name } @@ -218,7 +241,12 @@ func (v *FolderTreeValidator) Validate(ctx context.Context, sess *xorm.Session, } // Build unified storage folder parent map - unifiedParentMap, err := v.buildUnifiedFolderParentMap(ctx, summary.Namespace, log) + var unifiedParentMap map[string]string + if v.driverName == migrator.SQLite { + unifiedParentMap, err = v.buildUnifiedFolderParentMapSQLite(sess, summary.Namespace, log) + } else { + unifiedParentMap, err = v.buildUnifiedFolderParentMap(ctx, summary.Namespace, log) + } if err != nil { return fmt.Errorf("failed to build unified folder parent map: %w", err) } @@ -348,3 +376,30 @@ func (v *FolderTreeValidator) buildUnifiedFolderParentMap(ctx context.Context, n return parentMap, nil } + +func (v *FolderTreeValidator) buildUnifiedFolderParentMapSQLite(sess *xorm.Session, namespace string, log log.Logger) (map[string]string, error) { + var folders []unifiedFolder + err := sess.Table("resource"). + Cols("guid", "name", "folder"). + Where("namespace = ? AND resource = ?", namespace, "folder"). + Find(&folders) + if err != nil { + return nil, fmt.Errorf("failed to query unified folders: %w", err) + } + + parentMap := make(map[string]string) + for _, folder := range folders { + parentMap[folder.Name] = folder.Folder + } + + if len(parentMap) == 0 { + log.Debug("No unified folders found for namespace", "namespace", namespace) + return make(map[string]string), nil + } + + log.Debug("Built unified folder parent map", + "folder_count", len(parentMap), + "namespace", namespace) + + return parentMap, nil +} diff --git a/pkg/storage/unified/resource/notifier.go b/pkg/storage/unified/resource/notifier.go index f55db60623a..5dd6a17ad29 100644 --- a/pkg/storage/unified/resource/notifier.go +++ b/pkg/storage/unified/resource/notifier.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/grafana/dskit/backoff" "github.com/grafana/grafana-app-sdk/logging" gocache "github.com/patrickmn/go-cache" @@ -13,8 +14,8 @@ import ( const ( defaultLookbackPeriod = 30 * time.Second - defaultPollInterval = 100 * time.Millisecond - defaultEventCacheSize = 10000 + defaultMinBackoff = 100 * time.Millisecond + defaultMaxBackoff = 5 * time.Second defaultBufferSize = 10000 ) @@ -29,15 +30,17 @@ type notifierOptions struct { type watchOptions struct { LookbackPeriod time.Duration // How far back to look for events - PollInterval time.Duration // How often to poll for new events BufferSize int // How many events to buffer + MinBackoff time.Duration // Minimum interval between polling requests + MaxBackoff time.Duration // Maximum interval between polling requests } func defaultWatchOptions() watchOptions { return watchOptions{ LookbackPeriod: defaultLookbackPeriod, - PollInterval: defaultPollInterval, BufferSize: defaultBufferSize, + MinBackoff: defaultMinBackoff, + MaxBackoff: defaultMaxBackoff, } } @@ -62,9 +65,13 @@ func (n *notifier) cacheKey(evt Event) string { } func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { - if opts.PollInterval <= 0 { - opts.PollInterval = defaultPollInterval + if opts.MinBackoff <= 0 { + opts.MinBackoff = defaultMinBackoff } + if opts.MaxBackoff <= 0 || opts.MaxBackoff <= opts.MinBackoff { + opts.MaxBackoff = defaultMaxBackoff + } + cacheTTL := opts.LookbackPeriod cacheCleanupInterval := 2 * opts.LookbackPeriod @@ -81,11 +88,21 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { go func() { defer close(events) + // Initialize backoff with minimum backoff interval + currentInterval := opts.MinBackoff + backoffConfig := backoff.Config{ + MinBackoff: opts.MinBackoff, + MaxBackoff: opts.MaxBackoff, + MaxRetries: 0, // infinite retries + } + bo := backoff.New(ctx, backoffConfig) + for { select { case <-ctx.Done(): return - case <-time.After(opts.PollInterval): + case <-time.After(currentInterval): + foundEvents := false for evt, err := range n.eventStore.ListSince(ctx, subtractDurationFromSnowflake(lastRV, opts.LookbackPeriod)) { if err != nil { n.log.Error("Failed to list events since", "error", err) @@ -102,6 +119,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { continue } + foundEvents = true if evt.ResourceVersion > lastRV { lastRV = evt.ResourceVersion + 1 } @@ -113,6 +131,14 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { return } } + + // Apply backoff logic: reset to min when events are found, increase when no events + if foundEvents { + bo.Reset() + currentInterval = opts.MinBackoff + } else { + currentInterval = bo.NextDelay() + } } } }() diff --git a/pkg/storage/unified/resource/notifier_test.go b/pkg/storage/unified/resource/notifier_test.go index a4272a36698..7b201f47420 100644 --- a/pkg/storage/unified/resource/notifier_test.go +++ b/pkg/storage/unified/resource/notifier_test.go @@ -32,7 +32,6 @@ func TestDefaultWatchOptions(t *testing.T) { opts := defaultWatchOptions() assert.Equal(t, defaultLookbackPeriod, opts.LookbackPeriod) - assert.Equal(t, defaultPollInterval, opts.PollInterval) assert.Equal(t, defaultBufferSize, opts.BufferSize) } @@ -158,8 +157,9 @@ func TestNotifier_Watch_NoEvents(t *testing.T) { opts := watchOptions{ LookbackPeriod: 100 * time.Millisecond, - PollInterval: 50 * time.Millisecond, BufferSize: 10, + MinBackoff: 50 * time.Millisecond, + MaxBackoff: 500 * time.Millisecond, } events := notifier.Watch(ctx, opts) @@ -210,8 +210,9 @@ func TestNotifier_Watch_WithExistingEvents(t *testing.T) { opts := watchOptions{ LookbackPeriod: 100 * time.Millisecond, - PollInterval: 50 * time.Millisecond, BufferSize: 10, + MinBackoff: 50 * time.Millisecond, + MaxBackoff: 500 * time.Millisecond, } // Start watching @@ -265,8 +266,9 @@ func TestNotifier_Watch_EventDeduplication(t *testing.T) { opts := watchOptions{ LookbackPeriod: time.Second, - PollInterval: 20 * time.Millisecond, BufferSize: 10, + MinBackoff: 20 * time.Millisecond, + MaxBackoff: 200 * time.Millisecond, } // Start watching @@ -326,8 +328,9 @@ func TestNotifier_Watch_ContextCancellation(t *testing.T) { opts := watchOptions{ LookbackPeriod: 100 * time.Millisecond, - PollInterval: 20 * time.Millisecond, BufferSize: 10, + MinBackoff: 20 * time.Millisecond, + MaxBackoff: 200 * time.Millisecond, } events := notifier.Watch(ctx, opts) @@ -369,8 +372,9 @@ func TestNotifier_Watch_MultipleEvents(t *testing.T) { opts := watchOptions{ LookbackPeriod: time.Second, - PollInterval: 20 * time.Millisecond, BufferSize: 10, + MinBackoff: 20 * time.Millisecond, + MaxBackoff: 200 * time.Millisecond, } // Start watching diff --git a/pkg/storage/unified/resource/quotas.go b/pkg/storage/unified/resource/quotas.go new file mode 100644 index 00000000000..d956a6da017 --- /dev/null +++ b/pkg/storage/unified/resource/quotas.go @@ -0,0 +1,127 @@ +package resource + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/grafana/dskit/runtimeconfig" + "github.com/grafana/dskit/services" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/trace" + "go.yaml.in/yaml/v3" +) + +const DEFAULT_RESOURCE_LIMIT = 1000 + +type OverridesService struct { + manager *runtimeconfig.Manager + logger log.Logger + tracer trace.Tracer +} + +type ReloadOptions struct { + FilePath string + ReloadPeriod time.Duration +} + +// ResourceQuota represents quota limits for a specific resource +type ResourceQuota struct { + Limit int `yaml:"limit"` +} + +// NamespaceOverrides represents all overrides for a tenant +type NamespaceOverrides struct { + Quotas map[string]ResourceQuota `yaml:"quotas"` +} + +// Overrides represents the entire overrides configuration file +type Overrides struct { + Namespaces map[string]NamespaceOverrides +} + +/* +This service loads overrides (currently just quotas) from a YAML file with the following yaml structure: + +"123": + + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 1500 +*/ +func NewOverridesService(_ context.Context, logger log.Logger, reg prometheus.Registerer, tracer trace.Tracer, opts ReloadOptions) (*OverridesService, error) { + // shouldn't be empty since we use file path existence to determine if we should enable the service + if opts.FilePath == "" { + return nil, fmt.Errorf("overrides file path is required") + } + if opts.ReloadPeriod == 0 { + opts.ReloadPeriod = time.Second * 30 + } + + // Check if file exists + if _, err := os.Stat(opts.FilePath); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("overrides file does not exist: %s", opts.FilePath) + } + return nil, fmt.Errorf("failed to stat overrides file: %w", err) + } + + config := runtimeconfig.Config{ + ReloadPeriod: opts.ReloadPeriod, + LoadPath: []string{opts.FilePath}, + Loader: func(r io.Reader) (interface{}, error) { + var tenants map[string]NamespaceOverrides + decoder := yaml.NewDecoder(r) + if err := decoder.Decode(&tenants); err != nil { + return nil, err + } + return &Overrides{Namespaces: tenants}, nil + }, + } + + manager, err := runtimeconfig.New(config, "tenant-overrides", reg, logger) + if err != nil { + return nil, err + } + + return &OverridesService{ + manager: manager, + logger: logger, + tracer: tracer, + }, nil +} + +func (q *OverridesService) init(ctx context.Context) error { + return services.StartAndAwaitRunning(ctx, q.manager) +} + +func (q *OverridesService) stop(ctx context.Context) error { + return services.StopAndAwaitTerminated(ctx, q.manager) +} + +func (q *OverridesService) GetQuota(_ context.Context, nsr NamespacedResource) (ResourceQuota, error) { + if nsr.Namespace == "" || nsr.Resource == "" || nsr.Group == "" { + return ResourceQuota{}, fmt.Errorf("invalid namespaced resource: %+v", nsr) + } + + overrides, ok := q.manager.GetConfig().(*Overrides) + if !ok { + return ResourceQuota{}, fmt.Errorf("failed to get quota overrides from config manager") + } + + tenantId := strings.TrimPrefix(nsr.Namespace, "stacks-") + groupResource := nsr.Group + "/" + nsr.Resource + if tenantOverrides, ok := overrides.Namespaces[tenantId]; ok { + if resourceQuota, ok := tenantOverrides.Quotas[groupResource]; ok { + return resourceQuota, nil + } + } + + return ResourceQuota{Limit: DEFAULT_RESOURCE_LIMIT}, nil +} diff --git a/pkg/storage/unified/resource/quotas_test.go b/pkg/storage/unified/resource/quotas_test.go new file mode 100644 index 00000000000..97f9f355af6 --- /dev/null +++ b/pkg/storage/unified/resource/quotas_test.go @@ -0,0 +1,427 @@ +package resource + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewQuotaService(t *testing.T) { + tests := []struct { + name string + opts ReloadOptions + setupFile func(t *testing.T) string + expectError bool + errorMsg string + }{ + { + name: "success with valid file", + opts: ReloadOptions{}, + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + expectError: false, + }, + { + name: "success with custom reload period", + opts: ReloadOptions{ + ReloadPeriod: time.Minute, + }, + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + require.NoError(t, os.WriteFile(tmpFile, []byte{}, 0644)) + return tmpFile + }, + expectError: false, + }, + { + name: "error when file path is empty", + opts: ReloadOptions{ + FilePath: "", + }, + setupFile: func(t *testing.T) string { return "" }, + expectError: true, + errorMsg: "overrides file path is required", + }, + { + name: "error when file does not exist", + opts: ReloadOptions{ + FilePath: "/nonexistent/path/overrides.yaml", + }, + setupFile: func(t *testing.T) string { return "/nonexistent/path/overrides.yaml" }, + expectError: true, + errorMsg: "overrides file does not exist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + logger := log.NewNopLogger() + reg := prometheus.NewRegistry() + tcr := tracing.NewNoopTracerService() + + filePath := tt.setupFile(t) + if filePath != "" && tt.opts.FilePath == "" { + tt.opts.FilePath = filePath + } + + service, err := NewOverridesService(ctx, logger, reg, tcr, tt.opts) + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + assert.Nil(t, service) + } else { + require.NoError(t, err) + assert.NotNil(t, service) + assert.NotNil(t, service.manager) + assert.NotNil(t, service.logger) + } + }) + } +} + +func TestQuotaService_ConfigReload(t *testing.T) { + ctx := context.Background() + logger := log.NewNopLogger() + reg := prometheus.NewRegistry() + tcr := tracing.NewNoopTracerService() + + // Create a temporary config file + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + initialConfig := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(initialConfig), 0644)) + + // Create service with a very short reload period + service, err := NewOverridesService(ctx, logger, reg, tcr, ReloadOptions{ + FilePath: tmpFile, + ReloadPeriod: 100 * time.Millisecond, // Very short reload period for testing + }) + require.NoError(t, err) + require.NotNil(t, service) + + // Initialize the service + err = service.init(ctx) + require.NoError(t, err) + defer func(service *OverridesService, ctx context.Context) { + err := service.stop(ctx) + require.NoError(t, err) + }(service, ctx) + + // Verify initial config + nsr := NamespacedResource{ + Namespace: "stacks-123", + Group: "grafana.dashboard.app", + Resource: "dashboards", + } + quota, err := service.GetQuota(ctx, nsr) + require.NoError(t, err) + assert.Equal(t, 1500, quota.Limit, "initial quota should be 1500") + + // Update the config file with new values + updatedConfig := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 2500 +"456": + quotas: + grafana.folder.app/folders: + limit: 3000 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(updatedConfig), 0644)) + + // Wait for the config to be reloaded (wait longer than reload period) + time.Sleep(500 * time.Millisecond) + + // Verify the config was updated for existing tenant + quota, err = service.GetQuota(ctx, nsr) + require.NoError(t, err) + assert.Equal(t, 2500, quota.Limit, "quota should be updated to 2500") + + // Verify new tenant config is also loaded + nsr2 := NamespacedResource{ + Namespace: "stacks-456", + Group: "grafana.folder.app", + Resource: "folders", + } + quota2, err := service.GetQuota(ctx, nsr2) + require.NoError(t, err) + assert.Equal(t, 3000, quota2.Limit, "new tenant quota should be 3000") +} + +func TestQuotaService_GetQuota(t *testing.T) { + tests := []struct { + name string + setupFile func(t *testing.T) string + nsr NamespacedResource + expectedLimit int + expectError bool + errorMsg string + description string + }{ + { + name: "returns custom quota for matching tenant and resource", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-123", + Group: "grafana.dashboard.app", + Resource: "dashboards", + }, + expectedLimit: 1500, + expectError: false, + description: "should return custom limit for matching tenant", + }, + { + name: "returns default quota when tenant not found", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-456", + Group: "grafana.dashboard.app", + Resource: "dashboards", + }, + expectedLimit: DEFAULT_RESOURCE_LIMIT, + expectError: false, + description: "should return default limit when tenant not found", + }, + { + name: "returns default quota when resource not found", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-123", + Group: "grafana.folder.app", + Resource: "folders", + }, + expectedLimit: DEFAULT_RESOURCE_LIMIT, + expectError: false, + description: "should return default limit when resource not found", + }, + { + name: "handles namespace without stacks- prefix", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "123", + Group: "grafana.dashboard.app", + Resource: "dashboards", + }, + expectedLimit: 1500, + expectError: false, + description: "should handle namespace without stacks- prefix", + }, + { + name: "returns default quota when config is empty", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := "" + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-123", + Group: "grafana.dashboard.app", + Resource: "dashboards", + }, + expectedLimit: DEFAULT_RESOURCE_LIMIT, + expectError: false, + description: "should return default limit when config is empty", + }, + { + name: "handles multiple resources for same tenant", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-123", + Group: "grafana.folder.app", + Resource: "folders", + }, + expectedLimit: 2500, + expectError: false, + description: "should return correct limit for specific resource", + }, + { + name: "returns error when namespace is empty", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "", + Group: "grafana.dashboard.app", + Resource: "dashboards", + }, + expectError: true, + errorMsg: "invalid namespaced resource", + description: "should return error when namespace is empty", + }, + { + name: "returns error when group is empty", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-123", + Group: "", + Resource: "dashboards", + }, + expectError: true, + errorMsg: "invalid namespaced resource", + description: "should return error when group is empty", + }, + { + name: "returns error when resource is empty", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "stacks-123", + Group: "grafana.dashboard.app", + Resource: "", + }, + expectError: true, + errorMsg: "invalid namespaced resource", + description: "should return error when resource is empty", + }, + { + name: "returns error when all fields are empty", + setupFile: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") + content := `"123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + return tmpFile + }, + nsr: NamespacedResource{ + Namespace: "", + Group: "", + Resource: "", + }, + expectError: true, + errorMsg: "invalid namespaced resource", + description: "should return error when all fields are empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + logger := log.NewNopLogger() + reg := prometheus.NewRegistry() + tcr := tracing.NewNoopTracerService() + opts := ReloadOptions{ + FilePath: tt.setupFile(t), + } + + service, err := NewOverridesService(ctx, logger, reg, tcr, opts) + require.NoError(t, err, "failed to create quota service") + err = service.init(ctx) + require.NoError(t, err, "failed to initialize quota service") + + quota, err := service.GetQuota(ctx, tt.nsr) + + if tt.expectError { + require.Error(t, err, tt.description) + assert.Contains(t, err.Error(), tt.errorMsg, tt.description) + assert.Equal(t, ResourceQuota{}, quota, "should return empty quota on error") + } else { + require.NoError(t, err, tt.description) + assert.Equal(t, tt.expectedLimit, quota.Limit, tt.description) + } + }) + } +} diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 4c0adddf3c8..dd091c6e2d4 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -14,6 +14,8 @@ import ( "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -220,6 +222,9 @@ type ResourceServerOptions struct { // Search options Search SearchOptions + // Quota service + OverridesService *OverridesService + // Diagnostics Diagnostics resourcepb.DiagnosticsServer @@ -342,6 +347,7 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) { reg: opts.Reg, queue: opts.QOSQueue, queueConfig: opts.QOSConfig, + overridesService: opts.OverridesService, artificialSuccessfulWriteDelay: opts.Search.IndexMinUpdateInterval, } @@ -366,19 +372,20 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) { var _ ResourceServer = &server{} type server struct { - log log.Logger - backend StorageBackend - blob BlobSupport - secure secrets.InlineSecureValueSupport - search *searchSupport - diagnostics resourcepb.DiagnosticsServer - access claims.AccessClient - writeHooks WriteAccessHooks - lifecycle LifecycleHooks - now func() int64 - mostRecentRV atomic.Int64 // The most recent resource version seen by the server - storageMetrics *StorageMetrics - indexMetrics *BleveIndexMetrics + log log.Logger + backend StorageBackend + blob BlobSupport + secure secrets.InlineSecureValueSupport + search *searchSupport + diagnostics resourcepb.DiagnosticsServer + access claims.AccessClient + writeHooks WriteAccessHooks + lifecycle LifecycleHooks + now func() int64 + mostRecentRV atomic.Int64 // The most recent resource version seen by the server + storageMetrics *StorageMetrics + indexMetrics *BleveIndexMetrics + overridesService *OverridesService // Background watch task -- this has permissions for everything ctx context.Context @@ -411,6 +418,11 @@ func (s *server) Init(ctx context.Context) error { } } + // initialize tenant overrides service + if s.initErr == nil && s.overridesService != nil { + s.initErr = s.overridesService.init(ctx) + } + // initialize the search index if s.initErr == nil && s.search != nil { s.initErr = s.search.init(ctx) @@ -444,6 +456,13 @@ func (s *server) Stop(ctx context.Context) error { s.search.stop() } + if s.overridesService != nil { + if err := s.overridesService.stop(ctx); err != nil { + stopFailed = true + s.initErr = fmt.Errorf("service stopeed with error: %w", err) + } + } + // Stops the streaming s.cancel() @@ -647,6 +666,13 @@ func (s *server) Create(ctx context.Context, req *resourcepb.CreateRequest) (*re ctx, span := tracer.Start(ctx, "resource.server.Create") defer span.End() + // check quotas and log for now + s.checkQuota(ctx, NamespacedResource{ + Namespace: req.Key.Namespace, + Group: req.Key.Group, + Resource: req.Key.Resource, + }) + if r := verifyRequestKey(req.Key); r != nil { return nil, fmt.Errorf("invalid request key: %s", r.Message) } @@ -1549,3 +1575,31 @@ func (s *server) RebuildIndexes(ctx context.Context, req *resourcepb.RebuildInde return s.search.RebuildIndexes(ctx, req) } + +func (s *server) checkQuota(ctx context.Context, nsr NamespacedResource) { + span := trace.SpanFromContext(ctx) + span.AddEvent("checkQuota", trace.WithAttributes( + attribute.String("namespace", nsr.Namespace), + attribute.String("group", nsr.Group), + attribute.String("resource", nsr.Resource), + )) + + if s.overridesService == nil { + return + } + + quota, err := s.overridesService.GetQuota(ctx, nsr) + if err != nil { + s.log.FromContext(ctx).Error("failed to get quota for resource", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "error", err) + return + } + + stats, err := s.backend.GetResourceStats(ctx, nsr, 0) + if err != nil { + s.log.FromContext(ctx).Error("failed to get resource stats for quota checking", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "error", err) + return + } + if len(stats) > 0 && stats[0].Count >= int64(quota.Limit) { + s.log.FromContext(ctx).Info("Quota exceeded on create", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "quota", quota.Limit, "count", stats[0].Count, "stats_resource", stats[0].Resource) + } +} diff --git a/pkg/storage/unified/resource/transaction.go b/pkg/storage/unified/resource/transaction.go new file mode 100644 index 00000000000..7d47f02af71 --- /dev/null +++ b/pkg/storage/unified/resource/transaction.go @@ -0,0 +1,25 @@ +package resource + +import ( + "context" + "database/sql" +) + +type transactionContextKey struct{} + +// ContextWithTransaction returns a new context with the transaction stored directly. +// This is used for SQLite migrations where the transaction needs to be shared +// between the migration code and unified storage operations within the same process. +func ContextWithTransaction(ctx context.Context, tx *sql.Tx) context.Context { + return context.WithValue(ctx, transactionContextKey{}, tx) +} + +// TransactionFromContext retrieves the transaction from context +func TransactionFromContext(ctx context.Context) *sql.Tx { + if v := ctx.Value(transactionContextKey{}); v != nil { + if tx, ok := v.(*sql.Tx); ok { + return tx + } + } + return nil +} diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index 8de43f318b0..0abc2fd7329 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -14,6 +14,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "github.com/lib/pq" "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace/noop" "go.uber.org/atomic" @@ -263,7 +264,11 @@ func (b *backend) Stop(_ context.Context) error { // GetResourceStats implements Backend. func (b *backend) GetResourceStats(ctx context.Context, nsr resource.NamespacedResource, minCount int) ([]resource.ResourceStats, error) { - ctx, span := b.tracer.Start(ctx, tracePrefix+"GetResourceStats") + ctx, span := b.tracer.Start(ctx, tracePrefix+"GetResourceStats", trace.WithAttributes( + attribute.String("namespace", nsr.Namespace), + attribute.String("group", nsr.Group), + attribute.String("resource", nsr.Resource), + )) defer span.End() req := &sqlStatsRequest{ diff --git a/pkg/storage/unified/sql/bulk.go b/pkg/storage/unified/sql/bulk.go index 7d4d7165d50..6580975a764 100644 --- a/pkg/storage/unified/sql/bulk.go +++ b/pkg/storage/unified/sql/bulk.go @@ -8,6 +8,7 @@ import ( "sync" "time" + "github.com/fullstorydev/grpchan/inprocgrpc" "github.com/google/uuid" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -20,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/storage/unified/sql/db" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" "github.com/grafana/grafana/pkg/storage/unified/sql/dbutil" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) @@ -111,6 +113,19 @@ func (b *backend) ProcessBulk(ctx context.Context, setting resource.BulkSettings } defer b.bulkLock.Finish(setting.Collection) + // If provided, reuse the inproc transaction for SQLite + if clientCtx := inprocgrpc.ClientContext(ctx); clientCtx != nil && b.dialect.DialectName() == "sqlite" { + if externalTx := resource.TransactionFromContext(clientCtx); externalTx != nil { + b.log.Info("Using SQLite transaction from client context") + rsp := &resourcepb.BulkResponse{} + err := b.processBulkWithTx(ctx, dbimpl.NewTx(externalTx), setting, iter, rsp) + if err != nil { + rsp.Error = resource.AsErrorResult(err) + } + return rsp + } + } + // We may want to first write parquet, then read parquet if b.dialect.DialectName() == "sqlite" { file, err := os.CreateTemp("", "grafana-bulk-export-*.parquet") @@ -151,109 +166,134 @@ func (b *backend) ProcessBulk(ctx context.Context, setting resource.BulkSettings func (b *backend) processBulk(ctx context.Context, setting resource.BulkSettings, iter resource.BulkRequestIterator) *resourcepb.BulkResponse { rsp := &resourcepb.BulkResponse{} err := b.db.WithTx(ctx, ReadCommitted, func(ctx context.Context, tx db.Tx) error { - rollbackWithError := func(err error) error { - txerr := tx.Rollback() - if txerr != nil { - b.log.Warn("rollback", "error", txerr) - } else { - b.log.Info("rollback") + return b.processBulkWithTx(ctx, tx, setting, iter, rsp) + }) + if err != nil { + rsp.Error = resource.AsErrorResult(err) + } + return rsp +} + +// processBulkWithTx performs the bulk operation using the provided transaction. +// This is used both when creating our own transaction and when reusing an external one. +func (b *backend) processBulkWithTx(ctx context.Context, tx db.Tx, setting resource.BulkSettings, iter resource.BulkRequestIterator, rsp *resourcepb.BulkResponse) error { + rollbackWithError := func(err error) error { + txerr := tx.Rollback() + if txerr != nil { + b.log.Warn("rollback", "error", txerr) + } else { + b.log.Info("rollback") + } + return err + } + bulk := &bulkWroker{ + ctx: ctx, + tx: tx, + dialect: b.dialect, + logger: logging.FromContext(ctx), + } + + // Calculate the RV based on incoming request timestamps + rv := newBulkRV() + + summaries := make(map[string]*resourcepb.BulkResponse_Summary, len(setting.Collection)) + + // First clear everything in the transaction + if setting.RebuildCollection { + for _, key := range setting.Collection { + summary, err := bulk.deleteCollection(key) + if err != nil { + return rollbackWithError(err) } + summaries[resource.NSGR(key)] = summary + rsp.Summary = append(rsp.Summary, summary) + } + } else { + for _, key := range setting.Collection { + summaries[resource.NSGR(key)] = &resourcepb.BulkResponse_Summary{ + Namespace: key.Namespace, + Group: key.Group, + Resource: key.Resource, + } + } + } + + obj := &unstructured.Unstructured{} + + // Write each event into the history + for iter.Next() { + if iter.RollbackRequested() { + return rollbackWithError(nil) + } + req := iter.Request() + if req == nil { + return rollbackWithError(fmt.Errorf("missing request")) + } + rsp.Processed++ + + if req.Action == resourcepb.BulkRequest_UNKNOWN { + rsp.Rejected = append(rsp.Rejected, &resourcepb.BulkResponse_Rejected{ + Key: req.Key, + Action: req.Action, + Error: "unknown action", + }) + continue + } + + err := obj.UnmarshalJSON(req.Value) + if err != nil { + rsp.Rejected = append(rsp.Rejected, &resourcepb.BulkResponse_Rejected{ + Key: req.Key, + Action: req.Action, + Error: "unable to unmarshal json", + }) + continue + } + + // Write the event to history + if _, err := dbutil.Exec(ctx, tx, sqlResourceHistoryInsert, sqlResourceRequest{ + SQLTemplate: sqltemplate.New(b.dialect), + WriteEvent: resource.WriteEvent{ + Key: req.Key, + Type: resourcepb.WatchEvent_Type(req.Action), + Value: req.Value, + PreviousRV: -1, // Used for WATCH, but we want to skip watch events + }, + Folder: req.Folder, + GUID: uuid.New().String(), + ResourceVersion: rv.next(obj), + }); err != nil { + return rollbackWithError(fmt.Errorf("insert into resource history: %w", err)) + } + } + + // Now update the resource table from history + for _, key := range setting.Collection { + k := fmt.Sprintf("%s/%s/%s", key.Namespace, key.Group, key.Resource) + summary := summaries[k] + if summary == nil { + return rollbackWithError(fmt.Errorf("missing summary key for: %s", k)) + } + + err := bulk.syncCollection(key, summary) + if err != nil { return err } - bulk := &bulkWroker{ - ctx: ctx, - tx: tx, - dialect: b.dialect, - logger: logging.FromContext(ctx), - } - // Calculate the RV based on incoming request timestamps - rv := newBulkRV() - - summaries := make(map[string]*resourcepb.BulkResponse_Summary, len(setting.Collection)) - - // First clear everything in the transaction - if setting.RebuildCollection { - for _, key := range setting.Collection { - summary, err := bulk.deleteCollection(key) - if err != nil { - return rollbackWithError(err) + if b.dialect.DialectName() == "sqlite" { + nextRV, err := b.rvManager.lock(ctx, tx, key.Group, key.Resource) + if err != nil { + b.log.Error("error locking RV", "error", err, "key", resource.NSGR(key)) + } else { + b.log.Info("successfully locked RV", "nextRV", nextRV, "key", resource.NSGR(key)) + // Save the incremented RV + if err := b.rvManager.saveRV(ctx, tx, key.Group, key.Resource, nextRV); err != nil { + b.log.Error("error saving RV", "error", err, "key", resource.NSGR(key)) + } else { + b.log.Info("successfully saved RV", "rv", nextRV, "key", resource.NSGR(key)) } - summaries[resource.NSGR(key)] = summary - rsp.Summary = append(rsp.Summary, summary) } } else { - for _, key := range setting.Collection { - summaries[resource.NSGR(key)] = &resourcepb.BulkResponse_Summary{ - Namespace: key.Namespace, - Group: key.Group, - Resource: key.Resource, - } - } - } - - obj := &unstructured.Unstructured{} - - // Write each event into the history - for iter.Next() { - if iter.RollbackRequested() { - return rollbackWithError(nil) - } - req := iter.Request() - if req == nil { - return rollbackWithError(fmt.Errorf("missing request")) - } - rsp.Processed++ - - if req.Action == resourcepb.BulkRequest_UNKNOWN { - rsp.Rejected = append(rsp.Rejected, &resourcepb.BulkResponse_Rejected{ - Key: req.Key, - Action: req.Action, - Error: "unknown action", - }) - continue - } - - err := obj.UnmarshalJSON(req.Value) - if err != nil { - rsp.Rejected = append(rsp.Rejected, &resourcepb.BulkResponse_Rejected{ - Key: req.Key, - Action: req.Action, - Error: "unable to unmarshal json", - }) - continue - } - - // Write the event to history - if _, err := dbutil.Exec(ctx, tx, sqlResourceHistoryInsert, sqlResourceRequest{ - SQLTemplate: sqltemplate.New(b.dialect), - WriteEvent: resource.WriteEvent{ - Key: req.Key, - Type: resourcepb.WatchEvent_Type(req.Action), - Value: req.Value, - PreviousRV: -1, // Used for WATCH, but we want to skip watch events - }, - Folder: req.Folder, - GUID: uuid.New().String(), - ResourceVersion: rv.next(obj), - }); err != nil { - return rollbackWithError(fmt.Errorf("insert into resource history: %w", err)) - } - } - - // Now update the resource table from history - for _, key := range setting.Collection { - k := fmt.Sprintf("%s/%s/%s", key.Namespace, key.Group, key.Resource) - summary := summaries[k] - if summary == nil { - return rollbackWithError(fmt.Errorf("missing summary key for: %s", k)) - } - - err := bulk.syncCollection(key, summary) - if err != nil { - return err - } - // Make sure the collection RV is above our last written event _, err = b.rvManager.ExecWithRV(ctx, key, func(tx db.Tx) (string, error) { return "", nil @@ -261,19 +301,15 @@ func (b *backend) processBulk(ctx context.Context, setting resource.BulkSettings if err != nil { b.log.Warn("error increasing RV", "error", err) } - - // Update the last import time. This is important to trigger reindexing - // of the resource for a given namespace. - if err := b.updateLastImportTime(ctx, tx, key, time.Now()); err != nil { - return rollbackWithError(err) - } } - return nil - }) - if err != nil { - rsp.Error = resource.AsErrorResult(err) + + // Update the last import time. This is important to trigger reindexing + // of the resource for a given namespace. + if err := b.updateLastImportTime(ctx, tx, key, time.Now()); err != nil { + return rollbackWithError(err) + } } - return rsp + return nil } func (b *backend) updateLastImportTime(ctx context.Context, tx db.Tx, key *resourcepb.ResourceKey, now time.Time) error { diff --git a/pkg/storage/unified/sql/db/dbimpl/db.go b/pkg/storage/unified/sql/db/dbimpl/db.go index 262b03334aa..00099733bcf 100644 --- a/pkg/storage/unified/sql/db/dbimpl/db.go +++ b/pkg/storage/unified/sql/db/dbimpl/db.go @@ -48,6 +48,11 @@ type sqlTx struct { *sql.Tx } +// NewTx wraps an existing *sql.Tx with sqlTx +func NewTx(tx *sql.Tx) db.Tx { + return sqlTx{tx} +} + func (tx sqlTx) QueryContext(ctx context.Context, query string, args ...any) (db.Rows, error) { // // codeql-suppress go/sql-query-built-from-user-controlled-sources "The query comes from a safe template source // and the parameters are passed as arguments." diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index 1c2b02d8637..f91baa7a695 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -30,19 +30,20 @@ type QOSEnqueueDequeuer interface { // ServerOptions contains the options for creating a new ResourceServer type ServerOptions struct { - Backend resource.StorageBackend - DB infraDB.DB - Cfg *setting.Cfg - Tracer trace.Tracer - Reg prometheus.Registerer - AccessClient types.AccessClient - SearchOptions resource.SearchOptions - StorageMetrics *resource.StorageMetrics - IndexMetrics *resource.BleveIndexMetrics - Features featuremgmt.FeatureToggles - QOSQueue QOSEnqueueDequeuer - SecureValues secrets.InlineSecureValueSupport - OwnsIndexFn func(key resource.NamespacedResource) (bool, error) + Backend resource.StorageBackend + OverridesService *resource.OverridesService + DB infraDB.DB + Cfg *setting.Cfg + Tracer trace.Tracer + Reg prometheus.Registerer + AccessClient types.AccessClient + SearchOptions resource.SearchOptions + StorageMetrics *resource.StorageMetrics + IndexMetrics *resource.BleveIndexMetrics + Features featuremgmt.FeatureToggles + QOSQueue QOSEnqueueDequeuer + SecureValues secrets.InlineSecureValueSupport + OwnsIndexFn func(key resource.NamespacedResource) (bool, error) } func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { @@ -119,6 +120,7 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { serverOptions.IndexMetrics = opts.IndexMetrics serverOptions.QOSQueue = opts.QOSQueue serverOptions.OwnsIndexFn = opts.OwnsIndexFn + serverOptions.OverridesService = opts.OverridesService return resource.NewResourceServer(serverOptions) } diff --git a/pkg/storage/unified/sql/service.go b/pkg/storage/unified/sql/service.go index 00e2ae73750..334c2dfee76 100644 --- a/pkg/storage/unified/sql/service.go +++ b/pkg/storage/unified/sql/service.go @@ -279,6 +279,18 @@ func (s *service) starting(ctx context.Context) error { QOSQueue: s.queue, OwnsIndexFn: s.OwnsIndex, } + + if s.cfg.OverridesFilePath != "" { + overridesSvc, err := resource.NewOverridesService(context.Background(), s.log, s.reg, s.tracing, resource.ReloadOptions{ + FilePath: s.cfg.OverridesFilePath, + ReloadPeriod: s.cfg.OverridesReloadInterval, + }) + if err != nil { + return err + } + serverOptions.OverridesService = overridesSvc + } + server, err := NewResourceServer(serverOptions) if err != nil { return err diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index 23681381fc9..61bc3195857 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -252,7 +252,7 @@ func TestIntegrationPrometheusRules(t *testing.T) { "rules": [{ "state": "inactive", "name": "AlwaysFiring", - "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]", + "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]", "duration": 10, "folderUid": "default", "uid": "%s", @@ -270,7 +270,7 @@ func TestIntegrationPrometheusRules(t *testing.T) { }, { "state": "inactive", "name": "AlwaysFiringButSilenced", - "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]", + "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]", "folderUid": "default", "uid": "%s", "health": "ok", @@ -317,7 +317,7 @@ func TestIntegrationPrometheusRules(t *testing.T) { "rules": [{ "state": "inactive", "name": "AlwaysFiring", - "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]", + "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]", "duration": 10, "folderUid": "default", "uid": "%s", @@ -335,7 +335,7 @@ func TestIntegrationPrometheusRules(t *testing.T) { }, { "state": "inactive", "name": "AlwaysFiringButSilenced", - "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]", + "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]", "folderUid": "default", "uid": "%s", "health": "ok", @@ -639,7 +639,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) { "name": "AlwaysFiring", "uid": "%s", "folderUid": "default", - "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]", + "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]", "duration": 10, "keepFiringFor": 15, "annotations": { @@ -656,7 +656,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) { "name": "AlwaysFiringButSilenced", "uid": "%s", "folderUid": "default", - "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]", + "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]", "health": "ok", "isPaused": false, "type": "alerting", @@ -688,7 +688,7 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) { "name": "AlwaysFiring", "uid": "%s", "folderUid": "default", - "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]", + "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"__expr__\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"refId\":\"A\",\"type\":\"math\"}}]", "duration": 10, "keepFiringFor": 15, "annotations": { diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index 18eb018b336..9e8855d8f19 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -1166,6 +1166,7 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) { "expression": "2 + 3 \u003e 1", "intervalMs": 1000, "maxDataPoints": 43200, + "refId": "A", "type": "math" } }], @@ -1209,6 +1210,7 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) { "expression": "2 + 3 \u003e 1", "intervalMs": 1000, "maxDataPoints": 43200, + "refId": "A", "type": "math" } }], @@ -1264,6 +1266,7 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) { "expression": "2 + 3 \u003e 1", "intervalMs": 1000, "maxDataPoints": 43200, + "refId": "A", "type": "math" } }], @@ -1610,7 +1613,7 @@ func TestIntegrationRuleCreate(t *testing.T) { To: apimodels.Duration(15 * time.Minute), }, DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{"expression":"1","intervalMs":1000,"maxDataPoints":43200,"type":"math"}`), + Model: json.RawMessage(`{"expression":"1","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}`), }, }, UpdatedBy: &apimodels.UserInfo{ @@ -2681,6 +2684,7 @@ func TestIntegrationQuota(t *testing.T) { "expression":"2 + 4 \u003E 1", "intervalMs":1000, "maxDataPoints":43200, + "refId":"A", "type":"math" } } @@ -2798,6 +2802,7 @@ func TestIntegrationDeleteFolderWithRules(t *testing.T) { "expression": "2 + 3 > 1", "intervalMs": 1000, "maxDataPoints": 43200, + "refId": "A", "type": "math" } } @@ -3285,6 +3290,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { "expression":"2 + 3 \u003e 1", "intervalMs":1000, "maxDataPoints":43200, + "refId":"A", "type":"math" } } @@ -3331,6 +3337,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { "expression":"2 + 3 \u003e 1", "intervalMs":1000, "maxDataPoints":43200, + "refId":"A", "type":"math" } } @@ -3683,6 +3690,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { "expression":"2 + 3 \u003e 1", "intervalMs":1000, "maxDataPoints":43200, + "refId":"A", "type":"math" } } @@ -3729,6 +3737,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { "expression":"2 + 3 \u003e 1", "intervalMs":1000, "maxDataPoints":43200, + "refId":"A", "type":"math" } } @@ -3872,6 +3881,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { "expression":"2 + 3 \u003C 1", "intervalMs":1000, "maxDataPoints":43200, + "refId":"A", "type":"math" } } @@ -3995,6 +4005,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { "expression":"2 + 3 \u003C 1", "intervalMs":1000, "maxDataPoints":43200, + "refId":"A", "type":"math" } } @@ -4093,6 +4104,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { "expression":"2 + 3 \u003C 1", "intervalMs":1000, "maxDataPoints":43200, + "refId":"A", "type":"math" } } diff --git a/pkg/tests/api/alerting/test-data/rulegroup-1-export.json b/pkg/tests/api/alerting/test-data/rulegroup-1-export.json index dbf2ff417b0..18d8b8cea40 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-1-export.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-1-export.json @@ -23,6 +23,7 @@ "expression": "0 \u003e 0", "intervalMs": 1000, "maxDataPoints": 43200, + "refId": "A", "type": "math" } } @@ -55,6 +56,7 @@ "expression": "0 == 0", "intervalMs": 1000, "maxDataPoints": 43200, + "refId": "A", "type": "math" } } diff --git a/pkg/tests/api/alerting/test-data/rulegroup-2-export.json b/pkg/tests/api/alerting/test-data/rulegroup-2-export.json index 5f85d830260..43428689eb1 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-2-export.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-2-export.json @@ -23,6 +23,7 @@ "expression": "0/0", "intervalMs": 1000, "maxDataPoints": 43200, + "refId": "A", "type": "math" } } diff --git a/pkg/tests/api/alerting/test-data/rulegroup-3-export.json b/pkg/tests/api/alerting/test-data/rulegroup-3-export.json index 5a1ba9e0eac..76eda60f08d 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-3-export.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-3-export.json @@ -23,6 +23,7 @@ "expression": "0 \u003e 0", "intervalMs": 1000, "maxDataPoints": 43200, + "refId": "A", "type": "math" } } @@ -54,6 +55,7 @@ "expression": "0 == 0", "intervalMs": 1000, "maxDataPoints": 43200, + "refId": "A", "type": "math" } } diff --git a/pkg/tests/api/shorturl/short_url_test.go b/pkg/tests/api/shorturl/short_url_test.go index cc014662457..e3c4d10c675 100644 --- a/pkg/tests/api/shorturl/short_url_test.go +++ b/pkg/tests/api/shorturl/short_url_test.go @@ -42,7 +42,7 @@ func TestShortURL(t *testing.T) { // Test that the endpoint is accessible with authentication. username, password := "viewer", "viewer" createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ - DefaultOrgRole: string(org.RoleEditor), + DefaultOrgRole: string(org.RoleNone), Password: user.Password(password), Login: username, }) diff --git a/pkg/tests/apis/dashboard/search_test.go b/pkg/tests/apis/dashboard/search_test.go index 129a44aee21..09d23398c3f 100644 --- a/pkg/tests/apis/dashboard/search_test.go +++ b/pkg/tests/apis/dashboard/search_test.go @@ -126,7 +126,7 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) { // 2. Viewer searching with permission=View should find it { - res := callSearch(helper.Org1.Viewer, "permission=View") + res := callSearch(helper.Org1.Viewer, "permission=view") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder @@ -134,12 +134,12 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) { break } } - require.True(t, found, "Viewer should find folder with permission=View") + require.True(t, found, "Viewer should find folder with permission=view") } // 3. Viewer searching with permission=Edit should NOT find it { - res := callSearch(helper.Org1.Viewer, "permission=Edit") + res := callSearch(helper.Org1.Viewer, "permission=edit") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder @@ -147,12 +147,12 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) { break } } - require.False(t, found, "Viewer should NOT find folder with permission=Edit") + require.False(t, found, "Viewer should NOT find folder with permission=edit") } // 4. Editor searching with permission=Edit should find it { - res := callSearch(helper.Org1.Editor, "permission=Edit") + res := callSearch(helper.Org1.Editor, "permission=edit") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder @@ -160,12 +160,12 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) { break } } - require.True(t, found, "Editor should find folder with permission=Edit") + require.True(t, found, "Editor should find folder with permission=edit") } // 5. Editor searching with permission=View should find it (Edit permission includes View) { - res := callSearch(helper.Org1.Editor, "permission=View") + res := callSearch(helper.Org1.Editor, "permission=view") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder @@ -173,7 +173,7 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) { break } } - require.True(t, found, "Editor should find folder with permission=View (Edit includes View)") + require.True(t, found, "Editor should find folder with permission=view (Edit includes View)") } // 6. Editor searching without permission parameter should find it (has Edit access) @@ -191,7 +191,7 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) { // 7. Admin searching with permission=View should find it (Admin has full access) { - res := callSearch(helper.Org1.Admin, "permission=View") + res := callSearch(helper.Org1.Admin, "permission=view") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder @@ -199,12 +199,12 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) { break } } - require.True(t, found, "Admin should find folder with permission=View") + require.True(t, found, "Admin should find folder with permission=view") } // 8. Admin searching with permission=Edit should find it (Admin has full access) { - res := callSearch(helper.Org1.Admin, "permission=Edit") + res := callSearch(helper.Org1.Admin, "permission=edit") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder @@ -212,12 +212,12 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) { break } } - require.True(t, found, "Admin should find folder with permission=Edit") + require.True(t, found, "Admin should find folder with permission=edit") } // 9. Admin searching with permission=Admin should find it (Admin has full access) { - res := callSearch(helper.Org1.Admin, "permission=Admin") + res := callSearch(helper.Org1.Admin, "permission=admin") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder @@ -225,7 +225,7 @@ func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) { break } } - require.True(t, found, "Admin should find folder with permission=Admin") + require.True(t, found, "Admin should find folder with permission=admin") } // 10. Admin searching without permission parameter should find it (has Admin access) diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index b69b5e9e1d1..35c349184bf 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -93,7 +93,18 @@ type K8sTestHelper struct { userSvc user.Service } +type K8sTestHelperOpts struct { + testinfra.GrafanaOpts + // If provided, these users will be used instead of creating new ones + Org1Users *OrgUsers + OrgBUsers *OrgUsers +} + func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { + return NewK8sTestHelperWithOpts(t, K8sTestHelperOpts{GrafanaOpts: opts}) +} + +func NewK8sTestHelperWithOpts(t *testing.T, opts K8sTestHelperOpts) *K8sTestHelper { t.Helper() // Use GRPC server when not configured @@ -111,9 +122,12 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { path = opts.DirPath ) if opts.Dir == "" && opts.DirPath == "" { - dir, path = testinfra.CreateGrafDir(t, opts) + dir, path = testinfra.CreateGrafDir(t, opts.GrafanaOpts) + } + listenerAddress, env, testDB := testinfra.StartGrafanaEnvWithDB(t, dir, path) + if !opts.DisableDBCleanup { + t.Cleanup(testDB.Cleanup) } - listenerAddress, env := testinfra.StartGrafanaEnv(t, dir, path) c := &K8sTestHelper{ env: *env, @@ -143,8 +157,24 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { _ = c.CreateOrg(Org1) _ = c.CreateOrg(Org2) - c.Org1 = c.createTestUsers(Org1) - c.OrgB = c.createTestUsers(Org2) + if opts.Org1Users != nil { + c.Org1 = *opts.Org1Users + c.Org1.Admin.baseURL = listenerAddress + c.Org1.Editor.baseURL = listenerAddress + c.Org1.Viewer.baseURL = listenerAddress + c.Org1.None.baseURL = listenerAddress + } else { + c.Org1 = c.createTestUsers(Org1) + } + if opts.OrgBUsers != nil { + c.OrgB = *opts.OrgBUsers + c.OrgB.Admin.baseURL = listenerAddress + c.OrgB.Editor.baseURL = listenerAddress + c.OrgB.Viewer.baseURL = listenerAddress + c.OrgB.None.baseURL = listenerAddress + } else { + c.OrgB = c.createTestUsers(Org2) + } c.loadAPIGroups() diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index 06bad0ba004..238db87aff4 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -1823,13 +1823,13 @@ { "name": "permission", "in": "query", - "description": "permission needed for the resource (View, Edit, Admin)", + "description": "permission needed for the resource (view, edit, admin)", "schema": { "type": "string", "enum": [ - "View", - "Edit", - "Admin" + "view", + "edit", + "admin" ] } }, diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json index 90e70f63baf..1d5fff1367b 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json @@ -3100,7 +3100,7 @@ } } }, - "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind": { + "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind": { "type": "object", "properties": { "AdhocVariableKind": { @@ -3124,6 +3124,9 @@ "QueryVariableKind": { "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardQueryVariableKind" }, + "SwitchVariableKind": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardSwitchVariableKind" + }, "TextVariableKind": { "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardTextVariableKind" } @@ -3520,7 +3523,7 @@ "description": "Configured template variables.", "type": "array", "items": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKind" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardQueryVariableKindOrTextVariableKindOrConstantVariableKindOrDatasourceVariableKindOrIntervalVariableKindOrCustomVariableKindOrGroupByVariableKindOrAdhocVariableKindOrSwitchVariableKind" } } } @@ -3587,6 +3590,71 @@ } } }, + "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardSwitchVariableKind": { + "type": "object", + "required": [ + "kind", + "spec" + ], + "properties": { + "kind": { + "type": "string", + "default": "" + }, + "spec": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardSwitchVariableSpec" + } + ] + } + } + }, + "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardSwitchVariableSpec": { + "description": "Switch variable specification", + "type": "object", + "required": [ + "name", + "current", + "enabledValue", + "disabledValue", + "hide", + "skipUrlSync" + ], + "properties": { + "current": { + "type": "string", + "default": "" + }, + "description": { + "type": "string" + }, + "disabledValue": { + "type": "string", + "default": "" + }, + "enabledValue": { + "type": "string", + "default": "" + }, + "hide": { + "type": "string", + "default": "" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string", + "default": "" + }, + "skipUrlSync": { + "type": "boolean", + "default": false + } + } + }, "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardTabRepeatOptions": { "type": "object", "required": [ diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index bdfa6ce9490..f99b8f60738 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -4309,6 +4309,10 @@ "type": "string", "default": "" }, + "path": { + "description": "For git, this is the target path", + "type": "string" + }, "target": { "description": "When syncing, where values are saved\n\nPossible enum values:\n - `\"folder\"` Resources will be saved into a folder managed by this repository It will contain a copy of everything from the remote The folder k8s name will be the same as the repository k8s name\n - `\"instance\"` Resources are saved in the global context Only one repository may specify the `instance` target When this exists, the UI will promote writing to the instance repo rather than the grafana database (where possible)", "type": "string", @@ -4335,6 +4339,10 @@ "local" ] }, + "url": { + "description": "For git, this is the target URL", + "type": "string" + }, "workflows": { "description": "The supported workflows", "type": "array", diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go index 273d78c4e99..d7850c52d0a 100644 --- a/pkg/tests/apis/provisioning/repository_test.go +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -136,6 +136,19 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) { return } + for _, i := range settings.Items { + switch i.Type { + case provisioning.LocalRepositoryType: + assert.Equal(collect, i.Path, helper.ProvisioningPath) + case provisioning.GitHubRepositoryType: + assert.Equal(collect, i.URL, "https://github.com/grafana/grafana-git-sync-demo") + assert.Equal(collect, i.Path, "grafana/") + default: + assert.NotEmpty(collect, i.Path) + assert.NotEmpty(collect, i.URL) + } + } + assert.ElementsMatch(collect, []provisioning.RepositoryType{ provisioning.LocalRepositoryType, provisioning.GitHubRepositoryType, diff --git a/pkg/tests/apis/shorturl/shorturl_test.go b/pkg/tests/apis/shorturl/shorturl_test.go index be7023c0c6a..becb0b2b564 100644 --- a/pkg/tests/apis/shorturl/shorturl_test.go +++ b/pkg/tests/apis/shorturl/shorturl_test.go @@ -123,7 +123,7 @@ func TestIntegrationShortURL(t *testing.T) { // Only legacy API should be used, no K8s API interaction func doLegacyOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { client := helper.GetResourceClient(apis.ResourceClientArgs{ - User: helper.Org1.Editor, + User: helper.Org1.None, GVR: gvr, }) @@ -189,7 +189,7 @@ func doDualWriteTests(t *testing.T, helper *apis.K8sTestHelper, mode grafanarest t.Run("Legacy API -> K8s API visibility", func(t *testing.T) { client := helper.GetResourceClient(apis.ResourceClientArgs{ - User: helper.Org1.Editor, + User: helper.Org1.None, GVR: gvr, }) @@ -223,7 +223,7 @@ func doDualWriteTests(t *testing.T, helper *apis.K8sTestHelper, mode grafanarest t.Run("K8s API -> Legacy API visibility", func(t *testing.T) { client := helper.GetResourceClient(apis.ResourceClientArgs{ - User: helper.Org1.Editor, + User: helper.Org1.None, GVR: gvr, }) @@ -259,7 +259,7 @@ func doDualWriteTests(t *testing.T, helper *apis.K8sTestHelper, mode grafanarest t.Run("Redirect functionality", func(t *testing.T) { t.Skip("Skipping redirect functionality tests for now - flaky test") client := helper.GetResourceClient(apis.ResourceClientArgs{ - User: helper.Org1.Editor, + User: helper.Org1.None, GVR: gvr, }) @@ -312,7 +312,7 @@ func doUnifiedOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { t.Run("K8s API CRUD (unified storage only)", func(t *testing.T) { client := helper.GetResourceClient(apis.ResourceClientArgs{ - User: helper.Org1.Editor, + User: helper.Org1.None, GVR: gvr, }) @@ -349,7 +349,7 @@ func doUnifiedOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { t.Run("K8s API validation - invalid paths", func(t *testing.T) { client := helper.GetResourceClient(apis.ResourceClientArgs{ - User: helper.Org1.Editor, + User: helper.Org1.None, GVR: gvr, }) @@ -402,7 +402,7 @@ func doUnifiedOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { t.Run("K8s API validation - valid edge cases", func(t *testing.T) { client := helper.GetResourceClient(apis.ResourceClientArgs{ - User: helper.Org1.Editor, + User: helper.Org1.None, GVR: gvr, }) @@ -441,7 +441,7 @@ func doUnifiedOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { t.Run("Redirect functionality (unified only)", func(t *testing.T) { client := helper.GetResourceClient(apis.ResourceClientArgs{ - User: helper.Org1.Editor, + User: helper.Org1.None, GVR: gvr, }) diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index d95f108a231..25b2d4b177b 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -49,6 +49,12 @@ func StartGrafana(t *testing.T, grafDir, cfgPath string) (string, db.DB) { } func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv) { + addr, env, testDB := StartGrafanaEnvWithDB(t, grafDir, cfgPath) + t.Cleanup(testDB.Cleanup) + return addr, env +} + +func StartGrafanaEnvWithDB(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv, *sqlutil.TestDB) { t.Helper() ctx := context.Background() @@ -93,7 +99,6 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes // Use proper database type based on the environment variable GRAFANA_TEST_DB in tests testDB, err := sqlutil.GetTestDB(sqlutil.GetTestDBType()) require.NoError(t, err) - t.Cleanup(testDB.Cleanup) dbCfg := cfg.Raw.Section("database") dbCfg.Key("type").SetValue(testDB.DriverName) @@ -169,7 +174,7 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes t.Logf("Grafana is listening on %s", addr) - return addr, env + return addr, env, testDB } // CreateGrafDir creates the Grafana directory. @@ -538,6 +543,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { _, err = section.NewKey("max_page_size_bytes", fmt.Sprintf("%d", opts.UnifiedStorageMaxPageSizeBytes)) require.NoError(t, err) } + if opts.DisableDataMigrations { + section, err := getOrCreateSection("unified_storage") + require.NoError(t, err) + _, err = section.NewKey("disable_data_migrations", "true") + require.NoError(t, err) + } if opts.PermittedProvisioningPaths != "" { _, err = pathsSect.NewKey("permitted_provisioning_paths", opts.PermittedProvisioningPaths) require.NoError(t, err) @@ -637,6 +648,8 @@ type GrafanaOpts struct { EnableSCIM bool APIServerRuntimeConfig string DisableControllers bool + DisableDBCleanup bool + DisableDataMigrations bool SecretsManagerEnableDBMigrations bool // Allow creating grafana dir beforehand diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame.go b/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame.go index 2181d832dcf..94fcad61819 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-response-table-frame.go @@ -138,7 +138,7 @@ type KeyValue struct { } var tagsConverter = data.FieldConverter{ - OutputFieldType: data.FieldTypeNullableJSON, + OutputFieldType: data.FieldTypeNullableJSON, //nolint:staticcheck Converter: func(v any) (any, error) { if v == nil { return nil, nil diff --git a/pkg/tsdb/azuremonitor/metrics/url-builder.go b/pkg/tsdb/azuremonitor/metrics/url-builder.go index 32b268eeca5..1f3ef37cabd 100644 --- a/pkg/tsdb/azuremonitor/metrics/url-builder.go +++ b/pkg/tsdb/azuremonitor/metrics/url-builder.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" + "github.com/grafana/grafana-plugin-sdk-go/backend" ) // urlBuilder builds the URL for calling the Azure Monitor API @@ -35,7 +35,7 @@ func (params *urlBuilder) buildResourceURI() (*string, error) { if metricNamespace == nil || *metricNamespace == "" { if params.MetricDefinition == nil || *params.MetricDefinition == "" { - return nil, errorsource.DownstreamError(fmt.Errorf("no metricNamespace or metricDefiniton value provided"), false) + return nil, backend.DownstreamError(fmt.Errorf("no metricNamespace or metricDefiniton value provided")) } metricNamespace = params.MetricDefinition } @@ -47,7 +47,7 @@ func (params *urlBuilder) buildResourceURI() (*string, error) { provider = metricNamespaceArray[0] metricNamespaceArray = metricNamespaceArray[1:] } else { - return nil, errorsource.DownstreamError(fmt.Errorf("metricNamespace is not in the correct format"), false) + return nil, backend.DownstreamError(fmt.Errorf("metricNamespace is not in the correct format")) } var resourceNameArray []string @@ -78,7 +78,7 @@ func (params *urlBuilder) buildResourceURI() (*string, error) { if i < len(resourceNameArray) { urlArray = append(urlArray, namespace, resourceNameArray[i]) } else { - return nil, errorsource.DownstreamError(fmt.Errorf("resourceNameArray does not have enough elements"), false) + return nil, backend.DownstreamError(fmt.Errorf("resourceNameArray does not have enough elements")) } } diff --git a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go index 699ee7055a5..a8d01db97d5 100644 --- a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go +++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go @@ -15,7 +15,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -71,7 +70,7 @@ func (e *AzureResourceGraphDatasource) ExecuteTimeSeriesQuery(ctx context.Contex } res, err := e.executeQuery(ctx, graphQuery, dsInfo, client, url) if err != nil { - errorsource.AddErrorToResponse(query.RefID, result, err) + result.Responses[query.RefID] = backend.ErrorResponseWithErrorSource(err) continue } result.Responses[query.RefID] = *res @@ -158,7 +157,7 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query * res, err := client.Do(req) if err != nil { - return nil, errorsource.DownstreamError(err, false) + return nil, backend.DownstreamError(err) } defer func() { @@ -218,7 +217,11 @@ func (e *AzureResourceGraphDatasource) unmarshalResponse(res *http.Response) (Az }() if res.StatusCode/100 != 2 { - return AzureResourceGraphResponse{}, errorsource.SourceError(backend.ErrorSourceFromHTTPStatus(res.StatusCode), fmt.Errorf("%s. Azure Resource Graph error: %s", res.Status, string(body)), false) + err := fmt.Errorf("%s. Azure Resource Graph error: %s", res.Status, string(body)) + if backend.ErrorSourceFromHTTPStatus(res.StatusCode) == backend.ErrorSourceDownstream { + return AzureResourceGraphResponse{}, backend.DownstreamError(err) + } + return AzureResourceGraphResponse{}, backend.PluginError(err) } var data AzureResourceGraphResponse diff --git a/pkg/tsdb/azuremonitor/time/time-grain.go b/pkg/tsdb/azuremonitor/time/time-grain.go index d89e59bbf96..bc8a02fabef 100644 --- a/pkg/tsdb/azuremonitor/time/time-grain.go +++ b/pkg/tsdb/azuremonitor/time/time-grain.go @@ -6,8 +6,8 @@ import ( "strings" "time" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" - "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" ) // TimeGrain handles conversions between @@ -27,7 +27,7 @@ func CreateISO8601DurationFromIntervalMS(it int64) (string, error) { timeValueString := formatted[0 : len(formatted)-1] timeValue, err := strconv.Atoi(timeValueString) if err != nil { - return "", errorsource.DownstreamError(fmt.Errorf("could not parse interval %q to an ISO 8061 duration: %w", it, err), false) + return "", backend.DownstreamError(fmt.Errorf("could not parse interval %q to an ISO 8061 duration: %w", it, err)) } unit := formatted[len(formatted)-1:] diff --git a/pkg/tsdb/azuremonitor/utils/utils.go b/pkg/tsdb/azuremonitor/utils/utils.go index fd36558886d..b87ee1a2e98 100644 --- a/pkg/tsdb/azuremonitor/utils/utils.go +++ b/pkg/tsdb/azuremonitor/utils/utils.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) @@ -28,7 +28,7 @@ func GetFirstSubscriptionOrDefault(ctx context.Context, dsInfo types.DatasourceI res, err := dsInfo.Services["Azure Monitor"].HTTPClient.Do(request) if err != nil { - return "", errorsource.DownstreamError(fmt.Errorf("failed to retrieve subscriptions: %v", err), false) + return "", backend.DownstreamError(fmt.Errorf("failed to retrieve subscriptions: %v", err)) } defer func() { if err := res.Body.Close(); err != nil { @@ -42,7 +42,7 @@ func GetFirstSubscriptionOrDefault(ctx context.Context, dsInfo types.DatasourceI } if len(subscriptions) == 0 { - return "", errorsource.DownstreamError(fmt.Errorf("no subscriptions found: %v", err), false) + return "", backend.DownstreamError(fmt.Errorf("no subscriptions found: %v", err)) } return subscriptions[0], nil @@ -73,9 +73,12 @@ func ParseSubscriptions(res *http.Response, logger log.Logger) ([]string, error) } func ApplySourceFromError(errorMessage error, err error) error { - var sourceError errorsource.Error - if errors.As(err, &sourceError) { - return errorsource.SourceError(sourceError.Source(), errorMessage, false) + var errorWithSource backend.ErrorWithSource + if errors.As(err, &errorWithSource) { + if errorWithSource.ErrorSource() == backend.ErrorSourcePlugin { + return backend.PluginError(errorMessage) + } + return backend.DownstreamError(errorMessage) } return errorMessage } diff --git a/pkg/tsdb/elasticsearch/response_parser_test.go b/pkg/tsdb/elasticsearch/response_parser_test.go index 0032796cadf..fe913953ed4 100644 --- a/pkg/tsdb/elasticsearch/response_parser_test.go +++ b/pkg/tsdb/elasticsearch/response_parser_test.go @@ -355,7 +355,7 @@ func TestProcessLogsResponse(t *testing.T) { // Correctly detects float64 types require.Equal(t, data.FieldTypeNullableFloat64, frame.Fields[7].Type()) // Correctly detects json types - require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[8].Type()) + require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[8].Type()) //nolint:staticcheck // Correctly flattens fields require.Equal(t, "nested.field.double_nested", frame.Fields[13].Name) require.Equal(t, data.FieldTypeNullableString, frame.Fields[13].Type()) @@ -546,7 +546,7 @@ func TestProcessRawDataResponse(t *testing.T) { // Correctly detects float64 types require.Equal(t, data.FieldTypeNullableFloat64, frame.Fields[5].Type()) // Correctly detects json types - require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[6].Type()) + require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[6].Type()) //nolint:staticcheck // Correctly flattens fields require.Equal(t, "nested.field.double_nested", frame.Fields[11].Name) require.Equal(t, data.FieldTypeNullableString, frame.Fields[11].Type()) @@ -736,7 +736,7 @@ func TestProcessRawDocumentResponse(t *testing.T) { require.Len(t, fields, 1) f := fields[0] - require.Equal(t, data.FieldTypeNullableJSON, f.Type()) + require.Equal(t, data.FieldTypeNullableJSON, f.Type()) //nolint:staticcheck require.Equal(t, 2, f.Len()) v := f.At(0).(*json.RawMessage) @@ -829,7 +829,7 @@ func TestProcessRawDocumentResponse(t *testing.T) { //Fields have the correct length require.Equal(t, 2, frame.Fields[0].Len()) // The only field is the raw document - require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[0].Type()) + require.Equal(t, data.FieldTypeNullableJSON, frame.Fields[0].Type()) //nolint:staticcheck require.Equal(t, "A", frame.Fields[0].Name) }) } diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine.go index 5a19ca859f4..61784b36e51 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine.go +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine.go @@ -368,7 +368,7 @@ func (e *DataSourceHandler) processFrame(frame *data.Frame, qm *dataQueryModel, } var err error - frame, err = sqlutil.ResampleWideFrame(frame, qm.FillMissing, alignedTimeRange, qm.Interval) + frame, err = sqlutil.ResampleWideFrame(frame, qm.FillMissing, alignedTimeRange, qm.Interval) //nolint:staticcheck if err != nil { logger.Error("Failed to resample dataframe", "err", err) frame.AppendNotices(data.Notice{Text: "Failed to resample dataframe", Severity: data.NoticeSeverityWarning}) @@ -748,7 +748,7 @@ func getFieldTypesFromDescriptions(fieldDescriptions []pgconn.FieldDescription, case "timestamptz", "timestamp", "date": fieldTypes[i] = data.FieldTypeNullableTime case "json", "jsonb": - fieldTypes[i] = data.FieldTypeNullableJSON + fieldTypes[i] = data.FieldTypeNullableJSON //nolint:staticcheck default: fieldTypes[i] = data.FieldTypeNullableString } diff --git a/pkg/tsdb/graphite/query.go b/pkg/tsdb/graphite/query.go index 2ab60b21592..c06c6d5af08 100644 --- a/pkg/tsdb/graphite/query.go +++ b/pkg/tsdb/graphite/query.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "golang.org/x/net/html" @@ -250,7 +249,11 @@ func (s *Service) parseResponse(res *http.Response) ([]TargetResponseDTO, error) if res.StatusCode/100 != 2 { graphiteError := parseGraphiteError(res.StatusCode, string(body)) s.logger.Info("Request failed", "status", res.Status, "error", graphiteError, "body", string(body)) - return nil, errorsource.SourceError(backend.ErrorSourceFromHTTPStatus(res.StatusCode), fmt.Errorf("request failed with error: %s", graphiteError), false) + err := fmt.Errorf("request failed with error: %s", graphiteError) + if backend.ErrorSourceFromHTTPStatus(res.StatusCode) == backend.ErrorSourceDownstream { + return nil, backend.DownstreamError(err) + } + return nil, backend.PluginError(err) } var data []TargetResponseDTO diff --git a/pkg/tsdb/influxdb/influxql/converter/converter.go b/pkg/tsdb/influxdb/influxql/converter/converter.go index b4c366bff51..6b338fb3207 100644 --- a/pkg/tsdb/influxdb/influxql/converter/converter.go +++ b/pkg/tsdb/influxdb/influxql/converter/converter.go @@ -151,7 +151,7 @@ func readSeries(iter *sdkjsoniter.Iterator, query *models.Query) *backend.DataRe } if util.GetVisType(query.ResultFormat) != util.TableVisType { for i, v := range valueFields { - if v.Type() == data.FieldTypeNullableJSON { + if v.Type() == data.FieldTypeNullableJSON { //nolint:staticcheck maybeFixValueFieldType(valueFields, data.FieldTypeNullableFloat64, i) } } @@ -189,7 +189,7 @@ func readSeries(iter *sdkjsoniter.Iterator, query *models.Query) *backend.DataRe // also frontend probably will not interpret the nullableJson value for i, f := range rsp.Frames { for j, v := range f.Fields { - if v.Type() == data.FieldTypeNullableJSON { + if v.Type() == data.FieldTypeNullableJSON { //nolint:staticcheck newField := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, 0) newField.Name = v.Name newField.Config = v.Config @@ -301,7 +301,7 @@ func readValues(iter *sdkjsoniter.Iterator, hasTimeColumn bool) (valueFields dat // we don't know the type of the values for this field, yet // so we create a FieldTypeNullableJSON to hold nil values // if that is something else it will be replaced later - unknownField := data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0) + unknownField := data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0) //nolint:staticcheck unknownField.Name = "Value" valueFields = append(valueFields, unknownField) } @@ -335,7 +335,7 @@ func maybeCreateValueField(valueFields data.Fields, expectedType data.FieldType, // or the type of the field in valueFields is nullableJSON // we change the type of the field as expectedType func maybeFixValueFieldType(valueFields data.Fields, expectedType data.FieldType, colIdx int) { - if valueFields[colIdx].Type() == expectedType || valueFields[colIdx].Type() != data.FieldTypeNullableJSON { + if valueFields[colIdx].Type() == expectedType || valueFields[colIdx].Type() != data.FieldTypeNullableJSON { //nolint:staticcheck return } stringField := data.NewFieldFromFieldType(expectedType, 0) @@ -364,7 +364,7 @@ func typeOf(value interface{}) data.FieldType { return data.FieldTypeNullableBool default: slog.Error("unknown influx value type", "value", v) - return data.FieldTypeNullableJSON + return data.FieldTypeNullableJSON //nolint:staticcheck } } @@ -471,7 +471,7 @@ func handleTableFormatValueFields(rsp *backend.DataResponse, valueFields data.Fi } else { ll := valueFields[i].Len() for vi := 0; vi < ll; vi++ { - if valueFields[i].Type() == data.FieldTypeNullableJSON { + if valueFields[i].Type() == data.FieldTypeNullableJSON { //nolint:staticcheck // add nil explicitly. // we don't know if it is a float pointer nil or string pointer nil or etc rsp.Frames[0].Fields[si].Append(nil) diff --git a/pkg/tsdb/influxdb/influxql/converter/converter_test.go b/pkg/tsdb/influxdb/influxql/converter/converter_test.go index 2c5e1deecd5..ac1561afcc2 100644 --- a/pkg/tsdb/influxdb/influxql/converter/converter_test.go +++ b/pkg/tsdb/influxdb/influxql/converter/converter_test.go @@ -38,28 +38,28 @@ func TestMaybeFixValueFieldType(t *testing.T) { }, { name: "should return nullableJson if both are nullableJson", - valueFields: data.Fields{data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0)}, - inputType: data.FieldTypeNullableJSON, + valueFields: data.Fields{data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0)}, //nolint:staticcheck + inputType: data.FieldTypeNullableJSON, //nolint:staticcheck colIdx: 0, - expectedType: data.FieldTypeNullableJSON, + expectedType: data.FieldTypeNullableJSON, //nolint:staticcheck }, { name: "should return nullableString if valueField is nullableJson and input is nullableString", - valueFields: data.Fields{data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0)}, + valueFields: data.Fields{data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0)}, //nolint:staticcheck inputType: data.FieldTypeNullableString, colIdx: 0, expectedType: data.FieldTypeNullableString, }, { name: "should return nullableBool if valueField is nullableJson and input is nullableBool", - valueFields: data.Fields{data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0)}, + valueFields: data.Fields{data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0)}, //nolint:staticcheck inputType: data.FieldTypeNullableBool, colIdx: 0, expectedType: data.FieldTypeNullableBool, }, { name: "should return nullableFloat64 if valueField is nullableJson and input is nullableFloat64", - valueFields: data.Fields{data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0)}, + valueFields: data.Fields{data.NewFieldFromFieldType(data.FieldTypeNullableJSON, 0)}, //nolint:staticcheck inputType: data.FieldTypeNullableFloat64, colIdx: 0, expectedType: data.FieldTypeNullableFloat64, diff --git a/pkg/tsdb/mssql/azure/connection.go b/pkg/tsdb/mssql/azure/connection.go index 40978229b39..1d3c2b46cc2 100644 --- a/pkg/tsdb/mssql/azure/connection.go +++ b/pkg/tsdb/mssql/azure/connection.go @@ -4,14 +4,15 @@ import ( "fmt" "github.com/grafana/grafana-azure-sdk-go/v2/azcredentials" + "github.com/grafana/grafana-azure-sdk-go/v2/azsettings" ) -func GetAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentials, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool) (string, error) { +func GetAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentials, azureSettings *azsettings.AzureSettings, userAssertion string) (string, error) { connStr := "" switch c := azureCredentials.(type) { case *azcredentials.AzureManagedIdentityCredentials: - if azureManagedIdentityClientId != "" { - connStr += fmt.Sprintf("user id=%s;", azureManagedIdentityClientId) + if azureSettings.ManagedIdentityClientId != "" { + connStr += fmt.Sprintf("user id=%s;", azureSettings.ManagedIdentityClientId) } connStr += fmt.Sprintf("fedauth=%s;", "ActiveDirectoryManagedIdentity") @@ -23,7 +24,7 @@ func GetAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentia "ActiveDirectoryApplication", ) case *azcredentials.AzureEntraPasswordCredentials: - if azureEntraPasswordCredentialsEnabled { + if azureSettings.AzureEntraPasswordCredentialsEnabled { connStr += fmt.Sprintf("user id=%s;password=%s;applicationclientid=%s;fedauth=%s;", c.UserId, c.Password, @@ -33,6 +34,12 @@ func GetAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentia } else { return "", fmt.Errorf("azure entra password authentication is not enabled") } + case *azcredentials.AadCurrentUserCredentials: + if userAssertion == "" { + return "", fmt.Errorf("user ID token is empty but required for current user authentication") + } + connStr += fmt.Sprintf("user id=%s;userassertion=%s;password=%s;fedauth=%s;", + azureSettings.UserIdentityTokenEndpoint.ClientId, userAssertion, azureSettings.UserIdentityTokenEndpoint.ClientSecret, "ActiveDirectoryOnBehalfOf") default: return "", fmt.Errorf("unsupported azure authentication type") } diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index ca8595edd2e..77de0b82603 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/grafana/grafana-azure-sdk-go/v2/azsettings" + "github.com/grafana/grafana-azure-sdk-go/v2/azusercontext" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" @@ -43,7 +44,8 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) if err != nil { return nil, err } - return dsHandler.QueryData(ctx, req) + + return dsHandler.QueryData(azusercontext.WithUserFromQueryReq(ctx, req), req) } func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.InstanceFactoryFunc { @@ -53,6 +55,8 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc if err != nil { return nil, err } + pluginCfg := backend.PluginConfigFromContext(ctx) + jsonData := sqleng.JsonData{ MaxOpenConns: sqlCfg.DefaultMaxOpenConns, MaxIdleConns: sqlCfg.DefaultMaxIdleConns, @@ -87,6 +91,7 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc Updated: settings.Updated, UID: settings.UID, DecryptedSecureJSONData: settings.DecryptedSecureJSONData, + OrgID: pluginCfg.OrgID, } userFacingDefaultError, err := grafCfg.UserFacingDefaultError() @@ -117,5 +122,5 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque return nil, err } - return dsHandler.CheckHealth(ctx, req) + return dsHandler.CheckHealth(azusercontext.WithUserFromHealthCheckReq(ctx, req), req) } diff --git a/pkg/tsdb/mssql/sqleng/connection.go b/pkg/tsdb/mssql/sqleng/connection.go index 58a6d014219..7b779b547c8 100644 --- a/pkg/tsdb/mssql/sqleng/connection.go +++ b/pkg/tsdb/mssql/sqleng/connection.go @@ -1,14 +1,14 @@ package sqleng import ( - "context" "database/sql" "fmt" "time" "github.com/grafana/grafana-azure-sdk-go/v2/azcredentials" - "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-azure-sdk-go/v2/azsettings" "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "github.com/grafana/grafana/pkg/tsdb/mssql/azure" "github.com/grafana/grafana/pkg/tsdb/mssql/kerberos" "github.com/grafana/grafana/pkg/tsdb/mssql/utils" @@ -17,7 +17,7 @@ import ( "github.com/microsoft/go-mssqldb/azuread" ) -func newMSSQL(ctx context.Context, driverName string, rowLimit int64, dsInfo DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*sql.DB, error) { +func newMSSQL(driverName string, rowLimit int64, dsInfo DataSourceInfo, cnnstr string, logger log.Logger, proxyClient proxy.Client) (*sql.DB, error) { var connector *mssql.Connector var err error if driverName == "azuresql" { @@ -31,12 +31,6 @@ func newMSSQL(ctx context.Context, driverName string, rowLimit int64, dsInfo Dat return nil, fmt.Errorf("mssql connector creation failed") } - proxyClient, err := settings.ProxyClient(ctx) - if err != nil { - logger.Error("mssql proxy creation failed", "error", err) - return nil, fmt.Errorf("mssql proxy creation failed") - } - if proxyClient.SecureSocksProxyEnabled() { dialer, err := proxyClient.NewSecureSocksProxyContextDialer() if err != nil { @@ -81,7 +75,7 @@ const ( kerberosCredentialCacheFile = "Windows AD: Credential cache file" // #nosec G101 ) -func generateConnectionString(dsInfo DataSourceInfo, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool, azureCredentials azcredentials.AzureCredentials, kerberosAuth kerberos.KerberosAuth, logger log.Logger) (string, error) { +func generateConnectionString(dsInfo DataSourceInfo, azureCredentials azcredentials.AzureCredentials, kerberosAuth kerberos.KerberosAuth, logger log.Logger, azureSettings *azsettings.AzureSettings, userAssertion string) (string, error) { const dfltPort = "0" var addr util.NetworkAddress if dsInfo.URL != "" { @@ -119,7 +113,7 @@ func generateConnectionString(dsInfo DataSourceInfo, azureManagedIdentityClientI switch dsInfo.JsonData.AuthenticationType { case azureAuthentication: - azureCredentialDSNFragment, err := azure.GetAzureCredentialDSNFragment(azureCredentials, azureManagedIdentityClientId, azureEntraPasswordCredentialsEnabled) + azureCredentialDSNFragment, err := azure.GetAzureCredentialDSNFragment(azureCredentials, azureSettings, userAssertion) if err != nil { return "", err } diff --git a/pkg/tsdb/mssql/sqleng/handler_checkhealth.go b/pkg/tsdb/mssql/sqleng/handler_checkhealth.go index 133be66a70a..9a4bddeb686 100644 --- a/pkg/tsdb/mssql/sqleng/handler_checkhealth.go +++ b/pkg/tsdb/mssql/sqleng/handler_checkhealth.go @@ -13,7 +13,15 @@ import ( ) func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { - if err := e.db.Ping(); err != nil { + db, err := e.getDB(ctx) + if err != nil { + logCheckHealthError(ctx, e.dsInfo, err) + if strings.EqualFold(req.PluginContext.User.Role, "Admin") { + return ErrToHealthCheckResult(err) + } + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, err).Error()}, nil + } + if err := db.Ping(); err != nil { logCheckHealthError(ctx, e.dsInfo, err) if strings.EqualFold(req.PluginContext.User.Role, "Admin") { return ErrToHealthCheckResult(err) diff --git a/pkg/tsdb/mssql/sqleng/sql_engine.go b/pkg/tsdb/mssql/sqleng/sql_engine.go index 80e3d29927c..f6997a720c5 100644 --- a/pkg/tsdb/mssql/sqleng/sql_engine.go +++ b/pkg/tsdb/mssql/sqleng/sql_engine.go @@ -2,6 +2,7 @@ package sqleng import ( "context" + "crypto/sha256" "database/sql" "encoding/json" "errors" @@ -16,9 +17,11 @@ import ( "github.com/grafana/grafana-azure-sdk-go/v2/azcredentials" "github.com/grafana/grafana-azure-sdk-go/v2/azsettings" + "github.com/grafana/grafana-azure-sdk-go/v2/azusercontext" "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" + "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" @@ -74,6 +77,7 @@ type DataSourceInfo struct { Updated time.Time UID string DecryptedSecureJSONData map[string]string + OrgID int64 } type DataPluginConfiguration struct { @@ -97,6 +101,8 @@ type DataSourceHandler struct { azureCredentials azcredentials.AzureCredentials kerberosAuth kerberos.KerberosAuth driverName string + proxyClient proxy.Client + dbConnections sync.Map } type QueryJson struct { @@ -142,6 +148,12 @@ func NewQueryDataHandler(ctx context.Context, settings backend.DataSourceInstanc return nil, fmt.Errorf("error getting kerberos settings: %w", err) } + proxyClient, err := settings.ProxyClient(ctx) + if err != nil { + logger.Error("mssql proxy creation failed", "error", err) + return nil, fmt.Errorf("mssql proxy creation failed") + } + queryDataHandler := DataSourceHandler{ queryResultTransformer: &queryResultTransformer, macroEngine: newMssqlMacroEngine(), @@ -154,6 +166,7 @@ func NewQueryDataHandler(ctx context.Context, settings backend.DataSourceInstanc azureCredentials: azureCredentials, kerberosAuth: kerberosAuth, driverName: driverName, + proxyClient: proxyClient, } if len(config.TimeColumnNames) > 0 { @@ -164,18 +177,21 @@ func NewQueryDataHandler(ctx context.Context, settings backend.DataSourceInstanc queryDataHandler.metricColumnTypes = config.MetricColumnTypes } - cnnstr, err := generateConnectionString(config.DSInfo, azureSettings.ManagedIdentityClientId, azureSettings.AzureEntraPasswordCredentialsEnabled, azureCredentials, kerberosAuth, log) - if err != nil { - return nil, err - } + // Every auth method besides Azure AD Current User Identity can use a persistent DB connection + if config.DSInfo.JsonData.AuthenticationType != azureAuthentication || azureCredentials.AzureAuthType() != azcredentials.AzureAuthCurrentUserIdentity { + cnnstr, err := generateConnectionString(config.DSInfo, azureCredentials, kerberosAuth, log, azureSettings, "") + if err != nil { + return nil, err + } - db, err := newMSSQL(ctx, driverName, config.RowLimit, config.DSInfo, cnnstr, log, settings) - if err != nil { - logger.Error("Failed connecting to MSSQL", "err", err) - return nil, err - } + db, err := newMSSQL(driverName, config.RowLimit, config.DSInfo, cnnstr, log, proxyClient) + if err != nil { + logger.Error("Failed connecting to MSSQL", "err", err) + return nil, err + } - queryDataHandler.db = db + queryDataHandler.db = db + } return &queryDataHandler, nil } @@ -192,9 +208,52 @@ func (e *DataSourceHandler) Dispose() { e.log.Error("Failed to dispose db", "error", err) } } + + // Clear any cached user-specific connections + e.dbConnections.Range(func(_, conn interface{}) bool { + _ = conn.(*sql.DB).Close() + return true + }) + e.dbConnections.Clear() + e.log.Debug("DB disposed") } +func (e *DataSourceHandler) getDB(ctx context.Context) (*sql.DB, error) { + e.log.Debug("Getting DB...") + if e.dsInfo.JsonData.AuthenticationType != azureAuthentication || e.azureCredentials.AzureAuthType() != azcredentials.AzureAuthCurrentUserIdentity { + if e.db == nil { + return nil, fmt.Errorf("database connection is not initialized") + } + return e.db, nil + } + + userCtx, ok := azusercontext.GetCurrentUser(ctx) + if !ok { + return nil, fmt.Errorf("failed to get user from context for Azure Current User authentication") + } + cacheKey := fmt.Sprintf("mssql-%d-%x-%x-%s", e.dsInfo.OrgID, sha256.Sum256([]byte(userCtx.User.Email)), sha256.Sum256([]byte(userCtx.IdToken)), e.dsInfo.UID) + + conn, ok := e.dbConnections.Load(cacheKey) + if ok { + return conn.(*sql.DB), nil + } + + cnnstr, err := generateConnectionString(e.dsInfo, e.azureCredentials, e.kerberosAuth, e.log, e.azureSettings, userCtx.IdToken) + if err != nil { + return nil, err + } + + db, err := newMSSQL(e.driverName, e.rowLimit, e.dsInfo, cnnstr, e.log, e.proxyClient) + if err != nil { + logger.Error("Failed connecting to MSSQL", "err", err) + return nil, err + } + e.dbConnections.Store(cacheKey, db) + + return db, nil +} + func (e *DataSourceHandler) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { result := backend.NewQueryDataResponse() ch := make(chan DBDataResponse, len(req.Queries)) @@ -293,7 +352,12 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG return } - rows, err := e.db.QueryContext(queryContext, interpolatedQuery) + db, err := e.getDB(queryContext) + if err != nil { + errAppendDebug("retrieving database connection failed", e.TransformQueryError(logger, err), interpolatedQuery, backend.ErrorSourcePlugin) + return + } + rows, err := db.QueryContext(queryContext, interpolatedQuery) if err != nil { errAppendDebug("db query error", e.TransformQueryError(logger, err), interpolatedQuery, backend.ErrorSourceDownstream) return @@ -310,12 +374,19 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG return } + frame := e.processResponse(qm, rows, interpolatedQuery, errAppendDebug) + + queryResult.dataResponse.Frames = data.Frames{frame} + ch <- queryResult +} + +func (e *DataSourceHandler) processResponse(qm *dataQueryModel, rows *sql.Rows, interpolatedQuery string, errAppendDebug func(string, error, string, backend.ErrorSource)) *data.Frame { // Convert row.Rows to dataframe stringConverters := e.queryResultTransformer.GetConverterList() frame, err := sqlutil.FrameFromRows(rows, e.rowLimit, sqlutil.ToConverters(stringConverters...)...) if err != nil { errAppendDebug("convert frame from rows error", err, interpolatedQuery, backend.ErrorSourcePlugin) - return + return nil } if frame.Meta == nil { @@ -330,21 +401,19 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG // additionally-needed frame data stays intact and is correctly passed to our visulization. if frame.Rows() == 0 { frame.Fields = []*data.Field{} - queryResult.dataResponse.Frames = data.Frames{frame} - ch <- queryResult - return + return frame } if err := convertSQLTimeColumnsToEpochMS(frame, qm); err != nil { errAppendDebug("converting time columns failed", err, interpolatedQuery, backend.ErrorSourcePlugin) - return + return nil } if qm.Format == dataQueryFormatSeries { // time series has to have time column if qm.timeIndex == -1 { errAppendDebug("db has no time column", errors.New("time column is missing; make sure your data includes a time column for time series format or switch to a table format that doesn't require it"), interpolatedQuery, backend.ErrorSourceDownstream) - return + return nil } // Make sure to name the time field 'Time' to be backward compatible with Grafana pre-v8. @@ -362,7 +431,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG var err error if frame, err = convertSQLValueColumnToFloat(frame, i); err != nil { errAppendDebug("convert value to float failed", err, interpolatedQuery, backend.ErrorSourcePlugin) - return + return nil } } @@ -373,7 +442,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG frame, err = data.LongToWide(frame, qm.FillMissing) if err != nil { errAppendDebug("failed to convert long to wide series when converting from dataframe", err, interpolatedQuery, backend.ErrorSourcePlugin) - return + return nil } // Before 8x, a special metric column was used to name time series. The LongToWide transforms that into a metric label on the value field. @@ -400,7 +469,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG } var err error - frame, err = sqlutil.ResampleWideFrame(frame, qm.FillMissing, alignedTimeRange, qm.Interval) + frame, err = sqlutil.ResampleWideFrame(frame, qm.FillMissing, alignedTimeRange, qm.Interval) //nolint:staticcheck if err != nil { logger.Error("Failed to resample dataframe", "err", err) frame.AppendNotices(data.Notice{Text: "Failed to resample dataframe", Severity: data.NoticeSeverityWarning}) @@ -408,8 +477,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG } } - queryResult.dataResponse.Frames = data.Frames{frame} - ch <- queryResult + return frame } // Interpolate provides global macros/substitutions for all sql datasources. diff --git a/pkg/tsdb/mssql/sqleng/sql_engine_test.go b/pkg/tsdb/mssql/sqleng/sql_engine_test.go index c4b21b2b849..8a354fadc2f 100644 --- a/pkg/tsdb/mssql/sqleng/sql_engine_test.go +++ b/pkg/tsdb/mssql/sqleng/sql_engine_test.go @@ -697,7 +697,7 @@ func TestGenerateConnectionString(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - connStr, err := generateConnectionString(tc.dataSource, "", false, nil, tc.kerberosCfg, logger) + connStr, err := generateConnectionString(tc.dataSource, nil, tc.kerberosCfg, logger, nil, "") require.NoError(t, err) assert.Equal(t, tc.expConnStr, connStr) }) diff --git a/pkg/tsdb/mysql/sqleng/sql_engine.go b/pkg/tsdb/mysql/sqleng/sql_engine.go index 350c038b3c0..2d628b5c7f9 100644 --- a/pkg/tsdb/mysql/sqleng/sql_engine.go +++ b/pkg/tsdb/mysql/sqleng/sql_engine.go @@ -357,7 +357,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG } var err error - frame, err = sqlutil.ResampleWideFrame(frame, qm.FillMissing, alignedTimeRange, qm.Interval) + frame, err = sqlutil.ResampleWideFrame(frame, qm.FillMissing, alignedTimeRange, qm.Interval) //nolint:staticcheck if err != nil { logger.Error("Failed to resample dataframe", "err", err) frame.AppendNotices(data.Notice{Text: "Failed to resample dataframe", Severity: data.NoticeSeverityWarning}) diff --git a/pkg/util/xorm/session.go b/pkg/util/xorm/session.go index b174b8e4ed9..03edf4b2983 100644 --- a/pkg/util/xorm/session.go +++ b/pkg/util/xorm/session.go @@ -7,6 +7,7 @@ package xorm import ( "context" "database/sql" + "errors" "fmt" "hash/crc32" "reflect" @@ -43,7 +44,7 @@ type Session struct { afterProcessors []executedProcessor prepareStmt bool - stmtCache map[uint32]*core.Stmt //key: hash.Hash32 of (queryStr, len(queryStr)) + stmtCache map[uint32]*core.Stmt // key: hash.Hash32 of (queryStr, len(queryStr)) // !evalphobia! stored the last executed query on this session lastSQL string @@ -236,6 +237,14 @@ func (session *Session) DB() *core.DB { return session.db } +// Tx returns the underlying transaction +func (session *Session) Tx() (*core.Tx, error) { + if session.tx == nil { + return nil, errors.New("no open transaction") + } + return session.tx, nil +} + func cleanupProcessorsClosures(slices *[]func(any)) { if len(*slices) > 0 { *slices = make([]func(any), 0) diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index c976dcb9e7d..054cf609e40 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -8744,17 +8744,10 @@ "UpdateTeamCommand": { "type": "object", "properties": { - "Email": { + "email": { "type": "string" }, - "ExternalUID": { - "type": "string" - }, - "ID": { - "type": "integer", - "format": "int64" - }, - "Name": { + "name": { "type": "string" } } diff --git a/public/api-merged.json b/public/api-merged.json index 0d9c4129adc..12dd95170cf 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -10117,6 +10117,12 @@ "name": "team_id", "in": "path", "required": true + }, + { + "type": "boolean", + "default": false, + "name": "accesscontrol", + "in": "query" } ], "responses": { @@ -23164,17 +23170,10 @@ "UpdateTeamCommand": { "type": "object", "properties": { - "Email": { + "email": { "type": "string" }, - "ExternalUID": { - "type": "string" - }, - "ID": { - "type": "integer", - "format": "int64" - }, - "Name": { + "name": { "type": "string" } } diff --git a/public/app/api/clients/iam/v0alpha1/index.ts b/public/app/api/clients/iam/v0alpha1/index.ts index 190f7ede868..f54e38f7e15 100644 --- a/public/app/api/clients/iam/v0alpha1/index.ts +++ b/public/app/api/clients/iam/v0alpha1/index.ts @@ -2,7 +2,5 @@ import { generatedAPI } from '@grafana/api-clients/rtkq/iam/v0alpha1'; export const iamAPIv0alpha1 = generatedAPI.enhanceEndpoints({}); -export const { useGetDisplayMappingQuery, useLazyGetDisplayMappingQuery } = iamAPIv0alpha1; - // eslint-disable-next-line no-barrel-files/no-barrel-files -export type { DisplayList } from '@grafana/api-clients/rtkq/iam/v0alpha1'; +export * from '@grafana/api-clients/rtkq/iam/v0alpha1'; diff --git a/public/app/core/components/Animations/FadeIn.tsx b/public/app/core/components/Animations/FadeIn.tsx index 80a12637f18..9570f7255e9 100644 --- a/public/app/core/components/Animations/FadeIn.tsx +++ b/public/app/core/components/Animations/FadeIn.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, useRef } from 'react'; +import { CSSProperties, useRef, type JSX } from 'react'; import Transition, { ExitHandler } from 'react-transition-group/Transition'; interface Props { diff --git a/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx b/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx index 96d7d68d8ad..9f3e37555d6 100644 --- a/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx +++ b/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { PluginExtensionPoints } from '@grafana/data'; import { config, renderLimitedComponents, usePluginComponents } from '@grafana/runtime'; import { useGrafana } from 'app/core/context/GrafanaContext'; diff --git a/public/app/core/components/AppChrome/MegaMenu/FeatureHighlight.tsx b/public/app/core/components/AppChrome/MegaMenu/FeatureHighlight.tsx index 6678e57a2ec..ca660e8750c 100644 --- a/public/app/core/components/AppChrome/MegaMenu/FeatureHighlight.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/FeatureHighlight.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/core/components/AppNotifications/AppNotificationList.test.tsx b/public/app/core/components/AppNotifications/AppNotificationList.test.tsx index 61500e0cd4c..3c3eb04a44d 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.test.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.test.tsx @@ -98,6 +98,49 @@ describe('AppNotificationList', () => { }); }); + describe('Event listener cleanup', () => { + let onSpy: jest.SpyInstance; + let offSpy: jest.SpyInstance; + + const eventTypes = [AppEvents.alertWarning, AppEvents.alertSuccess, AppEvents.alertError, AppEvents.alertInfo]; + + beforeEach(() => { + onSpy = jest.spyOn(appEvents, 'on'); + offSpy = jest.spyOn(appEvents, 'off'); + }); + + afterEach(() => { + onSpy.mockRestore(); + offSpy.mockRestore(); + }); + + it('should register event listeners on mount', () => { + renderWithContext(); + + expect(onSpy).toHaveBeenCalledTimes(4); + eventTypes.forEach((eventType) => { + expect(onSpy).toHaveBeenCalledWith(eventType, expect.any(Function)); + }); + }); + + it('should unregister event listeners on unmount', () => { + const { unmount } = renderWithContext(); + + const handlers = eventTypes.map((eventType) => { + const handler = onSpy.mock.calls.find((call) => call[0] === eventType)?.[1]; + expect(handler).toBeDefined(); + return { eventType, handler }; + }); + + unmount(); + + expect(offSpy).toHaveBeenCalledTimes(4); + handlers.forEach(({ eventType, handler }) => { + expect(offSpy).toHaveBeenCalledWith(eventType, handler); + }); + }); + }); + describe('Edge cases', () => { it('should show error on dashboard page with uid and slug', async () => { renderWithContext(undefined, '/d/test-uid/test-slug'); diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx index 29cc9a4b0e3..8d5a1d3161e 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -1,8 +1,8 @@ import { css } from '@emotion/css'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; -import { AlertErrorPayload, AppEvents, GrafanaTheme2 } from '@grafana/data'; +import { AlertErrorPayload, AlertPayload, AppEvents, GrafanaTheme2 } from '@grafana/data'; import { useStyles2, Stack } from '@grafana/ui'; import { notifyApp, hideAppNotification } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; @@ -26,25 +26,60 @@ export function AppNotificationList() { const { chrome } = useGrafana(); const location = useLocation(); + // Store location ref to avoid re-registering listeners on route changes + const locationRef = useRef(location); + useEffect(() => { + locationRef.current = location; + }, [location]); + useEffect(() => { // Suppress error notifications in kiosk mode on dashboards. // Kiosk mode is typically used for TV displays which are non-interactive. // Backend errors like "Failed to fetch" cannot be dismissed and would remain visible, // degrading the viewing experience. Other notification types (success, warning, info) // are still shown as they indicate successful operations or important information. - const handleErrorAlert = (payload: AlertErrorPayload) => { - const isKioskDashboard = chrome.state.getValue().kioskMode && location.pathname.startsWith('/d/'); - - if (!isKioskDashboard) { - dispatch(notifyApp(createErrorNotification(...payload))); + const handleErrorAlert = (payload?: AlertErrorPayload) => { + const isKioskDashboard = chrome.state.getValue().kioskMode && locationRef.current.pathname.startsWith('/d/'); + if (isKioskDashboard || !payload) { + return; } + dispatch(notifyApp(createErrorNotification(...payload))); }; - appEvents.on(AppEvents.alertWarning, (payload) => dispatch(notifyApp(createWarningNotification(...payload)))); - appEvents.on(AppEvents.alertSuccess, (payload) => dispatch(notifyApp(createSuccessNotification(...payload)))); + const handleWarningAlert = (payload?: AlertPayload) => { + if (!payload) { + return; + } + dispatch(notifyApp(createWarningNotification(...payload))); + }; + + const handleSuccessAlert = (payload?: AlertPayload) => { + if (!payload) { + return; + } + dispatch(notifyApp(createSuccessNotification(...payload))); + }; + + const handleInfoAlert = (payload?: AlertPayload) => { + if (!payload) { + return; + } + dispatch(notifyApp(createInfoNotification(...payload))); + }; + + appEvents.on(AppEvents.alertWarning, handleWarningAlert); + appEvents.on(AppEvents.alertSuccess, handleSuccessAlert); appEvents.on(AppEvents.alertError, handleErrorAlert); - appEvents.on(AppEvents.alertInfo, (payload) => dispatch(notifyApp(createInfoNotification(...payload)))); - }, [dispatch, chrome, location.pathname]); + appEvents.on(AppEvents.alertInfo, handleInfoAlert); + + return () => { + // Unsubscribe from events on unmount to avoid memory leaks + appEvents.off(AppEvents.alertWarning, handleWarningAlert); + appEvents.off(AppEvents.alertSuccess, handleSuccessAlert); + appEvents.off(AppEvents.alertError, handleErrorAlert); + appEvents.off(AppEvents.alertInfo, handleInfoAlert); + }; + }, [dispatch, chrome]); const onClearAppNotification = (id: string) => { dispatch(hideAppNotification(id)); diff --git a/public/app/core/components/Branding/Branding.tsx b/public/app/core/components/Branding/Branding.tsx index b32199ed4c7..e44eb62658b 100644 --- a/public/app/core/components/Branding/Branding.tsx +++ b/public/app/core/components/Branding/Branding.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { FC } from 'react'; +import { FC, type JSX } from 'react'; import { colorManipulator } from '@grafana/data'; import { useTheme2 } from '@grafana/ui'; diff --git a/public/app/core/components/FolderFilter/FolderFilter.tsx b/public/app/core/components/FolderFilter/FolderFilter.tsx index 1f8f3e5557d..6f2ba1c6c91 100644 --- a/public/app/core/components/FolderFilter/FolderFilter.tsx +++ b/public/app/core/components/FolderFilter/FolderFilter.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useState, type JSX } from 'react'; import { t } from '@grafana/i18n'; import { ComboboxOption, MultiCombobox } from '@grafana/ui'; diff --git a/public/app/core/components/Layers/LayerDragDropList.tsx b/public/app/core/components/Layers/LayerDragDropList.tsx index d90dfdf04af..b2eca9ca2f1 100644 --- a/public/app/core/components/Layers/LayerDragDropList.tsx +++ b/public/app/core/components/Layers/LayerDragDropList.tsx @@ -1,5 +1,6 @@ import { css, cx } from '@emotion/css'; import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; diff --git a/public/app/core/components/Login/LoginCtrl.tsx b/public/app/core/components/Login/LoginCtrl.tsx index 9ea5cba50cf..1a5f3d572e4 100644 --- a/public/app/core/components/Login/LoginCtrl.tsx +++ b/public/app/core/components/Login/LoginCtrl.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useCallback } from 'react'; +import { memo, useState, useCallback, type JSX } from 'react'; import { t } from '@grafana/i18n'; import { FetchError, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; diff --git a/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts b/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts index d852d9c241f..19ceab28b77 100644 --- a/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts +++ b/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts @@ -22,7 +22,13 @@ export function useFoldersQuery({ rootFolderItem, }: UseFoldersQueryProps) { const resultLegacy = useFoldersQueryLegacy({ isBrowsing, openFolders, permission, rootFolderUID, rootFolderItem }); - const resultAppPlatform = useFoldersQueryAppPlatform({ isBrowsing, openFolders, rootFolderUID, rootFolderItem }); + const resultAppPlatform = useFoldersQueryAppPlatform({ + isBrowsing, + openFolders, + permission, + rootFolderUID, + rootFolderItem, + }); // Running the hooks themselves don't have any side effects, so we can just conditionally use one or the other // requestNextPage function from the result diff --git a/public/app/core/components/NestedFolderPicker/useFoldersQueryAppPlatform.ts b/public/app/core/components/NestedFolderPicker/useFoldersQueryAppPlatform.ts index 34d068589c9..ae6c8574adb 100644 --- a/public/app/core/components/NestedFolderPicker/useFoldersQueryAppPlatform.ts +++ b/public/app/core/components/NestedFolderPicker/useFoldersQueryAppPlatform.ts @@ -27,14 +27,14 @@ const collator = new Intl.Collator(); * does not have pagination at the moment. */ -type Props = Omit; export function useFoldersQueryAppPlatform({ isBrowsing, openFolders, /* rootFolderUID: configure which folder to start browsing from */ rootFolderUID, rootFolderItem, -}: Props) { + permission, +}: UseFoldersQueryProps) { const dispatch = useDispatch(); // Keep a list of all request subscriptions so we can unsubscribe from them when the component is unmounted @@ -89,7 +89,7 @@ export function useFoldersQueryAppPlatform({ return; } - const args = { folder: finalParentUid, type: 'folder' } as const; + const args = { folder: finalParentUid, type: 'folder', permission } as const; // Make a request const subscription = dispatch(dashboardAPIv0alpha1.endpoints.getSearch.initiate(args)); @@ -101,7 +101,7 @@ export function useFoldersQueryAppPlatform({ // the subscriptions are saved in a ref so they can be unsubscribed on unmount requestsRef.current = requestsRef.current.concat([subscription]); }, - [state, dispatch] + [state, dispatch, permission] ); // Unsubscribe from all requests when the component is unmounted diff --git a/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx b/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx index 61e3a78d800..4ab642ac551 100644 --- a/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx +++ b/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState, type JSX } from 'react'; import { GrafanaTheme2, PanelPluginMeta, SelectableValue } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/core/components/RolePicker/RolePicker.tsx b/public/app/core/components/RolePicker/RolePicker.tsx index 25cff0889a1..948b8af9506 100644 --- a/public/app/core/components/RolePicker/RolePicker.tsx +++ b/public/app/core/components/RolePicker/RolePicker.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useCallback, useEffect, useState, useRef } from 'react'; +import { FormEvent, useCallback, useEffect, useState, useRef, type JSX } from 'react'; import { OrgRole } from '@grafana/data'; import { ClickOutsideWrapper, Portal, useTheme2 } from '@grafana/ui'; diff --git a/public/app/core/components/RolePicker/RolePickerInput.tsx b/public/app/core/components/RolePicker/RolePickerInput.tsx index 3f8158020c3..9acdbed24d9 100644 --- a/public/app/core/components/RolePicker/RolePickerInput.tsx +++ b/public/app/core/components/RolePicker/RolePickerInput.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { FormEvent, HTMLProps, useEffect, useRef } from 'react'; +import { FormEvent, HTMLProps, useEffect, useRef, type JSX } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/core/components/RolePicker/RolePickerMenu.tsx b/public/app/core/components/RolePicker/RolePickerMenu.tsx index 81c38519ae6..4287d17c979 100644 --- a/public/app/core/components/RolePicker/RolePickerMenu.tsx +++ b/public/app/core/components/RolePicker/RolePickerMenu.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type JSX } from 'react'; import { OrgRole } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/core/components/RolePicker/RolePickerSubMenu.tsx b/public/app/core/components/RolePicker/RolePickerSubMenu.tsx index 3dff3fc88be..1254dac06a4 100644 --- a/public/app/core/components/RolePicker/RolePickerSubMenu.tsx +++ b/public/app/core/components/RolePicker/RolePickerSubMenu.tsx @@ -1,4 +1,5 @@ import { cx } from '@emotion/css'; +import type { JSX } from 'react'; import { Trans, t } from '@grafana/i18n'; import { Button, ScrollContainer, Stack, useStyles2, useTheme2 } from '@grafana/ui'; diff --git a/public/app/core/components/RolePicker/utils.ts b/public/app/core/components/RolePicker/utils.ts index 413671f0090..f386634e307 100644 --- a/public/app/core/components/RolePicker/utils.ts +++ b/public/app/core/components/RolePicker/utils.ts @@ -1,3 +1,4 @@ +import { RoleDto } from 'app/api/clients/legacy'; import { Role } from 'app/types/accessControl'; export const isNotDelegatable = (role: Role) => { @@ -23,9 +24,10 @@ export const addDisplayNameForFixedRole = (role: Role) => { // Adds a display name for use when the list of roles is filtered // If either group or displayName are undefined, we fall back (see RoleMenuOption.tsx) -export const addFilteredDisplayName = (role: Role) => { - if (role.group && role.displayName) { - role.filteredDisplayName = role.group + ':' + role.displayName; - } - return role; +export const addFilteredDisplayName = (role: RoleDto): Role => { + const filteredDisplayName = role.group && role.displayName ? `${role.group}:${role.displayName}` : ''; + return { + ...role, + filteredDisplayName, + }; }; diff --git a/public/app/core/components/Select/OrgPicker.test.tsx b/public/app/core/components/Select/OrgPicker.test.tsx index 38344d1fd8a..4bab94faefa 100644 --- a/public/app/core/components/Select/OrgPicker.test.tsx +++ b/public/app/core/components/Select/OrgPicker.test.tsx @@ -1,5 +1,6 @@ import { screen, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { OrgPicker } from './OrgPicker'; diff --git a/public/app/core/components/TimeSeries/utils.ts b/public/app/core/components/TimeSeries/utils.ts index f81de163b7f..88a82ec299c 100644 --- a/public/app/core/components/TimeSeries/utils.ts +++ b/public/app/core/components/TimeSeries/utils.ts @@ -138,10 +138,26 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ range: () => { const state = builder.getState(); if (state.isPanning) { + if (state.isTimeRangePending) { + const timeRange = getTimeRange(); + const propsFrom = timeRange.from.valueOf(); + const propsTo = timeRange.to.valueOf(); + + const MIN_TIMESPAN_MS = 1; + const fromMatches = Math.abs(propsFrom - state.min) <= MIN_TIMESPAN_MS; + const toMatches = Math.abs(propsTo - state.max) <= MIN_TIMESPAN_MS; + const timeRangeHasUpdated = fromMatches && toMatches; + + if (timeRangeHasUpdated) { + builder.setState({ isPanning: false }); + return [propsFrom, propsTo]; + } + } + return [state.min, state.max]; } - const r = getTimeRange(); - return [r.from.valueOf(), r.to.valueOf()]; + const timeRange = getTimeRange(); + return [timeRange.from.valueOf(), timeRange.to.valueOf()]; }, }); diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index 955f1ea11b2..264c5498be9 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -159,6 +159,24 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ( range: (u) => { const state = builder.getState(); if (state.isPanning) { + if (state.isTimeRangePending) { + const propsRange = coreConfig.xRange(u); + const propsFrom = propsRange[0]; + const propsTo = propsRange[1]; + + if (propsFrom != null && propsTo != null) { + const MIN_TIMESPAN_MS = 1; + const fromMatches = Math.abs(propsFrom - state.min) <= MIN_TIMESPAN_MS; + const toMatches = Math.abs(propsTo - state.max) <= MIN_TIMESPAN_MS; + const timeRangeHasUpdated = fromMatches && toMatches; + + if (timeRangeHasUpdated) { + builder.setState({ isPanning: false }); + return propsRange; + } + } + } + return [state.min, state.max]; } return coreConfig.xRange(u); diff --git a/public/app/core/components/help/HelpModal.tsx b/public/app/core/components/help/HelpModal.tsx index 82f39fac28f..e334d23982e 100644 --- a/public/app/core/components/help/HelpModal.tsx +++ b/public/app/core/components/help/HelpModal.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useMemo } from 'react'; +import { useMemo, type JSX } from 'react'; import { useAssistant } from '@grafana/assistant'; import { FeatureState, GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/core/reducers/root.test.ts b/public/app/core/reducers/root.test.ts deleted file mode 100644 index c9f5d5068bf..00000000000 --- a/public/app/core/reducers/root.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Team } from 'app/types/teams'; - -import { reducerTester } from '../../../test/core/redux/reducerTester'; -import { initialTeamsState, teamsLoaded } from '../../features/teams/state/reducers'; -import { StoreState } from '../../types/store'; -import { cleanUpAction } from '../actions/cleanUp'; - -import { createRootReducer } from './root'; - -describe('rootReducer', () => { - const rootReducer = createRootReducer(); - - describe('when called with any action except cleanUpAction', () => { - it('then it should not clean state', () => { - const teams = [{ id: 1 } as Team]; - const state = { - teams: { ...initialTeamsState }, - } as StoreState; - - reducerTester() - .givenReducer(rootReducer, state) - .whenActionIsDispatched(teamsLoaded({ teams: teams, page: 1, noTeams: false, perPage: 30, totalCount: 1 })) - .thenStatePredicateShouldEqual((resultingState) => { - expect(resultingState.teams).toEqual({ - hasFetched: true, - noTeams: false, - perPage: 30, - totalPages: 1, - query: '', - page: 1, - teams, - }); - return true; - }); - }); - }); - - describe('when called with cleanUpAction', () => { - it('then it should clean state', () => { - const teams = [{ id: 1 }] as Team[]; - const state: StoreState = { - teams: { - hasFetched: true, - query: '', - page: 1, - noTeams: false, - totalPages: 1, - perPage: 30, - teams, - }, - } as StoreState; - - reducerTester() - .givenReducer(rootReducer, state, false, true) - .whenActionIsDispatched( - cleanUpAction({ cleanupAction: (storeState) => (storeState.teams = initialTeamsState) }) - ) - .thenStatePredicateShouldEqual((resultingState) => { - expect(resultingState.teams).toEqual({ ...initialTeamsState }); - return true; - }); - }); - }); -}); diff --git a/public/app/features/admin/ServerStatsCard.tsx b/public/app/features/admin/ServerStatsCard.tsx index 070005e7e3a..872d3844e0b 100644 --- a/public/app/features/admin/ServerStatsCard.tsx +++ b/public/app/features/admin/ServerStatsCard.tsx @@ -1,4 +1,5 @@ import { css, cx } from '@emotion/css'; +import type { JSX } from 'react'; import Skeleton from 'react-loading-skeleton'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/features/alerting/unified/CLAUDE.md b/public/app/features/alerting/unified/CLAUDE.md new file mode 100644 index 00000000000..7778bb08da4 --- /dev/null +++ b/public/app/features/alerting/unified/CLAUDE.md @@ -0,0 +1,432 @@ +# Alerting Squad - Claude Code Configuration + +This file provides context for Claude Code when working on the Grafana Alerting codebase. It contains alerting-specific patterns and references to Grafana's coding standards. + +## Project Context + +**Location**: `public/app/features/alerting/unified/` +**Squad**: Alerting +**Focus**: Frontend development for Grafana's unified alerting system +**Tech Stack**: React, TypeScript, Redux Toolkit, RTK Query, Emotion, Jest, React Testing Library, MSW + +## Grafana Coding Standards + +**IMPORTANT**: Always follow Grafana's official style guides. Do not duplicate standards here - reference the source files: + +### Required Reading + +1. **Frontend Style Guide**: [../../../../contribute/style-guides/frontend.md](../../../../contribute/style-guides/frontend.md) + - Naming conventions, component patterns, TypeScript, exports + - Function declarations for components, callback props with "on" prefix + +2. **Testing Guidelines**: [../../../../contribute/style-guides/testing.md](../../../../contribute/style-guides/testing.md) + - React Testing Library, query priorities, user event setup + +3. **Styling Guide**: [../../../../contribute/style-guides/styling.md](../../../../contribute/style-guides/styling.md) + - Emotion usage, `useStyles2` hook patterns + +4. **Redux Framework**: [../../../../contribute/style-guides/redux.md](../../../../contribute/style-guides/redux.md) + - Redux Toolkit patterns, reducer testing + +5. **Alerting Testing Guide**: [./TESTING.md](./TESTING.md) + - MSW API mocking, permission mocking, data source setup + +### Alerting-Specific Conventions + +**Use @grafana/alerting Package**: + +- **Always check @grafana/alerting for shared components/hooks** before creating new ones: + + ```typescript + // Good - Use exported components/hooks from @grafana/alerting + import { AlertLabel, alertingMatchers } from '@grafana/alerting'; + + // Check what's available before reimplementing + ``` + +**Layout Components**: + +- **Prefer @grafana/ui layout components** over styled divs: + + ```typescript + // Good - Use Box for simple layout/spacing + import { Box } from '@grafana/ui'; + Content + + // Good - Use Stack for flex layouts + import { Stack } from '@grafana/ui'; + +
Item 1
+
Item 2
+
+ + // Avoid - Custom styled divs when layout components exist +
Content
+ ``` + +## Alerting Codebase Structure + +### Key Directories + +- `api/` - RTK Query API slices for data fetching +- `components/` - Feature-specific React components organized by domain +- `hooks/` - Reusable custom hooks for logic and data fetching +- `rule-editor/` - Alert rule creation and editing forms +- `rule-list/` - Alert rules list views (v1 and v2) +- `state/` - Redux state management and context providers +- `utils/` - Utility functions and helpers +- `types/` - TypeScript type definitions +- `mocks/` - MSW mock server setup for testing +- `testSetup/` - Test utilities and configuration + +### Component Domains + +- `alert-groups/` - Alert grouping and filtering +- `contact-points/` - Contact point configuration +- `notification-policies/` - Notification routing policies +- `mute-timings/` - Mute timing windows +- `silences/` - Silence management +- `receivers/` - Receiver configuration +- `templates/` - Notification templates +- `permissions/` - Permission management +- `settings/` - Alertmanager settings + +## State Management Patterns + +### RTK Query (Primary - Preferred) + +**IMPORTANT**: Our direction is to use RTK Query for data fetching, NOT Redux. Do not create new RTKQ endpoints, those should be created manually. + +- API slices in `api/` directory +- Custom base query in `api/alertingApi.ts` +- Automatic caching with 2-minute polling: `RULE_LIST_POLL_INTERVAL_MS` +- Key APIs: `alertRuleApi`, `alertmanagerApi`, `prometheusApi`, `receiversApi` + +**For new features**: Always use RTK Query hooks for data fetching: + +```typescript +import { useGetAlertRulesQuery } from '../api/alertRuleApi'; + +const { data, isLoading, error } = useGetAlertRulesQuery(params); +``` + +### Redux Toolkit (Legacy) + +**Avoid for new features** - Use RTK Query instead + +- Legacy reducers exist in `state/reducers/` +- Use state selectors: `useUnifiedAlertingSelector` +- Only modify if maintaining existing Redux code + +### Context Providers + +- `AlertmanagerContext` - Alertmanager selection state, for managing Alertmanager entities for a specific Alertmanager data source. +- `SettingsContext` - Settings state – used in `public/app/features/alerting/unified/components/settings` +- `WorkbenchContext` - Workbench state used in the alert triage feature `public/app/features/alerting/unified/triage` + +### Forms + +- Use `react-hook-form` (v7) for all forms +- See `rule-editor/alert-rule-form/` for patterns + +## Alerting-Specific Testing Patterns + +See [./TESTING.md](./TESTING.md) for comprehensive testing guide. Key points: + +### API Mocking with MSW + +**REQUIRED**: Use MSW for all API mocking (not `jest.fn()`) – though it's fine to use this function for unit testing. + +```typescript +import { mockApi } from '../mockApi'; + +// Mock common endpoints +mockApi.eval(); // for AlertingQueryRunner +// If helper doesn't exist, add it to mockApi.ts +``` + +**Why MSW?** Forces proper loading state handling, discovers UI issues early + +### Permission Mocking + +**Default: RBAC enabled** (most common user scenario) + +```typescript +import { enableRBAC, grantUserPermissions } from '../mocks'; + +enableRBAC(); // Usually not needed, enabled by default +grantUserPermissions([AccessControlAction.AlertingRuleRead]); +``` + +### Mock Data Factories + +Located in `mocks.ts`: + +```typescript +mockDataSource(); +mockPromAlert(); +mockRulerGrafanaRule(); +mockAlertmanagerAlert(); +mockSilence(); +``` + +### Data Source Setup + +Located in `testSetup/datasources.ts` for data source mocking patterns + +### Test Data Factories + +**Use factories for creating test data** - Don't manually create objects. + +For Kubernetes APIs and new schemas – use the `@grafana/alerting` package. + +Mock factories are defined in `packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/fakes` +MSW handlers in `packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers` + +And there are "scenarios" that combine the two above. An example of such is `packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.ts` and is used for integration tests. + +Additionally alerting uses **`alertingFactory`** from `mocks/server/db` for building test data: + +```typescript +import { alertingFactory } from './mocks/server/db'; +import { mockFolder } from './mocks'; + +// Build a single alerting rule +const alertingRuleBuilder = alertingFactory.ruler.grafana.alertingRule; +const rule = alertingRuleBuilder.build(); + +// Build multiple rules +const rules = alertingRuleBuilder.buildList(6); + +// Override specific fields +const customRule = alertingRuleBuilder.build({ + grafana_alert: { title: 'CPU Alert' }, + labels: { severity: 'critical' }, +}); +``` + +**Common patterns**: + +```typescript +// Alerting rules +alertingFactory.ruler.grafana.alertingRule.build(); +alertingFactory.ruler.grafana.alertingRule.buildList(n); + +// Folders +mockFolder(); // Simple mock function + +// Override fields when building +alertingRuleBuilder.build({ + grafana_alert: { title: 'Custom Title' }, + labels: { key: 'value' }, +}); +``` + +**Benefits**: + +- Consistent test data across tests +- Easy to generate multiple instances with `buildList(n)` +- Override only the fields you care about +- Automatic sequencing (e.g., "Alerting rule 1", "Alerting rule 2") + +**Other mock functions** (from `mocks.ts`): + +```typescript +mockDataSource(); +mockPromAlert(); +mockRulerGrafanaRule(); +mockAlertmanagerAlert(); +mockSilence(); +mockFolder(); +``` + +## Alerting-Specific Patterns + +### Feature Toggles & settings + +A full list of features can be found in `pkg/services/featuremgmt/toggles_gen.csv` – focus on feature toggles owned by `@grafana/alerting-squad`. + +```typescript +import { config } from '@grafana/runtime'; + +if (config.featureToggles.alertingTriage) { + // Render triage view +} +``` + +A common configuration setting would be `unifiedAlertingEnabled` which allows a user to configure Grafana without any alerting UI or backend enabled at all. + +### Data Source Abstractions + +```typescript +import { isGrafanaRulerRule } from '../utils/rules'; + +if (isGrafanaRulerRule(rule)) { + // Grafana-managed +} else { + // External alertmanager +} +``` + +### Access Control (RBAC) + +```typescript +import { useAbilities } from '../hooks/useAbilities'; + +function Component() { + const [_, { can }] = useAbilities(); + const canCreate = can(AccessControlAction.AlertingRuleCreate); + + return canCreate ? : null; +} +``` + +### Key Routes + +Defined in `routes.tsx`: + +- `/alerting` - Home +- `/alerting/list` - Rules list (v1/v2) +- `/alerting/new/:type?` - Create rule +- `/alerting/:id/edit` - Edit rule +- `/alerting/notifications` - Contact points +- `/alerting/routes` - Notification policies + +### Common Hooks + +```typescript +useCombinedRuleNamespaces(); // Combines Prometheus + Ruler rules +useAlertmanagerConfig(); // Fetch alertmanager config +useFolder(); // Folder operations +useUnifiedAlertingSelector(); // Redux state – avoid using +useAbilities(); // Permission checking +``` + +### Link URLs and Navigation + +**IMPORTANT**: Different navigation components require different URL formats. + +#### When to use `createRelativeUrl` + +Use `createRelativeUrl` **only with LinkButton** (and other components that render HTML `` elements): + +```typescript +import { createRelativeUrl } from '@grafana/data'; +import { LinkButton } from '@grafana/ui'; + +// LinkButton renders tag - needs manual subpath prefix + + View Rules + +``` + +**Why?** LinkButton renders a native HTML anchor element, so it doesn't use React Router. You must manually add the subpath prefix using `createRelativeUrl`. + +#### When NOT to use `createRelativeUrl` + +Do **NOT** use `createRelativeUrl` with: + +1. **locationService** - Automatically adds prefix: + +```typescript +import { locationService } from '@grafana/runtime'; + +// locationService uses react-router history - prefix added automatically +locationService.push('/alerting/list'); // ✅ Correct - no createRelativeUrl +locationService.push(createRelativeUrl('/alerting/list')); // ❌ Wrong - double prefix! +``` + +2. **TextLink component** - Automatically adds prefix: + +```typescript +import { TextLink } from '@grafana/ui'; + +// TextLink uses react-router Link - prefix added automatically +View Rules // ✅ Correct +View Rules // ❌ Wrong +``` + +#### Summary + +| Component/Service | Use `createRelativeUrl`? | Reason | +| -------------------- | ------------------------ | --------------------------------------- | +| `LinkButton` | ✅ YES | Renders `` tag (native HTML) | +| `Button` with `href` | ✅ YES | Renders `` tag when href provided | +| `locationService` | ❌ NO | Uses react-router history (auto-prefix) | +| `TextLink` | ❌ NO | Uses react-router Link (auto-prefix) | +| React Router `Link` | ❌ NO | React Router component (auto-prefix) | + +**Rule of thumb**: If it renders a native HTML `` tag, use `createRelativeUrl`. If it uses React Router, don't. + +## Key Libraries + +### Grafana Internal + +- `@grafana/ui` - UI components (Button, Select, Input, etc.) +- `@grafana/data` - Data models and utilities +- `@grafana/runtime` - Runtime services (config, backendSrv, locationService) +- `@grafana/scenes` - Scene framework (Insights/Triage views) +- `@grafana/e2e-selectors` - Test selectors +- `@grafana/alerting` - Grafana managed alerting specific package (utility functions, API endpoints, mocks, React components, etc) + +### External + +- `react-hook-form` (v7) - Form state +- `@reduxjs/toolkit` - Redux + RTK Query +- `@emotion/css` - Styling +- `lodash` - Utilities +- `msw` - API mocking for tests + +## Quick Reference Checklists + +### Creating a New Component + +1. ✅ Create in appropriate `components/` subdirectory +2. ✅ Use function declaration (not arrow function) +3. ✅ Add TypeScript props interface (no "I" prefix) +4. ✅ Use `useStyles2` for styling (Emotion) +5. ✅ Create colocated test file +6. ✅ Use MSW for API mocking in tests +7. ✅ Query with `*ByRole` queries + +### Adding a New API Endpoint + +1. ✅ Add to relevant API slice in `api/` (RTK Query) +2. ✅ Add helper to `mockApi.ts` for testing +3. ✅ Handle loading/error states in UI +4. ✅ Test with MSW + +### Creating a New Form + +1. ✅ Use `react-hook-form` (v7) +2. ✅ See `rule-editor/alert-rule-form/` for patterns +3. ✅ Add validation (schema if needed) +4. ✅ Handle API submission errors +5. ✅ Test user interactions with `userEvent` + +### Writing Tests + +Check https://testing-library.com/docs/queries/about/ for what selectors to prefer when using React Testing Library + +- [ ] RBAC enabled by default +- [ ] MSW for API mocking (not `jest.fn()`) +- [ ] Loading states tested +- [ ] Error states tested +- [ ] User interactions use `userEvent.setup()` +- [ ] Queries prefer `*ByRole` +- [ ] Async operations use `await` and `findBy*` +- [ ] Permissions tested with `grantUserPermissions` + +## Getting Help + +- Check patterns in existing `components/` code +- Review test examples in `*.test.tsx` files +- Consult `mockApi.ts` for API mocking +- See `mocks.ts` for data factories +- Read [./TESTING.md](./TESTING.md) for testing details +- Review Grafana style guides (linked at top) + +--- + +**Last Updated**: 2025-11-21 +**Maintained By**: Alerting Squad diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index dfe3549eeee..dac15642d7b 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -28,6 +28,9 @@ import { Annotation } from './utils/constants'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; jest.mock('./api/ruler'); +jest.mock('@grafana/assistant', () => ({ + useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }), +})); jest.spyOn(alertingAbilities, 'useAlertRuleAbility'); const prometheusModuleSettings = { alerting: true, module: 'core:plugin/prometheus' }; diff --git a/public/app/features/alerting/unified/RedirectToRuleViewer.tsx b/public/app/features/alerting/unified/RedirectToRuleViewer.tsx index 96410c28320..bbcd025d1db 100644 --- a/public/app/features/alerting/unified/RedirectToRuleViewer.tsx +++ b/public/app/features/alerting/unified/RedirectToRuleViewer.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useMemo } from 'react'; +import { type JSX, useMemo } from 'react'; import { Navigate } from 'react-router-dom-v5-compat'; import { useLocation } from 'react-use'; diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index f58aadb9afd..3849c4708aa 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -45,6 +45,9 @@ jest.mock('@grafana/runtime', () => ({ jest.mock('./api/buildInfo'); jest.mock('./api/prometheus'); jest.mock('./api/ruler'); +jest.mock('@grafana/assistant', () => ({ + useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }), +})); jest.spyOn(actions, 'rulesInSameGroupHaveInvalidFor').mockReturnValue([]); jest.spyOn(apiRuler, 'rulerUrlBuilder'); diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx index 4ff786b2bc4..e5bce1433f2 100644 --- a/public/app/features/alerting/unified/Silences.test.tsx +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -1,3 +1,4 @@ +import { HttpResponse, http } from 'msw'; import { Route, Routes } from 'react-router-dom-v5-compat'; import { render, screen, userEvent, waitFor, within } from 'test/test-utils'; import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector'; @@ -154,6 +155,28 @@ describe('Silences', () => { TEST_TIMEOUT ); + it( + 'fetches silenced alerts with correct filter parameters', + async () => { + let capturedParams: URLSearchParams | undefined; + server.use( + http.get('/api/alertmanager/:datasourceUid/api/v2/alerts', ({ request }) => { + capturedParams = new URL(request.url).searchParams; + return HttpResponse.json([]); + }) + ); + + renderSilences(); + + await waitFor(() => expect(capturedParams).toBeDefined()); + + expect(capturedParams?.get('silenced')).toBe('true'); + expect(capturedParams?.get('active')).toBe('false'); + expect(capturedParams?.get('inhibited')).toBe('false'); + }, + TEST_TIMEOUT + ); + it( 'shows the correct number of silenced alerts', async () => { diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index 565a50e46ad..a8e4279de7f 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -39,6 +39,7 @@ export type GrafanaPromRulesOptions = Omit; +} + +function AnalyzeRuleButtonView({ + rule, + openAssistant, +}: AnalyzeRuleButtonProps & { + openAssistant: (props: OpenAssistantProps) => void; +}) { + // Create alert rule context from alert rule data + const alertContext = useMemo(() => { + return createAssistantContextItem('structured', { + title: `Alert: ${rule.name}`, + data: { + rule: { + name: rule.name, + uid: rule.uid, + labels: rule.labels, + query: rule.query, + }, + }, + }); + }, [rule]); + + // Generate default prompt + const analyzeRulePrompt = useMemo(() => buildAnalyzeRulePrompt(rule), [rule]); + + const handleClick = () => { + reportInteraction('grafana_assistant_app_analyze_rule_button_clicked', { + origin: 'alerting', + alertName: rule.name, + alertState: prometheusRuleType.grafana.alertingRule(rule) ? rule.state : undefined, + }); + + openAssistant({ + origin: 'alerting', + mode: 'assistant', + prompt: analyzeRulePrompt, + context: [alertContext], + autoSend: true, + }); + }; + + return ( + + ); +} + +/** + * Builds a prompt for analyzing a rule (alerting or recording). + * Automatically detects the rule type and uses the appropriate prompt builder. + */ +function buildAnalyzeRulePrompt(rule: GrafanaRule): string { + if (prometheusRuleType.grafana.alertingRule(rule)) { + return buildAnalyzeAlertingRulePrompt(rule); + } else if (prometheusRuleType.grafana.recordingRule(rule)) { + return buildAnalyzeRecordingRulePrompt(rule); + } + // Fallback (should not happen for GrafanaRule, but TypeScript requires it) + return `Analyze the rule "${rule.name}".`; +} + +/** + * Builds a prompt for analyzing an alerting rule. + * Includes state, activeAt timestamp, annotations, and labels. + */ +function buildAnalyzeAlertingRulePrompt(rule: GrafanaAlertingRule): string { + const state = rule.state || 'firing'; + const timeInfo = rule.activeAt ? ` starting at ${new Date(rule.activeAt).toISOString()}` : ''; + + let prompt = `Analyze the ${state} alert "${rule.name}"${timeInfo}.`; + + const description = rule.annotations?.description || rule.annotations?.summary || ''; + if (description) { + prompt += ` ${description}`; + } + + const labelsStr = rule.labels + ? Object.entries(rule.labels) + .map(([k, v]) => `${k}="${v}"`) + .join(', ') + : ''; + if (labelsStr) { + prompt += ` Labels: ${labelsStr}.`; + } + + return prompt; +} + +/** + * Builds a prompt for analyzing a recording rule. + * Includes name, query, and labels (no state or activeAt). + */ +function buildAnalyzeRecordingRulePrompt(rule: GrafanaRecordingRule): string { + const labelsStr = rule.labels + ? Object.entries(rule.labels) + .map(([k, v]) => `${k}="${v}"`) + .join(', ') + : ''; + + let prompt = `Analyze the recording rule "${rule.name}".`; + + if (labelsStr) { + prompt += ` Labels: ${labelsStr}.`; + } + + return prompt; +} diff --git a/public/app/features/alerting/unified/components/common/DetailText.tsx b/public/app/features/alerting/unified/components/common/DetailText.tsx index f35c0765a60..296c5c79818 100644 --- a/public/app/features/alerting/unified/components/common/DetailText.tsx +++ b/public/app/features/alerting/unified/components/common/DetailText.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { t } from '@grafana/i18n'; import { Box, ClipboardButton, Stack, Text, Tooltip } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx index 1825b5e40a0..0fb1403cb35 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { Fragment, useState } from 'react'; +import { Fragment, type JSX, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx index 75413e20e33..9bc321a40a6 100644 --- a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx @@ -1,4 +1,5 @@ import 'core-js/stable/structured-clone'; +import type { JSX } from 'react'; import { Route, Routes } from 'react-router-dom-v5-compat'; import { clickSelectOption } from 'test/helpers/selectOptionInTest'; import { render, screen, within } from 'test/test-utils'; diff --git a/public/app/features/alerting/unified/components/contact-points/useExportContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/useExportContactPoint.tsx index 3dcb8753ccc..3638497e2a7 100644 --- a/public/app/features/alerting/unified/components/contact-points/useExportContactPoint.tsx +++ b/public/app/features/alerting/unified/components/contact-points/useExportContactPoint.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { type JSX, useCallback, useMemo, useState } from 'react'; import { useToggle } from 'react-use'; import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; diff --git a/public/app/features/alerting/unified/components/expressions/Expression.tsx b/public/app/features/alerting/unified/components/expressions/Expression.tsx index 79f805d52ad..0e5d5fd4b79 100644 --- a/public/app/features/alerting/unified/components/expressions/Expression.tsx +++ b/public/app/features/alerting/unified/components/expressions/Expression.tsx @@ -48,7 +48,6 @@ interface ExpressionProps { onSetCondition: (refId: string) => void; onUpdateRefId: (oldRefId: string, newRefId: string) => void; onRemoveExpression: (refId: string) => void; - onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void; onChangeQuery: (query: ExpressionQuery) => void; } @@ -62,7 +61,6 @@ export const Expression: FC = ({ onSetCondition, onUpdateRefId, onRemoveExpression, - onUpdateExpressionType, // this method is not used? maybe we should remove it onChangeQuery, }) => { const styles = useStyles2(getStyles); diff --git a/public/app/features/alerting/unified/components/extensions/AlertingRuleExtensionPointMenu.tsx b/public/app/features/alerting/unified/components/extensions/AlertingRuleExtensionPointMenu.tsx index 20ef95fdb22..ef7f9411a45 100644 --- a/public/app/features/alerting/unified/components/extensions/AlertingRuleExtensionPointMenu.tsx +++ b/public/app/features/alerting/unified/components/extensions/AlertingRuleExtensionPointMenu.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useMemo } from 'react'; +import { type JSX, ReactElement, useMemo } from 'react'; import { PluginExtensionLink } from '@grafana/data'; import { Menu } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/mute-timings/useExportMuteTimingsDrawer.tsx b/public/app/features/alerting/unified/components/mute-timings/useExportMuteTimingsDrawer.tsx index 3d65d91aa9a..b9cc9d1af55 100644 --- a/public/app/features/alerting/unified/components/mute-timings/useExportMuteTimingsDrawer.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/useExportMuteTimingsDrawer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { type JSX, useCallback, useMemo, useState } from 'react'; import { useToggle } from 'react-use'; import { GrafanaMuteTimingsExporter } from '../export/GrafanaMuteTimingsExporter'; diff --git a/public/app/features/alerting/unified/components/notification-policies/Modals.tsx b/public/app/features/alerting/unified/components/notification-policies/Modals.tsx index d3187c41a7e..20c222749fc 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Modals.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Modals.tsx @@ -1,5 +1,5 @@ import { groupBy } from 'lodash'; -import { FC, useCallback, useMemo, useState } from 'react'; +import { FC, type JSX, useCallback, useMemo, useState } from 'react'; import { Trans, t } from '@grafana/i18n'; import { Button, Icon, Modal, ModalProps, Spinner, Stack } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx index f27d5503d12..a10bca42100 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.test.tsx @@ -1,6 +1,7 @@ import { renderHook, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { first, noop } from 'lodash'; +import type { JSX } from 'react'; import { Route, Routes } from 'react-router-dom-v5-compat'; import { render } from 'test/test-utils'; diff --git a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx index 3f80f6cd902..c4b6a0c55b7 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { isArray, sumBy, uniqueId } from 'lodash'; import pluralize from 'pluralize'; import * as React from 'react'; -import { FC, Fragment, ReactNode, useState } from 'react'; +import { FC, Fragment, type JSX, ReactNode, useState } from 'react'; import { useToggle } from 'react-use'; import { AlertLabel, getInheritedProperties } from '@grafana/alerting'; diff --git a/public/app/features/alerting/unified/components/permissions/ManagePermissions.tsx b/public/app/features/alerting/unified/components/permissions/ManagePermissions.tsx index 33ebf71d59a..8ba91403352 100644 --- a/public/app/features/alerting/unified/components/permissions/ManagePermissions.tsx +++ b/public/app/features/alerting/unified/components/permissions/ManagePermissions.tsx @@ -1,4 +1,4 @@ -import { ComponentProps, useState } from 'react'; +import { ComponentProps, type JSX, useState } from 'react'; import { Trans, t } from '@grafana/i18n'; import { Button, Drawer } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx b/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx index 4252cfde654..239b8e9abf4 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/css'; import * as React from 'react'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; diff --git a/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx b/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx index fd38b545945..e4e1f441860 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx @@ -1,4 +1,4 @@ -import { default as React } from 'react'; +import { type JSX, default as React } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { Provider } from 'react-redux'; import { render, screen, waitFor, within } from 'test/test-utils'; diff --git a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx index 5e5ecc1e08b..c4b154809b8 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx @@ -1,6 +1,7 @@ import { css, cx } from '@emotion/css'; import { compact, uniqueId } from 'lodash'; import * as React from 'react'; +import type { JSX } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx index fbdfdbbf493..16042c0e654 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { DeepMap, FieldError, FieldErrors, useFormContext } from 'react-hook-form'; import { Field, SecretInput } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx index 0202e7c2e52..645c6565a01 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { sortBy } from 'lodash'; import * as React from 'react'; -import { useEffect, useMemo } from 'react'; +import { type JSX, useEffect, useMemo } from 'react'; import { Controller, FieldErrors, useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/DeletedSubform.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/DeletedSubform.tsx index bb5537cbbee..092e64f1da4 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/DeletedSubform.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/DeletedSubform.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { type JSX, useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; interface Props { diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx index a8f73230c21..5fb3267432a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx @@ -1,3 +1,4 @@ +import type { JSX } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { render, screen, within } from 'test/test-utils'; import { byRole, byTestId } from 'testing-library-selector'; diff --git a/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx b/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx index 72ec6200ec5..8479b335181 100644 --- a/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { type JSX, useCallback } from 'react'; import { DataSourceInstanceSettings } from '@grafana/data'; import { DataSourcePicker, DataSourcePickerProps } from 'app/features/datasources/components/picker/DataSourcePicker'; diff --git a/public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx b/public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx index 87952c4b19f..bf1a9985ec2 100644 --- a/public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/ExpressionsEditor.tsx @@ -4,7 +4,7 @@ import { useMemo } from 'react'; import { GrafanaTheme2, PanelData } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { isExpressionQuery } from 'app/features/expressions/guards'; -import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; +import { ExpressionQuery } from 'app/features/expressions/types'; import { AlertQuery } from 'app/types/unified-alerting-dto'; import { Expression } from '../expressions/Expression'; @@ -18,7 +18,6 @@ interface Props { queries: AlertQuery[]; onRemoveExpression: (refId: string) => void; onUpdateRefId: (oldRefId: string, newRefId: string) => void; - onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void; onUpdateQueryExpression: (query: ExpressionQuery) => void; } @@ -29,12 +28,15 @@ export const ExpressionsEditor = ({ panelData, onUpdateRefId, onRemoveExpression, - onUpdateExpressionType, onUpdateQueryExpression, }: Props) => { const expressionQueries = useMemo(() => { return queries.reduce((acc: ExpressionQuery[], query) => { - return isExpressionQuery(query.model) ? acc.concat(query.model) : acc; + if (isExpressionQuery(query.model)) { + acc.push(query.model); + } + + return acc; }, []); }, [queries]); const styles = useStyles2(getStyles); @@ -64,7 +66,6 @@ export const ExpressionsEditor = ({ onSetCondition={onSetCondition} onRemoveExpression={onRemoveExpression} onUpdateRefId={onUpdateRefId} - onUpdateExpressionType={onUpdateExpressionType} onChangeQuery={onUpdateQueryExpression} /> ); diff --git a/public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx b/public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx index 04b28bd3bf4..0be96f0bdc2 100644 --- a/public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index 844443a4dff..d19ac4bb619 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -73,7 +73,6 @@ import { updateExpression, updateExpressionRefId, updateExpressionTimeRange, - updateExpressionType, } from './reducer'; import { useAdvancedMode } from './useAdvancedMode'; import { useAlertQueryRunner } from './useAlertQueryRunner'; @@ -591,9 +590,6 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod dispatch(removeExpression(refId)); }} onUpdateRefId={onUpdateRefId} - onUpdateExpressionType={(refId, type) => { - dispatch(updateExpressionType({ refId, type })); - }} onUpdateQueryExpression={(model) => { dispatch(updateExpression(model)); }} diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap index 292a6904f80..33f3b0ea4e8 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap @@ -442,55 +442,3 @@ exports[`Query and expressions reducer should update an expression refId and rew ], } `; - -exports[`Query and expressions reducer should update expression type 1`] = ` -{ - "queries": [ - { - "datasourceUid": "abc123", - "model": { - "refId": "A", - }, - "queryType": "query", - "refId": "A", - }, - { - "datasourceUid": "__expr__", - "model": { - "conditions": [ - { - "evaluator": { - "params": [ - 0, - 0, - ], - "type": "gt", - }, - "operator": { - "type": "and", - }, - "query": { - "params": [], - }, - "reducer": { - "params": [], - "type": "avg", - }, - "type": "query", - }, - ], - "datasource": { - "name": "Expression", - "type": "__expr__", - "uid": "__expr__", - }, - "expression": "", - "refId": "B", - "type": "reduce", - }, - "queryType": "", - "refId": "B", - }, - ], -} -`; diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx index 31858408509..a4a214b8348 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx @@ -22,7 +22,6 @@ import { updateExpression, updateExpressionRefId, updateExpressionTimeRange, - updateExpressionType, } from './reducer'; const reduceExpression: AlertQuery = { @@ -388,22 +387,6 @@ describe('Query and expressions reducer', () => { expect(newState).toMatchSnapshot(); }); - - it('should update expression type', () => { - const initialState: QueriesAndExpressionsState = { - queries: [alertQuery, expressionQuery], - }; - - const newState = queriesAndExpressionsReducer( - initialState, - updateExpressionType({ - refId: 'B', - type: ExpressionQueryType.reduce, - }) - ); - - expect(newState).toMatchSnapshot(); - }); it('should remove first reducer', () => { const initialState: QueriesAndExpressionsState = { queries: [alertQuery, reduceExpression, thresholdExpression], diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts index 372e028bbcc..1514533cce0 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts @@ -283,23 +283,6 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder queryType: 'expression', }); } - }) - .addCase(updateExpressionType, (state, action) => { - state.queries = state.queries.map((query) => { - return query.refId === action.payload.refId - ? { - ...query, - model: { - ...expressionDatasource.newQuery({ - type: action.payload.type, - conditions: [{ ...defaultCondition, query: { params: [] } }], - expression: '', - }), - refId: action.payload.refId, - }, - } - : query; - }); }); }); diff --git a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx index a36a1fada67..a473eae60a1 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx @@ -1,5 +1,6 @@ import { PropsOf } from '@emotion/react'; +import { useAssistant } from '@grafana/assistant'; import { AppEvents } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; @@ -13,7 +14,9 @@ import { PromAlertingRuleState, RulerRuleDTO } from 'app/types/unified-alerting- import { AlertRuleAction, + EnrichmentAction, skipToken, + useEnrichmentAbility, useGrafanaPromRuleAbilities, useRulerRuleAbilities, } from '../../hooks/useAbilities'; @@ -27,6 +30,7 @@ import { rulerRuleType, } from '../../utils/rules'; import { createRelativeUrl } from '../../utils/url'; +import { AnalyzeRuleButton } from '../assistant/AnalizeRuleButton'; import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton'; interface Props { @@ -112,6 +116,8 @@ const AlertRuleMenu = ({ const extensionsAvailable = ruleExtensionLinks.length > 0; + const [enrichmentReadSupported, enrichmentReadAllowed] = useEnrichmentAbility(EnrichmentAction.Read); + /** * Since Incident isn't available as an open-source product we shouldn't show it for Open-Source licenced editions of Grafana. * We should show it in development mode @@ -122,6 +128,9 @@ const AlertRuleMenu = ({ prometheusRuleType.alertingRule(promRule) && promRule.state === PromAlertingRuleState.Firing; + const { isAvailable: isAssistantAvailable } = useAssistant(); + const shouldShowAnalyzeRuleButton = isAssistantAvailable && prometheusRuleType.grafana.rule(promRule); + const shareUrl = createShareLink(identifier); const showDivider = @@ -139,7 +148,8 @@ const AlertRuleMenu = ({ ruleUid && handleManageEnrichments && config.featureToggles.alertingEnrichmentPerRule && - config.featureToggles.alertEnrichment; + enrichmentReadSupported && + enrichmentReadAllowed; const menuItems = ( <> @@ -167,6 +177,7 @@ const AlertRuleMenu = ({ )} {/* TODO Migrate Declare Incident to plugin links extensions */} {shouldShowDeclareIncidentButton && } + {shouldShowAnalyzeRuleButton && } {canDuplicate && ( ({ + useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }), +})); + // metadata and interactive elements const ELEMENTS = { loading: byText(/Loading rule/i), @@ -86,6 +94,18 @@ const openSilenceDrawer = async () => { await screen.findByText(/Configure silences/i); }; +beforeAll(() => { + // Register the enrichment tab for all tests + addEnrichmentSection(); +}); + +afterEach(() => { + // Clear tabs after each test to prevent interference + __clearRuleViewTabsForTests(); + // Re-register for next test + addEnrichmentSection(); +}); + beforeEach(() => { grantPermissionsHelper([ AccessControlAction.AlertingRuleCreate, @@ -484,6 +504,34 @@ describe('RuleViewer', () => { ); expect(screen.getByTestId('enrichment-section')).toBeInTheDocument(); }); + + it('should show enrichment tab when user has enrichments:read permission', async () => { + grantPermissionsHelper([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingEnrichmentsRead]); + + await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.Query); + + // Check if enrichment tab exists in the navigation + expect(screen.getByText('Alert enrichment')).toBeInTheDocument(); + }); + + it('should hide enrichment tab when user lacks enrichments:read permission', async () => { + grantPermissionsHelper([AccessControlAction.AlertingRuleRead]); + + await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.Query); + + // Check that enrichment tab does not exist in the navigation + expect(screen.queryByText('Alert enrichment')).not.toBeInTheDocument(); + }); + + it('should show enrichment tab for admin users', async () => { + grantPermissionsHelper([AccessControlAction.AlertingRuleRead]); + jest.spyOn(require('../../utils/misc'), 'isAdmin').mockReturnValue(true); + + await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.Query); + + // Admin should see the tab even without explicit permission + expect(screen.getByText('Alert enrichment')).toBeInTheDocument(); + }); }); }); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx index 1f61f10e640..d9c61ca8fed 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerLayout.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/css'; import * as React from 'react'; +import type { JSX } from 'react'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx index c55b01c6d16..93f6b73feab 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { PanelData } from '@grafana/data'; import { VizWrapper } from '../rule-editor/VizWrapper'; diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx index 5de673ccb14..a88d1bef83f 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx @@ -21,6 +21,10 @@ import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { setupDataSources } from '../../testSetup/datasources'; import { fromCombinedRule, stringifyIdentifier } from '../../utils/rule-id'; +jest.mock('@grafana/assistant', () => ({ + useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }), +})); + setupMswServer(); jest.mock('app/core/services/context_srv'); const mockContextSrv = jest.mocked(contextSrv); diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index 206d939811f..68aed9c2dfe 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -1,5 +1,5 @@ import { isString } from 'lodash'; -import { useState } from 'react'; +import { type JSX, useState } from 'react'; import { Trans, t } from '@grafana/i18n'; import { LinkButton, Stack } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx index b2ff856a498..367ca5f5d6a 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx index 28cd663bdeb..8f733cd6df0 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsButtons.tsx @@ -1,4 +1,4 @@ -import { Fragment } from 'react'; +import { Fragment, type JSX } from 'react'; import { textUtil } from '@grafana/data'; import { Trans } from '@grafana/i18n'; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx index 3ba34bf86fa..fc03406cffd 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useMemo } from 'react'; +import { type JSX, useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx index d6d472886d4..3e60213a82f 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsExpression.tsx @@ -1,4 +1,5 @@ import { css, cx } from '@emotion/css'; +import type { JSX } from 'react'; import { t } from '@grafana/i18n'; import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; diff --git a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx index 58cc844d92c..6a35dbe5d0b 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListErrors.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { SerializedError } from '@reduxjs/toolkit'; -import { FC, ReactElement, useMemo, useState } from 'react'; +import { FC, type JSX, ReactElement, useMemo, useState } from 'react'; import { useLocalStorage } from 'react-use'; import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx index 19100a72600..42a7594027b 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx @@ -15,6 +15,10 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { RuleListGroupView } from './RuleListGroupView'; +jest.mock('@grafana/assistant', () => ({ + useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }), +})); + jest.spyOn(analytics, 'logInfo'); const ui = { diff --git a/public/app/features/alerting/unified/components/rules/RuleListStateView.test.tsx b/public/app/features/alerting/unified/components/rules/RuleListStateView.test.tsx index 260c7436a18..51739c6ae4c 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListStateView.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListStateView.test.tsx @@ -10,6 +10,10 @@ import { } from 'app/features/alerting/unified/mocks'; import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +jest.mock('@grafana/assistant', () => ({ + useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }), +})); + setPluginLinksHook(() => ({ links: [], isLoading: false, diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx index a4be47bbf81..a263b4711ca 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx @@ -7,6 +7,7 @@ import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { AlertRuleAction, useAlertRuleAbility, + useEnrichmentAbility, useGrafanaPromRuleAbilities, useGrafanaPromRuleAbility, useRulerRuleAbilities, @@ -17,6 +18,10 @@ import { mimirDataSource } from '../../mocks/server/configure'; import { RulesTable } from './RulesTable'; +jest.mock('@grafana/assistant', () => ({ + useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }), +})); + jest.mock('../../hooks/useAbilities'); const mocks = { @@ -29,6 +34,7 @@ const mocks = { useGrafanaPromRuleAbility: jest.mocked(useGrafanaPromRuleAbility), useRulerRuleAbilities: jest.mocked(useRulerRuleAbilities), useGrafanaPromRuleAbilities: jest.mocked(useGrafanaPromRuleAbilities), + useEnrichmentAbility: jest.mocked(useEnrichmentAbility), }; setPluginLinksHook(() => ({ @@ -63,6 +69,7 @@ describe('RulesTable RBAC', () => { mocks.useAlertRuleAbility.mockReturnValue([false, false]); mocks.useRulerRuleAbility.mockReturnValue([false, false]); mocks.useGrafanaPromRuleAbility.mockReturnValue([false, false]); + mocks.useEnrichmentAbility.mockReturnValue([false, false]); // Plural hooks (used by AlertRuleMenu) - need to return arrays based on input actions mocks.useRulerRuleAbilities.mockImplementation((_rule, _groupIdentifier, actions) => { diff --git a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx index 8282d16ed97..f853c19357b 100644 --- a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx +++ b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect, useState } from 'react'; +import { type JSX, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import AutoSizer from 'react-virtualized-auto-sizer'; diff --git a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx index 8d3c80acad1..a06acda7c35 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx @@ -56,7 +56,7 @@ const SilencesTable = () => { const { data: alertManagerAlerts = [], isLoading: amAlertsIsLoading } = alertmanagerApi.endpoints.getAlertmanagerAlerts.useQuery( - { amSourceName: alertManagerSourceName, filter: { silenced: true, active: true, inhibited: true } }, + { amSourceName: alertManagerSourceName, filter: { silenced: true, active: false, inhibited: false } }, { ...API_QUERY_OPTIONS, skip: !canPreview } ); diff --git a/public/app/features/alerting/unified/enterprise-components/rule-view-page/extensions.tsx b/public/app/features/alerting/unified/enterprise-components/rule-view-page/extensions.tsx index a4d5034e103..bc366ec1cfe 100644 --- a/public/app/features/alerting/unified/enterprise-components/rule-view-page/extensions.tsx +++ b/public/app/features/alerting/unified/enterprise-components/rule-view-page/extensions.tsx @@ -5,6 +5,7 @@ import { t } from '@grafana/i18n'; import { FeatureBadge, useStyles2 } from '@grafana/ui'; import { useAlertRule } from '../../components/rule-viewer/RuleContext'; +import { EnrichmentAction, useEnrichmentAbility } from '../../hooks/useAbilities'; import { rulerRuleType } from '../../utils/rules'; type SetActiveTab = (tab: string) => void; @@ -14,7 +15,7 @@ type RuleViewTabBuilderArgs = { setActiveTab: SetActiveTab; }; -type RuleViewTabBuilder = (args: RuleViewTabBuilderArgs) => NavModelItem; +type RuleViewTabBuilder = (args: RuleViewTabBuilderArgs) => NavModelItem | null; type RuleViewTabBuilderConfig = { filterOnlyGrafanaAlertRules: boolean; ruleViewTabBuilder: RuleViewTabBuilder; @@ -29,21 +30,35 @@ function registerRuleViewTab(builder: RuleViewTabBuilder) { }); } -export function useRuleViewExtensionTabs(args: RuleViewTabBuilderArgs): NavModelItem[] { - const { rule } = useAlertRule(); - const isGrafanaAlertRule = rulerRuleType.grafana.alertingRule(rule.rulerRule); +export function getRuleViewExtensionTabs(args: RuleViewTabBuilderArgs, isGrafanaAlertRule: boolean): NavModelItem[] { return ruleViewTabBuilders .filter((config) => { - if (config.filterOnlyGrafanaAlertRules) { - return isGrafanaAlertRule; + // Check if rule type matches requirement + if (config.filterOnlyGrafanaAlertRules && !isGrafanaAlertRule) { + return false; } return true; }) - .map((config) => config.ruleViewTabBuilder(args)); + .map((config) => config.ruleViewTabBuilder(args)) + .filter((item): item is NavModelItem => item !== null); +} + +export function useRuleViewExtensionTabs(args: RuleViewTabBuilderArgs): NavModelItem[] { + const { rule } = useAlertRule(); + const isGrafanaAlertRule = rulerRuleType.grafana.alertingRule(rule.rulerRule); + + return getRuleViewExtensionTabs(args, isGrafanaAlertRule); } export function addEnrichmentSection() { registerRuleViewTab(({ activeTab, setActiveTab }) => { + const [, canReadEnrichments] = useEnrichmentAbility(EnrichmentAction.Read); + + // Return null if user doesn't have permission (will be filtered out) + if (!canReadEnrichments) { + return null; + } + const tabId = 'enrichment'; return { text: t('alerting.use-page-nav.page-nav.text.enrichment', 'Alert enrichment'), @@ -59,18 +74,6 @@ export function __clearRuleViewTabsForTests() { ruleViewTabBuilders.splice(0, ruleViewTabBuilders.length); } -// ONLY FOR TESTS: non-hook version for testing -export function getRuleViewExtensionTabs(args: RuleViewTabBuilderArgs, isGrafanaAlertRule: boolean): NavModelItem[] { - return ruleViewTabBuilders - .filter((config) => { - if (config.filterOnlyGrafanaAlertRules) { - return isGrafanaAlertRule; - } - return true; - }) - .map((config) => config.ruleViewTabBuilder(args)); -} - function getStyles() { return { tabSuffix: css({ diff --git a/public/app/features/alerting/unified/enterprise-components/rule-view-page/navigation.test.ts b/public/app/features/alerting/unified/enterprise-components/rule-view-page/navigation.test.ts index 9fdaa2a74d9..b4ea7312d86 100644 --- a/public/app/features/alerting/unified/enterprise-components/rule-view-page/navigation.test.ts +++ b/public/app/features/alerting/unified/enterprise-components/rule-view-page/navigation.test.ts @@ -1,12 +1,20 @@ -import { beforeEach, describe, expect, it } from '@jest/globals'; +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { addRulePageEnrichmentSection } from '../../components/rule-viewer/tabs/extensions/RuleViewerExtension'; +import { useEnrichmentAbility } from '../../hooks/useAbilities'; import { __clearRuleViewTabsForTests, addEnrichmentSection, getRuleViewExtensionTabs } from './extensions'; +jest.mock('../../hooks/useAbilities'); + +const mocks = { + useEnrichmentAbility: jest.mocked(useEnrichmentAbility), +}; + describe('rule-view-page navigation', () => { beforeEach(() => { __clearRuleViewTabsForTests(); + mocks.useEnrichmentAbility.mockReturnValue([false, true]); }); it('does not include Alert enrichment tab when not registered', () => { diff --git a/public/app/features/alerting/unified/group-details/GroupDetailsPage.test.tsx b/public/app/features/alerting/unified/group-details/GroupDetailsPage.test.tsx index 90485290325..4e1bb3056fb 100644 --- a/public/app/features/alerting/unified/group-details/GroupDetailsPage.test.tsx +++ b/public/app/features/alerting/unified/group-details/GroupDetailsPage.test.tsx @@ -23,6 +23,10 @@ import { alertingFactory } from '../mocks/server/db'; import GroupDetailsPage from './GroupDetailsPage'; +jest.mock('@grafana/assistant', () => ({ + useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }), +})); + jest.mock('react-virtualized-auto-sizer', () => { return ({ children }: Props) => children({ diff --git a/public/app/features/alerting/unified/hooks/useAbilities.test.tsx b/public/app/features/alerting/unified/hooks/useAbilities.test.tsx index 3fdd05e6c63..b08d31a631c 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.test.tsx +++ b/public/app/features/alerting/unified/hooks/useAbilities.test.tsx @@ -1,6 +1,7 @@ import { PropsWithChildren } from 'react'; import { getWrapper, render, renderHook, screen, waitFor } from 'test/test-utils'; +import { config } from '@grafana/runtime'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { setFolderAccessControl } from 'app/features/alerting/unified/mocks/server/configure'; import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants'; @@ -10,16 +11,21 @@ import { CombinedRule } from 'app/types/unified-alerting'; import { getCloudRule, getGrafanaRule, grantUserPermissions, mockDataSource } from '../mocks'; import { AlertmanagerProvider } from '../state/AlertmanagerContext'; +import { grantPermissionsHelper } from '../test/test-utils'; import { setupDataSources } from '../testSetup/datasources'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import * as misc from '../utils/misc'; import { AlertRuleAction, AlertmanagerAction, + EnrichmentAction, useAlertmanagerAbilities, useAlertmanagerAbility, useAllAlertRuleAbilities, useAllAlertmanagerAbilities, + useEnrichmentAbilities, + useEnrichmentAbility, } from './useAbilities'; /** @@ -213,6 +219,126 @@ describe('AlertRule abilities', () => { }); }); +describe('enrichment abilities', () => { + setupMswServer(); + + const originalFeatureToggle = config.featureToggles.alertEnrichment; + + beforeEach(() => { + // Default to feature toggle enabled + config.featureToggles.alertEnrichment = true; + }); + + afterEach(() => { + config.featureToggles.alertEnrichment = originalFeatureToggle; + }); + + it('should grant read and write permissions to admin users when feature is enabled', () => { + grantPermissionsHelper([]); + jest.spyOn(misc, 'isAdmin').mockReturnValue(true); + + const { result } = renderHook(() => useEnrichmentAbilities(), { wrapper: wrapper() }); + + const [readSupported, readAllowed] = result.current[EnrichmentAction.Read]; + const [writeSupported, writeAllowed] = result.current[EnrichmentAction.Write]; + + expect(readSupported).toBe(true); + expect(readAllowed).toBe(true); + expect(writeSupported).toBe(true); + expect(writeAllowed).toBe(true); + }); + + it('should grant read permission when user has enrichments:read permission', () => { + jest.spyOn(misc, 'isAdmin').mockReturnValue(false); + grantPermissionsHelper([AccessControlAction.AlertingEnrichmentsRead]); + + const { result } = renderHook(() => useEnrichmentAbilities(), { wrapper: wrapper() }); + + const [readSupported, readAllowed] = result.current[EnrichmentAction.Read]; + const [writeSupported, writeAllowed] = result.current[EnrichmentAction.Write]; + + expect(readSupported).toBe(true); + expect(readAllowed).toBe(true); + expect(writeSupported).toBe(true); + expect(writeAllowed).toBe(false); + }); + + it('should grant write permission when user has enrichments:write permission', () => { + jest.spyOn(misc, 'isAdmin').mockReturnValue(false); + grantPermissionsHelper([AccessControlAction.AlertingEnrichmentsWrite]); + + const { result } = renderHook(() => useEnrichmentAbilities(), { wrapper: wrapper() }); + + const [readSupported, readAllowed] = result.current[EnrichmentAction.Read]; + const [writeSupported, writeAllowed] = result.current[EnrichmentAction.Write]; + + expect(readSupported).toBe(true); + expect(readAllowed).toBe(false); + expect(writeSupported).toBe(true); + expect(writeAllowed).toBe(true); + }); + + it('should grant both read and write permissions when user has both permissions', () => { + jest.spyOn(misc, 'isAdmin').mockReturnValue(false); + grantPermissionsHelper([AccessControlAction.AlertingEnrichmentsRead, AccessControlAction.AlertingEnrichmentsWrite]); + + const { result } = renderHook(() => useEnrichmentAbilities(), { wrapper: wrapper() }); + + const [readSupported, readAllowed] = result.current[EnrichmentAction.Read]; + const [writeSupported, writeAllowed] = result.current[EnrichmentAction.Write]; + + expect(readSupported).toBe(true); + expect(readAllowed).toBe(true); + expect(writeSupported).toBe(true); + expect(writeAllowed).toBe(true); + }); + + it('should deny all permissions when user is not admin and has no permissions', () => { + jest.spyOn(misc, 'isAdmin').mockReturnValue(false); + grantPermissionsHelper([]); + + const { result } = renderHook(() => useEnrichmentAbilities(), { wrapper: wrapper() }); + + const [readSupported, readAllowed] = result.current[EnrichmentAction.Read]; + const [writeSupported, writeAllowed] = result.current[EnrichmentAction.Write]; + + expect(readSupported).toBe(true); + expect(readAllowed).toBe(false); + expect(writeSupported).toBe(true); + expect(writeAllowed).toBe(false); + }); + + it('should return correct ability for specific action using useEnrichmentAbility', () => { + jest.spyOn(misc, 'isAdmin').mockReturnValue(false); + grantPermissionsHelper([AccessControlAction.AlertingEnrichmentsRead]); + + const { result } = renderHook(() => useEnrichmentAbility(EnrichmentAction.Read), { wrapper: wrapper() }); + + const [supported, allowed] = result.current; + + expect(supported).toBe(true); + expect(allowed).toBe(true); + }); + + it('should report enrichments as not supported when feature toggle is disabled', () => { + config.featureToggles.alertEnrichment = false; + jest.spyOn(misc, 'isAdmin').mockReturnValue(true); + grantPermissionsHelper([AccessControlAction.AlertingEnrichmentsRead, AccessControlAction.AlertingEnrichmentsWrite]); + + const { result } = renderHook(() => useEnrichmentAbilities(), { wrapper: wrapper() }); + + const [readSupported, readAllowed] = result.current[EnrichmentAction.Read]; + const [writeSupported, writeAllowed] = result.current[EnrichmentAction.Write]; + + // Enrichments not supported when feature toggle is off + expect(readSupported).toBe(false); + expect(writeSupported).toBe(false); + // Permissions would be granted if it were supported + expect(readAllowed).toBe(true); + expect(writeAllowed).toBe(true); + }); +}); + function createAlertmanagerWrapper(alertmanagerSourceName: string) { const ProviderWrapper = getWrapper({ renderWithRouter: true }); const wrapperComponent = (props: PropsWithChildren) => ( diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index 5635a3d666a..535d99d0bc0 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; +import { config } from '@grafana/runtime'; import { contextSrv as ctx } from 'app/core/services/context_srv'; import { PERMISSIONS_CONTACT_POINTS_READ } from 'app/features/alerting/unified/components/contact-points/permissions'; import { @@ -96,6 +97,12 @@ export enum AlertRuleAction { DeletePermanently = 'delete-alert-rule-permanently', } +// this enum lists all of the available actions we can perform with enrichments +export enum EnrichmentAction { + Read = 'read-enrichment', + Write = 'write-enrichment', +} + // this enum list all of the bulk actions we can perform on a folder export enum FolderBulkAction { Pause = 'pause-folder', // unpause permissions are the same as pause @@ -124,7 +131,7 @@ export enum AlertingAction { const AlwaysSupported = true; const NotSupported = false; -export type Action = AlertmanagerAction | AlertingAction | AlertRuleAction | FolderBulkAction; +export type Action = AlertmanagerAction | AlertingAction | AlertRuleAction | FolderBulkAction | EnrichmentAction; /** * Represents the ability to perform an action, with two distinct checks: @@ -189,6 +196,28 @@ export const useAlertingAbility = (action: AlertingAction): Ability => { return allAbilities[action]; }; +/** + * This one will check for enrichment abilities + */ +export const useEnrichmentAbilities = (): Abilities => { + const userIsAdmin = isAdmin(); + const hasReadPermission = ctx.hasPermission(AccessControlAction.AlertingEnrichmentsRead); + const hasWritePermission = ctx.hasPermission(AccessControlAction.AlertingEnrichmentsWrite); + + // Enrichments are only available when the feature toggle is enabled + const enrichmentsSupported = Boolean(config.featureToggles.alertEnrichment); + + return { + [EnrichmentAction.Read]: [enrichmentsSupported, userIsAdmin || hasReadPermission], + [EnrichmentAction.Write]: [enrichmentsSupported, userIsAdmin || hasWritePermission], + }; +}; + +export const useEnrichmentAbility = (action: EnrichmentAction): Ability => { + const allAbilities = useEnrichmentAbilities(); + return allAbilities[action]; +}; + /** * This hook will check if we support the action and have sufficient permissions for it on a single alert rule */ diff --git a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.test.tsx b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.test.tsx index 53b80f1ebaf..a057b5030a3 100644 --- a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.test.tsx +++ b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.test.tsx @@ -17,6 +17,10 @@ import { fromRulerRuleAndGroupIdentifierV2 } from '../utils/rule-id'; import { DataSourceGroupLoader } from './DataSourceGroupLoader'; import { createViewLinkFromIdentifier } from './DataSourceRuleListItem'; +jest.mock('@grafana/assistant', () => ({ + useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }), +})); + setPluginLinksHook(() => ({ links: [], isLoading: false })); setPluginComponentsHook(() => ({ components: [], isLoading: false })); diff --git a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx index 3a7348048da..5c8768f58ec 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx @@ -12,6 +12,10 @@ import { RulesFilter } from '../search/rulesSearchParser'; import { FilterView } from './FilterView'; +jest.mock('@grafana/assistant', () => ({ + useAssistant: () => ({ isAvailable: false, openAssistant: jest.fn() }), +})); + setPluginLinksHook(() => ({ links: [], isLoading: false })); setPluginComponentsHook(() => ({ components: [], isLoading: false })); diff --git a/public/app/features/alerting/unified/rule-list/FilterView.tsx b/public/app/features/alerting/unified/rule-list/FilterView.tsx index fb46822f0c3..c6e65f88cb9 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.tsx @@ -16,14 +16,13 @@ import { GrafanaRuleListItem } from './GrafanaRuleListItem'; import LoadMoreHelper from './LoadMoreHelper'; import { UnknownRuleListItem } from './components/AlertRuleListItem'; import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader'; -import { hasClientSideFilters } from './hooks/grafanaFilter'; import { GrafanaRuleWithOrigin, PromRuleWithOrigin, RuleWithOrigin, useFilteredRulesIteratorProvider, } from './hooks/useFilteredRulesIterator'; -import { FRONTEND_LIST_PAGE_SIZE, getSearchApiGroupPageSize } from './paginationLimits'; +import { FRONTEND_LIST_PAGE_SIZE, getFilteredRulesLimits } from './paginationLimits'; interface FilterViewProps { filterState: RulesFilter; @@ -78,10 +77,7 @@ function FilterViewResults({ filterState }: FilterViewProps) { * ⚠️ Make sure we are returning / using a "iterator" and not an "iterable" since the iterable is only a blueprint * and the iterator will allow us to exhaust the iterable in a stateful way */ - const { iterable, abortController } = getFilteredRulesIterator( - filterState, - getSearchApiGroupPageSize(hasClientSideFilters(filterState)) - ); + const { iterable, abortController } = getFilteredRulesIterator(filterState, getFilteredRulesLimits(filterState)); const rulesBatchIterator = iterable .pipe( bufferCountOrTime(FRONTEND_LIST_PAGE_SIZE, 1000), diff --git a/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx index 3105c3efd6b..7385215637a 100644 --- a/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx +++ b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx @@ -1,6 +1,7 @@ import { render } from 'test/test-utils'; import { byLabelText, byRole } from 'testing-library-selector'; +import { useAssistant } from '@grafana/assistant'; import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime'; import { AccessControlAction } from 'app/types/accessControl'; import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting'; @@ -22,6 +23,12 @@ import { intervalToSeconds } from '../utils/time'; import { GrafanaGroupLoader } from './GrafanaGroupLoader'; +jest.mock('@grafana/assistant', () => ({ + useAssistant: jest.fn(), + createAssistantContextItem: jest.fn((type, data) => ({ type, ...data })), +})); +const mockUseAssistant = jest.mocked(useAssistant); + setPluginLinksHook(() => ({ links: [], isLoading: false })); setPluginComponentsHook(() => ({ components: [], isLoading: false })); @@ -41,11 +48,18 @@ const ui = { export: () => byRole('menuitem', { name: /export/i }), delete: () => byRole('menuitem', { name: /delete/i }), pause: () => byRole('menuitem', { name: /pause/i }), + analyzeRule: () => byRole('menuitem', { name: /analyze rule/i }), }, }; describe('GrafanaGroupLoader', () => { beforeEach(() => { + mockUseAssistant.mockReturnValue({ + isAvailable: false, + openAssistant: jest.fn(), + closeAssistant: jest.fn(), + toggleAssistant: jest.fn(), + }); grantUserPermissions([ AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleDelete, @@ -213,6 +227,68 @@ describe('GrafanaGroupLoader', () => { const menuItems = byRole('menuitem').getAll(); expect(menuItems.length).toBe(6); }); + + it('should render Analyze rule menu item when assistant is available', async () => { + mockUseAssistant.mockReturnValue({ + isAvailable: true, + openAssistant: jest.fn(), + closeAssistant: jest.fn(), + toggleAssistant: jest.fn(), + }); + + setGrafanaPromRules([rulerGroupToPromGroup(grafanaRulerGroup)]); + + const groupIdentifier = getGroupIdentifier(grafanaRulerGroup); + + const { user } = render( + + ); + + const [rule1] = grafanaRulerGroup.rules; + const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find(); + + // Click the More button to open the menu + const moreButton = ui.moreButton().get(ruleListItem); + await user.click(moreButton); + + // Check that Analyze rule menu item is present + expect(ui.menuItems.analyzeRule().get()).toBeInTheDocument(); + + // With assistant enabled, there should be 7 menu items (6 + Analyze rule) + const menuItems = byRole('menuitem').getAll(); + expect(menuItems.length).toBe(7); + }); + + it('should not render Analyze rule menu item when assistant is not available', async () => { + mockUseAssistant.mockReturnValue({ + isAvailable: false, + openAssistant: jest.fn(), + closeAssistant: jest.fn(), + toggleAssistant: jest.fn(), + }); + + setGrafanaPromRules([rulerGroupToPromGroup(grafanaRulerGroup)]); + + const groupIdentifier = getGroupIdentifier(grafanaRulerGroup); + + const { user } = render( + + ); + + const [rule1] = grafanaRulerGroup.rules; + const ruleListItem = await ui.ruleItem(rule1.grafana_alert.title).find(); + + // Click the More button to open the menu + const moreButton = ui.moreButton().get(ruleListItem); + await user.click(moreButton); + + // Check that Analyze rule menu item is NOT present + expect(ui.menuItems.analyzeRule().query()).not.toBeInTheDocument(); + + // Without assistant, there should be 6 menu items + const menuItems = byRole('menuitem').getAll(); + expect(menuItems.length).toBe(6); + }); }); function rulerGroupToPromGroup(group: RulerRuleGroupDTO): GrafanaPromRuleGroupDTO { diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index a39509608b3..9dfd25309ba 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -7,7 +7,6 @@ import { GrafanaPromRuleGroupDTO, PromRuleGroupDTO } from 'app/types/unified-ale import { FolderActionsButton } from '../components/folder-actions/FolderActionsButton'; import { GrafanaNoRulesCTA } from '../components/rules/NoRulesCTA'; -import { shouldUseBackendFilters } from '../featureToggles'; import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { groups } from '../utils/navigation'; @@ -18,7 +17,7 @@ import { ListGroup } from './components/ListGroup'; import { ListSection } from './components/ListSection'; import { LoadMoreButton } from './components/LoadMoreButton'; import { NoRulesFound } from './components/NoRulesFound'; -import { getGrafanaFilter } from './hooks/grafanaFilter'; +import { getGrafanaFilter, hasGrafanaClientSideFilters } from './hooks/grafanaFilter'; import { toIndividualRuleGroups, useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator'; import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups'; import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits'; @@ -36,25 +35,27 @@ export function PaginatedGrafanaLoader({ groupFilter, namespaceFilter }: LoaderP } function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) { - const useBackendFilters = shouldUseBackendFilters(); - // When backend filters are enabled, groupFilter is handled on the backend - const hasFilters = useBackendFilters ? Boolean(namespaceFilter) : Boolean(groupFilter || namespaceFilter); + const filterState = { namespace: namespaceFilter, groupName: groupFilter }; + const { backendFilter } = getGrafanaFilter(filterState); + + const hasFilters = Boolean(groupFilter || namespaceFilter); + const needsClientSideFiltering = hasGrafanaClientSideFilters(filterState); // If there are filters, we don't want to populate the cache to avoid performance issues // Filtering may trigger multiple HTTP requests, which would populate the cache with a lot of groups hurting performance const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ - populateCache: hasFilters ? false : true, + populateCache: needsClientSideFiltering ? false : true, limitAlerts: 0, }); // If there are no filters we can match one frontend page to one API page. // However, if there are filters, we need to fetch more groups from the API to populate one frontend page - const apiGroupPageSize = getApiGroupPageSize(hasFilters); + const apiGroupPageSize = getApiGroupPageSize(needsClientSideFiltering); - const searchGroupName = useBackendFilters ? groupFilter : undefined; - - const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(apiGroupPageSize, { searchGroupName }))); + const groupsGenerator = useRef( + toIndividualRuleGroups(grafanaGroupsGenerator({ groupLimit: apiGroupPageSize }, backendFilter)) + ); useEffect(() => { const currentGenerator = groupsGenerator.current; diff --git a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx index 393355ade61..a73d27f7165 100644 --- a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx +++ b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx @@ -1,5 +1,5 @@ import { isString } from 'lodash'; -import { useState } from 'react'; +import { type JSX, useState } from 'react'; import { RequireAtLeastOne } from 'type-fest'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts index e6aa3089e55..b2360913581 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts @@ -22,6 +22,27 @@ import { ruleTypeFilter, } from './filterPredicates'; +/** + * Determines if client-side filtering is needed for data source-managed rules. + */ +export function hasDatasourceClientSideFilters(filterState: Partial): boolean { + // Check if any filter that applies to datasource rules is active + return ( + (filterState.freeFormWords && filterState.freeFormWords.length > 0) || + Boolean(filterState.ruleName) || + Boolean(filterState.ruleState) || + Boolean(filterState.ruleType) || + (filterState.dataSourceNames && filterState.dataSourceNames.length > 0) || + (filterState.labels && filterState.labels.length > 0) || + Boolean(filterState.ruleHealth) || + Boolean(filterState.dashboardUid) || + Boolean(filterState.plugins) || + Boolean(filterState.contactPoint) || + Boolean(filterState.namespace) || + Boolean(filterState.groupName) + ); +} + /** * Builds filter configurations for data source-managed alert rules. * diff --git a/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts index d8594135463..7bcf22e30dc 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts @@ -32,12 +32,14 @@ export function buildTitleSearch(filterState: RulesFilter): string | undefined { * Normalize filter state for case-insensitive matching * Lowercase free form words, rule name, group name and namespace */ -export function normalizeFilterState(filterState: RulesFilter): RulesFilter { +export function normalizeFilterState(filterState: Partial): RulesFilter { return { ...filterState, - freeFormWords: filterState.freeFormWords.map((word) => word.toLowerCase()), + freeFormWords: filterState.freeFormWords?.map((word) => word.toLowerCase()) ?? [], ruleName: filterState.ruleName?.toLowerCase(), groupName: filterState.groupName?.toLowerCase(), namespace: filterState.namespace?.toLowerCase(), + dataSourceNames: filterState.dataSourceNames ?? [], + labels: filterState.labels ?? [], }; } diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts index 56be25ee248..6a714382a96 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts @@ -8,7 +8,7 @@ import { Annotation } from '../../utils/constants'; import { getDatasourceAPIUid } from '../../utils/datasource'; import { getFilter } from '../../utils/search'; -import { getGrafanaFilter, hasClientSideFilters } from './grafanaFilter'; +import { getGrafanaFilter, hasGrafanaClientSideFilters } from './grafanaFilter'; jest.mock('../../utils/datasource'); @@ -670,41 +670,41 @@ describe('grafana-managed rules', () => { }); }); - describe('hasClientSideFilters', () => { + describe('hasGrafanaClientSideFilters', () => { describe('when alertingUIUseBackendFilters is disabled', () => { testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters'] }); it('should return false when no filters are applied', () => { - expect(hasClientSideFilters(getFilter({}))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false); }); it('should return true for title-related filters (freeFormWords, ruleName)', () => { - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); }); it('should return true for ruleType filter', () => { - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); }); it('should return true for dashboardUid filter', () => { - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); }); it('should return true for groupName filter', () => { - expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); }); it('should return true for client-side only filters', () => { - expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); }); it('should return false for backend-only filters (state, health, contactPoint)', () => { - expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); - expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); }); }); @@ -712,36 +712,36 @@ describe('grafana-managed rules', () => { testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] }); it('should return false when no filters are applied', () => { - expect(hasClientSideFilters(getFilter({}))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false); }); it('should return false for title-related filters (handled by backend)', () => { - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); }); it('should return false for ruleType filter (handled by backend)', () => { - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); }); it('should return false for dashboardUid filter (handled by backend)', () => { - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); }); it('should return false for groupName filter (handled by backend)', () => { - expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); }); it('should return true for client-side only filters', () => { - expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); }); it('should return false for backend-only filters (state, health, contactPoint)', () => { - expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); - expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); }); }); @@ -750,20 +750,20 @@ describe('grafana-managed rules', () => { it('should return correct values for all filter types', () => { // Should return false for: empty, backend-handled (ruleType, dashboardUid), and backend-only filters - expect(hasClientSideFilters(getFilter({}))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); - expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); // Should return true for: frontend-handled filters - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); }); }); @@ -772,20 +772,20 @@ describe('grafana-managed rules', () => { it('should return correct values for all filter types', () => { // Should return false for: empty, all backend-handled filters, and backend-only filters - expect(hasClientSideFilters(getFilter({}))).toBe(false); - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); - expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); // Should return true for: always-frontend filters only - expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); }); }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts index 5b5ddd81f9b..0cc89ceafcf 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -24,26 +24,26 @@ import { /** * Determines if client-side filtering is needed for Grafana-managed rules. */ -export function hasClientSideFilters(filterState: RulesFilter): boolean { +export function hasGrafanaClientSideFilters(filterState: Partial): boolean { const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs(); // Check each rule filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering const hasActiveRuleFilters = - (ruleFilterConfig.freeFormWords !== null && filterState.freeFormWords.length > 0) || - (ruleFilterConfig.ruleName !== null && Boolean(filterState.ruleName)) || - (ruleFilterConfig.ruleState !== null && Boolean(filterState.ruleState)) || - (ruleFilterConfig.ruleType !== null && Boolean(filterState.ruleType)) || - (ruleFilterConfig.dataSourceNames !== null && filterState.dataSourceNames.length > 0) || - (ruleFilterConfig.labels !== null && filterState.labels.length > 0) || - (ruleFilterConfig.ruleHealth !== null && Boolean(filterState.ruleHealth)) || - (ruleFilterConfig.dashboardUid !== null && Boolean(filterState.dashboardUid)) || - (ruleFilterConfig.plugins !== null && Boolean(filterState.plugins)) || - (ruleFilterConfig.contactPoint !== null && Boolean(filterState.contactPoint)); + (ruleFilterConfig.freeFormWords !== null && Boolean(filterState?.freeFormWords?.length)) || + (ruleFilterConfig.ruleName !== null && Boolean(filterState?.ruleName)) || + (ruleFilterConfig.ruleState !== null && Boolean(filterState?.ruleState)) || + (ruleFilterConfig.ruleType !== null && Boolean(filterState?.ruleType)) || + (ruleFilterConfig.dataSourceNames !== null && Boolean(filterState?.dataSourceNames?.length)) || + (ruleFilterConfig.labels !== null && Boolean(filterState?.labels?.length)) || + (ruleFilterConfig.ruleHealth !== null && Boolean(filterState?.ruleHealth)) || + (ruleFilterConfig.dashboardUid !== null && Boolean(filterState?.dashboardUid)) || + (ruleFilterConfig.plugins !== null && Boolean(filterState?.plugins)) || + (ruleFilterConfig.contactPoint !== null && Boolean(filterState?.contactPoint)); // Check each group filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering const hasActiveGroupFilters = - (groupFilterConfig.namespace !== null && Boolean(filterState.namespace)) || - (groupFilterConfig.groupName !== null && Boolean(filterState.groupName)); + (groupFilterConfig.namespace !== null && Boolean(filterState?.namespace)) || + (groupFilterConfig.groupName !== null && Boolean(filterState?.groupName)); return hasActiveRuleFilters || hasActiveGroupFilters; } @@ -55,7 +55,7 @@ export function hasClientSideFilters(filterState: RulesFilter): boolean { * The backend filter is used for server-side filtering when `shouldUseBackendFilters()` is enabled, * while the frontend filter provides client-side matching functions for rules and groups. */ -export function getGrafanaFilter(filterState: RulesFilter) { +export function getGrafanaFilter(filterState: Partial) { const normalizedFilterState = normalizeFilterState(filterState); const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs(); diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts index db2d5df077d..add1097fa0f 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react'; +import { MergeExclusive } from 'type-fest'; import { DataSourceRulesSourceIdentifier, RuleHealth } from 'app/types/unified-alerting'; import { PromAlertingRuleState, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; @@ -16,27 +17,23 @@ interface UseGeneratorHookOptions { limitAlerts?: number; } -interface FetchGroupsOptions { - groupLimit?: number; - groupNextToken?: string; -} - export function usePrometheusGroupsGenerator() { const [getGroups] = useLazyGetGroupsQuery(); return useCallback( async function* (ruleSource: DataSourceRulesSourceIdentifier, groupLimit: number) { - const getRuleSourceGroupsWithCache = async (fetchOptions: FetchGroupsOptions) => { + const getRuleSourceGroupsWithCache = async (fetchOptions: GroupsNextPageOptions) => { const response = await getGroups({ ruleSource: { uid: ruleSource.uid }, notificationOptions: { showErrorAlert: false }, + groupLimit, ...fetchOptions, }).unwrap(); return response; }; - yield* genericGroupsGenerator(getRuleSourceGroupsWithCache, groupLimit); + yield* genericGroupsGenerator(getRuleSourceGroupsWithCache); }, [getGroups] ); @@ -52,8 +49,21 @@ interface GrafanaPromApiFilter { dashboardUid?: string; } -interface GrafanaFetchGroupsOptions extends FetchGroupsOptions { +interface GrafanaFetchGroupsOptions extends GroupsNextPageOptions { filter?: GrafanaPromApiFilter; + groupLimit?: number; + // Limits the number of total rules returned across all groups + // Rounds up to full groups, so the response may contain more rules than the group limit + ruleLimit?: number; +} + +export type GrafanaFetchGroupsLimit = MergeExclusive<{ groupLimit: number }, { ruleLimit: number }>; + +export type DataSourceFetchGroupsLimit = { groupLimit: number }; + +export interface FetchGroupsLimitOptions { + grafanaManagedLimit: GrafanaFetchGroupsLimit; + datasourceManagedLimit: DataSourceFetchGroupsLimit; } export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions = {}) { @@ -78,11 +88,16 @@ export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions = ); return useCallback( - async function* (groupLimit: number, filter?: GrafanaPromApiFilter) { - yield* genericGroupsGenerator( - (fetchOptions) => getGroupsAndProvideCache({ ...fetchOptions, filter }), - groupLimit - ); + async function* (limit: GrafanaFetchGroupsLimit, filter?: GrafanaPromApiFilter) { + const fetchGroups = (fetchOptions: GroupsNextPageOptions) => + getGroupsAndProvideCache({ + ...fetchOptions, + filter, + groupLimit: 'groupLimit' in limit ? limit.groupLimit : undefined, + ruleLimit: 'ruleLimit' in limit ? limit.ruleLimit : undefined, + }); + + yield* genericGroupsGenerator(fetchGroups); }, [getGroupsAndProvideCache] ); @@ -105,21 +120,24 @@ export function toIndividualRuleGroups( })(); } +interface GroupsNextPageOptions { + groupNextToken?: string; +} + // Generator lazily provides groups one by one only when needed // This might look a bit complex but it allows us to have one API for paginated and non-paginated Prometheus data sources // For unpaginated data sources we fetch everything in one go // For paginated we fetch the next page when needed async function* genericGroupsGenerator( - fetchGroups: (options: FetchGroupsOptions) => Promise>, - groupLimit: number + fetchGroups: (options: GroupsNextPageOptions) => Promise> ) { - let response = await fetchGroups({ groupLimit }); + let response = await fetchGroups({ groupNextToken: undefined }); yield response.data.groups; let lastToken: string | undefined = response.data?.groupNextToken; while (lastToken) { - response = await fetchGroups({ groupNextToken: lastToken, groupLimit: groupLimit }); + response = await fetchGroups({ groupNextToken: lastToken }); yield response.data.groups; lastToken = response.data?.groupNextToken; } diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts index 407426bb3e1..7c5533beea5 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -26,7 +26,11 @@ import { RulePositionHash, createRulePositionHash } from '../rulePositionHash'; import { getDatasourceFilter } from './datasourceFilter'; import { getGrafanaFilter } from './grafanaFilter'; -import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator'; +import { + FetchGroupsLimitOptions, + useGrafanaGroupsGenerator, + usePrometheusGroupsGenerator, +} from './prometheusGroupsGenerator'; export type RuleWithOrigin = PromRuleWithOrigin | GrafanaRuleWithOrigin; @@ -74,7 +78,7 @@ export function useFilteredRulesIteratorProvider() { const prometheusGroupsGenerator = usePrometheusGroupsGenerator(); const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ limitAlerts: 0 }); - const getFilteredRulesIterable = (filterState: RulesFilter, groupLimit: number): GetIteratorResult => { + const getFilteredRulesIterable = (filterState: RulesFilter, options: FetchGroupsLimitOptions): GetIteratorResult => { /* this is the abort controller that allows us to stop an AsyncIterable */ const abortController = new AbortController(); @@ -83,7 +87,7 @@ export function useFilteredRulesIteratorProvider() { const { backendFilter, frontendFilter } = getGrafanaFilter(filterState); const grafanaRulesGenerator: AsyncIterableX = from( - grafanaGroupsGenerator(groupLimit, backendFilter) + grafanaGroupsGenerator(options.grafanaManagedLimit, backendFilter) ).pipe( withAbort(abortController.signal), concatMap((groups) => @@ -110,7 +114,7 @@ export function useFilteredRulesIteratorProvider() { const dataSourceGenerators: Array> = externalRulesSourcesToFetchFrom.map( (dataSourceIdentifier) => { const promGroupsGenerator: AsyncIterableX = from( - prometheusGroupsGenerator(dataSourceIdentifier, groupLimit) + prometheusGroupsGenerator(dataSourceIdentifier, options.datasourceManagedLimit.groupLimit) ).pipe( withAbort(abortController.signal), concatMap((groups) => diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts new file mode 100644 index 00000000000..5ef8431aced --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts @@ -0,0 +1,181 @@ +import { testWithFeatureToggles } from 'test/test-utils'; + +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; + +import { RuleHealth, RulesFilter } from '../search/rulesSearchParser'; +import { getFilter } from '../utils/search'; + +import { + FILTERED_GROUPS_LARGE_API_PAGE_SIZE, + FILTERED_GROUPS_SMALL_API_PAGE_SIZE, + RULE_LIMIT_WITH_BACKEND_FILTERS, + getFilteredRulesLimits, +} from './paginationLimits'; + +describe('paginationLimits', () => { + describe('getFilteredRulesLimits', () => { + describe('when backend filters are disabled', () => { + testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] }); + + it('should return small limits when no filters are applied', () => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({})); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + }); + + it.each>([ + { ruleState: PromAlertingRuleState.Firing }, + { ruleHealth: RuleHealth.Ok }, + { contactPoint: 'slack' }, + ])('should return small grafana limit + large datasource limit for backend-only filter: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + + it.each>([ + { freeFormWords: ['cpu'] }, + { ruleName: 'alert' }, + { ruleType: PromRuleType.Alerting }, + { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, + { dashboardUid: 'test-dashboard' }, + { plugins: 'hide' as const }, + { namespace: 'production' }, + { groupName: 'test-group' }, + { namespace: 'production', freeFormWords: ['cpu'] }, + ])('should return large limits for both when frontend filters are used: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + }); + + describe('when alertingUIUseBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] }); + + it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({})); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + }); + + it.each>([ + { freeFormWords: ['cpu'] }, + { ruleName: 'alert' }, + { ruleType: PromRuleType.Alerting }, + { dashboardUid: 'test-dashboard' }, + { groupName: 'test-group' }, + { ruleState: PromAlertingRuleState.Firing }, + { ruleHealth: RuleHealth.Ok }, + { contactPoint: 'slack' }, + ])( + 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', + (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + } + ); + + it.each>([ + { namespace: 'production' }, + { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, + { ruleState: PromAlertingRuleState.Firing, namespace: 'production' }, + ])('should return large limits for both when frontend filters are used: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + }); + + describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseFullyCompatBackendFilters'] }); + + it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({})); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + }); + + it.each>([ + { ruleType: PromRuleType.Alerting }, + { dashboardUid: 'test-dashboard' }, + { ruleState: PromAlertingRuleState.Firing }, + { ruleHealth: RuleHealth.Ok }, + { contactPoint: 'slack' }, + ])( + 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', + (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + } + ); + + it.each>([ + { freeFormWords: ['cpu'] }, + { ruleName: 'alert' }, + { groupName: 'test-group' }, + { namespace: 'production' }, + { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, + ])('should return large limits for both when frontend filters are used: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + }); + + describe('when both backend filter toggles are enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] }); + + it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({})); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE }); + }); + + it.each>([ + { freeFormWords: ['cpu'] }, + { ruleName: 'alert' }, + { ruleType: PromRuleType.Alerting }, + { dashboardUid: 'test-dashboard' }, + { groupName: 'test-group' }, + { ruleState: PromAlertingRuleState.Firing }, + { ruleHealth: RuleHealth.Ok }, + { contactPoint: 'slack' }, + ])( + 'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p', + (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + } + ); + + it.each>([ + { namespace: 'production' }, + { dataSourceNames: ['prometheus'] }, + { labels: ['severity=critical'] }, + ])('should return large limits for both when frontend filters are used: %p', (filterState) => { + const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState)); + + expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE }); + }); + }); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.ts index a8e03ac5a48..a5d359ded7b 100644 --- a/public/app/features/alerting/unified/rule-list/paginationLimits.ts +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.ts @@ -1,3 +1,10 @@ +import { shouldUseBackendFilters, shouldUseFullyCompatibleBackendFilters } from '../featureToggles'; +import { RulesFilter } from '../search/rulesSearchParser'; + +import { hasDatasourceClientSideFilters } from './hooks/datasourceFilter'; +import { hasGrafanaClientSideFilters } from './hooks/grafanaFilter'; +import { FetchGroupsLimitOptions } from './hooks/prometheusGroupsGenerator'; + export const FRONTEND_LIST_PAGE_SIZE = 100; export const FILTERED_GROUPS_LARGE_API_PAGE_SIZE = 2000; @@ -6,6 +13,8 @@ export const FILTERED_GROUPS_SMALL_API_PAGE_SIZE = 100; export const DEFAULT_GROUPS_API_PAGE_SIZE = 40; export const FRONTED_GROUPED_PAGE_SIZE = DEFAULT_GROUPS_API_PAGE_SIZE; +export const RULE_LIMIT_WITH_BACKEND_FILTERS = 100; + export function getApiGroupPageSize(hasFilters: boolean) { return hasFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE; } @@ -13,3 +22,27 @@ export function getApiGroupPageSize(hasFilters: boolean) { export function getSearchApiGroupPageSize(hasFrontendFilters: boolean) { return hasFrontendFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : FILTERED_GROUPS_SMALL_API_PAGE_SIZE; } + +export function getFilteredRulesLimits(filterState: RulesFilter): FetchGroupsLimitOptions { + return { + grafanaManagedLimit: getGrafanaFilterLimits(filterState), + datasourceManagedLimit: { + groupLimit: hasDatasourceClientSideFilters(filterState) + ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE + : FILTERED_GROUPS_SMALL_API_PAGE_SIZE, + }, + }; +} + +function getGrafanaFilterLimits(filterState: RulesFilter) { + const backendFiltersEnabled = shouldUseFullyCompatibleBackendFilters() || shouldUseBackendFilters(); + + const frontendFiltersInUse = hasGrafanaClientSideFilters(filterState); + const onlyBackendFiltersInUse = frontendFiltersInUse === false; + + if (backendFiltersEnabled && onlyBackendFiltersInUse) { + return { ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS }; + } + + return { groupLimit: getSearchApiGroupPageSize(frontendFiltersInUse) }; +} diff --git a/public/app/features/alerting/unified/utils/rule-form.test.ts b/public/app/features/alerting/unified/utils/rule-form.test.ts index 2e9b7a5730b..a5410e6ebd6 100644 --- a/public/app/features/alerting/unified/utils/rule-form.test.ts +++ b/public/app/features/alerting/unified/utils/rule-form.test.ts @@ -7,6 +7,7 @@ import { GrafanaAlertStateDecision, GrafanaRuleDefinition, RulerAlertingRuleDTO, + RulerGrafanaRuleDTO, } from 'app/types/unified-alerting-dto'; import { EvalFunction } from '../../state/alertDef'; @@ -20,6 +21,7 @@ import { alertingRulerRuleToRuleForm, cleanAnnotations, cleanLabels, + fixMissingRefIdsInExpressionModel, formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO, getContactPointsFromDTO, @@ -520,3 +522,70 @@ describe('getDefaultExpressions', () => { expect(thresholdModel.expression).toBe('X'); }); }); + +describe('fixMissingRefIdsInExpressionModel', () => { + it('should return non-Grafana managed rules unchanged', () => { + const cloudAlertingRule: RulerAlertingRuleDTO = { + alert: 'CloudAlert', + expr: 'up == 0', + for: '5m', + labels: { severity: 'critical' }, + annotations: { summary: 'Instance down' }, + }; + + const result = fixMissingRefIdsInExpressionModel(cloudAlertingRule); + + expect(result).toEqual(cloudAlertingRule); + expect(result).toBe(cloudAlertingRule); // should be the exact same reference + }); + + it('should copy refId from query to model when model.refId is missing in Grafana managed rules', () => { + const ruleWithMissingRefId: RulerGrafanaRuleDTO = { + grafana_alert: { + uid: 'test-uid', + title: 'Test Alert', + namespace_uid: 'namespace-uid', + rule_group: 'test-group', + condition: 'B', + no_data_state: GrafanaAlertStateDecision.NoData, + exec_err_state: GrafanaAlertStateDecision.Alerting, + is_paused: false, + data: [ + { + refId: 'A', + datasourceUid: 'datasource-uid', + queryType: '', + relativeTimeRange: { from: 600, to: 0 }, + // @ts-ignore + model: { + // refId is missing here + datasource: { + type: 'grafana-testdata-datasource', + uid: 'PD8C576611E62080A', + }, + }, + }, + { + refId: 'B', + datasourceUid: ExpressionDatasourceUID, + queryType: '', + // @ts-ignore + model: { + // refId is missing here + type: ExpressionQueryType.reduce, + expression: 'A', + }, + }, + ], + }, + for: '5m', + labels: {}, + annotations: {}, + }; + + const result = fixMissingRefIdsInExpressionModel(ruleWithMissingRefId); + + expect(result.grafana_alert.data[0].model.refId).toBe('A'); + expect(result.grafana_alert.data[1].model.refId).toBe('B'); + }); +}); diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 077934e21e1..0e83a276851 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -1,3 +1,5 @@ +import { produce } from 'immer'; + import { DataSourceInstanceSettings, IntervalValues, @@ -278,14 +280,16 @@ function getEditorSettingsFromDTO(ga: GrafanaRuleDefinition) { export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues { const { ruleSourceName, namespace, group, rule } = ruleWithLocation; - const isGrafanaRecordingRule = rulerRuleType.grafana.recordingRule(rule); + const normalizedRule = fixMissingRefIdsInExpressionModel(rule); + + const isGrafanaRecordingRule = rulerRuleType.grafana.recordingRule(normalizedRule); const defaultFormValues = getDefaultFormValues(isGrafanaRecordingRule ? RuleFormType.grafanaRecording : undefined); if (isGrafanaRulesSource(ruleSourceName)) { // GRAFANA-MANAGED RULES if (isGrafanaRecordingRule) { // grafana recording rule - const ga = rule.grafana_alert; + const ga = normalizedRule.grafana_alert; return { ...defaultFormValues, name: ga.title, @@ -294,16 +298,16 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF evaluateEvery: group.interval || defaultFormValues.evaluateEvery, queries: ga.data, condition: ga.condition, - annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)), - labels: listifyLabelsOrAnnotations(rule.labels, true), + annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(normalizedRule.annotations, false)), + labels: listifyLabelsOrAnnotations(normalizedRule.labels, true), folder: { title: namespace, uid: ga.namespace_uid }, isPaused: ga.is_paused, metric: ga.record?.metric, targetDatasourceUid: ga.record?.target_datasource_uid || defaultFormValues.targetDatasourceUid, }; - } else if (rulerRuleType.grafana.rule(rule)) { + } else if (rulerRuleType.grafana.rule(normalizedRule)) { // grafana alerting rule - const ga = rule.grafana_alert; + const ga = normalizedRule.grafana_alert; const routingSettings: AlertManagerManualRouting | undefined = getContactPointsFromDTO(ga); if (ga.no_data_state !== undefined && ga.exec_err_state !== undefined) { return { @@ -312,14 +316,14 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF type: RuleFormType.grafana, group: group.name, evaluateEvery: group.interval || defaultFormValues.evaluateEvery, - evaluateFor: rule.for || '0', - keepFiringFor: rule.keep_firing_for || '0', + evaluateFor: normalizedRule.for || '0', + keepFiringFor: normalizedRule.keep_firing_for || '0', noDataState: ga.no_data_state, execErrState: ga.exec_err_state, queries: ga.data, condition: ga.condition, - annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)), - labels: listifyLabelsOrAnnotations(rule.labels, true), + annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(normalizedRule.annotations, false)), + labels: listifyLabelsOrAnnotations(normalizedRule.labels, true), folder: { title: namespace, uid: ga.namespace_uid }, isPaused: ga.is_paused, @@ -338,7 +342,7 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF } } else { // DATASOURCE-MANAGED RULES - if (rulerRuleType.dataSource.alertingRule(rule)) { + if (rulerRuleType.dataSource.alertingRule(normalizedRule)) { const datasourceUid = getDataSourceSrv().getInstanceSettings(ruleSourceName)?.uid ?? ''; const defaultQuery = { @@ -346,27 +350,27 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF datasourceUid, queryType: '', relativeTimeRange: getDefaultRelativeTimeRange(), - expr: rule.expr, + expr: normalizedRule.expr, model: { refId: 'A', hide: false, - expr: rule.expr, + expr: normalizedRule.expr, }, }; - const alertingRuleValues = alertingRulerRuleToRuleForm(rule); + const alertingRuleValues = alertingRulerRuleToRuleForm(normalizedRule); return { ...defaultFormValues, ...alertingRuleValues, queries: [defaultQuery], - annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)), + annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(normalizedRule.annotations, false)), type: RuleFormType.cloudAlerting, dataSourceName: ruleSourceName, namespace, group: group.name, }; - } else if (rulerRuleType.dataSource.recordingRule(rule)) { + } else if (rulerRuleType.dataSource.recordingRule(normalizedRule)) { const datasourceUid = getDataSourceSrv().getInstanceSettings(ruleSourceName)?.uid ?? ''; const defaultQuery = { @@ -374,15 +378,15 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF datasourceUid, queryType: '', relativeTimeRange: getDefaultRelativeTimeRange(), - expr: rule.expr, + expr: normalizedRule.expr, model: { refId: 'A', hide: false, - expr: rule.expr, + expr: normalizedRule.expr, }, }; - const recordingRuleValues = recordingRulerRuleToRuleForm(rule); + const recordingRuleValues = recordingRulerRuleToRuleForm(normalizedRule); return { ...defaultFormValues, @@ -399,6 +403,23 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF } } +/** + * This function isn't supposed to be needed, but we've noticed some customers are creating rules via Provisioning or + * other interfaces where they aren't including the RefId in the "model" of the expression so copy the refId from the query definition. + */ +export function fixMissingRefIdsInExpressionModel(rule: T): T { + // non-Grafana managed rules don't use expression nodes so we return the rule as-is + if (!rulerRuleType.grafana.rule(rule)) { + return rule; + } + + return produce(rule, (draft) => { + draft.grafana_alert.data.forEach((query) => { + query.model.refId = query.model.refId ?? query.refId; + }); + }); +} + export function grafanaRuleDtoToFormValues(rule: RulerGrafanaRuleDTO, namespace: string): RuleFormValues { const isGrafanaRecordingRule = rulerRuleType.grafana.recordingRule(rule); const defaultFormValues = getDefaultFormValues(isGrafanaRecordingRule ? RuleFormType.grafanaRecording : undefined); diff --git a/public/app/features/auth-config/ErrorContainer.tsx b/public/app/features/auth-config/ErrorContainer.tsx index 1a51263a364..148ee9d33da 100644 --- a/public/app/features/auth-config/ErrorContainer.tsx +++ b/public/app/features/auth-config/ErrorContainer.tsx @@ -1,3 +1,4 @@ +import type { JSX } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Alert } from '@grafana/ui'; diff --git a/public/app/features/browse-dashboards/components/NameCell.tsx b/public/app/features/browse-dashboards/components/NameCell.tsx index 4ee8ceba594..0049fb8f829 100644 --- a/public/app/features/browse-dashboards/components/NameCell.tsx +++ b/public/app/features/browse-dashboards/components/NameCell.tsx @@ -97,6 +97,7 @@ export function NameCell({ row: { original: data }, onFolderClick, treeID }: Nam itemKind: item.kind, parent: item.parentUID ? 'folder' : 'root', source: 'browseDashboardsPage_BrowseView', + uid: item.uid, }); }} href={item.url} diff --git a/public/app/features/commandPalette/actions/scopeActions.test.tsx b/public/app/features/commandPalette/actions/scopeActions.test.tsx index 0d828d66a81..126877224de 100644 --- a/public/app/features/commandPalette/actions/scopeActions.test.tsx +++ b/public/app/features/commandPalette/actions/scopeActions.test.tsx @@ -22,7 +22,7 @@ jest.mock('./scopesUtils', () => { }); const mockScopeServicesState = { - updateNode: jest.fn(), + filterNode: jest.fn(), selectScope: jest.fn(), resetSelection: jest.fn(), nodes: {}, @@ -99,12 +99,12 @@ describe('useRegisterScopesActions', () => { }); it('should register scope tree actions and return scopesRow when scopes are selected', () => { - const mockUpdateNode = jest.fn(); + const mockFilterNode = jest.fn(); // First run with empty scopes in the scopes service (useScopeServicesState as jest.Mock).mockReturnValue({ ...mockScopeServicesState, - updateNode: mockUpdateNode, + filterNode: mockFilterNode, selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }], }); @@ -112,14 +112,14 @@ describe('useRegisterScopesActions', () => { return useRegisterScopesActions('', jest.fn()); }); - expect(mockUpdateNode).toHaveBeenCalledWith('', true, ''); + expect(mockFilterNode).toHaveBeenCalledWith('', ''); expect(useRegisterActions).toHaveBeenLastCalledWith([rootScopeAction], [[rootScopeAction]]); expect(result.current.scopesRow).toBeDefined(); // Simulate loading of scopes in the service (useScopeServicesState as jest.Mock).mockReturnValue({ ...mockScopeServicesState, - updateNode: mockUpdateNode, + filterNode: mockFilterNode, selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }], nodes, tree, @@ -151,12 +151,12 @@ describe('useRegisterScopesActions', () => { }); it('should load next level of scopes', () => { - const mockUpdateNode = jest.fn(); + const mockFilterNode = jest.fn(); // First run with empty scopes in the scopes service (useScopeServicesState as jest.Mock).mockReturnValue({ ...mockScopeServicesState, - updateNode: mockUpdateNode, + filterNode: mockFilterNode, nodes, tree, }); @@ -165,7 +165,7 @@ describe('useRegisterScopesActions', () => { return useRegisterScopesActions('', jest.fn(), 'scopes/scope1'); }); - expect(mockUpdateNode).toHaveBeenCalledWith('scope1', true, ''); + expect(mockFilterNode).toHaveBeenCalledWith('scope1', ''); }); it('does not return component if no scopes are selected', () => { @@ -259,12 +259,12 @@ describe('useRegisterScopesActions', () => { }); it('should not use global scope search when searching in some deeper scope category', async () => { - const mockUpdateNode = jest.fn(); + const mockFilterNode = jest.fn(); // First run with empty scopes in the scopes service (useScopeServicesState as jest.Mock).mockReturnValue({ ...mockScopeServicesState, - updateNode: mockUpdateNode, + filterNode: mockFilterNode, nodes, tree, }); @@ -273,17 +273,17 @@ describe('useRegisterScopesActions', () => { return useRegisterScopesActions('something', jest.fn(), 'scopes/scope1'); }); - expect(mockUpdateNode).toHaveBeenCalledWith('scope1', true, 'something'); + expect(mockFilterNode).toHaveBeenCalledWith('scope1', 'something'); expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled(); }); it('should not use global scope search if feature flag is off', async () => { config.featureToggles.scopeSearchAllLevels = false; - const mockUpdateNode = jest.fn(); + const mockFilterNode = jest.fn(); // First run with empty scopes in the scopes service (useScopeServicesState as jest.Mock).mockReturnValue({ ...mockScopeServicesState, - updateNode: mockUpdateNode, + filterNode: mockFilterNode, nodes, tree, }); @@ -292,7 +292,7 @@ describe('useRegisterScopesActions', () => { return useRegisterScopesActions('something', jest.fn(), ''); }); - expect(mockUpdateNode).toHaveBeenCalledWith('', true, 'something'); + expect(mockFilterNode).toHaveBeenCalledWith('', 'something'); expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled(); }); diff --git a/public/app/features/commandPalette/actions/scopeActions.tsx b/public/app/features/commandPalette/actions/scopeActions.tsx index bde41152237..e85d136d2f6 100644 --- a/public/app/features/commandPalette/actions/scopeActions.tsx +++ b/public/app/features/commandPalette/actions/scopeActions.tsx @@ -58,22 +58,22 @@ export function useRegisterScopesActions( * @param parentId */ function useScopeTreeActions(searchQuery: string, parentId?: string | null) { - const { updateNode, selectScope, resetSelection, nodes, tree, selectedScopes } = useScopeServicesState(); + const { filterNode, selectScope, resetSelection, nodes, tree, selectedScopes } = useScopeServicesState(); // Initialize the scopes the first time this runs and reset the scopes that were selected on unmount. useEffect(() => { - updateNode('', true, ''); + filterNode('', ''); resetSelection(); return () => { resetSelection(); }; - }, [updateNode, resetSelection]); + }, [filterNode, resetSelection]); // Load the next level of scopes when the parentId changes. useEffect(() => { const parentScopeId = !parentId || parentId === 'scopes' ? '' : last(parentId.split('/'))!; - updateNode(parentScopeId, true, searchQuery); - }, [updateNode, searchQuery, parentId]); + filterNode(parentScopeId, searchQuery); + }, [filterNode, searchQuery, parentId]); return useMemo( () => mapScopesNodesTreeToActions(nodes, tree!, selectedScopes, selectScope), diff --git a/public/app/features/commandPalette/actions/scopesUtils.ts b/public/app/features/commandPalette/actions/scopesUtils.ts index 2848569247a..d269a58abb1 100644 --- a/public/app/features/commandPalette/actions/scopesUtils.ts +++ b/public/app/features/commandPalette/actions/scopesUtils.ts @@ -14,7 +14,7 @@ export function useScopeServicesState() { const services = useScopesServices(); if (!services) { return { - updateNode: () => {}, + filterNode: () => Promise.resolve(), selectScope: () => {}, resetSelection: () => {}, searchAllNodes: () => Promise.resolve([]), @@ -32,7 +32,7 @@ export function useScopeServicesState() { }, }; } - const { updateNode, filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply, getScopeNodes } = + const { filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply, getScopeNodes } = services.scopesSelectorService; const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable( services.scopesSelectorService.stateObservable ?? new Observable(), @@ -42,7 +42,6 @@ export function useScopeServicesState() { return { getScopeNodes, filterNode, - updateNode, selectScope, resetSelection, searchAllNodes, diff --git a/public/app/features/commandPalette/actions/useExtensionActions.ts b/public/app/features/commandPalette/actions/useExtensionActions.ts index 112bdc5ab0d..643a4647e2d 100644 --- a/public/app/features/commandPalette/actions/useExtensionActions.ts +++ b/public/app/features/commandPalette/actions/useExtensionActions.ts @@ -13,7 +13,7 @@ export default function useExtensionActions(): CommandPaletteAction[] { const { links } = usePluginLinks({ extensionPointId: PluginExtensionPoints.CommandPalette, context, - limitPerPlugin: 40, + limitPerPlugin: 80, }); return useMemo(() => { diff --git a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingData.tsx b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingData.tsx index bc1078b7f1b..a7941569a0a 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingData.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingData.tsx @@ -68,22 +68,29 @@ export class ConditionalRenderingData extends SceneObjectBase; } diff --git a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingTimeRangeSize.tsx b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingTimeRangeSize.tsx index a717b68ea79..d22ad02b8f9 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingTimeRangeSize.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingTimeRangeSize.tsx @@ -80,6 +80,10 @@ export class ConditionalRenderingTimeRangeSize extends SceneObjectBase; } diff --git a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingVariable.tsx b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingVariable.tsx index 8f82b1f459f..827f7d9ca39 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingVariable.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/conditions/ConditionalRenderingVariable.tsx @@ -20,7 +20,7 @@ import { getLowerTranslatedObjectType } from '../object'; import { ConditionalRenderingConditionWrapper } from './ConditionalRenderingConditionWrapper'; import { ConditionalRenderingConditionsSerializerRegistryItem } from './serializers'; -import { checkGroup, getObjectType } from './utils'; +import { checkGroup, getObject, getObjectType } from './utils'; type VariableConditionValueOperator = '=' | '!=' | '=~' | '!~'; @@ -40,14 +40,6 @@ export class ConditionalRenderingVariable extends SceneObjectBase { - if (v.state.name === this.state.variable) { - this._check(); - } - }, - }); - public constructor(state: ConditionalRenderingVariableState) { super(state); @@ -55,6 +47,20 @@ export class ConditionalRenderingVariable extends SceneObjectBase { + if (v.state.name === this.state.variable) { + this._check(); + } + }, + }); + this.forEachChild((child) => { if (!child.isActive) { this._subs.add(child.activate()); @@ -78,7 +84,13 @@ export class ConditionalRenderingVariable extends SceneObjectBase; } diff --git a/public/app/features/dashboard-scene/conditional-rendering/conditions/utils.ts b/public/app/features/dashboard-scene/conditional-rendering/conditions/utils.ts index af8a4110a11..6ce89e17610 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/conditions/utils.ts +++ b/public/app/features/dashboard-scene/conditional-rendering/conditions/utils.ts @@ -14,6 +14,13 @@ export function getGroup(condition: ConditionalRenderingConditions): Conditional } export function getObject(condition: ConditionalRenderingConditions): SceneObject | undefined { + const group = getGroup(condition); + const groupTarget = group.getTarget(); + + if (groupTarget) { + return groupTarget; + } + return getGroup(condition).parent; } diff --git a/public/app/features/dashboard-scene/conditional-rendering/group/ConditionalRenderingGroup.tsx b/public/app/features/dashboard-scene/conditional-rendering/group/ConditionalRenderingGroup.tsx index 5f0b3319e25..8343bd1914c 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/group/ConditionalRenderingGroup.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/group/ConditionalRenderingGroup.tsx @@ -2,7 +2,14 @@ import { lowerCase } from 'lodash'; import { useMemo } from 'react'; import { t } from '@grafana/i18n'; -import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { + SceneComponentProps, + sceneGraph, + SceneObject, + SceneObjectBase, + SceneObjectRef, + SceneObjectState, +} from '@grafana/scenes'; import { ConditionalRenderingGroupKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Stack } from '@grafana/ui'; @@ -33,6 +40,7 @@ export class ConditionalRenderingGroup extends SceneObjectBase; public constructor(state: ConditionalRenderingGroupState) { super(state); @@ -52,6 +60,19 @@ export class ConditionalRenderingGroup extends SceneObjectBase condition.forceCheck()); + } + public check() { // Filter out undefined results // Because we negate the result if shouldShow is false, we can use `condition.state.result ?? true` directly below diff --git a/public/app/features/dashboard-scene/conditional-rendering/hooks/useIsConditionallyHidden.tsx b/public/app/features/dashboard-scene/conditional-rendering/hooks/useIsConditionallyHidden.tsx index 2292b63500a..b8b77fd44d6 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/hooks/useIsConditionallyHidden.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/hooks/useIsConditionallyHidden.tsx @@ -1,12 +1,13 @@ import { ReactNode } from 'react'; -import { SceneObject, useSceneObjectState } from '@grafana/scenes'; +import { useSceneObjectState } from '@grafana/scenes'; import { ConditionalRenderingGroup } from '../group/ConditionalRenderingGroup'; import { ConditionalRenderingOverlay } from './ConditionalRenderingOverlay'; let placeholderConditionalRendering: ConditionalRenderingGroup | undefined; + function getPlaceholderConditionalRendering(): ConditionalRenderingGroup { if (!placeholderConditionalRendering) { placeholderConditionalRendering = ConditionalRenderingGroup.createEmpty(); @@ -14,13 +15,10 @@ function getPlaceholderConditionalRendering(): ConditionalRenderingGroup { return placeholderConditionalRendering; } -export function useIsConditionallyHidden(scene: SceneObject): [boolean, string | undefined, ReactNode | null, boolean] { - const conditionalRenderingToRender = - 'conditionalRendering' in scene.state && scene.state.conditionalRendering instanceof ConditionalRenderingGroup - ? scene.state.conditionalRendering - : getPlaceholderConditionalRendering(); - - const { result, renderHidden } = useSceneObjectState(conditionalRenderingToRender, { +export function useIsConditionallyHidden( + conditionalRendering: ConditionalRenderingGroup = getPlaceholderConditionalRendering() +): [boolean, string | undefined, ReactNode | null, boolean] { + const { result, renderHidden } = useSceneObjectState(conditionalRendering, { shouldActivateOrKeepAlive: true, }); diff --git a/public/app/features/dashboard-scene/conditional-rendering/object.ts b/public/app/features/dashboard-scene/conditional-rendering/object.ts index 67fca6942be..f29ad53c231 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/object.ts +++ b/public/app/features/dashboard-scene/conditional-rendering/object.ts @@ -1,7 +1,7 @@ import { capitalize, lowerCase } from 'lodash'; import { t } from '@grafana/i18n'; -import { SceneObject } from '@grafana/scenes'; +import { SceneObject, VizPanel } from '@grafana/scenes'; import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem'; import { RowItem } from '../scene/layout-rows/RowItem'; @@ -50,7 +50,7 @@ export function getLowerTranslatedObjectType(type: ObjectsWithConditionalRenderi export function extractObjectType(object: SceneObject | undefined): ObjectsWithConditionalRendering { if (!object) { return 'element'; - } else if (object instanceof AutoGridItem) { + } else if (object instanceof AutoGridItem || object instanceof VizPanel) { return 'panel'; } else if (object instanceof RowItem) { return 'row'; diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 5a1b3624a2b..64f0c774ea8 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -26,8 +26,12 @@ export interface DashboardEditPaneState extends SceneObjectState { undoStack: DashboardEditActionEventPayload[]; redoStack: DashboardEditActionEventPayload[]; + openPane?: DashboardSidebarPaneName; + isDocked?: boolean; } +export type DashboardSidebarPaneName = 'element' | 'outline' | 'filters'; + export class DashboardEditPane extends SceneObjectBase { public constructor() { super({ @@ -192,6 +196,7 @@ export class DashboardEditPane extends SceneObjectBase { this.setState({ selectionContext: { ...this.state.selectionContext, selected: [], enabled: false }, selection: undefined, + openPane: this.state.openPane === 'element' ? undefined : this.state.openPane, }); } @@ -227,7 +232,6 @@ export class DashboardEditPane extends SceneObjectBase { } const elementSelection = this.state.selection ?? new ElementSelection([[id, obj.getRef()]]); - const { selection, contextItems: selected } = elementSelection.getStateWithValue(id, obj, !!multi); this.updateSelection(new ElementSelection(selection), selected); @@ -255,17 +259,58 @@ export class DashboardEditPane extends SceneObjectBase { document.activeElement.blur(); } - this.setState({ selection, selectionContext: { ...this.state.selectionContext, selected } }); + this.setState({ + selection, + selectionContext: { ...this.state.selectionContext, selected }, + openPane: selection ? 'element' : undefined, + }); } - public clearSelection() { + /** + * @param force If force = true it will clear selection even when docked + * @returns + */ + public clearSelection(force = false) { if (!this.state.selection) { return; } + // If we are docked then clearing selection should select dashboard itself + // Unless the user explicitly closes pane + if (this.state.isDocked && !force) { + const obj = this.state.selection?.getFirstObject(); + const dashboard = getDashboardSceneFor(this); + if (obj !== dashboard) { + this.selectObject(dashboard, dashboard.state.key!); + } + return; + } + this.updateSelection(undefined, []); } + public openPane(openPane: DashboardSidebarPaneName) { + if (this.state.selection) { + this.clearSelection(true); + } + + if (openPane === this.state.openPane) { + this.setState({ openPane: undefined }); + } else { + this.setState({ openPane }); + } + } + + public closePane() { + if (this.state.selection) { + this.clearSelection(true); + } + + if (this.state.openPane) { + this.setState({ openPane: undefined }); + } + } + private newObjectAddedToCanvas(obj: SceneObject) { this.selectObject(obj, obj.state.key!); this.state.selection?.markAsNewElement(); diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.test.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.test.tsx index 78192a0c8fe..d7f5c73280d 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.test.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.test.tsx @@ -1,18 +1,17 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, screen } from '@testing-library/react'; +import { render } from 'test/test-utils'; import { getPanelPlugin } from '@grafana/data/test'; import { selectors } from '@grafana/e2e-selectors'; -import { setPluginImportUtils } from '@grafana/runtime'; +import { setPluginImportUtils, config } from '@grafana/runtime'; import { SceneGridLayout, SceneTimeRange, SceneVariableSet, VizPanel } from '@grafana/scenes'; import { DashboardScene } from '../scene/DashboardScene'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; -import { DashboardInteractions } from '../utils/interactions'; import { activateFullSceneTree } from '../utils/test-utils'; -import { DashboardEditPaneRenderer } from './DashboardEditPaneRenderer'; +import { DashboardEditPaneSplitter } from './DashboardEditPaneSplitter'; setPluginImportUtils({ importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), @@ -26,14 +25,9 @@ jest.mock('../utils/interactions', () => ({ }, })); -jest.mock('react-router-dom-v5-compat', () => ({ - ...jest.requireActual('react-router-dom-v5-compat'), - useLocation: () => ({ - pathname: '/dashboard/test', - search: '', - hash: '', - state: null, - }), +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + useChromeHeaderHeight: jest.fn().mockReturnValue(80), })); export function buildTestScene() { @@ -47,26 +41,43 @@ export function buildTestScene() { }), }), }); - activateFullSceneTree(testScene); return testScene; } describe('DashboardEditPaneRenderer', () => { - describe('outline interactions tracking', () => { - it('should call DashboardInteractions.outlineClicked when clicking on dashboard outline', async () => { - const user = userEvent.setup(); - const scene = buildTestScene(); - render( - {}} - /> - ); - const outlineButton = screen.getByTestId(selectors.components.PanelEditor.Outline.section); - await user.click(outlineButton); + config.featureToggles.dashboardNewLayouts = true; - expect(DashboardInteractions.dashboardOutlineClicked).toHaveBeenCalled(); - }); + it('Should render sidebar', async () => { + const scene = buildTestScene(); + + act(() => activateFullSceneTree(scene)); + + render(); + + expect(await screen.findByTestId(selectors.pages.Dashboard.Sidebar.outlineButton)).toBeInTheDocument(); }); + + it('Should sync sidebar docked state with edit pane state', async () => { + const scene = buildTestScene(); + render(); + + act(() => screen.getByLabelText('Outline').click()); + + expect(await screen.findByTestId(selectors.components.Sidebar.dockToggle)).toBeInTheDocument(); + + act(() => screen.getByTestId(selectors.components.Sidebar.dockToggle).click()); + + expect(scene.state.editPane.state.isDocked).toBe(true); + }); + + // describe('outline interactions tracking', () => { + // it('should call DashboardInteractions.outlineClicked when clicking on dashboard outline', async () => { + // const user = userEvent.setup(); + // const scene = buildTestScene(); + // render(); + // const outlineButton = screen.getByTestId(selectors.components.PanelEditor.Outline.section); + // await user.click(outlineButton); + // expect(DashboardInteractions.dashboardOutlineClicked).toHaveBeenCalled(); + // }); + // }); }); diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx index 9ca7fbab86e..80ec361476a 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx @@ -1,211 +1,160 @@ -import { css, cx } from '@emotion/css'; -import { Resizable } from 're-resizable'; -import { useLocalStorage } from 'react-use'; +import { useMemo } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { Trans, t } from '@grafana/i18n'; +import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { useSceneObjectState } from '@grafana/scenes'; -import { useStyles2, useSplitter, ToolbarButton, ScrollContainer, Text, Icon, clearButtonStyles } from '@grafana/ui'; +import { Sidebar } from '@grafana/ui'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { DashboardInteractions } from '../utils/interactions'; +import { DashboardScene } from '../scene/DashboardScene'; +import { onOpenSnapshotOriginalDashboard } from '../scene/GoToSnapshotOriginButton'; +import { ManagedDashboardNavBarBadge } from '../scene/ManagedDashboardNavBarBadge'; +import { ToolbarActionProps } from '../scene/new-toolbar/types'; +import { dynamicDashNavActions } from '../utils/registerDynamicDashNavAction'; import { DashboardEditPane } from './DashboardEditPane'; +import { ShareExportDashboardButton } from './DashboardExportButton'; import { DashboardOutline } from './DashboardOutline'; import { ElementEditPane } from './ElementEditPane'; -import { useEditableElement } from './useEditableElement'; export interface Props { editPane: DashboardEditPane; - isEditPaneCollapsed: boolean; - openOverlay?: boolean; - onToggleCollapse: () => void; + dashboard: DashboardScene; + isDocked?: boolean; } /** * Making the EditPane rendering completely standalone (not using editPane.Component) in order to pass custom react props */ -export function DashboardEditPaneRenderer({ editPane, isEditPaneCollapsed, onToggleCollapse, openOverlay }: Props) { - const { selection } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true }); - const styles = useStyles2(getStyles); - const clearButton = useStyles2(clearButtonStyles); - const editableElement = useEditableElement(selection, editPane); +export function DashboardEditPaneRenderer({ editPane, dashboard, isDocked }: Props) { + const { selection, openPane } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true }); + const { isEditing, meta, uid } = dashboard.useState(); + const hasUid = Boolean(uid); const selectedObject = selection?.getFirstObject(); - const isNewElement = selection?.isNewElement() ?? false; - const [outlineCollapsed, setOutlineCollapsed] = useLocalStorage( - 'grafana.dashboard.edit-pane.outline.collapsed', - false - ); - const [outlinePaneSize = 0.4, setOutlinePaneSize] = useLocalStorage('grafana.dashboard.edit-pane.outline.size', 0.4); - // splitter for template and payload editor - const splitter = useSplitter({ - direction: 'column', - handleSize: 'sm', - // if Grafana Alertmanager, split 50/50, otherwise 100/0 because there is no payload editor - initialSize: 1 - outlinePaneSize, - dragPosition: 'middle', - onSizeChanged: (size) => { - setOutlinePaneSize(1 - size); - }, - }); + const editableElement = useMemo(() => { + if (selection) { + return selection.createSelectionElement(); + } - if (!editableElement) { - return null; - } - - if (isEditPaneCollapsed) { - return ( - <> -
- -
- - {openOverlay && ( - - - - )} - - ); - } - - if (outlineCollapsed) { - splitter.primaryProps.style.flexGrow = 1; - splitter.primaryProps.style.minHeight = 'unset'; - splitter.secondaryProps.style.flexGrow = 0; - splitter.secondaryProps.style.minHeight = 'min-content'; - } else { - splitter.primaryProps.style.minHeight = 'unset'; - splitter.secondaryProps.style.minHeight = 'unset'; - } + return undefined; + }, [selection]); return ( -
-
-
+ <> + {editableElement && ( + -
-
-
- - {!outlineCollapsed && ( -
- - - -
- )} -
-
-
+ + )} + {openPane === 'outline' && ( + + + + )} + + {isEditing && ( + <> + {config.featureToggles.dashboardUndoRedo && ( + <> + + + + )} + editPane.selectObject(dashboard, dashboard.state.key!)} + title={t('dashboard.sidebar.dashboard-options.title', 'Options')} + tooltip={t('dashboard.sidebar.dashboard-options.tooltip', 'Dashboard options')} + data-testid={selectors.pages.Dashboard.Sidebar.optionsButton} + active={selectedObject === dashboard ? true : false} + /> + dashboard.openV2SchemaEditor()} + /> + + + )} + {hasUid && } + editPane.openPane('outline')} + title={t('dashboard.sidebar.outline.title', 'Outline')} + tooltip={t('dashboard.sidebar.outline.tooltip', 'Content outline')} + data-testid={selectors.pages.Dashboard.Sidebar.outlineButton} + active={openPane === 'outline'} + > + {dashboard.isManaged() && Boolean(meta.canEdit) && } + {renderEnterpriseItems()} + {Boolean(meta.isSnapshot) && ( + onOpenSnapshotOriginalDashboard(dashboard.getSnapshotUrl())} + /> + )} + + ); } -function getStyles(theme: GrafanaTheme2) { - return { - wrapper: css({ - display: 'flex', - flexDirection: 'column', - flex: '1 1 0', - marginTop: theme.spacing(2), - borderLeft: `1px solid ${theme.colors.border.weak}`, - borderTop: `1px solid ${theme.colors.border.weak}`, - background: theme.colors.background.primary, - borderTopLeftRadius: theme.shape.radius.default, - }), - overlayWrapper: css({ - right: 0, - bottom: 0, - top: theme.spacing(2), - position: 'absolute !important' as 'absolute', - background: theme.colors.background.primary, - borderLeft: `1px solid ${theme.colors.border.weak}`, - borderTop: `1px solid ${theme.colors.border.weak}`, - boxShadow: theme.shadows.z3, - zIndex: theme.zIndex.navbarFixed, - flexGrow: 1, - }), - paneContent: css({ - overflow: 'hidden', - display: 'flex', - flexDirection: 'column', - }), - rotate180: css({ - rotate: '180deg', - }), - tabsbar: css({ - padding: theme.spacing(0, 1), - margin: theme.spacing(0.5, 0), - }), - expandOptionsWrapper: css({ - display: 'flex', - flexDirection: 'column', - padding: theme.spacing(2, 1, 2, 0), - }), - splitter: css({ - '&::after': { - background: 'transparent', - transform: 'unset', - width: '100%', - height: '1px', - top: '100%', - left: '0', - }, - }), - outlineCollapseButton: css({ - display: 'flex', - padding: theme.spacing(0.5, 2), - gap: theme.spacing(1), - justifyContent: 'space-between', - alignItems: 'center', - background: theme.colors.background.secondary, +function renderEnterpriseItems() { + const dashboard = getDashboardSrv().getCurrent()!; + const showProps = { dashboard }; - '&:hover': { - background: theme.colors.action.hover, - }, - }), - outlineContainer: css({ - display: 'flex', - flexDirection: 'column', - flexGrow: 1, - overflow: 'hidden', - }), - }; + return dynamicDashNavActions.right.map((action, index) => { + if (action.show(showProps)) { + const ActionComponent = action.component; + return ; + } + return null; + }); +} + +function UndoButton({ dashboard }: ToolbarActionProps) { + const editPane = dashboard.state.editPane; + const { undoStack } = editPane.useState(); + const undoAction = undoStack[undoStack.length - 1]; + const undoWord = t('dashboard.sidebar.undo', 'Undo'); + const tooltip = `${undoWord}${undoAction?.description ? ` ${undoAction.description}` : ''}`; + + return ( + editPane.undoAction()} + title={undoWord} + tooltip={tooltip} + /> + ); +} + +function RedoButton({ dashboard }: ToolbarActionProps) { + const editPane = dashboard.state.editPane; + const { redoStack } = editPane.useState(); + const redoAction = redoStack[redoStack.length - 1]; + const redoWord = t('dashboard.sidebar.redo', 'Redo'); + const tooltip = `${redoWord}${redoAction?.description ? ` ${redoAction.description}` : ''}`; + + return ( + editPane.redoAction()} + /> + ); } diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx index 9ede14fe11d..59af972e3b0 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx @@ -1,19 +1,22 @@ import { css, cx } from '@emotion/css'; -import React, { CSSProperties, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, useChromeHeaderHeight } from '@grafana/runtime'; import { useSceneObjectState } from '@grafana/scenes'; -import { ElementSelectionContext, useStyles2 } from '@grafana/ui'; +import { ElementSelectionContext, useSidebar, useStyles2, Sidebar } from '@grafana/ui'; +import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import NativeScrollbar, { DivScrollElement } from 'app/core/components/NativeScrollbar'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { useSnappingSplitter } from '../panel-edit/splitter/useSnappingSplitter'; import { DashboardScene } from '../scene/DashboardScene'; import { NavToolbarActions } from '../scene/NavToolbarActions'; +import { PublicDashboardBadge } from '../scene/new-toolbar/actions/PublicDashboardBadge'; +import { StarButton } from '../scene/new-toolbar/actions/StarButton'; +import { dynamicDashNavActions } from '../utils/registerDynamicDashNavAction'; import { DashboardEditPaneRenderer } from './DashboardEditPaneRenderer'; -import { useEditPaneCollapsed } from './shared'; interface Props { dashboard: DashboardScene; @@ -26,7 +29,10 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls const headerHeight = useChromeHeaderHeight(); const { editPane } = dashboard.state; const styles = useStyles2(getStyles, headerHeight ?? 0); - const [isCollapsed, setIsCollapsed] = useEditPaneCollapsed(); + const hasUid = Boolean(dashboard.state.uid); + const canStar = Boolean(dashboard.state.meta.canStar); + + //const [isCollapsed, setIsCollapsed] = useEditPaneCollapsed(); if (!config.featureToggles.dashboardNewLayouts) { return ( @@ -40,21 +46,6 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls ); } - const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } = - useSnappingSplitter({ - direction: 'row', - dragPosition: 'end', - initialSize: 330, - handleSize: 'sm', - usePixels: true, - collapseBelowPixels: 250, - collapsed: isCollapsed, - }); - - useEffect(() => { - setIsCollapsed(splitterState.collapsed); - }, [splitterState.collapsed, setIsCollapsed]); - /** * Enable / disable selection based on dashboard isEditing state */ @@ -66,15 +57,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls } }, [isEditing, editPane]); - const { selectionContext } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true }); - const containerStyle: CSSProperties = {}; - - if (!isEditing) { - primaryProps.style.flexGrow = 1; - primaryProps.style.width = '100%'; - primaryProps.style.minWidth = 'unset'; - containerStyle.overflow = 'unset'; - } + const { selectionContext, openPane } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true }); const onBodyRef = (ref: HTMLDivElement | null) => { if (ref) { @@ -82,54 +65,74 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls } }; - return ( -
- -
{ - if (evt.shiftKey) { - return; - } + const sidebarContext = useSidebar({ + hasOpenPane: Boolean(openPane), + contentMargin: 1, + position: 'right', + onClosePane: () => editPane.closePane(), + }); - editPane.clearSelection(); - }} - > - -
{controls}
-
-
- {body} -
+ /** + * Sync docked state to editPane state + */ + useEffect(() => { + editPane.setState({ isDocked: sidebarContext.isDocked }); + }, [sidebarContext.isDocked, editPane]); + + const onClearSelection: React.PointerEventHandler = (evt) => { + if (evt.shiftKey) { + return; + } + + editPane.clearSelection(); + }; + + return ( +
+ + + {hasUid && canStar && } + {hasUid && canStar && } + {renderDynamicNavActions()} + + } + /> +
+ {controls} +
+
+
+ {body}
+ + +
- {isEditing && ( - <> -
-
- 0} - /> -
- - )}
); } +function renderDynamicNavActions() { + const dashboard = getDashboardSrv().getCurrent()!; + const showProps = { dashboard }; + + return dynamicDashNavActions.left.map((action, index) => { + if (action.show(showProps)) { + const ActionComponent = action.component; + return ; + } + return null; + }); +} + function getStyles(theme: GrafanaTheme2, headerHeight: number) { return { canvasWrappperOld: css({ @@ -138,22 +141,33 @@ function getStyles(theme: GrafanaTheme2, headerHeight: number) { flexDirection: 'column', flexGrow: 1, }), - canvasWithSplitter: css({ - overflow: 'unset', - display: 'flex', - flexDirection: 'column', - flexGrow: 1, - }), - canvasWithSplitterEditing: css({ - overflow: 'unset', - }), - bodyWrapper: css({ - label: 'body-wrapper', + container: css({ + label: 'container', display: 'flex', flexDirection: 'column', flexGrow: 1, position: 'relative', }), + bodyWrapper: css({ + label: 'body-wrapper', + display: 'flex', + flexDirection: 'row', + flexGrow: 1, + position: 'relative', + flex: '1 1 0', + overflow: 'hidden', + }), + bodyWithToolbar: css({ + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + minHeight: 0, + overflow: 'auto', + scrollbarWidth: 'thin', + scrollbarGutter: 'stable', + // without top padding the fixed controls headers is rendered over the selection outline. + padding: theme.spacing(0.125, 1, 2, 2), + }), body: css({ label: 'body', display: 'flex', @@ -181,11 +195,6 @@ function getStyles(theme: GrafanaTheme2, headerHeight: number) { // borderLeft: `1px solid ${theme.colors.border.weak}`, // background: theme.colors.background.primary, }), - splitter: css({ - '&:after': { - display: 'none', - }, - }), controlsWrapperSticky: css({ [theme.breakpoints.up('md')]: { position: 'sticky', diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx index 8c81d88b345..c68696e439d 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx @@ -8,10 +8,9 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan import { DashboardScene } from '../scene/DashboardScene'; import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector'; -import { EditSchemaV2Button } from '../scene/new-toolbar/actions/EditSchemaV2Button'; import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement'; -import { dashboardEditActions, undoRedoWasClicked } from './shared'; +import { dashboardEditActions } from './shared'; function useEditPaneOptions( this: DashboardEditableElement, @@ -69,19 +68,16 @@ export class DashboardEditableElement implements EditableDashboardElement { public renderActions(): ReactNode { return ( - <> - - - + ); } } @@ -104,7 +100,7 @@ export function DashboardTitleInput({ dashboard, id }: { dashboard: DashboardSce }} onBlur={(e) => { const titleUnchanged = valueBeforeEdit.current === e.currentTarget.value; - const shouldSkip = titleUnchanged || undoRedoWasClicked(e); + const shouldSkip = titleUnchanged; if (shouldSkip) { return; } @@ -135,7 +131,7 @@ export function DashboardDescriptionInput({ dashboard, id }: { dashboard: Dashbo }} onBlur={(e) => { const descriptionUnchanged = valueBeforeEdit.current === e.currentTarget.value; - const shouldSkip = descriptionUnchanged || undoRedoWasClicked(e); + const shouldSkip = descriptionUnchanged; if (shouldSkip) { return; } diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardExportButton.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardExportButton.tsx new file mode 100644 index 00000000000..35d8afbdb4d --- /dev/null +++ b/public/app/features/dashboard-scene/edit-pane/DashboardExportButton.tsx @@ -0,0 +1,57 @@ +import { selectors } from '@grafana/e2e-selectors'; +import { t } from '@grafana/i18n'; +import { locationService } from '@grafana/runtime'; +import { Dropdown, Sidebar } from '@grafana/ui'; +import { appEvents } from 'app/core/app_events'; +import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; +import { ShowConfirmModalEvent } from 'app/types/events'; + +import { DashboardScene } from '../scene/DashboardScene'; +import ExportMenu from '../sharing/ExportButton/ExportMenu'; +import { DashboardInteractions } from '../utils/interactions'; + +interface Props { + dashboard: DashboardScene; +} + +const newExportButtonSelector = selectors.pages.Dashboard.DashNav.NewExportButton; + +export function ShareExportDashboardButton({ dashboard }: Props) { + return ( + } placement="left-end"> + { + if (dashboard.state.isEditing && dashboard.state.isDirty) { + evt.preventDefault(); + evt.stopPropagation(); + + appEvents.publish( + new ShowConfirmModalEvent({ + title: t('dashboard.sidebar.export.unsaved-modal.title', 'Save changes to dashboard?'), + text: t( + 'dashboard.sidebar.export.unsaved-modal.text', + 'You have unsaved changes to this dashboard. You need to save them before you can share it.' + ), + icon: 'exclamation-triangle', + noText: t('common.discard', 'Discard'), + yesText: t('common.save', 'Save'), + yesButtonVariant: 'primary', + onConfirm: () => dashboard.openSaveDrawer({}), + }) + ); + } else { + locationService.partial({ shareView: shareDashboardType.export }); + + DashboardInteractions.sharingCategoryClicked({ + item: shareDashboardType.export, + shareResource: getTrackingSource(), + }); + } + }} + /> + + ); +} diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.test.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.test.tsx index 81d56cab57d..f743f0a3ae7 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.test.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.test.tsx @@ -5,7 +5,7 @@ import { getPanelPlugin } from '@grafana/data/test'; import { selectors } from '@grafana/e2e-selectors'; import { setPluginImportUtils } from '@grafana/runtime'; import { SceneVariableSet, VizPanel } from '@grafana/scenes'; -import { ElementSelectionContext } from '@grafana/ui'; +import { ElementSelectionContext, Sidebar, useSidebar } from '@grafana/ui'; import { DashboardScene } from '../scene/DashboardScene'; import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem'; @@ -86,6 +86,12 @@ function buildTestScene() { return testScene; } +function WrapSidebar({ children }: { children: React.ReactElement }) { + const sidebarContext = useSidebar({}); + + return {children}; +} + describe('DashboardOutline', () => { afterEach(() => { jest.clearAllMocks(); @@ -101,7 +107,9 @@ describe('DashboardOutline', () => { render( - + + + ); // select Row lvl 1 diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx index 48a9c738856..de80db73254 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx @@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { SceneObject } from '@grafana/scenes'; -import { Box, Icon, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui'; +import { Box, Icon, Sidebar, Text, useElementSelection, useStyles2 } from '@grafana/ui'; import { isRepeatCloneOrChildOf } from '../utils/clone'; import { DashboardInteractions } from '../utils/interactions'; @@ -17,28 +17,33 @@ import { useOutlineRename } from './useOutlineRename'; export interface Props { editPane: DashboardEditPane; + isEditing: boolean | undefined; } -export function DashboardOutline({ editPane }: Props) { +export function DashboardOutline({ editPane, isEditing }: Props) { const dashboard = getDashboardSceneFor(editPane); return ( - - - + <> + + + + + ); } interface DashboardOutlineNodeProps { sceneObject: SceneObject; editPane: DashboardEditPane; + isEditing: boolean | undefined; depth: number; index: number; } -function DashboardOutlineNode({ sceneObject, editPane, depth, index }: DashboardOutlineNodeProps) { +function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }: DashboardOutlineNodeProps) { const styles = useStyles2(getStyles); - const { key } = sceneObject.useState(); + const key = sceneObject.state.key; const [isCollapsed, setIsCollapsed] = useState(depth > 0); const { isSelected, onSelect } = useElementSelection(key); const isCloned = useMemo(() => isRepeatCloneOrChildOf(sceneObject), [sceneObject]); @@ -49,7 +54,7 @@ function DashboardOutlineNode({ sceneObject, editPane, depth, index }: Dashboard const children = editableElement.getOutlineChildren?.() ?? []; const elementInfo = editableElement.getEditableElementInfo(); const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName; - const outlineRename = useOutlineRename(editableElement); + const outlineRename = useOutlineRename(editableElement, isEditing); const isContainer = editableElement.getOutlineChildren ? true : false; const onNodeClicked = (e: React.MouseEvent) => { @@ -77,6 +82,7 @@ function DashboardOutlineNode({ sceneObject, editPane, depth, index }: Dashboard aria-selected={isSelected} className={styles.container} onClick={onNodeClicked} + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions style={{ '--depth': depth } as React.CSSProperties} >
@@ -91,7 +97,7 @@ function DashboardOutlineNode({ sceneObject, editPane, depth, index }: Dashboard )} diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index 5268ea4d865..0679ab32f88 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -2,6 +2,8 @@ import { css, cx } from '@emotion/css'; import { GrafanaTheme2, VariableHide } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { Trans } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { SceneObjectState, SceneObjectBase, @@ -15,7 +17,8 @@ import { SceneObjectUrlValues, CancelActivationHandler, } from '@grafana/scenes'; -import { Box, useStyles2 } from '@grafana/ui'; +import { Box, Button, useStyles2 } from '@grafana/ui'; +import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { PanelEditControls } from '../panel-edit/PanelEditControls'; import { getDashboardSceneFor } from '../utils/utils'; @@ -25,6 +28,9 @@ import { DashboardDataLayerControls } from './DashboardDataLayerControls'; import { DashboardLinksControls } from './DashboardLinksControls'; import { DashboardScene } from './DashboardScene'; import { VariableControls } from './VariableControls'; +import { EditDashboardSwitch } from './new-toolbar/actions/EditDashboardSwitch'; +import { SaveDashboard } from './new-toolbar/actions/SaveDashboard'; +import { ShareDashboardButton } from './new-toolbar/actions/ShareDashboardButton'; export interface DashboardControlsState extends SceneObjectState { timePicker: SceneTimePicker; @@ -32,7 +38,7 @@ export interface DashboardControlsState extends SceneObjectState { hideTimeControls?: boolean; hideVariableControls?: boolean; hideLinksControls?: boolean; - // Hides the dashbaord-controls dropdown menu + // Hides the dashboard-controls dropdown menu hideDashboardControls?: boolean; } @@ -170,7 +176,13 @@ function DashboardControlsRenderer({ model }: SceneComponentProps
)} - {!hideDashboardControls && model.hasDashboardControls() && } + {!hideDashboardControls && model.hasDashboardControls() && ( +
+ +
+ )} + {config.featureToggles.dashboardNewLayouts && } + {!hideLinksControls && !editPanel && }
{!hideVariableControls && ( <> @@ -178,13 +190,43 @@ function DashboardControlsRenderer({ model }: SceneComponentProps )} - {!hideLinksControls && !editPanel && } {editPanel && } {showDebugger && }
); } +function DashboardControlActions({ dashboard }: { dashboard: DashboardScene }) { + const { isEditing, editPanel, uid, meta } = dashboard.useState(); + const { isPlaying } = playlistSrv.useState(); + + if (editPanel) { + return null; + } + + const canEditDashboard = dashboard.canEditDashboard(); + const hasUid = Boolean(uid); + const isSnapshot = Boolean(meta.isSnapshot); + const showShareButton = hasUid && !isSnapshot && !isPlaying; + + return ( + <> + {showShareButton && } + {isEditing && } + {!isPlaying && canEditDashboard && } + {isPlaying && ( + + )} + + ); +} + function renderHiddenVariables(dashboard: DashboardScene) { const { variables } = sceneGraph.getVariables(dashboard).useState(); const renderAsHiddenVariables = variables.filter((v) => v.UNSAFE_renderAsHidden); @@ -230,17 +272,26 @@ function getStyles(theme: GrafanaTheme2) { }), rightControls: css({ display: 'flex', - justifyContent: 'flex-end', gap: theme.spacing(1), - marginBottom: theme.spacing(1), float: 'right', alignItems: 'center', + flexWrap: 'wrap', + maxWidth: '100%', + minWidth: 0, }), timeControls: css({ display: 'flex', justifyContent: 'flex-end', gap: theme.spacing(1), marginBottom: theme.spacing(1), + order: 2, + marginLeft: 'auto', + flexShrink: 0, + alignSelf: 'flex-start', + }), + dashboardControlsButton: css({ + order: 2, + marginLeft: 'auto', }), rightControlsWrap: css({ flexWrap: 'wrap', diff --git a/public/app/features/dashboard-scene/scene/DashboardControlsMenu.test.tsx b/public/app/features/dashboard-scene/scene/DashboardControlsMenu.test.tsx index fc6ab425ef9..101bd69ec97 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControlsMenu.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControlsMenu.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { VariableHide } from '@grafana/data'; @@ -65,15 +65,13 @@ describe('DashboardControlsMenu', () => { }), ]; - act(() => { - render(); - }); + render(); // Should have rendered a dropdown expect(screen.getByRole('button')).toBeInTheDocument(); // Open the dropdown - userEvent.click(screen.getByRole('button')); + await userEvent.click(screen.getByRole('button')); expect(await screen.findByText('textVar1')).toBeInTheDocument(); expect(await screen.findByText('textVar2')).toBeInTheDocument(); expect(await screen.findByText('queryVar')).toBeInTheDocument(); @@ -104,7 +102,7 @@ describe('DashboardControlsMenu', () => { expect(screen.getByRole('button')).toBeInTheDocument(); // Open the dropdown - userEvent.click(screen.getByRole('button')); + await userEvent.click(screen.getByRole('button')); expect(await screen.findByText('textVar1')).toBeInTheDocument(); expect(await screen.findByText('customVar')).toBeInTheDocument(); expect(screen.queryByText('textVar2')).not.toBeInTheDocument(); diff --git a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx index 8053055fe81..11639554e5d 100644 --- a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx @@ -1,5 +1,9 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; import { sceneGraph } from '@grafana/scenes'; import { DashboardLink } from '@grafana/schema'; +import { useStyles2 } from '@grafana/ui'; import { DashboardLinkRenderer } from './DashboardLinkRenderer'; import { DashboardScene } from './DashboardScene'; @@ -12,18 +16,33 @@ export interface Props { export function DashboardLinksControls({ links, dashboard }: Props) { sceneGraph.getTimeRange(dashboard).useState(); const uid = dashboard.state.uid; + const styles = useStyles2(getStyles); if (!links || !uid) { return null; } return ( - <> +
{links .filter((link) => link.placement === undefined) .map((link: DashboardLink, index: number) => ( ))} - +
); } + +function getStyles(theme: GrafanaTheme2) { + return { + linksContainer: css({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), + maxWidth: '100%', + minWidth: 0, + order: 1, + flex: '1 1 0%', + }), + }; +} diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 1c75360a7b5..a542dd92826 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -275,7 +275,7 @@ export class DashboardScene extends SceneObjectBase impleme this._initialUrlState = locationService.getLocation(); // Switch to edit mode - this.setState({ isEditing: true }); + this.setState({ isEditing: true, editable: true }); // Propagate change edit mode change to children this.state.body.editModeChanged?.(true); @@ -692,16 +692,16 @@ export class DashboardScene extends SceneObjectBase impleme canEditDashboard() { const { meta } = this.state; - return Boolean(meta.canEdit || meta.canMakeEditable || config.viewersCanEdit); + return !meta.isSnapshot && Boolean(meta.canEdit || meta.canMakeEditable || config.viewersCanEdit); } public getInitialSaveModel() { return this.serializer.initialSaveModel; } - public getSnapshotUrl = () => { - return this.serializer.getSnapshotUrl(); - }; + public getSnapshotUrl() { + return this.serializer.getSnapshotUrl() ?? ''; + } /** Hacky temp function until we refactor transformSaveModelToScene a bit */ setInitialSaveModel(model?: Dashboard, meta?: DashboardMeta, apiVersion?: string): void; diff --git a/public/app/features/dashboard-scene/scene/GoToSnapshotOriginButton.tsx b/public/app/features/dashboard-scene/scene/GoToSnapshotOriginButton.tsx index 2efcad5a256..80c8487f1c5 100644 --- a/public/app/features/dashboard-scene/scene/GoToSnapshotOriginButton.tsx +++ b/public/app/features/dashboard-scene/scene/GoToSnapshotOriginButton.tsx @@ -20,7 +20,7 @@ export function GoToSnapshotOriginButton(props: { originalURL: string }) { ); } -const onOpenSnapshotOriginalDashboard = (originalUrl: string) => { +export const onOpenSnapshotOriginalDashboard = (originalUrl: string) => { const relativeURL = originalUrl ?? ''; const sanitizedRelativeURL = textUtil.sanitizeUrl(relativeURL); try { diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 68ccadaba13..959913c8f8f 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -153,9 +153,7 @@ export function ToolbarActions({ dashboard }: Props) { toolbarActions.push({ group: 'icon-actions', condition: meta.isSnapshot && !isEditing, - render: () => ( - - ), + render: () => , }); if (!isEditingPanel && !isEditing) { diff --git a/public/app/features/dashboard-scene/scene/VizPanelHeaderActions.test.tsx b/public/app/features/dashboard-scene/scene/VizPanelHeaderActions.test.tsx new file mode 100644 index 00000000000..24b83e853fe --- /dev/null +++ b/public/app/features/dashboard-scene/scene/VizPanelHeaderActions.test.tsx @@ -0,0 +1,154 @@ +import { of } from 'rxjs'; + +import { DataQueryRequest, DataSourceApi, LoadingState } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test'; +import { setPluginImportUtils } from '@grafana/runtime'; +import { + GroupByVariable, + SceneDataTransformer, + SceneQueryRunner, + SceneVariableSet, + VizPanel, + VizPanelState, +} from '@grafana/scenes'; + +import { activateFullSceneTree } from '../utils/test-utils'; + +import { DashboardScene } from './DashboardScene'; +import { VizPanelHeaderActions } from './VizPanelHeaderActions'; +import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; + +const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { + return of({ + state: LoadingState.Loading, + series: [], + timeRange: request.range, + }); +}); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { + return runRequestMock(ds, request); + }, + getDataSourceSrv: () => ({ + get: jest.fn().mockResolvedValue({ + getRef: () => ({ uid: 'ds-1', type: 'test' }), + }), + getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds-1', type: 'test' }), + }), + getPluginImportUtils: () => ({ + getPanelPluginFromCache: jest.fn(() => undefined), + }), +})); + +setPluginImportUtils({ + importPanelPlugin: () => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: () => undefined, +}); + +describe('VizPanelHeaderActions', () => { + it('renders PanelGroupByAction when the group by variable applies to the panel', async () => { + const { headerActions } = await buildScene(); + + expect(headerActions.state.supportsApplicability).toBe(true); + }); + + it('does not set when applicability is disabled', async () => { + const { headerActions } = await buildScene({ applicabilityEnabled: false }); + + expect(headerActions.state.supportsApplicability).toBe(false); + }); + + it('does not set when the datasource uid does not match', async () => { + const { headerActions } = await buildScene({ + variableDatasourceUid: 'other-ds', + }); + + expect(headerActions.state.supportsApplicability).toBe(false); + }); + + it('does not set if variable ds changes to a different type', async () => { + const { headerActions, groupByVariable } = await buildScene(); + + expect(headerActions.state.supportsApplicability).toBe(true); + + groupByVariable.setState({ datasource: { uid: 'ds-2' } }); + + expect(headerActions.state.supportsApplicability).toBe(false); + }); + + it('does not set if variable applicability becomes disabled', async () => { + const { headerActions, groupByVariable } = await buildScene(); + + expect(headerActions.state.supportsApplicability).toBe(true); + + groupByVariable.setState({ applicabilityEnabled: false }); + + expect(headerActions.state.supportsApplicability).toBe(false); + }); + + it('sdoes not set if queryRunner changes datasource to different one than vars', async () => { + const { headerActions, queryRunner } = await buildScene(); + + expect(headerActions.state.supportsApplicability).toBe(true); + + queryRunner.setState({ datasource: { uid: 'ds-2' } }); + + expect(headerActions.state.supportsApplicability).toBe(false); + }); +}); + +interface BuildSceneOptions { + applicabilityEnabled?: boolean; + variableDatasourceUid?: string; +} + +async function buildScene(options?: BuildSceneOptions) { + const headerActions = new VizPanelHeaderActions({}); + + const queryRunner = new SceneQueryRunner({ + datasource: { uid: 'ds-1' }, + queries: [{ refId: 'A', datasource: { uid: 'ds-1' } }], + }); + + const groupByVariable = new GroupByVariable({ + name: 'group', + label: 'group', + value: [], + text: [], + options: [], + applicabilityEnabled: options?.applicabilityEnabled ?? true, + datasource: { uid: options?.variableDatasourceUid ?? 'ds-1' }, + }); + + const dataProvider = new SceneDataTransformer({ + $data: queryRunner, + transformations: [], + }); + + const panelState: VizPanelState = { + key: 'panel-1', + title: 'Panel A', + pluginId: 'timeseries', + headerActions, + $data: dataProvider, + options: {}, + fieldConfig: { defaults: {}, overrides: [] }, + }; + + const panel = new VizPanel(panelState); + + const scene = new DashboardScene({ + $variables: new SceneVariableSet({ + variables: [groupByVariable], + }), + body: DefaultGridLayoutManager.fromVizPanels([panel]), + }); + + activateFullSceneTree(scene); + + await new Promise((r) => setTimeout(r, 1)); + + return { headerActions, panel, groupByVariable, queryRunner }; +} diff --git a/public/app/features/dashboard-scene/scene/VizPanelHeaderActions.tsx b/public/app/features/dashboard-scene/scene/VizPanelHeaderActions.tsx new file mode 100644 index 00000000000..3130d746c94 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/VizPanelHeaderActions.tsx @@ -0,0 +1,136 @@ +import { Unsubscribable } from 'rxjs'; + +import { + GroupByVariable, + SceneComponentProps, + sceneGraph, + SceneObjectBase, + SceneObjectState, + SceneQueryRunner, + VizPanel, +} from '@grafana/scenes'; +import { DataSourceRef } from '@grafana/schema'; + +import { verifyDrilldownApplicability } from '../utils/drilldownUtils'; + +import { PanelGroupByAction } from './panel-actions/PanelGroupByAction/PanelGroupByAction'; + +export interface VizPanelHeaderActionsState extends SceneObjectState { + hideGroupByAction?: boolean; + supportsApplicability?: boolean; +} + +export class VizPanelHeaderActions extends SceneObjectBase { + static Component = VizPanelHeaderActionsRenderer; + + private _groupByVar?: GroupByVariable; + private _groupBySub?: Unsubscribable; + private _queryRunnerDatasource?: DataSourceRef; + + constructor(state: Partial) { + super({ + hideGroupByAction: state.hideGroupByAction ?? false, + ...state, + }); + + this.addActivationHandler(this._onActivate); + } + + private _onActivate = () => { + if (!this.parent || !(this.parent instanceof VizPanel)) { + throw new Error('VizPanelHeaderActions must be a child of a VizPanel'); + } + + if (!this.state.hideGroupByAction) { + this.subscribeToGroupByChanges(); + } + + return () => { + this._groupBySub?.unsubscribe(); + }; + }; + + private setAplicabilitySupport(groupByDs?: DataSourceRef | null, groupByApplicability?: boolean) { + this.setState({ + supportsApplicability: verifyDrilldownApplicability( + this, + this._queryRunnerDatasource, + groupByDs ?? this._groupByVar?.state.datasource ?? null, + groupByApplicability ?? this._groupByVar?.state.applicabilityEnabled ?? false + ), + }); + } + + private subscribeToGroupByChanges() { + const vars = sceneGraph.getVariables(this); + const queryRunner = this.getQueryRunner(); + + this._groupByVar = vars.state.variables.find((variable) => variable instanceof GroupByVariable); + this._queryRunnerDatasource = queryRunner?.state.datasource; + + this.setAplicabilitySupport(); + + // check when var set updates and search for groupBy var + this._subs.add( + vars.subscribeToState((n) => { + this._groupByVar = n.variables.find((variable) => variable instanceof GroupByVariable); + + if (this._groupByVar) { + this._groupBySub?.unsubscribe(); + this._groupBySub = this._groupByVar?.subscribeToState((n, p) => { + if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) { + this.setAplicabilitySupport(n.datasource, n.applicabilityEnabled); + } + }); + } + }) + ); + + // update query runner datasource changes + this._subs.add( + queryRunner?.subscribeToState((n, p) => { + if (n.datasource !== p.datasource) { + this._queryRunnerDatasource = n.datasource; + + this.setAplicabilitySupport(); + } + }) + ); + + this._groupBySub = this._groupByVar?.subscribeToState((n, p) => { + if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) { + this.setAplicabilitySupport(n.datasource, n.applicabilityEnabled); + } + }); + } + + public getQueryRunner() { + const panel = this.parent; + const dataObject = panel ? sceneGraph.getData(panel) : undefined; + const queryRunner = dataObject?.state.$data; + + if (!queryRunner || !(queryRunner instanceof SceneQueryRunner)) { + return null; + } + + return queryRunner; + } +} + +export function VizPanelHeaderActionsRenderer({ model }: SceneComponentProps) { + const { hideGroupByAction, supportsApplicability } = model.useState(); + const variables = sceneGraph.getVariables(model); + const groupByVariable = variables.state.variables.find((variable) => variable instanceof GroupByVariable); + const queryRunner = model.getQueryRunner(); + const queries = queryRunner?.state.data?.request?.targets ?? []; + + return ( + <> + {!hideGroupByAction && supportsApplicability && ( +
+ +
+ )} + + ); +} diff --git a/public/app/features/dashboard-scene/scene/VizPanelSubHeader.tsx b/public/app/features/dashboard-scene/scene/VizPanelSubHeader.tsx index f2247661e04..ff6f6c0712e 100644 --- a/public/app/features/dashboard-scene/scene/VizPanelSubHeader.tsx +++ b/public/app/features/dashboard-scene/scene/VizPanelSubHeader.tsx @@ -116,7 +116,7 @@ export class VizPanelSubHeader extends SceneObjectBase { private refreshDrilldownVarsSubscriptions() { if (this._groupByVar) { this._groupBySub?.unsubscribe(); - this._groupByVar?.subscribeToState((n, p) => { + this._groupBySub = this._groupByVar?.subscribeToState((n, p) => { if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) { this.setDrilldownApplicabilitySupportHelper(undefined, { datasource: n.datasource, @@ -127,7 +127,8 @@ export class VizPanelSubHeader extends SceneObjectBase { } if (this._adHocVar) { - this._adHocVar?.subscribeToState((n, p) => { + this._adHocSub?.unsubscribe(); + this._adHocSub = this._adHocVar?.subscribeToState((n, p) => { if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) { this.setDrilldownApplicabilitySupportHelper({ datasource: n.datasource, diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx index 5a88e451fb0..3b7b6cf03d2 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx @@ -31,6 +31,7 @@ export interface AutoGridItemState extends SceneObjectState { variableName?: string; isHidden?: boolean; conditionalRendering?: ConditionalRenderingGroup; + repeatedConditionalRendering?: ConditionalRenderingGroup[]; } export class AutoGridItem extends SceneObjectBase implements DashboardLayoutItem { @@ -130,7 +131,21 @@ export class AutoGridItem extends SceneObjectBase implements } } - this.setState({ repeatedPanels }); + let repeatedConditionalRendering: ConditionalRenderingGroup[] | undefined; + + if (this.state.conditionalRendering) { + repeatedConditionalRendering = repeatedPanels.reduce((acc, panel) => { + const conditionalRendering = this.state.conditionalRendering!.clone(); + conditionalRendering.setTarget(panel); + acc.push(conditionalRendering); + + return acc; + }, []); + + this.state.conditionalRendering.setTarget(panelToRepeat); + } + + this.setState({ repeatedPanels, repeatedConditionalRendering }); this._prevRepeatValues = values; } diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx index a41fb80a233..28fd18768d2 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx @@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data/'; import { LazyLoader, SceneComponentProps, VizPanel } from '@grafana/scenes'; import { useStyles2 } from '@grafana/ui'; +import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup'; import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useIsConditionallyHidden'; import { useDashboardState } from '../../utils/utils'; import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext'; @@ -17,8 +18,6 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps getIsLazy(preload), [preload]); @@ -29,18 +28,23 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps - isConditionallyHidden && !isEditing && !renderHidden ? null : ( + }) => { + const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay, renderHidden] = + useIsConditionallyHidden(conditionalRendering); + + return isConditionallyHidden && !isEditing && !renderHidden ? null : (
- ) + ); + } ), - [ - conditionalRenderingClass, - conditionalRenderingOverlay, - isLazy, - key, - model.containerRef, - styles, - isConditionallyHidden, - isEditing, - renderHidden, - ] + [model, isLazy, key, styles, isEditing] ); if (soloPanelContext) { @@ -102,10 +97,18 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps - - {repeatedPanels.map((item) => ( + + {repeatedPanels.map((item, idx) => ( setInputRef(ref)} + ref={(ref) => { + setInputRef(ref); + }} type="number" min={50} max={2000} @@ -233,7 +235,9 @@ function GridLayoutRows({ layoutManager }: { layoutManager: AutoGridLayoutManage id="min-height" defaultValue={rowHeight} onBlur={onCustomHeightChanged} - ref={(ref) => setInputRef(ref)} + ref={(ref) => { + setInputRef(ref); + }} type="number" min={50} max={2000} diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx index d35eb19c33c..4c74ee35d99 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx @@ -20,8 +20,9 @@ export function RowItemRenderer({ model }: SceneComponentProps) { const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState(); const isClone = isRepeatCloneOrChildOf(model); const { isEditing } = useDashboardState(model); - const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] = - useIsConditionallyHidden(model); + const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden( + model.state.conditionalRendering + ); const { isSelected, onSelect, isSelectable } = useElementSelection(key); const title = useInterpolatedTitle(model); const { rows } = model.getParentLayout().useState(); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx index 0e18ffd7ef0..7de374bc5cf 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx @@ -112,7 +112,10 @@ export function performRowRepeats(variable: MultiValueVariable, row: RowItem, co }); if (!isSourceRow) { + rowClone.state.conditionalRendering?.setTarget(rowClone); clonedRows.push(rowClone); + } else { + row.state.conditionalRendering?.setTarget(row); } } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index 49c3e0fd0a9..778aad91757 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -292,6 +292,8 @@ export class RowsLayoutManager extends SceneObjectBase i const conditionalRendering = tab.state.conditionalRendering; conditionalRendering?.clearParent(); + // We need to clear the target since we don't want to point the original tab anymore (if it was set) + conditionalRendering?.setTarget(undefined); rows.push( new RowItem({ diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx index 6ddd916cad2..0fa22e9d305 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx @@ -29,7 +29,7 @@ export function TabItemRenderer({ model }: SceneComponentProps) { const href = textUtil.sanitize(locationUtil.getUrlForPartial(location, { [urlKey]: mySlug })); const styles = useStyles2(getStyles); const pointerDistance = usePointerDistance(); - const [isConditionallyHidden] = useIsConditionallyHidden(model); + const [isConditionallyHidden] = useIsConditionallyHidden(model.state.conditionalRendering); const isClone = isRepeatCloneOrChildOf(model); const soloPanelContext = useSoloPanelContext(); @@ -56,7 +56,9 @@ export function TabItemRenderer({ model }: SceneComponentProps) { {(dragProvided, dragSnapshot) => (
dragProvided.innerRef(ref)} + ref={(ref) => { + dragProvided.innerRef(ref); + }} className={cx(dragSnapshot.isDragging && styles.dragging)} {...dragProvided.draggableProps} {...dragProvided.dragHandleProps} @@ -116,7 +118,9 @@ interface TabItemLayoutRendererProps { export function TabItemLayoutRenderer({ tab, isEditing }: TabItemLayoutRendererProps) { const { layout, key } = tab.useState(); const styles = useStyles2(getStyles); - const [_, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(tab); + const [_, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden( + tab.state.conditionalRendering + ); return ( i const conditionalRendering = row.state.conditionalRendering; conditionalRendering?.clearParent(); + // We need to clear the target since we don't want to point the original row anymore (if it was set) + conditionalRendering?.setTarget(undefined); tabs.push( new TabItem({ diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/RightActions.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/RightActions.tsx index 59dfc714669..2e2f979d51d 100644 --- a/public/app/features/dashboard-scene/scene/new-toolbar/RightActions.tsx +++ b/public/app/features/dashboard-scene/scene/new-toolbar/RightActions.tsx @@ -1,8 +1,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui'; +import { ToolbarButtonRow, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; @@ -11,45 +10,35 @@ import { isLibraryPanel } from '../../utils/utils'; import { DashboardScene } from '../DashboardScene'; import { BackToDashboardButton } from './actions/BackToDashboardButton'; -import { DashboardSettingsButton } from './actions/DashboardSettingsButton'; import { DiscardLibraryPanelButton } from './actions/DiscardLibraryPanelButton'; import { DiscardPanelButton } from './actions/DiscardPanelButton'; -import { EditDashboardSwitch } from './actions/EditDashboardSwitch'; -import { ExportDashboardButton } from './actions/ExportDashboardButton'; import { MakeDashboardEditableButton } from './actions/MakeDashboardEditableButton'; import { PlayListNextButton } from './actions/PlayListNextButton'; import { PlayListPreviousButton } from './actions/PlayListPreviousButton'; import { PlayListStopButton } from './actions/PlayListStopButton'; import { SaveDashboard } from './actions/SaveDashboard'; import { SaveLibraryPanelButton } from './actions/SaveLibraryPanelButton'; -import { ShareDashboardButton } from './actions/ShareDashboardButton'; import { UnlinkLibraryPanelButton } from './actions/UnlinkLibraryPanelButton'; -import { ToolbarActionProps } from './types'; import { getDynamicActions, renderActionElements } from './utils'; export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => { - const { editPanel, editable, editview, isEditing, uid, meta, viewPanel } = dashboard.useState(); + const { editPanel, editable, editview, isEditing, meta, viewPanel } = dashboard.useState(); const { isPlaying } = playlistSrv.useState(); const styles = useStyles2(getStyles); const isEditable = Boolean(editable); const canSave = Boolean(meta.canSave); - const hasUid = Boolean(uid); const isEditingDashboard = Boolean(isEditing); const hasEditView = Boolean(editview); const isEditingPanel = Boolean(editPanel); const isViewingPanel = Boolean(viewPanel); const isEditingLibraryPanel = isEditingPanel && isLibraryPanel(editPanel!.state.panelRef.resolve()); const isShowingDashboard = !hasEditView && !isViewingPanel && !isEditingPanel; - const isEditingAndShowingDashboard = isEditingDashboard && isShowingDashboard; - const isSnapshot = Boolean(meta.isSnapshot); const canSaveInFolder = contextSrv.hasEditPermissionInFolders; const canEditDashboard = dashboard.canEditDashboard(); const showPanelButtons = isEditingPanel && !hasEditView && !isViewingPanel; const showPlayButtons = isPlaying && isShowingDashboard && !isEditingDashboard; - const showShareButton = hasUid && !isSnapshot && !isPlaying && !isEditingPanel; - const showUndoRedoButtons = isEditingAndShowingDashboard && !!config.featureToggles.dashboardUndoRedo; return ( @@ -106,28 +95,10 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => { group: 'panel', condition: showPanelButtons && isEditingLibraryPanel, }, - { - key: 'dashboard-undo', - component: UndoButton, - group: 'dashboard', - condition: showUndoRedoButtons, - }, - { - key: 'dashboard-redo', - component: RedoButton, - group: 'dashboard', - condition: showUndoRedoButtons, - }, - { - key: 'dashboard-settings', - component: DashboardSettingsButton, - group: 'dashboard', - condition: isEditingAndShowingDashboard && canEditDashboard, - }, { key: 'save-dashboard', component: SaveDashboard, - group: 'save-edit', + group: 'panel', condition: isEditingDashboard && !isEditingLibraryPanel && (canSave || canSaveInFolder), }, { @@ -136,31 +107,6 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => { group: 'save-edit', condition: !isEditing && canEditDashboard && !isViewingPanel && !isEditable && !isPlaying, }, - { - key: 'edit-dashboard-switch', - component: EditDashboardSwitch, - group: 'save-edit', - condition: - canEditDashboard && - !isEditingPanel && - !isEditingLibraryPanel && - !isViewingPanel && - isEditable && - !isPlaying && - !isEditingPanel, - }, - { - key: 'new-export-dashboard-button', - component: ExportDashboardButton, - group: 'export-share', - condition: showShareButton, - }, - { - key: 'new-share-dashboard-button', - component: ShareDashboardButton, - group: 'export-share', - condition: showShareButton, - }, ], dashboard )} @@ -168,42 +114,6 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => { ); }; -export const undoButtonID = 'undo-button'; -function UndoButton({ dashboard }: ToolbarActionProps) { - const editPane = dashboard.state.editPane; - const { undoStack } = editPane.useState(); - const undoAction = undoStack[undoStack.length - 1]; - const tooltip = `Undo${undoAction?.description ? ` '${undoAction.description}'` : ''}`; - - return ( - editPane.undoAction()} - tooltip={tooltip} - /> - ); -} - -export const redoButtonId = 'redo-button'; -function RedoButton({ dashboard }: ToolbarActionProps) { - const editPane = dashboard.state.editPane; - const { redoStack } = editPane.useState(); - const redoAction = redoStack[redoStack.length - 1]; - const tooltip = `Redo${redoAction?.description ? ` '${redoAction.description}'` : ''}`; - - return ( - editPane.redoAction()} - /> - ); -} - const getStyles = (theme: GrafanaTheme2) => ({ container: css({ paddingLeft: theme.spacing(0.5) }), }); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/ExportDashboardButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/ExportDashboardButton.tsx deleted file mode 100644 index d8004d00b13..00000000000 --- a/public/app/features/dashboard-scene/scene/new-toolbar/actions/ExportDashboardButton.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; -import { t } from '@grafana/i18n'; -import { config, locationService } from '@grafana/runtime'; -import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; - -import ExportMenu from '../../../sharing/ExportButton/ExportMenu'; -import { DashboardInteractions } from '../../../utils/interactions'; -import { ToolbarActionProps } from '../types'; - -import { ShareExportDashboardButton } from './ShareExportDashboardButton'; - -const newExportButtonSelector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton; - -export const ExportDashboardButton = ({ dashboard }: ToolbarActionProps) => { - const buttonTooltip = config.featureToggles.kubernetesDashboards - ? t('dashboard.toolbar.new.export.tooltip.as-code', 'Export as code') - : t('dashboard.toolbar.new.export.tooltip.json', 'Export as JSON'); - - return ( - } - groupTestId={newExportButtonSelector.container} - buttonLabel={t('dashboard.toolbar.new.export.title', 'Export')} - buttonTooltip={buttonTooltip} - buttonTestId={newExportButtonSelector.container} - onButtonClick={() => { - locationService.partial({ shareView: shareDashboardType.export }); - - DashboardInteractions.sharingCategoryClicked({ - item: shareDashboardType.export, - shareResource: getTrackingSource(), - }); - }} - arrowLabel={t('dashboard.toolbar.new.export.arrow', 'Export')} - arrowTestId={newExportButtonSelector.arrowMenu} - dashboard={dashboard} - /> - ); -}; diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/OpenSnapshotOriginButton.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/OpenSnapshotOriginButton.tsx index c59d06a74ae..51d04d2272a 100644 --- a/public/app/features/dashboard-scene/scene/new-toolbar/actions/OpenSnapshotOriginButton.tsx +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/OpenSnapshotOriginButton.tsx @@ -2,5 +2,5 @@ import { GoToSnapshotOriginButton } from '../../GoToSnapshotOriginButton'; import { ToolbarActionProps } from '../types'; export const OpenSnapshotOriginButton = ({ dashboard }: ToolbarActionProps) => ( - + ); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/SaveDashboard.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/SaveDashboard.tsx index 0c33cf19b68..f2819e44c1c 100644 --- a/public/app/features/dashboard-scene/scene/new-toolbar/actions/SaveDashboard.tsx +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/SaveDashboard.tsx @@ -6,10 +6,12 @@ import { contextSrv } from 'app/core/services/context_srv'; import { ToolbarActionProps } from '../types'; export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => { - const { meta, isDirty, uid } = dashboard.state; + const { meta, isDirty, uid, editview, editPanel } = dashboard.state; const isNew = !Boolean(uid || dashboard.isManaged()); const isManaged = dashboard.isManaged(); + // In dashboard settings we still use the nav toolbar for a short while + const buttonSize = Boolean(editview) || editPanel ? 'sm' : 'md'; // if we only can save if (isNew) { @@ -17,7 +19,7 @@ export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => { diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/ToolbarSwitch.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/ToolbarSwitch.tsx index 59262fc152e..0ec0232e985 100644 --- a/public/app/features/dashboard-scene/scene/new-toolbar/actions/ToolbarSwitch.tsx +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/ToolbarSwitch.tsx @@ -43,7 +43,7 @@ export const ToolbarSwitch = ({ onClick={disabled ? undefined : onClick} >
- +
@@ -53,11 +53,11 @@ export const ToolbarSwitch = ({ const getStyles = (theme: GrafanaTheme2) => ({ container: css({ border: `1px solid ${theme.components.input.borderColor}`, - padding: theme.spacing(0.25), + padding: theme.spacing(0.5), backgroundColor: theme.components.input.background, borderRadius: theme.shape.radius.default, - width: theme.spacing(5.5), - height: theme.spacing(3), + width: theme.spacing(6.5), + height: theme.spacing(theme.components.height.md), cursor: 'pointer', display: 'flex', flexDirection: 'row', @@ -90,19 +90,19 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: 'flex', alignItems: 'center', justifyContent: 'center', - width: theme.spacing(2.5), + width: theme.spacing(3.5), height: '100%', transform: 'translateX(0)', position: 'relative', borderRadius: styleMixins.getInternalRadius(theme, 2), - border: `1px solid ${theme.colors.secondary.border}`, + border: `1px solid ${theme.colors.border.weak}`, [theme.transitions.handleMotion('no-preference', 'reduce')]: { transition: 'all 0.2s ease-in-out', }, }), boxChecked: css({ - transform: `translateX(calc(100% - ${theme.spacing(0.25)}))`, + transform: `translateX(calc(100% - 14px))`, borderColor: 'transparent', }), }); diff --git a/public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByAction.tsx b/public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByAction.tsx new file mode 100644 index 00000000000..b12cc52cbe3 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByAction.tsx @@ -0,0 +1,150 @@ +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; +import { lastValueFrom } from 'rxjs'; + +import { fuzzySearch } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { Trans } from '@grafana/i18n'; +import { GroupByVariable, SceneDataQuery, VariableValueOption, VariableValueSingle } from '@grafana/scenes'; +import { Button, Icon, Popover } from '@grafana/ui'; + +import { PanelGroupByActionPopover } from './PanelGroupByActionPopover'; + +interface Props { + groupByVariable: GroupByVariable; + queries: SceneDataQuery[]; +} + +export function PanelGroupByAction({ groupByVariable, queries }: Props) { + const { options: groupByOptions } = groupByVariable.useState(); + + const [options, setOptions] = useState([]); + const [selectedValues, setSelectedValues] = useState([]); + const [searchValue, setSearchValue] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [reloadOptions, setReloadOptions] = useState(false); + const [isPopoverVisible, setPopoverVisible] = useState(false); + + const ref = useRef(null); + + const fetchOptions = useCallback(async () => { + if (!groupByVariable || !reloadOptions) { + return; + } + + setIsLoading(true); + try { + if (groupByOptions.length === 0) { + await lastValueFrom(groupByVariable.validateAndUpdate()); + const options = await getApplicableGroupByOptions(groupByVariable, groupByVariable.state.options, queries); + + setSelectedValues(getGroupByValue(groupByVariable)); + setOptions(options); + return; + } + + const options = await getApplicableGroupByOptions(groupByVariable, groupByOptions, queries); + + setSelectedValues(getGroupByValue(groupByVariable)); + setOptions(options); + } catch (error) { + setSelectedValues([]); + setOptions([]); + } finally { + setIsLoading(false); + setReloadOptions(false); + } + }, [groupByOptions, groupByVariable, queries, reloadOptions]); + + useEffect(() => { + setReloadOptions(true); + }, [groupByOptions, queries]); + + useEffect(() => { + if (isPopoverVisible) { + fetchOptions(); + } + }, [fetchOptions, isPopoverVisible]); + + const filteredOptions = useMemo(() => { + if (!searchValue) { + return options; + } + + const haystack = options.map((option) => option.label); + const indices = fuzzySearch(haystack, searchValue); + return indices.map((idx) => options[idx]); + }, [options, searchValue]); + + const onCancel = () => { + setSearchValue(''); + setPopoverVisible(false); + }; + + const openPopover = () => { + // Reset checked state to match current variable value when opening + setSelectedValues(getGroupByValue(groupByVariable)); + setPopoverVisible(true); + }; + + return ( + + ); +} + +function getGroupByValue(groupByVariable: GroupByVariable) { + return Array.isArray(groupByVariable.state.value) + ? groupByVariable.state.value + : groupByVariable.state.value + ? [groupByVariable.state.value] + : []; +} + +async function getApplicableGroupByOptions( + groupByVariable: GroupByVariable, + options: VariableValueOption[], + queries: SceneDataQuery[] +) { + const values = options.map((option) => option.value); + const applicability = await groupByVariable.getGroupByApplicabilityForQueries(values, queries); + + return applicability + ? applicability.filter((item) => item.applicable).map((item) => ({ label: item.key, value: item.key })) + : options; +} diff --git a/public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByActionPopover.tsx b/public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByActionPopover.tsx new file mode 100644 index 00000000000..06191e1f7bf --- /dev/null +++ b/public/app/features/dashboard-scene/scene/panel-actions/PanelGroupByAction/PanelGroupByActionPopover.tsx @@ -0,0 +1,156 @@ +import { css, cx } from '@emotion/css'; +import { useCallback } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; +import { GroupByVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes'; +import { Button, Checkbox, ClickOutsideWrapper, Icon, Input, Stack, useStyles2 } from '@grafana/ui'; + +interface Props { + groupByVariable: GroupByVariable; + onCancel: () => void; + isLoading: boolean; + searchValue: string; + setSearchValue: (value: string) => void; + options: VariableValueOption[]; + values: VariableValueSingle[]; + onValuesChange: (value: VariableValueSingle[]) => void; +} + +export function PanelGroupByActionPopover({ + groupByVariable, + onCancel, + isLoading, + searchValue, + setSearchValue, + options, + values, + onValuesChange, +}: Props) { + const styles = useStyles2(getStyles); + + const onCheckedChanged = useCallback( + (option: VariableValueOption) => (event: React.FormEvent) => { + const newValues = event.currentTarget.checked + ? values.concat(option.value) + : values.filter((c) => c !== option.value); + + onValuesChange(newValues); + }, + [onValuesChange, values] + ); + + const isChecked = (option: VariableValueOption) => { + return values.includes(option.value); + }; + + const handleApply = useCallback(() => { + if (!values.length) { + return; + } + + groupByVariable.changeValueTo(values, values.map(String), true); + onCancel(); + }, [groupByVariable, onCancel, values]); + + const isAnyOptionChecked = () => { + if (!values.length) { + return false; + } + + return values.some((value) => options.find((option) => option.value === value)); + }; + + return ( + + {/* This is just blocking click events from bubbeling and should not have a keyboard interaction. */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} +
ev.stopPropagation()}> + +
+ } + placeholder={t('panel-group-by.search-placeholder', 'Search')} + value={searchValue} + onChange={(e) => setSearchValue(e.currentTarget.value)} + /> +
+ +
+ {isLoading ? ( +
+ Loading options +
+ ) : options.length === 0 ? ( +
+ No options found +
+ ) : ( + options.map((option) => { + return ( +
+ +
+ ); + }) + )} +
+ + + + + +
+
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + menuContainer: css({ + display: 'flex', + flexDirection: 'column', + background: theme.colors.background.elevated, + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + boxShadow: theme.shadows.z3, + padding: theme.spacing(2), + }), + searchContainer: css({ + width: '100%', + paddingBottom: theme.spacing(1), + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), + listContainer: css({ + flex: 1, + overflow: 'auto', + minHeight: '100px', + maxHeight: '300px', + padding: theme.spacing(0.5), + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), + option: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: theme.spacing(1), + cursor: 'pointer', + borderRadius: theme.shape.radius.default, + '&:hover': { + background: theme.colors.background.secondary, + }, + '&:focus-visible': { + outline: `2px solid ${theme.colors.primary.border}`, + outlineOffset: '-2px', + }, + }), + emptyMessage: css({ + padding: theme.spacing(2), + textAlign: 'center', + color: theme.colors.text.secondary, + }), +}); 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 index 6bddc68fb09..add2123cdad 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -194,18 +194,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "label": "Custom Variable", "multi": true, "name": "customVar", - "options": [ - { - "selected": true, - "text": "option1", - "value": "option1", - }, - { - "selected": false, - "text": "option2", - "value": "option2", - }, - ], + "options": [], "query": "option1, option2", "skipUrlSync": false, }, diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts index 512631961d3..788dd53d11a 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -31,6 +31,7 @@ import { LibraryPanelBehavior } from '../../scene/LibraryPanelBehavior'; import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks'; import { panelLinksBehavior, panelMenuBehavior } from '../../scene/PanelMenuBehavior'; import { PanelNotices } from '../../scene/PanelNotices'; +import { VizPanelHeaderActions } from '../../scene/VizPanelHeaderActions'; import { VizPanelSubHeader } from '../../scene/VizPanelSubHeader'; import { AutoGridItem } from '../../scene/layout-auto-grid/AutoGridItem'; import { DashboardGridItem } from '../../scene/layout-default/DashboardGridItem'; @@ -71,6 +72,9 @@ export function buildVizPanel(panel: PanelKind, id?: number): VizPanel { seriesLimit: config.panelSeriesLimit, $data: createPanelDataProvider(panel), titleItems, + headerActions: new VizPanelHeaderActions({ + hideGroupByAction: !config.featureToggles.panelGroupBy, + }), subHeader: new VizPanelSubHeader({ hideNonApplicableDrilldowns: !config.featureToggles.perPanelNonApplicableDrilldowns, }), @@ -121,6 +125,9 @@ export function buildLibraryPanel(panel: LibraryPanelKind, id?: number): VizPane }), ], extendPanelContext: setDashboardPanelContext, + headerActions: new VizPanelHeaderActions({ + hideGroupByAction: !config.featureToggles.panelGroupBy, + }), pluginId: LibraryPanelBehavior.LOADING_VIZ_PANEL_PLUGIN_ID, title: panel.spec.title, options: {}, diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts index 8772646b496..cc49cfadc77 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts @@ -371,23 +371,7 @@ describe('sceneVariablesSetToVariables', () => { "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", - }, - ], + "options": [], "query": "test,test1,test2", "type": "custom", } @@ -1161,23 +1145,7 @@ describe('sceneVariablesSetToVariables', () => { "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", - }, - ], + "options": [], "query": "test,test1,test2", "skipUrlSync": false, }, diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts index 49b84626f97..fa7c2cc3855 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts @@ -66,9 +66,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio 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 (transformVariableRefreshToEnum(variable.state.refresh) === 'never' || keepQueryOptions) { + if (keepQueryOptions) { options = variableValueOptionsToVariableOptions(variable.state); } variables.push({ @@ -106,7 +104,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio // @ts-expect-error value: variable.state.value, }, - options: variableValueOptionsToVariableOptions(variable.state), + options: [], query: variable.state.query, multi: variable.state.isMulti, allValue: variable.state.allValue, @@ -319,9 +317,7 @@ export function sceneVariablesSetToSchemaV2Variables( // Query variable 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) === 'never' || keepQueryOptions) { + if (keepQueryOptions) { options = variableValueOptionsToVariableOptions(variable.state); } const query = variable.state.query; @@ -385,13 +381,12 @@ export function sceneVariablesSetToSchemaV2Variables( // Custom variable } else if (sceneUtils.isCustomVariable(variable)) { - options = variableValueOptionsToVariableOptions(variable.state); const customVariable: CustomVariableKind = { kind: 'CustomVariable', spec: { ...commonProperties, current: currentVariableOption, - options, + options: [], query: variable.state.query, multi: variable.state.isMulti || false, allValue: variable.state.allValue, diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index 87391044813..f054a9f7cab 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -14,6 +14,7 @@ import { AdHocFiltersVariable, SceneDataTransformer, SceneGridItem, + SwitchVariable, } from '@grafana/scenes'; import { AdhocVariableKind, @@ -27,6 +28,7 @@ import { GroupByVariableKind, IntervalVariableKind, QueryVariableKind, + SwitchVariableKind, TextVariableKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples'; @@ -204,6 +206,14 @@ describe('transformSaveModelSchemaV2ToScene', () => { sceneVariableClass: AdHocFiltersVariable, index: 7, }); + validateVariable({ + sceneVariable: variables?.state.variables[8], + variableKind: dash.variables[8] as SwitchVariableKind, + scene: scene, + dashSpec: dash, + sceneVariableClass: SwitchVariable, + index: 8, + }); // Annotations expect(scene.state.$data).toBeInstanceOf(DashboardDataLayerSet); @@ -371,7 +381,7 @@ describe('transformSaveModelSchemaV2ToScene', () => { const scene = transformSaveModelSchemaV2ToScene(snapshot); // check variables were converted to snapshot variables - expect(scene.state.$variables?.state.variables).toHaveLength(8); + expect(scene.state.$variables?.state.variables).toHaveLength(9); expect(scene.state.$variables?.getByName('customVar')).toBeInstanceOf(SnapshotVariable); expect(scene.state.$variables?.getByName('adhocVar')).toBeInstanceOf(AdHocFiltersVariable); expect(scene.state.$variables?.getByName('intervalVar')).toBeInstanceOf(SnapshotVariable); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 41dcc938222..3c334b022c1 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -33,6 +33,7 @@ import { DashboardDTO, DashboardDataDTO } from 'app/types/dashboard'; import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior'; import { dashboardAnalyticsInitializer } from '../behaviors/DashboardAnalyticsInitializerBehavior'; +import { shouldForceV2API } from '../pages/DashboardScenePageStateManager'; import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; @@ -45,6 +46,7 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior'; import { PanelNotices } from '../scene/PanelNotices'; +import { VizPanelHeaderActions } from '../scene/VizPanelHeaderActions'; import { VizPanelSubHeader } from '../scene/VizPanelSubHeader'; import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; @@ -258,7 +260,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel, let annotationLayers: SceneDataLayerProvider[] = []; let alertStatesLayer: AlertStatesDataLayer | undefined; const uid = oldModel.uid; - const serializerVersion = config.featureToggles.dashboardNewLayouts && !oldModel.meta.isSnapshot ? 'v2' : 'v1'; + const serializerVersion = shouldForceV2API() && !oldModel.meta.isSnapshot ? 'v2' : 'v1'; if (oldModel.meta.isSnapshot) { variables = createVariablesForSnapshot(oldModel); @@ -441,6 +443,9 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem { hoverHeaderOffset: 0, $data: createPanelDataProvider(panel), titleItems, + headerActions: new VizPanelHeaderActions({ + hideGroupByAction: !config.featureToggles.panelGroupBy, + }), subHeader: new VizPanelSubHeader({ hideNonApplicableDrilldowns: !config.featureToggles.perPanelNonApplicableDrilldowns, }), diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx index 78876f4ee55..00e072e4d6f 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx @@ -9,7 +9,7 @@ import { Input, TextArea, Button, Field, Box, Stack } from '@grafana/ui'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; -import { dashboardEditActions, undoRedoWasClicked } from '../../edit-pane/shared'; +import { dashboardEditActions } from '../../edit-pane/shared'; import { useEditPaneInputAutoFocus } from '../../scene/layouts-shared/utils'; import { BulkActionElement } from '../../scene/types/BulkActionElement'; import { EditableDashboardElement, EditableDashboardElementInfo } from '../../scene/types/EditableDashboardElement'; @@ -161,7 +161,7 @@ function VariableNameInput({ variable, isNewElement }: { variable: SceneVariable onChange={onChange} onBlur={(e) => { const labelUnchanged = oldName.current === name; - const shouldSkip = labelUnchanged || undoRedoWasClicked(e); + const shouldSkip = labelUnchanged; if (nameError) { setNameError(undefined); @@ -200,7 +200,7 @@ function VariableLabelInput({ variable, id }: VariableInputProps) { onChange={(e) => variable.setState({ label: e.currentTarget.value })} onBlur={(e) => { const labelUnchanged = oldLabel.current === e.currentTarget.value; - const shouldSkip = labelUnchanged || undoRedoWasClicked(e); + const shouldSkip = labelUnchanged; if (shouldSkip) { return; @@ -232,7 +232,7 @@ function VariableDescriptionTextArea({ variable, id }: VariableInputProps) { onChange={(e) => variable.setState({ description: e.currentTarget.value })} onBlur={(e) => { const labelUnchanged = oldDescription.current === e.currentTarget.value; - const shouldSkip = labelUnchanged || undoRedoWasClicked(e); + const shouldSkip = labelUnchanged; if (shouldSkip) { return; diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx index 63a1912fde7..d3d23396974 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx @@ -178,7 +178,7 @@ describe('QueryVariableEditorForm', () => { await userEvent.click(screen.getByText(/prometheus/i)); expect(mockOnDataSourceChange).toHaveBeenCalledTimes(1); - expect(mockOnDataSourceChange).toHaveBeenCalledWith(promDatasource, undefined); + expect(mockOnDataSourceChange).toHaveBeenCalledWith(promDatasource); }); it('should call onQueryChange when changing the query', async () => { diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx index 3849fb0a2c5..030de016a5f 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx @@ -1,4 +1,4 @@ -import { FormEvent } from 'react'; +import { FormEvent, useCallback } from 'react'; import { useAsync } from 'react-use'; import { DataSourceInstanceSettings, SelectableValue, TimeRange } from '@grafana/data'; @@ -27,7 +27,7 @@ type VariableQueryType = QueryVariable['state']['query']; interface QueryVariableEditorFormProps { datasource?: DataSourceRef; - onDataSourceChange: (dsSettings: DataSourceInstanceSettings) => void; + onDataSourceChange: (dsSettings: DataSourceInstanceSettings, preserveQuery?: boolean) => void; query: VariableQueryType; onQueryChange: (query: VariableQueryType) => void; onLegacyQueryChange: (query: VariableQueryType, definition: string) => void; @@ -89,17 +89,23 @@ export function QueryVariableEditorForm({ onQueryChange(query); } + // update data source if it is not defined in variable model if (!datasourceRef) { const instanceSettings = getDataSourceSrv().getInstanceSettings({ type: datasource.type, uid: datasource.uid }); - if (instanceSettings) { - onDataSourceChange(instanceSettings); + onDataSourceChange(instanceSettings, true); } } return { datasource, VariableQueryEditor }; }, [datasourceRef]); + // adjusting type miss match between DataSourcePicker onChange and onDataSourceChange + const datasourceChangeHandler = useCallback( + (dsSettings: DataSourceInstanceSettings) => onDataSourceChange(dsSettings), + [onDataSourceChange] + ); + const { datasource, VariableQueryEditor } = dsConfig ?? {}; return ( @@ -111,7 +117,7 @@ export function QueryVariableEditorForm({ label={t('dashboard-scene.query-variable-editor-form.label-data-source', 'Data source')} htmlFor="data-source-picker" > - + {datasource && VariableQueryEditor && ( diff --git a/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.test.tsx index 44e0cb9a5e2..b376bc2b0e5 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/DataSourceVariableEditor.test.tsx @@ -2,6 +2,7 @@ import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { DataSourceVariable } from '@grafana/scenes'; diff --git a/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.test.tsx index ef51fde16f6..392f31ec92f 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/IntervalVariableEditor.test.tsx @@ -2,6 +2,7 @@ import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { IntervalVariable } from '@grafana/scenes'; diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx index 26f9516e40a..99385438e87 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx @@ -152,10 +152,12 @@ describe('QueryVariableEditor', () => { const onRunQueryMock = jest.fn(); const variable = new QueryVariable({ datasource: undefined, query: '' }); - await setup({ - variable, - onRunQuery: onRunQueryMock, - }); + await act(() => + setup({ + variable, + onRunQuery: onRunQueryMock, + }) + ); await waitFor(async () => { expect(variable.state.datasource).not.toBe(undefined); diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx index da1649776b2..106468b2b31 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx @@ -68,10 +68,10 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito const onAllowCustomValueChange = (event: FormEvent) => { variable.setState({ allowCustomValue: event.currentTarget.checked }); }; - const onDataSourceChange = (dsInstanceSettings: DataSourceInstanceSettings) => { + const onDataSourceChange = (dsInstanceSettings: DataSourceInstanceSettings, preserveQuery = false) => { const datasource = getDataSourceRef(dsInstanceSettings); - if ((variable.state.datasource?.type || '') !== datasource.type) { + if (!preserveQuery && (variable.state.datasource?.type || '') !== datasource.type) { variable.setState({ datasource, query: '', definition: '' }); return; } diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 7188756b055..6c486021e62 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -24,6 +24,7 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { panelMenuBehavior } from '../scene/PanelMenuBehavior'; import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel'; +import { VizPanelHeaderActions } from '../scene/VizPanelHeaderActions'; import { VizPanelSubHeader } from '../scene/VizPanelSubHeader'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { setDashboardPanelContext } from '../scene/setDashboardPanelContext'; @@ -283,6 +284,9 @@ export function getDefaultVizPanel(): VizPanel { menu: new VizPanelMenu({ $behaviors: [panelMenuBehavior], }), + headerActions: new VizPanelHeaderActions({ + hideGroupByAction: !config.featureToggles.panelGroupBy, + }), $data: new SceneDataTransformer({ $data: new SceneQueryRunner({ queries: [{ refId: 'A' }], diff --git a/public/app/features/dashboard/api/ResponseTransformers.test.ts b/public/app/features/dashboard/api/ResponseTransformers.test.ts index 5ce3e2ffadd..642729d9ed5 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.test.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.test.ts @@ -311,6 +311,31 @@ describe('ResponseTransformers', () => { type: 'query', query: { refId: 'A', query: 'label_values(grafanacloud_org_info{org_slug="$org_slug"}, org_id)' }, }, + { + type: 'switch', + name: 'var9', + label: 'Switch variable', + description: 'Switch variable description', + skipUrlSync: false, + hide: 0, + current: { + value: 'true', + text: 'true', + }, + options: [ + { + selected: true, + text: 'true', + value: 'true', + }, + { + selected: false, + text: 'false', + value: 'false', + }, + ], + query: '', + }, ], }, panels: [ @@ -523,6 +548,7 @@ describe('ResponseTransformers', () => { validateVariablesV1ToV2(spec.variables[6], dashboardV1.templating?.list?.[6]); validateVariablesV1ToV2(spec.variables[7], dashboardV1.templating?.list?.[7]); validateVariablesV1ToV2(spec.variables[8], dashboardV1.templating?.list?.[8]); + validateVariablesV1ToV2(spec.variables[9], dashboardV1.templating?.list?.[9]); }); }); @@ -930,6 +956,7 @@ describe('ResponseTransformers', () => { validateVariablesV1ToV2(dashboardV2.spec.variables[5], dashboard.templating?.list?.[5]); validateVariablesV1ToV2(dashboardV2.spec.variables[6], dashboard.templating?.list?.[6]); validateVariablesV1ToV2(dashboardV2.spec.variables[7], dashboard.templating?.list?.[7]); + validateVariablesV1ToV2(dashboardV2.spec.variables[8], dashboard.templating?.list?.[8]); // annotations validateAnnotation(dashboard.annotations!.list![0], dashboardV2.spec.annotations[0]); validateAnnotation(dashboard.annotations!.list![1], dashboardV2.spec.annotations[1]); @@ -1172,5 +1199,23 @@ describe('ResponseTransformers', () => { expect(v2.group).toEqual(v1.datasource?.type); expect(v2.spec.options).toEqual(v1.options); } + + if (v2.kind === 'SwitchVariable') { + // V1 switch variables have options array with exactly 2 options + // First option is enabledValue, second is disabledValue + const options = v1.options ?? []; + const enabledValueRaw = options[0]?.value ?? 'true'; + const disabledValueRaw = options[1]?.value ?? 'false'; + const enabledValue = Array.isArray(enabledValueRaw) ? enabledValueRaw[0] : enabledValueRaw; + const disabledValue = Array.isArray(disabledValueRaw) ? disabledValueRaw[0] : disabledValueRaw; + + // Current value should be a string (not array) + const currentValueRaw = v1.current?.value ?? disabledValue; + const currentValue = Array.isArray(currentValueRaw) ? currentValueRaw[0] : currentValueRaw; + + expect(v2.spec.current).toBe(currentValue); + expect(v2.spec.enabledValue).toBe(enabledValue); + expect(v2.spec.disabledValue).toBe(disabledValue); + } } }); diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index ca8a48de32d..d4bce12f211 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -34,6 +34,7 @@ import { IntervalVariableKind, TextVariableKind, GroupByVariableKind, + SwitchVariableKind, LibraryPanelKind, PanelKind, GridLayoutItemKind, @@ -809,6 +810,29 @@ function getVariables(vars: TypedVariableModel[]): DashboardV2Spec['variables'] variables.push(gb); break; + case 'switch': + // V1 switch variables have options array with exactly 2 options + // First option is typically enabledValue, second is disabledValue + const options = v.options ?? []; + const enabledValueRaw = options[0]?.value ?? 'true'; + const disabledValueRaw = options[1]?.value ?? 'false'; + const enabledValue = Array.isArray(enabledValueRaw) ? enabledValueRaw[0] : enabledValueRaw; + const disabledValue = Array.isArray(disabledValueRaw) ? disabledValueRaw[0] : disabledValueRaw; + // Current value should be a string (not array) + const currentValueRaw = v.current?.value ?? disabledValue; + const currentValue = Array.isArray(currentValueRaw) ? currentValueRaw[0] : currentValueRaw; + + const sw: SwitchVariableKind = { + kind: 'SwitchVariable', + spec: { + ...commonProperties, + current: currentValue, + enabledValue, + disabledValue, + }, + }; + variables.push(sw); + break; default: // do not throw error, just log it console.error(`Variable transformation not implemented: ${v.type}`); @@ -997,6 +1021,29 @@ function getVariablesV1(vars: DashboardV2Spec['variables']): VariableModel[] { }; variables.push(av); break; + case 'SwitchVariable': + const sv: VariableModel = { + ...commonProperties, + current: { + text: v.spec.current, + value: v.spec.current, + }, + options: [ + { + text: v.spec.enabledValue, + value: v.spec.enabledValue, + selected: v.spec.current === v.spec.enabledValue, + }, + { + text: v.spec.disabledValue, + value: v.spec.disabledValue, + selected: v.spec.current === v.spec.disabledValue, + }, + ], + query: '', + }; + variables.push(sv); + break; default: // do not throw error, just log it console.error(`Variable transformation not implemented: ${v}`); @@ -1256,6 +1303,8 @@ function transformToV1VariableTypes(variable: TypedVariableModelV2): VariableTyp return 'groupby'; case 'AdhocVariable': return 'adhoc'; + case 'SwitchVariable': + return 'switch'; default: throw new Error(`Unknown variable type: ${variable}`); } diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts index b0f582869a1..688218de024 100644 --- a/public/app/features/dashboard/api/utils.ts +++ b/public/app/features/dashboard/api/utils.ts @@ -16,6 +16,9 @@ export function isV2StoredVersion(version: string | undefined): boolean { export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') { const isDashboardSceneEnabled = config.featureToggles.dashboardScene; const isKubernetesDashboardsEnabled = config.featureToggles.kubernetesDashboards; + const isV2DashboardAPIVersionEnabled = config.featureToggles.kubernetesDashboardsV2; + const isDashboardNewLayoutsEnabled = config.featureToggles.dashboardNewLayouts; + const forcingOldDashboardArch = locationService.getSearch().get('scenes') === 'false'; // Force legacy API when dashboard scene is disabled or explicitly forced @@ -32,7 +35,7 @@ export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') { if (responseFormat === 'v1') { return 'v1'; } - if (responseFormat === 'v2') { + if (responseFormat === 'v2' || isV2DashboardAPIVersionEnabled || isDashboardNewLayoutsEnabled) { return 'v2'; } return 'unified'; diff --git a/public/app/features/dashboard/api/v1.ts b/public/app/features/dashboard/api/v1.ts index b8c57653923..c69093258cb 100644 --- a/public/app/features/dashboard/api/v1.ts +++ b/public/app/features/dashboard/api/v1.ts @@ -6,21 +6,22 @@ import { getFolderByUidFacade } from 'app/api/clients/folder/v1beta1/hooks'; import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors'; import { ScopedResourceClient } from 'app/features/apiserver/client'; import { - ResourceClient, - ResourceForCreate, - AnnoKeyMessage, AnnoKeyFolder, AnnoKeyGrantPermissions, - Resource, - DeprecatedInternalId, - AnnoKeyManagerKind, - AnnoKeySourcePath, AnnoKeyManagerAllowsEdits, - ManagerKind, + AnnoKeyManagerKind, + AnnoKeyMessage, + AnnoKeySourcePath, AnnoReloadOnParamsChange, + DeprecatedInternalId, + ManagerKind, + Resource, + ResourceClient, + ResourceForCreate, } from 'app/features/apiserver/types'; import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { buildSourceLink, removeExistingSourceLinks } from 'app/features/provisioning/utils/sourceLink'; import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/types/dashboard'; import { SaveDashboardCommand } from '../components/SaveDashboard/types'; @@ -160,6 +161,13 @@ export class K8sDashboardAPI implements DashboardAPI { result.meta.provisionedExternalId = annotations[AnnoKeySourcePath]; } + // Inject source link for repo-managed dashboards + const sourceLink = await buildSourceLink(annotations); + if (sourceLink) { + const linksWithoutSource = removeExistingSourceLinks(result.dashboard.links); + result.dashboard.links = [sourceLink, ...linksWithoutSource]; + } + if (dash.metadata.labels?.[DeprecatedInternalId]) { result.dashboard.id = parseInt(dash.metadata.labels[DeprecatedInternalId], 10); } diff --git a/public/app/features/dashboard/api/v2.ts b/public/app/features/dashboard/api/v2.ts index 33ec2323786..35c5e1a7a0d 100644 --- a/public/app/features/dashboard/api/v2.ts +++ b/public/app/features/dashboard/api/v2.ts @@ -9,8 +9,8 @@ import { AnnoKeyFolder, AnnoKeyFolderTitle, AnnoKeyFolderUrl, - AnnoKeyMessage, AnnoKeyGrantPermissions, + AnnoKeyMessage, DeprecatedInternalId, Resource, ResourceClient, @@ -18,6 +18,7 @@ import { } from 'app/features/apiserver/types'; import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { buildSourceLink, removeExistingSourceLinks } from 'app/features/provisioning/utils/sourceLink'; import { DashboardDTO, SaveDashboardResponseDTO } from 'app/types/dashboard'; import { SaveDashboardCommand } from '../components/SaveDashboard/types'; @@ -75,6 +76,13 @@ export class K8sDashboardV2API dashboard.metadata.annotations[AnnoKeyFolder] = ''; } + // Inject source link for repo-managed dashboards + const sourceLink = await buildSourceLink(dashboard.metadata.annotations); + if (sourceLink) { + const linksWithoutSource = removeExistingSourceLinks(dashboard.spec.links); + dashboard.spec.links = [sourceLink, ...linksWithoutSource]; + } + return dashboard; } catch (e) { const status = getStatusFromError(e); diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx index d7b32cfabb2..461052cbe2a 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -1,4 +1,4 @@ -import { useCallback, ChangeEvent, useState } from 'react'; +import { useCallback, ChangeEvent, useState, type JSX } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { TimeZone } from '@grafana/data'; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx b/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx index 2ba5aec62bb..57fe4325b14 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { useCallback } from 'react'; +import { useCallback, type JSX } from 'react'; import { GrafanaTheme2, renderMarkdown, LinkModelSupplier, ScopedVars, IconName } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelNotSupported.tsx b/public/app/features/dashboard/components/PanelEditor/PanelNotSupported.tsx index 0e52eb1c74c..1d1b739f87a 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelNotSupported.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelNotSupported.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, type JSX } from 'react'; import { Trans } from '@grafana/i18n'; import { locationService } from '@grafana/runtime'; diff --git a/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx b/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx index 926cc15cd45..9e2b0c6f578 100644 --- a/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ViewJsonModal.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, type JSX } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx b/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx index 78b04d0bef4..d4e11bdb3ae 100644 --- a/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx +++ b/public/app/features/dashboard/components/SubMenu/AnnotationPicker.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, type JSX } from 'react'; import { AnnotationQuery, EventBus, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx index bef4318e339..8c623c2c7ca 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx @@ -15,7 +15,7 @@ import { SuggestedDashboards } from '../DashboardLibrary/SuggestedDashboards'; import { DashboardEmptyExtensionPoint } from './DashboardEmptyExtensionPoint'; import { - useIsReadOnlyRepo, + useRepositoryStatus, useOnAddVisualization, useOnAddLibraryPanel, useOnImportDashboard, @@ -149,10 +149,10 @@ export interface Props { // We pass the default empty UI through to the extension point so that the extension can conditionally render it if needed. // For example, an extension might want to render custom UI for a specific experiment cohort, and the default UI for everyone else. const DashboardEmpty = (props: Props) => { - const isReadOnlyRepo = useIsReadOnlyRepo(props); - const onAddVisualization = useOnAddVisualization({ ...props, isReadOnlyRepo }); - const onAddLibraryPanel = useOnAddLibraryPanel({ ...props, isReadOnlyRepo }); - const onImportDashboard = useOnImportDashboard({ ...props, isReadOnlyRepo }); + const { isReadOnlyRepo, isProvisioned } = useRepositoryStatus(props); + const onAddVisualization = useOnAddVisualization({ ...props, isReadOnlyRepo, isProvisioned }); + const onAddLibraryPanel = useOnAddLibraryPanel({ ...props, isReadOnlyRepo, isProvisioned }); + const onImportDashboard = useOnImportDashboard({ ...props, isReadOnlyRepo, isProvisioned }); return ( { - const { isReadOnlyRepo } = useGetResourceRepositoryView({ +export const useRepositoryStatus = ({ dashboard }: Props) => { + const { isReadOnlyRepo, repository } = useGetResourceRepositoryView({ folderName: dashboard instanceof DashboardScene ? dashboard.state.meta.folderUid : dashboard.meta.folderUid, }); - return isReadOnlyRepo; + const isFolderProvisioned = Boolean(repository); + const isProvisioned = isFolderProvisioned || (dashboard instanceof DashboardScene && dashboard.isManagedRepository()); + + return { + isReadOnlyRepo, + isProvisioned, + }; }; interface HookProps extends Props { isReadOnlyRepo: boolean; + isProvisioned: boolean; } export const useOnAddVisualization = ({ dashboard, canCreate, isReadOnlyRepo }: HookProps) => { @@ -53,9 +60,7 @@ export const useOnAddVisualization = ({ dashboard, canCreate, isReadOnlyRepo }: }, [canCreate, isReadOnlyRepo, dashboard, dispatch, initialDatasource]); }; -export const useOnAddLibraryPanel = ({ dashboard, canCreate, isReadOnlyRepo }: HookProps) => { - const isProvisioned = dashboard instanceof DashboardScene && dashboard.isManagedRepository(); - +export const useOnAddLibraryPanel = ({ dashboard, canCreate, isReadOnlyRepo, isProvisioned }: HookProps) => { return useMemo(() => { if (!canCreate || isProvisioned || isReadOnlyRepo) { return undefined; @@ -72,8 +77,7 @@ export const useOnAddLibraryPanel = ({ dashboard, canCreate, isReadOnlyRepo }: H }, [canCreate, isProvisioned, isReadOnlyRepo, dashboard]); }; -export const useOnImportDashboard = ({ dashboard, canCreate, isReadOnlyRepo }: HookProps) => { - const isProvisioned = dashboard instanceof DashboardScene && dashboard.isManagedRepository(); +export const useOnImportDashboard = ({ canCreate, isReadOnlyRepo, isProvisioned }: HookProps) => { return useMemo(() => { if (!canCreate || isProvisioned || isReadOnlyRepo) { return undefined; diff --git a/public/app/features/dashboard/dashgrid/PanelLinks.tsx b/public/app/features/dashboard/dashgrid/PanelLinks.tsx index 3ad8fca5d9f..6e1a5cb2fb3 100644 --- a/public/app/features/dashboard/dashgrid/PanelLinks.tsx +++ b/public/app/features/dashboard/dashgrid/PanelLinks.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { DataLink, GrafanaTheme2, LinkModel } from '@grafana/data'; import { t } from '@grafana/i18n'; diff --git a/public/app/features/datasources/components/DataSourceAddButton.tsx b/public/app/features/datasources/components/DataSourceAddButton.tsx index f765188b50d..a3af80ccf6c 100644 --- a/public/app/features/datasources/components/DataSourceAddButton.tsx +++ b/public/app/features/datasources/components/DataSourceAddButton.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, type JSX } from 'react'; import { Pages } from '@grafana/e2e-selectors'; import { Trans } from '@grafana/i18n'; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 79678f4e15a..39c5b09dc6c 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -640,7 +640,9 @@ export class Explore extends PureComponent { )} (this.scrollElement = scrollElement || undefined)} + ref={(scrollElement) => { + this.scrollElement = scrollElement || undefined; + }} >
{datasourceInstance ? ( diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 02b63224e76..b958aebcc62 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -197,7 +197,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { ); const [dedupStrategy, setDedupStrategy] = useState(LogsDedupStrategy.none); const [logsSortOrder, setLogsSortOrder] = useState( - store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending + panelState?.logs?.sortOrder ?? store.get(SETTINGS_KEYS.logsSortOrder) ?? LogsSortOrder.Descending ); const [isFlipping, setIsFlipping] = useState(false); const [displayedFields, setDisplayedFields] = useState(panelState?.logs?.displayedFields ?? []); @@ -269,6 +269,18 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { } }, [dispatch, exploreId, loading, panelState, previousLoading]); + useEffect(() => { + // Initialize URL sort order + if (!panelState?.logs?.sortOrder) { + dispatch( + changePanelState(exploreId, 'logs', { + ...panelState, + sortOrder: logsSortOrder, + }) + ); + } + }, [dispatch, exploreId, logsSortOrder, panelState]); + useEffect(() => { const visualisationType = panelState?.logs?.visualisationType ?? getDefaultVisualisationType(); setVisualisationType(visualisationType); @@ -287,23 +299,17 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { useUnmount(() => { // 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, - columns: undefined, - visualisationType: visualisationType, - labelFieldName: undefined, - refId: undefined, - displayedFields: undefined, - }) - ); - } + dispatch( + changePanelState(exploreId, 'logs', { + ...panelState?.logs, + columns: undefined, + visualisationType: visualisationType, + labelFieldName: undefined, + refId: undefined, + displayedFields: undefined, + sortOrder: undefined, + }) + ); }); const updatePanelState = useCallback( @@ -398,8 +404,14 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { dispatch(changeQueries({ exploreId, queries: newQueries })); dispatch(runQueries({ exploreId })); } + dispatch( + changePanelState(exploreId, 'logs', { + ...panelState?.logs, + sortOrder: newSortOrder, + }) + ); }, - [dispatch, exploreId, logsQueries] + [dispatch, exploreId, logsQueries, panelState?.logs] ); const onChangeLogsSortOrder = useCallback( @@ -588,7 +600,12 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!); urlState.panelsState = { ...panelState, - logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields }, + logs: { + id: row.uid, + visualisationType: visualisationType ?? getDefaultVisualisationType(), + displayedFields, + sortOrder: logsSortOrder, + }, }; urlState.range = getLogsPermalinkRange(row, logRows, absoluteRange); @@ -604,7 +621,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { logRowLevel: row.logLevel, }); }, - [absoluteRange, displayedFields, exploreId, logRows, panelState, visualisationType] + [absoluteRange, displayedFields, exploreId, logRows, logsSortOrder, panelState, visualisationType] ); const scrollToTopLogs = useCallback(() => { diff --git a/public/app/features/explore/Logs/LogsSamplePanel.tsx b/public/app/features/explore/Logs/LogsSamplePanel.tsx index 5dfba3eee39..0f99fde8063 100644 --- a/public/app/features/explore/Logs/LogsSamplePanel.tsx +++ b/public/app/features/explore/Logs/LogsSamplePanel.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useRef } from 'react'; +import { useRef, type JSX } from 'react'; import { CoreApp, diff --git a/public/app/features/explore/MetaInfoText.tsx b/public/app/features/explore/MetaInfoText.tsx index d2106cb155d..8e3dfb48a3d 100644 --- a/public/app/features/explore/MetaInfoText.tsx +++ b/public/app/features/explore/MetaInfoText.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { memo } from 'react'; +import { memo, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.test.tsx index b57f4274519..6cab9c42f80 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.test.tsx @@ -13,6 +13,7 @@ // limitations under the License. import { render, screen, fireEvent, within } from '@testing-library/react'; +import type { JSX } from 'react'; import Scrubber, { ScrubberProps } from './Scrubber'; diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx index 5843cdec64e..ce86d210b63 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx @@ -197,7 +197,9 @@ export class UnthemedTraceTimelineViewer extends PureComponent { return (
ref && this.setState({ height: ref.getBoundingClientRect().height })} + ref={(ref: HTMLDivElement | null) => { + ref && this.setState({ height: ref.getBoundingClientRect().height }); + }} > { isLoading: false, }); }); + beforeEach(() => { + jest.mocked(usePluginLinksMock).mockClear(); + }); it('should render "Add" extension point menu button', () => { renderWithExploreStore(setupToolbarExtensionPoint()); @@ -180,6 +185,20 @@ describe('ToolbarExtensionPoint', () => { expect(extensionPointId).toBe(PluginExtensionPoints.ExploreToolbarAction); }); + + it('should pass panelsState to the extensions', async () => { + const panelsState: ExplorePanelsState = { + logs: { sortOrder: LogsSortOrder.Ascending, displayedFields: ['time', 'body'] }, + }; + const targets = [{ refId: 'A' }]; + const data = createEmptyQueryResponse(); + renderWithExploreStore(setupToolbarExtensionPoint(), { targets, data, panelsState }); + + const [options] = usePluginLinksMock.mock.calls[0]; + const { context } = options; + + expect(context).toHaveProperty('panelsState', panelsState); + }); }); describe('with extension points without categories', () => { diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx index 070e48caa47..f9d6abe3df2 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx @@ -1,6 +1,12 @@ import { ReactElement, useMemo, useState } from 'react'; -import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange, getTimeZone } from '@grafana/data'; +import { + type ExplorePanelsState, + type PluginExtensionLink, + PluginExtensionPoints, + RawTimeRange, + getTimeZone, +} from '@grafana/data'; import { reportInteraction, usePluginLinks } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; @@ -89,13 +95,14 @@ export type PluginExtensionExploreContext = { timeRange: RawTimeRange; timeZone: TimeZone; shouldShowAddCorrelation: boolean; + panelsSate?: ExplorePanelsState; }; function useExtensionPointContext(props: Props): PluginExtensionExploreContext { const { exploreId, timeZone } = props; const isCorrelationDetails = useSelector(selectCorrelationDetails); const isCorrelationsEditorMode = isCorrelationDetails?.editorMode || false; - const { queries, queryResponse, range } = useSelector(getExploreItemSelector(exploreId))!; + const { queries, queryResponse, range, panelsState } = useSelector(getExploreItemSelector(exploreId))!; const isLeftPane = useSelector(isLeftPaneSelector(exploreId)); const datasourceUids = queries.map((query) => query?.datasource?.uid).filter((uid) => uid !== undefined); @@ -110,16 +117,18 @@ function useExtensionPointContext(props: Props): PluginExtensionExploreContext { timeRange: range.raw, timeZone: getTimeZone({ timeZone }), shouldShowAddCorrelation: canWriteCorrelations && !isCorrelationsEditorMode && isLeftPane && numUniqueIds === 1, + panelsState, }; }, [ + canWriteCorrelations, exploreId, + isCorrelationsEditorMode, + isLeftPane, + numUniqueIds, + panelsState, queries, queryResponse, range.raw, timeZone, - canWriteCorrelations, - isCorrelationsEditorMode, - isLeftPane, - numUniqueIds, ]); } diff --git a/public/app/features/explore/extensions/ToolbarExtensionPointMenu.tsx b/public/app/features/explore/extensions/ToolbarExtensionPointMenu.tsx index 1500d9a2b91..467bfd7071e 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPointMenu.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPointMenu.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useMemo } from 'react'; +import { ReactElement, useMemo, type JSX } from 'react'; import { PluginExtensionLink } from '@grafana/data'; import { Menu } from '@grafana/ui'; diff --git a/public/app/features/expressions/ExpressionQueryEditor.tsx b/public/app/features/expressions/ExpressionQueryEditor.tsx index e08cdd5d94c..4e3d89cd005 100644 --- a/public/app/features/expressions/ExpressionQueryEditor.tsx +++ b/public/app/features/expressions/ExpressionQueryEditor.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useRef } from 'react'; import { DataSourceApi, FeatureState, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; import { Button, FeatureBadge, IconButton, InlineField, PopoverContent, useStyles2 } from '@grafana/ui'; import { ClassicConditions } from './components/ClassicConditions'; @@ -97,22 +96,6 @@ export function ExpressionQueryEditor(props: ExpressionQueryEditorProps) { const styles = useStyles2(getStyles); - const initialExpressionRef = useRef(query.expression); - const hasTrackedAddExpression = useRef(false); - - useEffect(() => { - // Only track if 1) query has a type, and 2) we haven't tracked yet for this component instance, and - // 3) initial expression was empty (indicating a new expression, not editing existing) - if (query.type && !hasTrackedAddExpression.current && !initialExpressionRef.current) { - reportInteraction('dashboards_expression_interaction', { - action: 'add_expression', - expression_type: query.type, - context: 'panel_query_section', - }); - hasTrackedAddExpression.current = true; - } - }, [query.type, query.refId]); - useEffect(() => { setCachedExpression(query.type, query.expression); }, [query.expression, query.type, setCachedExpression]); diff --git a/public/app/features/expressions/components/QueryToolbox.tsx b/public/app/features/expressions/components/QueryToolbox.tsx index 08a5abf3a99..ba104554f8f 100644 --- a/public/app/features/expressions/components/QueryToolbox.tsx +++ b/public/app/features/expressions/components/QueryToolbox.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; diff --git a/public/app/features/geo/utils/location.ts b/public/app/features/geo/utils/location.ts index 96e92929cdd..90aaa0f9d1e 100644 --- a/public/app/features/geo/utils/location.ts +++ b/public/app/features/geo/utils/location.ts @@ -68,6 +68,16 @@ const defaultMatchers: LocationFieldMatchers = { geo: (frame: DataFrame) => frame.fields.find((f) => f.type === FieldType.geo), }; +/** + * suggestions needs to run sync, and we just want to use the default matchers in that situation. + */ +export function getDefaultLocationMatchers(): LocationFieldMatchers { + return { + ...defaultMatchers, + mode: FrameGeometrySourceMode.Auto, + }; +} + export async function getLocationMatchers(src?: FrameGeometrySource): Promise { const info: LocationFieldMatchers = { ...defaultMatchers, diff --git a/public/app/features/library-panels/components/ChangeLibraryPanelModal/ChangeLibraryPanelModal.tsx b/public/app/features/library-panels/components/ChangeLibraryPanelModal/ChangeLibraryPanelModal.tsx index c90a9fb2128..181573ada7c 100644 --- a/public/app/features/library-panels/components/ChangeLibraryPanelModal/ChangeLibraryPanelModal.tsx +++ b/public/app/features/library-panels/components/ChangeLibraryPanelModal/ChangeLibraryPanelModal.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { t } from '@grafana/i18n'; import { ConfirmModal } from '@grafana/ui'; diff --git a/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx b/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx index 9ae73c361d8..1faa611d888 100644 --- a/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx +++ b/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { ReactElement, useState } from 'react'; +import { ReactElement, useState, type JSX } from 'react'; import Skeleton from 'react-loading-skeleton'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx index 27a6635fd3f..86afd93236c 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { memo, useCallback, useState } from 'react'; +import { memo, useCallback, useState, type JSX } from 'react'; import { useDebounce } from 'react-use'; import { GrafanaTheme2, PanelPluginMeta, SelectableValue } from '@grafana/data'; diff --git a/public/app/features/library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal.tsx b/public/app/features/library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal.tsx index 22f16e4dfab..70f331416ae 100644 --- a/public/app/features/library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal.tsx +++ b/public/app/features/library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal.tsx @@ -1,5 +1,5 @@ import debounce from 'debounce-promise'; -import { MouseEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { MouseEvent, useCallback, useEffect, useMemo, useState, type JSX } from 'react'; import { SelectableValue, urlUtil } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/features/logs/components/LogLabels.tsx b/public/app/features/logs/components/LogLabels.tsx index c112eee4f83..3fa2961723e 100644 --- a/public/app/features/logs/components/LogLabels.tsx +++ b/public/app/features/logs/components/LogLabels.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { memo, forwardRef, useMemo, useState } from 'react'; +import { memo, forwardRef, useMemo, useState, type JSX } from 'react'; import { GrafanaTheme2, Labels } from '@grafana/data'; import { t } from '@grafana/i18n'; diff --git a/public/app/features/logs/components/fieldSelector/AvailableFields.tsx b/public/app/features/logs/components/fieldSelector/AvailableFields.tsx index bef8933b352..bfbd10048a7 100644 --- a/public/app/features/logs/components/fieldSelector/AvailableFields.tsx +++ b/public/app/features/logs/components/fieldSelector/AvailableFields.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, type JSX } from 'react'; import { t } from '@grafana/i18n'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/logs/components/panel/LogListControlsOption.tsx b/public/app/features/logs/components/panel/LogListControlsOption.tsx index 774faf04d7d..aa276c03a42 100644 --- a/public/app/features/logs/components/panel/LogListControlsOption.tsx +++ b/public/app/features/logs/components/panel/LogListControlsOption.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import React from 'react'; +import React, { type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Dropdown, Icon, IconButton, Tooltip, useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx index b35bcce3afc..c1c28a809d2 100644 --- a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { JSX } from 'react'; import { t } from '@grafana/i18n'; import { Button, ModalsController, ButtonProps } from '@grafana/ui'; diff --git a/public/app/features/panel/components/PanelDataErrorView.tsx b/public/app/features/panel/components/PanelDataErrorView.tsx index 4c72a3c879f..93723b3bff1 100644 --- a/public/app/features/panel/components/PanelDataErrorView.tsx +++ b/public/app/features/panel/components/PanelDataErrorView.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import { CoreApp, + FieldType, getPanelDataSummary, GrafanaTheme2, PanelDataSummary, @@ -134,15 +135,15 @@ function getMessageFor( return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data'); } - if (needsStringField && !dataSummary.hasStringField) { + if (needsStringField && !dataSummary.hasFieldType(FieldType.string)) { return t('panel.panel-data-error-view.missing-value.string', 'Data is missing a string field'); } - if (needsNumberField && !dataSummary.hasNumberField) { + if (needsNumberField && !dataSummary.hasFieldType(FieldType.number)) { return t('panel.panel-data-error-view.missing-value.number', 'Data is missing a number field'); } - if (needsTimeField && !dataSummary.hasTimeField) { + if (needsTimeField && !dataSummary.hasFieldType(FieldType.time)) { return t('panel.panel-data-error-view.missing-value.time', 'Data is missing a time field'); } diff --git a/public/app/features/panel/suggestions/getAllSuggestions.test.ts b/public/app/features/panel/suggestions/getAllSuggestions.test.ts index e4a0c72c5d9..1cb10605ab0 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.test.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.test.ts @@ -5,12 +5,18 @@ import { LoadingState, PanelData, PanelPluginMeta, - toDataFrame, PanelPluginVisualizationSuggestion, + toDataFrame, } from '@grafana/data'; -import { GraphFieldConfig, ReduceDataOptions } from '@grafana/schema'; +import { + BarGaugeDisplayMode, + BigValueColorMode, + GraphFieldConfig, + ReduceDataOptions, + StackingMode, + VizOrientation, +} from '@grafana/schema'; import { config } from 'app/core/config'; -import { SuggestionName } from 'app/types/suggestions'; import { getAllSuggestions, panelsToCheckFirst } from './getAllSuggestions'; @@ -21,6 +27,8 @@ for (const pluginId of panelsToCheckFirst) { } as PanelPluginMeta; } +const SCALAR_PLUGINS = ['gauge', 'stat', 'bargauge', 'piechart', 'radialbar']; + config.panels['text'] = { id: 'text', name: 'Text', @@ -69,7 +77,10 @@ scenario('No series', (ctx) => { ctx.setData([]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel]); + expect(ctx.suggestions).toEqual([ + expect.objectContaining({ pluginId: 'table' }), + expect.objectContaining({ pluginId: 'text' }), + ]); }); }); @@ -84,7 +95,7 @@ scenario('No rows', (ctx) => { ]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([SuggestionName.Table]); + expect(ctx.suggestions).toEqual([expect.objectContaining({ pluginId: 'table' })]); }); }); @@ -100,33 +111,46 @@ scenario('Single frame with time and number field', (ctx) => { it('should return correct suggestions', () => { expect(ctx.suggestions).toEqual([ - expect.objectContaining({ name: SuggestionName.LineChart }), - expect.objectContaining({ name: SuggestionName.LineChartSmooth }), - expect.objectContaining({ name: SuggestionName.AreaChart }), - expect.objectContaining({ name: SuggestionName.LineChartGradientColorScheme }), - expect.objectContaining({ name: SuggestionName.BarChart }), - expect.objectContaining({ name: SuggestionName.BarChartGradientColorScheme }), - expect.objectContaining({ name: SuggestionName.Gauge }), - expect.objectContaining({ name: SuggestionName.GaugeNoThresholds }), - expect.objectContaining({ name: SuggestionName.Stat }), - expect.objectContaining({ name: SuggestionName.StatColoredBackground }), - expect.objectContaining({ name: SuggestionName.BarGaugeBasic }), - expect.objectContaining({ name: SuggestionName.BarGaugeLCD }), - expect.objectContaining({ name: SuggestionName.Table }), + expect.objectContaining({ pluginId: 'timeseries', name: 'Line chart' }), + expect.objectContaining({ pluginId: 'timeseries', name: 'Line chart - smooth' }), + expect.objectContaining({ pluginId: 'timeseries', name: 'Area chart' }), + expect.objectContaining({ pluginId: 'timeseries', name: 'Bar chart' }), + expect.objectContaining({ pluginId: 'gauge' }), + expect.objectContaining({ pluginId: 'gauge', options: expect.objectContaining({ showThresholdMarkers: false }) }), + expect.objectContaining({ pluginId: 'stat' }), + expect.objectContaining({ + pluginId: 'stat', + options: expect.objectContaining({ colorMode: BigValueColorMode.Background }), + }), + expect.objectContaining({ + pluginId: 'bargauge', + options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }), + }), + expect.objectContaining({ + pluginId: 'bargauge', + options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Lcd }), + }), + expect.objectContaining({ pluginId: 'table' }), expect.objectContaining({ pluginId: 'state-timeline' }), - expect.objectContaining({ name: SuggestionName.StatusHistory }), + expect.objectContaining({ pluginId: 'status-history' }), + expect.objectContaining({ pluginId: 'heatmap' }), + expect.objectContaining({ pluginId: 'histogram' }), ]); }); it('Bar chart suggestion should be using timeseries panel', () => { - expect(ctx.suggestions.find((x) => x.name === SuggestionName.BarChart)?.pluginId).toBe('timeseries'); + expect(ctx.suggestions.find((x) => x.name === 'Bar chart')?.pluginId).toBe('timeseries'); }); - it('Stat panels have reduce values disabled', () => { - for (const suggestion of ctx.suggestions) { - if (suggestion.options?.reduceOptions?.values) { - throw new Error(`Suggestion ${suggestion.name} reduce.values set to true when it should be false`); - } + it('Scalar panels should use calcs', () => { + for (const suggestion of ctx.suggestions.filter((s) => SCALAR_PLUGINS.includes(s.pluginId))) { + expect(suggestion).toEqual( + expect.objectContaining({ + options: expect.objectContaining({ + reduceOptions: expect.objectContaining({ values: false, calcs: ['lastNotNull'] }), + }), + }) + ); } }); }); @@ -144,31 +168,46 @@ scenario('Single frame with time 2 number fields', (ctx) => { it('should return correct suggestions', () => { expect(ctx.suggestions).toEqual([ - expect.objectContaining({ name: SuggestionName.LineChart }), - expect.objectContaining({ name: SuggestionName.LineChartSmooth }), - expect.objectContaining({ name: SuggestionName.AreaChartStacked }), - expect.objectContaining({ name: SuggestionName.AreaChartStackedPercent }), - expect.objectContaining({ name: SuggestionName.BarChartStacked }), - expect.objectContaining({ name: SuggestionName.BarChartStackedPercent }), - expect.objectContaining({ name: SuggestionName.Gauge }), - expect.objectContaining({ name: SuggestionName.GaugeNoThresholds }), - expect.objectContaining({ name: SuggestionName.Stat }), - expect.objectContaining({ name: SuggestionName.StatColoredBackground }), - expect.objectContaining({ name: SuggestionName.PieChart }), - expect.objectContaining({ name: SuggestionName.PieChartDonut }), - expect.objectContaining({ name: SuggestionName.BarGaugeBasic }), - expect.objectContaining({ name: SuggestionName.BarGaugeLCD }), - expect.objectContaining({ name: SuggestionName.Table }), + expect.objectContaining({ pluginId: 'timeseries', name: 'Line chart' }), + expect.objectContaining({ pluginId: 'timeseries', name: 'Line chart - smooth' }), + expect.objectContaining({ pluginId: 'timeseries', name: 'Area chart - stacked' }), + expect.objectContaining({ pluginId: 'timeseries', name: 'Area chart - stacked by percentage' }), + expect.objectContaining({ pluginId: 'timeseries', name: 'Bar chart - stacked' }), + expect.objectContaining({ pluginId: 'timeseries', name: 'Bar chart - stacked by percentage' }), + expect.objectContaining({ pluginId: 'gauge' }), + expect.objectContaining({ pluginId: 'gauge', options: expect.objectContaining({ showThresholdMarkers: false }) }), + expect.objectContaining({ pluginId: 'stat' }), + expect.objectContaining({ + pluginId: 'stat', + options: expect.objectContaining({ colorMode: BigValueColorMode.Background }), + }), + expect.objectContaining({ pluginId: 'piechart' }), + expect.objectContaining({ pluginId: 'piechart', options: expect.objectContaining({ pieType: 'donut' }) }), + expect.objectContaining({ + pluginId: 'bargauge', + options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }), + }), + expect.objectContaining({ + pluginId: 'bargauge', + options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Lcd }), + }), + expect.objectContaining({ pluginId: 'table' }), expect.objectContaining({ pluginId: 'state-timeline' }), - expect.objectContaining({ name: SuggestionName.StatusHistory }), + expect.objectContaining({ pluginId: 'status-history' }), + expect.objectContaining({ pluginId: 'heatmap' }), + expect.objectContaining({ pluginId: 'histogram' }), ]); }); - it('Stat panels have reduceOptions.values disabled', () => { - for (const suggestion of ctx.suggestions) { - if (suggestion.options?.reduceOptions?.values) { - throw new Error(`Suggestion ${suggestion.name} reduce.values set to true when it should be false`); - } + it('Scalar panels should use calcs', () => { + for (const suggestion of ctx.suggestions.filter((s) => SCALAR_PLUGINS.includes(s.pluginId))) { + expect(suggestion).toEqual( + expect.objectContaining({ + options: expect.objectContaining({ + reduceOptions: expect.objectContaining({ values: false, calcs: ['lastNotNull'] }), + }), + }) + ); } }); }); @@ -184,7 +223,7 @@ scenario('Single time series with 100 data points', (ctx) => { ]); it('should not suggest bar chart', () => { - expect(ctx.suggestions.find((x) => x.name === SuggestionName.BarChart)).toBe(undefined); + expect(ctx.suggestions.find((x) => x.name === 'Bar chart')).toBe(undefined); }); }); @@ -235,26 +274,42 @@ scenario('Single frame with string and number field', (ctx) => { ]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([ - SuggestionName.BarChart, - SuggestionName.BarChartHorizontal, - SuggestionName.Gauge, - SuggestionName.GaugeNoThresholds, - SuggestionName.Stat, - SuggestionName.StatColoredBackground, - SuggestionName.PieChart, - SuggestionName.PieChartDonut, - SuggestionName.BarGaugeBasic, - SuggestionName.BarGaugeLCD, - SuggestionName.Table, + expect(ctx.suggestions).toEqual([ + expect.objectContaining({ pluginId: 'piechart' }), + expect.objectContaining({ pluginId: 'piechart', options: expect.objectContaining({ pieType: 'donut' }) }), + expect.objectContaining({ pluginId: 'barchart' }), + expect.objectContaining({ + pluginId: 'barchart', + options: expect.objectContaining({ orientation: VizOrientation.Horizontal }), + }), + expect.objectContaining({ pluginId: 'gauge' }), + expect.objectContaining({ pluginId: 'gauge', options: expect.objectContaining({ showThresholdMarkers: false }) }), + expect.objectContaining({ pluginId: 'stat' }), + expect.objectContaining({ + pluginId: 'stat', + options: expect.objectContaining({ colorMode: BigValueColorMode.Background }), + }), + + expect.objectContaining({ + pluginId: 'bargauge', + options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }), + }), + expect.objectContaining({ + pluginId: 'bargauge', + options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Lcd }), + }), + expect.objectContaining({ pluginId: 'table' }), + expect.objectContaining({ pluginId: 'histogram' }), ]); }); - it('Stat/Gauge/BarGauge/PieChart panels to have reduceOptions.values enabled', () => { - for (const suggestion of ctx.suggestions) { - if (suggestion.options?.reduceOptions && !suggestion.options?.reduceOptions?.values) { - throw new Error(`Suggestion ${suggestion.name} reduce.values set to false when it should be true`); - } + it('Scalar panels should contain raw values', () => { + for (const suggestion of ctx.suggestions.filter((s) => SCALAR_PLUGINS.includes(s.pluginId))) { + expect(suggestion).toEqual( + expect.objectContaining({ + options: expect.objectContaining({ reduceOptions: expect.objectContaining({ values: true, calcs: [] }) }), + }) + ); } }); }); @@ -271,22 +326,48 @@ scenario('Single frame with string and 2 number field', (ctx) => { ]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([ - SuggestionName.BarChart, - SuggestionName.BarChartStacked, - SuggestionName.BarChartStackedPercent, - SuggestionName.BarChartHorizontal, - SuggestionName.BarChartHorizontalStacked, - SuggestionName.BarChartHorizontalStackedPercent, - SuggestionName.Gauge, - SuggestionName.GaugeNoThresholds, - SuggestionName.Stat, - SuggestionName.StatColoredBackground, - SuggestionName.PieChart, - SuggestionName.PieChartDonut, - SuggestionName.BarGaugeBasic, - SuggestionName.BarGaugeLCD, - SuggestionName.Table, + expect(ctx.suggestions).toEqual([ + expect.objectContaining({ pluginId: 'barchart' }), + expect.objectContaining({ + pluginId: 'barchart', + options: expect.objectContaining({ stacking: StackingMode.Normal }), + }), + expect.objectContaining({ + pluginId: 'barchart', + options: expect.objectContaining({ stacking: StackingMode.Percent }), + }), + + expect.objectContaining({ + pluginId: 'barchart', + options: expect.objectContaining({ orientation: VizOrientation.Horizontal }), + }), + expect.objectContaining({ + pluginId: 'barchart', + options: expect.objectContaining({ orientation: VizOrientation.Horizontal, stacking: StackingMode.Normal }), + }), + expect.objectContaining({ + pluginId: 'barchart', + options: expect.objectContaining({ orientation: VizOrientation.Horizontal, stacking: StackingMode.Percent }), + }), + expect.objectContaining({ pluginId: 'gauge' }), + expect.objectContaining({ pluginId: 'gauge', options: expect.objectContaining({ showThresholdMarkers: false }) }), + expect.objectContaining({ pluginId: 'stat' }), + expect.objectContaining({ + pluginId: 'stat', + options: expect.objectContaining({ colorMode: BigValueColorMode.Background }), + }), + expect.objectContaining({ pluginId: 'piechart' }), + expect.objectContaining({ pluginId: 'piechart', options: expect.objectContaining({ pieType: 'donut' }) }), + expect.objectContaining({ + pluginId: 'bargauge', + options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Basic }), + }), + expect.objectContaining({ + pluginId: 'bargauge', + options: expect.objectContaining({ displayMode: BarGaugeDisplayMode.Lcd }), + }), + expect.objectContaining({ pluginId: 'table' }), + expect.objectContaining({ pluginId: 'histogram' }), ]); }); }); @@ -299,11 +380,14 @@ scenario('Single frame with only string field', (ctx) => { ]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([SuggestionName.Stat, SuggestionName.Table]); + expect(ctx.suggestions).toEqual([ + expect.objectContaining({ pluginId: 'stat' }), + expect.objectContaining({ pluginId: 'table' }), + ]); }); it('Stat panels have reduceOptions.fields set to show all fields', () => { - for (const suggestion of ctx.suggestions) { + for (const suggestion of ctx.suggestions.filter((s) => s.pluginId === 'stat')) { if (suggestion.options?.reduceOptions) { expect(suggestion.options.reduceOptions.fields).toBe('/.*/'); } @@ -333,7 +417,10 @@ scenario('Given default loki logs data', (ctx) => { ]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([SuggestionName.Logs, SuggestionName.Table]); + expect(ctx.suggestions).toEqual([ + expect.objectContaining({ pluginId: 'logs' }), + expect.objectContaining({ pluginId: 'table' }), + ]); }); }); @@ -356,7 +443,7 @@ scenario('Given a preferredVisualisationType', (ctx) => { ]); it('should return the preferred visualization first', () => { - expect(ctx.names()[0]).toEqual(SuggestionName.Table); + expect(ctx.suggestions[0]).toEqual(expect.objectContaining({ pluginId: 'table' })); }); }); diff --git a/public/app/features/panel/suggestions/getAllSuggestions.ts b/public/app/features/panel/suggestions/getAllSuggestions.ts index ff6fbef50e8..a7c9764d2a0 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.ts @@ -4,6 +4,7 @@ import { VisualizationSuggestionsBuilder, PanelModel, VisualizationSuggestionScore, + PreferredVisualisationType, } from '@grafana/data'; import { config } from '@grafana/runtime'; import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; @@ -23,8 +24,26 @@ export const panelsToCheckFirst = [ 'flamegraph', 'traces', 'nodeGraph', + 'heatmap', + 'histogram', + 'geomap', ]; +/** + * some of the PreferredVisualisationTypes do not match the panel plugin ids, so we have to map them. d'oh. + */ +const PLUGIN_ID_TO_PREFERRED_VIZ_TYPE: Record = { + traces: 'trace', + timeseries: 'graph', + table: 'table', + logs: 'logs', + nodeGraph: 'nodeGraph', + flamegraph: 'flamegraph', +}; +const mapPreferredVisualisationTypeToPlugin = (type: string): PreferredVisualisationType | undefined => { + return PLUGIN_ID_TO_PREFERRED_VIZ_TYPE[type]; +}; + export async function getAllSuggestions( data?: PanelData, panel?: PanelModel @@ -61,13 +80,13 @@ export async function getAllSuggestions( } return list.sort((a, b) => { - if (builder.dataSummary.preferredVisualisationType) { - if (a.pluginId === builder.dataSummary.preferredVisualisationType) { - return -1; - } - if (b.pluginId === builder.dataSummary.preferredVisualisationType) { - return 1; - } + const mappedA = mapPreferredVisualisationTypeToPlugin(a.pluginId); + if (mappedA && builder.dataSummary.hasPreferredVisualisationType(mappedA)) { + return -1; + } + const mappedB = mapPreferredVisualisationTypeToPlugin(a.pluginId); + if (mappedB && builder.dataSummary.hasPreferredVisualisationType(mappedB)) { + return 1; } return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK); }); diff --git a/public/app/features/panel/suggestions/utils.ts b/public/app/features/panel/suggestions/utils.ts index ca5cc74bd77..07820587be5 100644 --- a/public/app/features/panel/suggestions/utils.ts +++ b/public/app/features/panel/suggestions/utils.ts @@ -1,4 +1,11 @@ -import { PanelData, PanelDataSummary } from '@grafana/data'; +import { + DataFrameType, + PanelData, + PanelDataSummary, + VisualizationSuggestion, + VisualizationSuggestionScore, +} from '@grafana/data'; +import { ReduceDataOptions } from '@grafana/schema'; /** * @internal @@ -10,6 +17,42 @@ export function showDefaultSuggestion(fn: (panelDataSummary: PanelDataSummary) = return (panelDataSummary: PanelDataSummary) => (fn(panelDataSummary) ? [{}] : undefined); } +/** + * @internal + * for panel plugins which render "scalar" data (stat, gauge, etc), this helper provides default reduce options + * depending on whether deaggregation is likely needed. + * @param suggestion the suggestion to modify + * @param panelDataSummary the panel data summary to use for scoring + * @param shouldUseRawValues if true, reduceOptions will be set to use raw values, + * otherwise a calcs will be used with the default value of `lastNotNull`. + */ +export function defaultNumericVizOptions( + suggestion: VisualizationSuggestion<{ reduceOptions?: ReduceDataOptions }>, + panelDataSummary: PanelDataSummary, + shouldUseRawValues: boolean +): VisualizationSuggestion { + suggestion.score = + (suggestion.score ?? + (panelDataSummary.hasDataFrameType(DataFrameType.NumericLong) || + panelDataSummary.hasDataFrameType(DataFrameType.NumericWide) || + panelDataSummary.hasDataFrameType(DataFrameType.NumericMulti))) + ? VisualizationSuggestionScore.Good + : VisualizationSuggestionScore.OK; + suggestion.options = suggestion.options ?? {}; + suggestion.options.reduceOptions = + suggestion.options.reduceOptions ?? + (shouldUseRawValues + ? { + values: true, + calcs: [], + } + : { + values: false, + calcs: ['lastNotNull'], + }); + return suggestion; +} + /** * @internal * Checks if the panel has data diff --git a/public/app/features/plugins/admin/components/PluginDetailsBody.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsBody.test.tsx index eb5e51d07b4..6ebd0e6af5c 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsBody.test.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsBody.test.tsx @@ -1,4 +1,5 @@ import { act, render, screen } from '@testing-library/react'; +import type { JSX } from 'react'; import { Provider } from 'react-redux'; import { PluginType } from '@grafana/data'; diff --git a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx index 0cfbf334fb6..d93c5a913b5 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useMemo } from 'react'; +import { useMemo, type JSX } from 'react'; import { GrafanaTheme2, PluginContextProvider, UrlQueryMap, PluginType } from '@grafana/data'; import { Trans } from '@grafana/i18n'; diff --git a/public/app/features/plugins/admin/components/PluginSubtitle.tsx b/public/app/features/plugins/admin/components/PluginSubtitle.tsx index 0ebd745495c..58fcfe87217 100644 --- a/public/app/features/plugins/admin/components/PluginSubtitle.tsx +++ b/public/app/features/plugins/admin/components/PluginSubtitle.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { Fragment } from 'react'; +import { Fragment, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Alert, Stack, useStyles2 } from '@grafana/ui'; diff --git a/public/app/features/plugins/admin/components/VersionInstallButton.test.tsx b/public/app/features/plugins/admin/components/VersionInstallButton.test.tsx index 3c2ea0ce379..f5954f85553 100644 --- a/public/app/features/plugins/admin/components/VersionInstallButton.test.tsx +++ b/public/app/features/plugins/admin/components/VersionInstallButton.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; +import type { JSX } from 'react'; import { Provider } from 'react-redux'; import { config } from '@grafana/runtime'; diff --git a/public/app/features/plugins/admin/components/VersionList.test.tsx b/public/app/features/plugins/admin/components/VersionList.test.tsx index f0a13ebfbda..1142e84e642 100644 --- a/public/app/features/plugins/admin/components/VersionList.test.tsx +++ b/public/app/features/plugins/admin/components/VersionList.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react'; +import type { JSX } from 'react'; import { Provider } from 'react-redux'; import { configureStore } from 'app/store/configureStore'; diff --git a/public/app/features/plugins/admin/pages/PluginDetails.tsx b/public/app/features/plugins/admin/pages/PluginDetails.tsx index 004fc0a87be..a3900d86961 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.tsx @@ -1,3 +1,4 @@ +import type { JSX } from 'react'; import { useParams } from 'react-router-dom-v5-compat'; import { PluginDetailsPage } from '../components/PluginDetailsPage'; diff --git a/public/app/features/plugins/extensions/registry/useRegistrySlice.test.tsx b/public/app/features/plugins/extensions/registry/useRegistrySlice.test.tsx index c31797ea2dd..3985345faa0 100644 --- a/public/app/features/plugins/extensions/registry/useRegistrySlice.test.tsx +++ b/public/app/features/plugins/extensions/registry/useRegistrySlice.test.tsx @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react'; -import React from 'react'; +import React, { type JSX } from 'react'; import { ExtensionRegistriesProvider } from '../ExtensionRegistriesContext'; diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx index 51341e2051f..44f35114b96 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx @@ -1,4 +1,5 @@ import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; +import type { JSX } from 'react'; import { PluginContextProvider, PluginLoadingStrategy, PluginMeta, PluginType } from '@grafana/data'; import { config } from '@grafana/runtime'; diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx index 930e2dda359..f9c41cdc9b4 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx @@ -1,5 +1,5 @@ import { act, render, renderHook, screen } from '@testing-library/react'; -import React from 'react'; +import React, { type JSX } from 'react'; import { PluginContextProvider, diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx index e02fa956bbb..c800bf08a31 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, type JSX } from 'react'; import { type ComponentTypeWithExtensionMeta, diff --git a/public/app/features/plugins/extensions/usePluginFunctions.test.tsx b/public/app/features/plugins/extensions/usePluginFunctions.test.tsx index 19e3a20fc83..40e8afc5a44 100644 --- a/public/app/features/plugins/extensions/usePluginFunctions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginFunctions.test.tsx @@ -1,4 +1,5 @@ import { act, renderHook } from '@testing-library/react'; +import type { JSX } from 'react'; import { PluginContextProvider, diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx index 4f018236a59..15a8417ce52 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx @@ -1,4 +1,5 @@ import { act, renderHook } from '@testing-library/react'; +import type { JSX } from 'react'; import { PluginContextProvider, diff --git a/public/app/features/provisioning/File/FilesView.test.tsx b/public/app/features/provisioning/File/FilesView.test.tsx deleted file mode 100644 index ed29db649ae..00000000000 --- a/public/app/features/provisioning/File/FilesView.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { render, screen, waitFor } from 'test/test-utils'; - -import { Repository, useGetRepositoryFilesQuery } from 'app/api/clients/provisioning/v0alpha1'; - -import { FilesView } from './FilesView'; - -jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ - useGetRepositoryFilesQuery: jest.fn(), -})); - -const mockUseGetRepositoryFilesQuery = jest.mocked(useGetRepositoryFilesQuery); -type RepositoryFilesQueryResult = ReturnType; - -const baseQueryResult = (): RepositoryFilesQueryResult => - ({ - currentData: undefined, - data: { items: [] }, - endpointName: 'getRepositoryFiles', - error: undefined, - fulfilledTimeStamp: undefined, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: false, - originalArgs: { name: '' }, - refetch: jest.fn(), - requestId: 'test-request', - startedTimeStamp: 0, - status: 'uninitialized', - subscriptionOptions: undefined, - unsubscribe: jest.fn(), - }) satisfies RepositoryFilesQueryResult; - -const mockRepositoryFilesQuery = (overrides: Partial = {}) => { - mockUseGetRepositoryFilesQuery.mockReturnValue({ - ...baseQueryResult(), - ...overrides, - }); -}; - -const defaultRepository: Repository = { - metadata: { name: 'test-repo' }, - spec: { - title: 'Test repository', - type: 'github', - workflows: ['write'], - sync: { enabled: true, target: 'folder' }, - github: { branch: 'main' }, - }, -}; - -const localRepository: Repository = { - metadata: { name: 'local-repo' }, - spec: { - title: 'Local repository', - type: 'local', - workflows: [], - sync: { enabled: true, target: 'folder' }, - local: {}, - }, -}; - -const renderComponent = (repo: Repository = defaultRepository) => { - return render(); -}; - -describe('FilesView', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders spinner while loading', () => { - mockRepositoryFilesQuery({ isLoading: true, status: 'pending', data: undefined }); - - renderComponent(); - - expect(screen.getByTestId('Spinner')).toBeInTheDocument(); - }); - - it('renders file rows with view and history links when data is available', () => { - mockRepositoryFilesQuery({ - isSuccess: true, - status: 'fulfilled', - data: { - items: [{ path: 'dashboards/example.json', hash: 'abc', size: '10' }], - }, - }); - - renderComponent(); - - const viewLink = screen.getByRole('link', { name: 'View' }); - expect(viewLink).toHaveAttribute('href', '/admin/provisioning/test-repo/file/dashboards/example.json'); - - const historyLink = screen.getByRole('link', { name: 'History' }); - expect(historyLink).toHaveAttribute( - 'href', - '/admin/provisioning/test-repo/history/dashboards/example.json?repo_type=github' - ); - }); - - it('filters files using search input', async () => { - const mockItems = [ - { path: 'dashboards/example.json', hash: 'abc', size: '10' }, - { path: 'dashboards/other.yaml', hash: 'def', size: '20' }, - ]; - - mockRepositoryFilesQuery({ - isSuccess: true, - status: 'fulfilled', - data: { - items: mockItems, - }, - }); - - const { user } = renderComponent(); - - expect(screen.getAllByRole('row')).toHaveLength( - // +1 for the header row - mockItems.length + 1 - ); - - const input = screen.getByPlaceholderText('Search'); - await user.clear(input); - await user.type(input, 'other'); - - await waitFor(() => - expect(screen.getAllByRole('row')).toHaveLength( - // +1 for the header row - 2 - ) - ); - expect(screen.getByText('dashboards/other.yaml')).toBeInTheDocument(); - }); - - it('hides history link when repository type is not supported', () => { - mockRepositoryFilesQuery({ - isSuccess: true, - status: 'fulfilled', - data: { - items: [{ path: 'dashboards/example.json', hash: 'abc', size: '10' }], - }, - }); - - renderComponent(localRepository); - - expect(screen.getByRole('link', { name: 'View' })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'History' })).not.toBeInTheDocument(); - }); - - it('renders plain text and hides actions for .keep files', () => { - mockRepositoryFilesQuery({ - isSuccess: true, - status: 'fulfilled', - data: { - items: [{ path: 'dashboards/.keep', hash: 'abc', size: '0' }], - }, - }); - - renderComponent(); - - expect(screen.getByText('dashboards/.keep')).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'dashboards/.keep' })).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'View' })).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'History' })).not.toBeInTheDocument(); - }); -}); diff --git a/public/app/features/provisioning/File/FilesView.tsx b/public/app/features/provisioning/File/FilesView.tsx deleted file mode 100644 index 97ac77b2fc4..00000000000 --- a/public/app/features/provisioning/File/FilesView.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useState } from 'react'; - -import { Trans, t } from '@grafana/i18n'; -import { CellProps, Column, FilterInput, InteractiveTable, LinkButton, Spinner, Stack } from '@grafana/ui'; -import { Repository, useGetRepositoryFilesQuery } from 'app/api/clients/provisioning/v0alpha1'; - -import { PROVISIONING_URL } from '../constants'; -import { FileDetails } from '../types'; - -import { isFileHistorySupported } from './utils'; - -interface FilesViewProps { - repo: Repository; -} - -type FileCell = CellProps; - -export function FilesView({ repo }: FilesViewProps) { - const name = repo.metadata?.name ?? ''; - const query = useGetRepositoryFilesQuery({ name }); - const [searchQuery, setSearchQuery] = useState(''); - const data = [...(query.data?.items ?? [])].filter((file) => - file.path.toLowerCase().includes(searchQuery.toLowerCase()) - ); - const showHistoryBtn = isFileHistorySupported(repo.spec?.type); - - const columns: Array> = [ - { - id: 'path', - header: 'Path', - sortType: 'string', - cell: ({ row: { original } }: FileCell<'path'>) => { - const { path } = original; - const isDotKeepFile = getIsDotKeepFile(path); - if (isDotKeepFile) { - return path; - } - return {path}; - }, - }, - { - id: 'hash', - header: 'Hash', - sortType: 'string', - }, - { - id: 'actions', - header: '', - cell: ({ row: { original } }: FileCell<'path'>) => { - const { path } = original; - const isDotKeepFile = getIsDotKeepFile(path); - if (isDotKeepFile) { - return null; - } - return ( - - {(path.endsWith('.json') || path.endsWith('.yaml') || path.endsWith('.yml')) && ( - - View - - )} - {showHistoryBtn && ( - - History - - )} - - ); - }, - }, - ]; - - if (query.isLoading) { - return ( - - - - ); - } - - return ( - - - - - String(f.path)} /> - - ); -} - -function getIsDotKeepFile(path: string): boolean { - // e.g. 'dashboards/.keep' → true, 'dashboards/example.keep.json' → false - return path.split('/').pop() === '.keep'; -} diff --git a/public/app/features/provisioning/Repository/RepositoryLink.tsx b/public/app/features/provisioning/Repository/RepositoryLink.tsx index f97a448efa4..fe4dbd4b9c5 100644 --- a/public/app/features/provisioning/Repository/RepositoryLink.tsx +++ b/public/app/features/provisioning/Repository/RepositoryLink.tsx @@ -4,7 +4,7 @@ import { Trans } from '@grafana/i18n'; import { LinkButton, Stack, Text, TextLink } from '@grafana/ui'; import { useGetRepositoryQuery } from 'app/api/clients/provisioning/v0alpha1'; -import { getRepoHref } from '../utils/git'; +import { getRepoHrefForProvider } from '../utils/git'; type RepositoryLinkProps = { name?: string; @@ -19,7 +19,7 @@ export function RepositoryLink({ name, jobType }: RepositoryLinkProps) { return null; } - const repoHref = getRepoHref(repo.spec?.github); + const repoHref = getRepoHrefForProvider(repo.spec); if (jobType === 'sync') { return ( diff --git a/public/app/features/provisioning/Repository/RepositoryCard.tsx b/public/app/features/provisioning/Repository/RepositoryListItem.tsx similarity index 84% rename from public/app/features/provisioning/Repository/RepositoryCard.tsx rename to public/app/features/provisioning/Repository/RepositoryListItem.tsx index 8a8205e9a2f..841ec3bd928 100644 --- a/public/app/features/provisioning/Repository/RepositoryCard.tsx +++ b/public/app/features/provisioning/Repository/RepositoryListItem.tsx @@ -2,12 +2,13 @@ import { ReactNode } from 'react'; import { t, Trans } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; -import { Stack, Text, TextLink, Icon, Card, LinkButton, Badge } from '@grafana/ui'; +import { Badge, Card, LinkButton, Stack, Text, TextLink } from '@grafana/ui'; import { Repository, ResourceCount } from 'app/api/clients/provisioning/v0alpha1'; import { RepoIcon } from '../Shared/RepoIcon'; import { StatusBadge } from '../Shared/StatusBadge'; import { PROVISIONING_URL } from '../constants'; +import { getRepoHrefForProvider } from '../utils/git'; import { getIsReadOnlyWorkflows } from '../utils/repository'; import { SyncRepository } from './SyncRepository'; @@ -16,7 +17,7 @@ interface Props { repository: Repository; } -export function RepositoryCard({ repository }: Props) { +export function RepositoryListItem({ repository }: Props) { const isReadOnlyRepo = getIsReadOnlyWorkflows(repository.spec?.workflows); const { metadata, spec, status } = repository; const name = metadata?.name ?? ''; @@ -27,24 +28,13 @@ export function RepositoryCard({ repository }: Props) { if (spec?.type === 'github') { const { url = '', branch } = spec.github ?? {}; const branchUrl = branch ? `${url}/tree/${branch}` : url; + const href = getRepoHrefForProvider(spec) || branchUrl; meta.push( - - {branchUrl} + + {href.split('/').slice(3).join('/')} ); - - if (status?.webhook?.id) { - const webhookUrl = `${url}/settings/hooks/${status.webhook.id}`; - meta.push( - - - Webhook - - - - ); - } } else if (spec?.type === 'local') { meta.push( diff --git a/public/app/features/provisioning/Repository/RepositoryResources.tsx b/public/app/features/provisioning/Repository/RepositoryResources.tsx deleted file mode 100644 index fdffe1cea74..00000000000 --- a/public/app/features/provisioning/Repository/RepositoryResources.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useMemo, useState } from 'react'; - -import { Trans, t } from '@grafana/i18n'; -import { CellProps, Column, FilterInput, InteractiveTable, Link, LinkButton, Spinner, Stack } from '@grafana/ui'; -import { Repository, ResourceListItem, useGetRepositoryResourcesQuery } from 'app/api/clients/provisioning/v0alpha1'; - -import { isFileHistorySupported } from '../File/utils'; -import { PROVISIONING_URL } from '../constants'; - -interface RepoProps { - repo: Repository; -} - -type ResourceCell = CellProps< - ResourceListItem, - ResourceListItem[T] ->; - -export function RepositoryResources({ repo }: RepoProps) { - const name = repo.metadata?.name ?? ''; - const query = useGetRepositoryResourcesQuery({ name }); - const [searchQuery, setSearchQuery] = useState(''); - const data = (query.data?.items ?? []).filter((Resource) => - Resource.path.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - // hide history button when repo type is pure git as it won't be implemented. - const historySupported = isFileHistorySupported(repo.spec?.type); - - const columns: Array> = useMemo( - () => [ - { - id: 'title', - header: 'Title', - sortType: 'string', - cell: ({ row: { original } }: ResourceCell<'title'>) => { - const { resource, name, title } = original; - if (resource === 'dashboards') { - return {title}; - } - if (resource === 'folders') { - return {title}; - } - return {title}; - }, - }, - { - id: 'resource', - header: 'Type', - sortType: 'string', - cell: ({ row: { original } }: ResourceCell<'resource'>) => { - return {original.resource}; - }, - }, - { - id: 'path', - header: 'Path', - sortType: 'string', - cell: ({ row: { original } }: ResourceCell<'path'>) => { - const { resource, name, path } = original; - if (resource === 'dashboards') { - return {path}; - } - return {path}; - }, - }, - { - id: 'hash', - header: 'Hash', - sortType: 'string', - cell: ({ row: { original } }: ResourceCell<'hash'>) => { - const { hash } = original; - return {hash.substring(0, 7)}; - }, - }, - { - id: 'folder', - header: 'Folder', - sortType: 'string', - cell: ({ row: { original } }: ResourceCell<'title'>) => { - const { folder } = original; - if (folder?.length) { - return {folder}; - } - return ; - }, - }, - { - id: 'actions', - header: '', - cell: ({ row: { original } }: ResourceCell) => { - const { resource, name, path } = original; - return ( - - {resource === 'dashboards' && ( - - View - - )} - {resource === 'folders' && ( - - View - - )} - {historySupported && ( - - History - - )} - - ); - }, - }, - ], - [repo.metadata?.name, historySupported, repo.spec?.type] - ); - - if (query.isLoading) { - return ( - - - - ); - } - - return ( - - - - - String(r.path)} - /> - - ); -} diff --git a/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx b/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx index 07eefb59ca9..b41bcc3260f 100644 --- a/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx +++ b/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx @@ -4,24 +4,22 @@ import { useParams } from 'react-router-dom-v5-compat'; import { SelectableValue, urlUtil } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Alert, EmptyState, Spinner, Tab, TabContent, TabsBar, Text, TextLink } from '@grafana/ui'; +import { Alert, EmptyState, Spinner, Stack, Tab, TabContent, TabsBar, Text, TextLink } from '@grafana/ui'; import { useGetFrontendSettingsQuery, useListRepositoryQuery } from 'app/api/clients/provisioning/v0alpha1'; import { Page } from 'app/core/components/Page/Page'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { isNotFoundError } from 'app/features/alerting/unified/api/util'; -import { FilesView } from '../File/FilesView'; import { InlineSecureValueWarning } from '../components/InlineSecureValueWarning'; import { PROVISIONING_URL } from '../constants'; import { RepositoryActions } from './RepositoryActions'; import { RepositoryOverview } from './RepositoryOverview'; -import { RepositoryResources } from './RepositoryResources'; +import { ResourceTreeView } from './ResourceTreeView'; enum TabSelection { Overview = 'overview', Resources = 'resources', - Files = 'files', } export default function RepositoryStatusPage() { @@ -50,12 +48,7 @@ export default function RepositoryStatusPage() { { value: TabSelection.Resources, label: t('provisioning.repository-status-page.tab-resources', 'Resources'), - title: t('provisioning.repository-status-page.tab-resources-title', 'Resources saved in grafana database'), - }, - { - value: TabSelection.Files, - label: t('provisioning.repository-status-page.tab-files', 'Files'), - title: t('provisioning.repository-status-page.tab-files-title', 'The raw file list from the repository'), + title: t('provisioning.repository-status-page.tab-resources-title', 'Repository files and resources'), }, ], [] @@ -99,7 +92,7 @@ export default function RepositoryStatusPage() { ) : ( <> {data ? ( - <> + {tabInfo.map((t: SelectableValue) => ( )} {tab === TabSelection.Overview && } - {tab === TabSelection.Resources && } - {tab === TabSelection.Files && } + {tab === TabSelection.Resources && } - + ) : (
not found diff --git a/public/app/features/provisioning/Repository/ResourceTreeView.tsx b/public/app/features/provisioning/Repository/ResourceTreeView.tsx new file mode 100644 index 00000000000..33471a77af6 --- /dev/null +++ b/public/app/features/provisioning/Repository/ResourceTreeView.tsx @@ -0,0 +1,226 @@ +import { css } from '@emotion/css'; +import { useMemo, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { + CellProps, + Column, + FilterInput, + Icon, + InteractiveTable, + Link, + LinkButton, + Spinner, + Stack, + useStyles2, +} from '@grafana/ui'; +import { + Repository, + useGetRepositoryFilesQuery, + useGetRepositoryResourcesQuery, +} from 'app/api/clients/provisioning/v0alpha1'; + +import { FlatTreeItem, TreeItem } from '../types'; +import { getRepoFileUrl } from '../utils/git'; +import { buildTree, filterTree, flattenTree, getIconName, mergeFilesAndResources } from '../utils/treeUtils'; + +interface ResourceTreeViewProps { + repo: Repository; +} + +type TreeCell = CellProps; + +function getGrafanaLink(item: TreeItem) { + if (item.resourceName) { + if (item.type === 'Dashboard') { + return `/d/${item.resourceName}`; + } + if (item.type === 'Folder') { + return `/dashboards/f/${item.resourceName}`; + } + } + return undefined; +} + +export function ResourceTreeView({ repo }: ResourceTreeViewProps) { + const styles = useStyles2(getStyles); + const name = repo.metadata?.name ?? ''; + + const filesQuery = useGetRepositoryFilesQuery({ name }); + const resourcesQuery = useGetRepositoryResourcesQuery({ name }); + + const [searchQuery, setSearchQuery] = useState(''); + + const isLoading = filesQuery.isLoading || resourcesQuery.isLoading; + + const flatItems = useMemo(() => { + const files = filesQuery.data?.items ?? []; + const resources = resourcesQuery.data?.items ?? []; + + const merged = mergeFilesAndResources(files, resources); + let tree = buildTree(merged); + + if (searchQuery) { + tree = filterTree(tree, searchQuery); + } + + return flattenTree(tree); + }, [filesQuery.data?.items, resourcesQuery.data?.items, searchQuery]); + + const columns: Array> = useMemo( + () => [ + { + id: 'title', + header: t('provisioning.resource-tree.header-title', 'Title'), + cell: ({ row: { original } }: TreeCell) => { + const { item, level } = original; + const iconName = getIconName(item.type); + const link = getGrafanaLink(item); + + return ( +
+ + {link ? {item.title} : {item.title}} +
+ ); + }, + }, + { + id: 'type', + header: t('provisioning.resource-tree.header-type', 'Type'), + cell: ({ row: { original } }: TreeCell) => { + return {original.item.type}; + }, + }, + { + id: 'status', + header: t('provisioning.resource-tree.header-status', 'Status'), + cell: ({ row: { original } }: TreeCell) => { + const { status } = original.item; + if (!status) { + return null; + } + return ( + + ); + }, + }, + { + id: 'hash', + header: t('provisioning.resource-tree.header-hash', 'Hash'), + cell: ({ row: { original } }: TreeCell) => { + const { hash } = original.item; + if (!hash) { + return null; + } + return ( + + {hash.substring(0, 7)} + + ); + }, + }, + { + id: 'actions', + header: '', + cell: ({ row: { original } }: TreeCell) => { + const { item } = original; + const isDotKeepFile = item.path.endsWith('.keep') || item.path.endsWith('.gitkeep'); + if (isDotKeepFile) { + return null; + } + + const viewLink = getGrafanaLink(item); + let sourceLink: string | undefined = undefined; + if (item.hasFile && repo.spec?.type) { + const spec = repo.spec; + const config = spec.github || spec.gitlab || spec.bitbucket; + if (config) { + sourceLink = getRepoFileUrl({ + repoType: spec.type, + url: config.url, + branch: config.branch, + filePath: item.path, + pathPrefix: config.path, + }); + } + } + + if (!viewLink && !sourceLink) { + return null; + } + + return ( + + {viewLink && ( + + View + + )} + {sourceLink && ( + + Source + + )} + + ); + }, + }, + ], + [repo.spec, styles] + ); + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + item.item.path} + /> + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + titleCell: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }), + icon: css({ + color: theme.colors.text.secondary, + flexShrink: 0, + }), + hash: css({ + fontFamily: theme.typography.fontFamilyMonospace, + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + }), + syncedIcon: css({ + color: theme.colors.success.text, + }), +}); diff --git a/public/app/features/provisioning/Shared/RepositoryList.tsx b/public/app/features/provisioning/Shared/RepositoryList.tsx index 3bfadfe872a..038359c6e8b 100644 --- a/public/app/features/provisioning/Shared/RepositoryList.tsx +++ b/public/app/features/provisioning/Shared/RepositoryList.tsx @@ -4,7 +4,7 @@ import { t, Trans } from '@grafana/i18n'; import { Alert, Box, EmptyState, FilterInput, Icon, Stack, TextLink } from '@grafana/ui'; import { Repository } from 'app/api/clients/provisioning/v0alpha1'; -import { RepositoryCard } from '../Repository/RepositoryCard'; +import { RepositoryListItem } from '../Repository/RepositoryListItem'; import { useResourceStats } from '../Wizard/hooks/useResourceStats'; import { UPGRADE_URL } from '../constants'; import { useIsProvisionedInstance } from '../hooks/useIsProvisionedInstance'; @@ -89,7 +89,7 @@ export function RepositoryList({ items }: Props) { )} {filteredItems.length ? ( - filteredItems.map((item) => ) + filteredItems.map((item) => ) ) : ( ({ ...jest.requireActual('@grafana/data'), diff --git a/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.tsx b/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.tsx index 8a796ee049c..932f7152dac 100644 --- a/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.tsx +++ b/public/app/features/provisioning/components/Shared/PreviewBannerViewPR.tsx @@ -1,7 +1,8 @@ import { textUtil } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { Alert, Box, Icon, Stack, TextLink } from '@grafana/ui'; -import { RepoTypeDisplay, RepoType } from 'app/features/provisioning/Wizard/types'; +import { RepoTypeDisplay } from 'app/features/provisioning/Wizard/types'; +import { isValidRepoType } from 'app/features/provisioning/guards'; import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam'; import { commonAlertProps } from '../Dashboards/DashboardPreviewBanner'; @@ -107,22 +108,20 @@ export function PreviewBannerViewPR({ prParam, isNewPr, behindBranch, repoUrl, b {/* when repo type is not local, we show branch information */} {showBranchInfo(repoType, branchInfo) && ( - branch: - {targetBranch} {'\u2192'}{' '} - {configuredBranch} + branch:{' '} + + {targetBranch} + {' '} + {'\u2192'}{' '} + + {configuredBranch} + )} ); } -export function isValidRepoType(repoType: string | undefined): repoType is RepoType { - if (typeof repoType !== 'string') { - return false; - } - return repoType in RepoTypeDisplay; -} - function showBranchInfo(repoType: string | undefined, branchInfo?: PreviewBranchInfo): boolean { const { targetBranch, configuredBranch, repoBaseUrl } = branchInfo || {}; return repoType !== 'local' && !!targetBranch && !!configuredBranch && !!repoBaseUrl; diff --git a/public/app/features/provisioning/guards.ts b/public/app/features/provisioning/guards.ts index 1ac4e9cfdae..c9a6f16cbe2 100644 --- a/public/app/features/provisioning/guards.ts +++ b/public/app/features/provisioning/guards.ts @@ -1,3 +1,5 @@ +import { RepoType, RepoTypeDisplay } from './Wizard/types'; + export interface HttpError extends Error { status?: number; } @@ -9,3 +11,10 @@ export function isSupportedGitProvider(provider: string): provider is 'github' | export function isHttpError(err: unknown): err is HttpError { return err instanceof Error && 'status' in err; } + +export function isValidRepoType(repoType: string | undefined): repoType is RepoType { + if (typeof repoType !== 'string') { + return false; + } + return repoType in RepoTypeDisplay; +} diff --git a/public/app/features/provisioning/hooks/usePullRequestParam.ts b/public/app/features/provisioning/hooks/usePullRequestParam.ts index 81d10210503..8ad124953a4 100644 --- a/public/app/features/provisioning/hooks/usePullRequestParam.ts +++ b/public/app/features/provisioning/hooks/usePullRequestParam.ts @@ -9,9 +9,9 @@ export const usePullRequestParam = () => { const repoType = params.get('repo_type'); return { - prURL: prParam ? textUtil.sanitizeUrl(prParam) : undefined, - newPrURL: newPrParam ? textUtil.sanitizeUrl(newPrParam) : undefined, - repoURL: repoUrl ? textUtil.sanitizeUrl(repoUrl) : undefined, - repoType: repoType ? textUtil.sanitizeUrl(repoType) : undefined, + prURL: prParam ? textUtil.sanitizeUrl(decodeURIComponent(prParam)) : undefined, + newPrURL: newPrParam ? textUtil.sanitizeUrl(decodeURIComponent(newPrParam)) : undefined, + repoURL: repoUrl ? textUtil.sanitizeUrl(decodeURIComponent(repoUrl)) : undefined, + repoType: repoType ? textUtil.sanitizeUrl(decodeURIComponent(repoType)) : undefined, }; }; diff --git a/public/app/features/provisioning/types.ts b/public/app/features/provisioning/types.ts index 5603df43c04..0c8dfec5dbd 100644 --- a/public/app/features/provisioning/types.ts +++ b/public/app/features/provisioning/types.ts @@ -83,7 +83,7 @@ export type AuthorInfo = { export type FileDetails = { path: string; - size: string; + size?: string; hash: string; }; @@ -98,3 +98,24 @@ export interface StatusInfo { title?: string; message?: string | string[]; } + +// Tree view types for combined Resources/Files view +export type ItemType = 'Folder' | 'File' | 'Dashboard'; +export type SyncStatus = 'synced' | 'pending'; + +export interface TreeItem { + title: string; + type: ItemType; + path: string; + level: number; + children: TreeItem[]; + resourceName?: string; + hash?: string; + status?: SyncStatus; + hasFile?: boolean; +} + +export interface FlatTreeItem { + item: TreeItem; + level: number; +} diff --git a/public/app/features/provisioning/utils/git.ts b/public/app/features/provisioning/utils/git.ts index 7bd221851a2..ac16e715a70 100644 --- a/public/app/features/provisioning/utils/git.ts +++ b/public/app/features/provisioning/utils/git.ts @@ -17,14 +17,45 @@ export function validateBranchName(branchName?: string) { return branchName && branchNameRegex.test(branchName!); } -export const getRepoHref = (github?: RepositorySpec['github']) => { - if (!github?.url) { +// Remove leading and trailing slashes from a string. +const stripSlashes = (s: string) => s.replace(/^\/+|\/+$/g, ''); + +// Split a path into segments and URL-encode each segment. +// Ensures the final URL remains valid for all providers (GitHub, GitLab, etc.). +const splitAndEncode = (s: string) => stripSlashes(s).split('/').map(encodeURIComponent); + +type BuildRepoUrlParams = { + baseUrl?: string; + branch?: string | null; + providerSegments: string[]; + path?: string | null; +}; + +const buildRepoUrl = ({ baseUrl, branch, providerSegments, path }: BuildRepoUrlParams) => { + if (!baseUrl) { return undefined; } - if (!github.branch) { - return github.url; + + // Normalize base URL: trim whitespace + remove trailing slashes. + const cleanBase = stripSlashes(baseUrl.trim()); + const cleanBranch = branch?.trim() || undefined; + + // Start composing URL parts: + // base URL + provider-specific segments (e.g., "tree", "blob", etc.) + const parts = [cleanBase, ...providerSegments]; + + // Append the branch name if present. + if (cleanBranch) { + parts.push(cleanBranch); } - return `${github.url}/tree/${github.branch}`; + + // Append encoded path segments if provided. + // This ensures nested files like "src/utils/index.ts" produce safe URLs. + if (path) { + parts.push(...splitAndEncode(path.trim())); + } + + return parts.join('/'); }; export const getRepoHrefForProvider = (spec?: RepositorySpec) => { @@ -33,35 +64,34 @@ export const getRepoHrefForProvider = (spec?: RepositorySpec) => { } switch (spec.type) { - case 'github': { - const url = spec.github?.url; - const branch = spec.github?.branch; - if (!url) { - return undefined; - } - return branch ? `${url}/tree/${branch}` : url; - } - - case 'gitlab': { - const url = spec.gitlab?.url; - const branch = spec.gitlab?.branch; - if (!url) { - return undefined; - } - return branch ? `${url}/-/tree/${branch}` : url; - } - case 'bitbucket': { - const url = spec.bitbucket?.url; - const branch = spec.bitbucket?.branch; - if (!url) { - return undefined; - } - return branch ? `${url}/src/${branch}` : url; - } - case 'git': { - // Return a generic URL for pure git repositories - return spec.git?.url; - } + case 'github': + return buildRepoUrl({ + baseUrl: spec.github?.url, + branch: spec.github?.branch, + providerSegments: ['tree'], + path: spec.github?.path, + }); + case 'gitlab': + return buildRepoUrl({ + baseUrl: spec.gitlab?.url, + branch: spec.gitlab?.branch, + providerSegments: ['-', 'tree'], + path: spec.gitlab?.path, + }); + case 'bitbucket': + return buildRepoUrl({ + baseUrl: spec.bitbucket?.url, + branch: spec.bitbucket?.branch, + providerSegments: ['src'], + path: spec.bitbucket?.path, + }); + case 'git': + return buildRepoUrl({ + baseUrl: spec.git?.url, + branch: spec.git?.branch, + providerSegments: ['tree'], + path: spec.git?.path, + }); default: return undefined; } @@ -71,39 +101,109 @@ export function getHasTokenInstructions(type: RepoType): type is InstructionAvai return type === 'github' || type === 'gitlab' || type === 'bitbucket'; } -export function getRepoCommitUrl(spec?: RepositorySpec, commit?: string) { - let url: string | undefined = undefined; - let hasUrl = false; +type GetRepoFileUrlParams = { + repoType: RepoType; + url: string | undefined; + branch?: string | undefined; + filePath: string | undefined; + pathPrefix?: string | null; +}; +/** + * Build a URL to a specific source file in a repository. + * Only works for git providers (GitHub, GitLab, Bitbucket). + */ +export function getRepoFileUrl({ + repoType, + url, + branch, + filePath, + pathPrefix, +}: GetRepoFileUrlParams): string | undefined { + if (!url || !filePath) { + return undefined; + } + + const effectiveBranch = branch || 'main'; + const fullPath = pathPrefix ? `${pathPrefix}${filePath}` : filePath; + + switch (repoType) { + case 'github': + return buildRepoUrl({ + baseUrl: url, + branch: effectiveBranch, + providerSegments: ['blob'], + path: fullPath, + }); + case 'gitlab': + return buildRepoUrl({ + baseUrl: url, + branch: effectiveBranch, + providerSegments: ['-', 'blob'], + path: fullPath, + }); + case 'bitbucket': + return buildRepoUrl({ + baseUrl: url, + branch: effectiveBranch, + providerSegments: ['src'], + path: fullPath, + }); + default: + return undefined; + } +} + +export function getRepoCommitUrl(spec?: RepositorySpec, commit?: string) { if (!spec || !spec.type || !commit) { - return { hasUrl, url }; + return { hasUrl: false, url: undefined }; } const gitType = spec.type; // local repositories don't have a URL - if (gitType !== 'local' && commit) { - switch (gitType) { - case 'github': - if (spec.github?.url) { - url = `${spec.github.url}/commit/${commit}`; - hasUrl = true; - } - break; - case 'gitlab': - if (spec.gitlab?.url) { - url = `${spec.gitlab.url}/-/commit/${commit}`; - hasUrl = true; - } - break; - case 'bitbucket': - if (spec.bitbucket?.url) { - url = `${spec.bitbucket.url}/commits/${commit}`; - hasUrl = true; - } - break; - } + if (gitType === 'local') { + return { hasUrl: false, url: undefined }; } - return { hasUrl, url }; + let url: string | undefined = undefined; + let providerSegments: string[] = []; + + switch (gitType) { + case 'github': + if (spec.github?.url) { + providerSegments = ['commit']; + url = buildRepoUrl({ + baseUrl: spec.github.url, + branch: undefined, + providerSegments, + path: commit, + }); + } + break; + case 'gitlab': + if (spec.gitlab?.url) { + providerSegments = ['-', 'commit']; + url = buildRepoUrl({ + baseUrl: spec.gitlab.url, + branch: undefined, + providerSegments, + path: commit, + }); + } + break; + case 'bitbucket': + if (spec.bitbucket?.url) { + providerSegments = ['commits']; + url = buildRepoUrl({ + baseUrl: spec.bitbucket.url, + branch: undefined, + providerSegments, + path: commit, + }); + } + break; + } + + return { hasUrl: !!url, url }; } diff --git a/public/app/features/provisioning/utils/sourceLink.ts b/public/app/features/provisioning/utils/sourceLink.ts new file mode 100644 index 00000000000..cb8269fe715 --- /dev/null +++ b/public/app/features/provisioning/utils/sourceLink.ts @@ -0,0 +1,89 @@ +import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; +import { DashboardLink } from '@grafana/schema'; +import { provisioningAPIv0alpha1, RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; +import { + AnnoKeyManagerIdentity, + AnnoKeyManagerKind, + AnnoKeySourcePath, + ManagerKind, + ObjectMeta, +} from 'app/features/apiserver/types'; +import { dispatch } from 'app/store/store'; + +import { RepoTypeDisplay } from '../Wizard/types'; +import { isValidRepoType } from '../guards'; + +import { getHasTokenInstructions, getRepoFileUrl } from './git'; + +/** + * Find and remove existing source links from the links array. + * A source link is identified by its tooltip matching the source link tooltip translation. + * Returns the links array with source links removed. + */ +export function removeExistingSourceLinks(links: DashboardLink[] | undefined): DashboardLink[] { + if (!links) { + return []; + } + // TODO This is a pretty hacky way to match the source links, needs a better alternative + const sourceLinkTooltip = t('dashboard.source-link.tooltip', 'View source file in repository'); + return links.filter((link) => link.tooltip !== sourceLinkTooltip); +} + +/** + * Build a source link for a repo-managed dashboard. + * Returns undefined if the dashboard is not repo-managed or if the repository is not a git provider. + */ +export async function buildSourceLink(annotations: ObjectMeta['annotations']): Promise { + if (!annotations || !config.featureToggles.provisioning || annotations[AnnoKeyManagerKind] !== ManagerKind.Repo) { + return undefined; + } + + const managerIdentity = annotations[AnnoKeyManagerIdentity]; + const sourcePath = annotations[AnnoKeySourcePath]; + if (!managerIdentity || !sourcePath) { + return undefined; + } + + try { + const settingsResult = await dispatch(provisioningAPIv0alpha1.endpoints.getFrontendSettings.initiate()); + const repository = settingsResult.data?.items.find((repo: RepositoryView) => repo.name === managerIdentity); + + if (!repository) { + return undefined; + } + + const repoType = repository.type; + if (!getHasTokenInstructions(repoType) || !isValidRepoType(repoType)) { + return undefined; + } + + const sourceUrl = getRepoFileUrl({ + repoType, + url: repository.url, + branch: repository.branch, + filePath: sourcePath, + pathPrefix: repository.path, + }); + if (!sourceUrl) { + return undefined; + } + + const providerName = RepoTypeDisplay[repoType]; + return { + title: t('dashboard.source-link.title', 'Source ({{provider}})', { provider: providerName }), + type: 'link', + url: sourceUrl, + icon: 'external link', + tooltip: t('dashboard.source-link.tooltip', 'View source file in repository'), + targetBlank: true, + tags: [], + asDropdown: false, + includeVars: false, + keepTime: false, + }; + } catch (e) { + console.warn('Failed to fetch repository info for source link:', e); + return undefined; + } +} diff --git a/public/app/features/provisioning/utils/treeUtils.test.ts b/public/app/features/provisioning/utils/treeUtils.test.ts new file mode 100644 index 00000000000..bce7c1cafb5 --- /dev/null +++ b/public/app/features/provisioning/utils/treeUtils.test.ts @@ -0,0 +1,715 @@ +import { ResourceListItem } from 'app/api/clients/provisioning/v0alpha1'; + +import { TreeItem } from '../types'; + +import { buildTree, filterTree, flattenTree, getItemType, getStatus, mergeFilesAndResources } from './treeUtils'; + +// Mock data +const mockFileDetails = { + path: 'dashboards/my-dashboard.json', + size: '1234', + hash: 'abc123def456', +}; + +const mockResource: ResourceListItem = { + path: 'dashboards/my-dashboard.json', + name: 'dashboard-uid', + title: 'My Dashboard', + resource: 'dashboards', + hash: 'abc123def456', + folder: '', + group: 'dashboard.grafana.app', +}; + +const mockFolderResource: ResourceListItem = { + path: 'dashboards', + name: 'folder-uid', + title: 'Dashboards Folder', + resource: 'folders', + hash: 'xyz789', + folder: '', + group: 'folder.grafana.app', +}; + +describe('mergeFilesAndResources', () => { + it('should merge files and resources by path', () => { + const files = [mockFileDetails]; + const resources = [mockResource]; + + const result = mergeFilesAndResources(files, resources); + + // 2 items: the file + inferred folder 'dashboards' + expect(result).toHaveLength(2); + const file = result.find((r) => r.path === 'dashboards/my-dashboard.json'); + expect(file?.file).toEqual(mockFileDetails); + expect(file?.resource).toEqual(mockResource); + + const folder = result.find((r) => r.path === 'dashboards'); + expect(folder?.file).toEqual({ path: 'dashboards', hash: '' }); + expect(folder?.resource).toBeUndefined(); + }); + + it('should handle files without matching resources', () => { + const files = [{ path: 'orphan-file.json', size: '100', hash: 'hash1' }]; + const resources: ResourceListItem[] = []; + + const result = mergeFilesAndResources(files, resources); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('orphan-file.json'); + expect(result[0].file).toBeDefined(); + expect(result[0].resource).toBeUndefined(); + }); + + it('should handle resources without matching files', () => { + const files: unknown[] = []; + const resources = [mockResource]; + + const result = mergeFilesAndResources(files, resources); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('dashboards/my-dashboard.json'); + expect(result[0].file).toBeUndefined(); + expect(result[0].resource).toEqual(mockResource); + }); + + it('should handle empty arrays', () => { + const result = mergeFilesAndResources([], []); + + expect(result).toHaveLength(0); + }); + + it('should filter out invalid file objects', () => { + const files = [ + mockFileDetails, + { invalid: 'object' }, // Missing path and hash + null, + undefined, + 'string', + ]; + const resources: ResourceListItem[] = []; + + const result = mergeFilesAndResources(files, resources); + + // 2 items: the file + inferred folder 'dashboards' + expect(result).toHaveLength(2); + expect(result.find((r) => r.path === 'dashboards/my-dashboard.json')).toBeDefined(); + expect(result.find((r) => r.path === 'dashboards')).toBeDefined(); + }); + + it('should skip resources with empty path (root)', () => { + const files: unknown[] = []; + const rootResource = { ...mockResource, path: '' }; + const resources = [rootResource, mockResource]; + + const result = mergeFilesAndResources(files, resources); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('dashboards/my-dashboard.json'); + }); + + it('should handle folder in resources but not in files', () => { + const files = [ + { + path: 'new-dashboard-2025-10-24-NKAPX.json', + hash: '78383507641a9fe0c6dc715bf81989c2732e84df', + }, + ]; + const resources: ResourceListItem[] = [ + { + path: 'new-dashboard-2025-10-24-NKAPX.json', + group: 'dashboard.grafana.app', + resource: 'dashboards', + name: 'dcf20b2odenyf4d', + hash: '78383507641a9fe0c6dc715bf81989c2732e84df', + title: 'v2 dashboard', + folder: 'repository-89cac64', + }, + { + path: 'unsynced-folder', + group: 'folder.grafana.app', + resource: 'folders', + name: 'unsynced-folder-pyqothnbi8kcxjvo7tnujum7', + hash: '', + title: 'unsynced-folder', + folder: 'repository-89cac64', + }, + ]; + + const result = mergeFilesAndResources(files, resources); + + expect(result).toHaveLength(2); + + const dashboard = result.find((r) => r.path === 'new-dashboard-2025-10-24-NKAPX.json'); + expect(dashboard?.file).toBeDefined(); + expect(dashboard?.resource).toBeDefined(); + + const folder = result.find((r) => r.path === 'unsynced-folder'); + expect(folder?.file).toBeUndefined(); + expect(folder?.resource).toBeDefined(); + expect(folder?.resource?.resource).toBe('folders'); + }); +}); + +describe('getItemType', () => { + it('should return Dashboard for dashboard resources', () => { + const result = getItemType('dashboards/test.json', mockResource); + + expect(result).toBe('Dashboard'); + }); + + it('should return Folder for folder resources', () => { + const result = getItemType('dashboards', mockFolderResource); + + expect(result).toBe('Folder'); + }); + + it('should return File for unsynced files regardless of extension', () => { + const result = getItemType('some/path/file.json', undefined); + + expect(result).toBe('File'); + }); + + it('should return File for non-JSON paths without resource', () => { + const result = getItemType('some/path/file.txt', undefined); + + expect(result).toBe('File'); + }); + + it('should return File when resource type is unknown', () => { + const unknownResource = { + ...mockResource, + resource: 'unknown-type', + }; + + const result = getItemType('some/path', unknownResource); + + expect(result).toBe('File'); + }); +}); + +describe('getStatus', () => { + it('should return synced when both hashes exist and match', () => { + expect(getStatus('abc123', 'abc123')).toBe('synced'); + }); + + it('should return pending when both hashes exist but differ', () => { + expect(getStatus('abc123', 'xyz789')).toBe('pending'); + }); + + it('should return pending when only file hash exists', () => { + expect(getStatus('abc123', undefined)).toBe('pending'); + }); + + it('should return pending when only resource hash exists', () => { + expect(getStatus(undefined, 'abc123')).toBe('pending'); + }); + + it('should return pending when neither hash exists', () => { + expect(getStatus(undefined, undefined)).toBe('pending'); + }); + + it('should return synced for inferred folder (empty file hash) with resource', () => { + // Empty hash means folder was inferred from file paths + expect(getStatus('', 'abc123')).toBe('synced'); + }); + + it('should return pending for inferred folder (empty file hash) without resource', () => { + expect(getStatus('', undefined)).toBe('pending'); + }); +}); + +describe('buildTree', () => { + it('should build tree with folder hierarchy', () => { + const mergedItems = [ + { path: 'folder', file: { path: 'folder', hash: '' } }, + { path: 'folder/subfolder', file: { path: 'folder/subfolder', hash: '' } }, + { path: 'folder/subfolder/file.json', file: { path: 'folder/subfolder/file.json', size: '100', hash: 'h1' } }, + ]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('Folder'); + expect(result[0].path).toBe('folder'); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].type).toBe('Folder'); + expect(result[0].children[0].path).toBe('folder/subfolder'); + }); + + it('should place files under correct parent folders', () => { + const mergedItems = [ + { path: 'folder', file: { path: 'folder', hash: '' } }, + { path: 'folder/file.txt', file: { path: 'folder/file.txt', size: '100', hash: 'h1' } }, + ]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('folder'); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].path).toBe('folder/file.txt'); + expect(result[0].children[0].type).toBe('File'); + }); + + it('should sort folders before files', () => { + const mergedItems = [ + { path: 'file.txt', file: { path: 'file.txt', size: '100', hash: 'h1' } }, + { path: 'folder', file: { path: 'folder', hash: '' } }, + { path: 'folder/nested.txt', file: { path: 'folder/nested.txt', size: '100', hash: 'h2' } }, + ]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(2); + expect(result[0].type).toBe('Folder'); + expect(result[0].title).toBe('folder'); + expect(result[1].type).toBe('File'); + expect(result[1].title).toBe('file.txt'); + }); + + it('should sort alphabetically within same type', () => { + const mergedItems = [ + { path: 'zebra.json', file: { path: 'zebra.json', size: '100', hash: 'h1' } }, + { path: 'apple.json', file: { path: 'apple.json', size: '100', hash: 'h2' } }, + { path: 'mango.json', file: { path: 'mango.json', size: '100', hash: 'h3' } }, + ]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(3); + expect(result[0].title).toBe('apple.json'); + expect(result[1].title).toBe('mango.json'); + expect(result[2].title).toBe('zebra.json'); + }); + + it('should handle root-level items', () => { + const mergedItems = [{ path: 'root-file.txt', file: { path: 'root-file.txt', size: '100', hash: 'h1' } }]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('root-file.txt'); + expect(result[0].type).toBe('File'); + }); + + it('should handle deeply nested paths', () => { + const mergedItems = [ + { path: 'a', file: { path: 'a', hash: '' } }, + { path: 'a/b', file: { path: 'a/b', hash: '' } }, + { path: 'a/b/c', file: { path: 'a/b/c', hash: '' } }, + { path: 'a/b/c/d', file: { path: 'a/b/c/d', hash: '' } }, + { path: 'a/b/c/d/e', file: { path: 'a/b/c/d/e', hash: '' } }, + { path: 'a/b/c/d/e/file.txt', file: { path: 'a/b/c/d/e/file.txt', size: '100', hash: 'h1' } }, + ]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('a'); + + // Traverse to the deepest file + let current = result[0]; + const expectedPaths = ['a', 'a/b', 'a/b/c', 'a/b/c/d', 'a/b/c/d/e']; + for (let i = 0; i < expectedPaths.length; i++) { + expect(current.path).toBe(expectedPaths[i]); + expect(current.type).toBe('Folder'); + if (i < expectedPaths.length - 1) { + current = current.children[0]; + } + } + + // Check the file is in the last folder + const lastFolder = current; + expect(lastFolder.children).toHaveLength(1); + expect(lastFolder.children[0].path).toBe('a/b/c/d/e/file.txt'); + expect(lastFolder.children[0].type).toBe('File'); + }); + + it('should handle empty input', () => { + const result = buildTree([]); + + expect(result).toHaveLength(0); + }); + + it('should use resource info for folder nodes when available', () => { + const mergedItems = [ + { path: 'dashboards', resource: mockFolderResource }, + { path: 'dashboards/test.json', resource: mockResource }, + ]; + + const result = buildTree(mergedItems); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Dashboards Folder'); + expect(result[0].resourceName).toBe('folder-uid'); + }); + + it('should set synced status when file and resource hashes match', () => { + const mergedItems = [ + { + path: 'dashboard.json', + file: { path: 'dashboard.json', size: '100', hash: 'abc123def456' }, + resource: mockResource, // mockResource has hash: 'abc123def456' + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].status).toBe('synced'); + }); + + it('should set pending status when file and resource hashes differ', () => { + const mergedItems = [ + { + path: 'dashboard.json', + file: { path: 'dashboard.json', size: '100', hash: 'different-hash' }, + resource: mockResource, + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].status).toBe('pending'); + }); + + it('should not set status for non-JSON files', () => { + const mergedItems = [{ path: 'file.txt', file: { path: 'file.txt', size: '100', hash: 'h1' } }]; + + const result = buildTree(mergedItems); + + expect(result[0].status).toBeUndefined(); + }); + + it('should show unsynced JSON files as File type with pending status', () => { + const mergedItems = [{ path: 'dashboard.json', file: { path: 'dashboard.json', size: '100', hash: 'h1' } }]; + + const result = buildTree(mergedItems); + + expect(result[0].type).toBe('File'); + expect(result[0].status).toBe('pending'); + }); + + it('should set pending status when only resource exists', () => { + const mergedItems = [{ path: 'dashboard.json', resource: mockResource }]; + + const result = buildTree(mergedItems); + + expect(result[0].status).toBe('pending'); + }); + + it('should set folder status to synced when all children are synced', () => { + const syncedResource = { ...mockResource, hash: 'matching-hash' }; + const mergedItems = [ + { path: 'folder', file: { path: 'folder', hash: '' }, resource: mockFolderResource }, + { + path: 'folder/dashboard1.json', + file: { path: 'folder/dashboard1.json', size: '100', hash: 'matching-hash' }, + resource: syncedResource, + }, + { + path: 'folder/dashboard2.json', + file: { path: 'folder/dashboard2.json', size: '100', hash: 'matching-hash' }, + resource: { ...syncedResource, name: 'other-uid' }, + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].type).toBe('Folder'); + expect(result[0].resourceName).toBe('folder-uid'); + expect(result[0].status).toBe('synced'); + }); + + it('should set folder status to pending when any child is pending', () => { + const syncedResource = { ...mockResource, hash: 'matching-hash' }; + const mergedItems = [ + { path: 'folder', resource: mockFolderResource }, + { + path: 'folder/dashboard1.json', + file: { path: 'folder/dashboard1.json', size: '100', hash: 'matching-hash' }, + resource: syncedResource, + }, + { + path: 'folder/dashboard2.json', + file: { path: 'folder/dashboard2.json', size: '100', hash: 'different-hash' }, + resource: syncedResource, + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].type).toBe('Folder'); + expect(result[0].resourceName).toBe('folder-uid'); + expect(result[0].status).toBe('pending'); + }); + + it('should propagate pending status from nested folders', () => { + const syncedResource = { ...mockResource, hash: 'matching-hash' }; + const mergedItems = [ + { path: 'parent', file: { path: 'parent', hash: '' } }, + { path: 'parent/child', file: { path: 'parent/child', hash: '' } }, + { + path: 'parent/child/dashboard.json', + file: { path: 'parent/child/dashboard.json', size: '100', hash: 'different-hash' }, + resource: syncedResource, + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].path).toBe('parent'); + expect(result[0].status).toBe('pending'); + expect(result[0].children[0].path).toBe('parent/child'); + expect(result[0].children[0].status).toBe('pending'); + }); + + it('should set pending status for unsynced folders with no dashboard children', () => { + const mergedItems = [ + { + path: 'unsynced-folder', + resource: { + path: 'unsynced-folder', + group: 'folder.grafana.app', + resource: 'folders', + name: 'unsynced-folder-pyqothnbi8kcxjvo7tnujum7', + hash: '', + title: 'unsynced-folder', + folder: 'repository-89cac64', + }, + }, + { + path: 'new-dashboard-2025-10-24-NKAPX.json', + file: { + path: 'new-dashboard-2025-10-24-NKAPX.json', + hash: '78383507641a9fe0c6dc715bf81989c2732e84df', + }, + resource: { + path: 'new-dashboard-2025-10-24-NKAPX.json', + group: 'dashboard.grafana.app', + resource: 'dashboards', + name: 'dcf20b2odenyf4d', + hash: '78383507641a9fe0c6dc715bf81989c2732e84df', + title: 'v2 dashboard', + folder: 'repository-89cac64', + }, + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].type).toBe('Folder'); + expect(result[0].status).toBe('pending'); + }); + + it('should set pending status for folder in resources but not in files', () => { + // Folder only exists in resources (e.g., deleted from repo but not synced yet) + const mergedItems = [ + { path: 'folder', resource: mockFolderResource }, + { path: 'folder/file.txt', file: { path: 'folder/file.txt', size: '100', hash: 'h1' } }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].type).toBe('Folder'); + expect(result[0].resourceName).toBe('folder-uid'); + expect(result[0].status).toBe('pending'); + }); + + it('should set synced status for folder inferred from files with matching resource', () => { + // Folder inferred from file paths AND exists in resources → synced + const syncedResource = { ...mockResource, hash: 'matching-hash' }; + const mergedItems = [ + { path: 'folder', file: { path: 'folder', hash: '' }, resource: mockFolderResource }, + { + path: 'folder/dashboard.json', + file: { path: 'folder/dashboard.json', size: '100', hash: 'matching-hash' }, + resource: syncedResource, + }, + ]; + + const result = buildTree(mergedItems); + + expect(result[0].type).toBe('Folder'); + expect(result[0].resourceName).toBe('folder-uid'); + expect(result[0].status).toBe('synced'); + }); +}); + +describe('flattenTree', () => { + it('should flatten nested tree structure', () => { + const tree: TreeItem[] = [ + { + path: 'folder', + title: 'Folder', + type: 'Folder', + level: 0, + children: [ + { + path: 'folder/file.json', + title: 'file.json', + type: 'File', + level: 0, + children: [], + }, + ], + }, + ]; + + const result = flattenTree(tree); + + expect(result).toHaveLength(2); + expect(result[0].item.path).toBe('folder'); + expect(result[1].item.path).toBe('folder/file.json'); + }); + + it('should set correct level for each item', () => { + const tree: TreeItem[] = [ + { + path: 'folder', + title: 'Folder', + type: 'Folder', + level: 0, + children: [ + { + path: 'folder/subfolder', + title: 'Subfolder', + type: 'Folder', + level: 0, + children: [ + { + path: 'folder/subfolder/file.json', + title: 'file.json', + type: 'File', + level: 0, + children: [], + }, + ], + }, + ], + }, + ]; + + const result = flattenTree(tree); + + expect(result).toHaveLength(3); + expect(result[0].level).toBe(0); + expect(result[1].level).toBe(1); + expect(result[2].level).toBe(2); + }); + + it('should include all children', () => { + const tree: TreeItem[] = [ + { + path: 'folder', + title: 'Folder', + type: 'Folder', + level: 0, + children: [ + { path: 'folder/a.json', title: 'a.json', type: 'File', level: 0, children: [] }, + { path: 'folder/b.json', title: 'b.json', type: 'File', level: 0, children: [] }, + { path: 'folder/c.json', title: 'c.json', type: 'File', level: 0, children: [] }, + ], + }, + ]; + + const result = flattenTree(tree); + + expect(result).toHaveLength(4); + }); + + it('should handle empty tree', () => { + const result = flattenTree([]); + + expect(result).toHaveLength(0); + }); +}); + +describe('filterTree', () => { + const sampleTree: TreeItem[] = [ + { + path: 'dashboards', + title: 'Dashboards', + type: 'Folder', + level: 0, + children: [ + { + path: 'dashboards/monitoring.json', + title: 'System Monitoring', + type: 'Dashboard', + level: 0, + children: [], + }, + { + path: 'dashboards/sales.json', + title: 'Sales Report', + type: 'Dashboard', + level: 0, + children: [], + }, + ], + }, + { + path: 'config.json', + title: 'config.json', + type: 'File', + level: 0, + children: [], + }, + ]; + + it('should return all items when query is empty', () => { + const result = filterTree(sampleTree, ''); + + expect(result).toEqual(sampleTree); + }); + + it('should filter by path (case-insensitive)', () => { + const result = filterTree(sampleTree, 'MONITORING'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('dashboards'); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].path).toBe('dashboards/monitoring.json'); + }); + + it('should filter by title (case-insensitive)', () => { + const result = filterTree(sampleTree, 'sales report'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('dashboards'); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].title).toBe('Sales Report'); + }); + + it('should include parent folders when child matches', () => { + const result = filterTree(sampleTree, 'monitoring'); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('Folder'); + expect(result[0].path).toBe('dashboards'); + expect(result[0].children).toHaveLength(1); + }); + + it('should return empty array when nothing matches', () => { + const result = filterTree(sampleTree, 'nonexistent'); + + expect(result).toHaveLength(0); + }); + + it('should match folder itself if query matches folder name', () => { + const result = filterTree(sampleTree, 'dashboards'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('dashboards'); + // When folder matches, all children are included + expect(result[0].children).toHaveLength(2); + }); + + it('should match root level items', () => { + const result = filterTree(sampleTree, 'config'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('config.json'); + }); +}); diff --git a/public/app/features/provisioning/utils/treeUtils.ts b/public/app/features/provisioning/utils/treeUtils.ts new file mode 100644 index 00000000000..9cf56580a98 --- /dev/null +++ b/public/app/features/provisioning/utils/treeUtils.ts @@ -0,0 +1,229 @@ +import { IconName } from '@grafana/ui'; +import { ResourceListItem } from 'app/api/clients/provisioning/v0alpha1'; + +import { FileDetails, FlatTreeItem, ItemType, SyncStatus, TreeItem } from '../types'; + +const collator = new Intl.Collator(); + +interface MergedItem { + path: string; + file?: FileDetails; + resource?: ResourceListItem; +} + +function isFileDetails(obj: unknown): obj is FileDetails { + return typeof obj === 'object' && obj !== null && 'path' in obj && 'hash' in obj; +} + +export function mergeFilesAndResources(files: unknown[], resources: ResourceListItem[]): MergedItem[] { + const merged = new Map(); + const inferredFolders = new Set(); + + for (const file of files) { + if (isFileDetails(file)) { + merged.set(file.path, { path: file.path, file }); + + // Infer parent folders from file path + const parts = file.path.split('/'); + for (let i = 1; i < parts.length; i++) { + inferredFolders.add(parts.slice(0, i).join('/')); + } + } + } + + // Add inferred folders that don't already exist + for (const folderPath of inferredFolders) { + if (!merged.has(folderPath)) { + merged.set(folderPath, { path: folderPath, file: { path: folderPath, hash: '' } }); + } + } + + // Merge resources + for (const resource of resources) { + if (!resource.path) { + continue; + } + const existing = merged.get(resource.path); + if (existing) { + existing.resource = resource; + } else { + merged.set(resource.path, { path: resource.path, resource }); + } + } + + return Array.from(merged.values()); +} + +export function getItemType(path: string, resource?: ResourceListItem): ItemType { + if (resource?.resource === 'dashboards') { + return 'Dashboard'; + } + if (resource?.resource === 'folders') { + return 'Folder'; + } + // Inferred folder (no extension means it's a folder from file paths) + if (!resource && !path.includes('.')) { + return 'Folder'; + } + // Unsynced files are "File" - don't infer Dashboard from .json + return 'File'; +} + +export function getDisplayTitle(path: string, resource?: ResourceListItem): string { + if (resource?.title) { + return resource.title; + } + return path.split('/').pop() ?? path; +} + +export function getIconName(type: ItemType): IconName { + switch (type) { + case 'Folder': + return 'folder'; + case 'Dashboard': + return 'apps'; + case 'File': + default: + return 'file-alt'; + } +} + +export function getStatus(fileHash?: string, resourceHash?: string): SyncStatus { + if (fileHash !== undefined && resourceHash !== undefined) { + // Empty file hash means inferred folder (synced if resource exists) + return fileHash === '' || fileHash === resourceHash ? 'synced' : 'pending'; + } + return 'pending'; +} + +function calculateFolderStatus(node: TreeItem): SyncStatus | undefined { + if (node.type !== 'Folder') { + return node.status; + } + + // If any child is pending, folder is pending + for (const child of node.children) { + const childStatus = child.type === 'Folder' ? calculateFolderStatus(child) : child.status; + if (childStatus === 'pending') { + return 'pending'; + } + } + + return node.status; +} + +export function buildTree(mergedItems: MergedItem[]): TreeItem[] { + const nodeMap = new Map(); + const roots: TreeItem[] = []; + + // Create all nodes (files, dashboards, folders) + for (const item of mergedItems) { + const type = getItemType(item.path, item.resource); + const showStatus = type === 'Dashboard' || type === 'Folder' || item.path.endsWith('.json'); + + nodeMap.set(item.path, { + path: item.path, + title: getDisplayTitle(item.path, item.resource), + type, + level: 0, + children: [], + resourceName: item.resource?.name, + hash: item.file?.hash ?? item.resource?.hash, + status: showStatus ? getStatus(item.file?.hash, item.resource?.hash) : undefined, + hasFile: !!item.file, + }); + } + + // Build parent-child relationships + for (const [path, node] of nodeMap) { + const lastSlashIndex = path.lastIndexOf('/'); + if (lastSlashIndex === -1) { + roots.push(node); + } else { + const parentPath = path.substring(0, lastSlashIndex); + const parent = nodeMap.get(parentPath); + if (parent) { + parent.children.push(node); + } else { + roots.push(node); + } + } + } + + // Sort: folders first, then alphabetically, recursively + const sortNodes = (nodes: TreeItem[]) => { + nodes.sort((a, b) => { + if (a.type === 'Folder' && b.type !== 'Folder') { + return -1; + } + if (a.type !== 'Folder' && b.type === 'Folder') { + return 1; + } + return collator.compare(a.title, b.title); + }); + for (const node of nodes) { + sortNodes(node.children); + } + }; + + sortNodes(roots); + + // Update folder statuses recursively (folders inherit pending from children) + const updateFolderStatus = (nodes: TreeItem[]) => { + for (const node of nodes) { + if (node.type === 'Folder') { + updateFolderStatus(node.children); + node.status = calculateFolderStatus(node); + } + } + }; + + updateFolderStatus(roots); + return roots; +} + +export function flattenTree(items: TreeItem[], level = 0): FlatTreeItem[] { + const result: FlatTreeItem[] = []; + + for (const item of items) { + result.push({ + item: { ...item, level }, + level, + }); + + if (item.children.length > 0) { + result.push(...flattenTree(item.children, level + 1)); + } + } + + return result; +} + +/** + * Filter tree by search query (searches path and title). + * Returns filtered tree including ancestor folders for matching items. + */ +export function filterTree(items: TreeItem[], searchQuery: string): TreeItem[] { + if (!searchQuery) { + return items; + } + + const lowerQuery = searchQuery.toLowerCase(); + + const filterNode = (node: TreeItem): TreeItem | null => { + const matches = node.path.toLowerCase().includes(lowerQuery) || node.title.toLowerCase().includes(lowerQuery); + + if (matches) { + return node; + } + + if (node.type === 'Folder' && node.children.length > 0) { + const filteredChildren = node.children.map(filterNode).filter((n): n is TreeItem => n !== null); + return filteredChildren.length > 0 ? { ...node, children: filteredChildren } : null; + } + + return null; + }; + + return items.map(filterNode).filter((n): n is TreeItem => n !== null); +} diff --git a/public/app/features/query/components/QueryActionComponent.ts b/public/app/features/query/components/QueryActionComponent.ts index de162d358bd..295e52d6baa 100644 --- a/public/app/features/query/components/QueryActionComponent.ts +++ b/public/app/features/query/components/QueryActionComponent.ts @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { CoreApp, DataQuery, DataSourceInstanceSettings, TimeRange } from '@grafana/data'; interface ActionComponentProps { diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index d0e6369ced9..b474d0293f9 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import { cloneDeep, filter, uniqBy, uniqueId } from 'lodash'; import pluralize from 'pluralize'; -import { PureComponent, ReactNode } from 'react'; +import { PureComponent, ReactNode, type JSX } from 'react'; import { CoreApp, diff --git a/public/app/features/query/components/QueryEditorRows.tsx b/public/app/features/query/components/QueryEditorRows.tsx index 2cbdc1aba87..e0dacee2669 100644 --- a/public/app/features/query/components/QueryEditorRows.tsx +++ b/public/app/features/query/components/QueryEditorRows.tsx @@ -9,8 +9,10 @@ import { HistoryItem, PanelData, getDataSourceRef, + isSystemOverrideWithRef, } from '@grafana/data'; import { getDataSourceSrv, reportInteraction } from '@grafana/runtime'; +import { SceneObjectRef, VizPanel } from '@grafana/scenes'; import { DataSourceRef } from '@grafana/schema'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; @@ -44,6 +46,7 @@ export interface Props { queryLibraryRef?: string; onCancelQueryLibraryEdit?: () => void; isOpen?: boolean; + panelRef?: SceneObjectRef; } export class QueryEditorRows extends PureComponent { @@ -63,6 +66,20 @@ export class QueryEditorRows extends PureComponent { return item; }) ); + + if (this.props.panelRef) { + const panel = this.props.panelRef.resolve(); + const hideSeriesOverrideIndex = panel.state.fieldConfig.overrides.findIndex( + isSystemOverrideWithRef('hideSeriesFrom') + ); + + if (hideSeriesOverrideIndex !== -1) { + const newOverrides = [...panel.state.fieldConfig.overrides]; + newOverrides.splice(hideSeriesOverrideIndex, 1); + + panel.setState({ fieldConfig: { ...panel.state.fieldConfig, overrides: newOverrides } }); + } + } } onReplaceQuery(query: DataQuery, index: number) { diff --git a/public/app/features/scopes/ScopesService.test.ts b/public/app/features/scopes/ScopesService.test.ts index 61f85257dcf..6b360f853e3 100644 --- a/public/app/features/scopes/ScopesService.test.ts +++ b/public/app/features/scopes/ScopesService.test.ts @@ -14,15 +14,22 @@ describe('ScopesService', () => { let selectorService: jest.Mocked; let dashboardsService: jest.Mocked; let locationService: jest.Mocked; - let stateSubscription: + let selectorStateSubscription: | (( state: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> }, prevState: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> } ) => void) | undefined; + let dashboardsStateSubscription: + | (( + state: { navigationScope?: string; drawerOpened: boolean }, + prevState: { navigationScope?: string; drawerOpened: boolean } + ) => void) + | undefined; beforeEach(() => { - stateSubscription = undefined; + selectorStateSubscription = undefined; + dashboardsStateSubscription = undefined; selectorService = { state: { @@ -46,7 +53,7 @@ describe('ScopesService', () => { tree: { scopeNodeId: '', expanded: false, query: '', children: {} }, }), subscribeToState: jest.fn((callback) => { - stateSubscription = callback; + selectorStateSubscription = callback; return { unsubscribe: jest.fn() }; }), changeScopes: jest.fn(), @@ -63,6 +70,7 @@ describe('ScopesService', () => { forScopeNames: [], loading: false, searchQuery: '', + navigationScope: undefined, }, stateObservable: new BehaviorSubject({ drawerOpened: false, @@ -73,7 +81,13 @@ describe('ScopesService', () => { forScopeNames: [], loading: false, searchQuery: '', + navigationScope: undefined, }), + subscribeToState: jest.fn((callback) => { + dashboardsStateSubscription = callback; + return { unsubscribe: jest.fn() }; + }), + setNavigationScope: jest.fn(), } as unknown as jest.Mocked; locationService = { @@ -160,6 +174,40 @@ describe('ScopesService', () => { expect(selectorService.changeScopes).toHaveBeenCalledWith(['scope1', 'scope2'], undefined, 'node1', false); }); + + it('should read navigation_scope from URL on init', () => { + locationService.getLocation = jest.fn().mockReturnValue({ + pathname: '/test', + search: '?scopes=scope1&navigation_scope=navScope1', + }); + + service = new ScopesService(selectorService, dashboardsService, locationService); + + expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1'); + }); + + it('should read navigation_scope along with other scope parameters', () => { + locationService.getLocation = jest.fn().mockReturnValue({ + pathname: '/test', + search: '?scopes=scope1&scope_node=node1&navigation_scope=navScope1', + }); + + service = new ScopesService(selectorService, dashboardsService, locationService); + + expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1'); + expect(selectorService.changeScopes).toHaveBeenCalledWith(['scope1'], undefined, 'node1', false); + }); + + it('should not call setNavigationScope when navigation_scope is not in URL', () => { + locationService.getLocation = jest.fn().mockReturnValue({ + pathname: '/test', + search: '?scopes=scope1', + }); + + service = new ScopesService(selectorService, dashboardsService, locationService); + + expect(dashboardsService.setNavigationScope).not.toHaveBeenCalled(); + }); }); describe('URL synchronization', () => { @@ -172,11 +220,11 @@ describe('ScopesService', () => { }); it('should write scope_node to URL when scopes change', () => { - if (!stateSubscription) { - throw new Error('stateSubscription not set'); + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); } - stateSubscription( + selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], }, @@ -196,11 +244,11 @@ describe('ScopesService', () => { }); it('should reset scope_parent to null when writing URL', () => { - if (!stateSubscription) { - throw new Error('stateSubscription not set'); + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); } - stateSubscription( + selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1', parentNodeId: 'parent1' }], }, @@ -218,11 +266,11 @@ describe('ScopesService', () => { }); it('should handle scopeNodeId changes without scope changes', () => { - if (!stateSubscription) { - throw new Error('stateSubscription not set'); + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); } - stateSubscription( + selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node2' }], }, @@ -242,11 +290,11 @@ describe('ScopesService', () => { }); it('should handle missing scopeNodeId gracefully', () => { - if (!stateSubscription) { - throw new Error('stateSubscription not set'); + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); } - stateSubscription( + selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1' }], }, @@ -266,13 +314,13 @@ describe('ScopesService', () => { }); it('should not update URL when scopes and scopeNodeId have not changed', () => { - if (!stateSubscription) { - throw new Error('stateSubscription not set'); + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); } jest.clearAllMocks(); - stateSubscription( + selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], }, @@ -283,6 +331,90 @@ describe('ScopesService', () => { expect(locationService.partial).not.toHaveBeenCalled(); }); + + it('should write navigation_scope to URL when navigationScope changes', () => { + if (!dashboardsStateSubscription) { + throw new Error('dashboardsStateSubscription not set'); + } + + dashboardsStateSubscription( + { + navigationScope: 'navScope1', + drawerOpened: true, + }, + { + navigationScope: undefined, + drawerOpened: false, + } + ); + + expect(locationService.partial).toHaveBeenCalledWith({ + navigation_scope: 'navScope1', + }); + }); + + it('should update navigation_scope in URL when navigationScope changes', () => { + if (!dashboardsStateSubscription) { + throw new Error('dashboardsStateSubscription not set'); + } + + dashboardsStateSubscription( + { + navigationScope: 'navScope2', + drawerOpened: true, + }, + { + navigationScope: 'navScope1', + drawerOpened: true, + } + ); + + expect(locationService.partial).toHaveBeenCalledWith({ + navigation_scope: 'navScope2', + }); + }); + + it('should not update URL when navigationScope has not changed', () => { + if (!dashboardsStateSubscription) { + throw new Error('dashboardsStateSubscription not set'); + } + + jest.clearAllMocks(); + + dashboardsStateSubscription( + { + navigationScope: 'navScope1', + drawerOpened: true, + }, + { + navigationScope: 'navScope1', + drawerOpened: false, + } + ); + + expect(locationService.partial).not.toHaveBeenCalled(); + }); + + it('should clear navigation_scope from URL when navigationScope is cleared', () => { + if (!dashboardsStateSubscription) { + throw new Error('dashboardsStateSubscription not set'); + } + + dashboardsStateSubscription( + { + navigationScope: undefined, + drawerOpened: false, + }, + { + navigationScope: 'navScope1', + drawerOpened: true, + } + ); + + expect(locationService.partial).toHaveBeenCalledWith({ + navigation_scope: undefined, + }); + }); }); describe('setEnabled', () => { diff --git a/public/app/features/scopes/ScopesService.ts b/public/app/features/scopes/ScopesService.ts index 2595ff66876..36edcc94b13 100644 --- a/public/app/features/scopes/ScopesService.ts +++ b/public/app/features/scopes/ScopesService.ts @@ -73,6 +73,11 @@ export class ScopesService implements ScopesContextValue { const scopeNodeId = queryParams.get('scope_node'); // TODO: figure out when to remove this. scope_parent is for backward compatibility only const parentNodeId = queryParams.get('scope_parent'); + const navigationScope = queryParams.get('navigation_scope'); + + if (navigationScope) { + this.dashboardsService.setNavigationScope(navigationScope); + } this.changeScopes(queryParams.getAll('scopes'), parentNodeId ?? undefined, scopeNodeId ?? undefined); @@ -133,6 +138,16 @@ export class ScopesService implements ScopesContextValue { } }) ); + // Update the URL based on change in the navigation scope + this.subscriptions.push( + this.dashboardsService.subscribeToState((state, prevState) => { + if (state.navigationScope !== prevState.navigationScope) { + this.locationService.partial({ + navigation_scope: state.navigationScope, + }); + } + }) + ); } /** diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts index 62b3b71e09d..52368b4577d 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts @@ -1,7 +1,6 @@ import { Location } from 'history'; import { Subject } from 'rxjs'; -import { ScopeDashboardBinding } from '@grafana/data'; import { config, locationService } from '@grafana/runtime'; import { ScopesApiClient } from '../ScopesApiClient'; @@ -15,7 +14,7 @@ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), config: { featureToggles: { - useScopesNavigationEndpoint: false, + useScopesNavigationEndpoint: true, }, apps: {}, }, @@ -33,27 +32,32 @@ describe('ScopesDashboardsService', () => { let mockApiClient: jest.Mocked; beforeEach(() => { + const fetchScopeNavigationsMock = jest.fn().mockResolvedValue([]); mockApiClient = { fetchDashboards: jest.fn(), - fetchScopeNavigations: jest.fn(), + fetchScopeNavigations: fetchScopeNavigationsMock, } as unknown as jest.Mocked; service = new ScopesDashboardsService(mockApiClient); }); + afterEach(() => { + config.featureToggles.useScopesNavigationEndpoint = true; + }); + describe('folder expansion based on location', () => { it('should expand folders when current location matches dashboard ID', async () => { // Mock current location to be a dashboard (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' } as Location); - const mockDashboards: ScopeDashboardBinding[] = [ + const mockNavigations: ScopeNavigation[] = [ { spec: { scope: 'scope1', - dashboard: 'dashboard1', + url: '/d/dashboard1', }, status: { - dashboardTitle: 'Test Dashboard', + title: 'Test Dashboard', groups: ['group1'], }, metadata: { @@ -62,7 +66,7 @@ describe('ScopesDashboardsService', () => { }, ]; - mockApiClient.fetchDashboards.mockResolvedValue(mockDashboards); + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); await service.fetchDashboards(['scope1']); // Verify that the folder is expanded because the current dashboard ID matches @@ -168,14 +172,14 @@ describe('ScopesDashboardsService', () => { // Mock current location to not match any navigation (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/different-path' } as Location); - const mockDashboards: ScopeDashboardBinding[] = [ + const mockNavigations: ScopeNavigation[] = [ { spec: { scope: 'scope1', - dashboard: 'dashboard1', + url: '/d/dashboard1', }, status: { - dashboardTitle: 'Test Dashboard', + title: 'Test Dashboard', groups: ['group1'], }, metadata: { @@ -184,7 +188,7 @@ describe('ScopesDashboardsService', () => { }, ]; - mockApiClient.fetchDashboards.mockResolvedValue(mockDashboards); + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); await service.fetchDashboards(['scope1']); // Verify that the folder is not expanded because the current location doesn't match @@ -197,14 +201,14 @@ describe('ScopesDashboardsService', () => { pathname: '/d/dashboard1/very-important', } as Location); - const mockDashboards: ScopeDashboardBinding[] = [ + const mockNavigations: ScopeNavigation[] = [ { spec: { scope: 'scope1', - dashboard: 'dashboard1', + url: '/d/dashboard1', }, status: { - dashboardTitle: 'Test Dashboard', + title: 'Test Dashboard', groups: ['group1'], }, metadata: { @@ -213,7 +217,7 @@ describe('ScopesDashboardsService', () => { }, ]; - mockApiClient.fetchDashboards.mockResolvedValue(mockDashboards); + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); await service.fetchDashboards(['scope1']); // Verify that the folder is expanded because the current path starts with the dashboard ID @@ -224,14 +228,14 @@ describe('ScopesDashboardsService', () => { // Mock current location to be a specific dashboard (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' } as Location); - const mockDashboards: ScopeDashboardBinding[] = [ + const mockNavigations: ScopeNavigation[] = [ { spec: { scope: 'scope1', - dashboard: 'dashboard1', + url: '/d/dashboard1', }, status: { - dashboardTitle: 'Test Dashboard', + title: 'Test Dashboard', groups: ['group1'], }, metadata: { @@ -241,10 +245,10 @@ describe('ScopesDashboardsService', () => { { spec: { scope: 'scope1', - dashboard: 'dashboard2', + url: '/d/dashboard2', }, status: { - dashboardTitle: 'Another Dashboard', + title: 'Another Dashboard', groups: ['group2'], }, metadata: { @@ -253,7 +257,7 @@ describe('ScopesDashboardsService', () => { }, ]; - mockApiClient.fetchDashboards.mockResolvedValue(mockDashboards); + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); await service.fetchDashboards(['scope1']); // Verify that only the folder containing the current dashboard is expanded @@ -697,4 +701,142 @@ describe('ScopesDashboardsService', () => { expect(filteredItems.some((item) => item.metadata.name === 'loki-item-1')).toBe(true); }); }); + + describe('setNavigationScope', () => { + beforeEach(() => { + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location); + // Reset mocks but keep the mock functions + mockApiClient.fetchDashboards.mockClear(); + // Note: fetchScopeNavigations mock is set up in top-level beforeEach + // Individual tests will override it with their own mockResolvedValue calls + }); + + it('should set navigation scope and fetch dashboards', async () => { + // Mock non-empty results so drawerOpened stays true after fetchDashboards completes + mockApiClient.fetchScopeNavigations.mockResolvedValue([ + { + spec: { scope: 'navScope1', url: '/d/dashboard1' }, + status: { title: 'Test', groups: [] }, + metadata: { name: 'dashboard1' }, + }, + ]); + + await service.setNavigationScope('navScope1'); + + expect(service.state.navigationScope).toBe('navScope1'); + expect(service.state.drawerOpened).toBe(true); + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['navScope1']); + }); + + it('should clear navigation scope and use fallback scope names', async () => { + // Mock non-empty results so drawerOpened stays true after fetchDashboards completes + mockApiClient.fetchScopeNavigations.mockResolvedValue([ + { + spec: { scope: 'fallbackScope1', url: '/d/dashboard1' }, + status: { title: 'Test', groups: [] }, + metadata: { name: 'dashboard1' }, + }, + ]); + // Set an initial navigation scope + await service.setNavigationScope('initialScope'); + expect(service.state.navigationScope).toBe('initialScope'); + expect(service.state.drawerOpened).toBe(true); + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['initialScope']); + + await service.setNavigationScope(undefined, ['fallbackScope1', 'fallbackScope2']); + + expect(service.state.navigationScope).toBeUndefined(); + expect(service.state.drawerOpened).toBe(true); + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['fallbackScope1', 'fallbackScope2']); + }); + + it('should not run if previous and next navigation scopes are undefined and we provide fallback scope names', async () => { + await service.setNavigationScope(undefined, ['fallbackScope1', 'fallbackScope2']); + expect(service.state.navigationScope).toBeUndefined(); + expect(service.state.drawerOpened).toBe(false); + expect(mockApiClient.fetchScopeNavigations).not.toHaveBeenCalled(); + }); + + it('should close drawer when navigation scope is cleared without fallback', async () => { + // When setNavigationScope is called with undefined and no fallback, + // it calls fetchDashboards([]), which returns early without calling the API + await service.setNavigationScope(undefined); + + expect(service.state.navigationScope).toBeUndefined(); + expect(service.state.drawerOpened).toBe(false); + // fetchDashboards([]) returns early, so API client is not called + expect(mockApiClient.fetchScopeNavigations).not.toHaveBeenCalled(); + }); + + it('should not update state if navigation scope has not changed', async () => { + mockApiClient.fetchScopeNavigations.mockResolvedValue([ + { + spec: { scope: 'navScope1', url: '/d/dashboard1' }, + status: { title: 'Test', groups: [] }, + metadata: { name: 'dashboard1' }, + }, + ]); + await service.setNavigationScope('navScope1'); + mockApiClient.fetchScopeNavigations.mockClear(); + + await service.setNavigationScope('navScope1'); + + expect(mockApiClient.fetchScopeNavigations).not.toHaveBeenCalled(); + }); + + it('should update navigation scope when changing from one scope to another', async () => { + mockApiClient.fetchScopeNavigations.mockResolvedValue([]); + + await service.setNavigationScope('navScope1'); + mockApiClient.fetchScopeNavigations.mockClear(); + + await service.setNavigationScope('navScope2'); + + expect(service.state.navigationScope).toBe('navScope2'); + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['navScope2']); + }); + + it('should update navigation scope when clearing an existing scope', async () => { + mockApiClient.fetchScopeNavigations.mockResolvedValue([]); + + await service.setNavigationScope('navScope1'); + mockApiClient.fetchScopeNavigations.mockClear(); + + await service.setNavigationScope(undefined, ['fallbackScope']); + + expect(service.state.navigationScope).toBeUndefined(); + expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['fallbackScope']); + }); + + it('should open drawer when navigation scope is set', async () => { + // Mock to return non-empty results so drawer stays open + mockApiClient.fetchScopeNavigations.mockResolvedValue([ + { + spec: { scope: 'navScope1', url: '/d/dashboard1' }, + status: { title: 'Test', groups: [] }, + metadata: { name: 'dashboard1' }, + }, + ]); + + await service.setNavigationScope('navScope1'); + + expect(service.state.drawerOpened).toBe(true); + }); + + it('should open drawer when fallback scopes are provided', async () => { + // Mock non-empty results so drawerOpened stays true after fetchDashboards completes + mockApiClient.fetchScopeNavigations.mockResolvedValue([ + { + spec: { scope: 'fallbackScope', url: '/d/dashboard1' }, + status: { title: 'Test', groups: [] }, + metadata: { name: 'dashboard1' }, + }, + ]); + await service.setNavigationScope('initialScope'); + + await service.setNavigationScope(undefined, ['fallbackScope']); + + expect(service.state.drawerOpened).toBe(true); + }); + }); }); diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts index be6f966623e..55ea5ab737a 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts @@ -23,6 +23,7 @@ interface ScopesDashboardsServiceState { forScopeNames: string[]; loading: boolean; searchQuery: string; + navigationScope?: string; } export class ScopesDashboardsService extends ScopesServiceBase { @@ -56,6 +57,18 @@ export class ScopesDashboardsService extends ScopesServiceBase { + if (this.state.navigationScope === navigationScope) { + return; + } + + const forScopeNames = navigationScope ? [navigationScope] : (fallbackScopeNames ?? []); + this.updateState({ navigationScope, drawerOpened: forScopeNames.length > 0 }); + await this.fetchDashboards(forScopeNames); + }; + // Expand the group that matches the current path, if it is not already expanded private onLocationChange = (pathname: string) => { if (!this.state.drawerOpened) { diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx index b25a3bfc55b..7e7601f3b06 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx @@ -9,12 +9,13 @@ import { ScopesNavigationTreeLink } from './ScopesNavigationTreeLink'; import { OnFolderUpdate, SuggestedNavigationsFoldersMap } from './types'; export interface ScopesDashboardsTreeProps { + subScope?: string; folders: SuggestedNavigationsFoldersMap; folderPath: string[]; onFolderUpdate: OnFolderUpdate; } -export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: ScopesDashboardsTreeProps) { +export function ScopesDashboardsTree({ subScope, folders, folderPath, onFolderUpdate }: ScopesDashboardsTreeProps) { const [queryParams] = useQueryParams(); const styles = useStyles2(getStyles); @@ -52,6 +53,7 @@ export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: Sc ))} {regularNavigations.map((navigation) => ( ({ + useQueryParams: jest.fn(() => [{}]), +})); + +// Mock ScopesContextProvider +const mockScopesSelectorService = { + changeScopes: jest.fn(), +}; + +const mockScopesDashboardsService = { + setNavigationScope: jest.fn(), +}; + +jest.mock('../ScopesContextProvider', () => ({ + ...jest.requireActual('../ScopesContextProvider'), + useScopesServices: jest.fn(() => ({ + scopesSelectorService: mockScopesSelectorService, + scopesDashboardsService: mockScopesDashboardsService, + })), +})); + +describe('ScopesDashboardsTreeFolderItem', () => { + const mockOnFolderUpdate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createMockFolder = (overrides?: Partial): SuggestedNavigationsFolder => ({ + title: 'Test Folder', + expanded: false, + folders: {}, + suggestedNavigations: {}, + ...overrides, + }); + + const createMockFolders: SuggestedNavigationsFoldersMap = { + '': { + title: '', + expanded: true, + folders: {}, + suggestedNavigations: {}, + }, + }; + + it('renders folder with correct props', () => { + const folder = createMockFolder(); + render( + + ); + + expect(screen.getByText('Test Folder')).toBeInTheDocument(); + expect(screen.getByTestId('scopes-dashboards-Test Folder-expand')).toBeInTheDocument(); + }); + + it('calls onFolderUpdate when expand button is clicked', async () => { + const user = userEvent.setup(); + const folder = createMockFolder({ expanded: false }); + + render( + + ); + + const expandButton = screen.getByTestId('scopes-dashboards-Test Folder-expand'); + await user.click(expandButton); + + expect(mockOnFolderUpdate).toHaveBeenCalledWith([''], true); + }); + + it('shows exchange icon when folder has subScopeName', () => { + const folder = createMockFolder({ subScopeName: 'subScope1' }); + + render( + + ); + + // IconButton with exchange-alt icon should be present + const exchangeButton = screen.getByRole('button', { name: /change root scope/i }); + expect(exchangeButton).toBeInTheDocument(); + }); + + it('does not show exchange icon when folder does not have subScopeName', () => { + const folder = createMockFolder(); + + render( + + ); + + const exchangeButtons = screen.queryAllByRole('button', { name: /change root scope/i }); + expect(exchangeButtons).toHaveLength(0); + }); + + it('calls setNavigationScope when exchange icon is clicked', async () => { + const user = userEvent.setup(); + const folder = createMockFolder({ subScopeName: 'subScope1' }); + + render( + + ); + + const exchangeButton = screen.getByRole('button', { name: /change root scope/i }); + await user.click(exchangeButton); + + expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined, ['subScope1']); + }); + + it('calls changeScopes when exchange icon is clicked', async () => { + const user = userEvent.setup(); + const folder = createMockFolder({ subScopeName: 'subScope1' }); + + render( + + ); + + const exchangeButton = screen.getByRole('button', { name: /change root scope/i }); + await user.click(exchangeButton); + + expect(mockScopesSelectorService.changeScopes).toHaveBeenCalledWith(['subScope1']); + }); + + it('passes subScope prop to ScopesDashboardsTree when folder is expanded', () => { + const folder = createMockFolder({ expanded: true, subScopeName: 'subScope1' }); + const childFolders: SuggestedNavigationsFoldersMap = { + '': { + title: '', + expanded: true, + folders: { + childFolder: { + title: 'Child Folder', + expanded: false, + folders: {}, + suggestedNavigations: {}, + }, + }, + suggestedNavigations: {}, + }, + childFolder: { + title: 'Child Folder', + expanded: false, + folders: {}, + suggestedNavigations: {}, + }, + }; + + render( + + ); + + // ScopesDashboardsTree should be rendered when folder is expanded + // We can verify this by checking that the children container is present + const childrenContainer = screen.getByText('Test Folder').closest('div')?.nextSibling; + expect(childrenContainer).toBeInTheDocument(); + }); + + it('does not render ScopesDashboardsTree when folder is not expanded', () => { + const folder = createMockFolder({ expanded: false, subScopeName: 'subScope1' }); + + render( + + ); + + // When not expanded, the children container should not be visible + // The structure should only show the folder row + expect(screen.getByText('Test Folder')).toBeInTheDocument(); + }); + + it('prevents default and stops propagation when exchange icon is clicked', async () => { + const folder = createMockFolder({ subScopeName: 'subScope1' }); + const preventDefault = jest.fn(); + const stopPropagation = jest.fn(); + + render( + + ); + + const exchangeButton = screen.getByRole('button', { name: /change root scope/i }); + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefault }); + Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagation }); + + exchangeButton.dispatchEvent(clickEvent); + + // The onClick handler should prevent default and stop propagation + // Note: userEvent.click doesn't trigger preventDefault/stopPropagation directly, + // but the handler should call them. We verify the handler was called correctly. + expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalled(); + }); + + it('does not call setNavigationScope when subScopeName is missing', async () => { + const folder = createMockFolder({ subScopeName: undefined }); + + render( + + ); + + // No exchange button should be present, so no click should trigger setNavigationScope + expect(mockScopesDashboardsService.setNavigationScope).not.toHaveBeenCalled(); + // Check precense of exchange button + const exchangeButton = screen.queryByRole('button', { name: /change root scope/i }); + expect(exchangeButton).not.toBeInTheDocument(); + }); + + it('does not call setNavigationScope when scopesSelectorService is not available', async () => { + const user = userEvent.setup(); + const folder = createMockFolder({ subScopeName: 'subScope1' }); + + // Mock useScopesServices to return undefined + jest.spyOn(require('../ScopesContextProvider'), 'useScopesServices').mockReturnValue(undefined); + + render( + + ); + + const exchangeButton = screen.queryByRole('button', { name: /change root scope/i }); + if (exchangeButton) { + await user.click(exchangeButton); + } + + // Should not crash, but also should not call setNavigationScope if service is not available + // The component checks for scopesSelectorService existence before calling setNavigationScope + expect(mockScopesDashboardsService.setNavigationScope).not.toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx index da1f315c5e2..9d572a4d54c 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx @@ -26,7 +26,7 @@ export function ScopesDashboardsTreeFolderItem({ // get scopesselector service const scopesSelectorService = useScopesServices()?.scopesSelectorService ?? undefined; - + const scopesDashboardsService = useScopesServices()?.scopesDashboardsService ?? undefined; return (
@@ -57,6 +57,7 @@ export function ScopesDashboardsTreeFolderItem({ e.preventDefault(); e.stopPropagation(); if (folder.subScopeName && scopesSelectorService) { + scopesDashboardsService?.setNavigationScope(undefined, [folder.subScopeName]); scopesSelectorService.changeScopes([folder.subScopeName]); } }} @@ -66,7 +67,12 @@ export function ScopesDashboardsTreeFolderItem({ {folder.expanded && (
- +
)}
diff --git a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.test.tsx b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.test.tsx index 3aba827d4ef..8dafc80c612 100644 --- a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.test.tsx +++ b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.test.tsx @@ -1,6 +1,9 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { MemoryRouter, useLocation } from 'react-router-dom-v5-compat'; +import { locationService } from '@grafana/runtime'; + import { ScopesNavigationTreeLink } from './ScopesNavigationTreeLink'; // Mock react-router-dom's useLocation @@ -9,15 +12,50 @@ jest.mock('react-router-dom-v5-compat', () => ({ useLocation: jest.fn(), })); +// Mock @grafana/runtime +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + locationService: { + push: jest.fn(), + }, +})); + +// Mock ScopesContextProvider +const mockScopesSelectorService = { + state: { + appliedScopes: [{ scopeId: 'currentScope' }], + }, + changeScopes: jest.fn(), +}; + +const mockScopesDashboardsService = { + state: { + navigationScope: undefined as string | undefined, + }, + setNavigationScope: jest.fn(), +}; + +jest.mock('../ScopesContextProvider', () => ({ + ...jest.requireActual('../ScopesContextProvider'), + useScopesServices: jest.fn(() => ({ + scopesSelectorService: mockScopesSelectorService, + scopesDashboardsService: mockScopesDashboardsService, + })), +})); + const renderWithRouter = (ui: React.ReactElement) => { return render({ui}); }; describe('ScopesNavigationTreeLink', () => { const mockUseLocation = useLocation as jest.Mock; + const mockLocationServicePush = locationService.push as jest.Mock; beforeEach(() => { mockUseLocation.mockReturnValue({ pathname: '/current-path' }); + jest.clearAllMocks(); + mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }]; + mockScopesDashboardsService.state.navigationScope = undefined; }); afterEach(() => { @@ -133,4 +171,141 @@ describe('ScopesNavigationTreeLink', () => { expect(icon).toBeInTheDocument(); expect(link).toHaveTextContent('Metrics Drilldown'); }); + + describe('click handler with subScope', () => { + beforeEach(() => { + // Mock window.location.origin + Object.defineProperty(window, 'location', { + value: { + origin: 'http://localhost', + }, + writable: true, + }); + }); + + it('should prevent default navigation when subScope is provided', async () => { + const user = userEvent.setup(); + + renderWithRouter( + + ); + + const link = screen.getByTestId('scopes-dashboards-test-id'); + await user.click(link); + + // Should call changeScopes instead of navigating normally + expect(mockScopesSelectorService.changeScopes).toHaveBeenCalled(); + expect(mockLocationServicePush).toHaveBeenCalled(); + }); + + it('should set navigation scope from current scope when not already set', async () => { + mockScopesDashboardsService.state.navigationScope = undefined; + mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }]; + + renderWithRouter( + + ); + + const link = screen.getByTestId('scopes-dashboards-test-id'); + await userEvent.click(link); + + expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalledWith('currentScope'); + }); + + it('should not set navigation scope when already set', async () => { + mockScopesDashboardsService.state.navigationScope = 'existingNavScope'; + mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }]; + + renderWithRouter( + + ); + + const link = screen.getByTestId('scopes-dashboards-test-id'); + await userEvent.click(link); + + // Should not call setNavigationScope with currentScope since it's already set + expect(mockScopesDashboardsService.setNavigationScope).not.toHaveBeenCalledWith('currentScope'); + }); + + it('should call changeScopes with subScope', async () => { + renderWithRouter( + + ); + + const link = screen.getByTestId('scopes-dashboards-test-id'); + await userEvent.click(link); + + expect(mockScopesSelectorService.changeScopes).toHaveBeenCalledWith(['subScope1'], undefined, undefined, false); + }); + + it('should navigate to URL with updated query params', async () => { + renderWithRouter( + + ); + + const link = screen.getByTestId('scopes-dashboards-test-id'); + await userEvent.click(link); + + expect(mockLocationServicePush).toHaveBeenCalled(); + const pushedUrl = mockLocationServicePush.mock.calls[0][0]; + expect(pushedUrl).toContain('/test-path'); + expect(pushedUrl).toContain('scopes=subScope1'); + expect(pushedUrl).toContain('navigation_scope=currentScope'); + }); + + it('should remove scope_node and scope_parent from URL when navigating with subScope', async () => { + renderWithRouter( + + ); + + const link = screen.getByTestId('scopes-dashboards-test-id'); + await userEvent.click(link); + + expect(mockLocationServicePush).toHaveBeenCalled(); + const pushedUrl = mockLocationServicePush.mock.calls[0][0]; + expect(pushedUrl).not.toContain('scope_node'); + expect(pushedUrl).not.toContain('scope_parent'); + }); + + it('should allow normal navigation when subScope is not provided', async () => { + const user = userEvent.setup(); + + renderWithRouter(); + + const link = screen.getByTestId('scopes-dashboards-test-id'); + await user.click(link); + + // Should not call changeScopes or locationService.push when subScope is not provided + expect(mockScopesSelectorService.changeScopes).not.toHaveBeenCalled(); + expect(mockLocationServicePush).not.toHaveBeenCalled(); + }); + + it('should handle URL with existing query params correctly', async () => { + const user = userEvent.setup(); + mockScopesDashboardsService.state.navigationScope = undefined; + mockScopesSelectorService.state.appliedScopes = [{ scopeId: 'currentScope' }]; + + renderWithRouter( + + ); + + const link = screen.getByTestId('scopes-dashboards-test-id'); + await user.click(link); + + expect(mockLocationServicePush).toHaveBeenCalled(); + const pushedUrl = mockLocationServicePush.mock.calls[0][0]; + expect(pushedUrl).toContain('scopes=subScope1'); + expect(pushedUrl).toContain('navigation_scope=currentScope'); + }); + }); }); diff --git a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx index 859b6059059..b7722edcf8c 100644 --- a/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx +++ b/public/app/features/scopes/dashboards/ScopesNavigationTreeLink.tsx @@ -2,31 +2,76 @@ import { css, cx } from '@emotion/css'; import { useMemo } from 'react'; import { Link, useLocation } from 'react-router-dom-v5-compat'; -import { GrafanaTheme2, IconName, locationUtil } from '@grafana/data'; +import { GrafanaTheme2, IconName, locationUtil, UrlQueryMap, urlUtil } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; import { Icon, useStyles2 } from '@grafana/ui'; +import { useScopesServices } from '../ScopesContextProvider'; + import { isCurrentPath, normalizePath } from './scopeNavgiationUtils'; export interface ScopesNavigationTreeLinkProps { + subScope?: string; to: string; title: string; id: string; } -export function ScopesNavigationTreeLink({ to, title, id }: ScopesNavigationTreeLinkProps) { +export function ScopesNavigationTreeLink({ subScope, to, title, id }: ScopesNavigationTreeLinkProps) { const styles = useStyles2(getStyles); const linkIcon = useMemo(() => getLinkIcon(to), [to]); const locPathname = useLocation().pathname; - + const services = useScopesServices(); // Ignore query params const isCurrent = isCurrentPath(locPathname, to); + const handleClick = (e: React.MouseEvent) => { + if (subScope) { + e.preventDefault(); // Prevent default Link navigation + + // Set current scope to navigation scope and subScope to scope + const currentScope = services?.scopesSelectorService?.state.appliedScopes[0]?.scopeId; + const currentNavigationScope = services?.scopesDashboardsService?.state.navigationScope; + + // Parse the URL to extract path and existing query params + const url = new URL(to, window.location.origin); + const pathname = url.pathname; + const searchParams = new URLSearchParams(url.search); + if (!currentNavigationScope && currentScope) { + searchParams.set('navigation_scope', currentScope); + services?.scopesDashboardsService?.setNavigationScope(currentScope); + } + + // Update query params with the new subScope + searchParams.set('scopes', subScope); + // Remove scope_node and scope_parent since we're changing to a subScope + searchParams.delete('scope_node'); + searchParams.delete('scope_parent'); + + // Convert URLSearchParams to query map object for urlUtil.renderUrl + const queryMap: UrlQueryMap = {}; + searchParams.forEach((value, key) => { + queryMap[key] = value; + }); + + // Build the new URL safely using urlUtil.renderUrl + const newUrl = urlUtil.renderUrl(pathname, queryMap); + + // Change scopes first (this updates the state) + services?.scopesSelectorService?.changeScopes([subScope], undefined, undefined, false); + + // Then navigate to the URL with updated query params + locationService.push(newUrl); + } + }; + return ( diff --git a/public/app/features/scopes/selector/ScopesSelectorService.test.ts b/public/app/features/scopes/selector/ScopesSelectorService.test.ts index eef594d2335..2c89ea58fe5 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.test.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.test.ts @@ -73,6 +73,7 @@ describe('ScopesSelectorService', () => { dashboardsService = { fetchDashboards: jest.fn().mockResolvedValue(undefined), + setNavigationScope: jest.fn(), state: { scopeNavigations: [], dashboards: [], @@ -82,6 +83,7 @@ describe('ScopesSelectorService', () => { forScopeNames: [], loading: false, searchQuery: '', + navigationScope: undefined, }, } as unknown as jest.Mocked; @@ -105,162 +107,9 @@ describe('ScopesSelectorService', () => { service = new ScopesSelectorService(apiClient, dashboardsService, store); }); - describe('updateNode', () => { - it('should update node and fetch children when expanded', async () => { - await service.updateNode('', true, ''); - expect(service.state.nodes['test-scope-node']).toEqual(mockNode); - expect(service.state.tree).toMatchObject({ - children: { 'test-scope-node': { expanded: false, scopeNodeId: 'test-scope-node' } }, - expanded: true, - query: '', - scopeNodeId: '', - }); - expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: '' }); - }); - - it.skip('should update node query and fetch children when query changes', async () => { - await service.updateNode('', true, ''); // Expand first - // Simulate a change in the query - await service.updateNode('', true, 'new-qu'); - await service.updateNode('', true, 'new-query'); - expect(service.state.tree).toMatchObject({ - children: {}, - expanded: true, - query: 'new-query', - scopeNodeId: '', - }); - expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: 'new-query' }); - }); - - it('should not fetch children when node is collapsed and query is unchanged', async () => { - // First expand the node - await service.updateNode('', true, ''); - // Then collapse it - await service.updateNode('', false, ''); - // Only the first expansion should trigger fetchNodes - expect(apiClient.fetchNodes).toHaveBeenCalledTimes(1); - }); - - it.skip('should clear query on first expansion but keep it when filtering within populated node', async () => { - const mockChildNode: ScopeNode = { - metadata: { name: 'child-node' }, - spec: { linkId: 'child-scope', linkType: 'scope', parentName: '', nodeType: 'leaf', title: 'child-node' }, - }; - - apiClient.fetchNodes.mockResolvedValue([mockChildNode]); - - // Scenario 1: First expansion (no children yet) - clear query for unfiltered view - await service.updateNode('', true, 'search-query'); - expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: undefined }); - - // Parent query should be cleared and child nodes should have no query (first expansion) - expect(service.state.tree?.query).toBe(''); - let childTreeNode = service.state.tree?.children?.['child-node']; - expect(childTreeNode?.query).toBe(''); - - // Scenario 2: Filtering within node that already has children - await service.updateNode('', true, 'new-search'); - expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: 'new-search' }); - - // Parent and child nodes should have the filter query (filtering within existing children) - expect(service.state.tree?.query).toBe('new-search'); - childTreeNode = service.state.tree?.children?.['child-node']; - expect(childTreeNode?.query).toBe('new-search'); - - expect(apiClient.fetchNodes).toHaveBeenCalledTimes(2); - }); - - it.skip('should always reset query on any expansion', async () => { - const mockChildNode: ScopeNode = { - metadata: { name: 'child-node' }, - spec: { linkId: 'child-scope', linkType: 'scope', parentName: '', nodeType: 'leaf', title: 'child-node' }, - }; - - apiClient.fetchNodes.mockResolvedValue([mockChildNode]); - - // First expansion with any query should reset parent query and not pass query to API - await service.updateNode('', true, 'some-search-query'); - - // Verify query is reset and API called without query for first expansion - expect(service.state.tree?.query).toBe(''); - expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: undefined }); - expect(service.state.tree?.children?.['child-node']?.query).toBe(''); - }); - - it.skip('should handle query reset correctly for nested levels beyond root', async () => { - // Set up mock nodes for multi-level hierarchy - const mockParentNode: ScopeNode = { - metadata: { name: 'parent-container' }, - spec: { linkId: '', linkType: 'scope', parentName: '', nodeType: 'container', title: 'Parent Container' }, - }; - - const mockChildNode: ScopeNode = { - metadata: { name: 'child-container' }, - spec: { - linkId: '', - linkType: 'scope', - parentName: 'parent-container', - nodeType: 'container', - title: 'Child Container', - }, - }; - - const mockGrandchildNode: ScopeNode = { - metadata: { name: 'grandchild-leaf' }, - spec: { - linkId: 'leaf-scope', - linkType: 'scope', - parentName: 'child-container', - nodeType: 'leaf', - title: 'Grandchild Leaf', - }, - }; - - // Mock different responses for different parent nodes - apiClient.fetchNodes.mockImplementation((options: { parent?: string; query?: string; limit?: number }) => { - if (options.parent === '') { - return Promise.resolve([mockParentNode]); - } else if (options.parent === 'parent-container') { - return Promise.resolve([mockChildNode]); - } else if (options.parent === 'child-container') { - return Promise.resolve([mockGrandchildNode]); - } - return Promise.resolve([]); - }); - - // Step 1: Expand root node with search query - await service.updateNode('', true, 'search-query'); - - // Root should have query reset, API called without query - expect(service.state.tree?.query).toBe(''); - expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: '', query: undefined }); - expect(service.state.tree?.children?.['parent-container']?.query).toBe(''); - - // Step 2: Expand first-level child with search query - await service.updateNode('parent-container', true, 'open-search-query'); - - // First-level child should have query reset, API called without query - const parentContainer = service.state.tree?.children?.['parent-container']; - expect(parentContainer?.query).toBe(''); - expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: 'parent-container', query: undefined }); - expect(parentContainer?.children?.['child-container']?.query).toBe(''); - - // Step 3: Now filter within the first-level child (second call to same node) - await service.updateNode('parent-container', true, 'filter-search'); - - // Now both parent and children should show the filter query since we're filtering within existing children - const newParentContainer = service.state.tree?.children?.['parent-container']; - expect(newParentContainer?.query).toBe('filter-search'); - expect(apiClient.fetchNodes).toHaveBeenCalledWith({ parent: 'parent-container', query: 'filter-search' }); - expect(newParentContainer?.children?.['child-container']?.query).toBe('filter-search'); - - expect(apiClient.fetchNodes).toHaveBeenCalledTimes(3); - }); - }); - describe('selectScope and deselectScope', () => { beforeEach(async () => { - await service.updateNode('', true, ''); + await service.filterNode('', ''); }); it('should select a scope', async () => { @@ -309,7 +158,7 @@ describe('ScopesSelectorService', () => { it('should set parent node for recent scopes', async () => { // Load mock node - await service.updateNode('', true, ''); + await service.filterNode('', ''); await service.changeScopes(['test-scope'], 'test-scope-node'); expect(service.state.appliedScopes).toEqual([{ scopeId: 'test-scope', parentNodeId: 'test-scope-node' }]); @@ -361,7 +210,7 @@ describe('ScopesSelectorService', () => { describe('closeAndApply', () => { it('should close the selector and apply the selected scopes', async () => { - await service.updateNode('', true, ''); + await service.filterNode('', ''); await service.selectScope('test-scope-node'); await service.closeAndApply(); expect(service.state.opened).toBe(false); @@ -371,6 +220,7 @@ describe('ScopesSelectorService', () => { describe('apply', () => { it('should apply the selected scopes without closing the selector', async () => { + await service.filterNode('', ''); await service.open(); await service.selectScope('test-scope-node'); await service.apply(); @@ -389,17 +239,45 @@ describe('ScopesSelectorService', () => { describe('removeAllScopes', () => { it('should remove all selected and applied scopes', async () => { - await service.updateNode('', true, ''); + await service.filterNode('', ''); await service.selectScope('test-scope-node'); await service.apply(); await service.removeAllScopes(); expect(service.state.appliedScopes).toEqual([]); }); + + it('should clear navigation scope when removing all scopes', async () => { + await service.filterNode('', ''); + await service.selectScope('test-scope-node'); + await service.apply(); + await service.removeAllScopes(); + expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined); + }); + }); + + describe('navigation scope interaction', () => { + it('should skip fetchDashboards when navigationScope is set', async () => { + dashboardsService.state.navigationScope = 'navScope1'; + jest.clearAllMocks(); + + await service.changeScopes(['test-scope']); + + expect(dashboardsService.fetchDashboards).not.toHaveBeenCalled(); + }); + + it('should call fetchDashboards when navigationScope is not set', async () => { + dashboardsService.state.navigationScope = undefined; + jest.clearAllMocks(); + + await service.changeScopes(['test-scope']); + + expect(dashboardsService.fetchDashboards).toHaveBeenCalledWith(['test-scope']); + }); }); describe('getRecentScopes', () => { it('should parse and filter scopes', async () => { - await service.updateNode('', true, ''); + await service.filterNode('', ''); await service.selectScope('test-scope-node'); await service.apply(); storeValue[RECENT_SCOPES_KEY] = JSON.stringify([[mockScope2], [mockScope]]); @@ -409,7 +287,7 @@ describe('ScopesSelectorService', () => { }); it('should work with old version', async () => { - await service.updateNode('', true, ''); + await service.filterNode('', ''); await service.selectScope('test-scope-node'); await service.apply(); storeValue[RECENT_SCOPES_KEY] = JSON.stringify([ @@ -585,9 +463,347 @@ describe('ScopesSelectorService', () => { }); }); + describe('toggleExpandedNode', () => { + const expandableNode: ScopeNode = { + metadata: { name: 'expandable-node' }, + spec: { + linkId: '', + linkType: undefined, + parentName: '', + nodeType: 'container', + title: 'Expandable Node', + }, + }; + + const childNode: ScopeNode = { + metadata: { name: 'child-node' }, + spec: { + linkId: 'child-scope', + linkType: 'scope', + parentName: 'expandable-node', + nodeType: 'leaf', + title: 'Child Node', + }, + }; + + const leafNode: ScopeNode = { + metadata: { name: 'leaf-node' }, + spec: { + linkId: 'leaf-scope', + linkType: 'scope', + parentName: '', + nodeType: 'leaf', + title: 'Leaf Node', + }, + }; + + beforeEach(async () => { + // Mock fetchNodes to return different nodes based on parent + apiClient.fetchNodes = jest + .fn() + .mockImplementation((options: { parent?: string; query?: string; limit?: number }) => { + if (options.parent === '') { + return [expandableNode, leafNode]; + } else if (options.parent === 'expandable-node') { + return [childNode]; + } + return []; + }); + + // Load root nodes + await service.filterNode('', ''); + }); + + it('should expand a collapsed node and load its children', async () => { + // Node should start collapsed + expect(service.state.tree?.children?.['expandable-node']?.expanded).toBe(false); + + // Expand the node + await service.toggleExpandedNode('expandable-node'); + + // Node should now be expanded + expect(service.state.tree?.children?.['expandable-node']?.expanded).toBe(true); + // Children should be loaded + expect(service.state.tree?.children?.['expandable-node']?.children).toBeDefined(); + expect(service.state.tree?.children?.['expandable-node']?.children?.['child-node']).toBeDefined(); + }); + + it('should collapse an expanded node', async () => { + // First expand the node + await service.toggleExpandedNode('expandable-node'); + expect(service.state.tree?.children?.['expandable-node']?.expanded).toBe(true); + + // Now collapse it + await service.toggleExpandedNode('expandable-node'); + expect(service.state.tree?.children?.['expandable-node']?.expanded).toBe(false); + }); + + it('should reset query to empty string when toggling', async () => { + // First filter with a query + await service.filterNode('expandable-node', 'test-query'); + expect(service.state.tree?.children?.['expandable-node']?.query).toBe('test-query'); + + // Toggle the node + await service.toggleExpandedNode('expandable-node'); + + // Query should be reset + expect(service.state.tree?.children?.['expandable-node']?.query).toBe(''); + }); + + it('should throw error when node not found in tree', async () => { + await expect(service.toggleExpandedNode('non-existent-node')).rejects.toThrow( + 'Node non-existent-node not found in tree' + ); + }); + + it('should throw error when trying to toggle a non-expandable node', async () => { + await expect(service.toggleExpandedNode('leaf-node')).rejects.toThrow( + 'Trying to expand node at id leaf-node that is not expandable' + ); + }); + + it('should reload parent children when collapsing', async () => { + const fetchNodesSpy = jest.spyOn(apiClient, 'fetchNodes'); + + // Expand then collapse + await service.toggleExpandedNode('expandable-node'); + fetchNodesSpy.mockClear(); + + await service.toggleExpandedNode('expandable-node'); + + // Should reload parent's (root) children + expect(fetchNodesSpy).toHaveBeenCalledWith({ parent: '', query: '' }); + }); + + it('should reload parent children with parent query when collapsing', async () => { + // First filter the root with a query + await service.filterNode('', 'parent-query'); + + // Expand a node + await service.toggleExpandedNode('expandable-node'); + + const fetchNodesSpy = jest.spyOn(apiClient, 'fetchNodes'); + + // Collapse the node + await service.toggleExpandedNode('expandable-node'); + + // Should reload parent's children with parent's query + expect(fetchNodesSpy).toHaveBeenCalledWith({ parent: '', query: 'parent-query' }); + }); + }); + + describe('filterNode', () => { + const containerNode: ScopeNode = { + metadata: { name: 'container-node' }, + spec: { + linkId: '', + linkType: undefined, + parentName: '', + nodeType: 'container', + title: 'Container Node', + }, + }; + + const filteredChild: ScopeNode = { + metadata: { name: 'filtered-child' }, + spec: { + linkId: 'filtered-scope', + linkType: 'scope', + parentName: 'container-node', + nodeType: 'leaf', + title: 'Filtered Child', + }, + }; + + const leafNode: ScopeNode = { + metadata: { name: 'leaf-node-2' }, + spec: { + linkId: 'leaf-scope-2', + linkType: 'scope', + parentName: '', + nodeType: 'leaf', + title: 'Leaf Node 2', + }, + }; + + beforeEach(async () => { + // Mock fetchNodes to return different nodes based on query + apiClient.fetchNodes = jest + .fn() + .mockImplementation((options: { parent?: string; query?: string; limit?: number }) => { + if (options.parent === '' && !options.query) { + return [containerNode, leafNode]; + } else if (options.parent === 'container-node' && options.query === 'test-filter') { + return [filteredChild]; + } else if (options.parent === 'container-node' && !options.query) { + return [filteredChild]; + } + return []; + }); + + // Load root nodes + await service.filterNode('', ''); + }); + + it('should filter node with non-empty query', async () => { + await service.filterNode('container-node', 'test-filter'); + + // Node should be expanded + expect(service.state.tree?.children?.['container-node']?.expanded).toBe(true); + // Query should be set + expect(service.state.tree?.children?.['container-node']?.query).toBe('test-filter'); + }); + + it('should load children with the query parameter', async () => { + const fetchNodesSpy = jest.spyOn(apiClient, 'fetchNodes'); + + await service.filterNode('container-node', 'my-query'); + + expect(fetchNodesSpy).toHaveBeenCalledWith({ parent: 'container-node', query: 'my-query' }); + }); + + it('should set expanded to true when filtering', async () => { + // Node starts collapsed + expect(service.state.tree?.children?.['container-node']?.expanded).toBe(false); + + await service.filterNode('container-node', 'test-filter'); + + // Should be expanded after filtering + expect(service.state.tree?.children?.['container-node']?.expanded).toBe(true); + }); + + it('should throw error when node not found', async () => { + await expect(service.filterNode('non-existent-node', 'query')).rejects.toThrow( + 'Trying to filter node at path or id non-existent-node not found' + ); + }); + + it('should throw error when trying to filter a non-expandable node', async () => { + await expect(service.filterNode('leaf-node-2', 'query')).rejects.toThrow( + 'Trying to filter node at id leaf-node-2 that is not expandable' + ); + }); + + it('should handle multiple calls with different queries', async () => { + // First filter + await service.filterNode('container-node', 'first-query'); + expect(service.state.tree?.children?.['container-node']?.query).toBe('first-query'); + + // Second filter with different query + await service.filterNode('container-node', 'second-query'); + expect(service.state.tree?.children?.['container-node']?.query).toBe('second-query'); + + // Third filter with empty query + await service.filterNode('container-node', ''); + expect(service.state.tree?.children?.['container-node']?.query).toBe(''); + }); + + it('should start profiler interaction', async () => { + const profiler = { + startInteraction: jest.fn(), + stopInteraction: jest.fn(), + }; + + // Create new service with profiler + const serviceWithProfiler = new ScopesSelectorService(apiClient, dashboardsService, store, profiler as never); + + await serviceWithProfiler.filterNode('', ''); + + expect(profiler.startInteraction).toHaveBeenCalledWith('scopeNodeFilter'); + expect(profiler.stopInteraction).toHaveBeenCalled(); + }); + + it('should stop profiler even when error is thrown', async () => { + const profiler = { + startInteraction: jest.fn(), + stopInteraction: jest.fn(), + }; + + const serviceWithProfiler = new ScopesSelectorService(apiClient, dashboardsService, store, profiler as never); + + // Load initial nodes + await serviceWithProfiler.filterNode('', ''); + + // Try to filter a non-existent node + await expect(serviceWithProfiler.filterNode('non-existent', 'query')).rejects.toThrow(); + + // Profiler should still be stopped + expect(profiler.stopInteraction).toHaveBeenCalled(); + }); + }); + + describe('interaction between toggleExpandedNode and filterNode', () => { + const expandableNode: ScopeNode = { + metadata: { name: 'interaction-node' }, + spec: { + linkId: '', + linkType: undefined, + parentName: '', + nodeType: 'container', + title: 'Interaction Node', + }, + }; + + const childNode: ScopeNode = { + metadata: { name: 'interaction-child' }, + spec: { + linkId: 'child-scope', + linkType: 'scope', + parentName: 'interaction-node', + nodeType: 'leaf', + title: 'Child Node', + }, + }; + + beforeEach(async () => { + apiClient.fetchNodes = jest + .fn() + .mockImplementation((options: { parent?: string; query?: string; limit?: number }) => { + if (options.parent === '') { + return [expandableNode]; + } else if (options.parent === 'interaction-node') { + return [childNode]; + } + return []; + }); + + await service.filterNode('', ''); + }); + + it('should clear query when toggleExpandedNode is called after filterNode', async () => { + // Filter with a query + await service.filterNode('interaction-node', 'test-query'); + expect(service.state.tree?.children?.['interaction-node']?.query).toBe('test-query'); + + // Toggle should clear the query + await service.toggleExpandedNode('interaction-node'); + expect(service.state.tree?.children?.['interaction-node']?.query).toBe(''); + }); + + it('should set query when filterNode is called after toggleExpandedNode', async () => { + // First toggle (expand) + await service.toggleExpandedNode('interaction-node'); + expect(service.state.tree?.children?.['interaction-node']?.query).toBe(''); + + // Filter should set the query + await service.filterNode('interaction-node', 'new-query'); + expect(service.state.tree?.children?.['interaction-node']?.query).toBe('new-query'); + }); + + it('should maintain expanded state when filtering an already expanded node', async () => { + // Expand the node + await service.toggleExpandedNode('interaction-node'); + expect(service.state.tree?.children?.['interaction-node']?.expanded).toBe(true); + + // Filter should keep it expanded + await service.filterNode('interaction-node', 'query'); + expect(service.state.tree?.children?.['interaction-node']?.expanded).toBe(true); + }); + }); + describe('redirect on scope selection', () => { it('should redirect to the first scopeNavigation with /d/ URL when current URL is not a scopeNavigation', async () => { - const mockNavigations: ScopeNavigation[] = [ + dashboardsService.state.scopeNavigations = [ { spec: { scope: 'test-scope', @@ -602,8 +818,6 @@ describe('ScopesSelectorService', () => { }, }, ]; - - dashboardsService.state.scopeNavigations = mockNavigations; (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' }); await service.changeScopes(['test-scope']); @@ -612,7 +826,7 @@ describe('ScopesSelectorService', () => { }); it('should NOT redirect when the first scopeNavigation does not contain /d/ (e.g., logs drilldown)', async () => { - const mockNavigations: ScopeNavigation[] = [ + dashboardsService.state.scopeNavigations = [ { spec: { scope: 'test-scope', @@ -627,8 +841,6 @@ describe('ScopesSelectorService', () => { }, }, ]; - - dashboardsService.state.scopeNavigations = mockNavigations; (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' }); await service.changeScopes(['test-scope']); @@ -637,7 +849,7 @@ describe('ScopesSelectorService', () => { }); it('should NOT redirect when current URL matches a scopeNavigation', async () => { - const mockNavigations: ScopeNavigation[] = [ + dashboardsService.state.scopeNavigations = [ { spec: { scope: 'test-scope', @@ -652,8 +864,6 @@ describe('ScopesSelectorService', () => { }, }, ]; - - dashboardsService.state.scopeNavigations = mockNavigations; (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/d/dashboard1' }); await service.changeScopes(['test-scope']); @@ -671,7 +881,7 @@ describe('ScopesSelectorService', () => { }); it('should NOT redirect when scopeNavigation does not have a url property', async () => { - const mockNavigations = [ + dashboardsService.state.scopeNavigations = [ { spec: { scope: 'test-scope', @@ -686,8 +896,6 @@ describe('ScopesSelectorService', () => { }, }, ] as unknown as ScopeNavigation[]; - - dashboardsService.state.scopeNavigations = mockNavigations; (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' }); await service.changeScopes(['test-scope']); @@ -696,7 +904,7 @@ describe('ScopesSelectorService', () => { }); it('should handle multiple scopeNavigations and redirect to the first dashboard one', async () => { - const mockNavigations: ScopeNavigation[] = [ + dashboardsService.state.scopeNavigations = [ { spec: { scope: 'test-scope', @@ -724,8 +932,6 @@ describe('ScopesSelectorService', () => { }, }, ]; - - dashboardsService.state.scopeNavigations = mockNavigations; (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' }); await service.changeScopes(['test-scope']); @@ -760,7 +966,7 @@ describe('ScopesSelectorService', () => { }); // First update the node to populate the service state - await service.updateNode('', true, ''); + await service.filterNode('', ''); // Then select the scope to set scopeNodeId in selectedScopes await service.selectScope('test-scope-node'); @@ -807,7 +1013,7 @@ describe('ScopesSelectorService', () => { (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' }); // First update the node to populate the service state - await service.updateNode('', true, ''); + await service.filterNode('', ''); // Then select the scope to set scopeNodeId in selectedScopes await service.selectScope('test-scope-node'); @@ -821,16 +1027,14 @@ describe('ScopesSelectorService', () => { }); it('should fall back to scope navigation when scope node is undefined', async () => { - const mockNavigations: ScopeNavigation[] = [ + // Don't add the node to the service state, so it will be undefined + dashboardsService.state.scopeNavigations = [ { spec: { scope: 'test-scope', url: '/d/dashboard1' }, status: { title: 'Dashboard 1', groups: [] }, metadata: { name: 'dashboard1' }, }, ]; - - // Don't add the node to the service state, so it will be undefined - dashboardsService.state.scopeNavigations = mockNavigations; (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/some-other-page' }); await service.changeScopes(['test-scope']); diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index 742a77ad5d4..ede86a50135 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -46,7 +46,7 @@ export interface ScopesSelectorServiceState { // Simple tree structure for the scopes categories. Each node in a tree has a scopeNodeId which keys the nodes cache // map. - tree: TreeNode | undefined; + tree: TreeNode; } export class ScopesSelectorService extends ScopesServiceBase { @@ -116,9 +116,6 @@ export class ScopesSelectorService extends ScopesServiceBase => { - if (!tree) { - throw new Error('Tree is required'); - } const nodePath = await this.getNodePath(scopeNodeId); const newTree = insertPathNodesIntoTree(tree, nodePath); @@ -129,81 +126,38 @@ export class ScopesSelectorService extends ScopesServiceBase { - const path = getPathOfNode(scopeNodeId, this.state.nodes); - const nodeToToggle = treeNodeAtPath(this.state.tree!, path); - - if (!nodeToToggle) { - throw new Error(`Node ${scopeNodeId} not found in tree`); - } - - if (nodeToToggle.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToToggle.scopeNodeId])) { - throw new Error(`Trying to expand node at id ${scopeNodeId} that is not expandable`); - } - - const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => { - treeNode.expanded = !nodeToToggle.expanded; - treeNode.query = ''; - }); - - this.updateState({ tree: newTree }); - // If we are collapsing, we need to make sure that all the parent's children are avilable - if (nodeToToggle.expanded === true) { - const parentPath = path.slice(0, -1); - const parentNode = treeNodeAtPath(this.state.tree!, parentPath); - if (parentNode) { - await this.loadNodeChildren(parentPath, parentNode, parentNode.query); - } - } else { - await this.loadNodeChildren(path, nodeToToggle); - } - }; - - public filterNode = async (scopeNodeId: string, query: string) => { - const path = getPathOfNode(scopeNodeId, this.state.nodes); - const nodeToFilter = treeNodeAtPath(this.state.tree!, path); - - if (!nodeToFilter) { - throw new Error(`Trying to filter node at path or id ${scopeNodeId} not found`); - } - - if (nodeToFilter.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToFilter.scopeNodeId])) { - throw new Error(`Trying to filter node at id ${scopeNodeId} that is not expandable`); - } - - const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => { - treeNode.expanded = true; - treeNode.query = query; - }); - this.updateState({ tree: newTree }); - - await this.loadNodeChildren(path, nodeToFilter, query); - }; - - private expandOrFilterNode = async (scopeNodeId: string, query?: string) => { - this.interactionProfiler?.startInteraction('scopeNodeDiscovery'); - - const path = getPathOfNode(scopeNodeId, this.state.nodes); - - const nodeToExpand = treeNodeAtPath(this.state.tree!, path); + this.interactionProfiler?.startInteraction('scopeToggleExpandedNode'); try { - if (!nodeToExpand) { + const path = getPathOfNode(scopeNodeId, this.state.nodes); + const nodeToToggle = treeNodeAtPath(this.state.tree, path); + + if (!nodeToToggle) { throw new Error(`Node ${scopeNodeId} not found in tree`); } - if (nodeToExpand.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToExpand.scopeNodeId])) { + if (nodeToToggle.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToToggle.scopeNodeId])) { throw new Error(`Trying to expand node at id ${scopeNodeId} that is not expandable`); } - if (!nodeToExpand.expanded || nodeToExpand.query !== query) { - const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => { - treeNode.expanded = true; - treeNode.query = query || ''; - }); - this.updateState({ tree: newTree }); + const newTree = modifyTreeNodeAtPath(this.state.tree, path, (treeNode) => { + treeNode.expanded = !nodeToToggle.expanded; + treeNode.query = ''; + }); - await this.loadNodeChildren(path, nodeToExpand, query); + this.updateState({ tree: newTree }); + // If we are collapsing, we need to make sure that all the parent's children are available + if (nodeToToggle.expanded) { + const parentPath = path.slice(0, -1); + const parentNode = treeNodeAtPath(this.state.tree, parentPath); + if (parentNode) { + await this.loadNodeChildren(parentPath, parentNode, parentNode.query); + } + } else { + await this.loadNodeChildren(path, nodeToToggle); } + // Catch and throw error so we can ensure the profiler is stopped + // todo: leverage component-level } catch (error) { throw error; } finally { @@ -211,20 +165,34 @@ export class ScopesSelectorService extends ScopesServiceBase { - const path = getPathOfNode(scopeNodeId, this.state.nodes); + public filterNode = async (scopeNodeId: string, query: string) => { + this.interactionProfiler?.startInteraction('scopeNodeFilter'); - const nodeToCollapse = treeNodeAtPath(this.state.tree!, path); + try { + const path = getPathOfNode(scopeNodeId, this.state.nodes); + const nodeToFilter = treeNodeAtPath(this.state.tree, path); - if (!nodeToCollapse) { - throw new Error(`Trying to collapse node at path or id ${scopeNodeId} not found`); + if (!nodeToFilter) { + throw new Error(`Trying to filter node at path or id ${scopeNodeId} not found`); + } + + if (nodeToFilter.scopeNodeId !== '' && !isNodeExpandable(this.state.nodes[nodeToFilter.scopeNodeId])) { + throw new Error(`Trying to filter node at id ${scopeNodeId} that is not expandable`); + } + + const newTree = modifyTreeNodeAtPath(this.state.tree, path, (treeNode) => { + treeNode.expanded = true; + treeNode.query = query; + }); + this.updateState({ tree: newTree }); + + await this.loadNodeChildren(path, nodeToFilter, query); + // Catch and throw error so we can ensure the profiler is stopped + } catch (error) { + throw error; + } finally { + this.interactionProfiler?.stopInteraction(); } - - const newTree = modifyTreeNodeAtPath(this.state.tree!, path, (treeNode) => { - treeNode.expanded = false; - treeNode.query = ''; - }); - this.updateState({ tree: newTree }); }; private loadNodeChildren = async (path: string[], treeNode: TreeNode, query?: string) => { @@ -238,7 +206,7 @@ export class ScopesSelectorService extends ScopesServiceBase { + const newTree = modifyTreeNodeAtPath(this.state.tree, path, (treeNode) => { // Set parent query only when filtering within existing children treeNode.children = {}; for (const node of childNodes) { @@ -332,15 +300,6 @@ export class ScopesSelectorService extends ScopesServiceBase { - if (expanded) { - return this.expandOrFilterNode(scopeNodeId, query); - } - return this.collapseNode(scopeNodeId); - }; - changeScopes = (scopeNames: string[], parentNodeId?: string, scopeNodeId?: string, redirectOnApply?: boolean) => { return this.applyScopes( scopeNames.map((id, index) => ({ @@ -371,12 +330,15 @@ export class ScopesSelectorService extends ScopesServiceBase s.scopeId)).then(() => { - const selectedScopeNode = scopes[0]?.scopeNodeId ? this.state.nodes[scopes[0]?.scopeNodeId] : undefined; - if (redirectOnApply) { - this.redirectAfterApply(selectedScopeNode); - } - }); + // Only fetch dashboards based on the scopes if we don't have a navigation scope set. + if (!this.dashboardsService.state.navigationScope) { + this.dashboardsService.fetchDashboards(scopes.map((s) => s.scopeId)).then(() => { + const selectedScopeNode = scopes[0]?.scopeNodeId ? this.state.nodes[scopes[0]?.scopeNodeId] : undefined; + if (redirectOnApply) { + this.redirectAfterApply(selectedScopeNode); + } + }); + } if (scopes.length > 0) { const fetchedScopes = await this.apiClient.fetchMultipleScopes(scopes.map((s) => s.scopeId)); @@ -431,7 +393,10 @@ export class ScopesSelectorService extends ScopesServiceBase this.applyScopes([], false); + public removeAllScopes = () => { + this.applyScopes([], false); + this.dashboardsService.setNavigationScope(undefined); + }; private addRecentScopes = (scopes: Scope[], parentNode?: ScopeNode) => { if (scopes.length === 0) { @@ -487,12 +452,12 @@ export class ScopesSelectorService extends ScopesServiceBase { - if (!this.state.tree?.children || Object.keys(this.state.tree?.children).length === 0) { - await this.expandOrFilterNode(''); + if (!this.state.tree.children || Object.keys(this.state.tree.children).length === 0) { + await this.filterNode('', ''); } // First close all nodes - let newTree = closeNodes(this.state.tree!); + let newTree = closeNodes(this.state.tree); if (this.state.selectedScopes.length && this.state.selectedScopes[0].parentNodeId) { let path = getPathOfNode(this.state.selectedScopes[0].parentNodeId, this.state.nodes); diff --git a/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx b/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx index dff0a9c2a9c..4eed9131875 100644 --- a/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, type JSX } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { OrgRole } from '@grafana/data'; diff --git a/public/app/features/serviceaccounts/ServiceAccountPage.tsx b/public/app/features/serviceaccounts/ServiceAccountPage.tsx index 12119d1e271..4ecbc96412a 100644 --- a/public/app/features/serviceaccounts/ServiceAccountPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type JSX } from 'react'; import { ConnectedProps, connect } from 'react-redux'; import { useParams } from 'react-router-dom-v5-compat'; diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx index ebdeefcac1a..5a6edef62a8 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; import { OrgRole } from '@grafana/data'; diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx index 68ee38b7c73..3252f177b7f 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, type JSX } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { GrafanaTheme2, OrgRole } from '@grafana/data'; diff --git a/public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx b/public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx index 0e15cc944b0..b451cd91d87 100644 --- a/public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx +++ b/public/app/features/serviceaccounts/components/ServiceAccountProfile.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, type JSX } from 'react'; import { GrafanaTheme2, OrgRole, TimeZone, dateTimeFormat } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/features/serviceaccounts/components/ServiceAccountProfileRow.tsx b/public/app/features/serviceaccounts/components/ServiceAccountProfileRow.tsx index 428c78175a8..e11c417bfb7 100644 --- a/public/app/features/serviceaccounts/components/ServiceAccountProfileRow.tsx +++ b/public/app/features/serviceaccounts/components/ServiceAccountProfileRow.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type JSX } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/features/serviceaccounts/components/ServiceAccountRoleRow.tsx b/public/app/features/serviceaccounts/components/ServiceAccountRoleRow.tsx index 0e7bbf57f5b..65cfe010f69 100644 --- a/public/app/features/serviceaccounts/components/ServiceAccountRoleRow.tsx +++ b/public/app/features/serviceaccounts/components/ServiceAccountRoleRow.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { OrgRole } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Label } from '@grafana/ui'; diff --git a/public/app/features/serviceaccounts/components/ServiceAccountTokensTable.tsx b/public/app/features/serviceaccounts/components/ServiceAccountTokensTable.tsx index 66b2e818efd..c0e6c8ccd0b 100644 --- a/public/app/features/serviceaccounts/components/ServiceAccountTokensTable.tsx +++ b/public/app/features/serviceaccounts/components/ServiceAccountTokensTable.tsx @@ -1,4 +1,5 @@ import { css, cx } from '@emotion/css'; +import type { JSX } from 'react'; import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/features/support-bundles/SupportBundlesCreate.tsx b/public/app/features/support-bundles/SupportBundlesCreate.tsx index 4461870556a..ca0dab6aa24 100644 --- a/public/app/features/support-bundles/SupportBundlesCreate.tsx +++ b/public/app/features/support-bundles/SupportBundlesCreate.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, type JSX } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/features/teams/CreateTeam.test.tsx b/public/app/features/teams/CreateTeam.test.tsx index 519b835cd52..12db809d40f 100644 --- a/public/app/features/teams/CreateTeam.test.tsx +++ b/public/app/features/teams/CreateTeam.test.tsx @@ -8,7 +8,7 @@ import { MOCK_TEAMS } from '@grafana/test-utils/unstable'; import { backendSrv } from 'app/core/services/backend_srv'; import { contextSrv } from 'app/core/services/context_srv'; -import { CreateTeam } from './CreateTeam'; +import CreateTeam from './CreateTeam'; setBackendSrv(backendSrv); setupMockServer(); diff --git a/public/app/features/teams/CreateTeam.tsx b/public/app/features/teams/CreateTeam.tsx index 050f7aefa1e..82ee804f6d3 100644 --- a/public/app/features/teams/CreateTeam.tsx +++ b/public/app/features/teams/CreateTeam.tsx @@ -3,16 +3,19 @@ import { useForm } from 'react-hook-form'; import { NavModelItem } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { getBackendSrv, locationService } from '@grafana/runtime'; +import { locationService } from '@grafana/runtime'; import { Button, Field, Input, FieldSet, Stack } from '@grafana/ui'; +import { extractErrorMessage } from 'app/api/utils'; import { Page } from 'app/core/components/Page/Page'; import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker'; -import { updateTeamRoles } from 'app/core/components/RolePicker/api'; import { useRoleOptions } from 'app/core/components/RolePicker/hooks'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/services/context_srv'; -import { Role, AccessControlAction } from 'app/types/accessControl'; +import { Role } from 'app/types/accessControl'; import { TeamDTO } from 'app/types/teams'; +import { useCreateTeam } from './hooks'; + const pageNav: NavModelItem = { icon: 'users-alt', id: 'team-new', @@ -20,8 +23,11 @@ const pageNav: NavModelItem = { subTitle: 'Create a new team. Teams let you grant permissions to a group of users.', }; -export const CreateTeam = (): JSX.Element => { +const CreateTeam = (): JSX.Element => { const currentOrgId = contextSrv.user.orgId; + + const notifyApp = useAppNotification(); + const [createTeamTrigger] = useCreateTeam(); const [pendingRoles, setPendingRoles] = useState([]); const [{ roleOptions }] = useRoleOptions(currentOrgId); const { @@ -30,21 +36,28 @@ export const CreateTeam = (): JSX.Element => { formState: { errors }, } = useForm(); - const canUpdateRoles = - contextSrv.hasPermission(AccessControlAction.ActionUserRolesAdd) && - contextSrv.hasPermission(AccessControlAction.ActionUserRolesRemove); - const createTeam = async (formModel: TeamDTO) => { try { - const newTeam = await getBackendSrv().post('/api/teams', formModel); - if (newTeam.teamId) { - await contextSrv.fetchUserPermissions(); - if (contextSrv.licensedAccessControlEnabled() && canUpdateRoles) { - await updateTeamRoles(pendingRoles, newTeam.teamId, newTeam.orgId); - } - locationService.push(`/org/teams/edit/${newTeam.uid}`); + const { data, error } = await createTeamTrigger( + { + email: formModel.email || '', + name: formModel.name, + }, + pendingRoles + ); + + const errorMessage = error ? extractErrorMessage(error) : undefined; + + if (errorMessage) { + notifyApp.error(errorMessage); + return; + } + + if (data && data.uid) { + locationService.push(`/org/teams/edit/${data.uid}`); } } catch (e) { + notifyApp.error(t('teams.create-team.failed-to-create', 'Failed to create team')); console.error(e); } }; @@ -85,8 +98,13 @@ export const CreateTeam = (): JSX.Element => { 'This is optional and is primarily used for allowing custom team avatars' )} > - {/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */} - + diff --git a/public/app/features/teams/TeamGroupSync.test.tsx b/public/app/features/teams/TeamGroupSync.test.tsx index e3bd21b7e3a..d3a83d9c5dc 100644 --- a/public/app/features/teams/TeamGroupSync.test.tsx +++ b/public/app/features/teams/TeamGroupSync.test.tsx @@ -4,7 +4,7 @@ import { setBackendSrv } from '@grafana/runtime'; import { setupMockServer } from '@grafana/test-utils/server'; import { MOCK_TEAMS } from '@grafana/test-utils/unstable'; import { backendSrv } from 'app/core/services/backend_srv'; -import { Team, TeamGroup, TeamState } from 'app/types/teams'; +import { TeamGroup, TeamState } from 'app/types/teams'; import TeamGroupSync from './TeamGroupSync'; import { getMockTeamGroups } from './mocks/teamMocks'; @@ -13,12 +13,10 @@ setBackendSrv(backendSrv); setupMockServer(); const setup = (preloadedTeamState?: Partial) => { - return render(, { + return render(, { preloadedState: { team: { - members: [], groups: [], - team: { uid: MOCK_TEAMS[0].metadata.name } as Team, ...preloadedTeamState, }, }, diff --git a/public/app/features/teams/TeamGroupSync.tsx b/public/app/features/teams/TeamGroupSync.tsx index e87a1d4380f..37887ee8d6a 100644 --- a/public/app/features/teams/TeamGroupSync.tsx +++ b/public/app/features/teams/TeamGroupSync.tsx @@ -29,6 +29,7 @@ const mapDispatchToProps = { interface OwnProps { isReadOnly: boolean; + teamUid: string; } interface State { @@ -52,7 +53,7 @@ export class TeamGroupSync extends PureComponent { } async fetchTeamGroups() { - this.props.loadTeamGroups(); + this.props.loadTeamGroups(this.props.teamUid); } onToggleAdding = () => { @@ -65,12 +66,12 @@ export class TeamGroupSync extends PureComponent { onAddGroup: FormEventHandler = (event) => { event.preventDefault(); - this.props.addTeamGroup(this.state.newGroupId); + this.props.addTeamGroup(this.props.teamUid, this.state.newGroupId); this.setState({ isAdding: false, newGroupId: '' }); }; onRemoveGroup = (group: TeamGroup) => { - this.props.removeTeamGroup(group.groupId); + this.props.removeTeamGroup(this.props.teamUid, group.groupId); }; isNewGroupValid() { diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx index a78cbe2eceb..017b6ad2d25 100644 --- a/public/app/features/teams/TeamList.test.tsx +++ b/public/app/features/teams/TeamList.test.tsx @@ -40,16 +40,16 @@ describe('TeamList', () => { it('should enable the new team button', async () => { render(); - expect(screen.getByRole('link', { name: /new team/i })).not.toHaveStyle('pointer-events: none'); + expect(await screen.findByRole('link', { name: /new team/i })).not.toHaveStyle('pointer-events: none'); }); }); describe('when user does not have access to create a team', () => { - it('should disable the new team button', () => { + it('should disable the new team button', async () => { jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); render(); - expect(screen.getByRole('link', { name: /new team/i })).toHaveStyle('pointer-events: none'); + expect(await screen.findByRole('link', { name: /new team/i })).toHaveStyle('pointer-events: none'); }); }); }); diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index e2ecbf07c4a..3ffd09b94fe 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import Skeleton from 'react-loading-skeleton'; -import { connect, ConnectedProps } from 'react-redux'; +import { SortingRule } from 'react-table'; import { Trans, t } from '@grafana/i18n'; import { @@ -14,6 +14,7 @@ import { InlineField, InteractiveTable, LinkButton, + LoadingPlaceholder, Pagination, Stack, Tag, @@ -24,16 +25,14 @@ import { Page } from 'app/core/components/Page/Page'; import { fetchRoleOptions } from 'app/core/components/RolePicker/api'; import { contextSrv } from 'app/core/services/context_srv'; import { Role, AccessControlAction } from 'app/types/accessControl'; -import { StoreState } from 'app/types/store'; import { TeamWithRoles } from 'app/types/teams'; import { TeamRolePicker } from '../../core/components/RolePicker/TeamRolePicker'; import { EnterpriseAuthFeaturesCard } from '../admin/EnterpriseAuthFeaturesCard'; -import { deleteTeam, loadTeams, changePage, changeQuery, changeSort } from './state/actions'; +import { useDeleteTeam, useGetTeams } from './hooks'; type Cell = CellProps; -export interface OwnProps {} export interface State { roleOptions: Role[]; @@ -49,26 +48,31 @@ const skeletonData: TeamWithRoles[] = new Array(3).fill(null).map((_, index) => isProvisioned: false, })); -const TeamList = ({ - teams, - query, - noTeams, - hasFetched, - loadTeams, - deleteTeam, - changeQuery, - totalPages, - page, - rolesLoading, - changePage, - changeSort, -}: Props) => { +const TeamList = () => { + const canCreate = contextSrv.hasPermission(AccessControlAction.ActionTeamsCreate); + const displayRolePicker = shouldDisplayRolePicker(); + const pageSize = 20; + const [roleOptions, setRoleOptions] = useState([]); const styles = useStyles2(getStyles); + const [query, setQuery] = useState(''); + const [page, setPage] = useState(1); + const [sort, setSort] = useState(); + const { data, isLoading } = useGetTeams({ query, pageSize, page, sort }); + const [deleteTeam] = useDeleteTeam(); - useEffect(() => { - loadTeams(true); - }, [loadTeams]); + const teams = data?.teams || []; + const totalPages = Math.ceil((data?.totalCount || 0) / pageSize) || 0; + const noTeams = teams?.length === 0; + const changeSort = useCallback( + (sort: SortingRule) => { + setSort(`${sort.id}-${sort.desc ? 'desc' : 'asc'}`); + }, + [setSort] + ); + const changePage = (page: number) => { + setPage(page); + }; useEffect(() => { if (contextSrv.licensedAccessControlEnabled() && contextSrv.hasPermission(AccessControlAction.ActionRolesList)) { @@ -76,9 +80,6 @@ const TeamList = ({ } }, []); - const canCreate = contextSrv.hasPermission(AccessControlAction.ActionTeamsCreate); - const displayRolePicker = shouldDisplayRolePicker(); - const columns: Array> = useMemo( () => [ { @@ -86,7 +87,7 @@ const TeamList = ({ header: '', disableGrow: true, cell: ({ cell: { value } }: Cell<'avatarUrl'>) => { - if (!hasFetched) { + if (isLoading) { return ; } @@ -97,7 +98,7 @@ const TeamList = ({ id: 'name', header: 'Name', cell: ({ cell: { value }, row: { original } }: Cell<'name'>) => { - if (!hasFetched) { + if (isLoading) { return ; } @@ -123,7 +124,7 @@ const TeamList = ({ id: 'email', header: 'Email', cell: ({ cell: { value } }: Cell<'email'>) => { - if (!hasFetched) { + if (isLoading) { return ; } return value; @@ -135,7 +136,7 @@ const TeamList = ({ header: 'Members', disableGrow: true, cell: ({ cell: { value } }: Cell<'memberCount'>) => { - if (!hasFetched) { + if (isLoading) { return ; } return value; @@ -147,8 +148,8 @@ const TeamList = ({ { id: 'role', header: 'Role', - cell: ({ cell: { value }, row: { original } }: Cell<'memberCount'>) => { - if (!hasFetched) { + cell: ({ row: { original } }: Cell<'memberCount'>) => { + if (isLoading) { return ; } const canSeeTeamRoles = contextSrv.hasPermissionInMetadata( @@ -160,7 +161,7 @@ const TeamList = ({ @@ -174,7 +175,7 @@ const TeamList = ({ id: 'isProvisioned', header: '', cell: ({ cell: { value } }: Cell<'isProvisioned'>) => { - if (!hasFetched) { + if (isLoading) { return ; } return !!value && ; @@ -185,7 +186,7 @@ const TeamList = ({ header: '', disableGrow: true, cell: ({ row: { original } }: Cell) => { - if (!hasFetched) { + if (isLoading) { return ( @@ -216,14 +217,14 @@ const TeamList = ({ })} size="sm" disabled={!canDelete} - onConfirm={() => deleteTeam(original.uid)} + onConfirm={() => deleteTeam({ uid: original.uid })} /> ); }, }, ], - [displayRolePicker, hasFetched, rolesLoading, roleOptions, deleteTeam, styles] + [displayRolePicker, isLoading, styles.blockSkeleton, roleOptions, deleteTeam] ); return ( @@ -238,7 +239,7 @@ const TeamList = ({ } > - {noTeams ? ( + {!isLoading && !query && teams.length === 0 ? (
- {hasFetched && teams.length === 0 ? ( + {!isLoading && teams.length === 0 && ( - ) : ( + )} + {isLoading && } + {!isLoading && teams.length > 0 && ( String(team.id)} - fetchData={changeSort} + fetchData={({ sortBy }) => { + const sortingRule = sortBy.at(0); + if (sortingRule) { + return changeSort(sortingRule); + } + }} /> ; -export default connector(TeamList); +export default TeamList; const getStyles = () => ({ blockSkeleton: css({ diff --git a/public/app/features/teams/TeamPages.tsx b/public/app/features/teams/TeamPages.tsx index 89a5cb5e032..3391a08c17d 100644 --- a/public/app/features/teams/TeamPages.tsx +++ b/public/app/features/teams/TeamPages.tsx @@ -1,7 +1,6 @@ import { createSelector } from '@reduxjs/toolkit'; import { memo, useRef } from 'react'; import { useParams } from 'react-router-dom-v5-compat'; -import { useAsync } from 'react-use'; import { featureEnabled } from '@grafana/runtime'; import { Page } from 'app/core/components/Page/Page'; @@ -10,14 +9,13 @@ import config from 'app/core/config'; import { getNavModel } from 'app/core/selectors/navModel'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types/accessControl'; -import { StoreState, useDispatch, useSelector } from 'app/types/store'; +import { StoreState, useSelector } from 'app/types/store'; import TeamGroupSync, { TeamSyncUpgradeContent } from './TeamGroupSync'; import TeamPermissions from './TeamPermissions'; import TeamSettings from './TeamSettings'; -import { loadTeam } from './state/actions'; +import { useGetTeam } from './hooks'; import { getTeamLoadingNav } from './state/navModel'; -import { getTeam } from './state/selectors'; type TeamPageRouteParams = { uid: string; @@ -32,11 +30,6 @@ enum PageTypes { const PAGES = ['members', 'settings', 'groupsync']; -const teamSelector = createSelector( - [(state: StoreState) => state.team, (_: StoreState, teamUid: string) => teamUid], - (team, teamUid) => getTeam(team, teamUid) -); - const pageNavSelector = createSelector( [ (state: StoreState) => state.navIndex, @@ -52,7 +45,8 @@ const pageNavSelector = createSelector( const TeamPages = memo(() => { const isSyncEnabled = useRef(featureEnabled('teamsync')); const { uid: teamUid = '', page } = useParams(); - const team = useSelector((state) => teamSelector(state, teamUid)); + + const { data: team, isLoading } = useGetTeam({ uid: teamUid }); let defaultPage = 'members'; // With RBAC the settings page will always be available @@ -62,9 +56,6 @@ const TeamPages = memo(() => { const pageName = page ?? defaultPage; const pageNav = useSelector((state) => pageNavSelector(state, pageName, teamUid)); - const dispatch = useDispatch(); - const { loading: isLoading } = useAsync(async () => dispatch(loadTeam(teamUid)), [teamUid]); - const renderPage = () => { const currentPage = PAGES.includes(pageName) ? pageName : PAGES[0]; @@ -89,7 +80,7 @@ const TeamPages = memo(() => { case PageTypes.GroupSync: if (isSyncEnabled.current) { if (canReadTeamPermissions) { - return ; + return ; } } else if (config.featureToggles.featureHighlights) { return ( diff --git a/public/app/features/teams/TeamSettings.test.tsx b/public/app/features/teams/TeamSettings.test.tsx index 9d31644657a..f5cd0a6fcf3 100644 --- a/public/app/features/teams/TeamSettings.test.tsx +++ b/public/app/features/teams/TeamSettings.test.tsx @@ -1,12 +1,13 @@ -import { render, screen, waitFor } from 'test/test-utils'; +import { render, screen } from 'test/test-utils'; import { setBackendSrv } from '@grafana/runtime'; import { setupMockServer } from '@grafana/test-utils/server'; import { MOCK_TEAMS } from '@grafana/test-utils/unstable'; +import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; import { backendSrv } from 'app/core/services/backend_srv'; import { contextSrv } from 'app/core/services/context_srv'; -import { Props, TeamSettings } from './TeamSettings'; +import TeamSettings from './TeamSettings'; jest.spyOn(contextSrv, 'hasPermission').mockImplementation(() => true); jest.spyOn(contextSrv, 'hasPermissionInMetadata').mockImplementation(() => true); @@ -14,9 +15,9 @@ jest.spyOn(contextSrv, 'hasPermissionInMetadata').mockImplementation(() => true) setBackendSrv(backendSrv); setupMockServer(); -const setup = (propOverrides?: object) => { +const setup = () => { const team = MOCK_TEAMS[0]; - const props: Props = { + const props = { team: { id: Number(team.metadata.labels['grafana.app/deprecatedInternalID']), uid: team.metadata.name, @@ -25,12 +26,14 @@ const setup = (propOverrides?: object) => { orgId: 1, isProvisioned: false, }, - updateTeam: jest.fn(), }; - Object.assign(props, propOverrides); - - return render(); + return render( + <> + + + + ); }; describe('Team settings', () => { @@ -41,8 +44,7 @@ describe('Team settings', () => { }); it('should validate required fields', async () => { - const mockUpdate = jest.fn(); - const { user } = setup({ updateTeam: mockUpdate }); + const { user } = setup(); await screen.findByText('Team details'); await user.clear(screen.getByRole('textbox', { name: /Name/ })); @@ -50,12 +52,10 @@ describe('Team settings', () => { await user.click(screen.getByRole('button', { name: 'Save team details' })); expect(await screen.findByText('Name is required')).toBeInTheDocument(); - await waitFor(() => expect(mockUpdate).not.toHaveBeenCalled()); }); it('should submit form with correct values', async () => { - const mockUpdate = jest.fn(); - const { user } = setup({ updateTeam: mockUpdate }); + const { user } = setup(); await user.clear(screen.getByRole('textbox', { name: /Name/ })); await user.clear(screen.getByLabelText(/Email/i)); @@ -63,6 +63,6 @@ describe('Team settings', () => { await user.type(screen.getByLabelText(/Email/i), 'team@test.com'); await user.click(screen.getByRole('button', { name: 'Save team details' })); - await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith('New team', 'team@test.com')); + expect(await screen.findByText('Team updated')).toBeInTheDocument(); }); }); diff --git a/public/app/features/teams/TeamSettings.tsx b/public/app/features/teams/TeamSettings.tsx index a4779df63cc..7a847d9ebf3 100644 --- a/public/app/features/teams/TeamSettings.tsx +++ b/public/app/features/teams/TeamSettings.tsx @@ -1,5 +1,4 @@ import { useForm } from 'react-hook-form'; -import { ConnectedProps, connect } from 'react-redux'; import { Trans, t } from '@grafana/i18n'; import { Button, Field, FieldSet, Input, Stack } from '@grafana/ui'; @@ -10,22 +9,16 @@ import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types/accessControl'; import { Team } from 'app/types/teams'; -import { updateTeam } from './state/actions'; +import { useUpdateTeam } from './hooks'; -const mapDispatchToProps = { - updateTeam, -}; - -const connector = connect(null, mapDispatchToProps); - -interface OwnProps { +interface Props { team: Team; } -export type Props = ConnectedProps & OwnProps; -export const TeamSettings = ({ team, updateTeam }: Props) => { +const TeamSettings = ({ team }: Props) => { const canWriteTeamSettings = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsWrite, team); const currentOrgId = contextSrv.user.orgId; + const [updateTeam] = useUpdateTeam(); const [{ roleOptions }] = useRoleOptions(currentOrgId); const { @@ -43,7 +36,13 @@ export const TeamSettings = ({ team, updateTeam }: Props) => { contextSrv.hasPermission(AccessControlAction.ActionRolesList); const onSubmit = async (formTeam: Team) => { - updateTeam(formTeam.name, formTeam.email || ''); + return updateTeam({ + uid: team.uid, + team: { + name: formTeam.name, + email: formTeam.email || '', + }, + }); }; return ( @@ -103,4 +102,4 @@ export const TeamSettings = ({ team, updateTeam }: Props) => { ); }; -export default connector(TeamSettings); +export default TeamSettings; diff --git a/public/app/features/teams/hooks.ts b/public/app/features/teams/hooks.ts new file mode 100644 index 00000000000..2c563ef3d2f --- /dev/null +++ b/public/app/features/teams/hooks.ts @@ -0,0 +1,154 @@ +import { skipToken } from '@reduxjs/toolkit/query'; +import { useEffect, useMemo } from 'react'; + +import { + useSearchTeamsQuery as useLegacySearchTeamsQuery, + useCreateTeamMutation, + useDeleteTeamByIdMutation, + useListTeamsRolesQuery, + CreateTeamCommand, + useSetTeamRolesMutation, + useGetTeamByIdQuery, + useUpdateTeamMutation, + UpdateTeamCommand, +} from 'app/api/clients/legacy'; +import { updateNavIndex } from 'app/core/actions'; +import { addFilteredDisplayName } from 'app/core/components/RolePicker/utils'; +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction, Role } from 'app/types/accessControl'; +import { useDispatch } from 'app/types/store'; + +import { buildNavModel } from './state/navModel'; + +const rolesEnabled = + contextSrv.licensedAccessControlEnabled() && contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList); + +const canUpdateRoles = () => + contextSrv.hasPermission(AccessControlAction.ActionUserRolesAdd) && + contextSrv.hasPermission(AccessControlAction.ActionUserRolesRemove); + +/** + * Get list of teams and their associated roles (if roles are enabled) + */ +export const useGetTeams = ({ + query, + pageSize, + page, + sort, +}: { + query?: string; + pageSize?: number; + page?: number; + sort?: string; +}) => { + const legacyResponse = useLegacySearchTeamsQuery({ perpage: pageSize, accesscontrol: true, page, sort, query }); + + const teamIds = useMemo(() => { + const teams = legacyResponse.data?.teams || []; + const ids = teams.map((team) => team.id); + return ids.filter((id): id is number => id !== undefined); + }, [legacyResponse.data?.teams]); + + const teamsRolesResponse = useListTeamsRolesQuery( + rolesEnabled && teamIds.length ? { rolesSearchQuery: { teamIds } } : skipToken + ); + + const teamsWithRoles = useMemo(() => { + if (!rolesEnabled || (rolesEnabled && teamsRolesResponse.isLoading)) { + return legacyResponse.data?.teams || []; + } + return (legacyResponse.data?.teams || []).map((team) => { + const roles = team.id ? teamsRolesResponse.data?.[team.id] || [] : []; + const mappedRoles = roles.map((role) => addFilteredDisplayName(role)); + return { + ...team, + roles: mappedRoles, + }; + }); + }, [legacyResponse, teamsRolesResponse]); + + return { + ...legacyResponse, + isLoading: legacyResponse.isLoading || (rolesEnabled ? teamsRolesResponse.isLoading : false), + data: { + teams: teamsWithRoles, + totalCount: legacyResponse.data?.totalCount, + }, + }; +}; + +/** + * Get a single team by UID + */ +export const useGetTeam = ({ uid }: { uid: string }) => { + const response = useGetTeamByIdQuery({ teamId: uid, accesscontrol: true }); + const dispatch = useDispatch(); + + // TODO: Eventually remove and handle nav index logic elsewhere + useEffect(() => { + if (response.data) { + dispatch(updateNavIndex(buildNavModel(response.data))); + } + }, [response.data, dispatch]); + + return response; +}; + +/** + * Update a team by UID + */ +export const useUpdateTeam = () => { + const [updateTeam, response] = useUpdateTeamMutation(); + + const trigger = async ({ uid, team }: { uid: string; team: UpdateTeamCommand }) => { + const mutationResult = await updateTeam({ + teamId: uid, + updateTeamCommand: team, + }); + + return mutationResult; + }; + + return [trigger, response] as const; +}; + +/** + * Delete a team by UID + */ +export const useDeleteTeam = () => { + const [deleteTeam, response] = useDeleteTeamByIdMutation(); + + return [({ uid }: { uid: string }) => deleteTeam({ teamId: uid }), response] as const; +}; + +/** + * Create a new team, and link any pending roles + */ +export const useCreateTeam = () => { + const [createTeam, response] = useCreateTeamMutation(); + const [setTeamRoles] = useSetTeamRolesMutation(); + + const trigger = async (team: CreateTeamCommand, pendingRoles?: Role[]) => { + const mutationResult = await createTeam({ + createTeamCommand: team, + }); + + const { data } = mutationResult; + + if (data && data.teamId && pendingRoles && pendingRoles.length) { + await contextSrv.fetchUserPermissions(); + if (contextSrv.licensedAccessControlEnabled() && canUpdateRoles()) { + await setTeamRoles({ + teamId: data.teamId, + setTeamRolesCommand: { + roleUids: pendingRoles.map((role) => role.uid), + }, + }); + } + } + + return mutationResult; + }; + + return [trigger, response] as const; +}; diff --git a/public/app/features/teams/state/actions.ts b/public/app/features/teams/state/actions.ts index 49c22c2c449..d0d05e0946c 100644 --- a/public/app/features/teams/state/actions.ts +++ b/public/app/features/teams/state/actions.ts @@ -1,134 +1,26 @@ -import { debounce } from 'lodash'; - import { getBackendSrv } from '@grafana/runtime'; -import { FetchDataArgs } from '@grafana/ui'; -import { updateNavIndex } from 'app/core/actions'; -import { contextSrv } from 'app/core/services/context_srv'; -import { accessControlQueryParam } from 'app/core/utils/accessControl'; -import { AccessControlAction } from 'app/types/accessControl'; import { ThunkResult } from 'app/types/store'; -import { Team, TeamWithRoles } from 'app/types/teams'; -import { buildNavModel } from './navModel'; -import { - teamGroupsLoaded, - queryChanged, - pageChanged, - teamLoaded, - teamsLoaded, - sortChanged, - rolesFetchBegin, - rolesFetchEnd, -} from './reducers'; +import { teamGroupsLoaded } from './reducers'; -export function loadTeams(initial = false): ThunkResult { - return async (dispatch, getState) => { - const { query, page, perPage, sort } = getState().teams; - // Early return if the user cannot list teams - if (!contextSrv.hasPermission(AccessControlAction.ActionTeamsRead)) { - dispatch(teamsLoaded({ teams: [], totalCount: 0, page: 1, perPage, noTeams: true })); - return; - } - - const response = await getBackendSrv().get( - '/api/teams/search', - accessControlQueryParam({ query, page, perpage: perPage, sort }) - ); - - // We only want to check if there is no teams on the initial request. - // A query that returns no teams should not render the empty list banner. - let noTeams = false; - if (initial) { - noTeams = response.teams.length === 0; - } - - if ( - contextSrv.licensedAccessControlEnabled() && - contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList) - ) { - dispatch(rolesFetchBegin()); - const teamIds = response?.teams.map((t: TeamWithRoles) => t.id); - const roles = await getBackendSrv().post(`/api/access-control/teams/roles/search`, { teamIds }); - response.teams.forEach((t: TeamWithRoles) => { - t.roles = roles ? roles[t.id] || [] : []; - }); - dispatch(rolesFetchEnd()); - } - - dispatch(teamsLoaded({ noTeams, ...response })); - }; -} - -const loadTeamsWithDebounce = debounce((dispatch) => dispatch(loadTeams()), 500); - -export function loadTeam(uid: string): ThunkResult> { +export function loadTeamGroups(teamUid: string): ThunkResult { return async (dispatch) => { - const response = await getBackendSrv().get(`/api/teams/${uid}`, accessControlQueryParam()); - dispatch(teamLoaded(response)); - dispatch(updateNavIndex(buildNavModel(response))); - }; -} - -export function deleteTeam(uid: string): ThunkResult { - return async (dispatch) => { - await getBackendSrv().delete(`/api/teams/${uid}`); - // Update users permissions in case they lost teams.read with the deletion - await contextSrv.fetchUserPermissions(); - dispatch(loadTeams()); - }; -} - -export function changeQuery(query: string): ThunkResult { - return async (dispatch) => { - dispatch(queryChanged(query)); - loadTeamsWithDebounce(dispatch); - }; -} - -export function changePage(page: number): ThunkResult { - return async (dispatch) => { - dispatch(pageChanged(page)); - dispatch(loadTeams()); - }; -} - -export function changeSort({ sortBy }: FetchDataArgs): ThunkResult { - const sort = sortBy.length ? `${sortBy[0].id}-${sortBy[0].desc ? 'desc' : 'asc'}` : undefined; - return async (dispatch) => { - dispatch(sortChanged(sort)); - dispatch(loadTeams()); - }; -} - -export function updateTeam(name: string, email: string): ThunkResult { - return async (dispatch, getStore) => { - const team = getStore().team.team; - await getBackendSrv().put(`/api/teams/${team.uid}`, { name, email }); - dispatch(loadTeam(team.uid)); - }; -} - -export function loadTeamGroups(): ThunkResult { - return async (dispatch, getStore) => { - const team = getStore().team.team; - const response = await getBackendSrv().get(`/api/teams/${team.uid}/groups`); + const response = await getBackendSrv().get(`/api/teams/${teamUid}/groups`); dispatch(teamGroupsLoaded(response)); }; } -export function addTeamGroup(groupId: string): ThunkResult { - return async (dispatch, getStore) => { - const team = getStore().team.team; - await getBackendSrv().post(`/api/teams/${team.uid}/groups`, { groupId: groupId }); - dispatch(loadTeamGroups()); +export function addTeamGroup(teamUid: string, groupId: string): ThunkResult { + return async (dispatch) => { + await getBackendSrv().post(`/api/teams/${teamUid}/groups`, { groupId: groupId }); + dispatch(loadTeamGroups(teamUid)); }; } -export function removeTeamGroup(groupId: string): ThunkResult { - return async (dispatch, getStore) => { - const team = getStore().team.team; +export function removeTeamGroup(teamUid: string, groupId: string): ThunkResult { + return async (dispatch) => { // need to use query parameter due to escaped characters in the request - await getBackendSrv().delete(`/api/teams/${team.uid}/groups?groupId=${encodeURIComponent(groupId)}`); - dispatch(loadTeamGroups()); + await getBackendSrv().delete(`/api/teams/${teamUid}/groups?groupId=${encodeURIComponent(groupId)}`); + dispatch(loadTeamGroups(teamUid)); }; } diff --git a/public/app/features/teams/state/reducers.test.ts b/public/app/features/teams/state/reducers.test.ts deleted file mode 100644 index 4ea5c963739..00000000000 --- a/public/app/features/teams/state/reducers.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { TeamsState, TeamState } from 'app/types/teams'; - -import { reducerTester } from '../../../../test/core/redux/reducerTester'; -import { getMockTeam, getMockTeamGroups } from '../mocks/teamMocks'; - -import { - initialTeamsState, - initialTeamState, - teamGroupsLoaded, - teamLoaded, - queryChanged, - teamReducer, - teamsLoaded, - teamsReducer, -} from './reducers'; - -describe('teams reducer', () => { - describe('when teamsLoaded is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(teamsReducer, { ...initialTeamsState }) - .whenActionIsDispatched( - teamsLoaded({ teams: [getMockTeam()], page: 1, perPage: 30, noTeams: false, totalCount: 100 }) - ) - .thenStateShouldEqual({ - ...initialTeamsState, - hasFetched: true, - teams: [getMockTeam()], - noTeams: false, - totalPages: 4, - perPage: 30, - page: 1, - }); - }); - }); - - describe('when setSearchQueryAction is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(teamsReducer, { ...initialTeamsState }) - .whenActionIsDispatched(queryChanged('test')) - .thenStateShouldEqual({ - ...initialTeamsState, - query: 'test', - }); - }); - }); -}); - -describe('team reducer', () => { - describe('when loadTeamsAction is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(teamReducer, { ...initialTeamState }) - .whenActionIsDispatched(teamLoaded(getMockTeam())) - .thenStateShouldEqual({ - ...initialTeamState, - team: getMockTeam(), - }); - }); - }); - - describe('when loadTeamGroupsAction is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(teamReducer, { ...initialTeamState }) - .whenActionIsDispatched(teamGroupsLoaded(getMockTeamGroups(1))) - .thenStateShouldEqual({ - ...initialTeamState, - groups: getMockTeamGroups(1), - }); - }); - }); -}); diff --git a/public/app/features/teams/state/reducers.ts b/public/app/features/teams/state/reducers.ts index 2ef9dde3838..50011316a72 100644 --- a/public/app/features/teams/state/reducers.ts +++ b/public/app/features/teams/state/reducers.ts @@ -1,60 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { TeamsState, Team, TeamState, TeamGroup } from 'app/types/teams'; - -export const initialTeamsState: TeamsState = { - teams: [], - page: 1, - query: '', - perPage: 30, - totalPages: 0, - noTeams: false, - hasFetched: false, -}; - -type TeamsFetched = { - teams: Team[]; - page: number; - perPage: number; - noTeams: boolean; - totalCount: number; -}; - -const teamsSlice = createSlice({ - name: 'teams', - initialState: initialTeamsState, - reducers: { - teamsLoaded: (state, action: PayloadAction): TeamsState => { - const { totalCount, perPage, ...rest } = action.payload; - const totalPages = Math.ceil(totalCount / perPage); - return { ...state, ...rest, totalPages, perPage, hasFetched: true }; - }, - queryChanged: (state, action: PayloadAction): TeamsState => { - return { ...state, page: 1, query: action.payload }; - }, - pageChanged: (state, action: PayloadAction): TeamsState => { - return { ...state, page: action.payload }; - }, - sortChanged: (state, action: PayloadAction): TeamsState => { - return { ...state, sort: action.payload, page: 1 }; - }, - rolesFetchBegin: (state) => { - return { ...state, rolesLoading: true }; - }, - rolesFetchEnd: (state) => { - return { ...state, rolesLoading: false }; - }, - }, -}); - -export const { teamsLoaded, queryChanged, pageChanged, sortChanged, rolesFetchBegin, rolesFetchEnd } = - teamsSlice.actions; - -export const teamsReducer = teamsSlice.reducer; +import { TeamState, TeamGroup } from 'app/types/teams'; export const initialTeamState: TeamState = { - team: {} as Team, - members: [], groups: [], }; @@ -62,20 +10,16 @@ const teamSlice = createSlice({ name: 'team', initialState: initialTeamState, reducers: { - teamLoaded: (state, action: PayloadAction): TeamState => { - return { ...state, team: action.payload }; - }, teamGroupsLoaded: (state, action: PayloadAction): TeamState => { return { ...state, groups: action.payload }; }, }, }); -export const { teamLoaded, teamGroupsLoaded } = teamSlice.actions; +export const { teamGroupsLoaded } = teamSlice.actions; export const teamReducer = teamSlice.reducer; export default { - teams: teamsReducer, team: teamReducer, }; diff --git a/public/app/features/teams/state/selectors.test.ts b/public/app/features/teams/state/selectors.test.ts deleted file mode 100644 index 983c5d35a71..00000000000 --- a/public/app/features/teams/state/selectors.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { TeamState } from 'app/types/teams'; - -import { getMockTeam } from '../mocks/teamMocks'; - -import { getTeam } from './selectors'; - -describe('Team selectors', () => { - describe('Get team', () => { - const mockTeam = getMockTeam(); - - it('should return team if matching with location team', () => { - const mockState: TeamState = { - team: mockTeam, - members: [], - groups: [], - }; - - const team = getTeam(mockState, 'aaaaaa'); - expect(team).toEqual(mockTeam); - }); - }); -}); diff --git a/public/app/features/teams/state/selectors.ts b/public/app/features/teams/state/selectors.ts index ec99297158c..4ada9ee8d46 100644 --- a/public/app/features/teams/state/selectors.ts +++ b/public/app/features/teams/state/selectors.ts @@ -1,11 +1,3 @@ -import { Team, TeamState } from 'app/types/teams'; +import { TeamState } from 'app/types/teams'; export const getTeamGroups = (state: TeamState) => state.groups; - -export const getTeam = (state: TeamState, currentTeamUid: string): Team | null => { - if (state.team.uid === currentTeamUid) { - return state.team; - } - - return null; -}; diff --git a/public/app/features/users/UsersActionBar.tsx b/public/app/features/users/UsersActionBar.tsx index 7c2d85339b1..1a7245b1ad2 100644 --- a/public/app/features/users/UsersActionBar.tsx +++ b/public/app/features/users/UsersActionBar.tsx @@ -1,3 +1,4 @@ +import type { JSX } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Trans, t } from '@grafana/i18n'; diff --git a/public/app/features/variables/datasource/DataSourceVariableEditor.test.tsx b/public/app/features/variables/datasource/DataSourceVariableEditor.test.tsx index 67400bbd998..892a70d0755 100644 --- a/public/app/features/variables/datasource/DataSourceVariableEditor.test.tsx +++ b/public/app/features/variables/datasource/DataSourceVariableEditor.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { JSX } from 'react'; import { selectOptionInTest, getSelectParent } from 'test/helpers/selectOptionInTest'; import { DataSourceVariableEditorUnConnected as DataSourceVariableEditor } from './DataSourceVariableEditor'; diff --git a/public/app/features/variables/inspect/NetworkGraphModal.tsx b/public/app/features/variables/inspect/NetworkGraphModal.tsx index e6efc4d953c..84dd085d73c 100644 --- a/public/app/features/variables/inspect/NetworkGraphModal.tsx +++ b/public/app/features/variables/inspect/NetworkGraphModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useState, type JSX } from 'react'; import { Modal } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/AzureCredentialsForm.tsx b/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/AzureCredentialsForm.tsx index 3c4b942cc45..14b025b6d05 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/AzureCredentialsForm.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/AzureCredentialsForm.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, type JSX } from 'react'; import { AzureAuthType, AzureCredentials, getAzureClouds } from '@grafana/azure-sdk'; import { SelectableValue } from '@grafana/data'; diff --git a/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/CurrentUserFallbackCredentials.tsx b/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/CurrentUserFallbackCredentials.tsx index c9eb64d2c57..1e311fa3c6b 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/CurrentUserFallbackCredentials.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ConfigEditor/CurrentUserFallbackCredentials.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, type JSX } from 'react'; import { AadCurrentUserCredentials, AzureCredentials, instanceOfAzureCredential } from '@grafana/azure-sdk'; import { SelectableValue } from '@grafana/data'; diff --git a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filter.tsx b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filter.tsx index 21f8dad755e..6667fd93551 100644 --- a/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filter.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/TracesQueryEditor/Filter.tsx @@ -1,5 +1,5 @@ import { cx } from '@emotion/css'; -import { RefCallback, SyntheticEvent, useState } from 'react'; +import { RefCallback, SyntheticEvent, useState, type JSX } from 'react'; import * as React from 'react'; import { lastValueFrom } from 'rxjs'; diff --git a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/SecureSocksProxySettingsNewStyling.tsx b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/SecureSocksProxySettingsNewStyling.tsx index 19228467f51..a03aceeccb5 100644 --- a/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/SecureSocksProxySettingsNewStyling.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/ConfigEditor/SecureSocksProxySettingsNewStyling.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { ConfigSection } from '@grafana/plugin-ui'; import { Field, Switch } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx index b0659d7abe7..96158987b68 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/LogsQueryEditor/LogsQueryEditor.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useState, type JSX } from 'react'; import { useEffectOnce } from 'react-use'; import { QueryEditorProps, SelectableValue } from '@grafana/data'; diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.tsx index de736e380bd..db88bda25cd 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, useCallback, useEffect, useState } from 'react'; +import { ChangeEvent, useCallback, useEffect, useState, type JSX } from 'react'; import * as React from 'react'; import { QueryEditorProps, SelectableValue } from '@grafana/data'; @@ -139,7 +139,6 @@ export const MetricsQueryEditor = (props: Props) => { return ( <> - {query.metricQueryType === MetricQueryType.Search && ( <> {query.metricEditorMode === MetricEditorMode.Builder && ( diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryEditor.tsx index 04bf910f1d4..2999ff7f79b 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryEditor.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, type JSX } from 'react'; import { QueryEditorProps } from '@grafana/data'; diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx index 57568a0666e..78da93ba3a1 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { CoreApp, LoadingState, QueryEditorProps, SelectableValue } from '@grafana/data'; import { EditorHeader, InlineSelect, FlexItem } from '@grafana/plugin-ui'; import { config } from '@grafana/runtime'; diff --git a/public/app/plugins/datasource/graphite/configuration/MappingsConfiguration.tsx b/public/app/plugins/datasource/graphite/configuration/MappingsConfiguration.tsx index 0df6363a7ce..39f90596fb1 100644 --- a/public/app/plugins/datasource/graphite/configuration/MappingsConfiguration.tsx +++ b/public/app/plugins/datasource/graphite/configuration/MappingsConfiguration.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, useState } from 'react'; +import { ChangeEvent, useState, type JSX } from 'react'; import { Box, Button, Icon, InlineField, InlineFieldRow, Input } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/graphite/configuration/MappingsHelp.tsx b/public/app/plugins/datasource/graphite/configuration/MappingsHelp.tsx index 37a93580ca1..12a0a581e8e 100644 --- a/public/app/plugins/datasource/graphite/configuration/MappingsHelp.tsx +++ b/public/app/plugins/datasource/graphite/configuration/MappingsHelp.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { Alert } from '@grafana/ui'; type Props = { diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/QueryEditorModeSwitcher.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/QueryEditorModeSwitcher.tsx index 7bc5dcf6a6e..246f21c9b1c 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/QueryEditorModeSwitcher.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/QueryEditorModeSwitcher.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type JSX } from 'react'; import { Button, ConfirmModal } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/code/RawInfluxQLEditor.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/code/RawInfluxQLEditor.tsx index 0098e8eb0af..5871e0ebf4c 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/code/RawInfluxQLEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/code/RawInfluxQLEditor.tsx @@ -1,4 +1,4 @@ -import { useId } from 'react'; +import { useId, type JSX } from 'react'; import { Stack, InlineField, Input, Select, TextArea } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/AddButton.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/AddButton.tsx index 7123f816f22..7df491257d2 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/AddButton.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/AddButton.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { SelectableValue } from '@grafana/data'; import { unwrap } from '../utils/unwrap'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FormatAsSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FormatAsSection.tsx index 6bf005185f4..b88cb6d1bc6 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FormatAsSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FormatAsSection.tsx @@ -1,4 +1,5 @@ import { cx } from '@emotion/css'; +import type { JSX } from 'react'; import { Select } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FromSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FromSection.tsx index 153ec2aa039..b0dd96e711c 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FromSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/FromSection.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { AccessoryButton } from '@grafana/plugin-ui'; import { DEFAULT_POLICY } from '../../../../../types'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/InputSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/InputSection.tsx index 98c4ebf30ac..11deca0c8fe 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/InputSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/InputSection.tsx @@ -1,4 +1,5 @@ import { cx } from '@emotion/css'; +import type { JSX } from 'react'; import { Input } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/OrderByTimeSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/OrderByTimeSection.tsx index eab1943a4ea..d2a18d280f7 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/OrderByTimeSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/OrderByTimeSection.tsx @@ -1,4 +1,5 @@ import { cx } from '@emotion/css'; +import type { JSX } from 'react'; import { SelectableValue } from '@grafana/data'; import { Select } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx index 14f188bd683..b02fdeeafa2 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/PartListSection.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { Fragment, useMemo } from 'react'; +import { Fragment, useMemo, type JSX } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { AccessoryButton } from '@grafana/plugin-ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/Seg.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/Seg.tsx index 47b24fcecf6..2adae762b4e 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/Seg.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/Seg.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; import debouncePromise from 'debounce-promise'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, type JSX } from 'react'; import { useAsyncFn } from 'react-use'; import { SelectableValue } from '@grafana/data'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx index 3edc8320144..ec7a138b590 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/TagsSection.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { SelectableValue } from '@grafana/data'; import { AccessoryButton } from '@grafana/plugin-ui'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.test.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.test.tsx index 837a56b85e4..ce6e52fde97 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.test.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.test.tsx @@ -1,4 +1,5 @@ import { render, waitFor } from '@testing-library/react'; +import type { JSX } from 'react'; import InfluxDatasource from '../../../../../datasource'; import { getMockInfluxDS, getMockDSInstanceSettings } from '../../../../../mocks/datasource'; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx index ddc3f82cbb9..ac1a4a8dea6 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/influxql/visual/VisualInfluxQLEditor.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useId, useMemo } from 'react'; +import { useId, useMemo, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { InlineLabel, SegmentSection, useStyles2 } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/mssql/azureauth/AzureAuthSettings.tsx b/public/app/plugins/datasource/mssql/azureauth/AzureAuthSettings.tsx index 0d53f030095..f38a417466e 100644 --- a/public/app/plugins/datasource/mssql/azureauth/AzureAuthSettings.tsx +++ b/public/app/plugins/datasource/mssql/azureauth/AzureAuthSettings.tsx @@ -15,6 +15,7 @@ export const AzureAuthSettings = (props: HttpSettingsBaseProps) => { const { dataSourceConfig: dsSettings, onChange } = props; const managedIdentityEnabled = config.azure.managedIdentityEnabled; const azureEntraPasswordCredentialsEnabled = config.azure.azureEntraPasswordCredentialsEnabled; + const userIdentityEnabled = config.azure.userIdentityEnabled; const credentials = useMemo(() => getCredentials(dsSettings), [dsSettings]); @@ -33,6 +34,7 @@ export const AzureAuthSettings = (props: HttpSettingsBaseProps) => { void; @@ -22,13 +23,25 @@ export const AzureCredentialsForm = (props: Props) => { azureCloudOptions, onCredentialsChange, disabled, + userIdentityEnabled, } = props; const onAuthTypeChange = (selected: SelectableValue) => { + const defaultAuthType = (() => { + if (managedIdentityEnabled) { + return 'msi'; + } + + if (userIdentityEnabled) { + return 'currentuser'; + } + + return 'clientsecret'; + })(); if (onCredentialsChange) { const updated: AzureCredentials = { ...credentials, - authType: selected.value || 'msi', + authType: selected.value || defaultAuthType, }; onCredentialsChange(updated); } @@ -131,6 +144,13 @@ export const AzureCredentialsForm = (props: Props) => { value: 'ad-password', label: t('azureauth.azure-credentials-form.auth-options-azure-entra', 'Azure Entra Password'), }); + + if (userIdentityEnabled) { + authTypeOptions.unshift({ + value: 'currentuser', + label: 'Current User', + }); + } } return ( diff --git a/public/app/plugins/datasource/mssql/types.ts b/public/app/plugins/datasource/mssql/types.ts index 22066dcdd03..c4ed9763cec 100644 --- a/public/app/plugins/datasource/mssql/types.ts +++ b/public/app/plugins/datasource/mssql/types.ts @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { AzureCredentials } from '@grafana/azure-sdk'; import { SQLOptions } from '@grafana/sql'; import { HttpSettingsBaseProps } from '@grafana/ui/internal'; diff --git a/public/app/plugins/datasource/prometheus/configuration/ConfigEditorPackage.tsx b/public/app/plugins/datasource/prometheus/configuration/ConfigEditorPackage.tsx index 68140a80e68..a5bfccd9d23 100644 --- a/public/app/plugins/datasource/prometheus/configuration/ConfigEditorPackage.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/ConfigEditorPackage.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import type { JSX } from 'react'; import { SIGV4ConnectionConfig } from '@grafana/aws-sdk'; import { hasCredentials } from '@grafana/azure-sdk'; diff --git a/public/app/plugins/panel/annolist/AnnoListPanel.tsx b/public/app/plugins/panel/annolist/AnnoListPanel.tsx index de0baf6f375..77dbc76feb3 100644 --- a/public/app/plugins/panel/annolist/AnnoListPanel.tsx +++ b/public/app/plugins/panel/annolist/AnnoListPanel.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { createRef, PureComponent } from 'react'; +import { createRef, PureComponent, type JSX } from 'react'; import { Subscription } from 'rxjs'; import { diff --git a/public/app/plugins/panel/barchart/module.tsx b/public/app/plugins/panel/barchart/module.tsx index d4284656595..28ba77d5476 100644 --- a/public/app/plugins/panel/barchart/module.tsx +++ b/public/app/plugins/panel/barchart/module.tsx @@ -18,7 +18,7 @@ import { BarChartPanel } from './BarChartPanel'; import { TickSpacingEditor } from './TickSpacingEditor'; import { changeToBarChartPanelMigrationHandler } from './migrations'; import { FieldConfig, Options, defaultFieldConfig, defaultOptions } from './panelcfg.gen'; -import { BarChartSuggestionsSupplier } from './suggestions'; +import { barchartSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(BarChartPanel) .setPanelChangeHandler(changeToBarChartPanelMigrationHandler) @@ -257,7 +257,7 @@ export const plugin = new PanelPlugin(BarChartPanel) commonOptionsBuilder.addLegendOptions(builder); commonOptionsBuilder.addTextSizeOptions(builder, { withValue: true }); }) - .setSuggestionsSupplier(new BarChartSuggestionsSupplier()); + .setSuggestionsSupplier(barchartSuggestionsSupplier); function countNumberFields(data?: DataFrame[]): number { let count = 0; diff --git a/public/app/plugins/panel/barchart/suggestions.ts b/public/app/plugins/panel/barchart/suggestions.ts index f9428d6e709..56918e4b346 100644 --- a/public/app/plugins/panel/barchart/suggestions.ts +++ b/public/app/plugins/panel/barchart/suggestions.ts @@ -1,99 +1,112 @@ -import { VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data'; +import { defaultsDeep } from 'lodash'; + +import { FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn, VizOrientation } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { LegendDisplayMode, StackingMode, VisibilityMode } from '@grafana/schema'; -import { SuggestionName } from 'app/types/suggestions'; import { FieldConfig, Options } from './panelcfg.gen'; -export class BarChartSuggestionsSupplier { - getListWithDefaults(builder: VisualizationSuggestionsBuilder) { - return builder.getListAppender({ - name: SuggestionName.BarChart, - pluginId: 'barchart', - options: { - showValue: VisibilityMode.Never, - legend: { - calcs: [], - displayMode: LegendDisplayMode.List, - showLegend: true, - placement: 'right', - }, +const withDefaults = (suggestion: VisualizationSuggestion) => + defaultsDeep(suggestion, { + options: { + showValue: VisibilityMode.Never, + legend: { + calcs: [], + displayMode: LegendDisplayMode.List, + showLegend: true, + placement: 'right', }, - fieldConfig: { - defaults: { - unit: 'short', - custom: {}, - }, - overrides: [], + }, + fieldConfig: { + defaults: { + unit: 'short', + custom: {}, }, - cardOptions: { - previewModifier: (s) => { - s.options!.barWidth = 0.8; - }, + overrides: [], + }, + cardOptions: { + previewModifier: (s) => { + s.options!.barWidth = 0.8; + s.fieldConfig!.defaults!.custom!.hideFrom = { tooltip: false, legend: true, viz: false }; // hide legend in preview }, - }); + }, + } satisfies VisualizationSuggestion); + +export const barchartSuggestionsSupplier: VisualizationSuggestionsSupplierFn = (dataSummary) => { + if (dataSummary.frameCount !== 1) { + return; } - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const list = this.getListWithDefaults(builder); - const { dataSummary } = builder; + if (!dataSummary.hasFieldType(FieldType.number) || !dataSummary.hasFieldType(FieldType.string)) { + return; + } - if (dataSummary.frameCount !== 1) { - return; - } + // if you have this many rows barchart might not be a good fit + if (dataSummary.rowCountTotal > 50) { + return; + } - if (!dataSummary.hasNumberField || !dataSummary.hasStringField) { - return; - } + const result: Array> = [ + { + name: t('barchart.suggestions.vertical', 'Bar chart'), + }, + ]; - // if you have this many rows barchart might not be a good fit - if (dataSummary.rowCountTotal > 50) { - return; - } - - // Vertical bars - list.append({ - name: SuggestionName.BarChart, - }); - - if (dataSummary.numberFieldCount > 1) { - list.append({ - name: SuggestionName.BarChartStacked, + if (dataSummary.fieldCountByType(FieldType.number) > 1) { + result.push( + { + name: t('barchart.suggestions.vert-stacked', 'Bar chart - stacked'), options: { stacking: StackingMode.Normal, }, - }); - list.append({ - name: SuggestionName.BarChartStackedPercent, + }, + { + name: t('barchart.suggestions.vert-stacked-percent', 'Bar chart - stacked by percentage'), options: { stacking: StackingMode.Percent, }, - }); - } - - // horizontal bars - list.append({ - name: SuggestionName.BarChartHorizontal, - options: { - orientation: VizOrientation.Horizontal, - }, - }); - - if (dataSummary.numberFieldCount > 1) { - list.append({ - name: SuggestionName.BarChartHorizontalStacked, - options: { - stacking: StackingMode.Normal, - orientation: VizOrientation.Horizontal, + fieldConfig: { + overrides: [], + defaults: { + unit: 'percentunit', + }, }, - }); + } + ); + } - list.append({ - name: SuggestionName.BarChartHorizontalStackedPercent, + // horizontal bars + result.push({ + name: t('barchart.suggestions.horizontal', 'Horizontal bar chart'), + options: { + orientation: VizOrientation.Horizontal, + }, + }); + + if (dataSummary.fieldCountByType(FieldType.number) > 1) { + result.push( + { + name: t('barchart.suggestions.hz-stacked', 'Horizontal bar chart - stacked'), + options: { + orientation: VizOrientation.Horizontal, + stacking: StackingMode.Normal, + }, + }, + { + name: t('barchart.suggestions.hz-stacked-percent', 'Horizontal bar chart - stacked by percentage'), options: { orientation: VizOrientation.Horizontal, stacking: StackingMode.Percent, }, - }); - } + fieldConfig: { + overrides: [], + defaults: { + unit: 'percentunit', + }, + }, + } + ); } -} + + return result.map(withDefaults); +}; diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx index 0c59c7905c1..d8714fd8f4a 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx @@ -1,5 +1,5 @@ import { isNumber } from 'lodash'; -import { PureComponent } from 'react'; +import { PureComponent, type JSX } from 'react'; import { DisplayProcessor, diff --git a/public/app/plugins/panel/bargauge/module.tsx b/public/app/plugins/panel/bargauge/module.tsx index 3e0262fb237..8e7fd5ccae6 100644 --- a/public/app/plugins/panel/bargauge/module.tsx +++ b/public/app/plugins/panel/bargauge/module.tsx @@ -8,7 +8,7 @@ import { addOrientationOption, addStandardDataReduceOptions } from '../stat/comm import { barGaugePanelMigrationHandler } from './BarGaugeMigrations'; import { BarGaugePanel } from './BarGaugePanel'; import { Options, defaultOptions } from './panelcfg.gen'; -import { BarGaugeSuggestionsSupplier } from './suggestions'; +import { barGaugeSugggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(BarGaugePanel) .useFieldConfig() @@ -151,4 +151,4 @@ export const plugin = new PanelPlugin(BarGaugePanel) }) .setPanelChangeHandler(sharedSingleStatPanelChangedHandler) .setMigrationHandler(barGaugePanelMigrationHandler) - .setSuggestionsSupplier(new BarGaugeSuggestionsSupplier()); + .setSuggestionsSupplier(barGaugeSugggestionsSupplier); diff --git a/public/app/plugins/panel/bargauge/suggestions.ts b/public/app/plugins/panel/bargauge/suggestions.ts index 61d48ce104a..dc8a451cac0 100644 --- a/public/app/plugins/panel/bargauge/suggestions.ts +++ b/public/app/plugins/panel/bargauge/suggestions.ts @@ -1,115 +1,60 @@ -import { FieldColorModeId, VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data'; +import { defaultsDeep } from 'lodash'; + +import { + FieldColorModeId, + FieldType, + VisualizationSuggestion, + VisualizationSuggestionsSupplierFn, + VizOrientation, +} from '@grafana/data'; +import { t } from '@grafana/i18n'; import { BarGaugeDisplayMode } from '@grafana/ui'; -import { SuggestionName } from 'app/types/suggestions'; +import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils'; import { Options } from './panelcfg.gen'; -export class BarGaugeSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary } = builder; - - if (!dataSummary.hasData || !dataSummary.hasNumberField) { - return; - } - - const list = builder.getListAppender({ - name: '', - pluginId: 'bargauge', - options: {}, - fieldConfig: { - defaults: { - custom: {}, +const withDefaults = (suggestion: VisualizationSuggestion): VisualizationSuggestion => + defaultsDeep(suggestion, { + options: { + displayMode: BarGaugeDisplayMode.Basic, + orientation: VizOrientation.Horizontal, + }, + fieldConfig: { + defaults: { + color: { + mode: FieldColorModeId.ContinuousGrYlRd, }, - overrides: [], }, - }); + overrides: [], + }, + }); - // This is probably not a good option for many numeric fields - if (dataSummary.numberFieldCount > 50) { - return; - } +const BAR_LIMIT = 30; - // To use show individual row values we also need a string field to give each value a name - if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 30) { - list.append({ - name: SuggestionName.BarGaugeBasic, - options: { - reduceOptions: { - values: true, - calcs: [], - }, - displayMode: BarGaugeDisplayMode.Basic, - orientation: VizOrientation.Horizontal, - }, - fieldConfig: { - defaults: { - color: { - mode: FieldColorModeId.ContinuousGrYlRd, - }, - }, - overrides: [], - }, - }); - - list.append({ - name: SuggestionName.BarGaugeLCD, - options: { - reduceOptions: { - values: true, - calcs: [], - }, - displayMode: BarGaugeDisplayMode.Lcd, - orientation: VizOrientation.Horizontal, - }, - fieldConfig: { - defaults: { - color: { - mode: FieldColorModeId.ContinuousGrYlRd, - }, - }, - overrides: [], - }, - }); - } else { - list.append({ - name: SuggestionName.BarGaugeBasic, - options: { - displayMode: BarGaugeDisplayMode.Basic, - orientation: VizOrientation.Horizontal, - reduceOptions: { - values: false, - calcs: ['lastNotNull'], - }, - }, - fieldConfig: { - defaults: { - color: { - mode: FieldColorModeId.ContinuousGrYlRd, - }, - }, - overrides: [], - }, - }); - - list.append({ - name: SuggestionName.BarGaugeLCD, - options: { - displayMode: BarGaugeDisplayMode.Lcd, - orientation: VizOrientation.Horizontal, - reduceOptions: { - values: false, - calcs: ['lastNotNull'], - }, - }, - fieldConfig: { - defaults: { - color: { - mode: FieldColorModeId.ContinuousGrYlRd, - }, - }, - overrides: [], - }, - }); - } +export const barGaugeSugggestionsSupplier: VisualizationSuggestionsSupplierFn = (dataSummary) => { + if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) { + return; } -} + + // This is probably not a good option for many numeric fields + if (dataSummary.fieldCountByType(FieldType.number) > BAR_LIMIT) { + return; + } + + const suggestions: Array> = [ + { name: t('bargauge.suggestions.basic', 'Bar gauge') }, + { + name: t('bargauge.suggestions.lcd', 'Bar gauge - LCD'), + options: { + displayMode: BarGaugeDisplayMode.Lcd, + }, + }, + ]; + + const shouldUseRawValues = + dataSummary.hasFieldType(FieldType.string) && + dataSummary.frameCount === 1 && + dataSummary.rowCountTotal <= BAR_LIMIT; + + return suggestions.map((s) => defaultNumericVizOptions(withDefaults(s), dataSummary, shouldUseRawValues)); +}; diff --git a/public/app/plugins/panel/candlestick/module.tsx b/public/app/plugins/panel/candlestick/module.tsx index 8a57e4801c1..ae43037e1b1 100644 --- a/public/app/plugins/panel/candlestick/module.tsx +++ b/public/app/plugins/panel/candlestick/module.tsx @@ -8,7 +8,7 @@ import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config'; import { CandlestickPanel } from './CandlestickPanel'; import { CandlestickData, getCandlestickFieldsInfo, FieldPickerInfo, prepareCandlestickFields } from './fields'; -import { CandlestickSuggestionsSupplier } from './suggestions'; +import { candlestickSuggestionSupplier } from './suggestions'; import { defaultCandlestickColors, defaultOptions, Options, VizDisplayMode, ColorStrategy, CandleStyle } from './types'; const numericFieldFilter = (f: Field) => f.type === FieldType.number; @@ -147,4 +147,4 @@ export const plugin = new PanelPlugin(CandlestickPane commonOptionsBuilder.addLegendOptions(builder); }) .setDataSupport({ annotations: true, alertStates: true }) - .setSuggestionsSupplier(new CandlestickSuggestionsSupplier()); + .setSuggestionsSupplier(candlestickSuggestionSupplier); diff --git a/public/app/plugins/panel/candlestick/suggestions.ts b/public/app/plugins/panel/candlestick/suggestions.ts index ce22cc276c5..81834af9db5 100644 --- a/public/app/plugins/panel/candlestick/suggestions.ts +++ b/public/app/plugins/panel/candlestick/suggestions.ts @@ -1,54 +1,29 @@ -import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data'; +import { FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { SuggestionName } from 'app/types/suggestions'; import { prepareCandlestickFields } from './fields'; import { defaultOptions, Options } from './types'; -export class CandlestickSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary } = builder; - - if ( - !builder.data?.series || - !dataSummary.hasData || - dataSummary.timeFieldCount < 1 || - dataSummary.numberFieldCount < 2 || - dataSummary.numberFieldCount > 10 - ) { - return; - } - - const info = prepareCandlestickFields(builder.data.series, defaultOptions, config.theme2); - if (!info) { - return; - } - - // Regular timeseries - if (info.open === info.high && info.open === info.low) { - return; - } - - const list = builder.getListAppender({ - name: '', - pluginId: 'candlestick', - options: {}, - fieldConfig: { - defaults: { - custom: {}, - }, - overrides: [], - }, - }); - - list.append({ - name: SuggestionName.Candlestick, - options: defaultOptions, - fieldConfig: { - defaults: {}, - overrides: [], - }, - score: info.autoOpenClose ? VisualizationSuggestionScore.Good : VisualizationSuggestionScore.Best, - }); +export const candlestickSuggestionSupplier: VisualizationSuggestionsSupplierFn = (dataSummary) => { + if ( + !dataSummary.rawFrames || + !dataSummary.hasData || + dataSummary.fieldCountByType(FieldType.time) < 1 || + dataSummary.fieldCountByType(FieldType.number) < 2 || + dataSummary.fieldCountByType(FieldType.number) > 10 + ) { + return; } -} + + const info = prepareCandlestickFields(dataSummary.rawFrames, defaultOptions, config.theme2); + if (!info) { + return; + } + + // Regular timeseries + if (info.open === info.high && info.open === info.low) { + return; + } + + return [{ score: info.autoOpenClose ? VisualizationSuggestionScore.Good : VisualizationSuggestionScore.Best }]; +}; diff --git a/public/app/plugins/panel/flamegraph/FlameGraphPanel.tsx b/public/app/plugins/panel/flamegraph/FlameGraphPanel.tsx index e9e8d9c8368..54a68a78af0 100644 --- a/public/app/plugins/panel/flamegraph/FlameGraphPanel.tsx +++ b/public/app/plugins/panel/flamegraph/FlameGraphPanel.tsx @@ -2,6 +2,8 @@ import { CoreApp, PanelProps } from '@grafana/data'; import { FlameGraph, checkFields, getMessageCheckFieldsResult } from '@grafana/flamegraph'; import { PanelDataErrorView, reportInteraction, config } from '@grafana/runtime'; +import { Options } from './types'; + function interaction(name: string, context: Record = {}) { reportInteraction(`grafana_flamegraph_${name}`, { app: CoreApp.Unknown, @@ -10,7 +12,7 @@ function interaction(name: string, context: Record = {} }); } -export const FlameGraphPanel = (props: PanelProps) => { +export const FlameGraphPanel = (props: PanelProps) => { const wrongFields = checkFields(props.data.series[0]); if (wrongFields) { return ( diff --git a/public/app/plugins/panel/flamegraph/module.tsx b/public/app/plugins/panel/flamegraph/module.tsx index 19fdd0afcfc..a80fcea3eee 100644 --- a/public/app/plugins/panel/flamegraph/module.tsx +++ b/public/app/plugins/panel/flamegraph/module.tsx @@ -1,12 +1,29 @@ import { FieldConfigProperty, PanelPlugin } from '@grafana/data'; +import { checkFields } from '@grafana/flamegraph'; import { FlameGraphPanel } from './FlameGraphPanel'; -import { FlameGraphSuggestionsSupplier } from './suggestions'; +import { Options } from './types'; const flamegraphConfigOptions = [FieldConfigProperty.Unit, FieldConfigProperty.Decimals]; -export const plugin = new PanelPlugin(FlameGraphPanel) - .setSuggestionsSupplier(new FlameGraphSuggestionsSupplier()) +export const plugin = new PanelPlugin(FlameGraphPanel) + // check that the first frame of the data has the required fields for a flamegraph + .setSuggestionsSupplier((ds) => { + if (!ds.rawFrames?.some((frame) => checkFields(frame) === undefined)) { + return; + } + + return [ + { + cardOptions: { + previewModifier: (s) => { + s.options = s.options || {}; + s.options.showFlameGraphOnly = true; + }, + }, + }, + ]; + }) .useFieldConfig({ disableStandardOptions: Object.values(FieldConfigProperty).filter((v) => !flamegraphConfigOptions.includes(v)), }); diff --git a/public/app/plugins/panel/flamegraph/suggestions.ts b/public/app/plugins/panel/flamegraph/suggestions.ts deleted file mode 100644 index eb997ee8078..00000000000 --- a/public/app/plugins/panel/flamegraph/suggestions.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { VisualizationSuggestionsBuilder } from '@grafana/data'; -import { checkFields } from '@grafana/flamegraph'; -import { SuggestionName } from 'app/types/suggestions'; - -export class FlameGraphSuggestionsSupplier { - getListWithDefaults(builder: VisualizationSuggestionsBuilder) { - return builder.getListAppender<{}, {}>({ - name: SuggestionName.FlameGraph, - pluginId: 'flamegraph', - }); - } - - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - if (!builder.data) { - return; - } - - const dataFrame = builder.data.series[0]; - if (!dataFrame) { - return; - } - const wrongFields = checkFields(dataFrame); - if (wrongFields) { - return; - } - - this.getListWithDefaults(builder).append({ - name: SuggestionName.FlameGraph, - }); - } -} diff --git a/public/app/plugins/panel/flamegraph/types.ts b/public/app/plugins/panel/flamegraph/types.ts new file mode 100644 index 00000000000..8e5544c54a2 --- /dev/null +++ b/public/app/plugins/panel/flamegraph/types.ts @@ -0,0 +1,3 @@ +export interface Options { + showFlameGraphOnly?: boolean; +} diff --git a/public/app/plugins/panel/gauge/GaugePanel.tsx b/public/app/plugins/panel/gauge/GaugePanel.tsx index 2d8eb4c9610..3ae1988e30a 100644 --- a/public/app/plugins/panel/gauge/GaugePanel.tsx +++ b/public/app/plugins/panel/gauge/GaugePanel.tsx @@ -1,4 +1,4 @@ -import { PureComponent } from 'react'; +import { PureComponent, type JSX } from 'react'; import { FieldDisplay, getDisplayProcessor, getFieldDisplayValues, PanelProps } from '@grafana/data'; import { BarGaugeSizing, VizOrientation } from '@grafana/schema'; diff --git a/public/app/plugins/panel/gauge/module.tsx b/public/app/plugins/panel/gauge/module.tsx index 690698534a1..61ea853e943 100644 --- a/public/app/plugins/panel/gauge/module.tsx +++ b/public/app/plugins/panel/gauge/module.tsx @@ -8,7 +8,7 @@ import { addOrientationOption, addStandardDataReduceOptions } from '../stat/comm import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './GaugeMigrations'; import { GaugePanel } from './GaugePanel'; import { Options, defaultOptions } from './panelcfg.gen'; -import { GaugeSuggestionsSupplier } from './suggestions'; +import { gaugeSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(GaugePanel) .useFieldConfig({ @@ -88,5 +88,5 @@ export const plugin = new PanelPlugin(GaugePanel) commonOptionsBuilder.addTextSizeOptions(builder, { withTitle: true, withValue: true }); }) .setPanelChangeHandler(gaugePanelChangedHandler) - .setSuggestionsSupplier(new GaugeSuggestionsSupplier()) + .setSuggestionsSupplier(gaugeSuggestionsSupplier) .setMigrationHandler(gaugePanelMigrationHandler); diff --git a/public/app/plugins/panel/gauge/suggestions.ts b/public/app/plugins/panel/gauge/suggestions.ts index 5f5ebc8f40e..6695741b093 100644 --- a/public/app/plugins/panel/gauge/suggestions.ts +++ b/public/app/plugins/panel/gauge/suggestions.ts @@ -1,89 +1,63 @@ -import { ThresholdsMode, VisualizationSuggestionsBuilder } from '@grafana/data'; -import { GraphFieldConfig } from '@grafana/ui'; -import { SuggestionName } from 'app/types/suggestions'; +import { defaultsDeep } from 'lodash'; + +import { ThresholdsMode, FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils'; import { Options } from './panelcfg.gen'; -export class GaugeSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary } = builder; - - if (!dataSummary.hasData || !dataSummary.hasNumberField) { - return; - } - - // for many fields / series this is probably not a good fit - if (dataSummary.numberFieldCount >= 50) { - return; - } - - const list = builder.getListAppender({ - name: SuggestionName.Gauge, - pluginId: 'gauge', - options: {}, - fieldConfig: { - defaults: { - thresholds: { - steps: [ - { value: -Infinity, color: 'green' }, - { value: 70, color: 'orange' }, - { value: 85, color: 'red' }, - ], - mode: ThresholdsMode.Percentage, - }, - custom: {}, +const withDefaults = (suggestion: VisualizationSuggestion): VisualizationSuggestion => + defaultsDeep(suggestion, { + fieldConfig: { + defaults: { + thresholds: { + steps: [ + { value: -Infinity, color: 'green' }, + { value: 70, color: 'orange' }, + { value: 85, color: 'red' }, + ], + mode: ThresholdsMode.Percentage, }, - overrides: [], + custom: {}, }, - cardOptions: { - previewModifier: (s) => { - if (s.options?.reduceOptions?.values) { - s.options.reduceOptions.limit = 2; - } - }, + overrides: [], + }, + cardOptions: { + previewModifier: (s) => { + if (s.options?.reduceOptions?.values) { + s.options.reduceOptions.limit = 2; + } }, - }); + }, + } satisfies VisualizationSuggestion); - if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10) { - list.append({ - name: SuggestionName.Gauge, - options: { - reduceOptions: { - values: true, - calcs: [], - }, - }, - }); - list.append({ - name: SuggestionName.GaugeNoThresholds, - options: { - reduceOptions: { - values: true, - calcs: [], - }, - showThresholdMarkers: false, - }, - }); - } else { - list.append({ - name: SuggestionName.Gauge, - options: { - reduceOptions: { - values: false, - calcs: ['lastNotNull'], - }, - }, - }); - list.append({ - name: SuggestionName.GaugeNoThresholds, - options: { - reduceOptions: { - values: false, - calcs: ['lastNotNull'], - }, - showThresholdMarkers: false, - }, - }); - } +const GAUGE_LIMIT = 10; + +export const gaugeSuggestionsSupplier: VisualizationSuggestionsSupplierFn = (dataSummary) => { + if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) { + return; } -} + + // for many fields / series this is probably not a good fit + if (dataSummary.fieldCountByType(FieldType.number) > GAUGE_LIMIT) { + return; + } + + const suggestions: Array> = [ + { name: t('gauge.suggestions.arc', 'Gauge') }, + { + name: t('gauge.suggestions.no-thresholds', 'Gauge - no thresholds'), + options: { + showThresholdMarkers: false, + }, + }, + ]; + + // sometimes, we want to de-aggregate the data for the gauge suggestion + const shouldUseRawValues = + dataSummary.hasFieldType(FieldType.string) && + dataSummary.frameCount === 1 && + dataSummary.rowCountTotal <= GAUGE_LIMIT; + + return suggestions.map((s) => defaultNumericVizOptions(withDefaults(s), dataSummary, shouldUseRawValues)); +}; diff --git a/public/app/plugins/panel/geomap/module.tsx b/public/app/plugins/panel/geomap/module.tsx index 7573f9317aa..a88a3bfbe02 100644 --- a/public/app/plugins/panel/geomap/module.tsx +++ b/public/app/plugins/panel/geomap/module.tsx @@ -8,6 +8,7 @@ import { LayersEditor } from './editor/LayersEditor'; import { MapViewEditor } from './editor/MapViewEditor'; import { getLayerEditor } from './editor/layerEditor'; import { mapPanelChangedHandler, mapMigrationHandler } from './migrations'; +import { geomapSuggestionsSupplier } from './suggestions'; import { defaultMapViewConfig, Options, TooltipMode, GeomapInstanceState } from './types'; export const plugin = new PanelPlugin(GeomapPanel) @@ -171,4 +172,5 @@ export const plugin = new PanelPlugin(GeomapPanel) ], }, }); - }); + }) + .setSuggestionsSupplier(geomapSuggestionsSupplier); diff --git a/public/app/plugins/panel/geomap/suggestions.ts b/public/app/plugins/panel/geomap/suggestions.ts new file mode 100644 index 00000000000..97fe03a7739 --- /dev/null +++ b/public/app/plugins/panel/geomap/suggestions.ts @@ -0,0 +1,46 @@ +import { VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data'; +import { GraphFieldConfig } from '@grafana/ui'; +import { getGeometryField, getDefaultLocationMatchers } from 'app/features/geo/utils/location'; + +import { Options } from './panelcfg.gen'; + +export const geomapSuggestionsSupplier: VisualizationSuggestionsSupplierFn = ( + dataSummary +) => { + if (!dataSummary.hasData || !dataSummary.rawFrames) { + return; + } + + // use getGeometryField to see if any frames have geolocation info + const location = getDefaultLocationMatchers(); + if (!dataSummary.rawFrames.some((frame) => !getGeometryField(frame, location).warning)) { + return; + } + + return [ + { + score: VisualizationSuggestionScore.Best, + fieldConfig: { + defaults: { + custom: {}, + }, + overrides: [], + }, + cardOptions: { + previewModifier: (s) => { + s.options!.controls = { + showZoom: false, + showScale: false, + showAttribution: false, + showMeasure: false, + }; + // FIXME: this doesn't work. I want to disable legends in the preview. + s.options?.layers?.forEach((layer) => { + layer.config = layer.config || {}; + layer.config.showLegend = false; + }); + }, + }, + }, + ]; +}; diff --git a/public/app/plugins/panel/geomap/utils/layers.test.ts b/public/app/plugins/panel/geomap/utils/layers.test.ts new file mode 100644 index 00000000000..1310d892481 --- /dev/null +++ b/public/app/plugins/panel/geomap/utils/layers.test.ts @@ -0,0 +1,123 @@ +jest.mock('ol-mapbox-style', () => ({})); +jest.mock('geotiff', () => ({})); + +import BaseLayer from 'ol/layer/Base'; + +import { + DataFrame, + DataQueryRequest, + FieldType, + LoadingState, + MapLayerHandler, + MapLayerOptions, + PanelData, + TimeRange, +} from '@grafana/data'; + +import { applyLayerFilter } from './layers'; + +describe('applyLayerFilter', () => { + const createDataFrame = (refId: string): DataFrame => ({ + refId, + fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3], config: {} }], + length: 3, + }); + + it('should apply filter when query exists and is visible', () => { + const update = jest.fn(); + const handler: MapLayerHandler = { + init: () => ({}) as BaseLayer, + update, + }; + const options: MapLayerOptions = { + name: 'Test', + type: 'markers', + filterData: { id: 'byRefId', options: 'A' }, + }; + const panelData: PanelData = { + series: [createDataFrame('A'), createDataFrame('B')], + state: LoadingState.Done, + timeRange: {} as TimeRange, + request: { targets: [{ refId: 'A' }, { refId: 'B' }] } as DataQueryRequest, + }; + + applyLayerFilter(handler, options, panelData); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + series: [createDataFrame('A')], + }) + ); + }); + + it('should return empty series when query exists but is hidden', () => { + const update = jest.fn(); + const handler: MapLayerHandler = { + init: () => ({}) as BaseLayer, + update, + }; + const options: MapLayerOptions = { + name: 'Test', + type: 'markers', + filterData: { id: 'byRefId', options: 'A' }, + }; + const panelData: PanelData = { + series: [createDataFrame('B')], + state: LoadingState.Done, + timeRange: {} as TimeRange, + request: { targets: [{ refId: 'A', hide: true }, { refId: 'B' }] } as DataQueryRequest, + }; + + applyLayerFilter(handler, options, panelData); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + series: [], + }) + ); + }); + + it('should not apply filter when query does not exist', () => { + const update = jest.fn(); + const handler: MapLayerHandler = { + init: () => ({}) as BaseLayer, + update, + }; + const options: MapLayerOptions = { + name: 'Test', + type: 'markers', + filterData: { id: 'byRefId', options: 'C' }, + }; + const panelData: PanelData = { + series: [createDataFrame('A'), createDataFrame('B')], + state: LoadingState.Done, + timeRange: {} as TimeRange, + request: { targets: [{ refId: 'A' }, { refId: 'B' }] } as DataQueryRequest, + }; + + applyLayerFilter(handler, options, panelData); + + expect(update).toHaveBeenCalledWith(panelData); + }); + + it('should pass through all data when no filter is configured', () => { + const update = jest.fn(); + const handler: MapLayerHandler = { + init: () => ({}) as BaseLayer, + update, + }; + const options: MapLayerOptions = { + name: 'Test', + type: 'markers', + }; + const panelData: PanelData = { + series: [createDataFrame('A'), createDataFrame('B')], + state: LoadingState.Done, + timeRange: {} as TimeRange, + }; + + applyLayerFilter(handler, options, panelData); + + expect(update).toHaveBeenCalledWith(panelData); + }); +}); diff --git a/public/app/plugins/panel/geomap/utils/layers.ts b/public/app/plugins/panel/geomap/utils/layers.ts index f20d8891ade..925ec1a82c4 100644 --- a/public/app/plugins/panel/geomap/utils/layers.ts +++ b/public/app/plugins/panel/geomap/utils/layers.ts @@ -26,7 +26,13 @@ export const applyLayerFilter = ( let panelData = panelDataProps; if (options.filterData) { const matcherFunc = getFrameMatchers(options.filterData); - if (panelData.series.some(matcherFunc)) { + + const queryExists = panelData.request?.targets.some((target) => { + return target.refId === options.filterData?.options; + }); + + // Only apply filter if the target query exists + if (queryExists) { panelData = { ...panelData, series: panelData.series.filter(matcherFunc), diff --git a/public/app/plugins/panel/heatmap/module.tsx b/public/app/plugins/panel/heatmap/module.tsx index 897fdbda461..a7d44726886 100644 --- a/public/app/plugins/panel/heatmap/module.tsx +++ b/public/app/plugins/panel/heatmap/module.tsx @@ -18,7 +18,7 @@ import { HeatmapPanel } from './HeatmapPanel'; import { prepareHeatmapData } from './fields'; import { heatmapChangedHandler, heatmapMigrationHandler } from './migrations'; import { colorSchemes, quantizeScheme } from './palettes'; -import { HeatmapSuggestionsSupplier } from './suggestions'; +import { heatmapSuggestionsSupplier } from './suggestions'; import { Options, defaultOptions, HeatmapColorMode, HeatmapColorScale } from './types'; export const plugin = new PanelPlugin(HeatmapPanel) @@ -472,5 +472,5 @@ export const plugin = new PanelPlugin(HeatmapPanel) annotations?.some((df) => df.meta?.custom?.resultType === 'exemplar'), }); }) - .setSuggestionsSupplier(new HeatmapSuggestionsSupplier()) + .setSuggestionsSupplier(heatmapSuggestionsSupplier) .setDataSupport({ annotations: true }); diff --git a/public/app/plugins/panel/heatmap/suggestions.test.ts b/public/app/plugins/panel/heatmap/suggestions.test.ts new file mode 100644 index 00000000000..ef3e73bc0aa --- /dev/null +++ b/public/app/plugins/panel/heatmap/suggestions.test.ts @@ -0,0 +1,226 @@ +import { + createDataFrame, + DataFrameType, + FieldType, + getPanelDataSummary, + VisualizationSuggestionScore, +} from '@grafana/data'; + +import { heatmapSuggestionsSupplier } from './suggestions'; + +describe('heatmap suggestions', () => { + describe('applicability', () => { + it('should not suggest for data without time field', () => { + const dataSummary = getPanelDataSummary([ + createDataFrame({ + fields: [ + { name: 'value1', type: FieldType.number, values: [1, 2, 3] }, + { name: 'value2', type: FieldType.number, values: [4, 5, 6] }, + ], + }), + ]); + + const suggestions = heatmapSuggestionsSupplier(dataSummary); + expect(suggestions).toBeUndefined(); + }); + + it('should suggest for data with time and number fields', () => { + const dataSummary = getPanelDataSummary([ + createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1609459200000, 1609462800000, 1609466400000] }, + { name: 'value1', type: FieldType.number, values: [1, 2, 3] }, + { name: 'value2', type: FieldType.number, values: [4, 5, 6] }, + ], + }), + ]); + + const suggestions = heatmapSuggestionsSupplier(dataSummary); + expect(suggestions).toHaveLength(1); + }); + }); + + describe('scoring', () => { + it('should score this as "OK" if the data is not particularly heatmap-y', () => { + const dataSummary = getPanelDataSummary([ + createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1609459200000, 1609462800000, 1609466400000] }, + { name: 'value1', type: FieldType.number, values: [1, 2, 3] }, + { name: 'value2', type: FieldType.number, values: [4, 5, 6] }, + ], + }), + ]); + + const suggestions = heatmapSuggestionsSupplier(dataSummary); + expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.OK })]); + }); + + it.each([DataFrameType.HeatmapRows, DataFrameType.HeatmapCells])( + 'should score this as "Best" if the data explicitly has %s frame type', + (frameType: DataFrameType) => { + const dataSummary = getPanelDataSummary([ + createDataFrame({ + meta: { type: frameType }, + fields: [ + { + name: 'time', + type: FieldType.time, + values: [1609459200000, 1609462800000, 1609466400000], + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: 'value1', + type: FieldType.number, + values: [1, 2, 3], + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: 'value2', + type: FieldType.number, + values: [4, 5, 6], + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: 'value3', + type: FieldType.number, + values: [7, 8, 9], + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + ], + }), + ]); + + const suggestions = heatmapSuggestionsSupplier(dataSummary); + expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.Best })]); + } + ); + + it('should score this as "Best" if the data has "ge" labels', () => { + const dataSummary = getPanelDataSummary([ + createDataFrame({ + fields: [ + { + name: 'time', + type: FieldType.time, + values: [1609459200000, 1609462800000, 1609466400000], + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: 'value1', + type: FieldType.number, + values: [1, 2, 3], + labels: { ge: '-Inf' }, + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: 'value2', + type: FieldType.number, + values: [4, 5, 6], + labels: { ge: '0' }, + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: 'value3', + type: FieldType.number, + values: [7, 8, 9], + labels: { ge: '10' }, + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + ], + }), + ]); + + const suggestions = heatmapSuggestionsSupplier(dataSummary); + expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.Best })]); + }); + + it('should score this as "Best" if the data has "le" labels', () => { + const dataSummary = getPanelDataSummary([ + createDataFrame({ + fields: [ + { + name: 'time', + type: FieldType.time, + values: [1609459200000, 1609462800000, 1609466400000], + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: 'value1', + type: FieldType.number, + values: [1, 2, 3], + labels: { le: '1' }, + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: 'value2', + type: FieldType.number, + values: [4, 5, 6], + labels: { le: '2' }, + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: 'value2', + type: FieldType.number, + values: [6, 2, 6], + labels: { le: '4' }, + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: 'value3', + type: FieldType.number, + values: [7, 8, 9], + labels: { le: 'Inf' }, + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + ], + }), + ]); + + const suggestions = heatmapSuggestionsSupplier(dataSummary); + expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.Best })]); + }); + + it('should score this as "Best" if the field names are numeric in a way that makes sense', () => { + const dataSummary = getPanelDataSummary([ + createDataFrame({ + fields: [ + { + name: 'time', + type: FieldType.time, + values: [1609459200000, 1609462800000, 1609466400000], + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: '0', + type: FieldType.number, + values: [1, 2, 3], + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: '10', + type: FieldType.number, + values: [4, 5, 6], + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: '20', + type: FieldType.number, + values: [7, 8, 9], + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + { + name: 'Inf', + type: FieldType.number, + values: [10, 9, 8], + display: jest.fn((v) => ({ text: '' + v, numeric: Number(v) })), + }, + ], + }), + ]); + + const suggestions = heatmapSuggestionsSupplier(dataSummary); + expect(suggestions).toEqual([expect.objectContaining({ score: VisualizationSuggestionScore.Best })]); + }); + }); +}); diff --git a/public/app/plugins/panel/heatmap/suggestions.ts b/public/app/plugins/panel/heatmap/suggestions.ts index f49de761e85..670f76cda63 100644 --- a/public/app/plugins/panel/heatmap/suggestions.ts +++ b/public/app/plugins/panel/heatmap/suggestions.ts @@ -1,45 +1,73 @@ -import { VisualizationSuggestionsBuilder } from '@grafana/data'; +import { + DataFrameType, + FieldType, + PanelDataSummary, + VisualizationSuggestionScore, + VisualizationSuggestionsSupplierFn, +} from '@grafana/data'; import { config } from '@grafana/runtime'; +import { GraphFieldConfig } from '@grafana/schema'; import { prepareHeatmapData } from './fields'; import { quantizeScheme } from './palettes'; import { Options, defaultOptions } from './types'; -export class HeatmapSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary } = builder; - - if ( - !builder.data?.series || - !dataSummary.hasData || - dataSummary.timeFieldCount < 1 || - dataSummary.numberFieldCount < 2 || - dataSummary.numberFieldCount > 10 - ) { - return; - } - - const palette = quantizeScheme(defaultOptions.color, config.theme2); - const info = prepareHeatmapData({ - frames: builder.data.series, - options: defaultOptions, - palette, - theme: config.theme2, - }); - if (!info || info.warning) { - return; - } - - builder.getListAppender({ - name: '', - pluginId: 'heatmap', - options: {}, - fieldConfig: { - defaults: { - custom: {}, - }, - overrides: [], - }, - }); +function determineScore(dataSummary: PanelDataSummary): VisualizationSuggestionScore { + // look to see if the data has an explicity marker for heatmap data on it. + if ([DataFrameType.HeatmapRows, DataFrameType.HeatmapCells].some((t) => dataSummary.hasDataFrameType(t))) { + return VisualizationSuggestionScore.Best; } + + // we'll also look more closely at frames which return between 3 and 10 numeric fields. + if (dataSummary.fieldCountByType(FieldType.number) > 2 || dataSummary.fieldCountByType(FieldType.number) <= 10) { + // look through the names of the panels + const hasPotentialHeatmapSeries = dataSummary.rawFrames!.some((frame) => { + for (const field of frame.fields) { + if (field.type === FieldType.number) { + // if the field name, or "ge" or "le" label on the field, are numeric, then it's very possibly part of a heatmap. + if ([field.name, field.labels?.ge, field.labels?.le].some((v) => !isNaN(Number(v)))) { + return true; + } + } + } + return false; + }); + + // if at least all but 1 of the numeric fields in the frame have numeric names, then this is probably a heatmap. + // (the out-of-place one would be "Inf" or "-Inf") + if (hasPotentialHeatmapSeries) { + return VisualizationSuggestionScore.Best; + } + } + + return VisualizationSuggestionScore.OK; } + +export const heatmapSuggestionsSupplier: VisualizationSuggestionsSupplierFn = ( + dataSummary: PanelDataSummary +) => { + if ( + !dataSummary.rawFrames || + !dataSummary.hasData || + !dataSummary.hasFieldType(FieldType.time) || + !dataSummary.hasFieldType(FieldType.number) + ) { + return; + } + + // parse the frame into a heatmap structure to see if it's possible. + const palette = quantizeScheme(defaultOptions.color, config.theme2); + const info = prepareHeatmapData({ + frames: dataSummary.rawFrames, + options: defaultOptions, + palette, + theme: config.theme2, + }); + + // if we can't parse the data into a heatmap, then bail out and prevent showing suggestions. + if (!info || info.warning) { + return; + } + + return [{ score: determineScore(dataSummary) }]; +}; diff --git a/public/app/plugins/panel/histogram/module.tsx b/public/app/plugins/panel/histogram/module.tsx index 4ee2fa97a09..8855287cc93 100644 --- a/public/app/plugins/panel/histogram/module.tsx +++ b/public/app/plugins/panel/histogram/module.tsx @@ -5,6 +5,9 @@ import { identityOverrideProcessor, PanelPlugin, histogramFieldInfo, + buildHistogram, + VisualizationSuggestionScore, + DataFrameType, } from '@grafana/data'; import { t } from '@grafana/i18n'; import { commonOptionsBuilder, getGraphFieldOptions } from '@grafana/ui'; @@ -149,4 +152,16 @@ export const plugin = new PanelPlugin(HistogramPanel) commonOptionsBuilder.addHideFrom(builder); }, + }) + .setSuggestionsSupplier((ds) => { + if (ds.rawFrames && ds.hasData && buildHistogram(ds.rawFrames)) { + return [ + { + score: ds.hasDataFrameType(DataFrameType.Histogram) + ? VisualizationSuggestionScore.Best + : VisualizationSuggestionScore.OK, + }, + ]; + } + return; }); diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 191e503fd90..3cf47744762 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -576,7 +576,9 @@ export const LogsPanel = ({ onMouseLeave={onLogContainerMouseLeave} className={style.logListContainer} style={height ? { minHeight: height } : undefined} - ref={(element: HTMLDivElement) => setScrollElement(element)} + ref={(element: HTMLDivElement) => { + setScrollElement(element); + }} > {deduplicatedRows.length > 0 && scrollElement && ( )} {!config.featureToggles.newLogsPanel && !showControls && ( - setScrollElement(scrollElement)}> + { + setScrollElement(scrollElement); + }} + >
{showCommonLabels && !isAscending && renderCommonLabels()} {showCommonLabels && !isAscending && renderCommonLabels()} setScrollElement(scrollElement)} + ref={(scrollElement: HTMLDivElement | null) => { + setScrollElement(scrollElement); + }} visualisationType="logs" loading={infiniteScrolling} loadMoreLogs={enableInfiniteScrolling ? loadMoreLogs : undefined} diff --git a/public/app/plugins/panel/logs/module.tsx b/public/app/plugins/panel/logs/module.tsx index 04ed5e92183..e51ff946902 100644 --- a/public/app/plugins/panel/logs/module.tsx +++ b/public/app/plugins/panel/logs/module.tsx @@ -1,10 +1,10 @@ -import { PanelPlugin, LogsSortOrder, LogsDedupStrategy, LogsDedupDescription } from '@grafana/data'; +import { PanelPlugin, LogsSortOrder, LogsDedupStrategy, LogsDedupDescription, FieldType } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; +import { showDefaultSuggestion } from 'app/features/panel/suggestions/utils'; import { LogsPanel } from './LogsPanel'; import { Options } from './panelcfg.gen'; -import { LogsPanelSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(LogsPanel) .setPanelOptions((builder, context) => { @@ -207,4 +207,6 @@ export const plugin = new PanelPlugin(LogsPanel) defaultValue: LogsSortOrder.Descending, }); }) - .setSuggestionsSupplier(new LogsPanelSuggestionsSupplier()); + .setSuggestionsSupplier( + showDefaultSuggestion((ds) => ds.hasData && ds.hasFieldType(FieldType.time) && ds.hasFieldType(FieldType.string)) + ); diff --git a/public/app/plugins/panel/logs/suggestions.ts b/public/app/plugins/panel/logs/suggestions.ts deleted file mode 100644 index 79b804cb8d7..00000000000 --- a/public/app/plugins/panel/logs/suggestions.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data'; -import { SuggestionName } from 'app/types/suggestions'; - -import { Options } from './panelcfg.gen'; - -export class LogsPanelSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const list = builder.getListAppender({ - name: '', - pluginId: 'logs', - options: {}, - fieldConfig: { - defaults: { - custom: {}, - }, - overrides: [], - }, - }); - - const { dataSummary: ds } = builder; - - // Require a string & time field - if (!ds.hasData || !ds.hasTimeField || !ds.hasStringField) { - return; - } - - if (ds.preferredVisualisationType === 'logs') { - list.append({ name: SuggestionName.Logs, score: VisualizationSuggestionScore.Best }); - } else { - list.append({ name: SuggestionName.Logs }); - } - } -} diff --git a/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx b/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx index ee727b4f79b..48f871a3a10 100644 --- a/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx +++ b/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { memo } from 'react'; +import { memo, type JSX } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/plugins/panel/nodeGraph/module.tsx b/public/app/plugins/panel/nodeGraph/module.tsx index 9bfa3456fc4..4230b30a9a1 100644 --- a/public/app/plugins/panel/nodeGraph/module.tsx +++ b/public/app/plugins/panel/nodeGraph/module.tsx @@ -4,7 +4,7 @@ import { t } from '@grafana/i18n'; import { NodeGraphPanel } from './NodeGraphPanel'; import { ArcOptionsEditor } from './editor/ArcOptionsEditor'; import { LayoutAlgorithm, Options as NodeGraphOptions } from './panelcfg.gen'; -import { NodeGraphSuggestionsSupplier } from './suggestions'; +import { nodeGraphSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(NodeGraphPanel) .useFieldConfig({ @@ -92,4 +92,4 @@ export const plugin = new PanelPlugin(NodeGraphPanel) }, }); }) - .setSuggestionsSupplier(new NodeGraphSuggestionsSupplier()); + .setSuggestionsSupplier(nodeGraphSuggestionsSupplier); diff --git a/public/app/plugins/panel/nodeGraph/suggestions.ts b/public/app/plugins/panel/nodeGraph/suggestions.ts index eb4a4773ef4..d1e5e1e29b4 100644 --- a/public/app/plugins/panel/nodeGraph/suggestions.ts +++ b/public/app/plugins/panel/nodeGraph/suggestions.ts @@ -1,71 +1,60 @@ -import { DataFrame, FieldType, VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data'; -import { SuggestionName } from 'app/types/suggestions'; +import { DataFrame, FieldType, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data'; -export class NodeGraphSuggestionsSupplier { - getListWithDefaults(builder: VisualizationSuggestionsBuilder) { - return builder.getListAppender<{}, {}>({ - name: SuggestionName.NodeGraph, - pluginId: 'nodeGraph', - }); - } +import { Options } from './panelcfg.gen'; - hasCorrectFields(frames: DataFrame[]): boolean { - let hasNodesFrame = false; - let hasEdgesFrame = false; +function checkFields(fields: Array<[string, FieldType]>, frame: DataFrame): boolean { + let hasCorrectFields = true; - const nodeFields: Array<[string, FieldType]> = [ - ['id', FieldType.string], - ['title', FieldType.string], - ['mainstat', FieldType.number], - ]; - const edgeFields: Array<[string, FieldType]> = [ - ['id', FieldType.string], - ['source', FieldType.string], - ['target', FieldType.string], - ]; - - for (const frame of frames) { - if (this.checkFields(nodeFields, frame)) { - hasNodesFrame = true; - } - if (this.checkFields(edgeFields, frame)) { - hasEdgesFrame = true; - } - } - - return hasNodesFrame && hasEdgesFrame; - } - - checkFields(fields: Array<[string, FieldType]>, frame: DataFrame): boolean { - let hasCorrectFields = true; - - for (const field of fields) { - const [name, type] = field; - const frameField = frame.fields.find((f) => f.name === name); - if (!frameField || type !== frameField.type) { - hasCorrectFields = false; - break; - } - } - - return hasCorrectFields; - } - - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - if (!builder.data) { - return; - } - - const hasCorrectFields = this.hasCorrectFields(builder.data.series); - const nodeGraphFrames = builder.data.series.filter( - (df) => df.meta && df.meta.preferredVisualisationType === 'nodeGraph' - ); - - if (hasCorrectFields || nodeGraphFrames.length === 2) { - this.getListWithDefaults(builder).append({ - name: SuggestionName.NodeGraph, - score: VisualizationSuggestionScore.Best, - }); + for (const field of fields) { + const [name, type] = field; + const frameField = frame.fields.find((f) => f.name === name); + if (!frameField || type !== frameField.type) { + hasCorrectFields = false; + break; } } + + return hasCorrectFields; } + +function frameHasCorrectFields(frames: DataFrame[]): boolean { + let hasNodesFrame = false; + let hasEdgesFrame = false; + + const nodeFields: Array<[string, FieldType]> = [ + ['id', FieldType.string], + ['title', FieldType.string], + ['mainstat', FieldType.number], + ]; + const edgeFields: Array<[string, FieldType]> = [ + ['id', FieldType.string], + ['source', FieldType.string], + ['target', FieldType.string], + ]; + + for (const frame of frames) { + if (checkFields(nodeFields, frame)) { + hasNodesFrame = true; + } + if (checkFields(edgeFields, frame)) { + hasEdgesFrame = true; + } + } + + return hasNodesFrame && hasEdgesFrame; +} + +export const nodeGraphSuggestionsSupplier: VisualizationSuggestionsSupplierFn = (dataSummary) => { + if (!dataSummary.rawFrames) { + return; + } + + const hasCorrectFields = frameHasCorrectFields(dataSummary.rawFrames); + const nodeGraphFrames = dataSummary.hasPreferredVisualisationType('nodeGraph'); + + if (!hasCorrectFields && !nodeGraphFrames) { + return; + } + + return [{ score: VisualizationSuggestionScore.Best }]; +}; diff --git a/public/app/plugins/panel/nodeGraph/useContextMenu.tsx b/public/app/plugins/panel/nodeGraph/useContextMenu.tsx index 65daef5aeda..8ab331cfb3d 100644 --- a/public/app/plugins/panel/nodeGraph/useContextMenu.tsx +++ b/public/app/plugins/panel/nodeGraph/useContextMenu.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { MouseEvent, useCallback, useState } from 'react'; +import { MouseEvent, useCallback, useState, type JSX } from 'react'; import * as React from 'react'; import { DataFrame, Field, GrafanaTheme2, LinkModel, LinkTarget } from '@grafana/data'; diff --git a/public/app/plugins/panel/piechart/module.tsx b/public/app/plugins/panel/piechart/module.tsx index 33726bf3a0f..683a0cc0b6a 100644 --- a/public/app/plugins/panel/piechart/module.tsx +++ b/public/app/plugins/panel/piechart/module.tsx @@ -9,7 +9,7 @@ import { addStandardDataReduceOptions } from '../stat/common'; import { PieChartPanel } from './PieChartPanel'; import { PieChartPanelChangedHandler } from './migrations'; import { Options, FieldConfig, PieChartType, PieChartLabels, PieChartLegendValues } from './panelcfg.gen'; -import { PieChartSuggestionsSupplier } from './suggestions'; +import { piechartSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(PieChartPanel) .setPanelChangeHandler(PieChartPanelChangedHandler) @@ -92,4 +92,4 @@ export const plugin = new PanelPlugin(PieChartPanel) showIf: (c) => c.legend.showLegend !== false, }); }) - .setSuggestionsSupplier(new PieChartSuggestionsSupplier()); + .setSuggestionsSupplier(piechartSuggestionsSupplier); diff --git a/public/app/plugins/panel/piechart/suggestions.ts b/public/app/plugins/panel/piechart/suggestions.ts index fd4beed5e6a..405d5722796 100644 --- a/public/app/plugins/panel/piechart/suggestions.ts +++ b/public/app/plugins/panel/piechart/suggestions.ts @@ -1,79 +1,78 @@ -import { VisualizationSuggestionsBuilder } from '@grafana/data'; +import { defaultsDeep } from 'lodash'; + +import { + FieldType, + VisualizationSuggestion, + VisualizationSuggestionScore, + VisualizationSuggestionsSupplierFn, +} from '@grafana/data'; +import { t } from '@grafana/i18n'; import { LegendDisplayMode } from '@grafana/schema'; -import { SuggestionName } from 'app/types/suggestions'; +import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils'; import { PieChartLabels, Options, PieChartType } from './panelcfg.gen'; -export class PieChartSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const list = builder.getListAppender({ - name: SuggestionName.PieChart, - pluginId: 'piechart', - options: { - reduceOptions: { - values: false, - calcs: ['lastNotNull'], - }, - displayLabels: [PieChartLabels.Percent], - legend: { - calcs: [], - displayMode: LegendDisplayMode.Hidden, - placement: 'right', - values: [], - showLegend: false, - }, +const withDefaults = (suggestion: VisualizationSuggestion): VisualizationSuggestion => + defaultsDeep(suggestion, { + options: { + displayLabels: [PieChartLabels.Percent], + legend: { + calcs: [], + displayMode: LegendDisplayMode.Hidden, + placement: 'right', + values: [], + showLegend: false, }, - }); + }, + } satisfies VisualizationSuggestion); - const { dataSummary } = builder; +const SLICE_MAX = 30; +const SLICE_MIN = 2; - if (!dataSummary.hasNumberField) { - return; - } +export const piechartSuggestionsSupplier: VisualizationSuggestionsSupplierFn = (dataSummary) => { + if (!dataSummary.hasFieldType(FieldType.number)) { + return; + } - if (dataSummary.hasStringField && dataSummary.frameCount === 1) { - // if many values this or single value PieChart is not a good option - if (dataSummary.rowCountTotal > 30 || dataSummary.rowCountTotal < 2) { - return; - } - - list.append({ - name: SuggestionName.PieChart, - options: { - reduceOptions: { - values: true, - calcs: [], - }, - }, - }); - - list.append({ - name: SuggestionName.PieChartDonut, - options: { - reduceOptions: { - values: true, - calcs: [], - }, - pieType: PieChartType.Donut, - }, - }); - - return; - } - - if (dataSummary.numberFieldCount > 30 || dataSummary.numberFieldCount < 2) { - return; - } - - list.append({ - name: SuggestionName.PieChart, - }); - - list.append({ - name: SuggestionName.PieChartDonut, + const suggestions: Array> = [ + { + name: t('piechart.suggestions.pie', 'Pie chart'), + }, + { + name: t('piechart.suggestions.donut', 'Donut chart'), options: { pieType: PieChartType.Donut, }, - }); + }, + ]; + + let shouldUseRawValues = false; + + // we're filtering out data which has more than 30 slices or less than 2, and we're also + // determining whether the reduce options should be set based on the data summary. + if (dataSummary.hasFieldType(FieldType.string) && dataSummary.frameCount === 1) { + if (dataSummary.rowCountTotal > SLICE_MAX && dataSummary.rowCountTotal < SLICE_MIN) { + return; + } + + shouldUseRawValues = true; + } else if ( + dataSummary.fieldCountByType(FieldType.number) > SLICE_MAX || + dataSummary.fieldCountByType(FieldType.number) < SLICE_MIN + ) { + return; } -} + + return suggestions.map((s) => { + const result = defaultNumericVizOptions(withDefaults(s), dataSummary, shouldUseRawValues); + // bump the score up to best if we have exactly one numeric and one string field + if ( + dataSummary.fieldCount === 2 && + dataSummary.fieldCountByType(FieldType.string) === 1 && + dataSummary.fieldCountByType(FieldType.number) === 1 + ) { + result.score = VisualizationSuggestionScore.Best; + } + return result; + }); +}; diff --git a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx index c176b650c24..decd084f375 100644 --- a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx +++ b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { DisplayValueAlignmentFactors, FieldDisplay, diff --git a/public/app/plugins/panel/radialbar/module.tsx b/public/app/plugins/panel/radialbar/module.tsx index dfbdb875865..33212d3ce1c 100644 --- a/public/app/plugins/panel/radialbar/module.tsx +++ b/public/app/plugins/panel/radialbar/module.tsx @@ -8,7 +8,7 @@ import { EffectsEditor } from './EffectsEditor'; import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './GaugeMigrations'; import { RadialBarPanel } from './RadialBarPanel'; import { defaultGaugePanelEffects, defaultOptions, Options } from './panelcfg.gen'; -import { radialBarSuggestionsHandler } from './suggestions'; +import { radialBarSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(RadialBarPanel) .useFieldConfig({}) @@ -112,6 +112,6 @@ export const plugin = new PanelPlugin(RadialBarPanel) defaultValue: defaultGaugePanelEffects, }); }) - .setSuggestionsSupplier(radialBarSuggestionsHandler) + .setSuggestionsSupplier(radialBarSuggestionsSupplier) .setMigrationHandler(gaugePanelMigrationHandler, shouldMigrateGauge) .setPanelChangeHandler(gaugePanelChangedHandler); diff --git a/public/app/plugins/panel/radialbar/suggestions.test.ts b/public/app/plugins/panel/radialbar/suggestions.test.ts index 21a59267bbb..0d8ead08c87 100644 --- a/public/app/plugins/panel/radialbar/suggestions.test.ts +++ b/public/app/plugins/panel/radialbar/suggestions.test.ts @@ -1,13 +1,13 @@ import { createDataFrame, Field, FieldType, getPanelDataSummary } from '@grafana/data'; -import { radialBarSuggestionsHandler } from './suggestions'; +import { radialBarSuggestionsSupplier } from './suggestions'; describe('RadialBarPanel Suggestions', () => { it('does not suggest gauge if no data is present', () => { - expect(radialBarSuggestionsHandler(getPanelDataSummary([]))).toBeFalsy(); - expect(radialBarSuggestionsHandler(getPanelDataSummary(undefined))).toBeFalsy(); + expect(radialBarSuggestionsSupplier(getPanelDataSummary([]))).toBeFalsy(); + expect(radialBarSuggestionsSupplier(getPanelDataSummary(undefined))).toBeFalsy(); expect( - radialBarSuggestionsHandler( + radialBarSuggestionsSupplier( getPanelDataSummary([ createDataFrame({ fields: [ @@ -27,7 +27,7 @@ describe('RadialBarPanel Suggestions', () => { { name: 'status', type: FieldType.string }, ], }); - expect(radialBarSuggestionsHandler(getPanelDataSummary([df]))).toBeFalsy(); + expect(radialBarSuggestionsSupplier(getPanelDataSummary([df]))).toBeFalsy(); }); it('does not suggest gauge if there are too many numeric fields', () => { @@ -35,12 +35,12 @@ describe('RadialBarPanel Suggestions', () => { for (let i = 0; i < 20; i++) { fields.push({ name: `numeric-${i}`, type: FieldType.number, values: [0, 100, 200, 300, 400, 500], config: {} }); } - expect(radialBarSuggestionsHandler(getPanelDataSummary([createDataFrame({ fields })]))).toBeFalsy(); + expect(radialBarSuggestionsSupplier(getPanelDataSummary([createDataFrame({ fields })]))).toBeFalsy(); }); it('suggests gauge for a single numeric field', () => { expect( - radialBarSuggestionsHandler( + radialBarSuggestionsSupplier( getPanelDataSummary([ createDataFrame({ fields: [ @@ -58,7 +58,7 @@ describe('RadialBarPanel Suggestions', () => { it('suggests gauge for a few numeric fields, with other fields mixed in', () => { expect( - radialBarSuggestionsHandler( + radialBarSuggestionsSupplier( getPanelDataSummary([ createDataFrame({ fields: [ @@ -136,7 +136,7 @@ describe('RadialBarPanel Suggestions', () => { ], }, ])('$description suggests aggregated=$aggregated', ({ dataframes, aggregated }) => { - const suggestions = radialBarSuggestionsHandler(getPanelDataSummary(dataframes)); + const suggestions = radialBarSuggestionsSupplier(getPanelDataSummary(dataframes)); const expected = aggregated ? { values: false, calcs: ['lastNotNull'] } : { values: true, calcs: [] }; if (Array.isArray(suggestions)) { for (const suggestion of suggestions) { diff --git a/public/app/plugins/panel/radialbar/suggestions.ts b/public/app/plugins/panel/radialbar/suggestions.ts index 35cde48ec0f..f8d13a096b3 100644 --- a/public/app/plugins/panel/radialbar/suggestions.ts +++ b/public/app/plugins/panel/radialbar/suggestions.ts @@ -8,10 +8,39 @@ import { } from '@grafana/data'; import { t } from '@grafana/i18n'; import { GraphFieldConfig } from '@grafana/ui'; +import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils'; import { Options } from './panelcfg.gen'; -export const radialBarSuggestionsHandler: VisualizationSuggestionsSupplierFn = ( +const withDefaults = ( + suggestion: VisualizationSuggestion +): VisualizationSuggestion => + defaultsDeep(suggestion, { + cardOptions: { + previewModifier: (s) => { + if (s.options?.reduceOptions) { + s.options.reduceOptions.limit = 4; + } + }, + }, + // styles: [{ + // name: t('gauge.suggestions.style.circular', 'Glowing'), + // options: { + // effects: { + // rounded: true, + // barGlow: true, + // centerGlow: true, + // spotlight: true, + // }, + // }, + // }, { + // name: t('gauge.suggestions.style.simple', 'Simple'), + // }] + } satisfies VisualizationSuggestion); + +const MAX_GAUGES = 10; + +export const radialBarSuggestionsSupplier: VisualizationSuggestionsSupplierFn = ( dataSummary ) => { if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) { @@ -19,69 +48,40 @@ export const radialBarSuggestionsHandler: VisualizationSuggestionsSupplierFn= 10) { + if (dataSummary.fieldCountByType(FieldType.number) > MAX_GAUGES) { return; } - const withDefaults = ( - suggestion: VisualizationSuggestion - ): VisualizationSuggestion => { - // if there is a string field and there are few enough rows, we assume it's tabular data and not numeric series data, - // and the de-aggregated version of the viz probably makes more sense - const isTabularData = - dataSummary.hasFieldType(FieldType.string) && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10; - return defaultsDeep(suggestion, { - options: { - reduceOptions: isTabularData - ? { - values: true, - calcs: [], - } - : { - values: false, - calcs: ['lastNotNull'], - }, - }, - fieldConfig: { - defaults: isTabularData - ? { - color: { mode: FieldColorModeId.PaletteClassic }, - } - : {}, - overrides: [], - }, - cardOptions: { - previewModifier: (s) => { - if (s.options?.reduceOptions) { - s.options.reduceOptions.limit = 4; - } - }, - }, - // styles: [{ - // name: t('gauge.suggestions.style.circular', 'Glowing'), - // options: { - // effects: { - // rounded: true, - // barGlow: true, - // centerGlow: true, - // spotlight: true, - // }, - // }, - // }, { - // name: t('gauge.suggestions.style.simple', 'Simple'), - // }] - } satisfies VisualizationSuggestion); - }; - - return [ - withDefaults({ name: t('gauge.suggestions.arc', 'Gauge') }), - withDefaults({ + const suggestions: Array> = [ + { name: t('gauge.suggestions.arc', 'Gauge') }, + { name: t('gauge.suggestions.circular', 'Circular gauge'), options: { shape: 'circle', showThresholdMarkers: false, barWidthFactor: 0.3, }, - }), + }, ]; + + const shouldUseRawValues = + dataSummary.hasFieldType(FieldType.string) && + dataSummary.frameCount === 1 && + dataSummary.rowCountTotal <= MAX_GAUGES; + + return suggestions.map((s) => { + const suggestion = defaultNumericVizOptions(withDefaults(s), dataSummary, shouldUseRawValues); + + if (shouldUseRawValues) { + suggestion.fieldConfig = suggestion.fieldConfig ?? { + defaults: {}, + overrides: [], + }; + suggestion.fieldConfig.defaults.color = suggestion.fieldConfig.defaults.color ?? { + mode: FieldColorModeId.PaletteClassic, + }; + } + + return suggestion; + }); }; diff --git a/public/app/plugins/panel/stat/StatPanel.tsx b/public/app/plugins/panel/stat/StatPanel.tsx index 055fc4adc93..40df4acb9eb 100644 --- a/public/app/plugins/panel/stat/StatPanel.tsx +++ b/public/app/plugins/panel/stat/StatPanel.tsx @@ -1,5 +1,5 @@ import { isNumber } from 'lodash'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, type JSX } from 'react'; import { DisplayValueAlignmentFactors, diff --git a/public/app/plugins/panel/stat/module.tsx b/public/app/plugins/panel/stat/module.tsx index 969e010352c..ecb146a851b 100644 --- a/public/app/plugins/panel/stat/module.tsx +++ b/public/app/plugins/panel/stat/module.tsx @@ -13,7 +13,7 @@ import { statPanelChangedHandler } from './StatMigrations'; import { StatPanel } from './StatPanel'; import { addStandardDataReduceOptions, addOrientationOption } from './common'; import { defaultOptions, Options } from './panelcfg.gen'; -import { StatSuggestionsSupplier } from './suggestions'; +import { statSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(StatPanel) .useFieldConfig() @@ -137,5 +137,5 @@ export const plugin = new PanelPlugin(StatPanel) }) .setNoPadding() .setPanelChangeHandler(statPanelChangedHandler) - .setSuggestionsSupplier(new StatSuggestionsSupplier()) + .setSuggestionsSupplier(statSuggestionsSupplier) .setMigrationHandler(sharedSingleStatMigrationHandler); diff --git a/public/app/plugins/panel/stat/suggestions.ts b/public/app/plugins/panel/stat/suggestions.ts index 00af3220135..14a45d4b52d 100644 --- a/public/app/plugins/panel/stat/suggestions.ts +++ b/public/app/plugins/panel/stat/suggestions.ts @@ -1,41 +1,46 @@ -import { VisualizationSuggestionsBuilder } from '@grafana/data'; -import { BigValueColorMode, BigValueGraphMode, GraphFieldConfig } from '@grafana/schema'; -import { SuggestionName } from 'app/types/suggestions'; +import { defaultsDeep } from 'lodash'; + +import { FieldType, VisualizationSuggestion, VisualizationSuggestionsSupplierFn } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { BigValueColorMode, BigValueGraphMode } from '@grafana/schema'; import { Options } from './panelcfg.gen'; -export class StatSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary: ds } = builder; - - if (!ds.hasData) { - return; - } - - const list = builder.getListAppender({ - name: SuggestionName.Stat, - pluginId: 'stat', - options: {}, - fieldConfig: { - defaults: { - unit: 'short', - custom: {}, - }, - overrides: [], +const withDefaults = (s: VisualizationSuggestion): VisualizationSuggestion => + defaultsDeep(s, { + fieldConfig: { + defaults: { + unit: 'short', + custom: {}, }, - cardOptions: { - previewModifier: (s) => { - if (s.options?.reduceOptions?.values) { - s.options.reduceOptions.limit = 1; - } - }, + overrides: [], + }, + cardOptions: { + previewModifier: (s) => { + if (s.options?.reduceOptions?.values) { + s.options.reduceOptions.limit = 1; + } }, - }); + }, + } satisfies VisualizationSuggestion); - // String and number field with low row count show individual rows - if (ds.hasStringField && ds.hasNumberField && ds.frameCount === 1 && ds.rowCountTotal < 10) { - list.append({ - name: SuggestionName.Stat, +export const statSuggestionsSupplier: VisualizationSuggestionsSupplierFn = (ds) => { + if (!ds.hasData) { + return; + } + + const suggestions: Array> = []; + + // String and number field with low row count show individual rows + if ( + ds.hasFieldType(FieldType.string) && + ds.hasFieldType(FieldType.number) && + ds.frameCount === 1 && + ds.rowCountTotal < 10 + ) { + suggestions.push( + { + name: t('stat.suggestions.stat-discrete-values', 'Stat - discrete values'), options: { reduceOptions: { values: true, @@ -43,9 +48,9 @@ export class StatSuggestionsSupplier { fields: '/.*/', }, }, - }); - list.append({ - name: SuggestionName.StatColoredBackground, + }, + { + name: t('stat.suggestions.stat-discrete-values-color-background', 'Stat - discrete values - color background'), options: { reduceOptions: { values: true, @@ -54,36 +59,38 @@ export class StatSuggestionsSupplier { }, colorMode: BigValueColorMode.Background, }, - }); - } + } + ); + } - // Just a single string field - if (ds.stringFieldCount === 1 && ds.frameCount === 1 && ds.rowCountTotal < 10 && ds.fieldCount === 1) { - list.append({ - name: SuggestionName.Stat, - options: { - reduceOptions: { - values: true, - calcs: [], - fields: '/.*/', - }, - colorMode: BigValueColorMode.None, + // just a single string field + if (ds.fieldCount === 1 && ds.hasFieldType(FieldType.string)) { + suggestions.push({ + name: t('stat.suggestions.stat-single-string', 'Stat - single string'), + options: { + reduceOptions: { + values: true, + calcs: [], + fields: '/.*/', }, - }); - } + colorMode: BigValueColorMode.None, + }, + }); + } - if (ds.hasNumberField && ds.hasTimeField) { - list.append({ + // aggregated suggestions for number fields + if (ds.hasFieldType(FieldType.number) && ds.hasFieldType(FieldType.time)) { + suggestions.push( + { options: { reduceOptions: { values: false, calcs: ['lastNotNull'], }, }, - }); - - list.append({ - name: SuggestionName.StatColoredBackground, + }, + { + name: t('stat.suggestions.stat-color-background', 'Stat - color background'), options: { reduceOptions: { values: false, @@ -92,7 +99,9 @@ export class StatSuggestionsSupplier { graphMode: BigValueGraphMode.None, colorMode: BigValueColorMode.Background, }, - }); - } + } + ); } -} + + return suggestions.map(withDefaults); +}; diff --git a/public/app/plugins/panel/state-timeline/module.tsx b/public/app/plugins/panel/state-timeline/module.tsx index 55fd557eea0..89acb338825 100644 --- a/public/app/plugins/panel/state-timeline/module.tsx +++ b/public/app/plugins/panel/state-timeline/module.tsx @@ -177,7 +177,7 @@ export const plugin = new PanelPlugin(StateTimelinePanel) } // Probably better ways to filter out this by inspecting the types of string values so view this as temporary - if (ds.preferredVisualisationType === 'logs') { + if (ds.hasPreferredVisualisationType('logs')) { return; } diff --git a/public/app/plugins/panel/status-history/module.tsx b/public/app/plugins/panel/status-history/module.tsx index 1e07588d664..eed5e3546a8 100644 --- a/public/app/plugins/panel/status-history/module.tsx +++ b/public/app/plugins/panel/status-history/module.tsx @@ -1,11 +1,10 @@ -import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data'; +import { FieldColorModeId, FieldConfigProperty, FieldType, PanelPlugin } from '@grafana/data'; import { t } from '@grafana/i18n'; import { AxisPlacement, VisibilityMode } from '@grafana/schema'; import { commonOptionsBuilder } from '@grafana/ui'; import { StatusHistoryPanel } from './StatusHistoryPanel'; import { Options, FieldConfig, defaultFieldConfig } from './panelcfg.gen'; -import { StatusHistorySuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(StatusHistoryPanel) .useFieldConfig({ @@ -113,5 +112,42 @@ export const plugin = new PanelPlugin(StatusHistoryPanel) commonOptionsBuilder.addLegendOptions(builder, false); commonOptionsBuilder.addTooltipOptions(builder); }) - .setSuggestionsSupplier(new StatusHistorySuggestionsSupplier()) + .setSuggestionsSupplier((ds) => { + if (!ds.hasData) { + return; + } + + // This panel needs a time field and a string or number field + if ( + !ds.hasFieldType(FieldType.time) || + (!ds.hasFieldType(FieldType.string) && !ds.hasFieldType(FieldType.number)) + ) { + return; + } + + // If there are many series then they won't fit on y-axis so this panel is not good fit + if (ds.fieldCountByType(FieldType.number) >= 30) { + return; + } + + // if there a lot of data points for each series then this is not a good match + if (ds.rowCountMax > 100) { + return; + } + + // Probably better ways to filter out this by inspecting the types of string values so view this as temporary + if (ds.hasPreferredVisualisationType('logs')) { + return; + } + + return [ + { + cardOptions: { + previewModifier: (s) => { + s.options!.colWidth = 0.7; + }, + }, + }, + ]; + }) .setDataSupport({ annotations: true }); diff --git a/public/app/plugins/panel/status-history/suggestions.ts b/public/app/plugins/panel/status-history/suggestions.ts deleted file mode 100644 index 48add53c83d..00000000000 --- a/public/app/plugins/panel/status-history/suggestions.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { FieldColorModeId, VisualizationSuggestionsBuilder } from '@grafana/data'; -import { SuggestionName } from 'app/types/suggestions'; - -import { Options, FieldConfig } from './panelcfg.gen'; - -export class StatusHistorySuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary: ds } = builder; - - if (!ds.hasData) { - return; - } - - // This panel needs a time field and a string or number field - if (!ds.hasTimeField || (!ds.hasStringField && !ds.hasNumberField)) { - return; - } - - // If there are many series then they won't fit on y-axis so this panel is not good fit - if (ds.numberFieldCount >= 30) { - return; - } - - // if there a lot of data points for each series then this is not a good match - if (ds.rowCountMax > 100) { - return; - } - - // Probably better ways to filter out this by inspecting the types of string values so view this as temporary - if (ds.preferredVisualisationType === 'logs') { - return; - } - - const list = builder.getListAppender({ - name: '', - pluginId: 'status-history', - options: {}, - fieldConfig: { - defaults: { - color: { - mode: FieldColorModeId.ContinuousGrYlRd, - }, - custom: {}, - }, - overrides: [], - }, - cardOptions: { - previewModifier: (s) => { - s.options!.colWidth = 0.7; - }, - }, - }); - - list.append({ name: SuggestionName.StatusHistory }); - } -} diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx index e2ee5b8e97f..5a3a1d21fac 100644 --- a/public/app/plugins/panel/table/module.tsx +++ b/public/app/plugins/panel/table/module.tsx @@ -13,7 +13,7 @@ import { TableCellOptionEditor } from './TableCellOptionEditor'; import { TablePanel } from './TablePanel'; import { tableMigrationHandler, tablePanelChangedHandler } from './migrations'; import { Options, defaultOptions, FieldConfig } from './panelcfg.gen'; -import { TableSuggestionsSupplier } from './suggestions'; +import { tableSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(TablePanel) .setPanelChangeHandler(tablePanelChangedHandler) @@ -227,4 +227,4 @@ export const plugin = new PanelPlugin(TablePanel) defaultValue: defaultOptions?.enablePagination, }); }) - .setSuggestionsSupplier(new TableSuggestionsSupplier()); + .setSuggestionsSupplier(tableSuggestionsSupplier); diff --git a/public/app/plugins/panel/table/suggestions.ts b/public/app/plugins/panel/table/suggestions.ts index bcfead0fe31..98fc085e1cf 100644 --- a/public/app/plugins/panel/table/suggestions.ts +++ b/public/app/plugins/panel/table/suggestions.ts @@ -1,38 +1,33 @@ -import { VisualizationSuggestionsBuilder } from '@grafana/data'; -import { TableFieldOptions } from '@grafana/schema'; +import { PanelDataSummary, VisualizationSuggestionScore, VisualizationSuggestionsSupplierFn } from '@grafana/data'; import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg'; -import { SuggestionName } from 'app/types/suggestions'; -import { Options } from './panelcfg.gen'; +import { Options, FieldConfig } from './panelcfg.gen'; -export class TableSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const list = builder.getListAppender({ - name: SuggestionName.Table, - pluginId: 'table', - options: {}, - fieldConfig: { - defaults: { - custom: {}, - }, - overrides: [], - }, - cardOptions: { - previewModifier: (s) => { - s.fieldConfig!.defaults.custom!.minWidth = 50; - }, - }, - }); - - // If there are not data suggest table anyway but use icon instead of real preview - if (builder.dataSummary.fieldCount === 0) { - list.append({ - cardOptions: { - imgSrc: icnTablePanelSvg, - }, - }); - } else { - list.append({}); - } +function getTableSuggestionScore(dataSummary: PanelDataSummary): VisualizationSuggestionScore { + if (dataSummary.hasPreferredVisualisationType('table')) { + return VisualizationSuggestionScore.Best; } + + // table is best suited to showing many fields with many rows. + if (dataSummary.fieldCountMax > 5 && dataSummary.rowCountMax > 50) { + return VisualizationSuggestionScore.Good; + } + + return VisualizationSuggestionScore.OK; } + +export const tableSuggestionsSupplier: VisualizationSuggestionsSupplierFn = (dataSummary) => [ + { + score: getTableSuggestionScore(dataSummary), + cardOptions: { + previewModifier: (s) => { + if (s.fieldConfig && s.fieldConfig.defaults.custom) { + s.fieldConfig.defaults.custom.minWidth = 50; + } + }, + // If there is no data, suggest table anyway, but use icon instead of real preview + // TODO: delete this in once "new" suggestions are fully rolled out + imgSrc: dataSummary.fieldCount === 0 ? icnTablePanelSvg : undefined, + }, + }, +]; diff --git a/public/app/plugins/panel/timeseries/module.tsx b/public/app/plugins/panel/timeseries/module.tsx index beccdf480d6..22b0e3a715a 100644 --- a/public/app/plugins/panel/timeseries/module.tsx +++ b/public/app/plugins/panel/timeseries/module.tsx @@ -8,7 +8,7 @@ import { TimezonesEditor } from './TimezonesEditor'; import { defaultGraphConfig, getGraphFieldConfig } from './config'; import { graphPanelChangedHandler } from './migrations'; import { FieldConfig, Options } from './panelcfg.gen'; -import { TimeSeriesSuggestionsSupplier } from './suggestions'; +import { timeseriesSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(TimeSeriesPanel) .setPanelChangeHandler(graphPanelChangedHandler) @@ -26,5 +26,5 @@ export const plugin = new PanelPlugin(TimeSeriesPanel) defaultValue: undefined, }); }) - .setSuggestionsSupplier(new TimeSeriesSuggestionsSupplier()) + .setSuggestionsSupplier(timeseriesSuggestionsSupplier) .setDataSupport({ annotations: true, alertStates: true }); diff --git a/public/app/plugins/panel/timeseries/suggestions.ts b/public/app/plugins/panel/timeseries/suggestions.ts index d41179d3f36..05a73acdcc7 100644 --- a/public/app/plugins/panel/timeseries/suggestions.ts +++ b/public/app/plugins/panel/timeseries/suggestions.ts @@ -1,9 +1,15 @@ +import { defaultsDeep } from 'lodash'; + import { - FieldColorModeId, - VisualizationSuggestionsBuilder, + DataFrameType, DataTransformerID, + FieldType, PanelPluginVisualizationSuggestion, + VisualizationSuggestion, + VisualizationSuggestionScore, + VisualizationSuggestionsSupplierFn, } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { GraphDrawStyle, GraphFieldConfig, @@ -13,209 +19,142 @@ import { StackingMode, } from '@grafana/schema'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { SuggestionName } from 'app/types/suggestions'; import { Options } from './panelcfg.gen'; -export class TimeSeriesSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary } = builder; +const MAX_BARS = 100; +const MAX_ROWS_SMOOTH_CHART = 200; - if (!dataSummary.hasTimeField || !dataSummary.hasNumberField || dataSummary.rowCountTotal < 2) { - return; - } - - const list = builder.getListAppender({ - name: SuggestionName.LineChart, - pluginId: 'timeseries', - options: { - legend: { - calcs: [], - displayMode: LegendDisplayMode.Hidden, - placement: 'right', - showLegend: false, - }, +const withDefaults = ( + suggestion: VisualizationSuggestion +): VisualizationSuggestion => + defaultsDeep(suggestion, { + options: { + legend: { + calcs: [], + displayMode: LegendDisplayMode.Hidden, + placement: 'right', + showLegend: false, }, - fieldConfig: { - defaults: { - custom: {}, - }, - overrides: [], + }, + fieldConfig: { + defaults: { + custom: {}, }, - cardOptions: { - previewModifier: (s) => { - if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { - s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); - } - }, + overrides: [], + }, + cardOptions: { + previewModifier: (s) => { + if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { + s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); + } }, - }); + }, + } satisfies VisualizationSuggestion); - const maxBarsCount = 100; +const areaChart = (name: string, stacking?: StackingMode) => ({ + name, + fieldConfig: { + defaults: { + custom: { + fillOpacity: 25, + ...(stacking ? { stacking: { mode: stacking, group: 'A' } } : {}), + }, + }, + overrides: [], + }, +}); - list.append({ - name: SuggestionName.LineChart, - }); +const barChart = (name: string, stacking?: StackingMode) => ({ + name, + fieldConfig: { + defaults: { + custom: { + drawStyle: GraphDrawStyle.Bars, + fillOpacity: 100, + lineWidth: 1, + gradientMode: GraphGradientMode.Hue, + ...(stacking ? { stacking: { mode: stacking, group: 'A' } } : {}), + }, + }, + overrides: [], + }, +}); - if (dataSummary.rowCountMax < 200) { - list.append({ - name: SuggestionName.LineChartSmooth, - fieldConfig: { - defaults: { - custom: { - lineInterpolation: LineInterpolation.Smooth, - }, - }, - overrides: [], - }, - }); - } +// TODO: all "gradient color scheme" suggestions have been removed. they will be re-added as part of the "styles" feature. - // Single series suggestions - if (dataSummary.numberFieldCount === 1) { - list.append({ - name: SuggestionName.AreaChart, - fieldConfig: { - defaults: { - custom: { - fillOpacity: 25, - }, - }, - overrides: [], - }, - }); +export const timeseriesSuggestionsSupplier: VisualizationSuggestionsSupplierFn = ( + dataSummary +) => { + if ( + !dataSummary.hasFieldType(FieldType.time) || + !dataSummary.hasFieldType(FieldType.number) || + dataSummary.rowCountTotal < 2 + ) { + return; + } - list.append({ - name: SuggestionName.LineChartGradientColorScheme, - fieldConfig: { - defaults: { - color: { - mode: FieldColorModeId.ContinuousGrYlRd, - }, - custom: { - gradientMode: GraphGradientMode.Scheme, - lineInterpolation: LineInterpolation.Smooth, - lineWidth: 3, - fillOpacity: 20, - }, - }, - overrides: [], - }, - }); + const score: VisualizationSuggestionScore = + dataSummary.hasDataFrameType(DataFrameType.TimeSeriesLong) || + dataSummary.hasDataFrameType(DataFrameType.TimeSeriesWide) || + dataSummary.hasDataFrameType(DataFrameType.TimeSeriesMulti) + ? VisualizationSuggestionScore.Good + : VisualizationSuggestionScore.OK; - if (dataSummary.rowCountMax < maxBarsCount) { - list.append({ - name: SuggestionName.BarChart, - fieldConfig: { - defaults: { - custom: { - drawStyle: GraphDrawStyle.Bars, - fillOpacity: 100, - lineWidth: 1, - gradientMode: GraphGradientMode.Hue, - }, - }, - overrides: [], - }, - }); + const suggestions: Array> = [ + { + name: t('timeseries.suggestions.line', 'Line chart'), + }, + ]; - list.append({ - name: SuggestionName.BarChartGradientColorScheme, - fieldConfig: { - defaults: { - color: { - mode: FieldColorModeId.ContinuousGrYlRd, - }, - custom: { - drawStyle: GraphDrawStyle.Bars, - fillOpacity: 90, - lineWidth: 1, - gradientMode: GraphGradientMode.Scheme, - }, - }, - overrides: [], - }, - }); - } - - return; - } - - // Multiple series suggestions - - list.append({ - name: SuggestionName.AreaChartStacked, + if (dataSummary.rowCountMax < MAX_ROWS_SMOOTH_CHART) { + suggestions.push({ + name: t('timeseries.suggestions.line-smooth', 'Line chart - smooth'), fieldConfig: { defaults: { custom: { - fillOpacity: 25, - stacking: { - mode: StackingMode.Normal, - group: 'A', - }, + lineInterpolation: LineInterpolation.Smooth, }, }, overrides: [], }, }); + } - list.append({ - name: SuggestionName.AreaChartStackedPercent, - fieldConfig: { - defaults: { - custom: { - fillOpacity: 25, - stacking: { - mode: StackingMode.Percent, - group: 'A', - }, - }, - }, - overrides: [], - }, - }); + // Single-series suggestions + if (dataSummary.fieldCountByType(FieldType.number) === 1) { + suggestions.push(areaChart(t('timeseries.suggestions.area', 'Area chart'))); - if (dataSummary.rowCountTotal / dataSummary.numberFieldCount < maxBarsCount) { - list.append({ - name: SuggestionName.BarChartStacked, - fieldConfig: { - defaults: { - custom: { - drawStyle: GraphDrawStyle.Bars, - fillOpacity: 100, - lineWidth: 1, - gradientMode: GraphGradientMode.Hue, - stacking: { - mode: StackingMode.Normal, - group: 'A', - }, - }, - }, - overrides: [], - }, - }); - - list.append({ - name: SuggestionName.BarChartStackedPercent, - fieldConfig: { - defaults: { - custom: { - drawStyle: GraphDrawStyle.Bars, - fillOpacity: 100, - lineWidth: 1, - gradientMode: GraphGradientMode.Hue, - stacking: { - mode: StackingMode.Percent, - group: 'A', - }, - }, - }, - overrides: [], - }, - }); + if (dataSummary.rowCountMax < MAX_BARS) { + suggestions.push(barChart(t('timeseries.suggestions.bar', 'Bar chart'))); } } -} + // Multiple series suggestions + else { + suggestions.push( + areaChart(t('timeseries.suggestions.area-stacked', 'Area chart - stacked'), StackingMode.Normal), + areaChart( + t('timeseries.suggestions.area-stacked-percentage', 'Area chart - stacked by percentage'), + StackingMode.Percent + ) + ); + + if (dataSummary.rowCountTotal / dataSummary.fieldCountByType(FieldType.number) < MAX_BARS) { + suggestions.push( + barChart(t('timeseries.suggestions.bar-stacked', 'Bar chart - stacked'), StackingMode.Normal), + barChart( + t('timeseries.suggestions.bar-stacked-percent', 'Bar chart - stacked by percentage'), + StackingMode.Percent + ) + ); + } + } + + return suggestions.map((s) => { + s.score = score; + return withDefaults(s); + }); +}; // This will try to get a suggestion that will add a long to wide conversion export function getPrepareTimeseriesSuggestion(panelId: number): PanelPluginVisualizationSuggestion | undefined { diff --git a/public/app/plugins/panel/traces/module.tsx b/public/app/plugins/panel/traces/module.tsx index fdbe35c6c91..07b72f00d53 100644 --- a/public/app/plugins/panel/traces/module.tsx +++ b/public/app/plugins/panel/traces/module.tsx @@ -1,11 +1,11 @@ import { PanelPlugin } from '@grafana/data'; import { t } from '@grafana/i18n'; +import { showDefaultSuggestion } from 'app/features/panel/suggestions/utils'; import { migrateToAdhocFilters } from '../../../features/explore/TraceView/useSearch'; import { FiltersEditor } from './FiltersEditor'; import { TracesPanel } from './TracesPanel'; -import { TracesSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(TracesPanel) .setMigrationHandler((panel) => { @@ -44,4 +44,4 @@ export const plugin = new PanelPlugin(TracesPanel) category, }); }) - .setSuggestionsSupplier(new TracesSuggestionsSupplier()); + .setSuggestionsSupplier(showDefaultSuggestion((ds) => ds.hasPreferredVisualisationType('trace'))); diff --git a/public/app/plugins/panel/traces/suggestions.ts b/public/app/plugins/panel/traces/suggestions.ts deleted file mode 100644 index bda2e463927..00000000000 --- a/public/app/plugins/panel/traces/suggestions.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data'; -import { SuggestionName } from 'app/types/suggestions'; - -export class TracesSuggestionsSupplier { - getListWithDefaults(builder: VisualizationSuggestionsBuilder) { - return builder.getListAppender<{}, {}>({ - name: SuggestionName.Trace, - pluginId: 'traces', - }); - } - - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - if (!builder.data) { - return; - } - - const dataFrame = builder.data.series[0]; - if (!dataFrame) { - return; - } - - if (builder.data.series[0].meta?.preferredVisualisationType === 'trace') { - this.getListWithDefaults(builder).append({ - name: SuggestionName.Trace, - score: VisualizationSuggestionScore.Best, - }); - } - } -} diff --git a/public/app/plugins/panel/trend/TrendPanel.tsx b/public/app/plugins/panel/trend/TrendPanel.tsx index 95ce2330283..b446c651733 100644 --- a/public/app/plugins/panel/trend/TrendPanel.tsx +++ b/public/app/plugins/panel/trend/TrendPanel.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import { - isLikelyAscendingVector, DataFrame, FieldMatcherID, fieldMatchers, @@ -10,18 +9,17 @@ import { TimeRange, useDataLinksContext, } from '@grafana/data'; -import { config, PanelDataErrorView } from '@grafana/runtime'; +import { PanelDataErrorView } from '@grafana/runtime'; import { KeyboardPlugin, TooltipDisplayMode, TooltipPlugin2, usePanelContext } from '@grafana/ui'; import { TooltipHoverMode } from '@grafana/ui/internal'; import { XYFieldMatchers } from 'app/core/components/GraphNG/types'; import { preparePlotFrame } from 'app/core/components/GraphNG/utils'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; -import { findFieldIndex } from 'app/features/dimensions/utils'; import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip'; -import { prepareGraphableFields } from '../timeseries/utils'; import { Options } from './panelcfg.gen'; +import { prepSeries } from './utils'; export const TrendPanel = ({ data, @@ -51,49 +49,7 @@ export const TrendPanel = ({ return preparePlotFrame(frames, dimFields); }; - const info = useMemo(() => { - if (data.series.length > 1) { - return { - warning: 'Only one frame is supported, consider adding a join transformation', - frames: data.series, - }; - } - - let frames = data.series; - let xFieldIdx: number | undefined; - if (options.xField) { - xFieldIdx = findFieldIndex(options.xField, frames[0]); - if (xFieldIdx == null) { - return { - warning: 'Unable to find field: ' + options.xField, - frames: data.series, - }; - } - } else { - // first number field - // Perhaps we can/should support any ordinal rather than an error here - xFieldIdx = frames[0] ? frames[0].fields.findIndex((f) => f.type === FieldType.number) : -1; - if (xFieldIdx === -1) { - return { - warning: 'No numeric fields found for X axis', - frames, - }; - } - } - - // Make sure values are ascending - if (xFieldIdx != null) { - const field = frames[0].fields[xFieldIdx]; - if (field.type === FieldType.number && !isLikelyAscendingVector(field.values)) { - return { - warning: `Values must be in ascending order`, - frames, - }; - } - } - - return { frames: prepareGraphableFields(frames, config.theme2, undefined, xFieldIdx) }; - }, [data.series, options.xField]); + const info = useMemo(() => prepSeries(data.series, options.xField), [data.series, options.xField]); if (info.warning || !info.frames) { return ( diff --git a/public/app/plugins/panel/trend/module.tsx b/public/app/plugins/panel/trend/module.tsx index ec823e00644..67434a8c542 100644 --- a/public/app/plugins/panel/trend/module.tsx +++ b/public/app/plugins/panel/trend/module.tsx @@ -1,13 +1,14 @@ -import { Field, FieldType, PanelPlugin } from '@grafana/data'; +import { Field, FieldType, PanelPlugin, VisualizationSuggestionScore } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { commonOptionsBuilder } from '@grafana/ui'; +import { GraphDrawStyle } from '@grafana/schema'; +import { commonOptionsBuilder, LegendDisplayMode } from '@grafana/ui'; import { optsWithHideZeros } from '@grafana/ui/internal'; import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config'; import { TrendPanel } from './TrendPanel'; import { FieldConfig, Options } from './panelcfg.gen'; -import { TrendSuggestionsSupplier } from './suggestions'; +import { prepSeries } from './utils'; export const plugin = new PanelPlugin(TrendPanel) .useFieldConfig(getGraphFieldConfig(defaultGraphConfig, false)) @@ -29,5 +30,47 @@ export const plugin = new PanelPlugin(TrendPanel) commonOptionsBuilder.addTooltipOptions(builder, false, true, optsWithHideZeros); commonOptionsBuilder.addLegendOptions(builder); }) - .setSuggestionsSupplier(new TrendSuggestionsSupplier()); + .setSuggestionsSupplier((ds) => { + if ( + !ds.rawFrames || + ds.fieldCountByType(FieldType.number) < 2 || + ds.rowCountTotal < 2 || + ds.rowCountTotal < 2 || + ds.frameCount > 1 + ) { + return; + } + + const info = prepSeries(ds.rawFrames); + if (info.warning || !info.frames) { + return; + } + + return [ + { + score: VisualizationSuggestionScore.Good, + options: { + legend: { + calcs: [], + displayMode: LegendDisplayMode.Hidden, + placement: 'right', + showLegend: false, + }, + }, + fieldConfig: { + defaults: { + custom: {}, + }, + overrides: [], + }, + cardOptions: { + previewModifier: (s) => { + if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { + s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); + } + }, + }, + }, + ]; + }); //.setDataSupport({ annotations: true, alertStates: true }); diff --git a/public/app/plugins/panel/trend/suggestions.ts b/public/app/plugins/panel/trend/suggestions.ts deleted file mode 100644 index c135c5df30e..00000000000 --- a/public/app/plugins/panel/trend/suggestions.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { VisualizationSuggestionsBuilder } from '@grafana/data'; -import { GraphDrawStyle, GraphFieldConfig, LegendDisplayMode } from '@grafana/schema'; -import { SuggestionName } from 'app/types/suggestions'; - -import { Options } from './panelcfg.gen'; - -export class TrendSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary } = builder; - - if (dataSummary.numberFieldCount < 2 || dataSummary.rowCountTotal < 2 || dataSummary.rowCountTotal < 2) { - return; - } - - // Super basic - const list = builder.getListAppender({ - name: SuggestionName.LineChart, - pluginId: 'trend', - options: { - legend: { - calcs: [], - displayMode: LegendDisplayMode.Hidden, - placement: 'right', - showLegend: false, - }, - }, - fieldConfig: { - defaults: { - custom: {}, - }, - overrides: [], - }, - cardOptions: { - previewModifier: (s) => { - if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { - s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); - } - }, - }, - }); - return list; - } -} diff --git a/public/app/plugins/panel/trend/utils.ts b/public/app/plugins/panel/trend/utils.ts new file mode 100644 index 00000000000..17a02fd5049 --- /dev/null +++ b/public/app/plugins/panel/trend/utils.ts @@ -0,0 +1,48 @@ +import { DataFrame, FieldType, isLikelyAscendingVector } from '@grafana/data'; +import config from 'app/core/config'; +import { findFieldIndex } from 'app/features/dimensions/utils'; + +import { prepareGraphableFields } from '../timeseries/utils'; + +export function prepSeries(frames: DataFrame[], xField?: string): { warning?: string; frames: DataFrame[] | null } { + if (frames.length > 1) { + return { + warning: 'Only one frame is supported, consider adding a join transformation', + frames: frames, + }; + } + + let xFieldIdx: number | undefined; + if (xField) { + xFieldIdx = findFieldIndex(xField, frames[0]); + if (xFieldIdx == null) { + return { + warning: 'Unable to find field: ' + xField, + frames: frames, + }; + } + } else { + // first number field + // Perhaps we can/should support any ordinal rather than an error here + xFieldIdx = frames[0] ? frames[0].fields.findIndex((f) => f.type === FieldType.number) : -1; + if (xFieldIdx === -1) { + return { + warning: 'No numeric fields found for X axis', + frames, + }; + } + } + + // Make sure values are ascending + if (xFieldIdx != null) { + const field = frames[0].fields[xFieldIdx]; + if (field.type === FieldType.number && !isLikelyAscendingVector(field.values)) { + return { + warning: `Values must be in ascending order`, + frames, + }; + } + } + + return { frames: prepareGraphableFields(frames, config.theme2, undefined, xFieldIdx) }; +} diff --git a/public/app/routes/RoutesWrapper.tsx b/public/app/routes/RoutesWrapper.tsx index dafa5b000c5..2c36d7571f9 100644 --- a/public/app/routes/RoutesWrapper.tsx +++ b/public/app/routes/RoutesWrapper.tsx @@ -1,4 +1,4 @@ -import { ComponentType, ReactNode } from 'react'; +import { ComponentType, ReactNode, type JSX } from 'react'; import { Router } from 'react-router-dom'; import { CompatRouter } from 'react-router-dom-v5-compat'; diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index abbf8969b05..b2e2fc31a9b 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -1,3 +1,5 @@ +import { RoleDto } from 'app/api/clients/legacy'; + /** * UserPermission is a map storing permissions in a form of * { @@ -155,6 +157,10 @@ export enum AccessControlAction { AlertingTemplatesWrite = 'alert.notifications.templates:write', AlertingTemplatesDelete = 'alert.notifications.templates:delete', + // Alerting enrichments actions + AlertingEnrichmentsRead = 'alert.enrichments:read', + AlertingEnrichmentsWrite = 'alert.enrichments:write', + PluginsInstall = 'plugins:install', PluginsWrite = 'plugins:write', @@ -170,17 +176,6 @@ export enum AccessControlAction { MigrationAssistantMigrate = 'migrationassistant:migrate', } -export interface Role { - uid: string; - name: string; - displayName: string; +export interface Role extends RoleDto { filteredDisplayName: string; // name to be shown in filtered role list - description: string; - group: string; - global: boolean; - delegatable?: boolean; - mapped?: boolean; - version: number; - created: string; - updated: string; } diff --git a/public/app/types/suggestions.ts b/public/app/types/suggestions.ts deleted file mode 100644 index aac0dc1bd3c..00000000000 --- a/public/app/types/suggestions.ts +++ /dev/null @@ -1,34 +0,0 @@ -export enum SuggestionName { - LineChart = 'Line chart', - LineChartSmooth = 'Line chart smooth', - LineChartGradientColorScheme = 'Line chart with gradient color scheme', - AreaChart = 'Area chart', - AreaChartStacked = 'Area chart stacked', - AreaChartStackedPercent = 'Area chart 100% stacked', - BarChart = 'Bar chart', - BarChartGradientColorScheme = 'Bar chart with gradient color scheme', - BarChartStacked = 'Bar chart stacked', - BarChartStackedPercent = 'Bar chart 100% stacked', - BarChartHorizontal = 'Bar chart horizontal', - BarChartHorizontalStacked = 'Bar chart horizontal stacked', - BarChartHorizontalStackedPercent = 'Bar chart horizontal 100% stacked', - Candlestick = 'Candlestick', - PieChart = 'Pie chart', - PieChartDonut = 'Pie chart donut', - Stat = 'Stat', - StatColoredBackground = 'Stat colored background', - Gauge = 'Gauge', - GaugeCircular = 'Circular gauge', - GaugeNoThresholds = 'Gauge no thresholds', - BarGaugeBasic = 'Bar gauge basic', - BarGaugeLCD = 'Bar gauge LCD', - Table = 'Table', - StateTimeline = 'State timeline', - StatusHistory = 'Status history', - TextPanel = 'Text', - DashboardList = 'Dashboard list', - Logs = 'Logs', - FlameGraph = 'Flame graph', - Trace = 'Trace', - NodeGraph = 'Node graph', -} diff --git a/public/app/types/teams.ts b/public/app/types/teams.ts index f592b362894..72202a21295 100644 --- a/public/app/types/teams.ts +++ b/public/app/types/teams.ts @@ -1,4 +1,4 @@ -import { WithAccessControlMetadata } from '@grafana/data'; +import { TeamDto as TeamDtoLegacy } from 'app/api/clients/legacy'; import { Role } from './accessControl'; @@ -13,42 +13,7 @@ export interface TeamDTO { name: string; } -// This is the team resource with permissions and metadata expanded -export interface Team extends WithAccessControlMetadata { - /** - * Internal id of team - * @deprecated use uid instead - */ - id: number; - /** - * A unique identifier for the team. - */ - uid: string; // Prefer UUID - /** - * AvatarUrl is the team's avatar URL. - */ - avatarUrl?: string; - /** - * Email of the team. - */ - email?: string; - /** - * MemberCount is the number of the team members. - */ - memberCount: number; - /** - * Name of the team. - */ - name: string; - /** - * OrgId is the ID of an organisation the team belongs to. - */ - orgId: number; - /** - * isProvisioned is set if the team has been provisioned from IdP. - */ - isProvisioned: boolean; -} +export type Team = TeamDtoLegacy; export interface TeamWithRoles extends Team { /** @@ -73,20 +38,6 @@ export interface TeamGroup { teamId: number; } -export interface TeamsState { - teams: Team[]; - page: number; - query: string; - perPage: number; - noTeams: boolean; - totalPages: number; - hasFetched: boolean; - sort?: string; - rolesLoading?: boolean; -} - export interface TeamState { - team: Team; - members: TeamMember[]; groups: TeamGroup[]; } diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index ac6538d3719..73a8378bd20 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -483,6 +483,7 @@ "noOptionsMessage-no-datasources-found": "Nebyly nalezeny žádné zdroje dat" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Kopírovat odkaz", "duplicate": "Duplikovat", "export": "Exportovat", @@ -2648,7 +2649,9 @@ "placeholder-search-input": "Hledejte podle názvu nebo zadejte dotaz na filtr…" }, "grafana-folder": "Složka Grafana", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Kondice", "label": { "hide": "Skrýt", @@ -2659,6 +2662,7 @@ "placeholder-all-data-sources": "Všechny zdroje dat", "placeholder-contact-point": "Vyberte kontaktní bod", "placeholder-data-sources": "Vyberte zdroje dat", + "placeholder-group-search": "", "placeholder-labels": "Vyberte štítky", "plugin-rules": "Pravidla pluginu", "rule-source": { @@ -3497,6 +3501,14 @@ "label-negative-y": "Záporné Y" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Vyžadovat místo z pravé strany", "gaps-options": { @@ -3541,6 +3553,10 @@ }, "name-show-unfilled-area": "Zobrazit nevyplněnou oblast", "name-value-display": "Zobrazení hodnoty", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Skryté", "label-text-color": "Barva textu", @@ -4209,6 +4225,7 @@ "clear": "Vymazat", "collapse": "Sbalit", "disabled": "", + "discard": "", "edit": "Upravit", "help": "Nápověda", "loading": "Načítání…", @@ -4826,7 +4843,6 @@ "variable": "{{type}} proměnná", "variable-set": "Proměnné" }, - "open": "Otevřít podokno možností", "row": { "header": { "hide": "Skrýt", @@ -5179,6 +5195,7 @@ "title-matched_other": "Shoda možností: {{count}}/{{totalCount}}" }, "outline": { + "pane-header": "", "repeated-item": "Opakovat", "tree-item": { "empty": "(prázdné)", @@ -5390,6 +5407,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Načítání konfigurace" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Načítání a inicializace nástěnky", "title-not-found": "Panel s ID {{panelId}} nebyl nalezen" @@ -5493,11 +5536,8 @@ "tooltip": "Tato nástěnka byla označena jen pro čtení" }, "export": { - "arrow": "Exportovat", - "title": "Exportovat", "tooltip": { - "as-code": "Exportovat jako kód", - "json": "Exportovat jako JSON" + "as-code": "Exportovat jako kód" } }, "more-save-options": "Další možnosti uložení", @@ -5549,6 +5589,9 @@ "save-library-panel": "Uložit panel knihovny", "settings": "Nastavení nástěnky", "share-button": "Sdílet", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5880,9 +5923,6 @@ "name-values-separated-comma": "Hodnoty oddělené čárkou", "selection-options": "Možnosti výběru" }, - "dashboard-edit-pane-renderer": { - "outline": "Osnova" - }, "dashboard-link-form": { "back-to-list": "Zpět na seznam", "label-icon": "Ikona", @@ -6228,8 +6268,8 @@ "placeholder-search-for": "Hledat…", "radio-options": { "label": { - "suggestions": "Návrhy", - "visualizations": "Vizualizace" + "all-visualizations": "", + "suggestions": "Návrhy" } }, "title-close": "Zavřít" @@ -7873,6 +7913,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8170,14 +8211,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Přejít zpět" - } - }, - "select-group": "Vybrat skupinu" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11144,8 +11177,8 @@ "tooltip-delete": "Odstranit" }, "visualization-suggestions": { - "based-on-current-data": "Na základě současných dat", - "no-results-matched-your-query": "Vašemu dotazu neodpovídají žádné výsledky" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Nenašly jsme žádnou shodu s vaším dotazem" @@ -11177,6 +11210,10 @@ "pie-chart-type-options": { "label-donut": "Kobliha", "label-pie": "Koláč" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11766,13 +11803,6 @@ "saving": "Ukládání", "title-error-loading-file": "Chyba při načítání souboru" }, - "files-view": { - "columns": { - "history": "Historie", - "view": "Zobrazit" - }, - "placeholder-search": "Hledat" - }, "finish-step": { "description-enable-previews": "Přidá náhled obrázku změn nástěnky v pull requestech. Obrázky nástěnek Grafana budou sdíleny ve vašem úložišti Git a uvidí je každý, kdo má přístup k úložišti.", "description-generate-dashboard-previews": "Vytvořte odkazy na náhledy pro žádosti o stažení", @@ -11987,9 +12017,6 @@ "source-code": "Zdrojový kód" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Jen pro čtení", "settings": "Nastavení", "view": "Zobrazit" @@ -12026,14 +12053,6 @@ "webhook-last-event": "Poslední událost:", "webhook-url": "Zobrazit webhook" }, - "repository-resources": { - "columns": { - "history": "Historie", - "view-dashboard": "Zobrazit", - "view-folder": "Zobrazit" - }, - "placeholder-search": "Hledat" - }, "repository-status-page": { "back-to-repositories": "Zpět na úložiště", "cleaning-up-resources": "Čištění zdrojů úložiště", @@ -12041,12 +12060,10 @@ "not-found": "nenalezeno", "not-found-message": "Úložiště nebylo nalezeno", "repository-config-exists-configuration": "Ujistěte se, že konfigurace úložiště existuje v konfiguračním souboru.", - "tab-files": "Soubory", - "tab-files-title": "Seznam nezpracovaných souborů z úložiště", "tab-overview": "Přehled", "tab-overview-title": "Přehled úložiště", "tab-resources": "Zdroje", - "tab-resources-title": "Zdroje uložené v databázi Grafany", + "tab-resources-title": "", "title": "Stav úložiště", "title-legacy-storage": "Starší verze úložiště", "title-queued-for-deletion": "Zařazeno do fronty k odstranění" @@ -12069,6 +12086,17 @@ "pure-git": "Pouze Git", "pure-git-description": "Připojit k jakémukoli úložišti Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Základ", "dashboard-preview": "Náhled nástěnky", @@ -13143,6 +13171,12 @@ "label-same-as-value": "Stejné jako hodnota", "label-standard": "Standardní" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Auto", "label-center": "Střed" @@ -13482,7 +13516,8 @@ "forwards-time-aria-label": "Přesunout časový rozsah dopředu", "to": "do", "zoom-out-button": "Oddálit časový rozsah", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Použít časový rozsah", @@ -13584,6 +13619,16 @@ "label-threshold": "Prahová hodnota" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Přidat časové pásmo", "tooltip-remove-timezone": "Odebrat časové pásmo" diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 47445cd97ed..469a0ddfef8 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -477,6 +477,7 @@ "noOptionsMessage-no-datasources-found": "Keine Datenquellen gefunden" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Link kopieren", "duplicate": "Duplikat", "export": "Exportieren", @@ -2624,7 +2625,9 @@ "placeholder-search-input": "Nach Name suchen oder Filterabfrage eingeben …" }, "grafana-folder": "Grafana-Ordner", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Zustand", "label": { "hide": "Ausblenden", @@ -2635,6 +2638,7 @@ "placeholder-all-data-sources": "Alle Datenquellen", "placeholder-contact-point": "Kontaktpunkt auswählen", "placeholder-data-sources": "Datenquellen auswählen", + "placeholder-group-search": "", "placeholder-labels": "Labels auswählen", "plugin-rules": "Plugin-Regeln", "rule-source": { @@ -3473,6 +3477,14 @@ "label-negative-y": "Negativ Y" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Leerzeichen von der rechten Seite erforderlich", "gaps-options": { @@ -3517,6 +3529,10 @@ }, "name-show-unfilled-area": "Ungefüllten Bereich anzeigen", "name-value-display": "Wertanzeige", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Ausgeblendet", "label-text-color": "Textfarbe", @@ -4169,6 +4185,7 @@ "clear": "Löschen", "collapse": "Einklappen", "disabled": "", + "discard": "", "edit": "Bearbeiten", "help": "Hilfe", "loading": "Wird geladen ...", @@ -4786,7 +4803,6 @@ "variable": "{{type}} Variable", "variable-set": "Variable" }, - "open": "Optionsfenster öffnen", "row": { "header": { "hide": "Ausblenden", @@ -5137,6 +5153,7 @@ "title-matched_other": "Übereinstimmend {{count}}/{{totalCount}} Optionen" }, "outline": { + "pane-header": "", "repeated-item": "Wiederholen", "tree-item": { "empty": "(leer)", @@ -5348,6 +5365,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Konfiguration wird geladen" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Laden und Initialisieren des Dashboards", "title-not-found": "Das Panel mit der ID {{panelId}} wurde nicht gefunden" @@ -5451,11 +5494,8 @@ "tooltip": "Dieses Dashboard wurde als schreibgeschützt markiert" }, "export": { - "arrow": "Exportieren", - "title": "Exportieren", "tooltip": { - "as-code": "Als Code exportieren", - "json": "Als JSON exportieren" + "as-code": "Als Code exportieren" } }, "more-save-options": "Weitere Speicheroptionen", @@ -5507,6 +5547,9 @@ "save-library-panel": "Bibliotheks-Panel speichern", "settings": "Dashboard-Einstellungen", "share-button": "Teilen", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5836,9 +5879,6 @@ "name-values-separated-comma": "Werte werden durch Komma getrennt", "selection-options": "Auswahloptionen" }, - "dashboard-edit-pane-renderer": { - "outline": "Rahmen" - }, "dashboard-link-form": { "back-to-list": "Zurück zur Liste", "label-icon": "Symbol", @@ -6182,8 +6222,8 @@ "placeholder-search-for": "Suche nach ...", "radio-options": { "label": { - "suggestions": "Vorschläge", - "visualizations": "Visualisierungen" + "all-visualizations": "", + "suggestions": "Vorschläge" } }, "title-close": "Schließen" @@ -7821,6 +7861,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8118,14 +8159,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Zurückgehen" - } - }, - "select-group": "Gruppe auswählen" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11050,8 +11083,8 @@ "tooltip-delete": "Löschen" }, "visualization-suggestions": { - "based-on-current-data": "Basierend auf aktuellen Daten", - "no-results-matched-your-query": "Keine passenden Ergebnisse zu Ihrer Abfrage" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Es konnte nichts gefunden werden, was Ihrer Abfrage entspricht" @@ -11083,6 +11116,10 @@ "pie-chart-type-options": { "label-donut": "Donut", "label-pie": "Kreis" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11666,13 +11703,6 @@ "saving": "Einsparen", "title-error-loading-file": "Fehler beim Laden der Datei" }, - "files-view": { - "columns": { - "history": "Verlauf", - "view": "Anzeigen" - }, - "placeholder-search": "Suche" - }, "finish-step": { "description-enable-previews": "Fügt eine Bildvorschau der Dashboard-Änderungen bei Pull-Requests hinzu. Bilder Ihrer Grafana-Dashboards werden in Ihrem Git-Repository bereitgestellt und sind für jede Person mit Repository-Zugriff sichtbar.", "description-generate-dashboard-previews": "Erstellen Sie Vorschau-Links für Pull-Requests", @@ -11883,9 +11913,6 @@ "source-code": "Quellcode" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Schreibgeschützt", "settings": "Einstellungen", "view": "Anzeigen" @@ -11922,14 +11949,6 @@ "webhook-last-event": "Letztes Ereignis:", "webhook-url": "Webhook anzeigen" }, - "repository-resources": { - "columns": { - "history": "Verlauf", - "view-dashboard": "Anzeigen", - "view-folder": "Anzeigen" - }, - "placeholder-search": "Suche" - }, "repository-status-page": { "back-to-repositories": "Zurück zu den Repositorys", "cleaning-up-resources": "Bereinigen von Repository-Ressourcen", @@ -11937,12 +11956,10 @@ "not-found": "nicht gefunden", "not-found-message": "Repository nicht gefunden", "repository-config-exists-configuration": "Achten Sie darauf, dass die Repository-config in der Konfigurationsdatei vorhanden ist.", - "tab-files": "Dateien", - "tab-files-title": "Die Raw-Datei-Liste aus dem Repository", "tab-overview": "Übersicht", "tab-overview-title": "Repository-Übersicht", "tab-resources": "Ressourcen", - "tab-resources-title": "In der Grafana-Datenbank gespeicherte Ressourcen", + "tab-resources-title": "", "title": "Repository-Status", "title-legacy-storage": "Veralteter Speicher", "title-queued-for-deletion": "Zum Löschen in die Warteschlange gestellt" @@ -11965,6 +11982,17 @@ "pure-git": "Pure Git", "pure-git-description": "Mit beliebigem Git-Repository verbinden" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Basis", "dashboard-preview": "Dashboard-Vorschau", @@ -13031,6 +13059,12 @@ "label-same-as-value": "Identisch mit Wert", "label-standard": "Standard" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Auto", "label-center": "Mitte" @@ -13368,7 +13402,8 @@ "forwards-time-aria-label": "Zeitbereich nach vorne verschieben", "to": "bis", "zoom-out-button": "Zeitbereich verkleinern", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Zeitbereich anwenden", @@ -13470,6 +13505,16 @@ "label-threshold": "Schwellenwert" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Zeitzone hinzufügen", "tooltip-remove-timezone": "Zeitzone entfernen" diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e8d4b15d05f..d9efaadc4d6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -477,6 +477,7 @@ "noOptionsMessage-no-datasources-found": "No datasources found" }, "alert-menu": { + "analyze-rule": "Analyze rule", "copy-link": "Copy link", "duplicate": "Duplicate", "export": "Export", @@ -3476,6 +3477,14 @@ "label-negative-y": "Negative Y" } }, + "suggestions": { + "horizontal": "Horizontal bar chart", + "hz-stacked": "Horizontal bar chart - stacked", + "hz-stacked-percent": "Horizontal bar chart - stacked by percentage", + "vert-stacked": "Bar chart - stacked", + "vert-stacked-percent": "Bar chart - stacked by percentage", + "vertical": "Bar chart" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Require space from the right side", "gaps-options": { @@ -3520,6 +3529,10 @@ }, "name-show-unfilled-area": "Show unfilled area", "name-value-display": "Value display", + "suggestions": { + "basic": "Bar gauge", + "lcd": "Bar gauge - LCD" + }, "value-display-options": { "label-hidden": "Hidden", "label-text-color": "Text color", @@ -4172,6 +4185,7 @@ "clear": "Clear", "collapse": "Collapse", "disabled": "Disabled", + "discard": "Discard", "edit": "Edit", "help": "Help", "loading": "Loading...", @@ -4789,7 +4803,6 @@ "variable": "{{type}} variable", "variable-set": "Variables" }, - "open": "Open options pane", "row": { "header": { "hide": "Hide", @@ -5140,6 +5153,7 @@ "title-matched_other": "Matched {{count}}/{{totalCount}} options" }, "outline": { + "pane-header": "Content outline", "repeated-item": "Repeat", "tree-item": { "empty": "(empty)", @@ -5351,10 +5365,40 @@ "share-public-dashboard-loader": { "loading-configuration": "Loading configuration" }, + "sidebar": { + "dashboard-options": { + "title": "Options", + "tooltip": "Dashboard options" + }, + "edit-schema": { + "title": "Code", + "tooltip": "Edit as code" + }, + "export": { + "title": "Export", + "unsaved-modal": { + "text": "You have unsaved changes to this dashboard. You need to save them before you can share it.", + "title": "Save changes to dashboard?" + } + }, + "outline": { + "title": "Outline", + "tooltip": "Content outline" + }, + "redo": "Redo", + "snapshot": { + "tooltip": "Open original dashboard" + }, + "undo": "Undo" + }, "solo-panel": { "loading-initializing-dashboard": "Loading & initializing dashboard", "title-not-found": "Panel with id {{panelId}} not found" }, + "source-link": { + "title": "Source ({{provider}})", + "tooltip": "View source file in repository" + }, "sub-menu-un-connected": { "aria-label-template-variables": "Template variables" }, @@ -5454,11 +5498,8 @@ "tooltip": "This dashboard was marked as read only" }, "export": { - "arrow": "Export", - "title": "Export", "tooltip": { - "as-code": "Export as code", - "json": "Export as JSON" + "as-code": "Export as code" } }, "more-save-options": "More save options", @@ -5510,6 +5551,9 @@ "save-library-panel": "Save library panel", "settings": "Dashboard settings", "share-button": "Share", + "snapshot": { + "title": "Source" + }, "star-add-error": "Failed to add to starred", "star-added": "Added to starred", "star-remove-error": "Failed to remove from starred", @@ -5839,9 +5883,6 @@ "name-values-separated-comma": "Values separated by comma", "selection-options": "Selection options" }, - "dashboard-edit-pane-renderer": { - "outline": "Outline" - }, "dashboard-link-form": { "back-to-list": "Back to list", "label-icon": "Icon", @@ -7824,6 +7865,7 @@ "suggestions": { "arc": "Gauge", "circular": "Circular gauge", + "no-thresholds": "Gauge - no thresholds", "style": { "circular": "Glowing", "simple": "Simple" @@ -8121,13 +8163,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Go back" - } - } - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11059,6 +11094,12 @@ "could-anything-matching-query": "Could not find anything matching your query" } }, + "panel-group-by": { + "button": "Group by", + "loading": "Loading options", + "no-options": "No options found", + "search-placeholder": "Search" + }, "panel-type-filter": { "clear-button": "Clear types", "select-aria-label": "Panel type filter", @@ -11085,6 +11126,10 @@ "pie-chart-type-options": { "label-donut": "Donut", "label-pie": "Pie" + }, + "suggestions": { + "donut": "Donut chart", + "pie": "Pie chart" } }, "playlist": { @@ -11668,13 +11713,6 @@ "saving": "Saving", "title-error-loading-file": "Error loading file" }, - "files-view": { - "columns": { - "history": "History", - "view": "View" - }, - "placeholder-search": "Search" - }, "finish-step": { "description-enable-previews": "Adds an image preview of dashboard changes in pull requests. Images of your Grafana dashboards will be shared in your Git repository and visible to anyone with repository access.", "description-generate-dashboard-previews": "Create preview links for pull requests", @@ -11885,9 +11923,6 @@ "source-code": "Source code" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Read only", "settings": "Settings", "view": "View" @@ -11924,14 +11959,6 @@ "webhook-last-event": "Last Event:", "webhook-url": "View Webhook" }, - "repository-resources": { - "columns": { - "history": "History", - "view-dashboard": "View", - "view-folder": "View" - }, - "placeholder-search": "Search" - }, "repository-status-page": { "back-to-repositories": "Back to repositories", "cleaning-up-resources": "Cleaning up repository resources", @@ -11939,12 +11966,10 @@ "not-found": "not found", "not-found-message": "Repository not found", "repository-config-exists-configuration": "Make sure the repository config exists in the configuration file.", - "tab-files": "Files", - "tab-files-title": "The raw file list from the repository", "tab-overview": "Overview", "tab-overview-title": "Repository overview", "tab-resources": "Resources", - "tab-resources-title": "Resources saved in grafana database", + "tab-resources-title": "Repository files and resources", "title": "Repository Status", "title-legacy-storage": "Legacy Storage", "title-queued-for-deletion": "Queued for deletion" @@ -11967,6 +11992,17 @@ "pure-git": "Pure Git", "pure-git-description": "Connect to any Git repository" }, + "resource-tree": { + "header-hash": "Hash", + "header-status": "Status", + "header-title": "Title", + "header-type": "Type", + "search-placeholder": "Search by path or title", + "source": "Source", + "status-pending": "Pending", + "status-synced": "Synced", + "view": "View" + }, "resource-view": { "base": "Base", "dashboard-preview": "Dashboard Preview", @@ -13033,6 +13069,12 @@ "label-same-as-value": "Same as Value", "label-standard": "Standard" }, + "suggestions": { + "stat-color-background": "Stat - color background", + "stat-discrete-values": "Stat - discrete values", + "stat-discrete-values-color-background": "Stat - discrete values - color background", + "stat-single-string": "Stat - single string" + }, "text-alignment-options": { "label-auto": "Auto", "label-center": "Center" @@ -13241,6 +13283,7 @@ "create-team": { "create": "Create", "description-email": "This is optional and is primarily used for allowing custom team avatars", + "failed-to-create": "Failed to create team", "label-email": "Email", "label-name": "Name", "label-role": "Role" @@ -13272,6 +13315,7 @@ "title-edit-team": "Edit team", "tooltip-edit-team": "Edit team" }, + "loading-teams": "Loading teams...", "new-team": "New Team", "placeholder-search-teams": "Search teams" }, @@ -13370,7 +13414,8 @@ "forwards-time-aria-label": "Move time range forwards", "to": "to", "zoom-out-button": "Zoom out time range", - "zoom-out-tooltip": "Time range zoom out
CTRL+Z" + "zoom-out-tooltip": "Time range zoom out
CTRL+Z", + "zoom-out-tooltip-new": "Time range zoom out
t -" }, "time-range": { "apply": "Apply time range", @@ -13472,6 +13517,16 @@ "label-threshold": "Threshold" } }, + "suggestions": { + "area": "Area chart", + "area-stacked": "Area chart - stacked", + "area-stacked-percentage": "Area chart - stacked by percentage", + "bar": "Bar chart", + "bar-stacked": "Bar chart - stacked", + "bar-stacked-percent": "Bar chart - stacked by percentage", + "line": "Line chart", + "line-smooth": "Line chart - smooth" + }, "timezones-editor": { "tooltip-add-timezone": "Add timezone", "tooltip-remove-timezone": "Remove timezone" diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 653d7942c02..db55d7dbe2b 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -477,6 +477,7 @@ "noOptionsMessage-no-datasources-found": "No se han encontrado fuentes de datos" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Copiar enlace", "duplicate": "Duplicar", "export": "Exportar", @@ -2624,7 +2625,9 @@ "placeholder-search-input": "Buscar por nombre o introducir consulta de filtro..." }, "grafana-folder": "Carpeta de Grafana", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Salud", "label": { "hide": "Ocultar", @@ -2635,6 +2638,7 @@ "placeholder-all-data-sources": "Todas las fuentes de datos", "placeholder-contact-point": "Seleccionar punto de contacto", "placeholder-data-sources": "Seleccionar fuentes de datos", + "placeholder-group-search": "", "placeholder-labels": "Seleccionar etiquetas", "plugin-rules": "Reglas del plugin", "rule-source": { @@ -3473,6 +3477,14 @@ "label-negative-y": "Y negativa" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Requerir espacio desde el lado derecho", "gaps-options": { @@ -3517,6 +3529,10 @@ }, "name-show-unfilled-area": "Mostrar área sin rellenar", "name-value-display": "Visualización del valor", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Oculto", "label-text-color": "Color de texto", @@ -4169,6 +4185,7 @@ "clear": "Borrar", "collapse": "Contraer", "disabled": "", + "discard": "", "edit": "Editar", "help": "Ayuda", "loading": "Cargando...", @@ -4786,7 +4803,6 @@ "variable": "{{type}} variable", "variable-set": "Variables" }, - "open": "Abrir panel de opciones", "row": { "header": { "hide": "Ocultar", @@ -5137,6 +5153,7 @@ "title-matched_other": "{{count}} coincidencias/{{totalCount}} opciones" }, "outline": { + "pane-header": "", "repeated-item": "Repetir", "tree-item": { "empty": "(vacío)", @@ -5348,6 +5365,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Cargando configuración" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Cargando e iniciando el dashboard", "title-not-found": "Panel con ID {{panelId}} no encontrado" @@ -5451,11 +5494,8 @@ "tooltip": "Este dashboard se ha marcado como de solo lectura" }, "export": { - "arrow": "Exportar", - "title": "Exportar", "tooltip": { - "as-code": "Exportar como código", - "json": "Exportar como JSON" + "as-code": "Exportar como código" } }, "more-save-options": "Más opciones de guardado", @@ -5507,6 +5547,9 @@ "save-library-panel": "Guardar panel de biblioteca", "settings": "Ajustes del panel de control", "share-button": "Compartir", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5836,9 +5879,6 @@ "name-values-separated-comma": "Valores separados por comas", "selection-options": "Opciones de selección" }, - "dashboard-edit-pane-renderer": { - "outline": "Esquema" - }, "dashboard-link-form": { "back-to-list": "Regresar a la lista", "label-icon": "Icono", @@ -6182,8 +6222,8 @@ "placeholder-search-for": "Buscar...", "radio-options": { "label": { - "suggestions": "Sugerencias", - "visualizations": "Visualizaciones" + "all-visualizations": "", + "suggestions": "Sugerencias" } }, "title-close": "Cerrar" @@ -7821,6 +7861,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8118,14 +8159,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Volver" - } - }, - "select-group": "Seleccionar grupo" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11050,8 +11083,8 @@ "tooltip-delete": "Eliminar" }, "visualization-suggestions": { - "based-on-current-data": "Basado en datos actuales", - "no-results-matched-your-query": "No hay resultados que coincidan con tu consulta" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "No se ha encontrado nada que coincida con tu consulta" @@ -11083,6 +11116,10 @@ "pie-chart-type-options": { "label-donut": "Anillo", "label-pie": "Círculo" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11666,13 +11703,6 @@ "saving": "Guardando", "title-error-loading-file": "Error al cargar el archivo" }, - "files-view": { - "columns": { - "history": "Historial", - "view": "Vista" - }, - "placeholder-search": "Buscar" - }, "finish-step": { "description-enable-previews": "Añade una vista previa de la imagen de los cambios del dashboard en las solicitudes de extracción. Las imágenes de tus paneles de Grafana se compartirán en tu repositorio Git y serán visibles para cualquier persona con acceso al repositorio.", "description-generate-dashboard-previews": "Crear enlaces de vista previa para las solicitudes de extracción", @@ -11883,9 +11913,6 @@ "source-code": "Código fuente" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Solo lectura", "settings": "Configuración", "view": "Vista" @@ -11922,14 +11949,6 @@ "webhook-last-event": "Último evento:", "webhook-url": "Ver webhook" }, - "repository-resources": { - "columns": { - "history": "Historial", - "view-dashboard": "Vista", - "view-folder": "Vista" - }, - "placeholder-search": "Buscar" - }, "repository-status-page": { "back-to-repositories": "Volver a los repositorios", "cleaning-up-resources": "Limpiando los recursos del repositorio", @@ -11937,12 +11956,10 @@ "not-found": "no ha sido encontrado", "not-found-message": "Repositorio no encontrado", "repository-config-exists-configuration": "Asegúrate de que la configuración del repositorio exista en el archivo de configuración.", - "tab-files": "Archivos", - "tab-files-title": "La lista de archivos sin procesar del repositorio", "tab-overview": "Resumen", "tab-overview-title": "Resumen del repositorio", "tab-resources": "Recursos", - "tab-resources-title": "Recursos guardados en la base de datos de Grafana", + "tab-resources-title": "", "title": "Estado del repositorio", "title-legacy-storage": "Almacenamiento heredado", "title-queued-for-deletion": "En cola para su eliminación" @@ -11965,6 +11982,17 @@ "pure-git": "Git puro", "pure-git-description": "Conectar a cualquier repositorio Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Base", "dashboard-preview": "Vista previa del dashboard", @@ -13031,6 +13059,12 @@ "label-same-as-value": "Igual que el valor", "label-standard": "Estándar" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Auto", "label-center": "Centro" @@ -13368,7 +13402,8 @@ "forwards-time-aria-label": "Adelantar el intervalo de tiempo", "to": "hasta", "zoom-out-button": "Reducir el intervalo de tiempo", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Aplicar intervalo de tiempo", @@ -13470,6 +13505,16 @@ "label-threshold": "Umbral" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Añadir zona horaria", "tooltip-remove-timezone": "Eliminar zona horaria" diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index fecfc76d3ea..e0b0cd18363 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -477,6 +477,7 @@ "noOptionsMessage-no-datasources-found": "Aucune source de données trouvée" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Copier le lien", "duplicate": "Dupliquer", "export": "Exporter", @@ -2624,7 +2625,9 @@ "placeholder-search-input": "Rechercher par nom ou saisir une requête de filtre..." }, "grafana-folder": "Dossier Grafana", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Santé", "label": { "hide": "Masquer", @@ -2635,6 +2638,7 @@ "placeholder-all-data-sources": "Toutes les sources de données", "placeholder-contact-point": "Sélectionner un point de contact", "placeholder-data-sources": "Sélectionner des sources de données", + "placeholder-group-search": "", "placeholder-labels": "Sélectionner des étiquettes", "plugin-rules": "Règles de plugin", "rule-source": { @@ -3473,6 +3477,14 @@ "label-negative-y": "Axe Y négatif" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Exiger un espace du côté droit", "gaps-options": { @@ -3517,6 +3529,10 @@ }, "name-show-unfilled-area": "Afficher la zone non remplie", "name-value-display": "Affichage de la valeur", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Masqué", "label-text-color": "Couleur du texte", @@ -4169,6 +4185,7 @@ "clear": "Effacer", "collapse": "Réduire", "disabled": "", + "discard": "", "edit": "Modifier", "help": "Aide", "loading": "Chargement en cours...", @@ -4786,7 +4803,6 @@ "variable": "Variable {{type}}", "variable-set": "Variables" }, - "open": "Ouvrir le volet d'options", "row": { "header": { "hide": "Masquer", @@ -5137,6 +5153,7 @@ "title-matched_other": "{{count}} option(s)/{{totalCount}} mise(s) en correspondance" }, "outline": { + "pane-header": "", "repeated-item": "Répéter", "tree-item": { "empty": "(vide)", @@ -5348,6 +5365,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Chargement de la configuration" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Chargement et initialisation du tableau de bord", "title-not-found": "Panneau avec l’ID {{panelId}} introuvable" @@ -5451,11 +5494,8 @@ "tooltip": "Ce tableau de bord a été marqué comme étant en lecture seule" }, "export": { - "arrow": "Exporter", - "title": "Exporter", "tooltip": { - "as-code": "Exporter en tant que code", - "json": "Exporter en tant que JSON" + "as-code": "Exporter en tant que code" } }, "more-save-options": "Plus d’options d’enregistrement", @@ -5507,6 +5547,9 @@ "save-library-panel": "Enregistrer le panneau de la bibliothèque", "settings": "Paramètres du tableau de bord", "share-button": "Partager", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5836,9 +5879,6 @@ "name-values-separated-comma": "Valeurs séparées par des virgules", "selection-options": "Options de sélection" }, - "dashboard-edit-pane-renderer": { - "outline": "Présentation" - }, "dashboard-link-form": { "back-to-list": "Retour à la liste", "label-icon": "Icône", @@ -6182,8 +6222,8 @@ "placeholder-search-for": "Rechercher…", "radio-options": { "label": { - "suggestions": "Suggestions", - "visualizations": "Visualisations" + "all-visualizations": "", + "suggestions": "Suggestions" } }, "title-close": "Fermer" @@ -7821,6 +7861,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8118,14 +8159,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Retour" - } - }, - "select-group": "Sélectionner un groupe" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11050,8 +11083,8 @@ "tooltip-delete": "Supprimer" }, "visualization-suggestions": { - "based-on-current-data": "Sur la base des données actuelles", - "no-results-matched-your-query": "Aucun résultat ne correspond à votre requête" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Impossible de trouver quoi que ce soit correspondant à votre requête" @@ -11083,6 +11116,10 @@ "pie-chart-type-options": { "label-donut": "Anneau", "label-pie": "Circulaire" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11666,13 +11703,6 @@ "saving": "Enregistrement en cours", "title-error-loading-file": "Erreur lors du chargement du fichier" }, - "files-view": { - "columns": { - "history": "Historique", - "view": "Afficher" - }, - "placeholder-search": "Rechercher" - }, "finish-step": { "description-enable-previews": "Ajoute un aperçu des images des modifications apportées au tableau de bord dans les demandes de fusion. Les images de vos tableaux de bord Grafana seront partagées dans votre référentiel Git et visibles par toute personne ayant accès au référentiel.", "description-generate-dashboard-previews": "Créer des liens de prévisualisation pour les demandes de tirage", @@ -11883,9 +11913,6 @@ "source-code": "Code source" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Lecture seule", "settings": "Paramètres", "view": "Afficher" @@ -11922,14 +11949,6 @@ "webhook-last-event": "Dernier événement :", "webhook-url": "Voir le webhook" }, - "repository-resources": { - "columns": { - "history": "Historique", - "view-dashboard": "Afficher", - "view-folder": "Afficher" - }, - "placeholder-search": "Rechercher" - }, "repository-status-page": { "back-to-repositories": "Retour aux référentiels", "cleaning-up-resources": "Nettoyage des ressources du référentiel", @@ -11937,12 +11956,10 @@ "not-found": "introuvable", "not-found-message": "Référentiel introuvable", "repository-config-exists-configuration": "Assurez-vous que la configuration du référentiel existe dans le fichier de configuration.", - "tab-files": "Fichiers", - "tab-files-title": "La liste des fichiers bruts du référentiel", "tab-overview": "Vue d’ensemble", "tab-overview-title": "Vue d’ensemble du référentiel", "tab-resources": "Ressources", - "tab-resources-title": "Ressources enregistrées dans la base de données Grafana", + "tab-resources-title": "", "title": "Statut du référentiel", "title-legacy-storage": "Stockage hérité", "title-queued-for-deletion": "Mis en attente pour suppression" @@ -11965,6 +11982,17 @@ "pure-git": "Pure Git", "pure-git-description": "Se connecter à un quelconque dépôt Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Base", "dashboard-preview": "Aperçu du tableau de bord", @@ -13031,6 +13059,12 @@ "label-same-as-value": "Identique à la valeur", "label-standard": "Standard" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Auto", "label-center": "Centré" @@ -13368,7 +13402,8 @@ "forwards-time-aria-label": "Avancer la plage de temps", "to": "à", "zoom-out-button": "Dézoomer la plage de temps", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Appliquer la plage temporelle", @@ -13470,6 +13505,16 @@ "label-threshold": "Seuil" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Ajouter le fuseau horaire", "tooltip-remove-timezone": "Supprimer le fuseau horaire" diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index e691d36eff2..3ff7e30485b 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -477,6 +477,7 @@ "noOptionsMessage-no-datasources-found": "Nem található adatforrás" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Hivatkozás másolása", "duplicate": "Duplikálás", "export": "Exportálás", @@ -2624,7 +2625,9 @@ "placeholder-search-input": "Keresés név alapján, vagy szűrőlekérdezés megadása..." }, "grafana-folder": "Grafana mappa", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Állapot", "label": { "hide": "Elrejtés", @@ -2635,6 +2638,7 @@ "placeholder-all-data-sources": "Összes adatforrás", "placeholder-contact-point": "Kapcsolattartási pont kiválasztása", "placeholder-data-sources": "Adatforrások kiválasztása", + "placeholder-group-search": "", "placeholder-labels": "Címkék kiválasztása", "plugin-rules": "Bővítményszabályok", "rule-source": { @@ -3473,6 +3477,14 @@ "label-negative-y": "Negatív Y" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Hely szükséges a jobb oldalról", "gaps-options": { @@ -3517,6 +3529,10 @@ }, "name-show-unfilled-area": "Kitöltetlen terület megjelenítése", "name-value-display": "Érték megjelenítése", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Rejtett", "label-text-color": "Szöveg színe", @@ -4169,6 +4185,7 @@ "clear": "Törlés", "collapse": "Összecsukás", "disabled": "", + "discard": "", "edit": "Szerkesztés", "help": "Súgó", "loading": "Betöltés...", @@ -4786,7 +4803,6 @@ "variable": "{{type}} változó", "variable-set": "Változók" }, - "open": "Beállítások ablaktábla megnyitása", "row": { "header": { "hide": "Elrejtés", @@ -5137,6 +5153,7 @@ "title-matched_other": "{{totalCount}}/{{count}} egyező lehetőség" }, "outline": { + "pane-header": "", "repeated-item": "Ismétlés", "tree-item": { "empty": "(üres)", @@ -5348,6 +5365,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Konfiguráció betöltése" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Irányítópult betöltése és inicializálása", "title-not-found": "A(z) {{panelId}} azonosítójú panel nem található" @@ -5451,11 +5494,8 @@ "tooltip": "Ez az irányítópult csak olvashatóként van megjelölve" }, "export": { - "arrow": "Exportálás", - "title": "Exportálás", "tooltip": { - "as-code": "Exportálás kódként", - "json": "Exportálás JSON-fájlként" + "as-code": "Exportálás kódként" } }, "more-save-options": "További mentési beállítások", @@ -5507,6 +5547,9 @@ "save-library-panel": "Könyvtárpanel mentése", "settings": "Irányítópult beállításai", "share-button": "Megosztás", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5836,9 +5879,6 @@ "name-values-separated-comma": "Értékek vesszővel elválasztva", "selection-options": "Kijelölés beállításai" }, - "dashboard-edit-pane-renderer": { - "outline": "Körvonal" - }, "dashboard-link-form": { "back-to-list": "Vissza a listához", "label-icon": "Ikon", @@ -6182,8 +6222,8 @@ "placeholder-search-for": "Keresés a következőre…", "radio-options": { "label": { - "suggestions": "Javaslatok", - "visualizations": "Vizualizációk" + "all-visualizations": "", + "suggestions": "Javaslatok" } }, "title-close": "Bezárás" @@ -7821,6 +7861,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8118,14 +8159,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Visszalépés" - } - }, - "select-group": "Csoport kijelölése" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11050,8 +11083,8 @@ "tooltip-delete": "Törlés" }, "visualization-suggestions": { - "based-on-current-data": "Aktuális adatok alapján", - "no-results-matched-your-query": "Nincs találat a lekérdezésre" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Nincs találat a lekérdezésre" @@ -11083,6 +11116,10 @@ "pie-chart-type-options": { "label-donut": "Fánkdiagram", "label-pie": "Kördiagram" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11666,13 +11703,6 @@ "saving": "Mentés", "title-error-loading-file": "Hiba a fájl betöltésekor" }, - "files-view": { - "columns": { - "history": "Előzmények", - "view": "Nézet" - }, - "placeholder-search": "Keresés" - }, "finish-step": { "description-enable-previews": "Hozzáadja az irányítópult változásainak előnézetét a lekérésekben. A Grafana-irányítópultok képei meg lesznek osztva a Git-tárban, és bárki számára láthatóak lesznek, aki hozzáféréssel rendelkezik az adattárhoz.", "description-generate-dashboard-previews": "Előnézeti hivatkozások létrehozása az összefésülési kérelmekhez", @@ -11883,9 +11913,6 @@ "source-code": "Forráskód" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webkapocs" - }, "read-only-badge": "Csak olvasható", "settings": "Beállítások", "view": "Nézet" @@ -11922,14 +11949,6 @@ "webhook-last-event": "Legutóbbi esemény:", "webhook-url": "Webkapocs megtekintése" }, - "repository-resources": { - "columns": { - "history": "Előzmények", - "view-dashboard": "Nézet", - "view-folder": "Nézet" - }, - "placeholder-search": "Keresés" - }, "repository-status-page": { "back-to-repositories": "Vissza az adattárakhoz", "cleaning-up-resources": "Adattári erőforrások tisztítása", @@ -11937,12 +11956,10 @@ "not-found": "nem található", "not-found-message": "Nem található adattár", "repository-config-exists-configuration": "Győződjön meg arról, hogy a tároló konfigurációja létezik a konfigurációs fájlban.", - "tab-files": "Fájlok", - "tab-files-title": "Nyers fájllista az adattárból", "tab-overview": "Áttekintés", "tab-overview-title": "Adattár áttekintése", "tab-resources": "Erőforrások", - "tab-resources-title": "Grafana-adatbázisba mentett erőforrások", + "tab-resources-title": "", "title": "Adattár állapota", "title-legacy-storage": "Örökölt tárolás", "title-queued-for-deletion": "Törlésre vár" @@ -11965,6 +11982,17 @@ "pure-git": "Pure Git", "pure-git-description": "Csatlakozás bármely Git-adattárhoz" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Alap", "dashboard-preview": "Irányítópult előnézete", @@ -13031,6 +13059,12 @@ "label-same-as-value": "Értékkel megegyező", "label-standard": "Standard" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Automatikus", "label-center": "Középre" @@ -13368,7 +13402,8 @@ "forwards-time-aria-label": "Időtartomány mozgatása előre", "to": "vége", "zoom-out-button": "Időtartomány kicsinyítése", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Időtartomány alkalmazása", @@ -13470,6 +13505,16 @@ "label-threshold": "Küszöb" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Időzóna hozzáadása", "tooltip-remove-timezone": "Időzóna eltávolítása" diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index ff5ecf95800..a6708a09472 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -474,6 +474,7 @@ "noOptionsMessage-no-datasources-found": "Tidak ada sumber data yang ditemukan" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Salin tautan", "duplicate": "Duplikasikan", "export": "Ekspor", @@ -2612,7 +2613,9 @@ "placeholder-search-input": "Cari berdasarkan nama atau masukkan kueri filter..." }, "grafana-folder": "Folder Grafana", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Kesehatan", "label": { "hide": "Sembunyikan", @@ -2623,6 +2626,7 @@ "placeholder-all-data-sources": "Semua sumber data", "placeholder-contact-point": "Pilih titik kontak", "placeholder-data-sources": "Pilih sumber data", + "placeholder-group-search": "", "placeholder-labels": "Pilih label", "plugin-rules": "Aturan plugin", "rule-source": { @@ -3461,6 +3465,14 @@ "label-negative-y": "Y Negatif" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Membutuhkan ruang dari sisi kanan", "gaps-options": { @@ -3505,6 +3517,10 @@ }, "name-show-unfilled-area": "Tampilkan area yang tidak terisi", "name-value-display": "Tampilan nilai", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Tersembunyi", "label-text-color": "Warna teks", @@ -4149,6 +4165,7 @@ "clear": "Hapus", "collapse": "Ciutkan", "disabled": "", + "discard": "", "edit": "Edit", "help": "Bantuan", "loading": "Memuat...", @@ -4766,7 +4783,6 @@ "variable": "variabel {{type}}", "variable-set": "Variabel" }, - "open": "Buka panel opsi", "row": { "header": { "hide": "Sembunyikan", @@ -5116,6 +5132,7 @@ "title-matched_other": "Cocok {{count}}/{{totalCount}} opsi" }, "outline": { + "pane-header": "", "repeated-item": "Ulangi", "tree-item": { "empty": "(kosong)", @@ -5327,6 +5344,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Memuat konfigurasi" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Memuat & menginisialisasi dasbor", "title-not-found": "Panel dengan id {{panelId}} tidak ditemukan" @@ -5430,11 +5473,8 @@ "tooltip": "Dasbor ini ditandai sebagai hanya baca" }, "export": { - "arrow": "Ekspor", - "title": "Ekspor", "tooltip": { - "as-code": "Ekspor sebagai kode", - "json": "Ekspor sebagai JSON" + "as-code": "Ekspor sebagai kode" } }, "more-save-options": "Opsi simpan lainnya", @@ -5486,6 +5526,9 @@ "save-library-panel": "Simpan panel pustaka", "settings": "Pengaturan dasbor", "share-button": "Bagikan", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5814,9 +5857,6 @@ "name-values-separated-comma": "Nilai dipisahkan dengan koma", "selection-options": "Opsi pemilihan" }, - "dashboard-edit-pane-renderer": { - "outline": "Garis besar" - }, "dashboard-link-form": { "back-to-list": "Kembali ke daftar", "label-icon": "Ikon", @@ -6159,8 +6199,8 @@ "placeholder-search-for": "Cari...", "radio-options": { "label": { - "suggestions": "Saran", - "visualizations": "Visualisasi" + "all-visualizations": "", + "suggestions": "Saran" } }, "title-close": "Tutup" @@ -7795,6 +7835,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8092,14 +8133,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Kembali" - } - }, - "select-group": "Pilih grup" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11003,8 +11036,8 @@ "tooltip-delete": "Hapus" }, "visualization-suggestions": { - "based-on-current-data": "Berdasarkan data saat ini", - "no-results-matched-your-query": "Tidak ada hasil yang cocok dengan kueri Anda" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Tidak dapat menemukan sesuatu yang cocok dengan kueri Anda" @@ -11036,6 +11069,10 @@ "pie-chart-type-options": { "label-donut": "Donat", "label-pie": "Lingkaran" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11616,13 +11653,6 @@ "saving": "Menyimpan", "title-error-loading-file": "Kesalahan saat memuat file" }, - "files-view": { - "columns": { - "history": "Sejarah", - "view": "Lihat" - }, - "placeholder-search": "Cari" - }, "finish-step": { "description-enable-previews": "Menambahkan pratinjau gambar dari perubahan dasbor di permintaan pull. Gambar dasbor Grafana Anda akan dibagikan di repositori Git Anda dan dapat dilihat oleh siapa saja yang memiliki akses repositori.", "description-generate-dashboard-previews": "Buat tautan pratinjau untuk permintaan penarikan", @@ -11831,9 +11861,6 @@ "source-code": "Kode sumber" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Hanya baca", "settings": "Pengaturan", "view": "Lihat" @@ -11870,14 +11897,6 @@ "webhook-last-event": "Peristiwa Terakhir:", "webhook-url": "Lihat Webhook" }, - "repository-resources": { - "columns": { - "history": "Sejarah", - "view-dashboard": "Lihat", - "view-folder": "Lihat" - }, - "placeholder-search": "Cari" - }, "repository-status-page": { "back-to-repositories": "Kembali ke repositori", "cleaning-up-resources": "Membersihkan sumber daya repositori", @@ -11885,12 +11904,10 @@ "not-found": "tidak ditemukan", "not-found-message": "Repositori tidak ditemukan", "repository-config-exists-configuration": "Pastikan konfigurasi repositori ada dalam file konfigurasi.", - "tab-files": "File", - "tab-files-title": "Daftar file mentah dari repositori", "tab-overview": "Gambaran Umum", "tab-overview-title": "Gambaran umum repositori", "tab-resources": "Sumber Daya", - "tab-resources-title": "Sumber daya disimpan dalam database grafana", + "tab-resources-title": "", "title": "Status Repositori", "title-legacy-storage": "Penyimpanan Lama", "title-queued-for-deletion": "Diantrekan untuk dihapus" @@ -11913,6 +11930,17 @@ "pure-git": "Pure Git", "pure-git-description": "Hubungkan ke repositori Git mana pun" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Dasar", "dashboard-preview": "Pratinjau Dasbor", @@ -12975,6 +13003,12 @@ "label-same-as-value": "Sama dengan Nilai", "label-standard": "Standar" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Otomatis", "label-center": "Pusat" @@ -13311,7 +13345,8 @@ "forwards-time-aria-label": "Pindahkan rentang waktu ke depan", "to": "ke", "zoom-out-button": "Perkecil rentang waktu", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Gunakan rentang waktu", @@ -13413,6 +13448,16 @@ "label-threshold": "Ambang batas" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Tambahkan zona waktu", "tooltip-remove-timezone": "Hapus zona waktu" diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index d70278c2c40..e5fc8647a20 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -477,6 +477,7 @@ "noOptionsMessage-no-datasources-found": "Nessuna fonte dati trovata" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Copia link", "duplicate": "Duplica", "export": "Esporta", @@ -2624,7 +2625,9 @@ "placeholder-search-input": "Cerca per nome o inserisci una query di filtro..." }, "grafana-folder": "Cartella Grafana", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Stato", "label": { "hide": "Nascondi", @@ -2635,6 +2638,7 @@ "placeholder-all-data-sources": "Tutte le fonti dei dati", "placeholder-contact-point": "Seleziona punto di contatto", "placeholder-data-sources": "Seleziona origini dati", + "placeholder-group-search": "", "placeholder-labels": "Seleziona etichette", "plugin-rules": "Regole dei componenti aggiuntivi", "rule-source": { @@ -3473,6 +3477,14 @@ "label-negative-y": "Y negativo" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Richiedi spazio dal lato destro", "gaps-options": { @@ -3517,6 +3529,10 @@ }, "name-show-unfilled-area": "Mostra area non riempita", "name-value-display": "Visualizzazione del valore", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Nascosto", "label-text-color": "Colore del testo", @@ -4169,6 +4185,7 @@ "clear": "Cancella", "collapse": "Riduci", "disabled": "", + "discard": "", "edit": "Modifica", "help": "Guida", "loading": "Caricamento in corso...", @@ -4786,7 +4803,6 @@ "variable": "{{type}} variabile", "variable-set": "Variabili" }, - "open": "Apri il riquadro delle opzioni", "row": { "header": { "hide": "Nascondi", @@ -5137,6 +5153,7 @@ "title-matched_other": "{{count}} corrispondente/{{totalCount}} opzioni" }, "outline": { + "pane-header": "", "repeated-item": "Ripeti", "tree-item": { "empty": "(vuoto)", @@ -5348,6 +5365,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Caricamento configurazione" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Caricamento e inizializzazione della dashboard", "title-not-found": "Pannello con ID {{panelId}} non trovato" @@ -5451,11 +5494,8 @@ "tooltip": "Questa dashboard è stata contrassegnata come di sola lettura" }, "export": { - "arrow": "Esporta", - "title": "Esporta", "tooltip": { - "as-code": "Esporta come codice", - "json": "Esporta in formato JSON" + "as-code": "Esporta come codice" } }, "more-save-options": "Altre opzioni di salvataggio", @@ -5507,6 +5547,9 @@ "save-library-panel": "Salva pannello della libreria", "settings": "Impostazioni dashboard", "share-button": "Condividi", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5836,9 +5879,6 @@ "name-values-separated-comma": "Valori separati da virgola", "selection-options": "Seleziona opzioni" }, - "dashboard-edit-pane-renderer": { - "outline": "Schema" - }, "dashboard-link-form": { "back-to-list": "Torna all'elenco", "label-icon": "Icona", @@ -6182,8 +6222,8 @@ "placeholder-search-for": "Cerca...", "radio-options": { "label": { - "suggestions": "Suggerimenti", - "visualizations": "Visualizzazioni" + "all-visualizations": "", + "suggestions": "Suggerimenti" } }, "title-close": "Chiudi" @@ -7821,6 +7861,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8118,14 +8159,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Torna indietro" - } - }, - "select-group": "Seleziona gruppo" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11050,8 +11083,8 @@ "tooltip-delete": "Elimina" }, "visualization-suggestions": { - "based-on-current-data": "In base ai dati attuali", - "no-results-matched-your-query": "Nessun risultato corrisponde alla tua query" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Non è stato possibile trovare nulla che corrispondesse alla tua query" @@ -11083,6 +11116,10 @@ "pie-chart-type-options": { "label-donut": "A ciambella", "label-pie": "A torta" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11666,13 +11703,6 @@ "saving": "Salvataggio in corso", "title-error-loading-file": "Errore nel caricamento del file" }, - "files-view": { - "columns": { - "history": "Cronologia", - "view": "Visualizza" - }, - "placeholder-search": "Cerca" - }, "finish-step": { "description-enable-previews": "Aggiunge un'anteprima dell'immagine delle modifiche alla dashboard nelle richieste di pull. Le immagini delle dashboard Grafana verranno condivise nel repository Git e saranno visibili a chiunque abbia accesso al repository.", "description-generate-dashboard-previews": "Crea collegamenti di anteprima per le richieste di pull", @@ -11883,9 +11913,6 @@ "source-code": "Codice sorgente" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Solo lettura", "settings": "Impostazioni", "view": "Visualizza" @@ -11922,14 +11949,6 @@ "webhook-last-event": "Ultimo evento:", "webhook-url": "Visualizza webhook" }, - "repository-resources": { - "columns": { - "history": "Cronologia", - "view-dashboard": "Visualizza", - "view-folder": "Visualizza" - }, - "placeholder-search": "Cerca" - }, "repository-status-page": { "back-to-repositories": "Torna ai repository", "cleaning-up-resources": "Pulizia delle risorse del repository", @@ -11937,12 +11956,10 @@ "not-found": "non trovato", "not-found-message": "Repository non trovato", "repository-config-exists-configuration": "Assicurati che la configurazione del repository esista nel file di configurazione.", - "tab-files": "File", - "tab-files-title": "L'elenco dei file non elaborati dal repository", "tab-overview": "Panoramica", "tab-overview-title": "Panoramica del repository", "tab-resources": "Risorse", - "tab-resources-title": "Risorse salvate nel database di Grafana", + "tab-resources-title": "", "title": "Stato del repository", "title-legacy-storage": "Memoria esistente", "title-queued-for-deletion": "In coda per l'eliminazione" @@ -11965,6 +11982,17 @@ "pure-git": "Git puro", "pure-git-description": "Connetti a qualsiasi repository Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Base", "dashboard-preview": "Anteprima dashboard", @@ -13031,6 +13059,12 @@ "label-same-as-value": "Uguale al valore", "label-standard": "Standard" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Automatico", "label-center": "Centrale" @@ -13368,7 +13402,8 @@ "forwards-time-aria-label": "Usa un intervallo di tempo successivo", "to": "a", "zoom-out-button": "Riduci l'intervallo di tempo", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Applica intervallo di tempo", @@ -13470,6 +13505,16 @@ "label-threshold": "Soglia" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Aggiungi fuso orario", "tooltip-remove-timezone": "Rimuovi fuso orario" diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 9667ae7ea41..fa3f87eabf4 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -474,6 +474,7 @@ "noOptionsMessage-no-datasources-found": "データソースが見つかりません" }, "alert-menu": { + "analyze-rule": "", "copy-link": "リンクをコピー", "duplicate": "複製", "export": "エクスポート", @@ -2612,7 +2613,9 @@ "placeholder-search-input": "名前で検索するか、フィルタークエリを入力してください…" }, "grafana-folder": "Grafanaフォルダ", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "健康", "label": { "hide": "非表示", @@ -2623,6 +2626,7 @@ "placeholder-all-data-sources": "すべてのデータソース", "placeholder-contact-point": "コンタクトポイントを選択してください", "placeholder-data-sources": "データソースを選択してください", + "placeholder-group-search": "", "placeholder-labels": "ラベルを選択してください", "plugin-rules": "プラグインルール", "rule-source": { @@ -3461,6 +3465,14 @@ "label-negative-y": "Y軸を反転" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "右側にスペースが必要", "gaps-options": { @@ -3505,6 +3517,10 @@ }, "name-show-unfilled-area": "未入力の領域を表示", "name-value-display": "値を表示", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "非表示", "label-text-color": "文字色", @@ -4149,6 +4165,7 @@ "clear": "消去", "collapse": "折りたたみ表示", "disabled": "", + "discard": "", "edit": "編集", "help": "ヘルプ", "loading": "読み込み中...", @@ -4766,7 +4783,6 @@ "variable": "{{type}}変数", "variable-set": "変数" }, - "open": "オプションペインを開く", "row": { "header": { "hide": "非表示", @@ -5116,6 +5132,7 @@ "title-matched_other": "{{count}}/{{totalCount}}件のオプションが一致" }, "outline": { + "pane-header": "", "repeated-item": "繰り返し", "tree-item": { "empty": "(空白)", @@ -5327,6 +5344,32 @@ "share-public-dashboard-loader": { "loading-configuration": "設定を読み込み中" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "ダッシュボードの読み込みと初期化", "title-not-found": "ID{{panelId}}のパネルが見つかりません" @@ -5430,11 +5473,8 @@ "tooltip": "このダッシュボードは読み取り専用に設定されています" }, "export": { - "arrow": "エクスポート", - "title": "エクスポート", "tooltip": { - "as-code": "コードとしてエクスポート", - "json": "JSON形式でエクスポート" + "as-code": "コードとしてエクスポート" } }, "more-save-options": "その他の保存オプション", @@ -5486,6 +5526,9 @@ "save-library-panel": "ライブラリパネルを保存", "settings": "ダッシュボードの設定", "share-button": "共有", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5814,9 +5857,6 @@ "name-values-separated-comma": "カンマ区切りの値", "selection-options": "選択オプション" }, - "dashboard-edit-pane-renderer": { - "outline": "概要" - }, "dashboard-link-form": { "back-to-list": "一覧に戻る", "label-icon": "アイコン", @@ -6159,8 +6199,8 @@ "placeholder-search-for": "次を検索...", "radio-options": { "label": { - "suggestions": "提案", - "visualizations": "可視化" + "all-visualizations": "", + "suggestions": "提案" } }, "title-close": "閉じる" @@ -7795,6 +7835,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8092,14 +8133,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "戻る" - } - }, - "select-group": "グループを選択" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11003,8 +11036,8 @@ "tooltip-delete": "削除" }, "visualization-suggestions": { - "based-on-current-data": "現在のデータに基づく", - "no-results-matched-your-query": "クエリに一致する結果がありません" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "クエリに一致するものが見つかりませんでした" @@ -11036,6 +11069,10 @@ "pie-chart-type-options": { "label-donut": "ドーナツ", "label-pie": "円" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11616,13 +11653,6 @@ "saving": "保存中", "title-error-loading-file": "ファイル読み込み時のエラー" }, - "files-view": { - "columns": { - "history": "履歴", - "view": "表示" - }, - "placeholder-search": "検索" - }, "finish-step": { "description-enable-previews": "プルリクエストによるダッシュボードの変更の画像プレビューを追加します。Grafanaダッシュボードの画像はGitリポジトリで共有され、リポジトリにアクセスできる全ユーザーが閲覧できます。", "description-generate-dashboard-previews": "プルリクエストのプレビューリンクを作成する", @@ -11831,9 +11861,6 @@ "source-code": "ソースコード" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "読み取り専用", "settings": "設定", "view": "表示" @@ -11870,14 +11897,6 @@ "webhook-last-event": "最新イベント:", "webhook-url": "Webhookを表示" }, - "repository-resources": { - "columns": { - "history": "履歴", - "view-dashboard": "表示", - "view-folder": "表示" - }, - "placeholder-search": "検索" - }, "repository-status-page": { "back-to-repositories": "リポジトリに戻る", "cleaning-up-resources": "リポジトリリソースのクリーンアップ中", @@ -11885,12 +11904,10 @@ "not-found": "見つかりません", "not-found-message": "リポジトリが見つかりません", "repository-config-exists-configuration": "リポジトリ設定が設定ファイルに含まれていることを確認してください。", - "tab-files": "ファイル", - "tab-files-title": "リポジトリからのRawファイルリスト", "tab-overview": "概要", "tab-overview-title": "リポジトリの概要", "tab-resources": "リソース", - "tab-resources-title": "Grafanaデータベースに保存されたリソース", + "tab-resources-title": "", "title": "リポジトリの状態", "title-legacy-storage": "レガシーストレージ", "title-queued-for-deletion": "削除待ちリストに追加されました" @@ -11913,6 +11930,17 @@ "pure-git": "Pure Git", "pure-git-description": "任意のGitリポジトリに接続" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "ベース", "dashboard-preview": "ダッシュボードプレビュー", @@ -12975,6 +13003,12 @@ "label-same-as-value": "値と同じ", "label-standard": "標準" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "自動", "label-center": "中央" @@ -13311,7 +13345,8 @@ "forwards-time-aria-label": "時間範囲を前に移動", "to": "へ", "zoom-out-button": "時間範囲をズームアウト", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "時間範囲を適用", @@ -13413,6 +13448,16 @@ "label-threshold": "しきい値" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "タイムゾーンを追加", "tooltip-remove-timezone": "タイムゾーンを削除" diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 115f653a526..cf9773184cd 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -474,6 +474,7 @@ "noOptionsMessage-no-datasources-found": "데이터 소스를 찾을 수 없습니다" }, "alert-menu": { + "analyze-rule": "", "copy-link": "링크 복사", "duplicate": "복제", "export": "내보내기", @@ -2612,7 +2613,9 @@ "placeholder-search-input": "이름으로 검색하거나 필터 쿼리를 입력하세요..." }, "grafana-folder": "Grafana 폴더", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "상태", "label": { "hide": "숨기기", @@ -2623,6 +2626,7 @@ "placeholder-all-data-sources": "모든 데이터 소스", "placeholder-contact-point": "연락처 선택", "placeholder-data-sources": "데이터 소스 선택", + "placeholder-group-search": "", "placeholder-labels": "레이블 선택", "plugin-rules": "플러그인 규칙", "rule-source": { @@ -3461,6 +3465,14 @@ "label-negative-y": "Y 음수 값" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "오른쪽에서 공간이 필요합니다.", "gaps-options": { @@ -3505,6 +3517,10 @@ }, "name-show-unfilled-area": "미채움 영역 표시", "name-value-display": "값 표시", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "숨김", "label-text-color": "텍스트 색상", @@ -4149,6 +4165,7 @@ "clear": "초기화", "collapse": "접기", "disabled": "", + "discard": "", "edit": "편집", "help": "도움말", "loading": "로딩 중...", @@ -4766,7 +4783,6 @@ "variable": "{{type}} 변수", "variable-set": "변수" }, - "open": "옵션 창 열기", "row": { "header": { "hide": "숨기기", @@ -5116,6 +5132,7 @@ "title-matched_other": "{{totalCount}}개 옵션 중 {{count}}개 일치함" }, "outline": { + "pane-header": "", "repeated-item": "반복", "tree-item": { "empty": "(비어 있음)", @@ -5327,6 +5344,32 @@ "share-public-dashboard-loader": { "loading-configuration": "구성 로딩 중" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "대시보드 로딩 및 초기화", "title-not-found": "ID가 {{panelId}}인 패널을 찾을 수 없음" @@ -5430,11 +5473,8 @@ "tooltip": "이 대시보드는 읽기 전용으로 표시되었습니다." }, "export": { - "arrow": "내보내기", - "title": "내보내기", "tooltip": { - "as-code": "코드로 내보내기", - "json": "JSON으로 내보내기" + "as-code": "코드로 내보내기" } }, "more-save-options": "저장 옵션 더 보기", @@ -5486,6 +5526,9 @@ "save-library-panel": "라이브러리 패널 저장", "settings": "대시보드 설정", "share-button": "공유", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5814,9 +5857,6 @@ "name-values-separated-comma": "쉼표로 구분된 값", "selection-options": "선택 옵션" }, - "dashboard-edit-pane-renderer": { - "outline": "개요" - }, "dashboard-link-form": { "back-to-list": "목록으로 돌아가기", "label-icon": "아이콘", @@ -6159,8 +6199,8 @@ "placeholder-search-for": "검색…", "radio-options": { "label": { - "suggestions": "제안", - "visualizations": "시각화" + "all-visualizations": "", + "suggestions": "제안" } }, "title-close": "닫기" @@ -7795,6 +7835,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8092,14 +8133,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "뒤로 가기" - } - }, - "select-group": "그룹 선택" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11003,8 +11036,8 @@ "tooltip-delete": "삭제" }, "visualization-suggestions": { - "based-on-current-data": "현재 데이터 기반", - "no-results-matched-your-query": "쿼리와 일치하는 결과가 없습니다" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "쿼리와 일치하는 항목을 찾을 수 없습니다" @@ -11036,6 +11069,10 @@ "pie-chart-type-options": { "label-donut": "도넛", "label-pie": "원형" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11616,13 +11653,6 @@ "saving": "저장 중", "title-error-loading-file": "파일 로딩 중 오류 발생" }, - "files-view": { - "columns": { - "history": "이력", - "view": "보기" - }, - "placeholder-search": "검색" - }, "finish-step": { "description-enable-previews": "풀 요청에서 대시보드 변경 사항의 이미지 미리 보기를 추가합니다. Grafana 대시보드의 이미지는 Git 리포지토리에서 공유되며 리포지토리 액세스 권한이 있는 모든 사용자가 볼 수 있습니다.", "description-generate-dashboard-previews": "풀 요청에 대한 미리 보기 링크 생성", @@ -11831,9 +11861,6 @@ "source-code": "소스 코드" }, "repository-card": { - "get-repository-meta": { - "webhook": "웹훅" - }, "read-only-badge": "읽기 전용", "settings": "설정", "view": "보기" @@ -11870,14 +11897,6 @@ "webhook-last-event": "마지막 이벤트:", "webhook-url": "웹훅 보기" }, - "repository-resources": { - "columns": { - "history": "이력", - "view-dashboard": "보기", - "view-folder": "보기" - }, - "placeholder-search": "검색" - }, "repository-status-page": { "back-to-repositories": "리포지토리로 돌아가기", "cleaning-up-resources": "리포지토리 리소스 정리 및 삭제 중", @@ -11885,12 +11904,10 @@ "not-found": "찾을 수 없음", "not-found-message": "리포지토리를 찾을 수 없습니다", "repository-config-exists-configuration": "리포지토리 구성이 구성 파일에 있는지 확인하세요.", - "tab-files": "파일", - "tab-files-title": "리포지토리의 원시 파일 목록", "tab-overview": "개요", "tab-overview-title": "리포지토리 개요", "tab-resources": "리소스", - "tab-resources-title": "Grafana 데이터베이스에 저장된 리소스", + "tab-resources-title": "", "title": "리포지토리 상태", "title-legacy-storage": "레거시 스토리지", "title-queued-for-deletion": "삭제 대기열에 추가됨" @@ -11913,6 +11930,17 @@ "pure-git": "Git으로만 구성", "pure-git-description": "모든 Git 리포지토리에 연결" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "베이스", "dashboard-preview": "대시보드 미리 보기", @@ -12975,6 +13003,12 @@ "label-same-as-value": "값과 동일", "label-standard": "표준" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "자동", "label-center": "중앙" @@ -13311,7 +13345,8 @@ "forwards-time-aria-label": "시간 범위를 이후로 변경", "to": "종료", "zoom-out-button": "시간 범위 확대", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "시간 범위 적용", @@ -13413,6 +13448,16 @@ "label-threshold": "임계값" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "시간대 추가", "tooltip-remove-timezone": "시간대 제거" diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index f1236285a6b..8a7dcc3b2d0 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -477,6 +477,7 @@ "noOptionsMessage-no-datasources-found": "Geen gegevensbronnen gevonden" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Link kopiëren", "duplicate": "Dupliceren", "export": "Exporteren", @@ -2624,7 +2625,9 @@ "placeholder-search-input": "Zoeken op naam of filterquery invoeren..." }, "grafana-folder": "Grafana-map", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Gezondheid", "label": { "hide": "Verbergen", @@ -2635,6 +2638,7 @@ "placeholder-all-data-sources": "Alle gegevensbronnen", "placeholder-contact-point": "Contactpunt selecteren", "placeholder-data-sources": "Gegevensbronnen selecteren", + "placeholder-group-search": "", "placeholder-labels": "Labels selecteren", "plugin-rules": "Plug-inregels", "rule-source": { @@ -3473,6 +3477,14 @@ "label-negative-y": "Negatieve Y" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Ruimte van de rechterkant vereisen", "gaps-options": { @@ -3517,6 +3529,10 @@ }, "name-show-unfilled-area": "Niet-gevuld gebied weergeven", "name-value-display": "Weergave van waarden", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Verborgen", "label-text-color": "Tekstkleur", @@ -4169,6 +4185,7 @@ "clear": "Wissen", "collapse": "Samenvouwen", "disabled": "", + "discard": "", "edit": "Bewerken", "help": "Help", "loading": "Bezig met laden ...", @@ -4786,7 +4803,6 @@ "variable": "{{type}}variabele", "variable-set": "Variabelen" }, - "open": "Deelvenster Opties openen", "row": { "header": { "hide": "Verbergen", @@ -5137,6 +5153,7 @@ "title-matched_other": "{{count}} overeenkomende/{{totalCount}} opties" }, "outline": { + "pane-header": "", "repeated-item": "Herhalen", "tree-item": { "empty": "(leeg)", @@ -5348,6 +5365,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Configuratie laden" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Dashboard laden en initialiseren", "title-not-found": "Paneel met id {{panelId}} niet gevonden" @@ -5451,11 +5494,8 @@ "tooltip": "Dit dashboard is gemarkeerd als alleen lezen" }, "export": { - "arrow": "Exporteren", - "title": "Exporteren", "tooltip": { - "as-code": "Exporteren als code", - "json": "Exporteren als JSON" + "as-code": "Exporteren als code" } }, "more-save-options": "Meer opties voor opslaan", @@ -5507,6 +5547,9 @@ "save-library-panel": "Bibliotheekpaneel", "settings": "Dashboardinstellingen", "share-button": "Delen", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5836,9 +5879,6 @@ "name-values-separated-comma": "Waarden gescheiden door komma", "selection-options": "Selectiemogelijkheden" }, - "dashboard-edit-pane-renderer": { - "outline": "Schetsen" - }, "dashboard-link-form": { "back-to-list": "Terug naar lijst", "label-icon": "Pictogram", @@ -6182,8 +6222,8 @@ "placeholder-search-for": "Zoeken naar...", "radio-options": { "label": { - "suggestions": "Suggesties", - "visualizations": "Visualisaties" + "all-visualizations": "", + "suggestions": "Suggesties" } }, "title-close": "Sluiten" @@ -7821,6 +7861,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8118,14 +8159,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Ga terug" - } - }, - "select-group": "Groep selecteren" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11050,8 +11083,8 @@ "tooltip-delete": "Verwijderen" }, "visualization-suggestions": { - "based-on-current-data": "Op basis van huidige gegevens", - "no-results-matched-your-query": "Geen resultaten gevonden voor je query" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Kon niets vinden dat overeenkomt met je query" @@ -11083,6 +11116,10 @@ "pie-chart-type-options": { "label-donut": "Donut", "label-pie": "Taart" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11666,13 +11703,6 @@ "saving": "Opslaan", "title-error-loading-file": "Er is een fout opgetreden bij het laden van het bestand" }, - "files-view": { - "columns": { - "history": "Geschiedenis", - "view": "Weergave" - }, - "placeholder-search": "Zoeken" - }, "finish-step": { "description-enable-previews": "Voegt een afbeeldingsvoorbeeld toe van dashboardwijzigingen in pull requests. Afbeeldingen van je Grafana-dashboards worden gedeeld in je Git-repository en zijn zichtbaar voor iedereen met toegang tot de repository.", "description-generate-dashboard-previews": "Voorbeeldlinks maken voor pull-verzoeken", @@ -11883,9 +11913,6 @@ "source-code": "Broncode" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Alleen lezen", "settings": "Instellingen", "view": "Weergave" @@ -11922,14 +11949,6 @@ "webhook-last-event": "Laatste gebeurtenis:", "webhook-url": "Webhook bekijken" }, - "repository-resources": { - "columns": { - "history": "Geschiedenis", - "view-dashboard": "Weergave", - "view-folder": "Weergave" - }, - "placeholder-search": "Zoeken" - }, "repository-status-page": { "back-to-repositories": "Terug naar repositories", "cleaning-up-resources": "Bronnen van repository opschonen", @@ -11937,12 +11956,10 @@ "not-found": "niet gevonden", "not-found-message": "Repository niet gevonden", "repository-config-exists-configuration": "Zorg ervoor dat de repository-configuratie in het configuratiebestand bestaat.", - "tab-files": "Bestanden", - "tab-files-title": "De lijst met onbewerkte bestanden uit de repository", "tab-overview": "Overzicht", "tab-overview-title": "Repository-overzicht", "tab-resources": "Bronnen", - "tab-resources-title": "Bronnen opgeslagen in Grafana-database", + "tab-resources-title": "", "title": "Repository-status", "title-legacy-storage": "Legacy-opslag", "title-queued-for-deletion": "In de wachtrij voor verwijdering" @@ -11965,6 +11982,17 @@ "pure-git": "Pure Git", "pure-git-description": "Verbinden met elke Git-repository" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Basis", "dashboard-preview": "Dashboardvoorbeeld", @@ -13031,6 +13059,12 @@ "label-same-as-value": "Hetzelfde als waarde", "label-standard": "Standaard" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Automatisch", "label-center": "Midden" @@ -13368,7 +13402,8 @@ "forwards-time-aria-label": "Tijdsbereik vooruitzetten", "to": "tot", "zoom-out-button": "Tijdsbereik uitzoomen", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Tijdsbereik toepassen", @@ -13470,6 +13505,16 @@ "label-threshold": "Drempelwaarde" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Tijdzone toevoegen", "tooltip-remove-timezone": "Tijdzone verwijderen" diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 1a5897fd7ed..d988de6fdee 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -483,6 +483,7 @@ "noOptionsMessage-no-datasources-found": "Nie znaleziono źródeł danych" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Kopiuj link", "duplicate": "Duplikuj", "export": "Eksport", @@ -2648,7 +2649,9 @@ "placeholder-search-input": "Szukaj według nazwy lub wprowadź wyrażenie do filtrowania…" }, "grafana-folder": "Folder Grafana", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Zdrowie", "label": { "hide": "Ukryj", @@ -2659,6 +2662,7 @@ "placeholder-all-data-sources": "Wszystkie źródła danych", "placeholder-contact-point": "Wybierz punkt kontaktowy", "placeholder-data-sources": "Wybierz źródła danych", + "placeholder-group-search": "", "placeholder-labels": "Wybierz etykiety", "plugin-rules": "Reguły dotyczące wtyczek", "rule-source": { @@ -3497,6 +3501,14 @@ "label-negative-y": "Ujemna oś Y" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Wymagaj spacji po prawej stronie", "gaps-options": { @@ -3541,6 +3553,10 @@ }, "name-show-unfilled-area": "Pokaż niewypełniony obszar", "name-value-display": "Wyświetlanie wartości", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Ukryte", "label-text-color": "Kolor tekstu", @@ -4209,6 +4225,7 @@ "clear": "Wyczyść", "collapse": "Zwiń", "disabled": "", + "discard": "", "edit": "Edytuj", "help": "Pomoc", "loading": "Ładowanie…", @@ -4826,7 +4843,6 @@ "variable": "Zmienna {{type}}", "variable-set": "Zmienne" }, - "open": "Otwórz okno opcji", "row": { "header": { "hide": "Ukryj", @@ -5179,6 +5195,7 @@ "title-matched_other": "Dopasowano {{count}}/{{totalCount}} opcji" }, "outline": { + "pane-header": "", "repeated-item": "Powtórz", "tree-item": { "empty": "(puste)", @@ -5390,6 +5407,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Wczytywanie konfiguracji" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Wczytywanie i inicjowanie pulpitu", "title-not-found": "Nie znaleziono panelu o identyfikatorze {{panelId}}" @@ -5493,11 +5536,8 @@ "tooltip": "Ten pulpit został oznaczony jako tylko do odczytu" }, "export": { - "arrow": "Eksportuj", - "title": "Eksportuj", "tooltip": { - "as-code": "Eksportuj jako kod", - "json": "Eksportowanie jako JSON" + "as-code": "Eksportuj jako kod" } }, "more-save-options": "Więcej opcji zapisywania", @@ -5549,6 +5589,9 @@ "save-library-panel": "Zapisz panel biblioteki", "settings": "Ustawienia panelu", "share-button": "Udostępnij", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5880,9 +5923,6 @@ "name-values-separated-comma": "Wartości rozdzielone przecinkami", "selection-options": "Opcje wyboru" }, - "dashboard-edit-pane-renderer": { - "outline": "Konspekt" - }, "dashboard-link-form": { "back-to-list": "Powrót do listy", "label-icon": "Ikona", @@ -6228,8 +6268,8 @@ "placeholder-search-for": "Szukaj…", "radio-options": { "label": { - "suggestions": "Sugestie", - "visualizations": "Wizualizacje" + "all-visualizations": "", + "suggestions": "Sugestie" } }, "title-close": "Zamknij" @@ -7873,6 +7913,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8170,14 +8211,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Wróć" - } - }, - "select-group": "Wybierz grupę" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11144,8 +11177,8 @@ "tooltip-delete": "Usuń" }, "visualization-suggestions": { - "based-on-current-data": "Na podstawie aktualnych danych", - "no-results-matched-your-query": "Nie znaleziono wyników pasujących do zapytania" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Nie znaleziono wyników pasujących do Twojego zapytania" @@ -11177,6 +11210,10 @@ "pie-chart-type-options": { "label-donut": "Pierścieniowy", "label-pie": "Kołowy" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11766,13 +11803,6 @@ "saving": "Zapisywanie", "title-error-loading-file": "Błąd podczas ładowania pliku" }, - "files-view": { - "columns": { - "history": "Historia", - "view": "Wyświetl" - }, - "placeholder-search": "Szukaj" - }, "finish-step": { "description-enable-previews": "Dodaje podgląd obrazu zmian pulpitu w żądaniach pull. Obrazy pulpitów Grafany zostaną udostępnione w repozytorium Git i będą widoczne dla każdego, kto ma do niego dostęp.", "description-generate-dashboard-previews": "Utwórz linki podglądu dla żądań pull", @@ -11987,9 +12017,6 @@ "source-code": "Kod źródłowy" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Tylko do odczytu", "settings": "Ustawienia", "view": "Wyświetl" @@ -12026,14 +12053,6 @@ "webhook-last-event": "Ostatnie zdarzenie:", "webhook-url": "Wyświetl element webhook" }, - "repository-resources": { - "columns": { - "history": "Historia", - "view-dashboard": "Wyświetl", - "view-folder": "Wyświetl" - }, - "placeholder-search": "Szukaj" - }, "repository-status-page": { "back-to-repositories": "Wróć do repozytoriów", "cleaning-up-resources": "Sprzątanie zasobów repozytorium", @@ -12041,12 +12060,10 @@ "not-found": "nie znaleziono", "not-found-message": "Nie znaleziono repozytorium", "repository-config-exists-configuration": "Upewnij się, że konfiguracja repozytorium istnieje w pliku konfiguracyjnym.", - "tab-files": "Pliki", - "tab-files-title": "Lista nieprzetworzonych plików z repozytorium", "tab-overview": "Przegląd", "tab-overview-title": "Przegląd repozytorium", "tab-resources": "Zasoby", - "tab-resources-title": "Zasoby zapisane w bazie danych Grafany", + "tab-resources-title": "", "title": "Status repozytorium", "title-legacy-storage": "Starsza pamięć masowa", "title-queued-for-deletion": "Dodano do kolejki do usunięcia" @@ -12069,6 +12086,17 @@ "pure-git": "Czysty Git", "pure-git-description": "Połącz z dowolnym repozytorium Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Podstawa", "dashboard-preview": "Podgląd pulpitu", @@ -13143,6 +13171,12 @@ "label-same-as-value": "Taki sam jak wartość", "label-standard": "Standardowy" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Automatycznie", "label-center": "Pośrodku" @@ -13482,7 +13516,8 @@ "forwards-time-aria-label": "Przesuń zakres czasu do przodu", "to": "do", "zoom-out-button": "Oddal zakres czasu", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Zastosuj zakres czasu", @@ -13584,6 +13619,16 @@ "label-threshold": "Próg" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Dodaj strefę czasową", "tooltip-remove-timezone": "Usuń strefę czasową" diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 419d550fe09..e17ed3dade4 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -477,6 +477,7 @@ "noOptionsMessage-no-datasources-found": "Nenhuma fonte de dados encontrada" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Copiar link", "duplicate": "Duplicar", "export": "Exportar", @@ -2624,7 +2625,9 @@ "placeholder-search-input": "Pesquise por nome ou insira uma consulta de filtro…" }, "grafana-folder": "Pasta do Grafana", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Integridade", "label": { "hide": "Ocultar", @@ -2635,6 +2638,7 @@ "placeholder-all-data-sources": "Todas as fontes de dados", "placeholder-contact-point": "Selecionar ponto de contato", "placeholder-data-sources": "Selecionar fontes de dados", + "placeholder-group-search": "", "placeholder-labels": "Selecionar rótulos", "plugin-rules": "Regras de plug-in", "rule-source": { @@ -3473,6 +3477,14 @@ "label-negative-y": "Y negativo" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Exigir espaço do lado direito", "gaps-options": { @@ -3517,6 +3529,10 @@ }, "name-show-unfilled-area": "Mostrar área não preenchida", "name-value-display": "Exibição de valor", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Oculto", "label-text-color": "Cor do texto", @@ -4169,6 +4185,7 @@ "clear": "Limpar", "collapse": "Recolher", "disabled": "", + "discard": "", "edit": "Editar", "help": "Ajuda", "loading": "Carregando...", @@ -4786,7 +4803,6 @@ "variable": "Variável {{type}}", "variable-set": "Variáveis" }, - "open": "Abrir painel de opções", "row": { "header": { "hide": "Ocultar", @@ -5137,6 +5153,7 @@ "title-matched_other": "Opções correspondentes: {{count}}/{{totalCount}}" }, "outline": { + "pane-header": "", "repeated-item": "Repetido", "tree-item": { "empty": "(vazio)", @@ -5348,6 +5365,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Carregando configuração" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Carregando e inicializando o painel", "title-not-found": "Painel com ID {{panelId}} não encontrado" @@ -5451,11 +5494,8 @@ "tooltip": "Este painel foi marcado como somente leitura" }, "export": { - "arrow": "Exportar", - "title": "Exportar", "tooltip": { - "as-code": "Exportar como código", - "json": "Exportar como JSON" + "as-code": "Exportar como código" } }, "more-save-options": "Mais opções de salvamento", @@ -5507,6 +5547,9 @@ "save-library-panel": "Salvar painel da biblioteca", "settings": "Configurações do painel de controle", "share-button": "Compartilhar", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5836,9 +5879,6 @@ "name-values-separated-comma": "Valores separados por vírgula", "selection-options": "Opções de seleção" }, - "dashboard-edit-pane-renderer": { - "outline": "Contorno" - }, "dashboard-link-form": { "back-to-list": "Voltar para a lista", "label-icon": "Ícone", @@ -6182,8 +6222,8 @@ "placeholder-search-for": "Pesquisar por…", "radio-options": { "label": { - "suggestions": "Sugestões", - "visualizations": "Visualizações" + "all-visualizations": "", + "suggestions": "Sugestões" } }, "title-close": "Fechar" @@ -7821,6 +7861,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8118,14 +8159,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Voltar" - } - }, - "select-group": "Selecionar grupo" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11050,8 +11083,8 @@ "tooltip-delete": "Excluir" }, "visualization-suggestions": { - "based-on-current-data": "Com base nos dados atuais", - "no-results-matched-your-query": "Nenhum resultado correspondeu à sua consulta" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Não foi possível encontrar nada que corresponda à sua consulta" @@ -11083,6 +11116,10 @@ "pie-chart-type-options": { "label-donut": "Donut", "label-pie": "Pizza" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11666,13 +11703,6 @@ "saving": "Salvando", "title-error-loading-file": "Erro ao carregar o arquivo" }, - "files-view": { - "columns": { - "history": "Histórico", - "view": "Visualizar" - }, - "placeholder-search": "Pesquisar" - }, "finish-step": { "description-enable-previews": "Adiciona uma pré-visualização em imagem das alterações do painel nas solicitações de extração. As imagens dos seus painéis da Grafana serão compartilhadas no seu repositório Git e estarão disponíveis para qualquer pessoa com acesso ao repositório.", "description-generate-dashboard-previews": "Criar links de prévia para solicitações de extração", @@ -11883,9 +11913,6 @@ "source-code": "Código fonte" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Somente leitura", "settings": "Configurações", "view": "Visualizar" @@ -11922,14 +11949,6 @@ "webhook-last-event": "Último evento:", "webhook-url": "Visualizar Webhook" }, - "repository-resources": { - "columns": { - "history": "Histórico", - "view-dashboard": "Visualizar", - "view-folder": "Visualizar" - }, - "placeholder-search": "Pesquisar" - }, "repository-status-page": { "back-to-repositories": "Voltar para os repositórios", "cleaning-up-resources": "Limpando recursos do repositório", @@ -11937,12 +11956,10 @@ "not-found": "não encontrado", "not-found-message": "Repositório não encontrado", "repository-config-exists-configuration": "Verifique se a configuração do repositório existe no arquivo de configuração.", - "tab-files": "Arquivos", - "tab-files-title": "A lista de arquivos brutos do repositório", "tab-overview": "Visão geral", "tab-overview-title": "Visão geral do repositório", "tab-resources": "Fontes", - "tab-resources-title": "Recursos salvos no banco de dados da Grafana", + "tab-resources-title": "", "title": "Status do repositório", "title-legacy-storage": "Armazenamento legado", "title-queued-for-deletion": "Na fila para exclusão" @@ -11965,6 +11982,17 @@ "pure-git": "Git puro", "pure-git-description": "Conectar a qualquer repositório do Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Base", "dashboard-preview": "Pré-visualização do painel", @@ -13031,6 +13059,12 @@ "label-same-as-value": "Com o mesmo valor", "label-standard": "Padrão" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Automático", "label-center": "Centro" @@ -13368,7 +13402,8 @@ "forwards-time-aria-label": "Avançar intervalo de tempo", "to": "para", "zoom-out-button": "Diminuir o intervalo de tempo", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Aplicar intervalo de tempo", @@ -13470,6 +13505,16 @@ "label-threshold": "Limite" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Adicionar fuso horário", "tooltip-remove-timezone": "Remover fuso horário" diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 28b728aee0f..34cc8be7717 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -477,6 +477,7 @@ "noOptionsMessage-no-datasources-found": "Nenhuma fonte de dados encontrada" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Copiar link", "duplicate": "Duplicar", "export": "Exportar", @@ -2624,7 +2625,9 @@ "placeholder-search-input": "Pesquise por nome ou introduza uma consulta de filtro..." }, "grafana-folder": "Pasta Grafana", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Saúde", "label": { "hide": "Ocultar", @@ -2635,6 +2638,7 @@ "placeholder-all-data-sources": "Todas as origens de dados", "placeholder-contact-point": "Selecionar ponto de contacto", "placeholder-data-sources": "Selecionar origens de dados", + "placeholder-group-search": "", "placeholder-labels": "Selecionar etiquetas", "plugin-rules": "Regras de plugin", "rule-source": { @@ -3473,6 +3477,14 @@ "label-negative-y": "Y negativo" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Requerer espaço do lado direito", "gaps-options": { @@ -3517,6 +3529,10 @@ }, "name-show-unfilled-area": "Mostrar a área não preenchida", "name-value-display": "Apresentação dos valores", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Oculto", "label-text-color": "Cor do texto", @@ -4169,6 +4185,7 @@ "clear": "Limpar", "collapse": "Recolher", "disabled": "", + "discard": "", "edit": "Editar", "help": "Ajuda", "loading": "A carregar...", @@ -4786,7 +4803,6 @@ "variable": "Variável {{type}}", "variable-set": "Variáveis" }, - "open": "Abrir painel de opções", "row": { "header": { "hide": "Ocultar", @@ -5137,6 +5153,7 @@ "title-matched_other": "Correspondente a {{count}}/{{totalCount}} opções" }, "outline": { + "pane-header": "", "repeated-item": "Repetir", "tree-item": { "empty": "(vazio)", @@ -5348,6 +5365,32 @@ "share-public-dashboard-loader": { "loading-configuration": "A carregar a configuração" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "A carregar e inicializar o painel de controlo", "title-not-found": "Painel com ID {{panelId}} não encontrado" @@ -5451,11 +5494,8 @@ "tooltip": "Este painel de controlo foi marcado como apenas de leitura" }, "export": { - "arrow": "Exportar", - "title": "Exportar", "tooltip": { - "as-code": "Exportar como código", - "json": "Exportar como JSON" + "as-code": "Exportar como código" } }, "more-save-options": "Mais opções de guardar", @@ -5507,6 +5547,9 @@ "save-library-panel": "Guardar painel de biblioteca", "settings": "Definições do painel de controlo", "share-button": "Partilhar", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5836,9 +5879,6 @@ "name-values-separated-comma": "Valores separados por vírgulas", "selection-options": "Opções de seleção" }, - "dashboard-edit-pane-renderer": { - "outline": "Contorno" - }, "dashboard-link-form": { "back-to-list": "Voltar à lista", "label-icon": "Ícone", @@ -6182,8 +6222,8 @@ "placeholder-search-for": "Pesquisar por...", "radio-options": { "label": { - "suggestions": "Sugestões", - "visualizations": "Visualizações" + "all-visualizations": "", + "suggestions": "Sugestões" } }, "title-close": "Fechar" @@ -7821,6 +7861,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8118,14 +8159,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Voltar" - } - }, - "select-group": "Selecionar grupo" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11050,8 +11083,8 @@ "tooltip-delete": "Eliminar" }, "visualization-suggestions": { - "based-on-current-data": "Com base nos dados atuais", - "no-results-matched-your-query": "Não existem resultados que correspondam à sua consulta" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Não foi possível encontrar nada que corresponda à sua consulta" @@ -11083,6 +11116,10 @@ "pie-chart-type-options": { "label-donut": "Anel", "label-pie": "Circular" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11666,13 +11703,6 @@ "saving": "A guardar", "title-error-loading-file": "Erro ao carregar o ficheiro" }, - "files-view": { - "columns": { - "history": "Histórico", - "view": "Ver" - }, - "placeholder-search": "Pesquisar" - }, "finish-step": { "description-enable-previews": "Adicionar uma pré-visualização de imagem das alterações do painel de controlo nos pedidos de extração. As imagens dos seus painéis de controlo Grafana serão partilhadas no seu repositório Git e visíveis para qualquer pessoa com acesso ao repositório.", "description-generate-dashboard-previews": "Criar links de pré-visualização para pedidos de extração", @@ -11883,9 +11913,6 @@ "source-code": "Código-fonte" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Apenas para leitura", "settings": "Definições", "view": "Ver" @@ -11922,14 +11949,6 @@ "webhook-last-event": "Último evento:", "webhook-url": "Visualizar Webhook" }, - "repository-resources": { - "columns": { - "history": "Histórico", - "view-dashboard": "Ver", - "view-folder": "Ver" - }, - "placeholder-search": "Pesquisar" - }, "repository-status-page": { "back-to-repositories": "Voltar aos repositórios", "cleaning-up-resources": "A limpar recursos do repositório", @@ -11937,12 +11956,10 @@ "not-found": "não encontrado", "not-found-message": "Repositório não encontrado", "repository-config-exists-configuration": "Certifique-se de que a configuração do repositório existe no ficheiro de configuração.", - "tab-files": "Ficheiros", - "tab-files-title": "A lista de ficheiros sem processar do repositório", "tab-overview": "Visão geral", "tab-overview-title": "Vista geral do repositório", "tab-resources": "Recursos", - "tab-resources-title": "Recursos guardados na base de dados da Grafana", + "tab-resources-title": "", "title": "Estado do repositório", "title-legacy-storage": "Armazenamento herdado", "title-queued-for-deletion": "Em fila para eliminação" @@ -11965,6 +11982,17 @@ "pure-git": "Git puro", "pure-git-description": "Ligar a qualquer repositório Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Base", "dashboard-preview": "Pré-visualização do painel de controlo", @@ -13031,6 +13059,12 @@ "label-same-as-value": "Igual ao valor", "label-standard": "Predefinição" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Automático", "label-center": "Centro" @@ -13368,7 +13402,8 @@ "forwards-time-aria-label": "Mover o intervalo de tempo para a frente", "to": "para", "zoom-out-button": "Diminuir o zoom do intervalo de tempo", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Aplicar intervalo de tempo", @@ -13470,6 +13505,16 @@ "label-threshold": "Limite" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Adicionar fuso horário", "tooltip-remove-timezone": "Remover fuso horário" diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 1d05879899a..3a8724d3098 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -483,6 +483,7 @@ "noOptionsMessage-no-datasources-found": "Источники данных не найдены" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Копировать ссылку", "duplicate": "Дублировать", "export": "Экспорт", @@ -2648,7 +2649,9 @@ "placeholder-search-input": "Выполните поиск по имени или введите запрос фильтра..." }, "grafana-folder": "Папка Grafana", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Работоспособность", "label": { "hide": "Скрыть", @@ -2659,6 +2662,7 @@ "placeholder-all-data-sources": "Все источники данных", "placeholder-contact-point": "Выбрать точку контакта", "placeholder-data-sources": "Выбрать источники данных", + "placeholder-group-search": "", "placeholder-labels": "Выбрать метки", "plugin-rules": "Правила плагинов", "rule-source": { @@ -3497,6 +3501,14 @@ "label-negative-y": "Отрицательные для оси Y" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Требуется пространство с правой стороны", "gaps-options": { @@ -3541,6 +3553,10 @@ }, "name-show-unfilled-area": "Показывать незаполненную область", "name-value-display": "Отображение значений", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Скрыты", "label-text-color": "Цвет текста", @@ -4209,6 +4225,7 @@ "clear": "Очистить", "collapse": "Свернуть", "disabled": "", + "discard": "", "edit": "Редактировать", "help": "Справка", "loading": "Загрузка…", @@ -4826,7 +4843,6 @@ "variable": "Тип переменной: {{type}}", "variable-set": "Переменные" }, - "open": "Открыть область параметров", "row": { "header": { "hide": "Скрыть", @@ -5179,6 +5195,7 @@ "title-matched_other": "Совпадение: {{count}}/{{totalCount}} параметра" }, "outline": { + "pane-header": "", "repeated-item": "Повторить", "tree-item": { "empty": "(пусто)", @@ -5390,6 +5407,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Загрузка конфигурации" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Загрузка и инициализация дашборда", "title-not-found": "Панель с идентификатором {{panelId}} не найдена" @@ -5493,11 +5536,8 @@ "tooltip": "Дашборд был помечен как «только для чтения»" }, "export": { - "arrow": "Экспорт", - "title": "Экспорт", "tooltip": { - "as-code": "Экспортировать как код", - "json": "Экспорт в формате JSON" + "as-code": "Экспортировать как код" } }, "more-save-options": "Больше параметров сохранения", @@ -5549,6 +5589,9 @@ "save-library-panel": "Сохранить панель библиотеки", "settings": "Параметры дашборда", "share-button": "Общий доступ", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5880,9 +5923,6 @@ "name-values-separated-comma": "Значения, разделенные запятыми", "selection-options": "Параметры выбора" }, - "dashboard-edit-pane-renderer": { - "outline": "Структура" - }, "dashboard-link-form": { "back-to-list": "Назад к списку", "label-icon": "Значок", @@ -6228,8 +6268,8 @@ "placeholder-search-for": "Поиск...", "radio-options": { "label": { - "suggestions": "Предложения", - "visualizations": "Визуализации" + "all-visualizations": "", + "suggestions": "Предложения" } }, "title-close": "Закрыть" @@ -7873,6 +7913,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8170,14 +8211,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Назад" - } - }, - "select-group": "Выбрать группу" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11144,8 +11177,8 @@ "tooltip-delete": "Удалить" }, "visualization-suggestions": { - "based-on-current-data": "На основе текущих данных", - "no-results-matched-your-query": "По вашему запросу ничего не найдено" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Не удалось найти данные, соответствующие вашему запросу" @@ -11177,6 +11210,10 @@ "pie-chart-type-options": { "label-donut": "Кольцевая", "label-pie": "Круговая" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11766,13 +11803,6 @@ "saving": "Сохранение", "title-error-loading-file": "Ошибка при загрузке файла" }, - "files-view": { - "columns": { - "history": "История", - "view": "Просмотр" - }, - "placeholder-search": "Поиск" - }, "finish-step": { "description-enable-previews": "Обеспечивает возможность просмотра изображений с изменениями дашборда в запросах на включение изменений. Изображения дашбордов Grafana будут публиковаться в вашем репозитории Git и видны всем, у кого есть доступ к репозиторию.", "description-generate-dashboard-previews": "Создать ссылки для предварительного просмотра запросов на включение изменений", @@ -11987,9 +12017,6 @@ "source-code": "Исходный код" }, "repository-card": { - "get-repository-meta": { - "webhook": "Веб-перехватчик" - }, "read-only-badge": "Только для чтения", "settings": "Параметры", "view": "Просмотр" @@ -12026,14 +12053,6 @@ "webhook-last-event": "Последнее событие:", "webhook-url": "Просмотр веб-перехватчика" }, - "repository-resources": { - "columns": { - "history": "История", - "view-dashboard": "Просмотр", - "view-folder": "Просмотр" - }, - "placeholder-search": "Поиск" - }, "repository-status-page": { "back-to-repositories": "Назад к репозиториям", "cleaning-up-resources": "Очистка ресурсов репозитория", @@ -12041,12 +12060,10 @@ "not-found": "не найдено", "not-found-message": "Репозиторий не найден", "repository-config-exists-configuration": "Убедитесь, что конфигурация репозитория существует в файле конфигурации.", - "tab-files": "Файлы", - "tab-files-title": "Список необработанных файлов из репозитория", "tab-overview": "Обзор", "tab-overview-title": "Обзор репозитория", "tab-resources": "Ресурсы", - "tab-resources-title": "Ресурсы, сохраненные в базе данных Grafana", + "tab-resources-title": "", "title": "Состояние репозитория", "title-legacy-storage": "Устаревшее хранилище", "title-queued-for-deletion": "В очереди на удаление" @@ -12069,6 +12086,17 @@ "pure-git": "Pure Git", "pure-git-description": "Подключиться к любому репозиторию Git" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Основа", "dashboard-preview": "Просмотр дашборда", @@ -13143,6 +13171,12 @@ "label-same-as-value": "Аналогичный значению", "label-standard": "Стандартный" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Авто", "label-center": "По центру" @@ -13482,7 +13516,8 @@ "forwards-time-aria-label": "Переместить временной диапазон вперед", "to": "на", "zoom-out-button": "Уменьшение масштаба временного диапазона", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Применить временной диапазон", @@ -13584,6 +13619,16 @@ "label-threshold": "Пороговое значение" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Добавить часовой пояс", "tooltip-remove-timezone": "Удалить часовой пояс" diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 95e2ae6130e..c0bd748a996 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -477,6 +477,7 @@ "noOptionsMessage-no-datasources-found": "Inga datakällor hittades" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Kopiera länk", "duplicate": "Dubblett", "export": "Exportera", @@ -2624,7 +2625,9 @@ "placeholder-search-input": "Sök efter namn eller ange filterfråga …" }, "grafana-folder": "Grafana-mapp", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Hälsa", "label": { "hide": "Dölj", @@ -2635,6 +2638,7 @@ "placeholder-all-data-sources": "Alla datakällor", "placeholder-contact-point": "Välj kontaktpunkt", "placeholder-data-sources": "Välj datakällor", + "placeholder-group-search": "", "placeholder-labels": "Välj etiketter", "plugin-rules": "Tilläggsregler", "rule-source": { @@ -3473,6 +3477,14 @@ "label-negative-y": "Negativt Y" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Kräv utrymme från höger", "gaps-options": { @@ -3517,6 +3529,10 @@ }, "name-show-unfilled-area": "Visa ofyllt område", "name-value-display": "Värdevisning", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Dolt", "label-text-color": "Textfärg", @@ -4169,6 +4185,7 @@ "clear": "Rensa", "collapse": "Minimera", "disabled": "", + "discard": "", "edit": "Redigera", "help": "Hjälp", "loading": "Laddar …", @@ -4786,7 +4803,6 @@ "variable": "{{type}}-variabel", "variable-set": "Variabler" }, - "open": "Öppna alternativfönstret", "row": { "header": { "hide": "Dölj", @@ -5137,6 +5153,7 @@ "title-matched_other": "Matchade {{count}}/{{totalCount}} alternativ" }, "outline": { + "pane-header": "", "repeated-item": "Upprepa", "tree-item": { "empty": "(tomt)", @@ -5348,6 +5365,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Läser in konfiguration" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Laddar och initierar instrumentpanel", "title-not-found": "Panel med ID {{panelId}} hittades inte" @@ -5451,11 +5494,8 @@ "tooltip": "Den här instrumentpanelen markerades som skrivskyddad" }, "export": { - "arrow": "Exportera", - "title": "Exportera", "tooltip": { - "as-code": "Exportera som kod", - "json": "Exportera som JSON" + "as-code": "Exportera som kod" } }, "more-save-options": "Fler sparalternativ", @@ -5507,6 +5547,9 @@ "save-library-panel": "Spara bibliotekspanel", "settings": "Instrumentpanelens inställningar", "share-button": "Dela", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5836,9 +5879,6 @@ "name-values-separated-comma": "Värden åtskilda med kommatecken", "selection-options": "Urvalsalternativ" }, - "dashboard-edit-pane-renderer": { - "outline": "Översikt" - }, "dashboard-link-form": { "back-to-list": "Tillbaka till listan", "label-icon": "Ikon", @@ -6182,8 +6222,8 @@ "placeholder-search-for": "Sök efter …", "radio-options": { "label": { - "suggestions": "Förslag", - "visualizations": "Visualiseringar" + "all-visualizations": "", + "suggestions": "Förslag" } }, "title-close": "Stäng" @@ -7821,6 +7861,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8118,14 +8159,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Tillbaka" - } - }, - "select-group": "Markera grupp" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11050,8 +11083,8 @@ "tooltip-delete": "Ta bort" }, "visualization-suggestions": { - "based-on-current-data": "Baserat på aktuella data", - "no-results-matched-your-query": "Inga träffar matchade din sökfråga" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Kunde inte hitta något som matchar din fråga" @@ -11083,6 +11116,10 @@ "pie-chart-type-options": { "label-donut": "Munk", "label-pie": "Paj" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11666,13 +11703,6 @@ "saving": "Sparar", "title-error-loading-file": "Ett fel uppstod när en fil laddades" }, - "files-view": { - "columns": { - "history": "Historik", - "view": "Visa" - }, - "placeholder-search": "Sök" - }, "finish-step": { "description-enable-previews": "Lägger till en förhandsgranskning av ändringar i instrumentpanelen för pull-förfrågningar. Bilder av dina Grafana-instrumentpaneler kommer att delas på din Git-lagringsplats och vara synliga för alla med lagringsåtkomst.", "description-generate-dashboard-previews": "Skapa förhandsgranskningslänkar för pull-begäranden", @@ -11883,9 +11913,6 @@ "source-code": "Källkod" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "Skrivskyddad", "settings": "Inställningar", "view": "Visa" @@ -11922,14 +11949,6 @@ "webhook-last-event": "Senaste händelsen:", "webhook-url": "Visa webhook" }, - "repository-resources": { - "columns": { - "history": "Historik", - "view-dashboard": "Visa", - "view-folder": "Visa" - }, - "placeholder-search": "Sök" - }, "repository-status-page": { "back-to-repositories": "Tillbaka till lagringsplatserna", "cleaning-up-resources": "Rensa lagringsplatsresurser", @@ -11937,12 +11956,10 @@ "not-found": "hittades inte", "not-found-message": "Lagringsplatsen hittades inte", "repository-config-exists-configuration": "Verifiera att lagringsplatskonfigurationen finns i konfigurationsfilen.", - "tab-files": "Filer", - "tab-files-title": "Rådatafillistan från lagringsplatsen", "tab-overview": "Översikt", "tab-overview-title": "Översikt över lagringsplats", "tab-resources": "Resurser", - "tab-resources-title": "Resurser sparade i Grafana-databasen", + "tab-resources-title": "", "title": "Status för lagringsplats", "title-legacy-storage": "Äldre lagring", "title-queued-for-deletion": "Köad för radering" @@ -11965,6 +11982,17 @@ "pure-git": "Ren Git", "pure-git-description": "Anslut till valfri Git-lagringsplats" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Bas", "dashboard-preview": "Förhandsgranskning av instrumentpanel", @@ -13031,6 +13059,12 @@ "label-same-as-value": "Samma som värde", "label-standard": "Standard" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Auto", "label-center": "Centrera" @@ -13368,7 +13402,8 @@ "forwards-time-aria-label": "Flytta tidsintervallet framåt", "to": "till", "zoom-out-button": "Zooma ut tidsintervall", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Tillämpa tidsintervall", @@ -13470,6 +13505,16 @@ "label-threshold": "Tröskel" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Lägg till tidszon", "tooltip-remove-timezone": "Ta bort tidszon" diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 16e0520c9a7..cfeb21bd5aa 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -477,6 +477,7 @@ "noOptionsMessage-no-datasources-found": "Veri kaynağı bulunamadı" }, "alert-menu": { + "analyze-rule": "", "copy-link": "Bağlantıyı kopyala", "duplicate": "Çoğalt", "export": "Dışa aktar", @@ -2624,7 +2625,9 @@ "placeholder-search-input": "" }, "grafana-folder": "", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "Sağlık", "label": { "hide": "Gizle", @@ -2635,6 +2638,7 @@ "placeholder-all-data-sources": "Tüm veri kaynakları", "placeholder-contact-point": "", "placeholder-data-sources": "", + "placeholder-group-search": "", "placeholder-labels": "", "plugin-rules": "Eklenti kuralları", "rule-source": { @@ -3473,6 +3477,14 @@ "label-negative-y": "Negatif Y" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "Sağ tarafta boşluk bırakılmasını zorunlu kıl", "gaps-options": { @@ -3517,6 +3529,10 @@ }, "name-show-unfilled-area": "Doldurulmamış alanı göster", "name-value-display": "Değer görüntüleme", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "Gizli", "label-text-color": "Metin rengi", @@ -4169,6 +4185,7 @@ "clear": "Temizle", "collapse": "Daralt", "disabled": "", + "discard": "", "edit": "Düzenle", "help": "Yardım", "loading": "Yükleniyor...", @@ -4786,7 +4803,6 @@ "variable": "{{type}} değişkeni", "variable-set": "Değişkenler" }, - "open": "Seçenekler bölmesini aç", "row": { "header": { "hide": "Gizle", @@ -5137,6 +5153,7 @@ "title-matched_other": "{{totalCount}} seçenekten {{count}} tanesi eşleşti" }, "outline": { + "pane-header": "", "repeated-item": "Tekrar et", "tree-item": { "empty": "(boş)", @@ -5348,6 +5365,32 @@ "share-public-dashboard-loader": { "loading-configuration": "Yapılandırma yükleniyor" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "Pano yükleniyor ve başlatılıyor", "title-not-found": "{{panelId}} kimliğine sahip panel bulunamadı" @@ -5451,11 +5494,8 @@ "tooltip": "Bu pano salt okunur olarak işaretlendi" }, "export": { - "arrow": "Dışa aktar", - "title": "Dışa aktar", "tooltip": { - "as-code": "Kod olarak dışa aktar", - "json": "JSON olarak dışa aktar" + "as-code": "Kod olarak dışa aktar" } }, "more-save-options": "Diğer kaydetme seçenekleri", @@ -5507,6 +5547,9 @@ "save-library-panel": "Kütüphane panelini kaydet", "settings": "Pano ayarları", "share-button": "Paylaş", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5836,9 +5879,6 @@ "name-values-separated-comma": "Virgülle ayrılmış değerler", "selection-options": "Seçim ayarları" }, - "dashboard-edit-pane-renderer": { - "outline": "Ana hat" - }, "dashboard-link-form": { "back-to-list": "Listeye geri dön", "label-icon": "Simge", @@ -6182,8 +6222,8 @@ "placeholder-search-for": "Ara...", "radio-options": { "label": { - "suggestions": "Öneriler", - "visualizations": "Görselleştirmeler" + "all-visualizations": "", + "suggestions": "Öneriler" } }, "title-close": "Kapat" @@ -7821,6 +7861,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8118,14 +8159,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "Geri dön" - } - }, - "select-group": "" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11050,8 +11083,8 @@ "tooltip-delete": "Sil" }, "visualization-suggestions": { - "based-on-current-data": "Mevcut verilere göre", - "no-results-matched-your-query": "Sorgunuza uygun sonuç bulunamadı" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "Sorgunuzla eşleşen hiçbir şey bulunamadı" @@ -11083,6 +11116,10 @@ "pie-chart-type-options": { "label-donut": "Halka", "label-pie": "Pasta" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11666,13 +11703,6 @@ "saving": "Kaydediliyor", "title-error-loading-file": "Dosya yüklenirken hata oluştu" }, - "files-view": { - "columns": { - "history": "Geçmiş", - "view": "Görüntüle" - }, - "placeholder-search": "Ara" - }, "finish-step": { "description-enable-previews": "Çekme isteklerinde pano değişikliklerinin görsel ön izlemesini ekler. Grafana panolarınızın görselleri Git deponuzda paylaşılacak ve depo erişimi olan herkes tarafından görülebilecektir.", "description-generate-dashboard-previews": "Çekme istekleri için ön izleme bağlantıları oluşturun", @@ -11883,9 +11913,6 @@ "source-code": "Kaynak kodu" }, "repository-card": { - "get-repository-meta": { - "webhook": "Web kancası" - }, "read-only-badge": "", "settings": "Ayarlar", "view": "Görüntüle" @@ -11922,14 +11949,6 @@ "webhook-last-event": "Son Olay:", "webhook-url": "Web Kancasını Görüntüle" }, - "repository-resources": { - "columns": { - "history": "Geçmiş", - "view-dashboard": "Görüntüle", - "view-folder": "Görüntüle" - }, - "placeholder-search": "Ara" - }, "repository-status-page": { "back-to-repositories": "Depolara geri dön", "cleaning-up-resources": "Depo kaynakları temizleniyor", @@ -11937,12 +11956,10 @@ "not-found": "bulunamadı", "not-found-message": "Depo bulunamadı", "repository-config-exists-configuration": "Depo yapılandırmasının yapılandırma dosyasında mevcut olduğundan emin olun.", - "tab-files": "Dosyalar", - "tab-files-title": "Depodan ham dosya listesi", "tab-overview": "Genel Bakış", "tab-overview-title": "Depoya genel bakış", "tab-resources": "Kaynaklar", - "tab-resources-title": "Grafana veri tabanına kaydedilen kaynaklar", + "tab-resources-title": "", "title": "Depo Durumu", "title-legacy-storage": "Eski Depolama", "title-queued-for-deletion": "Silinmek üzere kuyruğa alındı" @@ -11965,6 +11982,17 @@ "pure-git": "Pure Git", "pure-git-description": "Herhangi bir Git deposuna bağlanın" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "Temel", "dashboard-preview": "Pano Ön İzlemesi", @@ -13031,6 +13059,12 @@ "label-same-as-value": "Değerle aynı", "label-standard": "Standart" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "Otomatik", "label-center": "Merkez" @@ -13368,7 +13402,8 @@ "forwards-time-aria-label": "Zaman aralığını ileri al", "to": "bitiş", "zoom-out-button": "Zaman aralığını büyüt", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "Zaman aralığını uygula", @@ -13470,6 +13505,16 @@ "label-threshold": "Eşik" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "Zaman dilimi ekle", "tooltip-remove-timezone": "Zaman dilimini kaldır" diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 4dccf320255..e96f271b082 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -474,6 +474,7 @@ "noOptionsMessage-no-datasources-found": "未找到数据源" }, "alert-menu": { + "analyze-rule": "", "copy-link": "复制链接", "duplicate": "复制", "export": "导出", @@ -2612,7 +2613,9 @@ "placeholder-search-input": "按名称搜索或输入筛选条件查询……" }, "grafana-folder": "Grafana 文件夹", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "健康", "label": { "hide": "隐藏", @@ -2623,6 +2626,7 @@ "placeholder-all-data-sources": "所有数据源", "placeholder-contact-point": "选择联络点", "placeholder-data-sources": "选择数据源", + "placeholder-group-search": "", "placeholder-labels": "选择标签", "plugin-rules": "插件规则", "rule-source": { @@ -3461,6 +3465,14 @@ "label-negative-y": "负 Y" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "需要右侧的空间", "gaps-options": { @@ -3505,6 +3517,10 @@ }, "name-show-unfilled-area": "显示未填充区域", "name-value-display": "值显示", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "隐藏", "label-text-color": "文字颜色", @@ -4149,6 +4165,7 @@ "clear": "清除", "collapse": "收起", "disabled": "", + "discard": "", "edit": "编辑", "help": "帮助", "loading": "加载中...", @@ -4766,7 +4783,6 @@ "variable": "{{type}} 变量", "variable-set": "变量" }, - "open": "打开选项窗格", "row": { "header": { "hide": "隐藏", @@ -5116,6 +5132,7 @@ "title-matched_other": "匹配了 {{count}}/{{totalCount}} 个选项" }, "outline": { + "pane-header": "", "repeated-item": "重复", "tree-item": { "empty": "(空缺)", @@ -5327,6 +5344,32 @@ "share-public-dashboard-loader": { "loading-configuration": "载入配置" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "正在加载和初始化数据面板", "title-not-found": "未找到 ID 为 {{panelId}} 的面板" @@ -5430,11 +5473,8 @@ "tooltip": "此数据面板已被标记为只读" }, "export": { - "arrow": "导出", - "title": "导出", "tooltip": { - "as-code": "导出为代码", - "json": "导出为 JSON" + "as-code": "导出为代码" } }, "more-save-options": "更多保存选项", @@ -5486,6 +5526,9 @@ "save-library-panel": "保存库面板", "settings": "仪表板设置", "share-button": "分享", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5814,9 +5857,6 @@ "name-values-separated-comma": "以逗号分隔的值", "selection-options": "选择内容选项" }, - "dashboard-edit-pane-renderer": { - "outline": "轮廓" - }, "dashboard-link-form": { "back-to-list": "回到列表", "label-icon": "图标", @@ -6159,8 +6199,8 @@ "placeholder-search-for": "搜索...", "radio-options": { "label": { - "suggestions": "建议", - "visualizations": "可视化" + "all-visualizations": "", + "suggestions": "建议" } }, "title-close": "关闭" @@ -7795,6 +7835,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8092,14 +8133,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "返回" - } - }, - "select-group": "选择组" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11003,8 +11036,8 @@ "tooltip-delete": "删除" }, "visualization-suggestions": { - "based-on-current-data": "基于当前数据", - "no-results-matched-your-query": "没有符合您查询的结果" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "找不到与您的查询匹配的内容" @@ -11036,6 +11069,10 @@ "pie-chart-type-options": { "label-donut": "圆环图", "label-pie": "饼图" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11616,13 +11653,6 @@ "saving": "正在保存", "title-error-loading-file": "加载文件时出错" }, - "files-view": { - "columns": { - "history": "历史记录", - "view": "查看" - }, - "placeholder-search": "搜索" - }, "finish-step": { "description-enable-previews": "在拉取请求中添加数据面板更改的图像预览。Grafana 数据面板的图像将在 Git 存储库中共享,并对具有存储库访问权限的任何人可见。", "description-generate-dashboard-previews": "创建拉取请求的预览链接", @@ -11831,9 +11861,6 @@ "source-code": "源代码" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "只读", "settings": "设置", "view": "查看" @@ -11870,14 +11897,6 @@ "webhook-last-event": "最后一个事件:", "webhook-url": "查看 Webhook" }, - "repository-resources": { - "columns": { - "history": "历史记录", - "view-dashboard": "查看", - "view-folder": "查看" - }, - "placeholder-search": "搜索" - }, "repository-status-page": { "back-to-repositories": "回到存储库", "cleaning-up-resources": "清理存储库资源", @@ -11885,12 +11904,10 @@ "not-found": "找不到", "not-found-message": "找不到存储库", "repository-config-exists-configuration": "确保存储库配置存在于配置文件中。", - "tab-files": "文件", - "tab-files-title": "来自存储库的原始文件列表", "tab-overview": "概述", "tab-overview-title": "存储库概览", "tab-resources": "资源", - "tab-resources-title": "保存在 Grafana 数据库中的资源", + "tab-resources-title": "", "title": "存储库状态", "title-legacy-storage": "传统存储", "title-queued-for-deletion": "已进入队列等待删除" @@ -11913,6 +11930,17 @@ "pure-git": "纯 Git", "pure-git-description": "连接到任何 Git 存储库" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "基本", "dashboard-preview": "数据面板预览", @@ -12975,6 +13003,12 @@ "label-same-as-value": "与值相同", "label-standard": "标准" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "自动", "label-center": "中心" @@ -13311,7 +13345,8 @@ "forwards-time-aria-label": "向前移动时间范围", "to": "至", "zoom-out-button": "缩放时间范围", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "应用时间范围", @@ -13413,6 +13448,16 @@ "label-threshold": "阈值" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "添加时区", "tooltip-remove-timezone": "移除时区" diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index dec7715e1fd..561d6b53c3a 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -474,6 +474,7 @@ "noOptionsMessage-no-datasources-found": "未找到資料來源" }, "alert-menu": { + "analyze-rule": "", "copy-link": "複製網址", "duplicate": "重複", "export": "匯出", @@ -2612,7 +2613,9 @@ "placeholder-search-input": "按名稱搜尋或輸入篩選條件查詢…" }, "grafana-folder": "Grafana 資料夾", - "group-autocomplete-unavailable": "", + "group-no-results": "", + "group-search-error": "", + "group-search-prompt": "", "health": "使用情況", "label": { "hide": "隱藏", @@ -2623,6 +2626,7 @@ "placeholder-all-data-sources": "所有資料來源", "placeholder-contact-point": "選擇聯絡點", "placeholder-data-sources": "選擇資料來源", + "placeholder-group-search": "", "placeholder-labels": "選擇標籤", "plugin-rules": "外掛程式規則", "rule-source": { @@ -3461,6 +3465,14 @@ "label-negative-y": "負 Y" } }, + "suggestions": { + "horizontal": "", + "hz-stacked": "", + "hz-stacked-percent": "", + "vert-stacked": "", + "vert-stacked-percent": "", + "vertical": "" + }, "tick-spacing-editor": { "content-require-space-from-the-right-side": "需要在右側保留空間", "gaps-options": { @@ -3505,6 +3517,10 @@ }, "name-show-unfilled-area": "顯示未填充區域", "name-value-display": "數值顯示", + "suggestions": { + "basic": "", + "lcd": "" + }, "value-display-options": { "label-hidden": "隱藏", "label-text-color": "文字色彩", @@ -4149,6 +4165,7 @@ "clear": "清除", "collapse": "收闔", "disabled": "", + "discard": "", "edit": "編輯", "help": "說明", "loading": "正在載入…", @@ -4766,7 +4783,6 @@ "variable": "{{type}}變數", "variable-set": "變量" }, - "open": "開啟選項窗格", "row": { "header": { "hide": "隱藏", @@ -5116,6 +5132,7 @@ "title-matched_other": "匹配的 {{count}}/{{totalCount}} 個選項" }, "outline": { + "pane-header": "", "repeated-item": "重複", "tree-item": { "empty": "(空)", @@ -5327,6 +5344,32 @@ "share-public-dashboard-loader": { "loading-configuration": "正在匯入設定" }, + "sidebar": { + "dashboard-options": { + "title": "", + "tooltip": "" + }, + "edit-schema": { + "title": "", + "tooltip": "" + }, + "export": { + "title": "", + "unsaved-modal": { + "text": "", + "title": "" + } + }, + "outline": { + "title": "", + "tooltip": "" + }, + "redo": "", + "snapshot": { + "tooltip": "" + }, + "undo": "" + }, "solo-panel": { "loading-initializing-dashboard": "正在載入並初始化儀表板", "title-not-found": "未找到 ID 為 {{panelId}} 的面板" @@ -5430,11 +5473,8 @@ "tooltip": "此儀表板已標記為唯讀" }, "export": { - "arrow": "匯出", - "title": "匯出", "tooltip": { - "as-code": "匯出為程式碼", - "json": "匯出為 JSON" + "as-code": "匯出為程式碼" } }, "more-save-options": "更多儲存選項", @@ -5486,6 +5526,9 @@ "save-library-panel": "儲存資料庫面板", "settings": "儀表板設定", "share-button": "分享", + "snapshot": { + "title": "" + }, "star-add-error": "", "star-added": "", "star-remove-error": "", @@ -5814,9 +5857,6 @@ "name-values-separated-comma": "以逗號分隔的值", "selection-options": "選擇選項" }, - "dashboard-edit-pane-renderer": { - "outline": "外框" - }, "dashboard-link-form": { "back-to-list": "返回清單", "label-icon": "圖示", @@ -6159,8 +6199,8 @@ "placeholder-search-for": "搜尋…", "radio-options": { "label": { - "suggestions": "建議", - "visualizations": "視覺化" + "all-visualizations": "", + "suggestions": "建議" } }, "title-close": "關閉" @@ -7795,6 +7835,7 @@ "suggestions": { "arc": "", "circular": "", + "no-thresholds": "", "style": { "circular": "", "simple": "" @@ -8092,14 +8133,6 @@ } } }, - "grafana": { - "dashboard": { - "edit-pane": { - "go-back": "返回" - } - }, - "select-group": "選擇群組" - }, "grafana-data": { "datetime": { "rangeutils": { @@ -11003,8 +11036,8 @@ "tooltip-delete": "刪除" }, "visualization-suggestions": { - "based-on-current-data": "根據目前的資料", - "no-results-matched-your-query": "沒有符合您查詢的結果" + "apply-suggestion-aria-label": "", + "use-this-suggestion": "" }, "viz-type-picker": { "could-anything-matching-query": "找不到符合您查詢的內容" @@ -11036,6 +11069,10 @@ "pie-chart-type-options": { "label-donut": "甜甜圈圖", "label-pie": "圓餅圖" + }, + "suggestions": { + "donut": "", + "pie": "" } }, "playlist": { @@ -11616,13 +11653,6 @@ "saving": "正在儲存", "title-error-loading-file": "載入檔案時發生錯誤" }, - "files-view": { - "columns": { - "history": "歷史紀錄", - "view": "檢視" - }, - "placeholder-search": "搜尋" - }, "finish-step": { "description-enable-previews": "在拉取請求中新增儀表板變更的圖片預覽。您的 Grafana 儀表板圖像將在您的 Git 儲存庫中共用,並且任何具有儲存庫存取權限者都可以看見。", "description-generate-dashboard-previews": "建立拉取請求的預覽連結", @@ -11831,9 +11861,6 @@ "source-code": "原始碼" }, "repository-card": { - "get-repository-meta": { - "webhook": "Webhook" - }, "read-only-badge": "唯讀", "settings": "設定", "view": "檢視" @@ -11870,14 +11897,6 @@ "webhook-last-event": "上次事件:", "webhook-url": "檢視 Webhook" }, - "repository-resources": { - "columns": { - "history": "歷史紀錄", - "view-dashboard": "檢視", - "view-folder": "檢視" - }, - "placeholder-search": "搜尋" - }, "repository-status-page": { "back-to-repositories": "返回至儲存庫", "cleaning-up-resources": "清理儲存庫資源", @@ -11885,12 +11904,10 @@ "not-found": "找不到", "not-found-message": "找不到儲存庫", "repository-config-exists-configuration": "請確認儲存庫設定存在於設定檔案中。", - "tab-files": "檔案", - "tab-files-title": "儲存庫中的原始檔案清單", "tab-overview": "概覽", "tab-overview-title": "儲存庫概覽", "tab-resources": "資源", - "tab-resources-title": "資源儲存在 grafana 資料庫中", + "tab-resources-title": "", "title": "儲存庫狀態", "title-legacy-storage": "舊版儲存空間", "title-queued-for-deletion": "已排入刪除佇列" @@ -11913,6 +11930,17 @@ "pure-git": "純 Git", "pure-git-description": "連接到任何 Git 儲存庫" }, + "resource-tree": { + "header-hash": "", + "header-status": "", + "header-title": "", + "header-type": "", + "search-placeholder": "", + "source": "", + "status-pending": "", + "status-synced": "", + "view": "" + }, "resource-view": { "base": "基數", "dashboard-preview": "儀表板預覽", @@ -12975,6 +13003,12 @@ "label-same-as-value": "與值相同", "label-standard": "標準" }, + "suggestions": { + "stat-color-background": "", + "stat-discrete-values": "", + "stat-discrete-values-color-background": "", + "stat-single-string": "" + }, "text-alignment-options": { "label-auto": "自動", "label-center": "置中" @@ -13311,7 +13345,8 @@ "forwards-time-aria-label": "將時間範圍向前移動", "to": "至", "zoom-out-button": "縮小時間範圍", - "zoom-out-tooltip": "" + "zoom-out-tooltip": "", + "zoom-out-tooltip-new": "" }, "time-range": { "apply": "套用時間範圍", @@ -13413,6 +13448,16 @@ "label-threshold": "閾值" } }, + "suggestions": { + "area": "", + "area-stacked": "", + "area-stacked-percentage": "", + "bar": "", + "bar-stacked": "", + "bar-stacked-percent": "", + "line": "", + "line-smooth": "" + }, "timezones-editor": { "tooltip-add-timezone": "新增時區", "tooltip-remove-timezone": "移除時區" diff --git a/public/openapi3.json b/public/openapi3.json index 20b97b3f14d..52322514490 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -12703,17 +12703,10 @@ }, "UpdateTeamCommand": { "properties": { - "Email": { + "email": { "type": "string" }, - "ExternalUID": { - "type": "string" - }, - "ID": { - "format": "int64", - "type": "integer" - }, - "Name": { + "name": { "type": "string" } }, @@ -24935,6 +24928,14 @@ "schema": { "type": "string" } + }, + { + "in": "query", + "name": "accesscontrol", + "schema": { + "default": false, + "type": "boolean" + } } ], "responses": { diff --git a/scripts/publish-npm-packages.sh b/scripts/publish-npm-packages.sh index eaa2e595824..34dc06fc009 100755 --- a/scripts/publish-npm-packages.sh +++ b/scripts/publish-npm-packages.sh @@ -60,7 +60,7 @@ if (( ${#failed_packages[@]} > 0 )); then fi # Check if any files in packages/grafana-e2e-selectors were changed. If so, add a 'modified' tag to the package -CHANGES_COUNT=$(git diff HEAD~1..HEAD --name-only -- packages/grafana-e2e-selectors | awk 'END{print NR}') +CHANGES_COUNT=$(git show --name-only --format= HEAD -- packages/grafana-e2e-selectors | awk 'END{print NR}') if (( CHANGES_COUNT > 0 )); then # Wait a little bit to allow the package to be published to the registry sleep 5s diff --git a/yarn.lock b/yarn.lock index 131a46fe1fc..abe69dda8b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1473,7 +1473,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.7, @babel/runtime@npm:^7.27.0, @babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.28.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.26.7, @babel/runtime@npm:^7.27.0, @babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.28.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7": version: 7.28.4 resolution: "@babel/runtime@npm:7.28.4" checksum: 10/6c9a70452322ea80b3c9b2a412bcf60771819213a67576c8cec41e88a95bb7bf01fc983754cda35dc19603eef52df22203ccbf7777b9d6316932f9fb77c25163 @@ -2324,15 +2324,15 @@ __metadata: languageName: node linkType: hard -"@formatjs/ecma402-abstract@npm:2.3.4": - version: 2.3.4 - resolution: "@formatjs/ecma402-abstract@npm:2.3.4" +"@formatjs/ecma402-abstract@npm:2.3.6": + version: 2.3.6 + resolution: "@formatjs/ecma402-abstract@npm:2.3.6" dependencies: "@formatjs/fast-memoize": "npm:2.2.7" - "@formatjs/intl-localematcher": "npm:0.6.1" + "@formatjs/intl-localematcher": "npm:0.6.2" decimal.js: "npm:^10.4.3" tslib: "npm:^2.8.0" - checksum: 10/573971ffc291096a4b9fcc80b4708124e89bf2e3ac50e0f78b41eb797e9aa1b842f4dc3665e4467a853c738386821769d9e40408a1d25bc73323a1f057a16cf2 + checksum: 10/30b1b5cd6b62ba46245f934429936592df5500bc1b089dc92dd49c826757b873dd92c305dcfe370701e4df6b057bf007782113abb9b65db550d73be4961718bc languageName: node linkType: hard @@ -2376,13 +2376,13 @@ __metadata: linkType: hard "@formatjs/intl-durationformat@npm:^0.7.0": - version: 0.7.4 - resolution: "@formatjs/intl-durationformat@npm:0.7.4" + version: 0.7.6 + resolution: "@formatjs/intl-durationformat@npm:0.7.6" dependencies: - "@formatjs/ecma402-abstract": "npm:2.3.4" - "@formatjs/intl-localematcher": "npm:0.6.1" + "@formatjs/ecma402-abstract": "npm:2.3.6" + "@formatjs/intl-localematcher": "npm:0.6.2" tslib: "npm:^2.8.0" - checksum: 10/d62273ecd635475ca91e9b501301f3f396403fa91b584c550734b19b2d194ba1316b27303fed985c1d42ae933d54eb220da6540edfdf376b0d9371ecfd0d4e15 + checksum: 10/442236ba85bcd9cb7296c43a708271fa09f110b1ca9d5899066d00812fc2965eaeaec6b5240be421b80daba62860352131088449ba0fcd2061f671cec6240f0b languageName: node linkType: hard @@ -2395,12 +2395,12 @@ __metadata: languageName: node linkType: hard -"@formatjs/intl-localematcher@npm:0.6.1": - version: 0.6.1 - resolution: "@formatjs/intl-localematcher@npm:0.6.1" +"@formatjs/intl-localematcher@npm:0.6.2": + version: 0.6.2 + resolution: "@formatjs/intl-localematcher@npm:0.6.2" dependencies: tslib: "npm:^2.8.0" - checksum: 10/c7b3bc8395d18670677f207b2fd107561fff5d6394a9b4273c29e0bea920300ec3a2eefead600ebb7761c04a770cada28f78ac059f84d00520bfb57a9db36998 + checksum: 10/eb12a7f5367bbecdfafc20d7f005559ce840f420e970f425c5213d35e94e86dfe75bde03464971a26494bf8427d4961269db22ecad2834f2a19d888b5d9cc064 languageName: node linkType: hard @@ -3260,11 +3260,15 @@ __metadata: "@grafana/ui": "npm:12.4.0-pre" "@leeoniya/ufuzzy": "npm:1.0.19" "@rollup/plugin-node-resolve": "npm:16.0.1" + "@storybook/addon-webpack5-compiler-swc": "npm:^4.0.2" + "@storybook/react": "npm:^10.0.8" + "@storybook/react-webpack5": "npm:^10.0.8" "@testing-library/dom": "npm:10.4.1" "@testing-library/jest-dom": "npm:^6.1.2" "@testing-library/react": "npm:16.3.0" "@testing-library/user-event": "npm:14.6.1" "@types/d3": "npm:^7" + "@types/fs-extra": "npm:^11" "@types/jest": "npm:^29.5.4" "@types/lodash": "npm:4.17.20" "@types/node": "npm:24.10.1" @@ -3274,6 +3278,7 @@ __metadata: babel-jest: "npm:29.7.0" d3: "npm:^7.8.5" esbuild: "npm:0.25.8" + fs-extra: "npm:^11.3.2" jest: "npm:^29.6.4" jest-canvas-mock: "npm:2.5.2" lodash: "npm:4.17.21" @@ -3283,6 +3288,7 @@ __metadata: rollup: "npm:^4.22.4" rollup-plugin-esbuild: "npm:6.2.1" rollup-plugin-node-externals: "npm:^8.0.0" + storybook: "npm:^10.0.8" tinycolor2: "npm:1.6.0" ts-jest: "npm:29.4.0" ts-node: "npm:10.9.2" @@ -5020,10 +5026,10 @@ __metadata: languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.15, @jridgewell/sourcemap-codec@npm:^1.5.0": - version: 1.5.0 - resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" - checksum: 10/4ed6123217569a1484419ac53f6ea0d9f3b57e5b57ab30d7c267bdb27792a27eb0e4b08e84a2680aa55cc2f2b411ffd6ec3db01c44fdc6dc43aca4b55f8374fd +"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.15, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10/5d9d207b462c11e322d71911e55e21a4e2772f71ffe8d6f1221b8eb5ae6774458c1d242f897fb0814e8714ca9a6b498abfa74dfe4f434493342902b1a48b33a5 languageName: node linkType: hard @@ -6096,9 +6102,9 @@ __metadata: linkType: hard "@openfeature/core@npm:^1.9.0": - version: 1.9.0 - resolution: "@openfeature/core@npm:1.9.0" - checksum: 10/c6d20edc09053afd99752fe46d8328158680950bca4b86679f67f79249d7226eea127b31fffdc38e26ecb729f2bab5a4a5a7c1db708ae76b7fbbac68cd56f094 + version: 1.9.1 + resolution: "@openfeature/core@npm:1.9.1" + checksum: 10/6099e16b1b4cd6e3c45c05ab4acd44c9cb4ab501b676ab6f3e77f0be1b56abc7506c5629187381333a02975d315d831e0905582435b356d9676255e72a465899 languageName: node linkType: hard @@ -6123,11 +6129,11 @@ __metadata: linkType: hard "@openfeature/web-sdk@npm:^1.6.1": - version: 1.6.1 - resolution: "@openfeature/web-sdk@npm:1.6.1" + version: 1.7.1 + resolution: "@openfeature/web-sdk@npm:1.7.1" peerDependencies: "@openfeature/core": ^1.9.0 - checksum: 10/8bd7d1ea386e21cdd7492cab2fd1d2b138b4e6a376a4c0a40244633e5955f6452039bc2633fc5230bd7b494506a4137ba7210d40850634f9618f77a0ee435f9d + checksum: 10/358cabfddda8bf67a9bd96ce28f0f84a3218416907f8df4f2a58750ee840d164d86a1c859e8c9aa92d132fa28fcf72a0f17ceece798ff8af297d3e1c1e30cdab languageName: node linkType: hard @@ -7943,6 +7949,18 @@ __metadata: languageName: node linkType: hard +"@storybook/addon-webpack5-compiler-swc@npm:^4.0.2": + version: 4.0.2 + resolution: "@storybook/addon-webpack5-compiler-swc@npm:4.0.2" + dependencies: + "@swc/core": "npm:^1.13.5" + swc-loader: "npm:^0.2.6" + peerDependencies: + storybook: ^9.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 + checksum: 10/1383346b97d9f679ad830751dd1c94c8ab1c6aeb141feda60dcc7ef2ac69e97c3fb3a2a2a3f020e07598dea315760cb0c079f928f29faaf12f143d627487b0e1 + languageName: node + linkType: hard + "@storybook/blocks@npm:8.6.2, @storybook/blocks@npm:^8.6.2": version: 8.6.2 resolution: "@storybook/blocks@npm:8.6.2" @@ -7962,6 +7980,34 @@ __metadata: languageName: node linkType: hard +"@storybook/builder-webpack5@npm:10.0.8": + version: 10.0.8 + resolution: "@storybook/builder-webpack5@npm:10.0.8" + dependencies: + "@storybook/core-webpack": "npm:10.0.8" + case-sensitive-paths-webpack-plugin: "npm:^2.4.0" + cjs-module-lexer: "npm:^1.2.3" + css-loader: "npm:^7.1.2" + es-module-lexer: "npm:^1.5.0" + fork-ts-checker-webpack-plugin: "npm:^9.1.0" + html-webpack-plugin: "npm:^5.5.0" + magic-string: "npm:^0.30.5" + style-loader: "npm:^4.0.0" + terser-webpack-plugin: "npm:^5.3.14" + ts-dedent: "npm:^2.0.0" + webpack: "npm:5" + webpack-dev-middleware: "npm:^6.1.2" + webpack-hot-middleware: "npm:^2.25.1" + webpack-virtual-modules: "npm:^0.6.0" + peerDependencies: + storybook: ^10.0.8 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/136ad58f58a3595d443fa4bd3a5dd9cca30a52a59c3a34e82d1b32b1be82e292b02d3c4b74204b1e5d92c3bab3de86a164ae69f19671a52408da5cc14f550720 + languageName: node + linkType: hard + "@storybook/builder-webpack5@npm:8.6.2": version: 8.6.2 resolution: "@storybook/builder-webpack5@npm:8.6.2" @@ -8017,6 +8063,17 @@ __metadata: languageName: node linkType: hard +"@storybook/core-webpack@npm:10.0.8": + version: 10.0.8 + resolution: "@storybook/core-webpack@npm:10.0.8" + dependencies: + ts-dedent: "npm:^2.0.0" + peerDependencies: + storybook: ^10.0.8 + checksum: 10/b71baea4b0c929d1173c39bf287be94fd2862c91cf0945bed62331a4ed2b6f96dc9273b1d8b229e885cfd260d0cac7d7a8a86582a20d95c5bf27669be198752e + languageName: node + linkType: hard + "@storybook/core-webpack@npm:8.6.2": version: 8.6.2 resolution: "@storybook/core-webpack@npm:8.6.2" @@ -8070,13 +8127,13 @@ __metadata: languageName: node linkType: hard -"@storybook/icons@npm:^1.2.12": - version: 1.2.12 - resolution: "@storybook/icons@npm:1.2.12" +"@storybook/icons@npm:^1.2.12, @storybook/icons@npm:^1.6.0": + version: 1.6.0 + resolution: "@storybook/icons@npm:1.6.0" peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 10/5df56f0856764ed7e4bb24ef7a08a8a9c93f8eedcb16dac062f1dfd3bd1fe6cb4a0aa5a0794083d95e31c04960d126a4d2028cfb4c53681bf05513bb38eae9d2 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + checksum: 10/f9036ca3b0d2904778eb4e202305f2780b549434380f9760f0bc704fe3ee19d7332f9560a66435ebb2156346cee9a863e40fa5e4b27790bf993b0c1180a3146d languageName: node linkType: hard @@ -8108,6 +8165,30 @@ __metadata: languageName: node linkType: hard +"@storybook/preset-react-webpack@npm:10.0.8": + version: 10.0.8 + resolution: "@storybook/preset-react-webpack@npm:10.0.8" + dependencies: + "@storybook/core-webpack": "npm:10.0.8" + "@storybook/react-docgen-typescript-plugin": "npm:1.0.6--canary.9.0c3f3b7.0" + "@types/semver": "npm:^7.3.4" + magic-string: "npm:^0.30.5" + react-docgen: "npm:^7.1.1" + resolve: "npm:^1.22.8" + semver: "npm:^7.3.7" + tsconfig-paths: "npm:^4.2.0" + webpack: "npm:5" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.0.8 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/b361b27b1550db1f03888fb06596cee35e1b6724aa41eafc23e47b28623d64c5ec1d33d0120f0280e49064a573144ccf8de114014f1d2f23730045be0ac86773 + languageName: node + linkType: hard + "@storybook/preset-react-webpack@npm:8.6.2": version: 8.6.2 resolution: "@storybook/preset-react-webpack@npm:8.6.2" @@ -8172,6 +8253,17 @@ __metadata: languageName: node linkType: hard +"@storybook/react-dom-shim@npm:10.0.8": + version: 10.0.8 + resolution: "@storybook/react-dom-shim@npm:10.0.8" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.0.8 + checksum: 10/99106afb92644ce801c0c24923e40d78d7ed935b9f8f10ceb1a1a827d777d294c37ecbdf793148747dcb4e735a34d876c691b6bac82c7058deef9ad451147dd1 + languageName: node + linkType: hard + "@storybook/react-dom-shim@npm:8.6.2": version: 8.6.2 resolution: "@storybook/react-dom-shim@npm:8.6.2" @@ -8183,6 +8275,25 @@ __metadata: languageName: node linkType: hard +"@storybook/react-webpack5@npm:^10.0.8": + version: 10.0.8 + resolution: "@storybook/react-webpack5@npm:10.0.8" + dependencies: + "@storybook/builder-webpack5": "npm:10.0.8" + "@storybook/preset-react-webpack": "npm:10.0.8" + "@storybook/react": "npm:10.0.8" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.0.8 + typescript: ">= 4.9.x" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/384b1261a9a94c4399109da0121c0070b8138a1a1ce7d1a39bab43fe5c4898144ffacb22308d6d7101677f5c4b70ffe0001b41054cc675ffe8d0fa7c84e9ed24 + languageName: node + linkType: hard + "@storybook/react-webpack5@npm:^8.6.2": version: 8.6.2 resolution: "@storybook/react-webpack5@npm:8.6.2" @@ -8202,6 +8313,24 @@ __metadata: languageName: node linkType: hard +"@storybook/react@npm:10.0.8, @storybook/react@npm:^10.0.8": + version: 10.0.8 + resolution: "@storybook/react@npm:10.0.8" + dependencies: + "@storybook/global": "npm:^5.0.0" + "@storybook/react-dom-shim": "npm:10.0.8" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.0.8 + typescript: ">= 4.9.x" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/cbb46246958a0d2db0cc641661a14e5ae20b08ee4d2626e6d39ae4332053b3c6d84bdb77e36bc839ae7dd770504049f187466383ee06aac33bc2ef5a1c69b78f + languageName: node + linkType: hard + "@storybook/react@npm:8.6.2, @storybook/react@npm:^8.6.2": version: 8.6.2 resolution: "@storybook/react@npm:8.6.2" @@ -8881,6 +9010,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-darwin-arm64@npm:1.15.3": + version: 1.15.3 + resolution: "@swc/core-darwin-arm64@npm:1.15.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@swc/core-darwin-x64@npm:1.13.3": version: 1.13.3 resolution: "@swc/core-darwin-x64@npm:1.13.3" @@ -8895,6 +9031,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-darwin-x64@npm:1.15.3": + version: 1.15.3 + resolution: "@swc/core-darwin-x64@npm:1.15.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@swc/core-linux-arm-gnueabihf@npm:1.13.3": version: 1.13.3 resolution: "@swc/core-linux-arm-gnueabihf@npm:1.13.3" @@ -8909,6 +9052,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-arm-gnueabihf@npm:1.15.3": + version: 1.15.3 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.15.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@swc/core-linux-arm64-gnu@npm:1.13.3": version: 1.13.3 resolution: "@swc/core-linux-arm64-gnu@npm:1.13.3" @@ -8923,6 +9073,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-arm64-gnu@npm:1.15.3": + version: 1.15.3 + resolution: "@swc/core-linux-arm64-gnu@npm:1.15.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@swc/core-linux-arm64-musl@npm:1.13.3": version: 1.13.3 resolution: "@swc/core-linux-arm64-musl@npm:1.13.3" @@ -8937,6 +9094,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-arm64-musl@npm:1.15.3": + version: 1.15.3 + resolution: "@swc/core-linux-arm64-musl@npm:1.15.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@swc/core-linux-x64-gnu@npm:1.13.3": version: 1.13.3 resolution: "@swc/core-linux-x64-gnu@npm:1.13.3" @@ -8951,6 +9115,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-x64-gnu@npm:1.15.3": + version: 1.15.3 + resolution: "@swc/core-linux-x64-gnu@npm:1.15.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@swc/core-linux-x64-musl@npm:1.13.3": version: 1.13.3 resolution: "@swc/core-linux-x64-musl@npm:1.13.3" @@ -8965,6 +9136,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-x64-musl@npm:1.15.3": + version: 1.15.3 + resolution: "@swc/core-linux-x64-musl@npm:1.15.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@swc/core-win32-arm64-msvc@npm:1.13.3": version: 1.13.3 resolution: "@swc/core-win32-arm64-msvc@npm:1.13.3" @@ -8979,6 +9157,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-win32-arm64-msvc@npm:1.15.3": + version: 1.15.3 + resolution: "@swc/core-win32-arm64-msvc@npm:1.15.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@swc/core-win32-ia32-msvc@npm:1.13.3": version: 1.13.3 resolution: "@swc/core-win32-ia32-msvc@npm:1.13.3" @@ -8993,6 +9178,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-win32-ia32-msvc@npm:1.15.3": + version: 1.15.3 + resolution: "@swc/core-win32-ia32-msvc@npm:1.15.3" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@swc/core-win32-x64-msvc@npm:1.13.3": version: 1.13.3 resolution: "@swc/core-win32-x64-msvc@npm:1.13.3" @@ -9007,6 +9199,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-win32-x64-msvc@npm:1.15.3": + version: 1.15.3 + resolution: "@swc/core-win32-x64-msvc@npm:1.15.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@swc/core@npm:1.13.3": version: 1.13.3 resolution: "@swc/core@npm:1.13.3" @@ -9053,7 +9252,7 @@ __metadata: languageName: node linkType: hard -"@swc/core@npm:1.15.2, @swc/core@npm:^1.10.8, @swc/core@npm:^1.5.22": +"@swc/core@npm:1.15.2": version: 1.15.2 resolution: "@swc/core@npm:1.15.2" dependencies: @@ -9099,6 +9298,52 @@ __metadata: languageName: node linkType: hard +"@swc/core@npm:^1.10.8, @swc/core@npm:^1.13.5, @swc/core@npm:^1.5.22": + version: 1.15.3 + resolution: "@swc/core@npm:1.15.3" + dependencies: + "@swc/core-darwin-arm64": "npm:1.15.3" + "@swc/core-darwin-x64": "npm:1.15.3" + "@swc/core-linux-arm-gnueabihf": "npm:1.15.3" + "@swc/core-linux-arm64-gnu": "npm:1.15.3" + "@swc/core-linux-arm64-musl": "npm:1.15.3" + "@swc/core-linux-x64-gnu": "npm:1.15.3" + "@swc/core-linux-x64-musl": "npm:1.15.3" + "@swc/core-win32-arm64-msvc": "npm:1.15.3" + "@swc/core-win32-ia32-msvc": "npm:1.15.3" + "@swc/core-win32-x64-msvc": "npm:1.15.3" + "@swc/counter": "npm:^0.1.3" + "@swc/types": "npm:^0.1.25" + peerDependencies: + "@swc/helpers": ">=0.5.17" + dependenciesMeta: + "@swc/core-darwin-arm64": + optional: true + "@swc/core-darwin-x64": + optional: true + "@swc/core-linux-arm-gnueabihf": + optional: true + "@swc/core-linux-arm64-gnu": + optional: true + "@swc/core-linux-arm64-musl": + optional: true + "@swc/core-linux-x64-gnu": + optional: true + "@swc/core-linux-x64-musl": + optional: true + "@swc/core-win32-arm64-msvc": + optional: true + "@swc/core-win32-ia32-msvc": + optional: true + "@swc/core-win32-x64-msvc": + optional: true + peerDependenciesMeta: + "@swc/helpers": + optional: true + checksum: 10/280330d82328818138ed64fdcf9ea9abde6b6f16eca65a9d4db27dde06a8dfffd2649f3447d2243387277513c7430fa4142cafcfd64e943d682ce6a713cb8c2d + languageName: node + linkType: hard + "@swc/counter@npm:^0.1.3": version: 0.1.3 resolution: "@swc/counter@npm:0.1.3" @@ -9480,6 +9725,16 @@ __metadata: languageName: node linkType: hard +"@types/chai@npm:^5.2.2": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10/e79947307dc235953622e65f83d2683835212357ca261389116ab90bed369ac862ba28b146b4fed08b503ae1e1a12cb93ce783f24bb8d562950469f4320e1c7c + languageName: node + linkType: hard + "@types/chance@npm:^1.1.7": version: 1.1.7 resolution: "@types/chance@npm:1.1.7" @@ -9844,6 +10099,13 @@ __metadata: languageName: node linkType: hard +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10/249a27b0bb22f6aa28461db56afa21ec044fa0e303221a62dff81831b20c8530502175f1a49060f7099e7be06181078548ac47c668de79ff9880241968d43d0c + languageName: node + linkType: hard + "@types/doctrine@npm:^0.0.9": version: 0.0.9 resolution: "@types/doctrine@npm:0.0.9" @@ -9916,7 +10178,7 @@ __metadata: languageName: node linkType: hard -"@types/fs-extra@npm:^11.0.4": +"@types/fs-extra@npm:^11, @types/fs-extra@npm:^11.0.4": version: 11.0.4 resolution: "@types/fs-extra@npm:11.0.4" dependencies: @@ -11134,6 +11396,38 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/expect@npm:3.2.4" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:3.2.4" + "@vitest/utils": "npm:3.2.4" + chai: "npm:^5.2.0" + tinyrainbow: "npm:^2.0.0" + checksum: 10/dc69ce886c13714dfbbff78f2d2cb7eb536017e82301a73c42d573a9e9d2bf91005ac7abd9b977adf0a3bd431209f45a8ac2418029b68b0a377e092607c843ce + languageName: node + linkType: hard + +"@vitest/mocker@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/mocker@npm:3.2.4" + dependencies: + "@vitest/spy": "npm:3.2.4" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.17" + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10/5e92431b6ed9fc1679060e4caef3e4623f4750542a5d7cd944774f8217c4d231e273202e8aea00bab33260a5a9222ecb7005d80da0348c3c829bd37d123071a8 + languageName: node + linkType: hard + "@vitest/pretty-format@npm:2.0.5": version: 2.0.5 resolution: "@vitest/pretty-format@npm:2.0.5" @@ -11152,6 +11446,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/pretty-format@npm:3.2.4" + dependencies: + tinyrainbow: "npm:^2.0.0" + checksum: 10/8dd30cbf956e01fbab042fe651fb5175d9f0cd00b7b569a46cd98df89c4fec47dab12916201ad6e09a4f25f2a2ec8927a4bfdc61118593097f759c90b18a51d4 + languageName: node + linkType: hard + "@vitest/spy@npm:2.0.5": version: 2.0.5 resolution: "@vitest/spy@npm:2.0.5" @@ -11161,6 +11464,15 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/spy@npm:3.2.4" + dependencies: + tinyspy: "npm:^4.0.3" + checksum: 10/7d38c299f42a8c7e5e41652b203af98ca54e63df69c3b072d0e401d5a57fbbba3e39d8538ac1b3022c26718a6388d0bcc222bc2f07faab75942543b9247c007d + languageName: node + linkType: hard + "@vitest/utils@npm:2.0.5": version: 2.0.5 resolution: "@vitest/utils@npm:2.0.5" @@ -11173,6 +11485,17 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/utils@npm:3.2.4" + dependencies: + "@vitest/pretty-format": "npm:3.2.4" + loupe: "npm:^3.1.4" + tinyrainbow: "npm:^2.0.0" + checksum: 10/7f12ef63bd8ee13957744d1f336b0405f164ade4358bf9dfa531f75bbb58ffac02bf61aba65724311ddbc50b12ba54853a169e59c6b837c16086173b9a480710 + languageName: node + linkType: hard + "@vitest/utils@npm:^2.1.1": version: 2.1.9 resolution: "@vitest/utils@npm:2.1.9" @@ -12728,19 +13051,19 @@ __metadata: linkType: hard "body-parser@npm:^2.2.0": - version: 2.2.0 - resolution: "body-parser@npm:2.2.0" + version: 2.2.1 + resolution: "body-parser@npm:2.2.1" dependencies: bytes: "npm:^3.1.2" content-type: "npm:^1.0.5" - debug: "npm:^4.4.0" + debug: "npm:^4.4.3" http-errors: "npm:^2.0.0" - iconv-lite: "npm:^0.6.3" + iconv-lite: "npm:^0.7.0" on-finished: "npm:^2.4.1" qs: "npm:^6.14.0" - raw-body: "npm:^3.0.0" - type-is: "npm:^2.0.0" - checksum: 10/e9d844b036bd15970df00a16f373c7ed28e1ef870974a0a1d4d6ef60d70e01087cc20a0dbb2081c49a88e3c08ce1d87caf1e2898c615dffa193f63e8faa8a84e + raw-body: "npm:^3.0.1" + type-is: "npm:^2.0.1" + checksum: 10/cab162d62da03058dec8ff4ebf6bf22922b46bf32bd85e59e7fca78d4962aec97b7a7f913dbc3204bb4aa058df03284463ca4c5cc920bf783e591b8de049ffe0 languageName: node linkType: hard @@ -12957,7 +13280,7 @@ __metadata: languageName: node linkType: hard -"bytes@npm:3.1.2, bytes@npm:^3.1.2": +"bytes@npm:3.1.2, bytes@npm:^3.1.2, bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388 @@ -13188,16 +13511,16 @@ __metadata: languageName: node linkType: hard -"chai@npm:^5.1.1": - version: 5.2.0 - resolution: "chai@npm:5.2.0" +"chai@npm:^5.1.1, chai@npm:^5.2.0": + version: 5.3.3 + resolution: "chai@npm:5.3.3" dependencies: assertion-error: "npm:^2.0.1" check-error: "npm:^2.1.1" deep-eql: "npm:^5.0.1" loupe: "npm:^3.1.0" pathval: "npm:^2.0.0" - checksum: 10/2ce03671c159c6a567bf1912756daabdbb7c075f3c0078f1b59d61da8d276936367ee696dfe093b49e1479d9ba93a6074c8e55d49791dddd8061728cdcad249e + checksum: 10/0d0ef63106083b05c7ba510697cd9991a02b8df5984a7d010ab4af10205c7a1f27d1c06bfa4679540894295ac4dcc22aa2a281e2e4cfe5133c1db379626689a2 languageName: node linkType: hard @@ -14455,7 +14778,7 @@ __metadata: languageName: node linkType: hard -"css-loader@npm:7.1.2": +"css-loader@npm:7.1.2, css-loader@npm:^7.1.2": version: 7.1.2 resolution: "css-loader@npm:7.1.2" dependencies: @@ -15319,15 +15642,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0, debug@npm:^4.4.1": - version: 4.4.1 - resolution: "debug@npm:4.4.1" +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" dependencies: ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10/8e2709b2144f03c7950f8804d01ccb3786373df01e406a0f66928e47001cf2d336cbed9ee137261d4f90d68d8679468c755e3548ed83ddacdc82b194d2468afe + checksum: 10/9ada3434ea2993800bd9a1e320bd4aa7af69659fb51cca685d390949434bc0a8873c21ed7c9b852af6f2455a55c6d050aa3937d52b3c69f796dab666f762acad languageName: node linkType: hard @@ -15576,7 +15899,7 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0, depd@npm:^2.0.0": +"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: 10/c0c8ff36079ce5ada64f46cc9d6fd47ebcf38241105b6e0c98f412e8ad91f084bcf906ff644cc3a4bd876ca27a62accb8b0fff72ea6ed1a414b89d8506f4a5ca @@ -17951,7 +18274,7 @@ __metadata: languageName: node linkType: hard -"fork-ts-checker-webpack-plugin@npm:9.1.0": +"fork-ts-checker-webpack-plugin@npm:9.1.0, fork-ts-checker-webpack-plugin@npm:^9.1.0": version: 9.1.0 resolution: "fork-ts-checker-webpack-plugin@npm:9.1.0" dependencies: @@ -18086,14 +18409,14 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^11.1.1, fs-extra@npm:^11.2.0": - version: 11.3.0 - resolution: "fs-extra@npm:11.3.0" +"fs-extra@npm:^11.1.1, fs-extra@npm:^11.2.0, fs-extra@npm:^11.3.2": + version: 11.3.2 + resolution: "fs-extra@npm:11.3.2" dependencies: graceful-fs: "npm:^4.2.0" jsonfile: "npm:^6.0.1" universalify: "npm:^2.0.0" - checksum: 10/c9fe7b23dded1efe7bbae528d685c3206477e20cc60e9aaceb3f024f9b9ff2ee1f62413c161cb88546cc564009ab516dec99e9781ba782d869bb37e4fe04a97f + checksum: 10/d559545c73fda69c75aa786f345c2f738b623b42aea850200b1582e006a35278f63787179e3194ba19413c26a280441758952b0c7e88dd96762d497e365a6c3e languageName: node linkType: hard @@ -19769,7 +20092,7 @@ __metadata: languageName: node linkType: hard -"http-errors@npm:2.0.0, http-errors@npm:^2.0.0": +"http-errors@npm:2.0.0": version: 2.0.0 resolution: "http-errors@npm:2.0.0" dependencies: @@ -19782,6 +20105,19 @@ __metadata: languageName: node linkType: hard +"http-errors@npm:^2.0.0, http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" + dependencies: + depd: "npm:~2.0.0" + inherits: "npm:~2.0.4" + setprototypeof: "npm:~1.2.0" + statuses: "npm:~2.0.2" + toidentifier: "npm:~1.0.1" + checksum: 10/9fe31bc0edf36566c87048aed1d3d0cbe03552564adc3541626a0613f542d753fbcb13bdfcec0a3a530dbe1714bb566c89d46244616b66bddd26ac413b06a207 + languageName: node + linkType: hard + "http-parser-js@npm:>=0.5.1": version: 0.5.6 resolution: "http-parser-js@npm:0.5.6" @@ -20037,22 +20373,22 @@ __metadata: linkType: hard "i18next@npm:^23.5.1 || ^24.2.0": - version: 24.2.2 - resolution: "i18next@npm:24.2.2" + version: 24.2.3 + resolution: "i18next@npm:24.2.3" dependencies: - "@babel/runtime": "npm:^7.23.2" + "@babel/runtime": "npm:^7.26.10" peerDependencies: typescript: ^5 peerDependenciesMeta: typescript: optional: true - checksum: 10/f66ed9e56d9412e59502f5df39163631daf9f1264774732fb21edbd66a528ca7a6b67dc2e2aec95683c6c7956e42c651587a54bd8ee082bd12008880ce6cd326 + checksum: 10/6c73d964f2a98b1aa2c2717fe6da66fc265bcbbc5fcd52b2bfff51ff013d30d4f7d7449c4eb7f464d27af43e2e73f2e7f1d46a144d731bd3bdb1385d4c199e4c languageName: node linkType: hard "i18next@npm:^25.0.0, i18next@npm:^25.5.2": - version: 25.5.2 - resolution: "i18next@npm:25.5.2" + version: 25.6.2 + resolution: "i18next@npm:25.6.2" dependencies: "@babel/runtime": "npm:^7.27.6" peerDependencies: @@ -20060,7 +20396,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/8d52e82386722a228f4465aa5cf39d82bd9861ea9cc8b31d7cc4d22d5e70b2740ee006068b09f486d5273cabd227a2ac14f37d68fab26344524200083f679bcd + checksum: 10/bb7f3e200caac72bacaead766287dbad5ed5945430bd9f2be1c14fbccd49cf78ab751f78cf53cd0be3ec063bc4c40837d7d771f7b14d50fcb9022e1ed5d6e72f languageName: node linkType: hard @@ -20082,7 +20418,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.7.0": +"iconv-lite@npm:^0.7.0, iconv-lite@npm:~0.7.0": version: 0.7.0 resolution: "iconv-lite@npm:0.7.0" dependencies: @@ -23161,16 +23497,16 @@ __metadata: linkType: hard "lossless-json@npm:^4.1.1": - version: 4.2.0 - resolution: "lossless-json@npm:4.2.0" - checksum: 10/b29cbf9a90b8f3108219ff410223a90a3a6ad14cf902eed4038571d9421c69ade7077fb59737e1ca20f3f404ddd68fad69be11365b0d79b4b61421b054759551 + version: 4.3.0 + resolution: "lossless-json@npm:4.3.0" + checksum: 10/a984a882c79b6e62a917d0202518472c17587bdee002f1427d7ec61115f9fbdeb5f0baa9f0e9fff8d9dbacd17da6b6c910012b2dcab69dd33d151cb7c13d5a37 languageName: node linkType: hard -"loupe@npm:^3.1.0, loupe@npm:^3.1.1, loupe@npm:^3.1.2": - version: 3.1.3 - resolution: "loupe@npm:3.1.3" - checksum: 10/9e98c34daf0eba48ccc603595e51f2ae002110982d84879cf78c51de2c632f0c571dfe82ce4210af60c32203d06b443465c269bda925076fe6d9b612cc65c321 +"loupe@npm:^3.1.0, loupe@npm:^3.1.1, loupe@npm:^3.1.2, loupe@npm:^3.1.4": + version: 3.2.1 + resolution: "loupe@npm:3.2.1" + checksum: 10/a4d78ec758aaa04e0e35d5cd1c15e970beb9cdbfd3d0f34f98b9bcda489f896a7190b3b6cc40b7a6dcb8e97e82e96eafaae10096aaa469804acdba6f7c2bde5f languageName: node linkType: hard @@ -23262,12 +23598,12 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.3, magic-string@npm:^0.30.5": - version: 0.30.17 - resolution: "magic-string@npm:0.30.17" +"magic-string@npm:^0.30.17, magic-string@npm:^0.30.3, magic-string@npm:^0.30.5": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.5.0" - checksum: 10/2f71af2b0afd78c2e9012a29b066d2c8ba45a9cd0c8070f7fd72de982fb1c403b4e3afdb1dae00691d56885ede66b772ef6bedf765e02e3a7066208fe2fec4aa + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10/57d5691f41ed40d962d8bd300148114f53db67fadbff336207db10a99f2bdf4a1be9cac3a68ee85dba575912ee1d4402e4396408196ec2d3afd043b076156221 languageName: node linkType: hard @@ -23638,9 +23974,9 @@ __metadata: linkType: hard "micro-memoize@npm:^4.1.2": - version: 4.1.3 - resolution: "micro-memoize@npm:4.1.3" - checksum: 10/4e9c7767911cc76ae9c9779584ec87844437af9446b295a01774640a732c2c7f91944794027f44625031f7330ab7f9147740d0a9fb612680d1d2d858dad43402 + version: 4.2.0 + resolution: "micro-memoize@npm:4.2.0" + checksum: 10/260e27a5c15809f7dc435a89a424d93e49ccca62b743fa96ee72f254264df58c95edacbd847a2899a6d7f8ad0daef188548a8b12add5ccff83e88869a50a762c languageName: node linkType: hard @@ -24497,9 +24833,9 @@ __metadata: linkType: hard "node-forge@npm:^1.3.1": - version: 1.3.1 - resolution: "node-forge@npm:1.3.1" - checksum: 10/05bab6868633bf9ad4c3b1dd50ec501c22ffd69f556cdf169a00998ca1d03e8107a6032ba013852f202035372021b845603aeccd7dfcb58cdb7430013b3daa8d + version: 1.3.2 + resolution: "node-forge@npm:1.3.2" + checksum: 10/dcc54aaffe0cf52367214a20c0032aa9b209d9095dd14526504f1972d1900a07e96046b3684cb0c8d0cc3d48744dd18e02b7b447ab28fac615ffb850beeabf18 languageName: node linkType: hard @@ -27558,15 +27894,15 @@ __metadata: languageName: node linkType: hard -"raw-body@npm:^3.0.0": - version: 3.0.0 - resolution: "raw-body@npm:3.0.0" +"raw-body@npm:^3.0.0, raw-body@npm:^3.0.1": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" dependencies: - bytes: "npm:3.1.2" - http-errors: "npm:2.0.0" - iconv-lite: "npm:0.6.3" - unpipe: "npm:1.0.0" - checksum: 10/2443429bbb2f9ae5c50d3d2a6c342533dfbde6b3173740b70fa0302b30914ff400c6d31a46b3ceacbe7d0925dc07d4413928278b494b04a65736fc17ca33e30c + bytes: "npm:~3.1.2" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.7.0" + unpipe: "npm:~1.0.0" + checksum: 10/4168c82157bd69175d5bd960e59b74e253e237b358213694946a427a6f750a18b8e150f036fed3421b3e83294b071a4e2bb01037a79ccacdac05360c63d3ebba languageName: node linkType: hard @@ -27930,9 +28266,9 @@ __metadata: languageName: node linkType: hard -"react-docgen@npm:^7.0.0": - version: 7.0.3 - resolution: "react-docgen@npm:7.0.3" +"react-docgen@npm:^7.0.0, react-docgen@npm:^7.1.1": + version: 7.1.1 + resolution: "react-docgen@npm:7.1.1" dependencies: "@babel/core": "npm:^7.18.9" "@babel/traverse": "npm:^7.18.9" @@ -27944,7 +28280,7 @@ __metadata: doctrine: "npm:^3.0.0" resolve: "npm:^1.22.1" strip-indent: "npm:^4.0.0" - checksum: 10/53eaed76cceb55606584c6ab603f04ec78c066cfb9ed983e1f7b388a75bfb8c2fc9c6b7ab299bac311b3daeca95adb8076b58ca96b41907b33c518299268831f + checksum: 10/501e5fa0d00e32ee27559f44462a34e9531018ccb46c51efbe60b98a4c077f43dbe8999da5bb91d2ab45a83a34099436a3b725fdabd3f218dbb4493c0b1c9f95 languageName: node linkType: hard @@ -29979,7 +30315,7 @@ __metadata: languageName: node linkType: hard -"setprototypeof@npm:1.2.0": +"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" checksum: 10/fde1630422502fbbc19e6844346778f99d449986b2f9cdcceb8326730d2f3d9964dbcb03c02aaadaefffecd0f2c063315ebea8b3ad895914bf1afc1747fc172e @@ -30824,13 +31160,20 @@ __metadata: languageName: node linkType: hard -"statuses@npm:2.0.1, statuses@npm:^2.0.1": +"statuses@npm:2.0.1": version: 2.0.1 resolution: "statuses@npm:2.0.1" checksum: 10/18c7623fdb8f646fb213ca4051be4df7efb3484d4ab662937ca6fbef7ced9b9e12842709872eb3020cc3504b93bde88935c9f6417489627a7786f24f8031cbcb languageName: node linkType: hard +"statuses@npm:^2.0.1, statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 10/6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 + languageName: node + linkType: hard + "statuses@npm:~1.5.0": version: 1.5.0 resolution: "statuses@npm:1.5.0" @@ -30862,6 +31205,32 @@ __metadata: languageName: node linkType: hard +"storybook@npm:^10.0.8": + version: 10.0.8 + resolution: "storybook@npm:10.0.8" + dependencies: + "@storybook/global": "npm:^5.0.0" + "@storybook/icons": "npm:^1.6.0" + "@testing-library/jest-dom": "npm:^6.6.3" + "@testing-library/user-event": "npm:^14.6.1" + "@vitest/expect": "npm:3.2.4" + "@vitest/mocker": "npm:3.2.4" + "@vitest/spy": "npm:3.2.4" + esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0" + recast: "npm:^0.23.5" + semver: "npm:^7.6.2" + ws: "npm:^8.18.0" + peerDependencies: + prettier: ^2 || ^3 + peerDependenciesMeta: + prettier: + optional: true + bin: + storybook: ./dist/bin/dispatcher.js + checksum: 10/a30b54248d69d7406f78e1871e075092ed9ae366d1c7cec56dc10b31b72a5196ef9e751453c9a7546797228c4fe0f01893c6fa541d24cf51037124d4f0e1566e + languageName: node + linkType: hard + "storybook@npm:^8.6.2": version: 8.6.2 resolution: "storybook@npm:8.6.2" @@ -31210,7 +31579,7 @@ __metadata: languageName: node linkType: hard -"style-loader@npm:4.0.0": +"style-loader@npm:4.0.0, style-loader@npm:^4.0.0": version: 4.0.0 resolution: "style-loader@npm:4.0.0" peerDependencies: @@ -31656,7 +32025,7 @@ __metadata: languageName: node linkType: hard -"terser-webpack-plugin@npm:5.3.14, terser-webpack-plugin@npm:^5.3.1, terser-webpack-plugin@npm:^5.3.10, terser-webpack-plugin@npm:^5.3.11": +"terser-webpack-plugin@npm:5.3.14, terser-webpack-plugin@npm:^5.3.1, terser-webpack-plugin@npm:^5.3.10, terser-webpack-plugin@npm:^5.3.11, terser-webpack-plugin@npm:^5.3.14": version: 5.3.14 resolution: "terser-webpack-plugin@npm:5.3.14" dependencies: @@ -31860,6 +32229,13 @@ __metadata: languageName: node linkType: hard +"tinyrainbow@npm:^2.0.0": + version: 2.0.0 + resolution: "tinyrainbow@npm:2.0.0" + checksum: 10/94d4e16246972614a5601eeb169ba94f1d49752426312d3cf8cc4f2cc663a2e354ffc653aa4de4eebccbf9eeebdd0caef52d1150271fdfde65d7ae7f3dcb9eb5 + languageName: node + linkType: hard + "tinyspy@npm:^3.0.0": version: 3.0.2 resolution: "tinyspy@npm:3.0.2" @@ -31867,6 +32243,13 @@ __metadata: languageName: node linkType: hard +"tinyspy@npm:^4.0.3": + version: 4.0.4 + resolution: "tinyspy@npm:4.0.4" + checksum: 10/858a99e3ded2fba8fe7c243099d9e58e926d6525af03d19cdf86c1a9a30398161fb830b4f77890d266bcc1c69df08fa6f4baf29d089385e4cdaa98d7b6296e7c + languageName: node + linkType: hard + "title-case@npm:^3.0.3": version: 3.0.3 resolution: "title-case@npm:3.0.3" @@ -31978,7 +32361,7 @@ __metadata: languageName: node linkType: hard -"toidentifier@npm:1.0.1": +"toidentifier@npm:1.0.1, toidentifier@npm:~1.0.1": version: 1.0.1 resolution: "toidentifier@npm:1.0.1" checksum: 10/952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45 @@ -32431,7 +32814,7 @@ __metadata: languageName: node linkType: hard -"type-is@npm:^2.0.0, type-is@npm:^2.0.1": +"type-is@npm:^2.0.1": version: 2.0.1 resolution: "type-is@npm:2.0.1" dependencies: @@ -34037,7 +34420,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.3, ws@npm:^8.2.3, ws@npm:^8.9.0": +"ws@npm:^8.18.0, ws@npm:^8.18.3, ws@npm:^8.2.3, ws@npm:^8.9.0": version: 8.18.3 resolution: "ws@npm:8.18.3" peerDependencies: