diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c76673efd98..85db1f8d4c7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1262,6 +1262,7 @@ embed.go @grafana/grafana-as-code /.github/workflows/changelog.yml @zserge /.github/workflows/shellcheck.yml @grafana/grafana-developer-enablement-squad /.github/workflows/release-build.yml @grafana/grafana-developer-enablement-squad +/.github/workflows/cleanup-branches.yml @grafana/grafana-developer-enablement-squad /.github/workflows/publish-artifact.yml @grafana/grafana-developer-enablement-squad /.github/actions/changelog @zserge /.github/workflows/swagger-gen.yml @grafana/grafana-backend-group diff --git a/.github/workflows/cleanup-branches.yml b/.github/workflows/cleanup-branches.yml new file mode 100644 index 00000000000..00cb4f26541 --- /dev/null +++ b/.github/workflows/cleanup-branches.yml @@ -0,0 +1,18 @@ +name: Clean up orphaned branches +on: + workflow_dispatch: + schedule: + - cron: "0 9 * * 1" + +jobs: + cleanup-branches: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + steps: + - uses: actions/checkout@v5 + - uses: grafana/shared-workflows/actions/cleanup-branches@cleanup-branches/v1.0.0 + with: + dry-run: true + max-date: "1 month ago" diff --git a/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1/types.go b/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1/types.go index 7c3f8a60235..a7c37efde69 100644 --- a/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1/types.go +++ b/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1/types.go @@ -132,6 +132,7 @@ const ( EnricherTypeAsserts EnricherType = "asserts" EnricherTypeExplain EnricherType = "explain" EnricherTypeLoop EnricherType = "loop" + EnricherTypeAssistant EnricherType = "assistant" ) // EnricherConfig is a discriminated union of enricher configurations. @@ -145,6 +146,7 @@ type EnricherConfig struct { Asserts *AssertsEnricher `json:"asserts,omitempty" yaml:"asserts,omitempty" jsonschema:"description=Asserts enricher settings"` Explain *ExplainEnricher `json:"explain,omitempty" yaml:"explain,omitempty" jsonschema:"description=Explain enricher settings"` Loop *LoopEnricher `json:"loop,omitempty" yaml:"loop,omitempty" jsonschema:"description=Loop enricher settings"` + Assistant *AssistantEnricher `json:"assistant,omitempty" yaml:"assistant,omitempty" jsonschema:"description=Assistant enricher settings"` } // AssignEnricher configures an enricher which assigns annotations. @@ -224,10 +226,15 @@ type AssertsEnricher struct { // ExplainEnricher uses LLM to generate explanations for alerts. type ExplainEnricher struct { - Annotation string `json:"annotation" yaml:"annotation" jsonschema:"description=Annotation name to set the explanation in, by default 'ai_explanation'"` + Annotation string `json:"annotation" yaml:"annotation" jsonschema:"description=Annotation name to set the explanation in, by default '__enriched_ai_explanation'"` } // LoopEnricher configures an enricher which calls into Loop. type LoopEnricher struct { // In the future, there may be configuration options. } + +// AssistantEnricher configures an enricher which calls into Assistant. +type AssistantEnricher struct { + // In the future, there may be configuration options. +} diff --git a/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1/zz_generated.deepcopy.go b/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1/zz_generated.deepcopy.go index c385190bfaf..c9740e363c1 100644 --- a/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1/zz_generated.deepcopy.go +++ b/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1/zz_generated.deepcopy.go @@ -183,6 +183,22 @@ func (in *Assignment) DeepCopy() *Assignment { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AssistantEnricher) DeepCopyInto(out *AssistantEnricher) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AssistantEnricher. +func (in *AssistantEnricher) DeepCopy() *AssistantEnricher { + if in == nil { + return nil + } + out := new(AssistantEnricher) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Condition) DeepCopyInto(out *Condition) { *out = *in @@ -309,6 +325,11 @@ func (in *EnricherConfig) DeepCopyInto(out *EnricherConfig) { *out = new(LoopEnricher) **out = **in } + if in.Assistant != nil { + in, out := &in.Assistant, &out.Assistant + *out = new(AssistantEnricher) + **out = **in + } return } diff --git a/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1/zz_generated.openapi.go b/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1/zz_generated.openapi.go index 658672e0af1..f8e4c2bdf49 100644 --- a/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1/zz_generated.openapi.go +++ b/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1/zz_generated.openapi.go @@ -21,6 +21,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssertsEnricher": schema_pkg_apis_alertenrichment_v1beta1_AssertsEnricher(ref), "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssignEnricher": schema_pkg_apis_alertenrichment_v1beta1_AssignEnricher(ref), "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.Assignment": schema_pkg_apis_alertenrichment_v1beta1_Assignment(ref), + "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssistantEnricher": schema_pkg_apis_alertenrichment_v1beta1_AssistantEnricher(ref), "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.Condition": schema_pkg_apis_alertenrichment_v1beta1_Condition(ref), "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.Conditional": schema_pkg_apis_alertenrichment_v1beta1_Conditional(ref), "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.DataSourceEnricher": schema_pkg_apis_alertenrichment_v1beta1_DataSourceEnricher(ref), @@ -325,6 +326,17 @@ func schema_pkg_apis_alertenrichment_v1beta1_Assignment(ref common.ReferenceCall } } +func schema_pkg_apis_alertenrichment_v1beta1_AssistantEnricher(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "AssistantEnricher configures an enricher which calls into Assistant.", + Type: []string{"object"}, + }, + }, + } +} + func schema_pkg_apis_alertenrichment_v1beta1_Condition(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -467,11 +479,11 @@ func schema_pkg_apis_alertenrichment_v1beta1_EnricherConfig(ref common.Reference Properties: map[string]spec.Schema{ "type": { SchemaProps: spec.SchemaProps{ - Description: "Possible enum values:\n - `\"asserts\"`\n - `\"assign\"`\n - `\"dsquery\"`\n - `\"explain\"`\n - `\"external\"`\n - `\"loop\"`\n - `\"sift\"`", + Description: "Possible enum values:\n - `\"asserts\"`\n - `\"assign\"`\n - `\"assistant\"`\n - `\"dsquery\"`\n - `\"explain\"`\n - `\"external\"`\n - `\"loop\"`\n - `\"sift\"`", Default: "", Type: []string{"string"}, Format: "", - Enum: []interface{}{"asserts", "assign", "dsquery", "explain", "external", "loop", "sift"}, + Enum: []interface{}{"asserts", "assign", "assistant", "dsquery", "explain", "external", "loop", "sift"}, }, }, "assign": { @@ -509,12 +521,17 @@ func schema_pkg_apis_alertenrichment_v1beta1_EnricherConfig(ref common.Reference Ref: ref("github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.LoopEnricher"), }, }, + "assistant": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssistantEnricher"), + }, + }, }, Required: []string{"type"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssertsEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssignEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.DataSourceEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.ExplainEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.ExternalEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.LoopEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.SiftEnricher"}, + "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssertsEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssignEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.AssistantEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.DataSourceEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.ExplainEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.ExternalEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.LoopEnricher", "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1.SiftEnricher"}, } } diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go index 000ff7f22ff..777484828f6 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go @@ -18,7 +18,7 @@ const ( ) var RepositoryResourceInfo = utils.NewResourceInfo(GROUP, VERSION, - "repositories", "repository", "Repositories", + "repositories", "repository", "Repository", func() runtime.Object { return &Repository{} }, // newObj func() runtime.Object { return &RepositoryList{} }, // newList utils.TableColumns{ // Returned by `kubectl get`. Doesn't affect disk storage. diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go index 9ddfcd3897f..fc24ac84869 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go @@ -159,6 +159,66 @@ func (r *Repository) Branch() string { return "" } +// URL returns the URL for git-based repositories +// or an empty string for local repositories +func (r *Repository) URL() string { + if !r.Spec.Type.IsGit() { + return "" + } + + switch r.Spec.Type { + case GitHubRepositoryType: + if r.Spec.GitHub != nil { + return r.Spec.GitHub.URL + } + case GitRepositoryType: + if r.Spec.Git != nil { + return r.Spec.Git.URL + } + case BitbucketRepositoryType: + if r.Spec.Bitbucket != nil { + return r.Spec.Bitbucket.URL + } + case GitLabRepositoryType: + if r.Spec.GitLab != nil { + return r.Spec.GitLab.URL + } + default: + return "" + } + + return "" +} + +func (r *Repository) Path() string { + switch r.Spec.Type { + case GitHubRepositoryType: + if r.Spec.GitHub != nil { + return r.Spec.GitHub.Path + } + case GitRepositoryType: + if r.Spec.Git != nil { + return r.Spec.Git.Path + } + case BitbucketRepositoryType: + if r.Spec.Bitbucket != nil { + return r.Spec.Bitbucket.Path + } + case GitLabRepositoryType: + if r.Spec.GitLab != nil { + return r.Spec.GitLab.Path + } + case LocalRepositoryType: + if r.Spec.Local != nil { + return r.Spec.Local.Path + } + default: + return "" + } + + return "" +} + type RepositorySpec struct { // The repository display name (shown in the UI) Title string `json:"title"` diff --git a/apps/provisioning/pkg/repository/test.go b/apps/provisioning/pkg/repository/test.go index cba9736e2d6..b6307237a0a 100644 --- a/apps/provisioning/pkg/repository/test.go +++ b/apps/provisioning/pkg/repository/test.go @@ -12,7 +12,16 @@ import ( provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" ) +// RepositoryValidator interface for validating repositories against existing ones +type RepositoryValidator interface { + VerifyAgainstExistingRepositories(ctx context.Context, cfg *provisioning.Repository) *field.Error +} + func TestRepository(ctx context.Context, repo Repository) (*provisioning.TestResults, error) { + return TestRepositoryWithValidator(ctx, repo, nil) +} + +func TestRepositoryWithValidator(ctx context.Context, repo Repository, validator RepositoryValidator) (*provisioning.TestResults, error) { errors := ValidateRepository(repo) if len(errors) > 0 { rsp := &provisioning.TestResults{ @@ -30,7 +39,27 @@ func TestRepository(ctx context.Context, repo Repository) (*provisioning.TestRes return rsp, nil } - return repo.Test(ctx) + rsp, err := repo.Test(ctx) + if err != nil { + return nil, err + } + + if rsp.Success && validator != nil { + cfg := repo.Config() + if validationErr := validator.VerifyAgainstExistingRepositories(ctx, cfg); validationErr != nil { + rsp = &provisioning.TestResults{ + Success: false, + Code: http.StatusUnprocessableEntity, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseType(validationErr.Type), + Field: validationErr.Field, + Detail: validationErr.Detail, + }}, + } + } + } + + return rsp, nil } func ValidateRepository(repo Repository) field.ErrorList { diff --git a/devenv/create_docker_compose.sh b/devenv/create_docker_compose.sh index baa50818f11..2acdd0836a1 100755 --- a/devenv/create_docker_compose.sh +++ b/devenv/create_docker_compose.sh @@ -64,13 +64,6 @@ for dir in $@; do fi done -volume_files=$($blocks_dir/**/$compose_volume_section_create_flag) - -if [[ ${#volume_files[@]} -ne 0 ]]; then - echo "Adding volume section to $compose_file" - cat $compose_volume_section_file >> $compose_file - echo "" >> $compose_file - for dir in $@; do current_dir=$blocks_dir/$dir if [ ! -d "$current_dir" ]; then @@ -80,11 +73,15 @@ if [[ ${#volume_files[@]} -ne 0 ]]; then if [ -f $current_dir/$compose_volume_section_create_flag ]; then + if [ -z ${inserted_volume_section_start+x} ]; then + echo "Adding volume section to $compose_file" + cat $compose_volume_section_file >> $compose_file + echo "" >> $compose_file + inserted_volume_section_start=true + fi + echo "Adding volume for $current_dir to $compose_file" echo " $dir-data-volume:" >> $compose_file echo "" >> $compose_file fi done - - cat $compose_file -fi diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md index 1c5a5fa7272..7ffb3c3d26a 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-for-app-plugins/index.md @@ -25,6 +25,8 @@ refs: - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/account-management/authentication-and-permissions/access-control/custom-role-actions-scopes/#grafana-adaptive-metrics-action-definitions cloud-access-policies-action-definitions: + - pattern: /docs/grafana/ + destination: docs/grafana//administration/roles-and-permissions/access-control/custom-role-actions-scopes/#cloud-access-policies-action-definitions - pattern: /docs/grafana-cloud/ destination: /docs/grafana//administration/roles-and-permissions/access-control/custom-role-actions-scopes/#cloud-access-policies-action-definitions rbac-role-definitions: diff --git a/docs/sources/observability-as-code/grafana-cli/grafanacli-workflows.md b/docs/sources/observability-as-code/grafana-cli/grafanacli-workflows.md index b510dd2e64c..b8128d211cd 100644 --- a/docs/sources/observability-as-code/grafana-cli/grafanacli-workflows.md +++ b/docs/sources/observability-as-code/grafana-cli/grafanacli-workflows.md @@ -37,7 +37,7 @@ Use these steps to migrate resources between environments: {{< admonition type="note" >}} Resources are pulled and pushed from the `./resources` directory by default. -This directory can be configured with the `--directory`/`-d` flags. +This can be configured with the `-p, --path` flags to specify custom paths on disk. {{< /admonition >}} 1. Make changes to dashboards and other resources using the Grafana UI in your **development instance**. @@ -45,21 +45,21 @@ This directory can be configured with the `--directory`/`-d` flags. ```bash grafanactl config use-context YOUR_CONTEXT # for example "dev" - grafanactl resources pull -d ./resources/ -o yaml # or json + grafanactl resources pull --path ./resources/ -o yaml # or json ``` 1. (Optional) Preview the resources locally before pushing: ```bash grafanactl config use-context YOUR_CONTEXT # for example "prod" - grafanactl resources serve -d ./resources/ + grafanactl resources serve ./resources/ ``` 1. Switch to the **production instance** and push the resources: ```bash grafanactl config use-context YOUR_CONTEXT # for example "prod" - grafanactl resources push -d ./resources/ + grafanactl resources push -p ./resources/ ``` ## Back up Grafana resources @@ -70,7 +70,7 @@ This workflow helps you back up all Grafana resources from one instance and late ```bash grafanactl config use-context YOUR_CONTEXT # for example "prod" - grafanactl resources pull -d ./resources/ -o yaml # or json + grafanactl resources pull --path ./resources/ -o yaml # or json ``` 1. Save the exported resources to version control or cloud storage. @@ -81,14 +81,14 @@ This workflow helps you back up all Grafana resources from one instance and late ```bash grafanactl config use-context YOUR_CONTEXT # for example "prod" - grafanactl resources serve -d ./resources/ + grafanactl resources serve ./resources/ ``` 1. To restore the resources later or restore them on another instance, push the saved resources: ```bash grafanactl config use-context YOUR_CONTEXT # for example "prod" - grafanactl resources push -d ./resources/ + grafanactl resources push -p ./resources/ ``` ## Manage dashboards as code @@ -114,7 +114,7 @@ With this workflow, you can define and manage dashboards as code, saving them to ```bash grafanactl config use-context YOUR_CONTEXT # for example "dev" - grafanactl resources push -d ./resources/ + grafanactl resources push -p ./resources/ ``` ## Explore and modify resources from the terminal @@ -197,7 +197,7 @@ Use this workflow to locate dashboards using a deprecated API version and mark t playlist.grafana.app v1 playlist ``` -1. Find dashboards that are still using an old API version: +1. Find dashboards that are still using a deprecated API version: ```bash grafanactl resources get dashboards.v1.dashboard.grafana.app diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e66f3ac2486..d6951a21011 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -24,11 +24,6 @@ "count": 1 } }, - "packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "packages/grafana-data/src/dataframe/ArrayDataFrame.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/go.mod b/go.mod index d0269ade929..5a66ee070ad 100644 --- a/go.mod +++ b/go.mod @@ -101,7 +101,7 @@ require ( github.com/grafana/grafana-aws-sdk v1.2.0 // @grafana/aws-datasources github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 // @grafana/partner-datasources github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 // @grafana/grafana-operator-experience-squad - github.com/grafana/grafana-google-sdk-go v0.4.1 // @grafana/partner-datasources + 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.279.0 // @grafana/plugins-platform-backend github.com/grafana/loki/v3 v3.2.1 // @grafana/observability-logs diff --git a/go.sum b/go.sum index 5e77d1adea6..53c44fc95a0 100644 --- a/go.sum +++ b/go.sum @@ -1615,8 +1615,8 @@ github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 h1:0TYrkzAc3u0HX+9GK86cGrLTUAc github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0/go.mod h1:H9sVh9A4yg5egMGZeh0mifxT1Q/uqwKe1LBjBJU6pN8= github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 h1:JOzchPgptwJdruYoed7x28lFDwhzs7kssResYsnC0iI= github.com/grafana/grafana-cloud-migration-snapshot v1.9.0/go.mod h1:nOHgq4Oa829qmBKA5KIXw5Ipo3rhLs0d6A8UI9Nw8Zk= -github.com/grafana/grafana-google-sdk-go v0.4.1 h1:QdHmgDzlV3RzBTvIxd+WuxER1+afnFzUmEivbwDo27E= -github.com/grafana/grafana-google-sdk-go v0.4.1/go.mod h1:U73+w9DlbEtUonhQUzERwlXnzWTtfRoyrtKH8d3VY40= +github.com/grafana/grafana-google-sdk-go v0.4.2 h1:F44hQF1y6UVJhlJPi+Mz+GCJsioVgezEgPMMEQbUZRo= +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.279.0 h1:/KCrsZkj9pEGwIGovqAz1A8rjI2A2YT+ZpvgfZN0LAA= diff --git a/go.work.sum b/go.work.sum index 918fc645382..d9a860898d1 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1074,6 +1074,8 @@ github.com/grafana/grafana-aws-sdk v0.38.2/go.mod h1:j3vi+cXYHEFqjhBGrI6/lw1TNM+ github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9yhDIpkss= github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= +github.com/grafana/grafana-google-sdk-go v0.4.2 h1:F44hQF1y6UVJhlJPi+Mz+GCJsioVgezEgPMMEQbUZRo= +github.com/grafana/grafana-google-sdk-go v0.4.2/go.mod h1:U73+w9DlbEtUonhQUzERwlXnzWTtfRoyrtKH8d3VY40= github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw= github.com/grafana/grafana-plugin-sdk-go v0.267.0/go.mod h1:OuwS4c/JYgn0rr/w5zhJBpLo4gKm/vw15RsfpYAvK9Q= github.com/grafana/grafana-plugin-sdk-go v0.269.1/go.mod h1:yv2KbO4mlr9WuDK2f+2gHAMTwwLmLuqaEnrPXTRU+OI= diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.test.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.test.ts new file mode 100644 index 00000000000..9bf6cd1c7b1 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.test.ts @@ -0,0 +1,74 @@ +import { RoutingTree } from '../../api/v0alpha1/api.gen'; +import { LabelMatcherFactory, RouteFactory } from '../../api/v0alpha1/mocks/fakes/Routes'; +import { Label } from '../../matchers/types'; + +import { matchInstancesToRouteTrees } from './useMatchPolicies'; + +describe('matchInstancesToRouteTrees', () => { + it('should return root route when child routes do not match instances', () => { + const route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-team', + }); + + const treeName = 'test-tree'; + const trees: RoutingTree[] = [ + { + kind: 'RoutingTree', + metadata: { name: treeName }, + spec: { + defaults: { + receiver: 'receiver 1', + }, + routes: [route], + }, + status: {}, + }, + ]; + + const instanceLabels: Label[] = [['service', 'api']]; + const instances: Label[][] = [instanceLabels]; // Different service - should not match + + const result = matchInstancesToRouteTrees(trees, instances); + + expect(result).toHaveLength(1); + expect(result[0].labels).toBe(instanceLabels); + expect(result[0].matchedRoutes).toHaveLength(1); + // The root route should match as it's a catch-all + expect(result[0].matchedRoutes[0].route.receiver).toBe('receiver 1'); + expect(result[0].matchedRoutes[0].routeTree.metadata.name).toBe(treeName); + }); + + it('should return matched routes when trees match instances', () => { + const route = RouteFactory.build({ + matchers: [LabelMatcherFactory.build({ label: 'service', type: '=', value: 'web' })], + receiver: 'web-team', + }); + + const treeName = 'test-tree'; + const trees: RoutingTree[] = [ + { + kind: 'RoutingTree', + metadata: { name: treeName }, + spec: { + defaults: { + receiver: 'receiver 1', + }, + routes: [route], + }, + status: {}, + }, + ]; + + const instanceLabels: Label[] = [['service', 'web']]; + const instances: Label[][] = [instanceLabels]; + + const result = matchInstancesToRouteTrees(trees, instances); + + expect(result).toHaveLength(1); + expect(result[0].labels).toBe(instanceLabels); + expect(result[0].matchedRoutes.length).toBeGreaterThan(0); + expect(result[0].matchedRoutes[0].routeTree.metadata.name).toBe(treeName); + expect(result[0].matchedRoutes[0].matchDetails.labels).toBe(instanceLabels); + }); +}); diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts index 14516375a86..1e830830701 100644 --- a/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts @@ -4,7 +4,7 @@ import { RoutingTree, alertingAPI } from '../../api/v0alpha1/api.gen'; import { Label } from '../../matchers/types'; import { USER_DEFINED_TREE_NAME } from '../consts'; import { Route, RouteWithID } from '../types'; -import { RouteMatchResult, convertRoutingTreeToRoute, matchAlertInstancesToPolicyTree } from '../utils'; +import { RouteMatchResult, TreeMatch, convertRoutingTreeToRoute, matchInstancesToRoute } from '../utils'; export type RouteMatch = { route: Route; @@ -32,11 +32,10 @@ export type InstanceMatchResult = { * 2. Compute the inherited properties for each node in the tree * 3. Find routes within each tree that match the given set of labels * - * @returns An object containing a `matchInstancesToPolicies` function that takes alert instances + * @returns An object containing a `matchInstancesToRoutingTrees` function that takes alert instances * and returns an array of InstanceMatchResult objects, each containing the matched routes and matching details */ -export function useMatchAlertInstancesToNotificationPolicies() { - // fetch the routing trees from the API +export function useMatchInstancesToRouteTrees() { const { data, ...rest } = alertingAPI.endpoints.listRoutingTree.useQuery( {}, { @@ -45,52 +44,52 @@ export function useMatchAlertInstancesToNotificationPolicies() { } ); - const matchInstancesToPolicies = useCallback( - (instances: Label[][]): InstanceMatchResult[] => { - if (!data) { - return []; - } - - // the routing trees are returned as an array of items because there can be several - const trees = data.items; - - return instances.map((labels) => { - // Collect all matched routes from all trees - const allMatchedRoutes: RouteMatch[] = []; - - // Process each tree for this instance - trees.forEach((tree) => { - const treeName = tree.metadata.name ?? USER_DEFINED_TREE_NAME; - // We have to convert the RoutingTree structure to a Route structure to be able to use the matching functions - const rootRoute = convertRoutingTreeToRoute(tree); - - // Match this single instance against the route tree - const { expandedTree, matchedPolicies } = matchAlertInstancesToPolicyTree([labels], rootRoute); - - // Process each matched route from the tree - matchedPolicies.forEach((results, route) => { - // For each match result, create a RouteMatch object - results.forEach((matchDetails) => { - allMatchedRoutes.push({ - route, - routeTree: { - metadata: { name: treeName }, - expandedSpec: expandedTree, - }, - matchDetails, - }); - }); - }); - }); - - return { - labels, - matchedRoutes: allMatchedRoutes, - }; - }); - }, - [data] + const memoizedFunction = useCallback( + (instances: Label[][]) => matchInstancesToRouteTrees(data?.items ?? [], instances), + [data?.items] ); - return { matchInstancesToPolicies, ...rest }; + return { + matchInstancesToRouteTrees: memoizedFunction, + ...rest, + }; +} + +/** + * This function will match a set of labels to multiple routing trees. Assumes a list of routing trees has already been fetched. + * + * Use "useMatchInstancesToRouteTrees" if you want the hook to automatically fetch the latest definition of routing trees. + */ +export function matchInstancesToRouteTrees(trees: RoutingTree[], instances: Label[][]): InstanceMatchResult[] { + // Process each tree and get matches for all instances + const treeMatches = trees.map((tree) => { + const rootRoute = convertRoutingTreeToRoute(tree); + return matchInstancesToRoute(rootRoute, instances); + }); + + // Group results by instance + return instances.map((labels) => { + // Collect matches for this specific instance from all trees + const allMatchedRoutes = treeMatches.flatMap(({ expandedTree, matchedPolicies }, index) => { + const tree = trees[index]; + + return Array.from(matchedPolicies.entries()).flatMap(([route, results]) => + results + .filter((matchDetails) => matchDetails.labels === labels) + .map((matchDetails) => ({ + route, + routeTree: { + metadata: { name: tree.metadata.name ?? USER_DEFINED_TREE_NAME }, + expandedSpec: expandedTree, + }, + matchDetails, + })) + ); + }); + + return { + labels, + matchedRoutes: allMatchedRoutes, + }; + }); } diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/utils.test.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.test.ts index 3911a4ba676..2c23b899a64 100644 --- a/packages/grafana-alerting/src/grafana/notificationPolicies/utils.test.ts +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.test.ts @@ -12,7 +12,7 @@ import { computeInheritedTree, findMatchingRoutes, getInheritedProperties, - matchAlertInstancesToPolicyTree, + matchInstancesToRoute, } from './utils'; describe('findMatchingRoutes', () => { @@ -1072,7 +1072,7 @@ describe('matchAlertInstancesToPolicyTree', () => { ], // Should match parent only ]; - const result = matchAlertInstancesToPolicyTree(instances, parentRoute); + const result = matchInstancesToRoute(parentRoute, instances); // Should return expanded tree with identifiers expect(result.expandedTree).toHaveProperty('id'); @@ -1109,13 +1109,13 @@ describe('matchAlertInstancesToPolicyTree', () => { }); // Empty instances array - const result1 = matchAlertInstancesToPolicyTree([], route); + const result1 = matchInstancesToRoute(route, []); expect(result1.expandedTree).toHaveProperty('id'); expect(result1.matchedPolicies.size).toBe(0); // Instances that don't match const instances: Label[][] = [[['service', 'api']]]; - const result2 = matchAlertInstancesToPolicyTree(instances, route); + const result2 = matchInstancesToRoute(route, instances); expect(result2.expandedTree).toHaveProperty('id'); expect(result2.matchedPolicies.size).toBe(0); }); diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts index 2cf5babe025..444244fe5fe 100644 --- a/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts @@ -1,7 +1,7 @@ import { groupBy, isArray, pick, reduce, uniqueId } from 'lodash'; import { RoutingTree, RoutingTreeRoute } from '../api/v0alpha1/api.gen'; -import { Label, LabelMatcher } from '../matchers/types'; +import { Label } from '../matchers/types'; import { LabelMatchDetails, matchLabels } from '../matchers/utils'; import { Route, RouteWithID } from './types'; @@ -150,6 +150,7 @@ export function addUniqueIdentifier(route: Route): RouteWithID { }; } +// all policies that were matched of a single tree export type TreeMatch = { /* we'll include the entire expanded policy tree for diagnostics */ expandedTree: RouteWithID; @@ -166,13 +167,13 @@ export type TreeMatch = { * @param instances - A set of labels for which you want to determine the matching policies * @param routingTree - A notification policy tree (or subtree) */ -export function matchAlertInstancesToPolicyTree(instances: Label[][], routingTree: Route): TreeMatch { +export function matchInstancesToRoute(rootRoute: Route, instances: Label[][]): TreeMatch { // initially empty map of matches policies const matchedPolicies = new Map(); // compute the entire expanded tree for matching routes and diagnostics // this will include inherited properties from parent nodes - const expandedTree = addUniqueIdentifier(computeInheritedTree(routingTree)); + const expandedTree = addUniqueIdentifier(computeInheritedTree(rootRoute)); // let's first find all matching routes for the provided instances const matchesArray = instances.flatMap((labels) => findMatchingRoutes(expandedTree, labels)); @@ -202,13 +203,6 @@ export function convertRoutingTreeToRoute(routingTree: RoutingTree): Route { return routes.map( (route): Route => ({ ...route, - matchers: route.matchers?.map( - (matcher): LabelMatcher => ({ - ...matcher, - // sadly we use type narrowing for this on Route but the codegen has it as a string - type: matcher.type as LabelMatcher['type'], - }) - ), routes: route.routes ? convertRoutingTreeRoutes(route.routes) : [], }) ); diff --git a/packages/grafana-alerting/src/unstable.ts b/packages/grafana-alerting/src/unstable.ts index 5f9bc060350..1b5fd591ee4 100644 --- a/packages/grafana-alerting/src/unstable.ts +++ b/packages/grafana-alerting/src/unstable.ts @@ -10,14 +10,15 @@ export { getContactPointDescription } from './grafana/contactPoints/utils'; // Notification Policies export { - useMatchAlertInstancesToNotificationPolicies, + useMatchInstancesToRouteTrees, + matchInstancesToRouteTrees, type RouteMatch, type InstanceMatchResult, } from './grafana/notificationPolicies/hooks/useMatchPolicies'; export { type TreeMatch, type RouteMatchResult, - matchAlertInstancesToPolicyTree, + matchInstancesToRoute, findMatchingRoutes, getInheritedProperties, computeInheritedTree, diff --git a/packages/grafana-data/src/themes/createShape.ts b/packages/grafana-data/src/themes/createShape.ts index 8e6497639a7..42291fb78d0 100644 --- a/packages/grafana-data/src/themes/createShape.ts +++ b/packages/grafana-data/src/themes/createShape.ts @@ -7,8 +7,28 @@ export interface ThemeShape { radius: Radii; } -interface Radii { +export interface Radii { + /** + * Use for most things (inputs, buttons, cards, panels, etc) + * Same as `md` + */ default: string; + /** + * Use for most things (inputs, buttons, cards, panels, etc) + * Same as `default` + */ + md: string; + /** + * Use for smaller things like chips, tags and badges + */ + sm: string; + /** + * Use for large things, like modals + */ + lg: string; + /** + * Used to create maximum half circle sides (e.g. for pills) + */ pill: string; circle: string; } @@ -19,10 +39,13 @@ export interface ThemeShapeInput { } export function createShape(options: ThemeShapeInput): ThemeShape { - const baseBorderRadius = options.borderRadius ?? 2; + const baseBorderRadius = options.borderRadius ?? 6; const radius = { default: `${baseBorderRadius}px`, + md: `${baseBorderRadius}px`, + sm: `${Math.ceil(baseBorderRadius * (2 / 3))}px`, // for default base becomes 4 + lg: `${Math.ceil(baseBorderRadius * (5 / 3))}px`, // for default base becomes 10 pill: '9999px', circle: '100%', }; diff --git a/packages/grafana-data/src/themes/themeDefinitions/aubergine.ts b/packages/grafana-data/src/themes/themeDefinitions/aubergine.ts index aaaa79f51a1..967621ebc60 100644 --- a/packages/grafana-data/src/themes/themeDefinitions/aubergine.ts +++ b/packages/grafana-data/src/themes/themeDefinitions/aubergine.ts @@ -48,9 +48,6 @@ const aubergineTheme: NewThemeOptions = { hoverFactor: 0.07, tonalOffset: 0.15, }, - shape: { - borderRadius: 6, - }, }; export default aubergineTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/desertbloom.ts b/packages/grafana-data/src/themes/themeDefinitions/desertbloom.ts index 8c08ca75da1..8a86b73a0f7 100644 --- a/packages/grafana-data/src/themes/themeDefinitions/desertbloom.ts +++ b/packages/grafana-data/src/themes/themeDefinitions/desertbloom.ts @@ -70,9 +70,6 @@ const desertBloomTheme: NewThemeOptions = { hoverFactor: 0.03, tonalOffset: 0.15, }, - shape: { - borderRadius: 6, - }, }; export default desertBloomTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.ts b/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.ts index 6492aaa906f..bfa3e121329 100644 --- a/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.ts +++ b/packages/grafana-data/src/themes/themeDefinitions/gildedgrove.ts @@ -60,9 +60,6 @@ const gildedGroveTheme: NewThemeOptions = { hoverFactor: 0.03, tonalOffset: 0.15, }, - shape: { - borderRadius: 5, - }, }; export default gildedGroveTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/gloom.ts b/packages/grafana-data/src/themes/themeDefinitions/gloom.ts index 8eda705ced8..49c105626fb 100644 --- a/packages/grafana-data/src/themes/themeDefinitions/gloom.ts +++ b/packages/grafana-data/src/themes/themeDefinitions/gloom.ts @@ -75,9 +75,6 @@ const gloomTheme: NewThemeOptions = { hoverFactor: 0.03, tonalOffset: 0.15, }, - shape: { - borderRadius: 5, - }, }; export default gloomTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/mars.ts b/packages/grafana-data/src/themes/themeDefinitions/mars.ts index 2d3680aacfe..f1db51e23b2 100644 --- a/packages/grafana-data/src/themes/themeDefinitions/mars.ts +++ b/packages/grafana-data/src/themes/themeDefinitions/mars.ts @@ -48,9 +48,6 @@ const marsTheme: NewThemeOptions = { hoverFactor: 0.05, tonalOffset: 0.2, }, - shape: { - borderRadius: 4, - }, }; export default marsTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.ts b/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.ts index 596ef029490..c777c61b055 100644 --- a/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.ts +++ b/packages/grafana-data/src/themes/themeDefinitions/sapphiredusk.ts @@ -74,9 +74,6 @@ const sapphireDuskTheme: NewThemeOptions = { hoverFactor: 0.03, tonalOffset: 0.15, }, - shape: { - borderRadius: 5, - }, }; export default sapphireDuskTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/synthwave.ts b/packages/grafana-data/src/themes/themeDefinitions/synthwave.ts index 9e7e227621d..5fc53cda0bb 100644 --- a/packages/grafana-data/src/themes/themeDefinitions/synthwave.ts +++ b/packages/grafana-data/src/themes/themeDefinitions/synthwave.ts @@ -48,9 +48,6 @@ const synthwaveTheme: NewThemeOptions = { hoverFactor: 0.03, tonalOffset: 0.15, }, - shape: { - borderRadius: 6, - }, }; export default synthwaveTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/tron.ts b/packages/grafana-data/src/themes/themeDefinitions/tron.ts index c95c9dea4fa..a9f0b8c3ed4 100644 --- a/packages/grafana-data/src/themes/themeDefinitions/tron.ts +++ b/packages/grafana-data/src/themes/themeDefinitions/tron.ts @@ -48,9 +48,6 @@ const tronTheme: NewThemeOptions = { hoverFactor: 0.05, tonalOffset: 0.2, }, - shape: { - borderRadius: 6, - }, }; export default tronTheme; diff --git a/packages/grafana-data/src/themes/themeDefinitions/victorian.ts b/packages/grafana-data/src/themes/themeDefinitions/victorian.ts index 504b06f80ad..32ddbcb244e 100644 --- a/packages/grafana-data/src/themes/themeDefinitions/victorian.ts +++ b/packages/grafana-data/src/themes/themeDefinitions/victorian.ts @@ -48,9 +48,6 @@ const victorianTheme: NewThemeOptions = { hoverFactor: 0.07, tonalOffset: 0.15, }, - shape: { - borderRadius: 6, - }, typography: { fontFamily: '"Georgia", "Times New Roman", serif', fontFamilyMonospace: "'Courier New', monospace", diff --git a/packages/grafana-data/src/themes/themeDefinitions/zen.ts b/packages/grafana-data/src/themes/themeDefinitions/zen.ts index 8fd12542c21..f2735f41b74 100644 --- a/packages/grafana-data/src/themes/themeDefinitions/zen.ts +++ b/packages/grafana-data/src/themes/themeDefinitions/zen.ts @@ -48,9 +48,6 @@ const zenTheme: NewThemeOptions = { hoverFactor: 0.03, tonalOffset: 0.2, }, - shape: { - borderRadius: 8, - }, }; export default zenTheme; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 3c7060ea17c..3d92d782abe 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1013,6 +1013,10 @@ export interface FeatureToggles { */ kubernetesAuthzApis?: boolean; /** + * Redirects the traffic from the legacy access control endpoints to the new K8s AuthZ endpoints + */ + kubernetesAuthZHandlerRedirect?: boolean; + /** * Registers AuthZ resource permission /apis endpoints */ kubernetesAuthzResourcePermissionApis?: boolean; diff --git a/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.test.tsx b/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.test.tsx index 22f50cb36b8..79cb8d459d3 100644 --- a/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.test.tsx +++ b/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.test.tsx @@ -5,9 +5,10 @@ import { createDataFrame } from '@grafana/data'; import { FlameGraphDataContainer } from '../FlameGraph/dataTransform'; import { data } from '../FlameGraph/testData/dataNestedSet'; +import { textToDataContainer } from '../FlameGraph/testHelpers'; import { ColorScheme } from '../types'; -import FlameGraphTopTableContainer from './FlameGraphTopTableContainer'; +import FlameGraphTopTableContainer, { buildFilteredTable } from './FlameGraphTopTableContainer'; describe('FlameGraphTopTableContainer', () => { const setup = () => { @@ -52,7 +53,10 @@ describe('FlameGraphTopTableContainer', () => { expect(cells).toHaveLength(60); // 16 rows expect(cells[1].textContent).toEqual('net/http.HandlerFunc.ServeHTTP'); expect(cells[2].textContent).toEqual('31.7 K'); - expect(cells[3].textContent).toEqual('31.7 Bil'); + expect(cells[3].textContent).toEqual('5.58 Bil'); + expect(cells[5].textContent).toEqual('total'); + expect(cells[6].textContent).toEqual('16.5 K'); + expect(cells[7].textContent).toEqual('16.5 Bil'); expect(cells[25].textContent).toEqual('net/http.(*conn).serve'); expect(cells[26].textContent).toEqual('5.63 K'); expect(cells[27].textContent).toEqual('5.63 Bil'); @@ -83,3 +87,111 @@ describe('FlameGraphTopTableContainer', () => { expect(mocks.onSandwich).toHaveBeenCalledWith('net/http.HandlerFunc.ServeHTTP'); }); }); + +describe('buildFilteredTable', () => { + it('should group data by label and sum values', () => { + const container = textToDataContainer(` +[0////] +[1][2] +[3][4] + `); + + const result = buildFilteredTable(container!); + + expect(result).toEqual({ + '0': { self: 1, total: 7, totalRight: 0 }, + '1': { self: 0, total: 3, totalRight: 0 }, + '2': { self: 0, total: 3, totalRight: 0 }, + '3': { self: 3, total: 3, totalRight: 0 }, + '4': { self: 3, total: 3, totalRight: 0 }, + }); + }); + + it('should sum values for duplicate labels', () => { + const container = textToDataContainer(` +[0///] +[1][1] + `); + + const result = buildFilteredTable(container!); + + expect(result).toEqual({ + '0': { self: 0, total: 6, totalRight: 0 }, + '1': { self: 6, total: 6, totalRight: 0 }, + }); + }); + + it('should filter by matchedLabels when provided', () => { + const container = textToDataContainer(` +[0////] +[1][2] +[3][4] + `); + + const matchedLabels = new Set(['1', '3']); + const result = buildFilteredTable(container!, matchedLabels); + + expect(result).toEqual({ + '1': { self: 0, total: 3, totalRight: 0 }, + '3': { self: 3, total: 3, totalRight: 0 }, + }); + }); + + it('should handle empty matchedLabels set', () => { + const container = textToDataContainer(` +[0////] +[1][2] +[3][4] + `); + + const matchedLabels = new Set(); + const result = buildFilteredTable(container!, matchedLabels); + + expect(result).toEqual({}); + }); + + it('should handle data with no matches', () => { + const container = textToDataContainer(` +[0////] +[1][2] +[3][4] + `); + + const matchedLabels = new Set(['9']); + const result = buildFilteredTable(container!, matchedLabels); + + expect(result).toEqual({}); + }); + + it('should work without matchedLabels filter', () => { + const container = textToDataContainer(` +[0] +[1] + `); + + const result = buildFilteredTable(container!); + + expect(result).toEqual({ + '0': { self: 0, total: 3, totalRight: 0 }, + '1': { self: 3, total: 3, totalRight: 0 }, + }); + }); + it('should not inflate totals for recursive calls', () => { + const container = textToDataContainer(` +[0////] +[1][2] +[3][4] +[0] + `); + + const result = buildFilteredTable(container!); + + expect(result).toEqual({ + '0': { self: 4, total: 7, totalRight: 0 }, + '1': { self: 0, total: 3, totalRight: 0 }, + '2': { self: 0, total: 3, totalRight: 0 }, + '3': { self: 0, total: 3, totalRight: 0 }, + '4': { self: 3, total: 3, totalRight: 0 }, + }); + }); +}); diff --git a/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx b/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx index b3a1be9a3b3..8c5461bb053 100644 --- a/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx +++ b/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx @@ -53,28 +53,7 @@ const FlameGraphTopTableContainer = memo( onTableSort, colorScheme, }: Props) => { - const table = useMemo(() => { - // Group the data by label, we show only one row per label and sum the values - // TODO: should be by filename + funcName + linenumber? - let filteredTable: { [key: string]: TableData } = Object.create(null); - for (let i = 0; i < data.data.length; i++) { - const value = data.getValue(i); - const valueRight = data.getValueRight(i); - const self = data.getSelf(i); - const label = data.getLabel(i); - - // If user is doing text search we filter out labels in the same way we highlight them in flame graph. - if (!matchedLabels || matchedLabels.has(label)) { - filteredTable[label] = filteredTable[label] || {}; - filteredTable[label].self = filteredTable[label].self ? filteredTable[label].self + self : self; - filteredTable[label].total = filteredTable[label].total ? filteredTable[label].total + value : value; - filteredTable[label].totalRight = filteredTable[label].totalRight - ? filteredTable[label].totalRight + valueRight - : valueRight; - } - } - return filteredTable; - }, [data, matchedLabels]); + const table = useMemo(() => buildFilteredTable(data, matchedLabels), [data, matchedLabels]); const styles = useStyles2(getStyles); const theme = useTheme2(); @@ -124,6 +103,49 @@ const FlameGraphTopTableContainer = memo( FlameGraphTopTableContainer.displayName = 'FlameGraphTopTableContainer'; +function buildFilteredTable(data: FlameGraphDataContainer, matchedLabels?: Set) { + // Group the data by label, we show only one row per label and sum the values + // TODO: should be by filename + funcName + linenumber? + let filteredTable: { [key: string]: TableData } = Object.create(null); + + // Track call stack to detect recursive calls + const callStack: string[] = []; + + for (let i = 0; i < data.data.length; i++) { + const value = data.getValue(i); + const valueRight = data.getValueRight(i); + const self = data.getSelf(i); + const label = data.getLabel(i); + const level = data.getLevel(i); + + // Maintain call stack based on level changes + while (callStack.length > level) { + callStack.pop(); + } + + // Check if this is a recursive call (same label already in call stack) + const isRecursive = callStack.some((entry) => entry === label); + + // If user is doing text search we filter out labels in the same way we highlight them in flame graph. + if (!matchedLabels || matchedLabels.has(label)) { + filteredTable[label] = filteredTable[label] || {}; + filteredTable[label].self = filteredTable[label].self ? filteredTable[label].self + self : self; + + // Only add to total if this is not a recursive call + if (!isRecursive) { + filteredTable[label].total = filteredTable[label].total ? filteredTable[label].total + value : value; + filteredTable[label].totalRight = filteredTable[label].totalRight + ? filteredTable[label].totalRight + valueRight + : valueRight; + } + } + + // Add current call to the stack + callStack.push(label); + } + return filteredTable; +} + function buildTableDataFrame( data: FlameGraphDataContainer, table: { [key: string]: TableData }, @@ -365,4 +387,6 @@ const getStylesActionCell = () => { }; }; +export { buildFilteredTable }; + export default FlameGraphTopTableContainer; diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts index ca7bc045f9c..2e7153328e9 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -211,23 +211,12 @@ class DataSourceWithBackend< // Use the new query service for explore if (config.featureToggles.queryServiceFromExplore && request.app === CoreApp.Explore) { - // make sure query-service is enabled on the backend - const isQueryServiceEnabled = config.featureToggles.queryService; - const isExperimentalAPIsEnabled = config.featureToggles.grafanaAPIServerWithExperimentalAPIs; - if (!isQueryServiceEnabled && !isExperimentalAPIsEnabled) { - console.warn('feature toggle queryServiceFromExplore also requires the queryService to be running'); - } else { - url = `/apis/query.grafana.app/v0alpha1/namespaces/${config.namespace}/query?ds_type=${this.type}`; - } + url = `/apis/query.grafana.app/v0alpha1/namespaces/${config.namespace}/query?ds_type=${this.type}`; } // Use the new query service if (config.featureToggles.queryServiceFromUI) { - if (!(config.featureToggles.queryService || config.featureToggles.grafanaAPIServerWithExperimentalAPIs)) { - console.warn('feature toggle queryServiceFromUI also requires the queryService to be running'); - } else { - url = `/apis/query.grafana.app/v0alpha1/namespaces/${config.namespace}/query?ds_type=${this.type}`; - } + url = `/apis/query.grafana.app/v0alpha1/namespaces/${config.namespace}/query?ds_type=${this.type}`; } if (hasExpr) { diff --git a/packages/grafana-ui/src/components/Badge/Badge.tsx b/packages/grafana-ui/src/components/Badge/Badge.tsx index f28f4d35a1d..201957ab534 100644 --- a/packages/grafana-ui/src/components/Badge/Badge.tsx +++ b/packages/grafana-ui/src/components/Badge/Badge.tsx @@ -81,7 +81,7 @@ const getStyles = (theme: GrafanaTheme2, color: BadgeColor) => { wrapper: css({ display: 'inline-flex', padding: '1px 4px', - borderRadius: theme.shape.radius.default, + borderRadius: theme.shape.radius.sm, background: bgColor, border: `1px solid ${borderColor}`, color: textColor, diff --git a/packages/grafana-ui/src/components/Carousel/Carousel.tsx b/packages/grafana-ui/src/components/Carousel/Carousel.tsx index d33f2ae06ca..f3cc7892293 100644 --- a/packages/grafana-ui/src/components/Carousel/Carousel.tsx +++ b/packages/grafana-ui/src/components/Carousel/Carousel.tsx @@ -183,6 +183,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ flex: 1, }), imagePreview: css({ + borderRadius: theme.shape.radius.lg, maxWidth: '100%', maxHeight: '80vh', objectFit: 'contain', diff --git a/packages/grafana-ui/src/components/Combobox/ValuePill.tsx b/packages/grafana-ui/src/components/Combobox/ValuePill.tsx index e597c329103..f911dff1e28 100644 --- a/packages/grafana-ui/src/components/Combobox/ValuePill.tsx +++ b/packages/grafana-ui/src/components/Combobox/ValuePill.tsx @@ -43,7 +43,7 @@ ValuePill.displayName = 'ValuePill'; const getValuePillStyles = (theme: GrafanaTheme2, disabled?: boolean) => ({ wrapper: css({ display: 'inline-flex', - borderRadius: theme.shape.radius.default, + borderRadius: theme.shape.radius.sm, color: theme.colors.text.primary, background: theme.colors.background.secondary, padding: theme.spacing(0.25), @@ -67,7 +67,7 @@ const getValuePillStyles = (theme: GrafanaTheme2, disabled?: boolean) => ({ separator: css({ background: theme.colors.border.weak, - width: '2px', + width: '1px', height: '100%', marginRight: theme.spacing(0.5), }), diff --git a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap index 578c6f96821..bfdedc58db4 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap +++ b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap @@ -3,7 +3,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
{ display: 'inline-block', width: theme.spacing(checkboxSize), height: theme.spacing(checkboxSize), - borderRadius: theme.shape.radius.default, + borderRadius: theme.shape.radius.sm, background: theme.components.input.background, border: `1px solid ${getBorderColor(theme.components.input.borderColor)}`, diff --git a/packages/grafana-ui/src/components/Modal/getModalStyles.ts b/packages/grafana-ui/src/components/Modal/getModalStyles.ts index 9847f8df418..d6ef33245b6 100644 --- a/packages/grafana-ui/src/components/Modal/getModalStyles.ts +++ b/packages/grafana-ui/src/components/Modal/getModalStyles.ts @@ -3,15 +3,13 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; export const getModalStyles = (theme: GrafanaTheme2) => { - const borderRadius = theme.shape.radius.default; - return { modal: css({ position: 'fixed', zIndex: theme.zIndex.modal, background: theme.colors.background.primary, boxShadow: theme.shadows.z3, - borderRadius, + borderRadius: theme.shape.radius.lg, border: `1px solid ${theme.colors.border.weak}`, backgroundClip: 'padding-box', outline: 'none', diff --git a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx index 0d525d5b4a5..276520b873d 100644 --- a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx +++ b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx @@ -1,4 +1,4 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api'; import { PureComponent } from 'react'; @@ -136,7 +136,7 @@ class UnthemedCodeEditor extends PureComponent { const value = this.props.value ?? ''; const longText = value.length > 100; - const containerStyles = this.props.containerStyles ?? getStyles(theme).container; + const containerStyles = cx(getStyles(theme).container, this.props.containerStyles); const options: MonacoOptions = { wordWrap: wordWrap ? 'on' : 'off', @@ -203,6 +203,7 @@ const getStyles = (theme: GrafanaTheme2) => { container: css({ borderRadius: theme.shape.radius.default, border: `1px solid ${theme.components.input.borderColor}`, + overflow: 'hidden', }), }; }; diff --git a/packages/grafana-ui/src/components/Select/getSelectStyles.ts b/packages/grafana-ui/src/components/Select/getSelectStyles.ts index 8d6292c432b..392c1523a33 100644 --- a/packages/grafana-ui/src/components/Select/getSelectStyles.ts +++ b/packages/grafana-ui/src/components/Select/getSelectStyles.ts @@ -127,7 +127,7 @@ export const getSelectStyles = stylesFactory((theme: GrafanaTheme2) => { alignItems: 'center', lineHeight: 1, background: theme.colors.background.secondary, - borderRadius: theme.shape.radius.default, + borderRadius: theme.shape.radius.sm, margin: theme.spacing(0.25, 1, 0.25, 0), padding: theme.spacing(0.25, 0, 0.25, 1), color: theme.colors.text.primary, diff --git a/packages/grafana-ui/src/components/Tags/Tag.tsx b/packages/grafana-ui/src/components/Tags/Tag.tsx index 2550cb052c5..e1dde995bad 100644 --- a/packages/grafana-ui/src/components/Tags/Tag.tsx +++ b/packages/grafana-ui/src/components/Tags/Tag.tsx @@ -85,7 +85,7 @@ const getTagStyles = (theme: GrafanaTheme2, name: string, colorIndex?: number) = whiteSpace: 'pre', textShadow: 'none', padding: '3px 6px', - borderRadius: theme.shape.radius.default, + borderRadius: theme.shape.radius.sm, }), hover: css({ '&:hover': { diff --git a/pkg/registry/apis/apis.go b/pkg/registry/apis/apis.go index 315afb0e676..98a09583c85 100644 --- a/pkg/registry/apis/apis.go +++ b/pkg/registry/apis/apis.go @@ -4,7 +4,6 @@ import ( dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" "github.com/grafana/grafana/pkg/registry/apis/datasource" - "github.com/grafana/grafana/pkg/registry/apis/featuretoggle" "github.com/grafana/grafana/pkg/registry/apis/folders" "github.com/grafana/grafana/pkg/registry/apis/iam" "github.com/grafana/grafana/pkg/registry/apis/ofrep" @@ -22,7 +21,6 @@ type Service struct{} func ProvideRegistryServiceSink( _ *dashboardinternal.DashboardsAPIBuilder, _ *dashboardsnapshot.SnapshotsAPIBuilder, - _ *featuretoggle.FeatureFlagAPIBuilder, _ *datasource.DataSourceAPIBuilder, _ *folders.FolderAPIBuilder, _ *iam.IdentityAccessManagementAPIBuilder, diff --git a/pkg/registry/apis/dashboard/authorizer.go b/pkg/registry/apis/dashboard/authorizer.go index 05dfc6a7e61..07ad6bfda19 100644 --- a/pkg/registry/apis/dashboard/authorizer.go +++ b/pkg/registry/apis/dashboard/authorizer.go @@ -8,13 +8,12 @@ import ( "github.com/grafana/authlib/types" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/libraryelements" ) -func newLegacyAuthorizer(ac accesscontrol.AccessControl, l log.Logger) authorizer.Authorizer { +func newLegacyAuthorizer(ac accesscontrol.AccessControl) authorizer.Authorizer { return authorizer.AuthorizerFunc( func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { // Note that we will return Allow more than expected. diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 0bd80b9c4b7..1f295b29d21 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -20,6 +20,7 @@ import ( "k8s.io/kube-openapi/pkg/validation/spec" authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana-app-sdk/logging" internal "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" @@ -33,7 +34,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher" @@ -48,7 +48,6 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/librarypanels" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/search/sort" @@ -103,12 +102,10 @@ type DashboardsAPIBuilder struct { dashStore dashboards.Store QuotaService quota.Service ProvisioningService provisioning.ProvisioningService - cfg *setting.Cfg + minRefreshInterval string dualWriter dualwrite.Service folderClientProvider client.K8sHandlerProvider - log log.Logger - reg prometheus.Registerer isStandalone bool // skips any handling including anything to do with legacy storage } @@ -118,7 +115,6 @@ func RegisterAPIService( apiregistration builder.APIRegistrar, dashboardService dashboards.DashboardService, provisioningDashboardService dashboards.DashboardProvisioningService, - pluginStore pluginstore.Store, datasourceService datasources.DataSourceService, dashboardPermissions dashboards.PermissionsRegistrationService, dashboardPermissionsSvc accesscontrol.DashboardPermissionsService, @@ -142,10 +138,8 @@ func RegisterAPIService( legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter) folderClient := client.NewK8sHandler(dual, request.GetNamespaceMapper(cfg), folders.FolderResourceInfo.GroupVersionResource(), restConfigProvider.GetRestConfig, dashStore, userService, unified, sorter, features) - dashLog := log.New("grafana-apiserver.dashboards") builder := &DashboardsAPIBuilder{ - log: dashLog, - authorizer: newLegacyAuthorizer(accessControl, dashLog), + authorizer: newLegacyAuthorizer(accessControl), dashboardService: dashboardService, dashboardPermissions: dashboardPermissions, dashboardPermissionsSvc: dashboardPermissionsSvc, @@ -158,7 +152,7 @@ func RegisterAPIService( dashStore: dashStore, QuotaService: quotaService, ProvisioningService: provisioning, - cfg: cfg, + minRefreshInterval: cfg.MinRefreshInterval, dualWriter: dual, folderClientProvider: newSimpleFolderClientProvider(folderClient), @@ -166,7 +160,6 @@ func RegisterAPIService( Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features), DashboardService: dashboardService, }, - reg: reg, } migration.RegisterMetrics(reg) @@ -177,24 +170,10 @@ func RegisterAPIService( return builder } -func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, folderClientProvider client.K8sHandlerProvider, datasourceProvider schemaversion.DataSourceInfoProvider, pluginStore *pluginstore.Service) *DashboardsAPIBuilder { - // TODO: Plugin store will soon be removed, - // as the cases for plugin fetching is not needed. Keeping it now to not break implementation - if pluginStore == nil { - panic("pluginStore is nil") - } - - logger := log.New("grafana-apiserver.dashboards") - +func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, folderClientProvider client.K8sHandlerProvider, datasourceProvider schemaversion.DataSourceInfoProvider) *DashboardsAPIBuilder { migration.Initialize(datasourceProvider) - return &DashboardsAPIBuilder{ - log: logger, - reg: prometheus.NewRegistry(), - - cfg: &setting.Cfg{ - MinRefreshInterval: "10s", - }, + minRefreshInterval: "10s", accessClient: ac, authorizer: authsvc.NewResourceAuthorizer(ac), features: features, @@ -336,7 +315,7 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A } // Validate refresh interval - if err := b.dashboardService.ValidateDashboardRefreshInterval(b.cfg.MinRefreshInterval, refresh); err != nil { + if err := b.dashboardService.ValidateDashboardRefreshInterval(b.minRefreshInterval, refresh); err != nil { return apierrors.NewBadRequest(err.Error()) } @@ -422,7 +401,7 @@ func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.A } // Validate refresh interval - if err := b.dashboardService.ValidateDashboardRefreshInterval(b.cfg.MinRefreshInterval, refresh); err != nil { + if err := b.dashboardService.ValidateDashboardRefreshInterval(b.minRefreshInterval, refresh); err != nil { return apierrors.NewBadRequest(err.Error()) } @@ -592,7 +571,7 @@ func (b *DashboardsAPIBuilder) storageForVersion( return nil } - legacyStore, err := b.legacy.NewStore(dashboards, opts.Scheme, opts.OptsGetter, b.reg, b.dashboardPermissions, b.accessClient) + legacyStore, err := b.legacy.NewStore(dashboards, opts.Scheme, opts.OptsGetter, opts.MetricsRegister, b.dashboardPermissions, b.accessClient) if err != nil { return err } @@ -692,7 +671,7 @@ func (b *DashboardsAPIBuilder) verifyFolderAccessPermissions(ctx context.Context var accessInfo folders.FolderAccessInfo err = runtime.DefaultUnstructuredConverter.FromUnstructured(resp.Object, &accessInfo) if err != nil { - b.log.Error("Failed to convert folder access response", "error", err) + logging.FromContext(ctx).Error("Failed to convert folder access response", "error", err) return dashboards.ErrFolderAccessDenied } diff --git a/pkg/registry/apis/dashboard/schema_validation.go b/pkg/registry/apis/dashboard/schema_validation.go index 4263d2af388..c53ed82481a 100644 --- a/pkg/registry/apis/dashboard/schema_validation.go +++ b/pkg/registry/apis/dashboard/schema_validation.go @@ -10,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/validation/field" + "github.com/grafana/grafana-app-sdk/logging" v0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" v1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" v2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" @@ -62,7 +63,7 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru } if alwaysLogSchemaValidationErrors && len(errors) > 0 { - b.log.Info("Schema validation errors during dashboard validation", "group_version", obj.GetObjectKind().GroupVersionKind().GroupVersion().String(), "name", accessor.GetName(), "errors", errors.ToAggregate().Error(), "schema_version_mismatch", schemaVersionError != nil) + logging.FromContext(ctx).Info("Schema validation errors during dashboard validation", "group_version", obj.GetObjectKind().GroupVersionKind().GroupVersion().String(), "name", accessor.GetName(), "errors", errors.ToAggregate().Error(), "schema_version_mismatch", schemaVersionError != nil) } if errorOnSchemaMismatches { diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 355c193ce48..6747f8ed69b 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -85,6 +85,15 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) * Schema: spec.StringProperty(), }, }, + { + ParameterProps: spec3.ParameterProps{ + Name: "type", + In: "query", + Description: "search dashboards or folders. When empty, this will search both", + Required: false, + Schema: spec.StringProperty().WithEnum("folder", "dashboard"), + }, + }, { ParameterProps: spec3.ParameterProps{ Name: "folder", @@ -94,6 +103,24 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) * Schema: spec.StringProperty(), }, }, + { + ParameterProps: spec3.ParameterProps{ + Name: "facet", + In: "query", + Description: "count distinct terms for selected fields", + Required: false, + Schema: spec.ArrayProperty(spec.StringProperty()), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "tags", + In: "query", + Description: "tag query filter", + Required: false, + Schema: spec.ArrayProperty(spec.StringProperty()), + }, + }, { ParameterProps: spec3.ParameterProps{ Name: "sort", @@ -124,6 +151,24 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) * Schema: spec.StringProperty(), }, }, + { + ParameterProps: spec3.ParameterProps{ + Name: "limit", + In: "query", + Description: "number of results to return", + Required: false, + Schema: spec.Int64Property(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "explain", + In: "query", + Description: "add debugging info that may help explain why the result matched", + Required: false, + Schema: spec.BoolProperty(), + }, + }, }, Responses: &spec3.Responses{ ResponsesProps: spec3.ResponsesProps{ @@ -257,33 +302,25 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { } searchRequest.Fields = fields - types := queryParams["type"] - var federate *resourcepb.ResourceKey - switch len(types) { - case 0: - // When no type specified, search for dashboards + // Search dashboards or folders (or both) + switch queryParams.Get("type") { + case "folder": + searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), folders.RESOURCE) + case "dashboard": searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), dashboardv0alpha1.DASHBOARD_RESOURCE) - // Currently a search query is across folders and dashboards - if err == nil { - federate, err = asResourceKey(user.GetNamespace(), folders.RESOURCE) - } - case 1: - searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), types[0]) - case 2: - searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), types[0]) - if err == nil { - federate, err = asResourceKey(user.GetNamespace(), types[1]) - } default: - err = apierrors.NewBadRequest("too many type requests") + searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), dashboardv0alpha1.DASHBOARD_RESOURCE) + if err == nil { + federate, _ := asResourceKey(user.GetNamespace(), folders.RESOURCE) + if federate != nil { + searchRequest.Federated = []*resourcepb.ResourceKey{federate} + } + } } if err != nil { errhttp.Write(ctx, err, w) return } - if federate != nil { - searchRequest.Federated = []*resourcepb.ResourceKey{federate} - } // Add sorting if queryParams.Has("sort") { diff --git a/pkg/registry/apis/dashboard/sub_dto.go b/pkg/registry/apis/dashboard/sub_dto.go index 80ec5c8f12c..d3023664f65 100644 --- a/pkg/registry/apis/dashboard/sub_dto.go +++ b/pkg/registry/apis/dashboard/sub_dto.go @@ -9,9 +9,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" - claims "github.com/grafana/authlib/types" + authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana-app-sdk/logging" - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" @@ -145,14 +144,12 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob access.CanAdmin, _ = r.accessControl.Evaluate(ctx, user, adminEvaluator) deleteEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashScope) access.CanDelete, _ = r.accessControl.Evaluate(ctx, user, deleteEvaluator) - access.CanStar = user.IsIdentityType(claims.TypeUser) + access.CanStar = user.IsIdentityType(authlib.TypeUser) access.AnnotationsPermissions = &dashboard.AnnotationPermission{} r.getAnnotationPermissionsByScope(ctx, user, &access.AnnotationsPermissions.Dashboard, accesscontrol.ScopeAnnotationsTypeDashboard) r.getAnnotationPermissionsByScope(ctx, user, &access.AnnotationsPermissions.Organization, accesscontrol.ScopeAnnotationsTypeOrganization) - // FIXME!!!! does not get the title! - // The title property next to unstructured and not found in this model title := obj.FindTitle("") access.Slug = slugify.Slugify(title) access.Url = dashboards.GetDashboardFolderURL(false, name, access.Slug) diff --git a/pkg/registry/apis/featuretoggle/README.md b/pkg/registry/apis/featuretoggle/README.md deleted file mode 100644 index 7267e2e2819..00000000000 --- a/pkg/registry/apis/featuretoggle/README.md +++ /dev/null @@ -1,5 +0,0 @@ -This package supports the [Feature toggle admin page](https://grafana.com/docs/grafana/latest/administration/feature-toggles/) feature. - -In order to update feature toggles through the app, the PATCH handler calls a webhook that should update Grafana's configuration and restarts the instance. - -For local development, set the app mode to `development` by adding `app_mode = development` to the top level of your Grafana .ini file. \ No newline at end of file diff --git a/pkg/registry/apis/featuretoggle/current.go b/pkg/registry/apis/featuretoggle/current.go deleted file mode 100644 index f7602584a85..00000000000 --- a/pkg/registry/apis/featuretoggle/current.go +++ /dev/null @@ -1,237 +0,0 @@ -package featuretoggle - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - "github.com/grafana/grafana/pkg/apimachinery/errutil" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" - ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util/errhttp" - "github.com/grafana/grafana/pkg/web" -) - -func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0alpha1.ResolvedToggleState { - state := v0alpha1.ResolvedToggleState{ - TypeMeta: v1.TypeMeta{ - APIVersion: v0alpha1.APIVERSION, - Kind: "ResolvedToggleState", - }, - Enabled: b.features.GetEnabled(ctx), - RestartRequired: b.features.IsRestartRequired(), - } - - // Reference to the object that defined the values - startupRef := &common.ObjectReference{ - Namespace: "system", - Name: "startup", - } - - startup := b.features.GetStartupFlags() - warnings := b.features.GetWarning() - for _, f := range b.features.GetFlags() { - name := f.Name - if b.features.IsHiddenFromAdminPage(name, false) { - continue - } - - toggle := v0alpha1.ToggleStatus{ - Name: name, - Description: f.Description, // simplify the UI changes - Stage: f.Stage.String(), - Enabled: state.Enabled[name], - Writeable: b.features.IsEditableFromAdminPage(name), - Source: startupRef, - Warning: warnings[name], - } - if f.Expression == "true" && toggle.Enabled { - toggle.Source = nil - } - _, inStartup := startup[name] - if toggle.Enabled || toggle.Writeable || toggle.Warning != "" || inStartup { - state.Toggles = append(state.Toggles, toggle) - } - - if toggle.Writeable { - state.AllowEditing = true - } - } - - // Make sure the user can actually write values - if state.AllowEditing { - state.AllowEditing = b.features.IsFeatureEditingAllowed() && b.userCanWrite(ctx, nil) - } - return state -} - -func (b *FeatureFlagAPIBuilder) userCanRead(ctx context.Context, u identity.Requester) bool { - if u == nil { - u, _ = identity.GetRequester(ctx) - if u == nil { - return false - } - } - ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementRead)) - return ok && err == nil -} - -func (b *FeatureFlagAPIBuilder) userCanWrite(ctx context.Context, u identity.Requester) bool { - if u == nil { - u, _ = identity.GetRequester(ctx) - if u == nil { - return false - } - } - ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementWrite)) - return ok && err == nil -} - -func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPatch { - b.handlePatchCurrent(w, r) - return - } - - // Check if the user can access toggle info - ctx := r.Context() - user, err := identity.GetRequester(ctx) - if err != nil { - errhttp.Write(ctx, err, w) - return - } - - if !b.userCanRead(ctx, user) { - err = errutil.Unauthorized("featuretoggle.canNotRead", - errutil.WithPublicMessage("missing read permission")).Errorf("user %s does not have read permissions", user.GetLogin()) - errhttp.Write(ctx, err, w) - return - } - - // Write the state to the response body - state := b.getResolvedToggleState(r.Context()) - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(state) -} - -// NOTE: authz is already handled by the authorizer -func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if !b.features.IsFeatureEditingAllowed() { - err := errutil.Forbidden("featuretoggle.disabled", - errutil.WithPublicMessage("feature toggles are read-only")).Errorf("feature toggles are not writeable due to missing configuration") - errhttp.Write(ctx, err, w) - return - } - - user, err := identity.GetRequester(ctx) - if err != nil { - errhttp.Write(ctx, err, w) - return - } - - if !b.userCanWrite(ctx, user) { - err = errutil.Unauthorized("featuretoggle.canNotWrite", - errutil.WithPublicMessage("missing write permission")).Errorf("user %s does not have write permissions", user.GetLogin()) - errhttp.Write(ctx, err, w) - return - } - - request := v0alpha1.ResolvedToggleState{} - err = web.Bind(r, &request) - if err != nil { - errhttp.Write(ctx, err, w) - return - } - - if len(request.Toggles) > 0 { - err = errutil.BadRequest("featuretoggle.badRequest", - errutil.WithPublicMessage("can only patch the enabled section")).Errorf("request payload included properties in the read-only Toggles section") - errhttp.Write(ctx, err, w) - return - } - - changes := map[string]string{} // TODO would be nice to have this be a bool on the HG side - for k, v := range request.Enabled { - current := b.features.IsEnabled(ctx, k) - if current != v { - if !b.features.IsEditableFromAdminPage(k) { - err = errutil.BadRequest("featuretoggle.badRequest", - errutil.WithPublicMessage("invalid toggle passed in")).Errorf("can not edit toggle %s", k) - errhttp.Write(ctx, err, w) - w.WriteHeader(http.StatusBadRequest) - return - } - changes[k] = strconv.FormatBool(v) - } - } - - if len(changes) == 0 { - w.WriteHeader(http.StatusNotModified) - return - } - - payload := featuremgmt.FeatureToggleWebhookPayload{ - FeatureToggles: changes, - User: user.GetEmail(), - } - - err = sendWebhookUpdate(b.features.Settings, payload) - if err != nil && b.cfg.Env != setting.Dev { - err = errutil.Internal("featuretoggle.webhookFailure", errutil.WithPublicMessage("an error occurred while updating feeature toggles")).Errorf("webhook error: %w", err) - errhttp.Write(ctx, err, w) - return - } - - b.features.SetRestartRequired() - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("feature toggles updated successfully")) -} - -func sendWebhookUpdate(cfg setting.FeatureMgmtSettings, payload featuremgmt.FeatureToggleWebhookPayload) error { - data, err := json.Marshal(payload) - if err != nil { - return err - } - - req, err := http.NewRequest(http.MethodPost, cfg.UpdateWebhook, bytes.NewBuffer(data)) - if err != nil { - return err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+cfg.UpdateWebhookToken) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return err - } - defer func() { - if err := resp.Body.Close(); err != nil { - logger.Warn("Failed to close response body", "err", err) - } - }() - - if resp.StatusCode >= http.StatusBadRequest { - if body, err := io.ReadAll(resp.Body); err != nil { - return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %s", resp.StatusCode, string(body)) - } else { - return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %w", resp.StatusCode, err) - } - } - - return nil -} diff --git a/pkg/registry/apis/featuretoggle/current_test.go b/pkg/registry/apis/featuretoggle/current_test.go deleted file mode 100644 index e37dff81cbc..00000000000 --- a/pkg/registry/apis/featuretoggle/current_test.go +++ /dev/null @@ -1,459 +0,0 @@ -package featuretoggle - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetFeatureToggles(t *testing.T) { - t.Run("fails without adequate permissions", func(t *testing.T) { - features := featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, []*featuremgmt.FeatureFlag{{ - // Add this here to ensure the feature works as expected during tests - Name: featuremgmt.FlagFeatureToggleAdminPage, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }}) - - b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{}) - - callGetWith(t, b, http.StatusUnauthorized) - }) - - t.Run("should be able to get feature toggles", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: "toggle1", - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, - } - disabled := []string{"toggle2"} - - b := newTestAPIBuilder(t, features, disabled, setting.FeatureMgmtSettings{}) - result := callGetWith(t, b, http.StatusOK) - assert.Len(t, result.Toggles, 2) - t1, _ := findResult(t, result, "toggle1") - assert.True(t, t1.Enabled) - t2, _ := findResult(t, result, "toggle2") - assert.False(t, t2.Enabled) - }) - - t.Run("toggles hidden by config are not present in the response", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: "toggle1", - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, - } - settings := setting.FeatureMgmtSettings{ - HiddenToggles: map[string]struct{}{"toggle1": {}}, - } - - b := newTestAPIBuilder(t, features, []string{}, settings) - result := callGetWith(t, b, http.StatusOK) - - assert.Len(t, result.Toggles, 1) - assert.Equal(t, "toggle2", result.Toggles[0].Name) - }) - - t.Run("toggles that are read-only by config have the readOnly field set", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: "toggle1", - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, - } - disabled := []string{"toggle2"} - settings := setting.FeatureMgmtSettings{ - HiddenToggles: map[string]struct{}{"toggle1": {}}, - ReadOnlyToggles: map[string]struct{}{"toggle2": {}}, - AllowEditing: true, - UpdateWebhook: "bogus", - } - - b := newTestAPIBuilder(t, features, disabled, settings) - result := callGetWith(t, b, http.StatusOK) - - assert.Len(t, result.Toggles, 1) - assert.Equal(t, "toggle2", result.Toggles[0].Name) - assert.False(t, result.Toggles[0].Writeable) - }) - - t.Run("feature toggle defailts", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: "toggle1", - Stage: featuremgmt.FeatureStageUnknown, - }, { - Name: "toggle2", - Stage: featuremgmt.FeatureStageExperimental, - }, { - Name: "toggle3", - Stage: featuremgmt.FeatureStagePrivatePreview, - }, { - Name: "toggle4", - Stage: featuremgmt.FeatureStagePublicPreview, - AllowSelfServe: true, - }, { - Name: "toggle5", - Stage: featuremgmt.FeatureStageGeneralAvailability, - AllowSelfServe: true, - }, { - Name: "toggle6", - Stage: featuremgmt.FeatureStageDeprecated, - AllowSelfServe: true, - }, { - Name: "toggle7", - Stage: featuremgmt.FeatureStageGeneralAvailability, - AllowSelfServe: false, - }, - } - - t.Run("unknown, experimental, and private preview toggles are hidden by default", func(t *testing.T) { - b := newTestAPIBuilder(t, features, []string{}, setting.FeatureMgmtSettings{}) - result := callGetWith(t, b, http.StatusOK) - - assert.Len(t, result.Toggles, 4) - - _, ok := findResult(t, result, "toggle1") - assert.False(t, ok) - _, ok = findResult(t, result, "toggle2") - assert.False(t, ok) - _, ok = findResult(t, result, "toggle3") - assert.False(t, ok) - }) - - t.Run("only public preview and GA with AllowSelfServe are writeable", func(t *testing.T) { - settings := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateWebhook: "bogus", - } - - b := newTestAPIBuilder(t, features, []string{}, settings) - result := callGetWith(t, b, http.StatusOK) - - t4, ok := findResult(t, result, "toggle4") - assert.True(t, ok) - assert.True(t, t4.Writeable) - t5, ok := findResult(t, result, "toggle5") - assert.True(t, ok) - assert.True(t, t5.Writeable) - t6, ok := findResult(t, result, "toggle6") - assert.True(t, ok) - assert.True(t, t6.Writeable) - }) - - t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) { - settings := setting.FeatureMgmtSettings{ - AllowEditing: false, - UpdateWebhook: "", - } - b := newTestAPIBuilder(t, features, []string{}, settings) - result := callGetWith(t, b, http.StatusOK) - - assert.Len(t, result.Toggles, 4) - - t4, ok := findResult(t, result, "toggle4") - assert.True(t, ok) - assert.False(t, t4.Writeable) - t5, ok := findResult(t, result, "toggle5") - assert.True(t, ok) - assert.False(t, t5.Writeable) - t6, ok := findResult(t, result, "toggle6") - assert.True(t, ok) - assert.False(t, t6.Writeable) - }) - }) -} - -func TestSetFeatureToggles(t *testing.T) { - t.Run("fails when the user doesn't have write permissions", func(t *testing.T) { - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateWebhook: "random", - } - features := featuremgmt.WithFeatureManager(s, []*featuremgmt.FeatureFlag{{ - // Add this here to ensure the feature works as expected during tests - Name: featuremgmt.FlagFeatureToggleAdminPage, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }}) - - b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{}) - msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusUnauthorized) - assert.Equal(t, "missing write permission", msg) - }) - - t.Run("fails when update toggle url is not set", func(t *testing.T) { - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - } - b := newTestAPIBuilder(t, nil, []string{}, s) - msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusForbidden) - assert.Equal(t, "feature toggles are read-only", msg) - }) - - t.Run("fails with non-existent toggle", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: "toggle1", - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, - } - disabled := []string{"toggle2"} - update := v0alpha1.ResolvedToggleState{ - Enabled: map[string]bool{ - "toggle3": true, - }, - } - - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateWebhook: "random", - } - b := newTestAPIBuilder(t, features, disabled, s) - msg := callPatchWith(t, b, update, http.StatusBadRequest) - assert.Equal(t, "invalid toggle passed in", msg) - }) - - t.Run("fails with read-only toggles", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: featuremgmt.FlagFeatureToggleAdminPage, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Stage: featuremgmt.FeatureStagePublicPreview, - }, { - Name: "toggle3", - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, - } - disabled := []string{"toggle2", "toggle3"} - - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateWebhook: "random", - ReadOnlyToggles: map[string]struct{}{ - "toggle3": {}, - }, - } - - t.Run("because it is the feature toggle admin page toggle", func(t *testing.T) { - update := v0alpha1.ResolvedToggleState{ - Enabled: map[string]bool{ - featuremgmt.FlagFeatureToggleAdminPage: true, - }, - } - b := newTestAPIBuilder(t, features, disabled, s) - callPatchWith(t, b, update, http.StatusNotModified) - }) - - t.Run("because it is not GA or Deprecated", func(t *testing.T) { - update := v0alpha1.ResolvedToggleState{ - Enabled: map[string]bool{ - "toggle2": true, - }, - } - b := newTestAPIBuilder(t, features, disabled, s) - msg := callPatchWith(t, b, update, http.StatusBadRequest) - assert.Equal(t, "invalid toggle passed in", msg) - }) - - t.Run("because it is configured to be read-only", func(t *testing.T) { - update := v0alpha1.ResolvedToggleState{ - Enabled: map[string]bool{ - "toggle2": true, - }, - } - b := newTestAPIBuilder(t, features, disabled, s) - msg := callPatchWith(t, b, update, http.StatusBadRequest) - assert.Equal(t, "invalid toggle passed in", msg) - }) - }) - - t.Run("when all conditions met", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: featuremgmt.FlagFeatureToggleAdminPage, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Stage: featuremgmt.FeatureStagePublicPreview, - }, { - Name: "toggle3", - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle4", - Stage: featuremgmt.FeatureStageGeneralAvailability, - AllowSelfServe: true, - }, { - Name: "toggle5", - Stage: featuremgmt.FeatureStageDeprecated, - AllowSelfServe: true, - }, - } - disabled := []string{"toggle2", "toggle3", "toggle4"} - - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateWebhook: "random", - UpdateWebhookToken: "token", - ReadOnlyToggles: map[string]struct{}{ - "toggle3": {}, - }, - } - - update := v0alpha1.ResolvedToggleState{ - Enabled: map[string]bool{ - "toggle4": true, - "toggle5": false, - }, - } - t.Run("fail when webhook request is not successful", func(t *testing.T) { - webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - })) - defer webhookServer.Close() - s.UpdateWebhook = webhookServer.URL - - b := newTestAPIBuilder(t, features, disabled, s) - msg := callPatchWith(t, b, update, http.StatusInternalServerError) - assert.Equal(t, "an error occurred while updating feeature toggles", msg) - }) - - t.Run("succeed when webhook request is not successful but app is in dev mode", func(t *testing.T) { - webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - })) - defer webhookServer.Close() - s.UpdateWebhook = webhookServer.URL - - b := newTestAPIBuilder(t, features, disabled, s) - b.cfg.Env = setting.Dev - callPatchWith(t, b, update, http.StatusOK) - }) - - t.Run("succeed when webhook request is successful", func(t *testing.T) { - webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "Bearer "+s.UpdateWebhookToken, r.Header.Get("Authorization")) - - var req featuremgmt.FeatureToggleWebhookPayload - require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) - - assert.Equal(t, "true", req.FeatureToggles["toggle4"]) - assert.Equal(t, "false", req.FeatureToggles["toggle5"]) - w.WriteHeader(http.StatusOK) - })) - defer webhookServer.Close() - s.UpdateWebhook = webhookServer.URL - - b := newTestAPIBuilder(t, features, disabled, s) - msg := callPatchWith(t, b, update, http.StatusOK) - assert.Equal(t, "feature toggles updated successfully", msg) - }) - }) -} - -func findResult(t *testing.T, result v0alpha1.ResolvedToggleState, name string) (v0alpha1.ToggleStatus, bool) { - t.Helper() - - for _, t := range result.Toggles { - if t.Name == name { - return t, true - } - } - return v0alpha1.ToggleStatus{}, false -} - -func callGetWith(t *testing.T, b *FeatureFlagAPIBuilder, expectedCode int) v0alpha1.ResolvedToggleState { - w := response.CreateNormalResponse(http.Header{}, []byte{}, 0) - req := &http.Request{ - Method: "GET", - Header: http.Header{}, - } - req.Header.Add("content-type", "application/json") - req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{})) - b.handleCurrentStatus(w, req) - - rts := v0alpha1.ResolvedToggleState{} - require.NoError(t, json.Unmarshal(w.Body(), &rts)) - require.Equal(t, expectedCode, w.Status()) - - // Tests don't expect the feature toggle admin page feature to be present, so remove them from the resolved toggle state - for i, t := range rts.Toggles { - if t.Name == "featureToggleAdminPage" { - rts.Toggles = append(rts.Toggles[0:i], rts.Toggles[i+1:]...) - } - } - - return rts -} - -func callPatchWith(t *testing.T, b *FeatureFlagAPIBuilder, update v0alpha1.ResolvedToggleState, expectedCode int) string { - w := response.CreateNormalResponse(http.Header{}, []byte{}, 0) - - body, err := json.Marshal(update) - require.NoError(t, err) - - req := &http.Request{ - Method: "PATCH", - Body: io.NopCloser(bytes.NewReader(body)), - Header: http.Header{}, - } - req.Header.Add("content-type", "application/json") - req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{})) - b.handleCurrentStatus(w, req) - - require.NotNil(t, w.Body()) - require.Equal(t, expectedCode, w.Status()) - - // Extract the public facing message if this is an error - if w.Status() > 399 { - res := map[string]any{} - require.NoError(t, json.Unmarshal(w.Body(), &res)) - - return res["message"].(string) - } - - return string(w.Body()) -} - -func newTestAPIBuilder( - t *testing.T, - serverFeatures []*featuremgmt.FeatureFlag, - disabled []string, // the flags that are disabled - settings setting.FeatureMgmtSettings, -) *FeatureFlagAPIBuilder { - t.Helper() - features := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{ - // Add this here to ensure the feature works as expected during tests - Name: featuremgmt.FlagFeatureToggleAdminPage, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }}, serverFeatures...), disabled...) - - return NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: true}, &setting.Cfg{}) -} diff --git a/pkg/registry/apis/featuretoggle/features.go b/pkg/registry/apis/featuretoggle/features.go deleted file mode 100644 index fa7cf10a7fb..00000000000 --- a/pkg/registry/apis/featuretoggle/features.go +++ /dev/null @@ -1,95 +0,0 @@ -package featuretoggle - -import ( - "context" - "fmt" - "sync" - - "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "k8s.io/apimachinery/pkg/apis/meta/internalversion" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/rest" -) - -var ( - _ rest.Storage = (*featuresStorage)(nil) - _ rest.Scoper = (*featuresStorage)(nil) - _ rest.SingularNameProvider = (*featuresStorage)(nil) - _ rest.Lister = (*featuresStorage)(nil) - _ rest.Getter = (*featuresStorage)(nil) -) - -type featuresStorage struct { - resource *utils.ResourceInfo - tableConverter rest.TableConvertor - features *v0alpha1.FeatureList - featuresOnce sync.Once -} - -// NOTE! this does not depend on config or any system state! -// In the future, the existence of features (and their properties) can be defined dynamically -func NewFeaturesStorage() *featuresStorage { - resourceInfo := v0alpha1.FeatureResourceInfo - return &featuresStorage{ - resource: &resourceInfo, - tableConverter: resourceInfo.TableConverter(), - } -} - -func (s *featuresStorage) New() runtime.Object { - return s.resource.NewFunc() -} - -func (s *featuresStorage) Destroy() {} - -func (s *featuresStorage) NamespaceScoped() bool { - return false -} - -func (s *featuresStorage) GetSingularName() string { - return s.resource.GetSingularName() -} - -func (s *featuresStorage) NewList() runtime.Object { - return s.resource.NewListFunc() -} - -func (s *featuresStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { - return s.tableConverter.ConvertToTable(ctx, object, tableOptions) -} - -func (s *featuresStorage) init() { - s.featuresOnce.Do(func() { - rv := "1" - features, _ := featuremgmt.GetEmbeddedFeatureList() - for _, feature := range features.Items { - if feature.ResourceVersion > rv { - rv = feature.ResourceVersion - } - } - features.ResourceVersion = rv - s.features = &features - }) -} - -func (s *featuresStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { - s.init() - if s.features == nil { - return nil, fmt.Errorf("error loading embedded features") - } - return s.features, nil -} - -func (s *featuresStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { - s.init() - - for idx, flag := range s.features.Items { - if flag.Name == name { - return &s.features.Items[idx], nil - } - } - return nil, fmt.Errorf("not found") -} diff --git a/pkg/registry/apis/featuretoggle/register.go b/pkg/registry/apis/featuretoggle/register.go deleted file mode 100644 index ef80d7c618e..00000000000 --- a/pkg/registry/apis/featuretoggle/register.go +++ /dev/null @@ -1,148 +0,0 @@ -package featuretoggle - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apiserver/pkg/authorization/authorizer" - "k8s.io/apiserver/pkg/registry/rest" - genericapiserver "k8s.io/apiserver/pkg/server" - common "k8s.io/kube-openapi/pkg/common" - "k8s.io/kube-openapi/pkg/spec3" - "k8s.io/kube-openapi/pkg/validation/spec" - - "github.com/prometheus/client_golang/prometheus" - - "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/apiserver/builder" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" -) - -var _ builder.APIGroupBuilder = (*FeatureFlagAPIBuilder)(nil) -var _ builder.APIGroupRouteProvider = (*FeatureFlagAPIBuilder)(nil) - -var gv = v0alpha1.SchemeGroupVersion - -// This is used just so wire has something unique to return -type FeatureFlagAPIBuilder struct { - features *featuremgmt.FeatureManager - accessControl accesscontrol.AccessControl - cfg *setting.Cfg -} - -func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl, cfg *setting.Cfg) *FeatureFlagAPIBuilder { - return &FeatureFlagAPIBuilder{features, accessControl, cfg} -} - -func RegisterAPIService(features *featuremgmt.FeatureManager, - accessControl accesscontrol.AccessControl, - apiregistration builder.APIRegistrar, - cfg *setting.Cfg, - registerer prometheus.Registerer, -) *FeatureFlagAPIBuilder { - builder := NewFeatureFlagAPIBuilder(features, accessControl, cfg) - apiregistration.RegisterAPI(builder) - return builder -} - -func (b *FeatureFlagAPIBuilder) GetGroupVersion() schema.GroupVersion { - return gv -} - -func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { - scheme.AddKnownTypes(gv, - &v0alpha1.Feature{}, - &v0alpha1.FeatureList{}, - &v0alpha1.FeatureToggles{}, - &v0alpha1.FeatureTogglesList{}, - &v0alpha1.ResolvedToggleState{}, - ) -} - -func (b *FeatureFlagAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { - addKnownTypes(scheme, gv) - - // Link this version to the internal representation. - // This is used for server-side-apply (PATCH), and avoids the error: - // "no kind is registered for the type" - addKnownTypes(scheme, schema.GroupVersion{ - Group: gv.Group, - Version: runtime.APIVersionInternal, - }) - - // If multiple versions exist, then register conversions from zz_generated.conversion.go - // if err := playlist.RegisterConversions(scheme); err != nil { - // return err - // } - metav1.AddToGroupVersion(scheme, gv) - return scheme.SetVersionPriority(gv) -} - -func (b *FeatureFlagAPIBuilder) AllowedV0Alpha1Resources() []string { - return nil -} - -func (b *FeatureFlagAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, _ builder.APIGroupOptions) error { - featureStore := NewFeaturesStorage() - toggleStore := NewTogglesStorage(b.features) - - storage := map[string]rest.Storage{} - storage[featureStore.resource.StoragePath()] = featureStore - storage[toggleStore.resource.StoragePath()] = toggleStore - - apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage - return nil -} - -func (b *FeatureFlagAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { - return v0alpha1.GetOpenAPIDefinitions -} - -func (b *FeatureFlagAPIBuilder) GetAuthorizer() authorizer.Authorizer { - return nil // default authorizer is fine -} - -// Register additional routes with the server -func (b *FeatureFlagAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes { - defs := v0alpha1.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} }) - stateSchema := defs["github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.ResolvedToggleState"].Schema - - tags := []string{"Editor"} - return &builder.APIRoutes{ - Root: []builder.APIRouteHandler{ - { - Path: "current", - Spec: &spec3.PathProps{ - Get: &spec3.Operation{ - OperationProps: spec3.OperationProps{ - Tags: tags, - Summary: "Current configuration with details", - Description: "Show details about the current flags and where they come from", - Responses: &spec3.Responses{ - ResponsesProps: spec3.ResponsesProps{ - StatusCodeResponses: map[int]*spec3.Response{ - 200: { - ResponseProps: spec3.ResponseProps{ - Content: map[string]*spec3.MediaType{ - "application/json": { - MediaTypeProps: spec3.MediaTypeProps{ - Schema: &stateSchema, - }, - }, - }, - Description: "OK", - }, - }, - }, - }, - }, - }, - }, - }, - Handler: b.handleCurrentStatus, - }, - }, - } -} diff --git a/pkg/registry/apis/featuretoggle/toggles.go b/pkg/registry/apis/featuretoggle/toggles.go deleted file mode 100644 index 5d84bcc7843..00000000000 --- a/pkg/registry/apis/featuretoggle/toggles.go +++ /dev/null @@ -1,91 +0,0 @@ -package featuretoggle - -import ( - "context" - "fmt" - - "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "k8s.io/apimachinery/pkg/apis/meta/internalversion" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/rest" -) - -var ( - _ rest.Storage = (*togglesStorage)(nil) - _ rest.Scoper = (*togglesStorage)(nil) - _ rest.SingularNameProvider = (*togglesStorage)(nil) - _ rest.Lister = (*togglesStorage)(nil) - _ rest.Getter = (*togglesStorage)(nil) -) - -type togglesStorage struct { - resource *utils.ResourceInfo - tableConverter rest.TableConvertor - - // The startup toggles - startup *v0alpha1.FeatureToggles -} - -func NewTogglesStorage(features *featuremgmt.FeatureManager) *togglesStorage { - resourceInfo := v0alpha1.TogglesResourceInfo - return &togglesStorage{ - resource: &resourceInfo, - startup: &v0alpha1.FeatureToggles{ - TypeMeta: resourceInfo.TypeMeta(), - ObjectMeta: metav1.ObjectMeta{ - Name: "startup", - Namespace: "system", - CreationTimestamp: metav1.Now(), - }, - Spec: features.GetStartupFlags(), - }, - tableConverter: rest.NewDefaultTableConvertor(resourceInfo.GroupResource()), - } -} - -func (s *togglesStorage) New() runtime.Object { - return s.resource.NewFunc() -} - -func (s *togglesStorage) Destroy() {} - -func (s *togglesStorage) NamespaceScoped() bool { - return true -} - -func (s *togglesStorage) GetSingularName() string { - return s.resource.GetSingularName() -} - -func (s *togglesStorage) NewList() runtime.Object { - return s.resource.NewListFunc() -} - -func (s *togglesStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { - return s.tableConverter.ConvertToTable(ctx, object, tableOptions) -} - -func (s *togglesStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { - flags := &v0alpha1.FeatureTogglesList{ - Items: []v0alpha1.FeatureToggles{*s.startup}, - } - return flags, nil -} - -func (s *togglesStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { - info, err := request.NamespaceInfoFrom(ctx, false) // allow system - if err != nil { - return nil, err - } - if info.Value != "" && info.Value != "system" { - return nil, fmt.Errorf("only system namespace is currently supported") - } - if name != "startup" { - return nil, fmt.Errorf("only system/startup is currently supported") - } - return s.startup, nil -} diff --git a/pkg/registry/apis/folders/parents.go b/pkg/registry/apis/folders/parents.go index 61d694b3e36..ef765e94aa5 100644 --- a/pkg/registry/apis/folders/parents.go +++ b/pkg/registry/apis/folders/parents.go @@ -72,11 +72,6 @@ func newParentsGetter(getter rest.Getter, maxDepth int) parentsGetter { break } - if len(info.Items) >= maxDepth { - err = folderLegacy.ErrMaximumDepthReached - break - } - found[parentFolder.Name] = true folder = parentFolder } diff --git a/pkg/registry/apis/folders/parents_test.go b/pkg/registry/apis/folders/parents_test.go index ee1326ae99b..34bbbef9a0f 100644 --- a/pkg/registry/apis/folders/parents_test.go +++ b/pkg/registry/apis/folders/parents_test.go @@ -111,21 +111,6 @@ func TestParents(t *testing.T) { }, expectedErr: "cyclic folder references found", }, - { - name: "too deep", - request: input{ - name: "test", - folder: "p1", - }, - maxDepth: 3, - expectedErr: "[folder.maximum-depth-reached]", - expected: &folders.FolderInfoList{Items: []folders.FolderInfo{ - {Name: "test", Parent: "p1"}, - {Name: "p1", Parent: "p2"}, - {Name: "p2", Parent: "p3"}, - {Name: "p3", Parent: "p4"}, // should not try calling p4 - }}, - }, } for _, tt := range tests { diff --git a/pkg/registry/apis/folders/register_test.go b/pkg/registry/apis/folders/register_test.go index 762fb309278..6f9cd177fbe 100644 --- a/pkg/registry/apis/folders/register_test.go +++ b/pkg/registry/apis/folders/register_test.go @@ -2,6 +2,7 @@ package folders import ( "context" + "fmt" "testing" "github.com/stretchr/testify/mock" @@ -57,35 +58,6 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) { name: "valid-name", }, }, - { - name: "should not allow creating a folder in a tree that is too deep", - input: input{ - obj: &folders.Folder{ - Spec: folders.FolderSpec{ - Title: "foo", - }, - }, - annotations: map[string]string{"grafana.app/folder": "p1"}, // already max depth - name: "valid-name", - }, - setupFn: func(m *grafanarest.MockStorage) { - m.On("Get", mock.Anything, "p1", mock.Anything).Return( - &folders.Folder{ - ObjectMeta: metav1.ObjectMeta{ - Name: "p1", - Annotations: map[string]string{"grafana.app/folder": "p2"}, - }, - }, nil).Maybe() - m.On("Get", mock.Anything, "p2", mock.Anything).Return( - &folders.Folder{ - ObjectMeta: metav1.ObjectMeta{ - Name: "p2", - Annotations: map[string]string{"grafana.app/folder": "p3"}, - }, - }, nil).Maybe() - }, - err: folder.ErrMaximumDepthReached, - }, { name: "should return error when title is empty", input: input{ @@ -111,6 +83,59 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) { }, err: folder.ErrFolderCannotBeParentOfItself, }, + { + name: "should not allow creating a folder that will become too deep", + input: input{ + annotations: map[string]string{utils.AnnoKeyFolder: "p1"}, + obj: &folders.Folder{ + Spec: folders.FolderSpec{ + Title: "title", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "p0", + Annotations: map[string]string{"grafana.app/folder": "p1"}, + }, + }, + name: "p0", + }, + setupFn: func(m *grafanarest.MockStorage) { + m.On("Get", mock.Anything, "p1", mock.Anything).Return( + &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "p1", + Annotations: map[string]string{"grafana.app/folder": "p2"}, + }, + }, nil) + m.On("Get", mock.Anything, "p2", mock.Anything).Return( + &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "p2", + Annotations: map[string]string{"grafana.app/folder": "p3"}, + }, + }, nil) + m.On("Get", mock.Anything, "p3", mock.Anything).Return( + &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "p3", + Annotations: map[string]string{"grafana.app/folder": "p4"}, + }, + }, nil) + m.On("Get", mock.Anything, "p4", mock.Anything).Return( + &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "p4", + Annotations: map[string]string{"grafana.app/folder": "p5"}, + }, + }, nil) + m.On("Get", mock.Anything, "p5", mock.Anything).Return( + &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "p5", + }, + }, nil) + }, + err: fmt.Errorf("folder max depth exceeded, max depth is 4"), + }, } for _, tt := range tests { @@ -148,7 +173,7 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) { if tt.err == nil { require.NoError(t, err) } else { - require.ErrorIs(t, err, tt.err) + require.Contains(t, err.Error(), tt.err.Error()) return } }) @@ -336,6 +361,12 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) { Annotations: map[string]string{"grafana.app/folder": "p4"}, }, }, nil) + m.On("Get", mock.Anything, "p4", mock.Anything).Return( + &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "p4", + }, + }, nil) }, wantErr: true, }, diff --git a/pkg/registry/apis/iam/legacy/delete_team.sql b/pkg/registry/apis/iam/legacy/delete_team.sql new file mode 100644 index 00000000000..2e68fe1648d --- /dev/null +++ b/pkg/registry/apis/iam/legacy/delete_team.sql @@ -0,0 +1,2 @@ +DELETE FROM {{ .Ident .TeamTable }} +WHERE uid = {{ .Arg .Command.UID }} diff --git a/pkg/registry/apis/iam/legacy/sql.go b/pkg/registry/apis/iam/legacy/sql.go index d14bbb76123..3f2156d26a2 100644 --- a/pkg/registry/apis/iam/legacy/sql.go +++ b/pkg/registry/apis/iam/legacy/sql.go @@ -32,6 +32,7 @@ type LegacyIdentityStore interface { GetTeamInternalID(ctx context.Context, ns claims.NamespaceInfo, query GetTeamInternalIDQuery) (*GetTeamInternalIDResult, error) CreateTeam(ctx context.Context, ns claims.NamespaceInfo, cmd CreateTeamCommand) (*CreateTeamResult, error) ListTeams(ctx context.Context, ns claims.NamespaceInfo, query ListTeamQuery) (*ListTeamResult, error) + DeleteTeam(ctx context.Context, ns claims.NamespaceInfo, cmd DeleteTeamCommand) error ListTeamBindings(ctx context.Context, ns claims.NamespaceInfo, query ListTeamBindingsQuery) (*ListTeamBindingsResult, error) ListTeamMembers(ctx context.Context, ns claims.NamespaceInfo, query ListTeamMembersQuery) (*ListTeamMembersResult, error) } diff --git a/pkg/registry/apis/iam/legacy/sql_test.go b/pkg/registry/apis/iam/legacy/sql_test.go index 4cfdcc35004..d9187c163b5 100644 --- a/pkg/registry/apis/iam/legacy/sql_test.go +++ b/pkg/registry/apis/iam/legacy/sql_test.go @@ -78,6 +78,12 @@ func TestIdentityQueries(t *testing.T) { return &v } + deleteTeam := func(q *DeleteTeamCommand) sqltemplate.SQLTemplate { + v := newDeleteTeam(nodb, q) + v.SQLTemplate = mocks.NewTestingSQLTemplate() + return &v + } + listUserTeams := func(q *ListUserTeamsQuery) sqltemplate.SQLTemplate { v := newListUserTeams(nodb, q) v.SQLTemplate = mocks.NewTestingSQLTemplate() @@ -387,6 +393,14 @@ func TestIdentityQueries(t *testing.T) { }), }, }, + sqlDeleteTeamTemplate: { + { + Name: "delete_team_basic", + Data: deleteTeam(&DeleteTeamCommand{ + UID: "team-1", + }), + }, + }, sqlCreateOrgUserTemplate: { { Name: "create_org_user_basic", diff --git a/pkg/registry/apis/iam/legacy/team.go b/pkg/registry/apis/iam/legacy/team.go index e9bc80476eb..a52f7658df9 100644 --- a/pkg/registry/apis/iam/legacy/team.go +++ b/pkg/registry/apis/iam/legacy/team.go @@ -265,6 +265,69 @@ func (s *legacySQLStore) CreateTeam(ctx context.Context, ns claims.NamespaceInfo return &CreateTeamResult{Team: createdTeam}, nil } +type DeleteTeamCommand struct { + UID string +} + +var sqlDeleteTeamTemplate = mustTemplate("delete_team.sql") + +func newDeleteTeam(sql *legacysql.LegacyDatabaseHelper, cmd *DeleteTeamCommand) deleteTeamQuery { + return deleteTeamQuery{ + SQLTemplate: sqltemplate.New(sql.DialectForDriver()), + TeamTable: sql.Table("team"), + Command: cmd, + } +} + +type deleteTeamQuery struct { + sqltemplate.SQLTemplate + TeamTable string + Command *DeleteTeamCommand +} + +func (r deleteTeamQuery) Validate() error { + return nil +} + +func (s *legacySQLStore) DeleteTeam(ctx context.Context, ns claims.NamespaceInfo, cmd DeleteTeamCommand) error { + sql, err := s.sql(ctx) + if err != nil { + return err + } + + req := newDeleteTeam(sql, &cmd) + if err := req.Validate(); err != nil { + return err + } + + return sql.DB.GetSqlxSession().WithTransaction(ctx, func(st *session.SessionTx) error { + _, err := s.GetTeamInternalID(ctx, ns, GetTeamInternalIDQuery{ + OrgID: ns.OrgID, + UID: cmd.UID, + }) + if err != nil { + return err + } + + teamDeleteReq := newDeleteTeam(sql, &cmd) + if err := teamDeleteReq.Validate(); err != nil { + return err + } + + teamDeleteQuery, err := sqltemplate.Execute(sqlDeleteTeamTemplate, teamDeleteReq) + if err != nil { + return fmt.Errorf("error executing team delete template: %w", err) + } + + _, err = st.Exec(ctx, teamDeleteQuery, teamDeleteReq.GetArgs()...) + if err != nil { + return fmt.Errorf("failed to delete team: %w", err) + } + + return nil + }) +} + type ListTeamBindingsQuery struct { // UID is team uid to list bindings for. If not set store should list bindings for all teams UID string diff --git a/pkg/registry/apis/iam/legacy/testdata/mysql--delete_team-delete_team_basic.sql b/pkg/registry/apis/iam/legacy/testdata/mysql--delete_team-delete_team_basic.sql new file mode 100755 index 00000000000..2263074103c --- /dev/null +++ b/pkg/registry/apis/iam/legacy/testdata/mysql--delete_team-delete_team_basic.sql @@ -0,0 +1,2 @@ +DELETE FROM `grafana`.`team` +WHERE uid = 'team-1' diff --git a/pkg/registry/apis/iam/legacy/testdata/postgres--delete_team-delete_team_basic.sql b/pkg/registry/apis/iam/legacy/testdata/postgres--delete_team-delete_team_basic.sql new file mode 100755 index 00000000000..da4ea12930e --- /dev/null +++ b/pkg/registry/apis/iam/legacy/testdata/postgres--delete_team-delete_team_basic.sql @@ -0,0 +1,2 @@ +DELETE FROM "grafana"."team" +WHERE uid = 'team-1' diff --git a/pkg/registry/apis/iam/legacy/testdata/sqlite--delete_team-delete_team_basic.sql b/pkg/registry/apis/iam/legacy/testdata/sqlite--delete_team-delete_team_basic.sql new file mode 100755 index 00000000000..da4ea12930e --- /dev/null +++ b/pkg/registry/apis/iam/legacy/testdata/sqlite--delete_team-delete_team_basic.sql @@ -0,0 +1,2 @@ +DELETE FROM "grafana"."team" +WHERE uid = 'team-1' diff --git a/pkg/registry/apis/iam/team/store.go b/pkg/registry/apis/iam/team/store.go index f06c04bb955..88bf50986d6 100644 --- a/pkg/registry/apis/iam/team/store.go +++ b/pkg/registry/apis/iam/team/store.go @@ -74,7 +74,40 @@ func (s *LegacyStore) DeleteCollection(ctx context.Context, deleteValidation res // Delete implements rest.GracefulDeleter. func (s *LegacyStore) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { - return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "delete") + if !s.enableAuthnMutation { + return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "delete") + } + + ns, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + + toBeDeleted, err := s.Get(ctx, name, nil) + if err != nil { + return nil, false, err + } + + if deleteValidation != nil { + if err := deleteValidation(ctx, toBeDeleted); err != nil { + return nil, false, err + } + } + + err = s.store.DeleteTeam(ctx, ns, legacy.DeleteTeamCommand{ + UID: name, + }) + + if err != nil { + return nil, false, err + } + + return &iamv0alpha1.Team{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns.Value, + }, + }, true, nil } // Update implements rest.Updater. diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 920c8680b21..a63f42bb987 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -2,6 +2,7 @@ package provisioning import ( "context" + "errors" "fmt" "net/http" "net/url" @@ -11,7 +12,6 @@ import ( "github.com/prometheus/client_golang/prometheus" apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -19,7 +19,6 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/authorization/authorizer" - "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" clientrest "k8s.io/client-go/rest" @@ -75,6 +74,11 @@ var ( _ builder.OpenAPIPostProcessor = (*APIBuilder)(nil) ) +var ( + ErrRepositoryParentFolderConflict = errors.New("parent folder conflict") + ErrRepositoryDuplicatePath = errors.New("duplicate repository path") +) + // JobHistoryConfig holds configuration for job history backends type JobHistoryConfig struct { Loki *loki.Config `json:"loki,omitempty"` @@ -348,7 +352,11 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { } else { return authorizer.DecisionDeny, "editor role is required", nil } - + case "status": + if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "users cannot update the status of a repository", nil default: if id.GetIsGrafanaAdmin() { return authorizer.DecisionAllow, "", nil @@ -467,7 +475,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI storage[provisioning.RepositoryResourceInfo.StoragePath("status")] = repositoryStatusStorage // TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place - storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, b.repoFactory, b) + storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b) storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.access) storage[provisioning.RepositoryResourceInfo.StoragePath("refs")] = NewRefsConnector(b) storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{ @@ -611,7 +619,7 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm } // Exit early if we have already found errors - targetError := b.verifyAgainstExistingRepositories(cfg) + targetError := b.VerifyAgainstExistingRepositories(ctx, cfg) if targetError != nil { return invalidRepositoryError(a.GetName(), field.ErrorList{targetError}) } @@ -625,64 +633,8 @@ func invalidRepositoryError(name string, list field.ErrorList) error { name, list) } -func (b *APIBuilder) getRepositoriesInNamespace(ctx context.Context) ([]provisioning.Repository, error) { - obj, err := b.store.List(ctx, &internalversion.ListOptions{ - Limit: 100, - }) - if err != nil { - return nil, err - } - - all, ok := obj.(*provisioning.RepositoryList) - if !ok { - return nil, fmt.Errorf("expected repository list") - } - return all.Items, nil -} - -// TODO: move this to a more appropriate place. Probably controller/validation.go -func (b *APIBuilder) verifyAgainstExistingRepositories(cfg *provisioning.Repository) *field.Error { - ctx, _, err := identity.WithProvisioningIdentity(context.Background(), cfg.Namespace) - if err != nil { - return &field.Error{Type: field.ErrorTypeInternal, Detail: err.Error()} - } - all, err := b.getRepositoriesInNamespace(request.WithNamespace(ctx, cfg.Namespace)) - if err != nil { - return field.Forbidden(field.NewPath("spec"), - "Unable to verify root target: "+err.Error()) - } - - if cfg.Spec.Sync.Target == provisioning.SyncTargetTypeInstance { - // Instance sync can only be created if NO other repositories exist - for _, v := range all { - if v.Name != cfg.Name { - return field.Forbidden(field.NewPath("spec", "sync", "target"), - "Instance repository can only be created when no other repositories exist. Found: "+v.Name) - } - } - } else { - // Folder sync cannot be created if an instance repository exists - for _, v := range all { - if v.Spec.Sync.Target == provisioning.SyncTargetTypeInstance && v.Name != cfg.Name { - return field.Forbidden(field.NewPath("spec", "sync", "target"), - "Cannot create folder repository when instance repository exists: "+v.Name) - } - } - } - - // Count repositories excluding the current one being created/updated - count := 0 - for _, v := range all { - if v.Name != cfg.Name { - count++ - } - } - if count >= 10 { - return field.Forbidden(field.NewPath("spec"), - "Maximum number of 10 repositories reached") - } - - return nil +func (b *APIBuilder) VerifyAgainstExistingRepositories(ctx context.Context, cfg *provisioning.Repository) *field.Error { + return VerifyAgainstExistingRepositories(ctx, b.store, cfg) } func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartHookFunc, error) { @@ -734,7 +686,10 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH } // Create the repository resources factory - usageMetricCollector := usage.MetricCollector(b.tracer, b.getRepositoriesInNamespace, b.unified) + repositoryListerWrapper := func(ctx context.Context) ([]provisioning.Repository, error) { + return GetRepositoriesInNamespace(ctx, b.store) + } + usageMetricCollector := usage.MetricCollector(b.tracer, repositoryListerWrapper, b.unified) b.usageStats.RegisterMetricsFunc(usageMetricCollector) metrics := jobs.RegisterJobMetrics(b.registry) @@ -1321,6 +1276,10 @@ func (b *APIBuilder) GetRepository(ctx context.Context, name string) (repository return b.asRepository(ctx, obj, nil) } +func (b *APIBuilder) GetRepoFactory() repository.Factory { + return b.repoFactory +} + func (b *APIBuilder) GetHealthyRepository(ctx context.Context, name string) (repository.Repository, error) { repo, err := b.GetRepository(ctx, name) if err != nil { diff --git a/pkg/registry/apis/provisioning/routes.go b/pkg/registry/apis/provisioning/routes.go index b653e96d417..4ffefcdfdbb 100644 --- a/pkg/registry/apis/provisioning/routes.go +++ b/pkg/registry/apis/provisioning/routes.go @@ -150,7 +150,7 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) { } // TODO: check if lister could list too many repositories or resources - all, err := b.getRepositoriesInNamespace(request.WithNamespace(r.Context(), u.GetNamespace())) + all, err := GetRepositoriesInNamespace(request.WithNamespace(r.Context(), u.GetNamespace()), b.store) if err != nil { errhttp.Write(r.Context(), err, w) return diff --git a/pkg/registry/apis/provisioning/test.go b/pkg/registry/apis/provisioning/test.go index c1b87ab70ab..b664e9e9039 100644 --- a/pkg/registry/apis/provisioning/test.go +++ b/pkg/registry/apis/provisioning/test.go @@ -28,21 +28,26 @@ type HealthCheckerProvider interface { GetHealthChecker() *controller.HealthChecker } +type ConnectorDependencies interface { + RepoGetter + HealthCheckerProvider + repository.RepositoryValidator + GetRepoFactory() repository.Factory +} + type testConnector struct { getter RepoGetter factory repository.Factory healthProvider HealthCheckerProvider + validator repository.RepositoryValidator } -func NewTestConnector( - getter RepoGetter, - factory repository.Factory, - healthProvider HealthCheckerProvider, -) *testConnector { +func NewTestConnector(deps ConnectorDependencies) *testConnector { return &testConnector{ - factory: factory, - getter: getter, - healthProvider: healthProvider, + factory: deps.GetRepoFactory(), + getter: deps, + healthProvider: deps, + validator: deps, } } @@ -181,7 +186,7 @@ func (s *testConnector) Connect(ctx context.Context, name string, opts runtime.O } } else { // Testing temporary repository - just run test without status update - rsp, err = repository.TestRepository(ctx, repo) + rsp, err = repository.TestRepositoryWithValidator(ctx, repo, s.validator) if err != nil { responder.Error(err) return diff --git a/pkg/registry/apis/provisioning/validation.go b/pkg/registry/apis/provisioning/validation.go new file mode 100644 index 00000000000..ff84872403c --- /dev/null +++ b/pkg/registry/apis/provisioning/validation.go @@ -0,0 +1,124 @@ +package provisioning + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/endpoints/request" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +// RepositoryLister interface for listing repositories +type RepositoryLister interface { + List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) +} + +// GetRepositoriesInNamespace retrieves all repositories in a given namespace +func GetRepositoriesInNamespace(ctx context.Context, store RepositoryLister) ([]provisioning.Repository, error) { + var allRepositories []provisioning.Repository + continueToken := "" + + for { + obj, err := store.List(ctx, &internalversion.ListOptions{ + Limit: 100, + Continue: continueToken, + }) + if err != nil { + return nil, err + } + + repositoryList, ok := obj.(*provisioning.RepositoryList) + if !ok { + return nil, fmt.Errorf("expected repository list") + } + + allRepositories = append(allRepositories, repositoryList.Items...) + + continueToken = repositoryList.GetContinue() + if continueToken == "" { + break + } + } + + return allRepositories, nil +} + +// VerifyAgainstExistingRepositories validates a repository configuration against existing repositories +func VerifyAgainstExistingRepositories(ctx context.Context, store RepositoryLister, cfg *provisioning.Repository) *field.Error { + ctx, _, err := identity.WithProvisioningIdentity(ctx, cfg.Namespace) + if err != nil { + return &field.Error{Type: field.ErrorTypeInternal, Detail: err.Error()} + } + all, err := GetRepositoriesInNamespace(request.WithNamespace(ctx, cfg.Namespace), store) + if err != nil { + return field.Forbidden(field.NewPath("spec"), + "Unable to verify root target: "+err.Error()) + } + + if cfg.Spec.Sync.Target == provisioning.SyncTargetTypeInstance { + // Instance sync can only be created if NO other repositories exist + for _, v := range all { + if v.Name != cfg.Name { + return field.Forbidden(field.NewPath("spec", "sync", "target"), + "Instance repository can only be created when no other repositories exist. Found: "+v.Name) + } + } + } else { + // Folder sync cannot be created if an instance repository exists + for _, v := range all { + if v.Spec.Sync.Target == provisioning.SyncTargetTypeInstance && v.Name != cfg.Name { + return field.Forbidden(field.NewPath("spec", "sync", "target"), + "Cannot create folder repository when instance repository exists: "+v.Name) + } + } + } + + // If repo is git, ensure no other repository is defined with a child path + if cfg.Spec.Type.IsGit() { + for _, v := range all { + // skip itself + if cfg.Name == v.Name { + continue + } + if v.URL() == cfg.URL() { + if v.Path() == cfg.Path() { + return field.Invalid(field.NewPath("spec", string(cfg.Spec.Type), "path"), + cfg.Path(), + fmt.Sprintf("%s: %s", ErrRepositoryDuplicatePath.Error(), v.Name)) + } + + relPath, err := filepath.Rel(v.Path(), cfg.Path()) + if err != nil { + return field.Invalid(field.NewPath("spec", string(cfg.Spec.Type), "path"), cfg.Path(), "failed to evaluate path: "+err.Error()) + } + // https://pkg.go.dev/path/filepath#Rel + // Rel will return "../" if the relative paths are not related + if !strings.HasPrefix(relPath, "../") { + return field.Invalid(field.NewPath("spec", string(cfg.Spec.Type), "path"), cfg.Path(), + fmt.Sprintf("%s: %s", ErrRepositoryParentFolderConflict.Error(), v.Name)) + } + } + } + } + + // Count repositories excluding the current one being created/updated + count := 0 + for _, v := range all { + if v.Name != cfg.Name { + count++ + } + } + if count >= 10 { + return field.Forbidden(field.NewPath("spec"), + "Maximum number of 10 repositories reached") + } + + return nil +} diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index 0f3e7f8969b..0dd28d4d274 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -6,7 +6,6 @@ import ( dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" "github.com/grafana/grafana/pkg/registry/apis/datasource" - "github.com/grafana/grafana/pkg/registry/apis/featuretoggle" "github.com/grafana/grafana/pkg/registry/apis/folders" "github.com/grafana/grafana/pkg/registry/apis/iam" "github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage" @@ -54,7 +53,6 @@ var WireSet = wire.NewSet( // Each must be added here *and* in the ServiceSink above dashboardinternal.RegisterAPIService, dashboardsnapshot.RegisterAPIService, - featuretoggle.RegisterAPIService, datasource.RegisterAPIService, folders.RegisterAPIService, iam.RegisterAPIService, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 11a6de3b807..61b6607a162 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -53,7 +53,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" "github.com/grafana/grafana/pkg/registry/apis/datasource" - "github.com/grafana/grafana/pkg/registry/apis/featuretoggle" "github.com/grafana/grafana/pkg/registry/apis/folders" "github.com/grafana/grafana/pkg/registry/apis/iam" "github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage" @@ -809,9 +808,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService) ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) - dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, pluginstoreService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService) + dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService) snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer) - featureFlagAPIBuilder := featuretoggle.RegisterAPIService(featureManager, accessControl, apiserverService, cfg, registerer) dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, accessControl, registerer) if err != nil { return nil, err @@ -863,7 +861,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, featureFlagAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer) + apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer) teamPermissionsService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService) if err != nil { return nil, err @@ -1417,9 +1415,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService) ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) - dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, pluginstoreService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService) + dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService) snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer) - featureFlagAPIBuilder := featuretoggle.RegisterAPIService(featureManager, accessControl, apiserverService, cfg, registerer) dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, accessControl, registerer) if err != nil { return nil, err @@ -1471,7 +1468,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, featureFlagAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer) + apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer) teamPermissionsService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService) if err != nil { return nil, err diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 9bb38bf740f..dc710222b16 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1750,6 +1750,14 @@ var ( HideFromAdminPage: true, HideFromDocs: true, }, + { + Name: "kubernetesAuthZHandlerRedirect", + Description: "Redirects the traffic from the legacy access control endpoints to the new K8s AuthZ endpoints", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromAdminPage: true, + HideFromDocs: true, + }, { Name: "kubernetesAuthzResourcePermissionApis", Description: "Registers AuthZ resource permission /apis endpoints", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 3e835b592cc..c2642ddffbe 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -227,6 +227,7 @@ alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,fal alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true kubernetesAuthzApis,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthZHandlerRedirect,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthzResourcePermissionApis,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,false restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 7a4f1bbd066..1d418977c77 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -919,6 +919,10 @@ const ( // Registers AuthZ /apis endpoint FlagKubernetesAuthzApis = "kubernetesAuthzApis" + // FlagKubernetesAuthZHandlerRedirect + // Redirects the traffic from the legacy access control endpoints to the new K8s AuthZ endpoints + FlagKubernetesAuthZHandlerRedirect = "kubernetesAuthZHandlerRedirect" + // FlagKubernetesAuthzResourcePermissionApis // Registers AuthZ resource permission /apis endpoints FlagKubernetesAuthzResourcePermissionApis = "kubernetesAuthzResourcePermissionApis" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 610ffc59594..66a6fa28684 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1986,6 +1986,20 @@ "requiresRestart": true } }, + { + "metadata": { + "name": "kubernetesAuthZHandlerRedirect", + "resourceVersion": "1758820248165", + "creationTimestamp": "2025-09-25T17:10:48Z" + }, + "spec": { + "description": "Redirects the traffic from the legacy access control endpoints to the new K8s AuthZ endpoints", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "kubernetesAuthnMutation", @@ -2017,6 +2031,21 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "kubernetesAuthzEndpoints", + "resourceVersion": "1758779666607", + "creationTimestamp": "2025-09-25T05:54:26Z", + "deletionTimestamp": "2025-09-25T17:10:48Z" + }, + "spec": { + "description": "Enables K8s AuthZ endpoints", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "kubernetesAuthzResourcePermissionApis", @@ -3716,6 +3745,20 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "unifiedStorageUseFullNgram", + "resourceVersion": "1758820248165", + "creationTimestamp": "2025-09-25T17:10:48Z" + }, + "spec": { + "description": "Use full n-gram indexing instead of edge n-gram for unified storage search", + "stage": "experimental", + "codeowner": "@grafana/search-and-storage", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "useKubernetesShortURLsAPI", diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index 7337a832d71..ff256522fba 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -76,7 +76,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink Text: "General", SubTitle: "Manage default preferences and settings across Grafana", Id: navtree.NavIDCfgGeneral, - Url: "/admin/general", + Url: s.cfg.AppSubURL + "/admin/general", Icon: "shield", Children: generalNodeLinks, } @@ -121,7 +121,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink Text: "Plugins and data", SubTitle: "Install plugins and define the relationships between data", Id: navtree.NavIDCfgPlugins, - Url: "/admin/plugins", + Url: s.cfg.AppSubURL + "/admin/plugins", Icon: "shield", Children: pluginsNodeLinks, } @@ -174,7 +174,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink Text: "Users and access", SubTitle: "Configure access for individual users, teams, and service accounts", Id: navtree.NavIDCfgAccess, - Url: "/admin/access", + Url: s.cfg.AppSubURL + "/admin/access", Icon: "shield", Children: accessNodeLinks, } @@ -201,7 +201,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink Icon: "cog", SortWeight: navtree.WeightConfig, Children: configNodes, - Url: "/admin", + Url: s.cfg.AppSubURL + "/admin", } return configNode, nil diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index e826a748cc1..000ead39266 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -267,7 +267,7 @@ func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *contextmode supportBundleNode := &navtree.NavLink{ Text: "Support bundles", Id: "support-bundles", - Url: "/support-bundles", + Url: s.cfg.AppSubURL + "/support-bundles", Icon: "wrench", SortWeight: navtree.WeightHelp, } diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 3dd1b410c86..1fdf89a9cab 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -222,7 +222,6 @@ func (ng *AlertNG) init() error { autogenFn := func(ctx context.Context, logger log.Logger, orgID int64, cfg *definitions.PostableApiAlertingConfig, skipInvalid bool) error { return notifier.AddAutogenConfig(ctx, logger, ng.store, orgID, cfg, skipInvalid) } - store := notifier.NewFileStore(cfg.OrgID, ng.KVStore) // This function will be used by the MOA to create new Alertmanagers. var override func(notifier.OrgAlertmanagerFactory) notifier.OrgAlertmanagerFactory @@ -230,12 +229,12 @@ func (ng *AlertNG) init() error { if remotePrimary { ng.Log.Debug("Starting Grafana with remote primary mode enabled") m.Info.WithLabelValues(metrics.ModeRemotePrimary).Set(1) - override = remote.NewRemotePrimaryFactory(cfg, store, crypto, autogenFn, m, ng.tracer) + override = remote.NewRemotePrimaryFactory(cfg, ng.KVStore, crypto, autogenFn, m, ng.tracer) } else { ng.Log.Debug("Starting Grafana with remote secondary mode enabled") m.Info.WithLabelValues(metrics.ModeRemoteSecondary).Set(1) override = remote.NewRemoteSecondaryFactory(cfg, - store, + ng.KVStore, ng.store, ng.Cfg.UnifiedAlerting.RemoteAlertmanager.SyncInterval, crypto, diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go b/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go index 344486bb14d..42093e35f3f 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go @@ -60,7 +60,7 @@ func TestMultiorgAlertmanager_RemoteSecondaryMode(t *testing.T) { } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) override := remote.NewRemoteSecondaryFactory(remoteAMCfg, - notifier.NewFileStore(remoteAMCfg.OrgID, kvStore), + kvStore, configStore, 10*time.Second, notifier.NewCrypto(secretsService, configStore, log.NewNopLogger()), diff --git a/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go b/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go index 4e124639431..7516171edba 100644 --- a/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go +++ b/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go @@ -7,6 +7,7 @@ import ( alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -25,7 +26,7 @@ type RemotePrimaryForkedAlertmanager struct { // NewRemotePrimaryFactory returns a function to override the default AM factory in the multi-org Alertmanager. func NewRemotePrimaryFactory( cfg AlertmanagerConfig, - store stateStore, + store kvstore.KVStore, crypto Crypto, autogenFn AutogenFn, m *metrics.RemoteAlertmanager, @@ -43,7 +44,7 @@ func NewRemotePrimaryFactory( cfg.OrgID = orgID cfg.PromoteConfig = true l := log.New("ngalert.forked-alertmanager.remote-primary") - remoteAM, err := NewAlertmanager(ctx, cfg, store, crypto, autogenFn, m, t) + remoteAM, err := NewAlertmanager(ctx, cfg, notifier.NewFileStore(cfg.OrgID, store), crypto, autogenFn, m, t) if err != nil { l.Error("Failed to create remote Alertmanager, falling back to using only the internal one", "err", err) return internalAM, nil diff --git a/pkg/services/ngalert/remote/remote_secondary_forked_alertmanager.go b/pkg/services/ngalert/remote/remote_secondary_forked_alertmanager.go index 94c06f5ed19..c5b751cd336 100644 --- a/pkg/services/ngalert/remote/remote_secondary_forked_alertmanager.go +++ b/pkg/services/ngalert/remote/remote_secondary_forked_alertmanager.go @@ -8,6 +8,7 @@ import ( alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -65,7 +66,7 @@ func (c *RemoteSecondaryConfig) Validate() error { // NewRemoteSecondaryFactory returns a function to override the default AM factory in the multi-org Alertmanager. func NewRemoteSecondaryFactory( cfg AlertmanagerConfig, - stateStore stateStore, + store kvstore.KVStore, cfgStore configStore, syncInterval time.Duration, crypto Crypto, @@ -79,7 +80,7 @@ func NewRemoteSecondaryFactory( // Create the remote Alertmanager first so we don't need to unregister internal AM metrics if this fails. cfg.OrgID = orgID l := log.New("ngalert.forked-alertmanager.remote-secondary") - remoteAM, err := NewAlertmanager(ctx, cfg, stateStore, crypto, autogenFn, m, t) + remoteAM, err := NewAlertmanager(ctx, cfg, notifier.NewFileStore(cfg.OrgID, store), crypto, autogenFn, m, t) if err != nil && withRemoteState { // We can't start the internal Alertmanager without the remote state. return nil, fmt.Errorf("failed to create remote Alertmanager, can't start the internal Alertmanager without the remote state: %w", err) diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index fdcdc9d9301..677753f77d8 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -943,6 +943,11 @@ func (s *server) List(ctx context.Context, req *resourcepb.ListRequest) (*resour } } + // Fast path for getting single value in a list + if rsp := s.tryFastPathList(ctx, req); rsp != nil { + return rsp, nil + } + if req.Limit < 1 { req.Limit = 500 // default max 500 items in a page } @@ -1036,6 +1041,40 @@ func (s *server) List(ctx context.Context, req *resourcepb.ListRequest) (*resour return rsp, err } +// Some list queries can be calculated with simple reads +func (s *server) tryFastPathList(ctx context.Context, req *resourcepb.ListRequest) *resourcepb.ListResponse { + if req.Source != resourcepb.ListRequest_STORE || req.Options.Key.Namespace == "" { + return nil + } + + for _, v := range req.Options.Fields { + if v.Key == "metadata.name" && v.Operator == `=` { + if len(v.Values) == 1 { + read := &resourcepb.ReadRequest{ + Key: req.Options.Key, + ResourceVersion: req.ResourceVersion, + } + read.Key.Name = v.Values[0] + found, err := s.Read(ctx, read) + if err != nil { + return &resourcepb.ListResponse{Error: AsErrorResult(err)} + } + + // Return a value when it exists + rsp := &resourcepb.ListResponse{} + if len(found.Value) > 0 { + rsp.Items = []*resourcepb.ResourceWrapper{{ + Value: found.Value, + ResourceVersion: found.ResourceVersion, + }} + } + return rsp + } + } + } + return nil +} + // isTrashItemAuthorized checks if the user has access to the trash item. func (s *server) isTrashItemAuthorized(ctx context.Context, iter ListIterator, trashChecker claims.ItemChecker) bool { user, ok := claims.AuthInfoFrom(ctx) diff --git a/pkg/storage/unified/resource/server_test.go b/pkg/storage/unified/resource/server_test.go index 98bc928da63..5eda20dc89b 100644 --- a/pkg/storage/unified/resource/server_test.go +++ b/pkg/storage/unified/resource/server_test.go @@ -174,6 +174,39 @@ func TestSimpleServer(t *testing.T) { require.Len(t, all.Items, 1) require.Equal(t, updated.ResourceVersion, all.Items[0].ResourceVersion) + // Try again with a direct query + all, err = server.List(ctx, &resourcepb.ListRequest{Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Namespace: key.Namespace, + Group: key.Group, + Resource: key.Resource, + }, + Fields: []*resourcepb.Requirement{{ + Key: "metadata.name", + Operator: "=", + Values: []string{"not-matching"}, + }}, + }}) + require.NoError(t, err) + require.Len(t, all.Items, 0) + + // This time matching + all, err = server.List(ctx, &resourcepb.ListRequest{Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Namespace: key.Namespace, + Group: key.Group, + Resource: key.Resource, + }, + Fields: []*resourcepb.Requirement{{ + Key: "metadata.name", + Operator: "=", + Values: []string{"fdgsv37qslr0ga"}, + }}, + }}) + require.NoError(t, err) + require.Len(t, all.Items, 1) + require.Equal(t, raw, all.Items[0].Value) + deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{Key: key, ResourceVersion: updated.ResourceVersion}) require.NoError(t, err) require.True(t, deleted.ResourceVersion > updated.ResourceVersion) diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index 9239ce78309..6423f8edf56 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -647,12 +647,9 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { }) t.Run("Dashboard refresh interval validations", func(t *testing.T) { - // Store original settings to restore after test - origCfg := ctx.Helper.GetEnv().Cfg - origMinRefreshInterval := origCfg.MinRefreshInterval - - // Set a fixed min_refresh_interval for all tests to make them predictable - ctx.Helper.GetEnv().Cfg.MinRefreshInterval = "10s" + // Test infrastructure is configured with + // [dashboards] + // min_refresh_interval = 10s testCases := []struct { name string @@ -720,9 +717,6 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { } }) } - - // Restore original settings - ctx.Helper.GetEnv().Cfg.MinRefreshInterval = origMinRefreshInterval }) t.Run("Dashboard size limit validations", func(t *testing.T) { diff --git a/pkg/tests/apis/iam/team_integration_test.go b/pkg/tests/apis/iam/team_integration_test.go index 5e0145dcede..f6e0a4605f6 100644 --- a/pkg/tests/apis/iam/team_integration_test.go +++ b/pkg/tests/apis/iam/team_integration_test.go @@ -48,7 +48,7 @@ func TestIntegrationTeams(t *testing.T) { } func doTeamCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) { - t.Run("should create team and get it using the new APIs as a GrafanaAdmin", func(t *testing.T) { + t.Run("should create/get/delete team using the new APIs as a GrafanaAdmin", func(t *testing.T) { ctx := context.Background() teamClient := helper.GetResourceClient(apis.ResourceClientArgs{ @@ -80,6 +80,16 @@ func doTeamCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) { require.Equal(t, createdUID, fetched.GetName()) require.Equal(t, "default", fetched.GetNamespace()) + + err = teamClient.Resource.Delete(ctx, createdUID, metav1.DeleteOptions{}) + require.NoError(t, err) + + _, err = teamClient.Resource.Get(ctx, createdUID, metav1.GetOptions{}) + require.Error(t, err) + var statusErr *errors.StatusError + require.ErrorAs(t, err, &statusErr) + require.Equal(t, "Failure", statusErr.ErrStatus.Status) + require.Contains(t, statusErr.ErrStatus.Message, "team not found") }) t.Run("should not be able to create team when using a user with insufficient permissions", func(t *testing.T) { @@ -193,7 +203,7 @@ func doTeamCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) { } func doTeamCRUDTestsUsingTheLegacyAPIs(t *testing.T, helper *apis.K8sTestHelper) { - t.Run("should create team using legacy APIs and get it using the new APIs", func(t *testing.T) { + t.Run("should create team using legacy APIs and get/delete it using the new APIs", func(t *testing.T) { ctx := context.Background() teamClient := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, @@ -232,5 +242,15 @@ func doTeamCRUDTestsUsingTheLegacyAPIs(t *testing.T, helper *apis.K8sTestHelper) require.Equal(t, rsp.Result.UID, team.GetName()) require.Equal(t, "default", team.GetNamespace()) + + err = teamClient.Resource.Delete(ctx, rsp.Result.UID, metav1.DeleteOptions{}) + require.NoError(t, err) + + _, err = teamClient.Resource.Get(ctx, rsp.Result.UID, metav1.GetOptions{}) + require.Error(t, err) + var statusErr *errors.StatusError + require.ErrorAs(t, err, &statusErr) + require.Equal(t, "Failure", statusErr.ErrStatus.Status) + require.Contains(t, statusErr.ErrStatus.Message, "team not found") }) } 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 d3ac742e8fb..e8daf1b3a34 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -1770,6 +1770,18 @@ "type": "string" } }, + { + "name": "type", + "in": "query", + "description": "search dashboards or folders. When empty, this will search both", + "schema": { + "type": "string", + "enum": [ + "folder", + "dashboard" + ] + } + }, { "name": "folder", "in": "query", @@ -1778,6 +1790,28 @@ "type": "string" } }, + { + "name": "facet", + "in": "query", + "description": "count distinct terms for selected fields", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "tags", + "in": "query", + "description": "tag query filter", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, { "name": "sort", "in": "query", @@ -1798,6 +1832,23 @@ "value": "title" } } + }, + { + "name": "limit", + "in": "query", + "description": "number of results to return", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "explain", + "in": "query", + "description": "add debugging info that may help explain why the result matched", + "schema": { + "type": "boolean" + } } ], "responses": { diff --git a/pkg/tests/apis/provisioning/health_test.go b/pkg/tests/apis/provisioning/health_test.go index e5ff3f482e0..6221a489702 100644 --- a/pkg/tests/apis/provisioning/health_test.go +++ b/pkg/tests/apis/provisioning/health_test.go @@ -22,7 +22,9 @@ func TestIntegrationHealth(t *testing.T) { ctx := context.Background() repo := "test-repo-health" helper.CreateRepo(t, TestRepo{ - Name: repo, + Name: repo, + Target: "folder", + ExpectedFolders: 1, }) // Verify the health status before calling the endpoint diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go index 8d3e7daa089..273d78c4e99 100644 --- a/pkg/tests/apis/provisioning/repository_test.go +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -3,12 +3,14 @@ package provisioning import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strings" "testing" "time" + provisioningAPIServer "github.com/grafana/grafana/pkg/registry/apis/provisioning" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -213,6 +215,70 @@ func TestIntegrationProvisioning_RepositoryValidation(t *testing.T) { } }) } + + // Test Git repository path validation - ensure child paths are rejected + t.Run("Git repository path validation", func(t *testing.T) { + baseURL := "https://github.com/grafana/test-repo-path-validation" + + pathTests := []struct { + name string + path string + expectError error + }{ + { + name: "first repo with path 'demo/nested' should succeed", + path: "demo/nested", + expectError: nil, + }, + { + name: "second repo with child path 'demo/nested/again' should fail", + path: "demo/nested/again", + expectError: provisioningAPIServer.ErrRepositoryParentFolderConflict, + }, + { + name: "third repo with parent path 'demo' should fail", + path: "demo", + expectError: provisioningAPIServer.ErrRepositoryParentFolderConflict, + }, + { + name: "fourth repo with nested child path 'demo/nested/nested-second' should fail", + path: "demo/nested/again/two", + expectError: provisioningAPIServer.ErrRepositoryParentFolderConflict, + }, + { + name: "fifth repo with duplicate path 'demo/nested' should fail", + path: "demo/nested", + expectError: provisioningAPIServer.ErrRepositoryDuplicatePath, + }, + } + + for i, test := range pathTests { + t.Run(test.name, func(t *testing.T) { + repoName := fmt.Sprintf("git-path-test-%d", i+1) + gitRepo := helper.RenderObject(t, "testdata/github-readonly.json.tmpl", map[string]any{ + "Name": repoName, + "URL": baseURL, + "Path": test.path, + "SyncEnabled": false, // Disable sync to avoid external dependencies + "SyncTarget": "folder", + }) + + _, err := helper.Repositories.Resource.Create(ctx, gitRepo, metav1.CreateOptions{FieldValidation: "Strict"}) + + if test.expectError != nil { + require.Error(t, err, "Expected error for repository with path: %s", test.path) + require.ErrorContains(t, err, test.expectError.Error(), "Error should contain expected message for path: %s", test.path) + var statusError *apierrors.StatusError + if errors.As(err, &statusError) { + require.Equal(t, metav1.StatusReasonInvalid, statusError.ErrStatus.Reason, "Should be a validation error") + require.Equal(t, http.StatusUnprocessableEntity, int(statusError.ErrStatus.Code), "Should return 422 status code") + } + } else { + require.NoError(t, err, "Expected success for repository with path: %s", test.path) + } + }) + } + }) } func TestIntegrationProvisioning_FailInvalidSchema(t *testing.T) { @@ -364,7 +430,8 @@ func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) { "Name": test.name, "URL": test.input, "SyncTarget": "folder", - "SyncEnabled": false, // Disable sync since we're just testing URL cleanup + "SyncEnabled": false, // Disable sync since we're just testing URL cleanup, + "Path": fmt.Sprintf("grafana-%s/", test.name), }) _, err := helper.Repositories.Resource.Create(ctx, input, metav1.CreateOptions{}) diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 0b026b7e185..bac5f5208f7 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -545,6 +545,11 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) } + dashboardsSection, err := getOrCreateSection("dashboards") + require.NoError(t, err) + _, err = dashboardsSection.NewKey("min_refresh_interval", "10s") + require.NoError(t, err) + if opts.APIServerRuntimeConfig != "" { section, err := getOrCreateSection("grafana-apiserver") require.NoError(t, err) diff --git a/public/app/api/clients/dashboard/v0alpha1/endpoints.gen.ts b/public/app/api/clients/dashboard/v0alpha1/endpoints.gen.ts index 8730157dedf..1c5c6d627ac 100644 --- a/public/app/api/clients/dashboard/v0alpha1/endpoints.gen.ts +++ b/public/app/api/clients/dashboard/v0alpha1/endpoints.gen.ts @@ -11,8 +11,13 @@ const injectedRtkApi = api url: `/search`, params: { query: queryArg.query, + type: queryArg['type'], folder: queryArg.folder, + facet: queryArg.facet, + tags: queryArg.tags, sort: queryArg.sort, + limit: queryArg.limit, + explain: queryArg.explain, }, }), providesTags: ['Search'], @@ -46,8 +51,18 @@ export type GetSearchApiResponse = /** status 200 undefined */ { export type GetSearchApiArg = { /** user query string */ query?: string; + /** search dashboards or folders. When empty, this will search both */ + type?: 'folder' | 'dashboard'; /** search/list within a folder (not recursive) */ folder?: string; + /** count distinct terms for selected fields */ + facet?: string[]; + /** tag query filter */ + tags?: string[]; /** sortable field */ sort?: string; + /** number of results to return */ + limit?: number; + /** add debugging info that may help explain why the result matched */ + explain?: boolean; }; diff --git a/public/app/api/clients/dashboard/v0alpha1/index.ts b/public/app/api/clients/dashboard/v0alpha1/index.ts index f61b46bc1f9..5cf86831590 100644 --- a/public/app/api/clients/dashboard/v0alpha1/index.ts +++ b/public/app/api/clients/dashboard/v0alpha1/index.ts @@ -1,25 +1,10 @@ -import { generatedAPI, GetSearchApiArg } from './endpoints.gen'; - -type OverrideGetSearchRequestOptions = GetSearchApiArg & { - type: string; -}; +import { generatedAPI } from './endpoints.gen'; export const dashboardAPIv0alpha1 = generatedAPI.enhanceEndpoints({ addTagTypes: ['Folder', 'Dashboard'], endpoints: { getSearch: (endpointDefinition) => { - const originalQuery = endpointDefinition.query; endpointDefinition.providesTags = ['Search', 'Folder', 'Dashboard']; - if (originalQuery) { - // TODO: Remove once API spec is updated with `type` - endpointDefinition.query = (requestOptions: OverrideGetSearchRequestOptions) => ({ - ...originalQuery(requestOptions), - params: { - ...requestOptions, - type: requestOptions.type, - }, - }); - } }, }, }); diff --git a/public/app/app.ts b/public/app/app.ts index 8cd58c4c6d6..25c730a2d34 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -23,10 +23,8 @@ import { DEFAULT_LANGUAGE } from '@grafana/i18n'; import { initializeI18n, loadNamespacedResources } from '@grafana/i18n/internal'; import { locationService, - registerEchoBackend, setBackendSrv, setDataSourceSrv, - setEchoSrv, setLocationSrv, setQueryRunnerFactory, setRunRequest, @@ -79,8 +77,7 @@ import { CorrelationsService } from './core/services/CorrelationsService'; import { NewFrontendAssetsChecker } from './core/services/NewFrontendAssetsChecker'; import { backendSrv } from './core/services/backend_srv'; import { contextSrv, RedirectToUrlKey } from './core/services/context_srv'; -import { Echo } from './core/services/echo/Echo'; -import { reportPerformance } from './core/services/echo/EchoSrv'; +import { initEchoSrv } from './core/services/echo/init'; import { KeybindingSrv } from './core/services/keybindingSrv'; import { startMeasure, stopMeasure } from './core/utils/metrics'; import { initAlerting } from './features/alerting/unified/initAlerting'; @@ -325,125 +322,6 @@ function initExtensions() { } } -async function initEchoSrv() { - setEchoSrv(new Echo({ debug: process.env.NODE_ENV === 'development' })); - - window.addEventListener('load', (e) => { - const loadMetricName = 'frontend_boot_load_time_seconds'; - // Metrics below are marked in public/views/index.html - const jsLoadMetricName = 'frontend_boot_js_done_time_seconds'; - const cssLoadMetricName = 'frontend_boot_css_time_seconds'; - - if (performance) { - performance.mark(loadMetricName); - reportMetricPerformanceMark('first-paint', 'frontend_boot_', '_time_seconds'); - reportMetricPerformanceMark('first-contentful-paint', 'frontend_boot_', '_time_seconds'); - reportMetricPerformanceMark(loadMetricName); - reportMetricPerformanceMark(jsLoadMetricName); - reportMetricPerformanceMark(cssLoadMetricName); - } - }); - - if (contextSrv.user.orgRole !== '') { - const { PerformanceBackend } = await import('./core/services/echo/backends/PerformanceBackend'); - registerEchoBackend(new PerformanceBackend({})); - } - - if (config.grafanaJavascriptAgent.enabled) { - // Ignore Rudderstack URLs - const rudderstackUrls = [ - config.rudderstackConfigUrl, - config.rudderstackDataPlaneUrl, - config.rudderstackIntegrationsUrl, - ] - .filter(Boolean) - .map((url) => new RegExp(`${url}.*.`)); - - const { GrafanaJavascriptAgentBackend } = await import( - './core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend' - ); - - registerEchoBackend( - new GrafanaJavascriptAgentBackend({ - ...config.grafanaJavascriptAgent, - app: { - version: config.buildInfo.version, - environment: config.buildInfo.env, - }, - buildInfo: config.buildInfo, - user: { - id: String(contextSrv.user?.id), - email: contextSrv.user?.email, - }, - ignoreUrls: rudderstackUrls, - }) - ); - } - - if (config.googleAnalyticsId) { - const { GAEchoBackend } = await import('./core/services/echo/backends/analytics/GABackend'); - registerEchoBackend( - new GAEchoBackend({ - googleAnalyticsId: config.googleAnalyticsId, - }) - ); - } - - if (config.googleAnalytics4Id) { - const { GA4EchoBackend } = await import('./core/services/echo/backends/analytics/GA4Backend'); - registerEchoBackend( - new GA4EchoBackend({ - googleAnalyticsId: config.googleAnalytics4Id, - googleAnalytics4SendManualPageViews: config.googleAnalytics4SendManualPageViews, - }) - ); - } - - if (config.rudderstackWriteKey && config.rudderstackDataPlaneUrl) { - const { RudderstackBackend } = await import('./core/services/echo/backends/analytics/RudderstackBackend'); - registerEchoBackend( - new RudderstackBackend({ - writeKey: config.rudderstackWriteKey, - dataPlaneUrl: config.rudderstackDataPlaneUrl, - user: contextSrv.user, - sdkUrl: config.rudderstackSdkUrl, - configUrl: config.rudderstackConfigUrl, - integrationsUrl: config.rudderstackIntegrationsUrl, - buildInfo: config.buildInfo, - }) - ); - } - - if (config.applicationInsightsConnectionString) { - const { ApplicationInsightsBackend } = await import( - './core/services/echo/backends/analytics/ApplicationInsightsBackend' - ); - registerEchoBackend( - new ApplicationInsightsBackend({ - connectionString: config.applicationInsightsConnectionString, - endpointUrl: config.applicationInsightsEndpointUrl, - }) - ); - } - - if (config.analyticsConsoleReporting) { - const { BrowserConsoleBackend } = await import('./core/services/echo/backends/analytics/BrowseConsoleBackend'); - registerEchoBackend(new BrowserConsoleBackend()); - } -} - -/** - * Report when a metric of a given name was marked during the document lifecycle. Works for markers with no duration, - * like PerformanceMark or PerformancePaintTiming (e.g. created with performance.mark, or first-contentful-paint) - */ -function reportMetricPerformanceMark(metricName: string, prefix = '', suffix = ''): void { - const metric = performance.getEntriesByName(metricName).at(0); - if (metric) { - const metricName = metric.name.replace(/-/g, '_'); - reportPerformance(`${prefix}${metricName}${suffix}`, Math.round(metric.startTime) / 1000); - } -} - function handleRedirectTo(): void { const queryParams = locationService.getSearch(); const redirectToParamKey = 'redirectTo'; diff --git a/public/app/core/components/Login/LoginLayout.tsx b/public/app/core/components/Login/LoginLayout.tsx index 2b68fb94a97..00e0e63b21e 100644 --- a/public/app/core/components/Login/LoginLayout.tsx +++ b/public/app/core/components/Login/LoginLayout.tsx @@ -150,7 +150,7 @@ export const getLoginStyles = (theme: GrafanaTheme2) => { justifyContent: 'flex-start', zIndex: 1, minHeight: 320, - borderRadius: theme.shape.radius.default, + borderRadius: theme.shape.radius.lg, padding: theme.spacing(2, 0), opacity: 0, [theme.transitions.handleMotion('no-preference', 'reduce')]: { diff --git a/public/app/core/components/NestedFolderPicker/useFoldersQueryAppPlatform.ts b/public/app/core/components/NestedFolderPicker/useFoldersQueryAppPlatform.ts index 64a56377dc8..546a7b177db 100644 --- a/public/app/core/components/NestedFolderPicker/useFoldersQueryAppPlatform.ts +++ b/public/app/core/components/NestedFolderPicker/useFoldersQueryAppPlatform.ts @@ -86,7 +86,7 @@ export function useFoldersQueryAppPlatform({ return; } - const args = { folder: finalParentUid, type: 'folder' }; + const args = { folder: finalParentUid, type: 'folder' } as const; // Make a request const subscription = dispatch(dashboardAPIv0alpha1.endpoints.getSearch.initiate(args)); diff --git a/public/app/core/components/TagFilter/TagBadge.tsx b/public/app/core/components/TagFilter/TagBadge.tsx index d5cdce494b5..9dcb8912358 100644 --- a/public/app/core/components/TagFilter/TagBadge.tsx +++ b/public/app/core/components/TagFilter/TagBadge.tsx @@ -34,7 +34,7 @@ export const getStyles = (theme: GrafanaTheme2) => ({ badge: css({ ...theme.typography.bodySmall, backgroundColor: theme.v1.palette.gray1, - borderRadius: theme.shape.radius.default, + borderRadius: theme.shape.radius.sm, color: theme.v1.palette.white, display: 'inline-block', height: '20px', diff --git a/public/app/core/services/echo/init.ts b/public/app/core/services/echo/init.ts new file mode 100644 index 00000000000..f25e13f2325 --- /dev/null +++ b/public/app/core/services/echo/init.ts @@ -0,0 +1,182 @@ +import { config, registerEchoBackend, setEchoSrv } from '@grafana/runtime'; +import { reportMetricPerformanceMark } from 'app/core/utils/metrics'; + +import { contextSrv } from '../context_srv'; + +import { Echo } from './Echo'; + +// Initialise EchoSrv backends, calls during frontend app startup +export async function initEchoSrv() { + setEchoSrv(new Echo({ debug: process.env.NODE_ENV === 'development' })); + + window.addEventListener('load', (e) => { + const loadMetricName = 'frontend_boot_load_time_seconds'; + // Metrics below are marked in public/views/index.html + const jsLoadMetricName = 'frontend_boot_js_done_time_seconds'; + const cssLoadMetricName = 'frontend_boot_css_time_seconds'; + + if (performance) { + performance.mark(loadMetricName); + reportMetricPerformanceMark('first-paint', 'frontend_boot_', '_time_seconds'); + reportMetricPerformanceMark('first-contentful-paint', 'frontend_boot_', '_time_seconds'); + reportMetricPerformanceMark(loadMetricName); + reportMetricPerformanceMark(jsLoadMetricName); + reportMetricPerformanceMark(cssLoadMetricName); + } + }); + + try { + await initPerformanceBackend(); + } catch (error) { + console.error('Error initializing EchoSrv Performance backend', error); + } + + try { + await initFaroBackend(); + } catch (error) { + console.error('Error initializing EchoSrv Faro backend', error); + } + + try { + await initGoogleAnalyticsBackend(); + } catch (error) { + console.error('Error initializing EchoSrv GoogleAnalytics backend', error); + } + + try { + await initGoogleAnalaytics4Backend(); + } catch (error) { + console.error('Error initializing EchoSrv GoogleAnalaytics4 backend', error); + } + + try { + await initRudderstackBackend(); + } catch (error) { + console.error('Error initializing EchoSrv Rudderstack backend', error); + } + + try { + await initAzureAppInsightsBackend(); + } catch (error) { + console.error('Error initializing EchoSrv AzureAppInsights backend', error); + } + + try { + await initConsoleBackend(); + } catch (error) { + console.error('Error initializing EchoSrv Console backend', error); + } +} + +async function initPerformanceBackend() { + if (contextSrv.user.orgRole === '') { + return; + } + + const { PerformanceBackend } = await import('./backends/PerformanceBackend'); + registerEchoBackend(new PerformanceBackend({})); +} + +async function initFaroBackend() { + if (!config.grafanaJavascriptAgent.enabled) { + return; + } + + // Ignore Rudderstack URLs + const rudderstackUrls = [ + config.rudderstackConfigUrl, + config.rudderstackDataPlaneUrl, + config.rudderstackIntegrationsUrl, + ] + .filter(Boolean) + .map((url) => new RegExp(`${url}.*.`)); + + const { GrafanaJavascriptAgentBackend } = await import( + './backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend' + ); + + registerEchoBackend( + new GrafanaJavascriptAgentBackend({ + ...config.grafanaJavascriptAgent, + app: { + version: config.buildInfo.version, + environment: config.buildInfo.env, + }, + buildInfo: config.buildInfo, + user: { + id: String(contextSrv.user?.id), + email: contextSrv.user?.email, + }, + ignoreUrls: rudderstackUrls, + }) + ); +} + +async function initGoogleAnalyticsBackend() { + if (!config.googleAnalyticsId) { + return; + } + + const { GAEchoBackend } = await import('./backends/analytics/GABackend'); + registerEchoBackend( + new GAEchoBackend({ + googleAnalyticsId: config.googleAnalyticsId, + }) + ); +} + +async function initGoogleAnalaytics4Backend() { + if (!config.googleAnalytics4Id) { + return; + } + + const { GA4EchoBackend } = await import('./backends/analytics/GA4Backend'); + registerEchoBackend( + new GA4EchoBackend({ + googleAnalyticsId: config.googleAnalytics4Id, + googleAnalytics4SendManualPageViews: config.googleAnalytics4SendManualPageViews, + }) + ); +} + +async function initRudderstackBackend() { + if (!(config.rudderstackWriteKey && config.rudderstackDataPlaneUrl)) { + return; + } + + const { RudderstackBackend } = await import('./backends/analytics/RudderstackBackend'); + registerEchoBackend( + new RudderstackBackend({ + writeKey: config.rudderstackWriteKey, + dataPlaneUrl: config.rudderstackDataPlaneUrl, + user: contextSrv.user, + sdkUrl: config.rudderstackSdkUrl, + configUrl: config.rudderstackConfigUrl, + integrationsUrl: config.rudderstackIntegrationsUrl, + buildInfo: config.buildInfo, + }) + ); +} + +async function initAzureAppInsightsBackend() { + if (!config.applicationInsightsConnectionString) { + return; + } + + const { ApplicationInsightsBackend } = await import('./backends/analytics/ApplicationInsightsBackend'); + registerEchoBackend( + new ApplicationInsightsBackend({ + connectionString: config.applicationInsightsConnectionString, + endpointUrl: config.applicationInsightsEndpointUrl, + }) + ); +} + +async function initConsoleBackend() { + if (!config.analyticsConsoleReporting) { + return; + } + + const { BrowserConsoleBackend } = await import('./backends/analytics/BrowseConsoleBackend'); + registerEchoBackend(new BrowserConsoleBackend()); +} diff --git a/public/app/core/utils/metrics.ts b/public/app/core/utils/metrics.ts index e244851aa74..28df336b38b 100644 --- a/public/app/core/utils/metrics.ts +++ b/public/app/core/utils/metrics.ts @@ -35,3 +35,15 @@ export function stopMeasure(eventName: string) { return; } } + +/** + * Report when a metric of a given name was marked during the document lifecycle. Works for markers with no duration, + * like PerformanceMark or PerformancePaintTiming (e.g. created with performance.mark, or first-contentful-paint) + */ +export function reportMetricPerformanceMark(metricName: string, prefix = '', suffix = ''): void { + const metric = performance.getEntriesByName(metricName).at(0); + if (metric) { + const metricName = metric.name.replace(/-/g, '_'); + reportPerformance(`${prefix}${metricName}${suffix}`, Math.round(metric.startTime) / 1000); + } +} diff --git a/public/app/features/admin/EnterpriseAuthFeaturesCard.tsx b/public/app/features/admin/EnterpriseAuthFeaturesCard.tsx index f52e2726b3c..d3387d46528 100644 --- a/public/app/features/admin/EnterpriseAuthFeaturesCard.tsx +++ b/public/app/features/admin/EnterpriseAuthFeaturesCard.tsx @@ -91,7 +91,7 @@ function getStyles(theme: GrafanaTheme2) { display: 'flex', flexDirection: 'column', gap: theme.spacing(1.5), - borderRadius: theme.shape.radius.default, + borderRadius: theme.shape.radius.lg, marginTop: theme.spacing(3), strong: { color: theme.colors.text.primary, diff --git a/public/app/features/admin/LicenseChrome.tsx b/public/app/features/admin/LicenseChrome.tsx index 2c83c6b99b4..fe0868730a1 100644 --- a/public/app/features/admin/LicenseChrome.tsx +++ b/public/app/features/admin/LicenseChrome.tsx @@ -22,12 +22,14 @@ const getStyles = (theme: GrafanaTheme2) => { textAlign: 'center', padding: theme.spacing(2), background: footerBg, + borderRadius: theme.shape.radius.lg, }), header: css({ height: '137px', padding: theme.spacing(4, 0, 0, 4), position: 'relative', background: `url('${backgroundUrl}') right`, + borderRadius: theme.shape.radius.lg, }), }; }; diff --git a/public/app/features/alerting/unified/AlertsFolderView.tsx b/public/app/features/alerting/unified/AlertsFolderView.tsx index ec39dcdde18..01841e51364 100644 --- a/public/app/features/alerting/unified/AlertsFolderView.tsx +++ b/public/app/features/alerting/unified/AlertsFolderView.tsx @@ -213,6 +213,7 @@ export const getStyles = (theme: GrafanaTheme2) => ({ noResults: css({ padding: theme.spacing(2), backgroundColor: theme.colors.background.secondary, + borderRadius: theme.shape.radius.lg, fontStyle: 'italic', }), }); diff --git a/public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts b/public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts index fc4b41be7b7..9d0d7373a82 100644 --- a/public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts +++ b/public/app/features/alerting/unified/__mocks__/useRouteGroupsMatcher.ts @@ -10,9 +10,9 @@ export function useRouteGroupsMatcher() { return routeGroupsMatcher.getRouteGroupsMap(route, groups); }, []); - const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => { - return routeGroupsMatcher.matchInstancesToRoute(rootRoute, instancesToMatch); + const matchInstancesToRoutes = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => { + return routeGroupsMatcher.matchInstancesToRoutes(rootRoute, instancesToMatch); }, []); - return { getRouteGroupsMap, matchInstancesToRoute }; + return { getRouteGroupsMap, matchInstancesToRoutes }; } diff --git a/public/app/features/alerting/unified/__snapshots__/NotificationPoliciesPage.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/NotificationPoliciesPage.test.tsx.snap index b6c6a2c31fb..89a949d8533 100644 --- a/public/app/features/alerting/unified/__snapshots__/NotificationPoliciesPage.test.tsx.snap +++ b/public/app/features/alerting/unified/__snapshots__/NotificationPoliciesPage.test.tsx.snap @@ -19,41 +19,101 @@ exports[`findRoutesMatchingFilters should work with all filters 1`] = ` "filtersApplied": true, "matchedRoutesWithPath": Map { { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "1", - "matchers": [ - "hello=world", - "foo!=bar", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "hello", + "=", + "world", + ], + [ + "foo", + "!=", + "bar", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, }, ], } => [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "0", + "mute_time_intervals": undefined, + "object_matchers": [], "receiver": "default-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "1", - "matchers": [ - "hello=world", - "foo!=bar", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "hello", + "=", + "world", + ], + [ + "foo", + "!=", + "bar", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, }, ], @@ -61,19 +121,45 @@ exports[`findRoutesMatchingFilters should work with all filters 1`] = ` ], }, { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "1", - "matchers": [ - "hello=world", - "foo!=bar", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "hello", + "=", + "world", + ], + [ + "foo", + "!=", + "bar", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, }, ], @@ -95,41 +181,101 @@ exports[`findRoutesMatchingFilters should work with only contact point and inher "filtersApplied": true, "matchedRoutesWithPath": Map { { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "1", - "matchers": [ - "hello=world", - "foo!=bar", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "hello", + "=", + "world", + ], + [ + "foo", + "!=", + "bar", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, }, ], } => [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "0", + "mute_time_intervals": undefined, + "object_matchers": [], "receiver": "default-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "1", - "matchers": [ - "hello=world", - "foo!=bar", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "hello", + "=", + "world", + ], + [ + "foo", + "!=", + "bar", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, }, ], @@ -137,50 +283,121 @@ exports[`findRoutesMatchingFilters should work with only contact point and inher ], }, { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "1", - "matchers": [ - "hello=world", - "foo!=bar", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "hello", + "=", + "world", + ], + [ + "foo", + "!=", + "bar", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, }, ], }, ], { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, } => [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "0", + "mute_time_intervals": undefined, + "object_matchers": [], "receiver": "default-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "1", - "matchers": [ - "hello=world", - "foo!=bar", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "hello", + "=", + "world", + ], + [ + "foo", + "!=", + "bar", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, }, ], @@ -188,29 +405,66 @@ exports[`findRoutesMatchingFilters should work with only contact point and inher ], }, { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "1", - "matchers": [ - "hello=world", - "foo!=bar", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "hello", + "=", + "world", + ], + [ + "foo", + "!=", + "bar", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, }, ], }, { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, }, ], @@ -223,41 +477,101 @@ exports[`findRoutesMatchingFilters should work with only label matchers 1`] = ` "filtersApplied": true, "matchedRoutesWithPath": Map { { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "1", - "matchers": [ - "hello=world", - "foo!=bar", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "hello", + "=", + "world", + ], + [ + "foo", + "!=", + "bar", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, }, ], } => [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "0", + "mute_time_intervals": undefined, + "object_matchers": [], "receiver": "default-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "1", - "matchers": [ - "hello=world", - "foo!=bar", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "hello", + "=", + "world", + ], + [ + "foo", + "!=", + "bar", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, }, ], @@ -265,19 +579,45 @@ exports[`findRoutesMatchingFilters should work with only label matchers 1`] = ` ], }, { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "1", - "matchers": [ - "hello=world", - "foo!=bar", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "hello", + "=", + "world", + ], + [ + "foo", + "!=", + "bar", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": [ { + "active_time_intervals": undefined, + "continue": false, + "group_by": undefined, + "group_interval": undefined, + "group_wait": undefined, "id": "2", - "matchers": [ - "bar=baz", + "mute_time_intervals": undefined, + "object_matchers": [ + [ + "bar", + "=", + "baz", + ], ], "receiver": "simple-receiver", + "repeat_interval": undefined, "routes": undefined, }, ], diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index aaee307716b..19f2b95d537 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -1,6 +1,6 @@ import { RelativeTimeRange } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { AlertmanagerAlert, Matcher } from 'app/plugins/datasource/alertmanager/types'; import { RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting'; import { AlertQuery, @@ -33,11 +33,9 @@ import { } from './prometheus'; import { FetchRulerRulesFilter, rulerUrlBuilder } from './ruler'; -export type ResponseLabels = { - labels: AlertInstances[]; -}; - -export type PreviewResponse = ResponseLabels[]; +export type PreviewResponse = Array< + Pick +>; export interface Datasource { type: string; @@ -83,8 +81,6 @@ export interface Rule { annotations: Annotations; } -export type AlertInstances = Record; - interface ExportRulesParams { format: ExportFormats; folderUid?: string; diff --git a/public/app/features/alerting/unified/components/EmptyArea.tsx b/public/app/features/alerting/unified/components/EmptyArea.tsx index d3a90d42acf..69f03939dd6 100644 --- a/public/app/features/alerting/unified/components/EmptyArea.tsx +++ b/public/app/features/alerting/unified/components/EmptyArea.tsx @@ -13,6 +13,7 @@ export const EmptyArea = ({ children }: React.PropsWithChildren<{}>) => { const getStyles = (theme: GrafanaTheme2) => { return { container: css({ + borderRadius: theme.shape.radius.lg, backgroundColor: theme.colors.background.secondary, color: theme.colors.text.secondary, padding: theme.spacing(4), diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index 24441564615..7c75e015412 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -1,6 +1,7 @@ import { difference, groupBy, take, trim, upperFirst } from 'lodash'; import { ReactNode } from 'react'; +import { computeInheritedTree } from '@grafana/alerting/unstable'; import { t } from '@grafana/i18n'; import { NotifierDTO, NotifierStatus, ReceiversStateDTO } from 'app/features/alerting/unified/types/alerting'; import { canAdminEntity, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils'; @@ -14,8 +15,8 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { OnCallIntegrationDTO } from '../../api/onCallApi'; -import { computeInheritedTree } from '../../utils/notification-policies'; import { extractReceivers } from '../../utils/receivers'; +import { routeAdapter } from '../../utils/routeAdapter'; import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall'; import { ReceiverPluginMetadata, getOnCallMetadata } from '../receivers/grafanaAppReceivers/useReceiversMetadata'; @@ -132,8 +133,10 @@ export function enhanceContactPointsWithMetadata({ alertmanagerConfiguration, }: EnhanceContactPointsArgs): ContactPointWithMetadata[] { // compute the entire inherited tree before finding what notification policies are using a particular contact point - const fullyInheritedTree = computeInheritedTree(alertmanagerConfiguration?.alertmanager_config?.route ?? {}); - const usedContactPoints = getUsedContactPoints(fullyInheritedTree); + const fullyInheritedTree = computeInheritedTree( + routeAdapter.toPackage(alertmanagerConfiguration?.alertmanager_config?.route ?? {}) + ); + const usedContactPoints = getUsedContactPoints(routeAdapter.fromPackage(fullyInheritedTree)); const usedContactPointsByName = groupBy(usedContactPoints, 'receiver'); const enhanced = contactPoints.map((contactPoint) => { diff --git a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx index 93dfdfe1989..93cb7db63da 100644 --- a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx +++ b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx @@ -6,7 +6,7 @@ import { byRole, byTestId, byText } from 'testing-library-selector'; import { mockExportApi, setupMswServer } from '../../mockApi'; import { mockDataSource } from '../../mocks'; -import { grafanaRulerRule } from '../../mocks/grafanaRulerApi'; +import { grafanaRulerRule, mockPreviewApiResponse } from '../../mocks/grafanaRulerApi'; import { setupDataSources } from '../../testSetup/datasources'; import GrafanaModifyExport from './GrafanaModifyExport'; @@ -59,6 +59,10 @@ function renderModifyExport(ruleId: string) { const server = setupMswServer(); +beforeEach(() => { + mockPreviewApiResponse(server, []); +}); + describe('GrafanaModifyExport', () => { setupDataSources(dataSources.default); diff --git a/public/app/features/alerting/unified/components/expressions/Expression.tsx b/public/app/features/alerting/unified/components/expressions/Expression.tsx index 032feeabd5d..173bca42a4c 100644 --- a/public/app/features/alerting/unified/components/expressions/Expression.tsx +++ b/public/app/features/alerting/unified/components/expressions/Expression.tsx @@ -541,6 +541,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ flex: 1, flexBasis: '400px', borderRadius: theme.shape.radius.default, + overflow: 'hidden', }), stack: css({ display: 'flex', diff --git a/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx b/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx index a82e9dfed4f..b1fcde8caf0 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Matchers.tsx @@ -55,7 +55,7 @@ interface MatcherBadgeProps { formatter?: MatcherFormatter; } -const MatcherBadge: FC = ({ matcher, formatter = 'default' }) => { +export const MatcherBadge: FC = ({ matcher, formatter = 'default' }) => { const styles = useStyles2(getStyles); return ( diff --git a/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx b/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx index 2e57a1960d2..72dace0fc9d 100644 --- a/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/NotificationPoliciesList.tsx @@ -2,6 +2,7 @@ import { defaults } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; import { useAsyncFn } from 'react-use'; +import { computeInheritedTree } from '@grafana/alerting/unstable'; import { Trans, t } from '@grafana/i18n'; import { Alert, Button, Stack } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; @@ -10,12 +11,12 @@ import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alertin import { FormAmRoute } from 'app/features/alerting/unified/types/amroutes'; import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes'; import { getErrorCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; -import { computeInheritedTree } from 'app/features/alerting/unified/utils/notification-policies'; import { ObjectMatcher, ROUTES_META_SYMBOL, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { anyOfRequestState, isError } from '../../hooks/useAsync'; import { useAlertmanager } from '../../state/AlertmanagerContext'; import { ERROR_NEWER_CONFIGURATION } from '../../utils/k8s/errors'; +import { routeAdapter } from '../../utils/routeAdapter'; import { alertmanagerApi } from './../../api/alertmanagerApi'; import { useGetContactPointsState } from './../../api/receiversApi'; @@ -297,7 +298,10 @@ export const findRoutesMatchingFilters = (rootRoute: RouteWithID, filters: Route const matchedRoutes: RouteWithID[][] = []; // compute fully inherited tree so all policies have their inherited receiver - const fullRoute = computeInheritedTree(rootRoute); + const adaptedRootRoute = routeAdapter.toPackage(rootRoute); + const adaptedFullTree = computeInheritedTree(adaptedRootRoute); + + const fullRoute = routeAdapter.fromPackage(adaptedFullTree); // find all routes for our contact point filter const matchingRoutesForContactPoint = contactPointFilter 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 2eded5fb40f..abd12efd135 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Policy.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Policy.tsx @@ -5,7 +5,8 @@ import * as React from 'react'; import { FC, Fragment, ReactNode, useState } from 'react'; import { useToggle } from 'react-use'; -import { AlertLabel } from '@grafana/alerting/unstable'; +import { InheritableProperties } from '@grafana/alerting/internal'; +import { AlertLabel, getInheritedProperties } from '@grafana/alerting/unstable'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { @@ -39,7 +40,7 @@ import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } import { getAmMatcherFormatter } from '../../utils/alertmanager'; import { MatcherFormatter, normalizeMatchers } from '../../utils/matchers'; import { createContactPointLink, createContactPointSearchLink, createMuteTimingLink } from '../../utils/misc'; -import { InheritableProperties, getInheritedProperties } from '../../utils/notification-policies'; +import { routeAdapter } from '../../utils/routeAdapter'; import { InsertPosition } from '../../utils/routeTree'; import { Authorize } from '../Authorize'; import { PopupCard } from '../HoverCard'; @@ -59,7 +60,7 @@ interface PolicyComponentProps { contactPointsState?: ReceiversState; readOnly?: boolean; provisioned?: boolean; - inheritedProperties?: Partial; + inheritedProperties?: InheritableProperties; routesMatchingFilters?: RoutesMatchingFilters; matchingInstancesPreview?: { @@ -346,7 +347,11 @@ const Policy = (props: PolicyComponentProps) => { {showPolicyChildren && ( <> {pageOfChildren.map((child) => { - const childInheritedProperties = getInheritedProperties(currentRoute, child, inheritedProperties); + const childInheritedProperties = getInheritedProperties( + routeAdapter.toPackage(currentRoute), + routeAdapter.toPackage(child), + inheritedProperties + ); // This child is autogenerated if it's the autogenerated root or if it's a child of an autogenerated policy. const isThisChildAutoGenerated = isAutoGeneratedRoot(child) || isAutoGenerated; /* pass the "readOnly" prop from the parent, because for any child policy , if its parent it's not editable, @@ -685,7 +690,7 @@ const AllMatchesIndicator: FC = () => { ); }; -function DefaultPolicyIndicator() { +export function DefaultPolicyIndicator() { const styles = useStyles2(getStyles); return ( <> diff --git a/public/app/features/alerting/unified/components/notification-policies/__snapshots__/useNotificationPolicyRoute.test.tsx.snap b/public/app/features/alerting/unified/components/notification-policies/__snapshots__/useNotificationPolicyRoute.test.tsx.snap index cfd5f04b6af..f4e5ae1f88f 100644 --- a/public/app/features/alerting/unified/components/notification-policies/__snapshots__/useNotificationPolicyRoute.test.tsx.snap +++ b/public/app/features/alerting/unified/components/notification-policies/__snapshots__/useNotificationPolicyRoute.test.tsx.snap @@ -11,6 +11,8 @@ exports[`createKubernetesRoutingTreeSpec 1`] = ` "group_by": [ "alertname", ], + "group_interval": undefined, + "group_wait": undefined, "receiver": "default-receiver", "repeat_interval": "4h", }, diff --git a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts index f927fc49d70..e6a0b7a0cc5 100644 --- a/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts +++ b/public/app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute.ts @@ -1,6 +1,7 @@ import { pick } from 'lodash'; import memoize from 'micro-memoize'; +import { INHERITABLE_KEYS, type InheritableProperties } from '@grafana/alerting/internal'; import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; import { MatcherOperator, ROUTES_META_SYMBOL, Route } from 'app/plugins/datasource/alertmanager/types'; @@ -23,7 +24,7 @@ import { FormAmRoute } from '../../types/amroutes'; import { addUniqueIdentifierToRoute } from '../../utils/amroutes'; import { PROVENANCE_NONE, ROOT_ROUTE_NAME } from '../../utils/k8s/constants'; import { isK8sEntityProvisioned, shouldUseK8sApi } from '../../utils/k8s/utils'; -import { INHERITABLE_KEYS, InheritableProperties } from '../../utils/notification-policies'; +import { routeAdapter } from '../../utils/routeAdapter'; import { InsertPosition, addRouteToReferenceRoute, @@ -279,7 +280,7 @@ export function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApis export function createKubernetesRoutingTreeSpec( rootRoute: Route ): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree { - const inheritableDefaultProperties: InheritableProperties = pick(rootRoute, INHERITABLE_KEYS); + const inheritableDefaultProperties: InheritableProperties = pick(routeAdapter.toPackage(rootRoute), INHERITABLE_KEYS); const defaults: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults = { ...inheritableDefaultProperties, diff --git a/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx b/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx index 1f4b7dfa30d..13777d4036a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx @@ -70,7 +70,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: 'flex', flexDirection: 'row', border: `solid 1px ${theme.colors.border.weak}`, - borderRadius: theme.shape.radius.default, + borderRadius: theme.shape.radius.lg, padding: `${theme.spacing(2)} ${theme.spacing(3)}`, }), description: css({ diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx index 400ea551a25..c5b364a20d9 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx @@ -20,7 +20,7 @@ import { MANUAL_ROUTING_KEY, SIMPLIFIED_QUERY_EDITOR_KEY } from 'app/features/al import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types/accessControl'; -import { grafanaRulerGroup } from '../../../../mocks/grafanaRulerApi'; +import { grafanaRulerGroup, mockPreviewApiResponse } from '../../../../mocks/grafanaRulerApi'; jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ AppChromeUpdate: ({ actions }: { actions: ReactNode }) =>
{actions}
, @@ -63,6 +63,8 @@ const selectContactPoint = async (contactPointName: string) => { await clickSelectOption(contactPointInput, contactPointName); }; +const server = setupMswServer(); + // combobox hack beforeEach(() => { const mockGetBoundingClientRect = jest.fn(() => ({ @@ -77,9 +79,10 @@ beforeEach(() => { Object.defineProperty(Element.prototype, 'getBoundingClientRect', { value: mockGetBoundingClientRect, }); + + mockPreviewApiResponse(server, []); }); -setupMswServer(); setupDataSources(dataSources.default, dataSources.am); // Setup plugin extensions hook to prevent setPluginLinksHook errors diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/ConnectionLine.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/ConnectionLine.tsx new file mode 100644 index 00000000000..9e2881cdc35 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/ConnectionLine.tsx @@ -0,0 +1,22 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Box, useStyles2 } from '@grafana/ui'; + +export function ConnectionLine() { + const styles = useStyles2(getStyles); + + return ( + +
+ + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + line: css({ + width: '1px', + height: '100%', + background: theme.colors.border.medium, + }), +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/ContactPointGroup.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/ContactPointGroup.tsx new file mode 100644 index 00000000000..ba69db35766 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/ContactPointGroup.tsx @@ -0,0 +1,158 @@ +import { css } from '@emotion/css'; +import { PropsWithChildren, ReactNode } from 'react'; +import Skeleton from 'react-loading-skeleton'; +import { useToggle } from 'react-use'; + +import { alertingAPI, getContactPointDescription } from '@grafana/alerting/unstable'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { Stack, Text, TextLink, useStyles2 } from '@grafana/ui'; + +import { stringifyFieldSelector } from '../../../utils/k8s/utils'; +import { createContactPointLink } from '../../../utils/misc'; +import { CollapseToggle } from '../../CollapseToggle'; +import { MetaText } from '../../MetaText'; +import { ContactPointLink } from '../../rule-viewer/ContactPointLink'; + +import UnknownContactPointDetails from './UnknownContactPointDetails'; + +interface ContactPointGroupProps extends PropsWithChildren { + name: string; + matchedInstancesCount: number; +} + +export function GrafanaContactPointGroup({ name, matchedInstancesCount, children }: ContactPointGroupProps) { + // find receiver by name – since this is what we store in the alert rule definition + const { data, isLoading } = alertingAPI.endpoints.listReceiver.useQuery({ + fieldSelector: stringifyFieldSelector([['spec.title', name]]), + }); + + // grab the first result from the fieldSelector result + const contactPoint = data?.items.at(0); + + return ( + + ) : ( + + ) + } + description={contactPoint ? getContactPointDescription(contactPoint) : null} + > + {children} + + ); +} + +export function ExternalContactPointGroup({ + name, + alertmanagerSourceName, + matchedInstancesCount, + children, +}: ContactPointGroupProps & { alertmanagerSourceName: string }) { + const link = ( + + {name} + + ); + return ( + + {children} + + ); +} + +interface ContactPointGroupInnerProps extends Omit { + name: NonNullable; + description?: ReactNode; + isLoading?: boolean; + children: ReactNode; +} + +export function ContactPointGroup({ + name, + description, + matchedInstancesCount, + isLoading = false, + children, +}: ContactPointGroupInnerProps) { + const styles = useStyles2(getStyles); + const [isExpanded, toggleExpanded] = useToggle(false); + + return ( + +
+ + toggleExpanded()} + aria-label={t('alerting.notification-route-header.aria-label-expand-policy-route', 'Expand policy route')} + /> + {isLoading && loader} + {!isLoading && ( + <> + {name && ( + <> + + Delivered to {name} + + {description && ( + + ⋅ {description} + + )} + + )} + {matchedInstancesCount && ( + <> + + | + + + {/* @TODO pluralization */} + {matchedInstancesCount}{' '} + instances + + + )} + + )} + +
+ {isExpanded &&
{children}
} +
+ ); +} + +const loader = ( + + + + +); + +const getStyles = (theme: GrafanaTheme2) => ({ + contactPointRow: css({ + padding: theme.spacing(0.5), + + ':hover': { + background: theme.components.table.rowHoverBackground, + }, + }), + notificationPolicies: css({ + marginLeft: theme.spacing(2), + borderLeftStyle: 'solid', + borderLeftWidth: 1, + borderLeftColor: theme.colors.border.weak, + }), +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/JourneyPolicyCard.test.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/JourneyPolicyCard.test.tsx new file mode 100644 index 00000000000..ab8bd49ede0 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/JourneyPolicyCard.test.tsx @@ -0,0 +1,103 @@ +import { render, screen } from '@testing-library/react'; + +import { LabelMatcher, RouteWithID } from '@grafana/alerting/unstable'; + +import { JourneyPolicyCard } from './JourneyPolicyCard'; + +describe('JourneyPolicyCard', () => { + const mockMatchers: LabelMatcher[] = [ + { label: 'severity', type: '=', value: 'critical' }, + { label: 'team', type: '=', value: 'backend' }, + ]; + + const mockRoute: RouteWithID = { + id: 'test-route', + receiver: 'test-receiver', + group_by: ['alertname', 'severity'], + matchers: mockMatchers, + routes: [], + continue: false, + }; + + it('should render basic route information', () => { + render(); + + expect(screen.getByText('test-receiver')).toBeInTheDocument(); + expect(screen.getByText('alertname, severity')).toBeInTheDocument(); + expect(screen.getByTestId('label-matchers')).toBeInTheDocument(); + }); + + it('should render "No matchers" when route has no matchers', () => { + const routeWithoutMatchers: RouteWithID = { + id: 'test-route', + receiver: 'test-receiver', + matchers: [], + routes: [], + continue: false, + }; + + render(); + + expect(screen.getByText('No matchers')).toBeInTheDocument(); + expect(screen.queryByTestId('label-matchers')).not.toBeInTheDocument(); + }); + + it('should show continue matching indicator when route.continue is true', () => { + const routeWithContinue: RouteWithID = { + ...mockRoute, + continue: true, + }; + + render(); + + expect(screen.getByTestId('continue-matching')).toBeInTheDocument(); + }); + + it('should not show continue matching indicator when route.continue is false or undefined', () => { + render(); + + expect(screen.queryByTestId('continue-matching')).not.toBeInTheDocument(); + }); + + it('should not render receiver or group_by when they are not provided', () => { + const minimalRoute: RouteWithID = { + id: 'test-route', + routes: [], + continue: false, + }; + + render(); + + expect(screen.queryByText(/test-receiver/)).not.toBeInTheDocument(); + expect(screen.queryByText(/alertname/)).not.toBeInTheDocument(); + }); + + it('should show DefaultPolicyIndicator when isRoot is true', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Default policy' })).toBeInTheDocument(); + }); + + describe('isFinalRoute prop', () => { + it('should set aria-current="true" and aria-label when isFinalRoute is true', () => { + render(); + + const card = screen.getByRole('article', { current: true }); + expect(card).toBeInTheDocument(); + }); + + it('should not set aria-current when isFinalRoute is false', () => { + render(); + + const card = screen.getByRole('article', { current: false }); + expect(card).toBeInTheDocument(); + }); + + it('should not set aria-current when isFinalRoute is undefined (default)', () => { + render(); + + const card = screen.getByRole('article', { current: false }); + expect(card).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/JourneyPolicyCard.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/JourneyPolicyCard.tsx new file mode 100644 index 00000000000..bab2fb31e8e --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/JourneyPolicyCard.tsx @@ -0,0 +1,112 @@ +import { css } from '@emotion/css'; + +import { RouteWithID } from '@grafana/alerting/unstable'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { Icon, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui'; +import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types'; + +import { labelMatcherToObjectMatcher } from '../../../utils/routeAdapter'; +import { Matchers } from '../../notification-policies/Matchers'; +import { DefaultPolicyIndicator } from '../../notification-policies/Policy'; + +interface JourneyPolicyCardProps { + route: RouteWithID; + isRoot?: boolean; + isFinalRoute?: boolean; +} + +export function JourneyPolicyCard({ route, isRoot = false, isFinalRoute = false }: JourneyPolicyCardProps) { + const styles = useStyles2(getStyles); + + // Convert route matchers to ObjectMatcher format + const matchers: ObjectMatcher[] = route.matchers?.map(labelMatcherToObjectMatcher) ?? []; + + const hasMatchers = matchers.length > 0; + const continueMatching = route.continue ?? false; + + return ( +
+ {continueMatching && } + + {/* root route indicator */} + {isRoot && } + + {/* Matchers */} + {hasMatchers ? ( + + ) : ( + + No matchers + + )} + + {/* Route metadata */} + + {route.receiver && ( + + {route.receiver} + + )} + {route.group_by && route.group_by.length > 0 && ( + + {route.group_by.join(', ')} + + )} + + +
+ ); +} + +const ContinueMatchingIndicator = () => { + const styles = useStyles2(getStyles); + + return ( + + This route will continue matching other policies + + } + > +
+ +
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + policyWrapper: (hasFocus = false) => + css({ + position: 'relative', + background: theme.colors.background.secondary, + borderRadius: theme.shape.radius.default, + border: `solid 1px ${theme.colors.border.weak}`, + ...(hasFocus && { + borderColor: theme.colors.primary.border, + background: theme.colors.primary.transparent, + }), + padding: theme.spacing(1), + }), + gutterIcon: css({ + position: 'absolute', + left: `-${theme.spacing(3.5)}`, + top: theme.spacing(2.25), + + color: theme.colors.text.secondary, + background: theme.colors.background.primary, + + width: '20px', + height: '20px', + + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + + border: `solid 1px ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + }), +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/MatchDetails.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/MatchDetails.tsx new file mode 100644 index 00000000000..40cbf60da62 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/MatchDetails.tsx @@ -0,0 +1,56 @@ +import { css } from '@emotion/css'; + +import { AlertLabel, LabelMatchDetails } from '@grafana/alerting/unstable'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { Box, Text, useStyles2 } from '@grafana/ui'; + +import { labelMatcherToObjectMatcher } from '../../../utils/routeAdapter'; +import { MatcherBadge } from '../../notification-policies/Matchers'; + +interface MatchDetailsProps { + matchDetails: LabelMatchDetails[]; + labels: Array<[string, string]>; +} + +export function MatchDetails({ matchDetails, labels }: MatchDetailsProps) { + const styles = useStyles2(getStyles); + const matchingLabels = matchDetails.filter((detail) => detail.match); + + const noMatchingLabels = matchingLabels.length === 0; + + return ( +
+ {noMatchingLabels ? ( + + Policy matches all labels + + ) : ( + matchingLabels.map((detail) => ( + + + + matched + + {detail.matcher && } + + )) + )} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + background: theme.colors.background.secondary, + borderRadius: theme.shape.radius.pill, + border: `solid 1px ${theme.colors.border.weak}`, + + width: 'fit-content', + alignSelf: 'center', + }), +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyDrawer.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyDrawer.tsx new file mode 100644 index 00000000000..e43bb432b82 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyDrawer.tsx @@ -0,0 +1,110 @@ +import { Fragment, useState } from 'react'; + +import { AlertLabel, RouteMatchResult, RouteWithID } from '@grafana/alerting/unstable'; +import { Trans } from '@grafana/i18n'; +import { Button, Drawer, Text, TextLink } from '@grafana/ui'; + +import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; +import { createRelativeUrl } from '../../../utils/url'; + +import { ConnectionLine } from './ConnectionLine'; +import { JourneyPolicyCard } from './JourneyPolicyCard'; +import { MatchDetails } from './MatchDetails'; + +type NotificationPolicyDrawerProps = { + policyName?: string; + matchedRootRoute: boolean; + journey: RouteMatchResult['matchingJourney']; + labels: Array<[string, string]>; +}; + +export function NotificationPolicyDrawer({ + policyName, + matchedRootRoute, + journey, + labels, +}: NotificationPolicyDrawerProps) { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const handleOpenDrawer = () => { + setIsDrawerOpen(true); + }; + + const handleCloseDrawer = () => { + setIsDrawerOpen(false); + }; + + // Process the journey data to extract the information we need + const finalRouteMatchInfo = journey.at(-1); + const nonMatchingLabels = finalRouteMatchInfo?.matchDetails.filter((detail) => !detail.match) ?? []; + + return ( + <> + + + {isDrawerOpen && ( + + Notification policy + {policyName && ( + + {' '} + ⋅ {policyName} + + )} + + } + onClose={handleCloseDrawer} + > + + + + {journey.map((routeInfo, index) => ( + + {index > 0 && ( + <> + + + + + )} + + + ))} + + + {nonMatchingLabels.length > 0 && ( + + + Non-matching labels + + + {nonMatchingLabels.map((detail) => ( + + + + ))} + + + )} + + + + + View notification policy tree + + + + + )} + + ); +} diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx index 2da056f7b2a..7ea07fdc3ec 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx @@ -1,45 +1,23 @@ -import { css } from '@emotion/css'; - -import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; -import { useStyles2 } from '@grafana/ui'; +import { Text } from '@grafana/ui'; +import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types'; import { MatcherFormatter } from '../../../utils/matchers'; import { Matchers } from '../../notification-policies/Matchers'; -import { RouteWithPath, hasEmptyMatchers, isDefaultPolicy } from './route'; - interface Props { - route: RouteWithPath; + matchers?: ObjectMatcher[]; matcherFormatter: MatcherFormatter; } -export function NotificationPolicyMatchers({ route, matcherFormatter }: Props) { - const styles = useStyles2(getStyles); - if (isDefaultPolicy(route)) { +export function NotificationPolicyMatchers({ matchers = [], matcherFormatter }: Props) { + if (matchers.length === 0) { return ( -
- Default policy -
- ); - } else if (hasEmptyMatchers(route)) { - return ( -
+ No matchers -
+ ); } else { - return ; + return ; } } - -const getStyles = (theme: GrafanaTheme2) => ({ - defaultPolicy: css({ - padding: theme.spacing(0.5), - background: theme.colors.background.secondary, - width: 'fit-content', - }), - textMuted: css({ - color: theme.colors.text.secondary, - }), -}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx index fa87aef23a4..ab95bb4b679 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.test.tsx @@ -1,13 +1,12 @@ -import { render, screen, waitFor, within } from 'test/test-utils'; -import { byRole, byTestId, byText } from 'testing-library-selector'; +import { render, waitFor, within } from 'test/test-utils'; +import { byRole, byText } from 'testing-library-selector'; import { setAlertmanagerConfig } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers'; import { AccessControlAction } from 'app/types/accessControl'; import { MatcherOperator } from '../../../../../../plugins/datasource/alertmanager/types'; -import { Labels } from '../../../../../../types/unified-alerting-dto'; import { getMockConfig, setupMswServer } from '../../../mockApi'; -import { grantUserPermissions, mockAlertQuery } from '../../../mocks'; +import { grantUserPermissions, mockAlertQuery, mockAlertmanagerAlert } from '../../../mocks'; import { mockPreviewApiResponse } from '../../../mocks/grafanaRulerApi'; import { Folder } from '../../../types/rule-form'; import * as dataSource from '../../../utils/datasource'; @@ -18,7 +17,6 @@ import { } from '../../../utils/datasource'; import { NotificationPreview } from './NotificationPreview'; -import NotificationPreviewByAlertManager from './NotificationPreviewByAlertManager'; jest.mock('../../../useRouteGroupsMatcher'); @@ -34,18 +32,14 @@ const getAlertManagerDataSourcesByPermissionAndConfigMock = >; const ui = { - route: byTestId('matching-policy-route'), - routeButton: byRole('button', { name: /Expand policy route/ }), - routeMatchingInstances: byTestId('route-matching-instance'), - loadingIndicator: byText(/Loading routing preview/i), - previewButton: byRole('button', { name: /preview routing/i }), + contactPointGroup: byRole('list'), grafanaAlertManagerLabel: byText(/alertmanager:grafana/i), otherAlertManagerLabel: byText(/alertmanager:other_am/i), - seeDetails: byText(/see details/i), + expandButton: byRole('button', { name: 'Expand policy route' }), + seeDetails: byRole('button', { name: 'View route' }), details: { - title: byRole('heading', { name: /routing details/i }), - modal: byRole('dialog'), - linkToContactPoint: byRole('link', { name: /see details/i }), + drawer: byRole('dialog'), + linkToPolicyTree: byRole('link', { name: /view notification policy tree/i }), }, }; @@ -118,360 +112,91 @@ describe('NotificationPreview', () => { it('should render notification preview without alert manager label, when having only one alert manager configured to receive alerts', async () => { mockOneAlertManager(); - mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]); + mockPreviewApiResponse(server, [ + mockAlertmanagerAlert({ + labels: { tomato: 'red', avocate: 'green' }, + }), + ]); - const { user } = render( - - ); + render(); - await user.click(ui.previewButton.get()); - await waitFor(() => { - expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); + // wait for loading to finish + await waitFor(async () => { + const matchingContactPoint = await ui.contactPointGroup.findAll(); + expect(matchingContactPoint).toHaveLength(1); }); // we expect the alert manager label to be missing as there is only one alert manager configured to receive alerts - await waitFor(() => { - expect(ui.grafanaAlertManagerLabel.query()).not.toBeInTheDocument(); - }); + expect(ui.grafanaAlertManagerLabel.query()).not.toBeInTheDocument(); expect(ui.otherAlertManagerLabel.query()).not.toBeInTheDocument(); - const matchingPoliciesElements = ui.route.queryAll; - await waitFor(() => { - expect(matchingPoliciesElements()).toHaveLength(1); - }); - expect(matchingPoliciesElements()[0]).toHaveTextContent(/tomato = red/); + const matchingContactPoint = await ui.contactPointGroup.findAll(); + expect(matchingContactPoint[0]).toHaveTextContent(/Delivered to slack/); + expect(matchingContactPoint[0]).toHaveTextContent(/1 instance/); }); + it('should render notification preview with alert manager sections, when having more than one alert manager configured to receive alerts', async () => { // two alert managers configured to receive alerts mockTwoAlertManagers(); - mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]); + mockPreviewApiResponse(server, [ + mockAlertmanagerAlert({ + labels: { tomato: 'red', avocate: 'green' }, + }), + ]); - const { user } = render( - - ); + render(); - await user.click(await ui.previewButton.find()); + // wait for loading to finish + await waitFor(async () => { + const matchingContactPoint = await ui.contactPointGroup.findAll(); + expect(matchingContactPoint).toHaveLength(2); + }); // we expect the alert manager label to be present as there is more than one alert manager configured to receive alerts expect(await ui.grafanaAlertManagerLabel.find()).toBeInTheDocument(); expect(await ui.otherAlertManagerLabel.find()).toBeInTheDocument(); - const matchingPoliciesElements = await ui.route.findAll(); + const matchingContactPoint = await ui.contactPointGroup.findAll(); - expect(matchingPoliciesElements).toHaveLength(2); - expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/); - expect(matchingPoliciesElements[1]).toHaveTextContent(/tomato = red/); + expect(matchingContactPoint).toHaveLength(2); + expect(matchingContactPoint[0]).toHaveTextContent(/Delivered to slack/); + expect(matchingContactPoint[0]).toHaveTextContent(/1 instance/); + + expect(matchingContactPoint[1]).toHaveTextContent(/Delivered to slack/); + expect(matchingContactPoint[1]).toHaveTextContent(/1 instance/); }); - it('should render details modal when clicking see details button', async () => { - // two alert managers configured to receive alerts - mockOneAlertManager(); - mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]); + + it('should render details when clicking see details button', async () => { mockHasEditPermission(true); - - const { user } = render( - - ); - await waitFor(() => { - expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); - }); - - await user.click(ui.previewButton.get()); - await user.click(await ui.seeDetails.find()); - expect(ui.details.title.query()).toBeInTheDocument(); - //we expect seeing the default policy - expect(screen.getByText(/default policy/i)).toBeInTheDocument(); - const matchingPoliciesElements = within(ui.details.modal.get()).getAllByTestId('label-matchers'); - expect(matchingPoliciesElements).toHaveLength(1); - expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/); - expect(within(ui.details.modal.get()).getByText(/slack/i)).toBeInTheDocument(); - expect(ui.details.linkToContactPoint.get()).toBeInTheDocument(); - }); - it('should not render contact point link in details modal if user has no permissions for editing contact points', async () => { - // two alert managers configured to receive alerts mockOneAlertManager(); - mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]); - mockHasEditPermission(false); + mockPreviewApiResponse(server, [ + mockAlertmanagerAlert({ + labels: { tomato: 'red', avocate: 'green' }, + }), + ]); const { user } = render( ); - await waitFor(() => { - expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); + // wait for loading to finish + await waitFor(async () => { + const matchingContactPoint = await ui.contactPointGroup.findAll(); + expect(matchingContactPoint).toHaveLength(1); }); - await user.click(ui.previewButton.get()); + // expand the matching contact point to show instances + await user.click(await ui.expandButton.find()); + + // click "view route" await user.click(await ui.seeDetails.find()); - expect(ui.details.title.query()).toBeInTheDocument(); - //we expect seeing the default policy - expect(screen.getByText(/default policy/i)).toBeInTheDocument(); - const matchingPoliciesElements = within(ui.details.modal.get()).getAllByTestId('label-matchers'); - expect(matchingPoliciesElements).toHaveLength(1); - expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/); - expect(within(ui.details.modal.get()).getByText(/slack/i)).toBeInTheDocument(); - expect(ui.details.linkToContactPoint.query()).not.toBeInTheDocument(); - }); -}); -describe('NotificationPreviewByAlertmanager', () => { - it('should render route matching preview for alertmanager', async () => { - const potentialInstances: Labels[] = [ - { foo: 'bar', severity: 'critical' }, - { job: 'prometheus', severity: 'warning' }, - ]; + // grab drawer and assert within + const drawer = ui.details.drawer.getAll()[0]; + expect(drawer).toBeInTheDocument(); - const mockConfig = getMockConfig((amConfigBuilder) => - amConfigBuilder - .withRoute((routeBuilder) => - routeBuilder - .withReceiver('email') - .addRoute((rb) => rb.withReceiver('slack').addMatcher('severity', MatcherOperator.equal, 'critical')) - .addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations')) - ) - .addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com'))) - .addReceivers((b) => b.withName('slack')) - .addReceivers((b) => b.withName('opsgenie')) - ); - setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig); - - const { user } = render( - - ); - - await waitFor(() => { - expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); - }); - - const routeElements = ui.route.getAll(); - - expect(routeElements).toHaveLength(2); - expect(routeElements[0]).toHaveTextContent(/slack/); - expect(routeElements[1]).toHaveTextContent(/email/); - - await user.click(ui.routeButton.get(routeElements[0])); - await user.click(ui.routeButton.get(routeElements[1])); - - const matchingInstances0 = ui.routeMatchingInstances.get(routeElements[0]); - const matchingInstances1 = ui.routeMatchingInstances.get(routeElements[1]); - - expect(matchingInstances0).toHaveTextContent(/severity=critical/); - expect(matchingInstances0).toHaveTextContent(/foo=bar/); - - expect(matchingInstances1).toHaveTextContent(/job=prometheus/); - expect(matchingInstances1).toHaveTextContent(/severity=warning/); - }); - it('should render route matching preview for alertmanager without errors if receiver is inherited from parent route (no receiver) ', async () => { - const potentialInstances: Labels[] = [ - { foo: 'bar', severity: 'critical' }, - { job: 'prometheus', severity: 'warning' }, - ]; - - const mockConfig = getMockConfig((amConfigBuilder) => - amConfigBuilder - .withRoute((routeBuilder) => - routeBuilder - .withReceiver('email') - .addRoute((rb) => { - rb.addRoute((rb) => rb.withoutReceiver().addMatcher('foo', MatcherOperator.equal, 'bar')); - return rb.withReceiver('slack').addMatcher('severity', MatcherOperator.equal, 'critical'); - }) - .addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations')) - ) - .addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com'))) - .addReceivers((b) => b.withName('slack')) - .addReceivers((b) => b.withName('opsgenie')) - ); - setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig); - - const { user } = render( - - ); - - await waitFor(() => { - expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); - }); - - const routeElements = ui.route.getAll(); - - expect(routeElements).toHaveLength(2); - expect(routeElements[0]).toHaveTextContent(/slack/); - expect(routeElements[1]).toHaveTextContent(/email/); - - await user.click(ui.routeButton.get(routeElements[0])); - await user.click(ui.routeButton.get(routeElements[1])); - - const matchingInstances0 = ui.routeMatchingInstances.get(routeElements[0]); - const matchingInstances1 = ui.routeMatchingInstances.get(routeElements[1]); - - expect(matchingInstances0).toHaveTextContent(/severity=critical/); - expect(matchingInstances0).toHaveTextContent(/foo=bar/); - - expect(matchingInstances1).toHaveTextContent(/job=prometheus/); - expect(matchingInstances1).toHaveTextContent(/severity=warning/); - }); - it('should render route matching preview for alertmanager without errors if receiver is inherited from parent route (empty string receiver)', async () => { - const potentialInstances: Labels[] = [ - { foo: 'bar', severity: 'critical' }, - { job: 'prometheus', severity: 'warning' }, - ]; - - const mockConfig = getMockConfig((amConfigBuilder) => - amConfigBuilder - .withRoute((routeBuilder) => - routeBuilder - .withReceiver('email') - .addRoute((rb) => { - rb.addRoute((rb) => rb.withEmptyReceiver().addMatcher('foo', MatcherOperator.equal, 'bar')); - return rb.withReceiver('slack').addMatcher('severity', MatcherOperator.equal, 'critical'); - }) - .addRoute((rb) => rb.withReceiver('opsgenie').addMatcher('team', MatcherOperator.equal, 'operations')) - ) - .addReceivers((b) => b.withName('email').addEmailConfig((eb) => eb.withTo('test@example.com'))) - .addReceivers((b) => b.withName('slack')) - .addReceivers((b) => b.withName('opsgenie')) - ); - - setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig); - - const { user } = render( - - ); - - await waitFor(() => { - expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); - }); - - const routeElements = ui.route.getAll(); - - expect(routeElements).toHaveLength(2); - expect(routeElements[0]).toHaveTextContent(/slack/); - expect(routeElements[1]).toHaveTextContent(/email/); - - await user.click(ui.routeButton.get(routeElements[0])); - await user.click(ui.routeButton.get(routeElements[1])); - - const matchingInstances0 = ui.routeMatchingInstances.get(routeElements[0]); - const matchingInstances1 = ui.routeMatchingInstances.get(routeElements[1]); - - expect(matchingInstances0).toHaveTextContent(/severity=critical/); - expect(matchingInstances0).toHaveTextContent(/foo=bar/); - - expect(matchingInstances1).toHaveTextContent(/job=prometheus/); - expect(matchingInstances1).toHaveTextContent(/severity=warning/); - }); - - describe('regex matching', () => { - it('does not match regex in middle of the word as alertmanager will anchor when queried via API', async () => { - const potentialInstances: Labels[] = [{ regexfield: 'foobarfoo' }]; - - const mockConfig = getMockConfig((amConfigBuilder) => - amConfigBuilder - .addReceivers((b) => b.withName('email')) - .withRoute((routeBuilder) => - routeBuilder - .withReceiver('email') - .addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.regex, 'bar')) - ) - ); - - setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig); - - render( - - ); - - expect(await screen.findByText(/default policy/i)).toBeInTheDocument(); - expect(screen.queryByText(/regexfield/)).not.toBeInTheDocument(); - }); - - it('matches regex at the start of the word', async () => { - const potentialInstances: Labels[] = [{ regexfield: 'baaaaaaah' }]; - - const mockConfig = getMockConfig((amConfigBuilder) => - amConfigBuilder - .addReceivers((b) => b.withName('email')) - .withRoute((routeBuilder) => - routeBuilder - .withReceiver('email') - .addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.regex, 'ba.*h')) - ) - ); - setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig); - - render( - - ); - - expect(await screen.findByText(/regexfield/i)).toBeInTheDocument(); - }); - - it('handles negated regex correctly', async () => { - const potentialInstances: Labels[] = [{ regexfield: 'thing' }]; - - const mockConfig = getMockConfig((amConfigBuilder) => - amConfigBuilder - .addReceivers((b) => b.withName('email')) - .withRoute((routeBuilder) => - routeBuilder - .withReceiver('email') - .addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.notRegex, 'thing')) - ) - ); - setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig); - - render( - - ); - - expect(await screen.findByText(/default policy/i)).toBeInTheDocument(); - expect(screen.queryByText(/regexfield/i)).not.toBeInTheDocument(); - }); - }); - it('matches regex with flags', async () => { - const potentialInstances: Labels[] = [{ regexfield: 'baaaaaaah' }]; - - const mockConfig = getMockConfig((amConfigBuilder) => - amConfigBuilder - .addReceivers((b) => b.withName('email')) - .withRoute((routeBuilder) => - routeBuilder - .withReceiver('email') - .addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.regex, '(?i)BA.*h')) - ) - ); - setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, mockConfig); - - render( - - ); - - expect(await screen.findByText(/regexfield/i)).toBeInTheDocument(); + // assert within the drawer + expect(within(drawer).getByRole('heading', { name: 'Default policy' })).toBeInTheDocument(); + expect(within(drawer).getByText(/non-matching labels/i)).toBeInTheDocument(); + expect(ui.details.linkToPolicyTree.get()).toBeInTheDocument(); }); }); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx index 248ef0c00c2..c1396464c86 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreview.tsx @@ -1,15 +1,18 @@ -import { compact } from 'lodash'; -import { Suspense, lazy } from 'react'; +import { css } from '@emotion/css'; +import { Fragment, Suspense, lazy } from 'react'; +import { useEffectOnce } from 'react-use'; +import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Button, LoadingPlaceholder, Stack, Text } from '@grafana/ui'; +import { Button, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui'; import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; -import { AlertQuery } from 'app/types/unified-alerting-dto'; +import { AlertQuery, Labels } from 'app/types/unified-alerting-dto'; import { Folder, KBObjectArray } from '../../../types/rule-form'; import { useGetAlertManagerDataSourcesByPermissionAndConfig } from '../../../utils/datasource'; const NotificationPreviewByAlertManager = lazy(() => import('./NotificationPreviewByAlertManager')); +const NotificationPreviewForGrafanaManaged = lazy(() => import('./NotificationPreviewGrafanaManaged')); interface NotificationPreviewProps { customLabels: KBObjectArray; @@ -20,6 +23,8 @@ interface NotificationPreviewProps { alertUid?: string; } +const { preview } = alertRuleApi.endpoints; + // TODO the scroll position keeps resetting when we preview // this is to be expected because the list of routes dissapears as we start the request but is very annoying export const NotificationPreview = ({ @@ -30,15 +35,20 @@ export const NotificationPreview = ({ alertName, alertUid, }: NotificationPreviewProps) => { + const styles = useStyles2(getStyles); const disabled = !condition || !folder; - const previewEndpoint = alertRuleApi.endpoints.preview; - - const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = previewEndpoint.useMutation(); + const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = preview.useMutation(); // potential instances are the instances that are going to be routed to the notification policies // convert data to list of labels: are the representation of the potential instances - const potentialInstances = compact(data.flatMap((label) => label?.labels)); + const potentialInstances = data.reduce((acc = [], instance) => { + if (instance.labels) { + acc.push(instance.labels); + } + + return acc; + }, []); const onPreview = () => { if (!folder || !condition) { @@ -56,10 +66,13 @@ export const NotificationPreview = ({ }); }; + useEffectOnce(() => { + onPreview(); + }); + // Get alert managers's data source information const alertManagerDataSources = useGetAlertManagerDataSourcesByPermissionAndConfig('notification'); - - const onlyOneAM = alertManagerDataSources.length === 1; + const singleAlertManagerConfigured = alertManagerDataSources.length === 1; return ( @@ -93,22 +106,63 @@ export const NotificationPreview = ({ Preview routing - {!isLoading && !previewUninitialized && potentialInstances.length > 0 && ( + {potentialInstances.length > 0 && ( } > {alertManagerDataSources.map((alertManagerSource) => ( - + + {!singleAlertManagerConfigured && ( + +
+
+ Alertmanager: + + {alertManagerSource.name} +
+
+ + )} + {alertManagerSource.name === 'grafana' ? ( + + ) : ( + + )} + ))} )} ); }; + +const getStyles = (theme: GrafanaTheme2) => ({ + firstAlertManagerLine: css({ + height: '1px', + width: theme.spacing(4), + backgroundColor: theme.colors.secondary.main, + }), + alertManagerName: css({ + width: 'fit-content', + }), + secondAlertManagerLine: css({ + height: '1px', + width: '100%', + flex: 1, + backgroundColor: theme.colors.secondary.main, + }), + img: css({ + marginLeft: theme.spacing(2), + width: theme.spacing(3), + height: theme.spacing(3), + marginRight: theme.spacing(1), + }), +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx index d40f2f3de67..564b8eb3a92 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewByAlertManager.tsx @@ -1,31 +1,29 @@ -import { css } from '@emotion/css'; +import { groupBy } from 'lodash'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Trans, t } from '@grafana/i18n'; -import { Alert, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui'; +import { t } from '@grafana/i18n'; +import { Alert, Box, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui'; import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; import { Labels } from '../../../../../../types/unified-alerting-dto'; import { AlertManagerDataSource } from '../../../utils/datasource'; -import { NotificationRoute } from './NotificationRoute'; +import { ExternalContactPointGroup } from './ContactPointGroup'; +import { InstanceMatch } from './NotificationRoute'; import { useAlertmanagerNotificationRoutingPreview } from './useAlertmanagerNotificationRoutingPreview'; +const UNKNOWN_RECEIVER = 'unknown'; + function NotificationPreviewByAlertManager({ alertManagerSource, - potentialInstances, - onlyOneAM, + instances, }: { alertManagerSource: AlertManagerDataSource; - potentialInstances: Labels[]; - onlyOneAM: boolean; + instances: Labels[]; }) { - const styles = useStyles2(getStyles); - - const { routesByIdMap, receiversByName, matchingMap, loading, error } = useAlertmanagerNotificationRoutingPreview( + const { treeMatchingResults, isLoading, error } = useAlertmanagerNotificationRoutingPreview( alertManagerSource.name, - potentialInstances + instances ); if (error) { @@ -39,7 +37,7 @@ function NotificationPreviewByAlertManager({ ); } - if (loading) { + if (isLoading) { return ( 0; + const matchingPoliciesFound = treeMatchingResults.some((result) => result.matchedRoutes.length > 0); + + // Group results by receiver name + // We need to flatten the structure first to group by receiver + const flattenedResults = treeMatchingResults.flatMap(({ labels, matchedRoutes }) => { + return Array.from(matchedRoutes).map(({ route, routeTree, matchDetails }) => ({ + labels, + receiver: route.receiver || UNKNOWN_RECEIVER, + routeTree, + matchDetails, + })); + }); + + const contactPointGroups = groupBy(flattenedResults, 'receiver'); return matchingPoliciesFound ? ( -
- {!onlyOneAM && ( - -
-
- Alertmanager: - - {alertManagerSource.name} -
-
- - )} - - {Array.from(matchingMap.entries()).map(([routeId, instanceMatches]) => { - const route = routesByIdMap.get(routeId); - const receiver = route?.receiver && receiversByName.get(route.receiver); - - if (!route) { - return null; - } - return ( - - ); - })} + + + {Object.entries(contactPointGroups).map(([receiver, resultsForReceiver]) => ( + + + {resultsForReceiver.map(({ routeTree, matchDetails }) => ( + + ))} + + + ))} -
+ ) : null; } // export default because we want to load the component dynamically using React.lazy // Due to loading of the web worker we don't want to load this component when not necessary export default withErrorBoundary(NotificationPreviewByAlertManager); - -const getStyles = (theme: GrafanaTheme2) => ({ - alertManagerRow: css({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(1), - width: '100%', - }), - firstAlertManagerLine: css({ - height: '1px', - width: theme.spacing(4), - backgroundColor: theme.colors.secondary.main, - }), - alertManagerName: css({ - width: 'fit-content', - }), - secondAlertManagerLine: css({ - height: '1px', - width: '100%', - flex: 1, - backgroundColor: theme.colors.secondary.main, - }), - img: css({ - marginLeft: theme.spacing(2), - width: theme.spacing(3), - height: theme.spacing(3), - marginRight: theme.spacing(1), - }), -}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewGrafanaManaged.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewGrafanaManaged.tsx new file mode 100644 index 00000000000..2a5cc020996 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPreviewGrafanaManaged.tsx @@ -0,0 +1,90 @@ +import { groupBy } from 'lodash'; + +import { t } from '@grafana/i18n'; +import { Alert, Box, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui'; +import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; + +import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; +import { Labels } from '../../../../../../types/unified-alerting-dto'; +import { AlertManagerDataSource } from '../../../utils/datasource'; + +import { GrafanaContactPointGroup } from './ContactPointGroup'; +import { InstanceMatch } from './NotificationRoute'; +import { useAlertmanagerNotificationRoutingPreview } from './useAlertmanagerNotificationRoutingPreview'; + +const UNKNOWN_RECEIVER = 'unknown'; + +function NotificationPreviewGrafanaManaged({ + alertManagerSource, + instances, +}: { + alertManagerSource: AlertManagerDataSource; + instances: Labels[]; +}) { + const { treeMatchingResults, isLoading, error } = useAlertmanagerNotificationRoutingPreview( + alertManagerSource.name, + instances + ); + + if (error) { + const title = t('alerting.notification-preview.error', 'Could not load routing preview for {{alertmanager}}', { + alertmanager: alertManagerSource.name, + }); + return ( + + {stringifyErrorLike(error)} + + ); + } + + if (isLoading) { + return ( + + ); + } + + const matchingPoliciesFound = treeMatchingResults.some((result) => result.matchedRoutes.length > 0); + + // Group results by receiver name + // We need to flatten the structure first to group by receiver + const flattenedResults = treeMatchingResults.flatMap(({ labels, matchedRoutes }) => { + return Array.from(matchedRoutes).map(({ route, routeTree, matchDetails }) => ({ + labels, + receiver: route.receiver || UNKNOWN_RECEIVER, + routeTree, + matchDetails, + })); + }); + + const contactPointGroups = groupBy(flattenedResults, 'receiver'); + + return matchingPoliciesFound ? ( + + + {Object.entries(contactPointGroups).map(([receiver, resultsForReceiver]) => ( + + + {resultsForReceiver.map(({ routeTree, matchDetails }) => ( + + ))} + + + ))} + + + ) : null; +} + +// export default because we want to load the component dynamically using React.lazy +// Due to loading of the web worker we don't want to load this component when not necessary +export default withErrorBoundary(NotificationPreviewGrafanaManaged); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx index 421c25b2ec3..67bdce2d4d2 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx @@ -1,263 +1,66 @@ -import { css, cx } from '@emotion/css'; -import { uniqueId } from 'lodash'; -import pluralize from 'pluralize'; -import { useState } from 'react'; -import { useToggle } from 'react-use'; +import { css } from '@emotion/css'; +import { AlertLabels, RouteMatchResult, RouteWithID } from '@grafana/alerting/unstable'; import { GrafanaTheme2 } from '@grafana/data'; -import { Trans, t } from '@grafana/i18n'; -import { Button, TagList, getTagColorIndexFromName, useStyles2 } from '@grafana/ui'; +import { Trans } from '@grafana/i18n'; +import { Text, useStyles2 } from '@grafana/ui'; -import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack'; -import { getAmMatcherFormatter } from '../../../utils/alertmanager'; -import { AlertInstanceMatch } from '../../../utils/notification-policies'; -import { CollapseToggle } from '../../CollapseToggle'; -import { MetaText } from '../../MetaText'; +import { arrayLabelsToObject } from '../../../utils/labels'; import { Spacer } from '../../Spacer'; -import { NotificationPolicyMatchers } from './NotificationPolicyMatchers'; -import { NotificationRouteDetailsModal } from './NotificationRouteDetailsModal'; -import UnknownContactPointDetails from './UnknownContactPointDetails'; -import { RouteWithPath } from './route'; +import { NotificationPolicyDrawer } from './NotificationPolicyDrawer'; -export interface ReceiverNameProps { - /** Receiver name taken from route definition. Used as a fallback when full receiver details cannot be found (in case of RBAC restrictions) */ - receiverNameFromRoute?: string; -} +type TreeMeta = { + name?: string; +}; -interface NotificationRouteHeaderProps extends ReceiverNameProps { - route: RouteWithPath; - receiver?: Receiver; - routesByIdMap: Map; - instancesCount: number; - alertManagerSourceName: string; - expandRoute: boolean; - onExpandRouteClick: (expand: boolean) => void; -} +type InstanceMatchProps = { + matchedInstance: RouteMatchResult; + policyTreeSpec: RouteWithID; + policyTreeMetadata: TreeMeta; +}; -function NotificationRouteHeader({ - route, - receiver, - receiverNameFromRoute, - routesByIdMap, - instancesCount, - alertManagerSourceName, - expandRoute, - onExpandRouteClick, -}: NotificationRouteHeaderProps) { +export function InstanceMatch({ matchedInstance, policyTreeSpec, policyTreeMetadata }: InstanceMatchProps) { const styles = useStyles2(getStyles); - const [showDetails, setShowDetails] = useState(false); - const onClickDetails = () => { - setShowDetails(true); - }; + const { labels, matchingJourney, route } = matchedInstance; - // @TODO: re-use component ContactPointsHoverDetails from Policy once we have it for cloud AMs. - - return ( -
- onExpandRouteClick(!isCollapsed)} - aria-label={t('alerting.notification-route-header.aria-label-expand-policy-route', 'Expand policy route')} - /> - - - {/* TODO: fix keyboard a11y */} - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} -
onExpandRouteClick(!expandRoute)} className={styles.expandable}> - - Notification policy - - -
- - - - {instancesCount ?? '-'} {pluralize('instance', instancesCount)} - - -
- - @ Delivered to - {' '} - {receiver ? receiver.name : } -
- -
- - - - - - {showDetails && ( - setShowDetails(false)} - route={route} - receiver={receiver} - receiverNameFromRoute={receiverNameFromRoute} - routesByIdMap={routesByIdMap} - alertManagerSourceName={alertManagerSourceName} - /> - )} -
+ // Get all match details from the final matched route in the journey + const finalRouteMatchInfo = matchingJourney.at(-1); + const routeMatchLabels = arrayLabelsToObject( + finalRouteMatchInfo?.matchDetails.map((detail) => labels[detail.labelIndex]) ?? [] ); -} - -interface NotificationRouteProps extends ReceiverNameProps { - route: RouteWithPath; - receiver?: Receiver; - instanceMatches: AlertInstanceMatch[]; - routesByIdMap: Map; - alertManagerSourceName: string; -} - -export function NotificationRoute({ - route, - instanceMatches, - receiver, - receiverNameFromRoute, - routesByIdMap, - alertManagerSourceName, -}: NotificationRouteProps) { - const styles = useStyles2(getStyles); - const [expandRoute, setExpandRoute] = useToggle(false); - // @TODO: The color index might be updated at some point in the future.Maybe we should roll our own tag component, - // one that supports a custom function to define the color and allow manual color overrides - const GREY_COLOR_INDEX = 9; + const matchedRootRoute = route.id === policyTreeSpec.id; return ( -
- - {expandRoute && ( - -
- {instanceMatches.map((instanceMatch) => { - const matchArray = Array.from(instanceMatch.labelsMatch); - const matchResult = matchArray.map(([label, matchResult]) => ({ - label: `${label[0]}=${label[1]}`, - match: matchResult.match, - colorIndex: matchResult.match ? getTagColorIndexFromName(label[0]) : GREY_COLOR_INDEX, - })); - - const matchingLabels = matchResult.filter((mr) => mr.match); - const nonMatchingLabels = matchResult.filter((mr) => !mr.match); - - return ( -
- {matchArray.length > 0 ? ( - <> - {matchingLabels.length > 0 ? ( - mr.label)} - className={styles.labelList} - getColorIndex={(_, index) => matchingLabels[index].colorIndex} - /> - ) : ( -
- No matching labels -
- )} -
- mr.label)} - className={styles.labelList} - getColorIndex={(_, index) => nonMatchingLabels[index].colorIndex} - /> - - ) : ( -
- No labels -
- )} -
- ); - })} -
- - )} +
+ + {labels.length > 0 ? ( + + ) : ( + + No labels + + )} + + +
); } const getStyles = (theme: GrafanaTheme2) => ({ - textMuted: css({ - color: theme.colors.text.secondary, - }), - textItalic: css({ - fontStyle: 'italic', - }), - expandable: css({ - cursor: 'pointer', - }), - routeHeader: css({ - display: 'flex', - flexDirection: 'row', - gap: theme.spacing(1), - alignItems: 'center', - borderBottom: `1px solid ${theme.colors.border.weak}`, - padding: theme.spacing(0.5, 0.5, 0.5, 0), + instanceListItem: css({ + padding: theme.spacing(1, 2), + '&:hover': { backgroundColor: theme.components.table.rowHoverBackground, }, }), - labelList: css({ - flex: '0 1 auto', - justifyContent: 'flex-start', - }), - labelSeparator: css({ - width: '1px', - backgroundColor: theme.colors.border.weak, - }), - tagListCard: css({ - display: 'flex', - flexDirection: 'row', - gap: theme.spacing(2), - - position: 'relative', - background: theme.colors.background.secondary, - padding: theme.spacing(1), - - borderRadius: theme.shape.borderRadius(2), - border: `solid 1px ${theme.colors.border.weak}`, - }), - routeInstances: css({ - padding: theme.spacing(1, 0, 1, 4), - position: 'relative', - - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(1), - - '&:before': { - content: '""', - position: 'absolute', - left: theme.spacing(2), - height: `calc(100% - ${theme.spacing(2)})`, - width: theme.spacing(4), - borderLeft: `solid 1px ${theme.colors.border.weak}`, - }, - }), - verticalBar: css({ - width: '1px', - height: '20px', - backgroundColor: theme.colors.secondary.main, - marginLeft: theme.spacing(1), - marginRight: theme.spacing(1), - }), }); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx deleted file mode 100644 index 74394a57677..00000000000 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { css, cx } from '@emotion/css'; -import { compact } from 'lodash'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Trans, t } from '@grafana/i18n'; -import { Button, Modal, Stack, TextLink, useStyles2 } from '@grafana/ui'; - -import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; -import { AlertmanagerAction } from '../../../hooks/useAbilities'; -import { AlertmanagerProvider } from '../../../state/AlertmanagerContext'; -import { getAmMatcherFormatter } from '../../../utils/alertmanager'; -import { MatcherFormatter } from '../../../utils/matchers'; -import { createContactPointSearchLink } from '../../../utils/misc'; -import { Authorize } from '../../Authorize'; -import { Matchers } from '../../notification-policies/Matchers'; - -import { ReceiverNameProps } from './NotificationRoute'; -import UnknownContactPointDetails from './UnknownContactPointDetails'; -import { RouteWithPath, hasEmptyMatchers, isDefaultPolicy } from './route'; - -interface Props { - routesByIdMap: Map; - route: RouteWithPath; - matcherFormatter: MatcherFormatter; -} - -function PolicyPath({ route, routesByIdMap, matcherFormatter }: Props) { - const styles = useStyles2(getStyles); - const routePathIds = route.path?.slice(1) ?? []; - const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route]; - - return ( -
-
- Default policy -
- {routePathObjects.map((pathRoute, index) => { - return ( -
-
- {hasEmptyMatchers(pathRoute) ? ( -
- No matchers -
- ) : ( - - )} -
-
- ); - })} -
- ); -} - -interface NotificationRouteDetailsModalProps extends ReceiverNameProps { - onClose: () => void; - route: RouteWithPath; - receiver?: Receiver; - routesByIdMap: Map; - alertManagerSourceName: string; -} - -export function NotificationRouteDetailsModal({ - onClose, - route, - receiver, - receiverNameFromRoute, - routesByIdMap, - alertManagerSourceName, -}: NotificationRouteDetailsModalProps) { - const styles = useStyles2(getStyles); - - const isDefault = isDefaultPolicy(route); - - return ( - - - -
- - Your alert instances are routed as follows. - -
-
- - Notification policy path - -
- {isDefault && ( -
- Default policy -
- )} -
- {!isDefault && ( - - )} -
-
- - Contact point - - - {receiver ? receiver.name : } - - - - - {receiver ? ( - - See details - - ) : null} - - -
-
- -
- - - - ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - textMuted: css({ - color: theme.colors.text.secondary, - }), - link: css({ - display: 'block', - color: theme.colors.text.link, - }), - button: css({ - justifyContent: 'flex-end', - display: 'flex', - }), - detailsModal: css({ - maxWidth: '560px', - }), - defaultPolicy: css({ - padding: theme.spacing(0.5), - background: theme.colors.background.secondary, - width: 'fit-content', - }), - contactPoint: css({ - display: 'flex', - flexDirection: 'row', - gap: theme.spacing(1), - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: theme.spacing(1), - }), - policyPathWrapper: css({ - display: 'flex', - flexDirection: 'column', - marginTop: theme.spacing(1), - }), - separator: (units: number) => - css({ - marginTop: theme.spacing(units), - }), - marginBottom: (units: number) => - css({ - marginBottom: theme.spacing(theme.spacing(units)), - }), - policyInPath: (index = 0, highlight = false) => - css({ - marginLeft: `${30 + index * 30}px`, - padding: theme.spacing(1), - marginTop: theme.spacing(1), - border: `solid 1px ${highlight ? theme.colors.info.border : theme.colors.border.weak}`, - background: theme.colors.background.secondary, - width: 'fit-content', - position: 'relative', - '&:before': { - content: '""', - position: 'absolute', - height: 'calc(100% - 10px)', - width: theme.spacing(1), - borderLeft: `solid 1px ${theme.colors.border.weak}`, - borderBottom: `solid 1px ${theme.colors.border.weak}`, - marginTop: theme.spacing(-2), - marginLeft: `-17px`, - }, - }), -}); diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/route.ts b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/route.ts deleted file mode 100644 index 8e2647400be..00000000000 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { RouteWithID } from '../../../../../../plugins/datasource/alertmanager/types'; - -export interface RouteWithPath extends RouteWithID { - path: string[]; // path from root route to this route -} - -export function isDefaultPolicy(route: RouteWithPath) { - return route.path?.length === 0; -} - -// we traverse the whole tree and we create a map with -export function getRoutesByIdMap(rootRoute: RouteWithID): Map { - const map = new Map(); - - function addRoutesToMap(route: RouteWithID, path: string[] = []) { - map.set(route.id, { ...route, path: path }); - route.routes?.forEach((r) => addRoutesToMap(r, [...path, route.id])); - } - - addRoutesToMap(rootRoute, []); - return map; -} - -export function hasEmptyMatchers(route: RouteWithID) { - return route.object_matchers?.length === 0; -} diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts index aeec03ffea1..2f95fe40d52 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts @@ -1,36 +1,23 @@ import { useMemo } from 'react'; import { useAsync } from 'react-use'; -import { useContactPointsWithStatus } from 'app/features/alerting/unified/components/contact-points/useContactPoints'; import { useNotificationPolicyRoute } from 'app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute'; -import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types'; import { Labels } from '../../../../../../types/unified-alerting-dto'; import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher'; import { addUniqueIdentifierToRoute } from '../../../utils/amroutes'; import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; -import { AlertInstanceMatch, computeInheritedTree, normalizeRoute } from '../../../utils/notification-policies'; +import { normalizeRoute } from '../../../utils/notification-policies'; -import { RouteWithPath, getRoutesByIdMap } from './route'; - -export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string, potentialInstances: Labels[]) => { +export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string, instances: Labels[]) => { const { data: currentData, isLoading: isPoliciesLoading, error: policiesError, } = useNotificationPolicyRoute({ alertmanager }); - const { - contactPoints, - isLoading: contactPointsLoading, - error: contactPointsError, - } = useContactPointsWithStatus({ - alertmanager, - fetchPolicies: false, - fetchStatuses: false, - }); - - const { matchInstancesToRoute } = useRouteGroupsMatcher(); + // this function will use a web worker to compute matching routes + const { matchInstancesToRoutes } = useRouteGroupsMatcher(); const [defaultPolicy] = currentData ?? []; const rootRoute = useMemo(() => { @@ -40,27 +27,9 @@ export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string, return normalizeRoute(addUniqueIdentifierToRoute(defaultPolicy)); }, [defaultPolicy]); - // create maps for routes to be get by id, this map also contains the path to the route - // ⚠️ don't forget to compute the inherited tree before using this map - const routesByIdMap = rootRoute - ? getRoutesByIdMap(computeInheritedTree(rootRoute)) - : new Map(); - - // to create the list of matching contact points we need to first get the rootRoute - const receiversByName = useMemo(() => { - if (!contactPoints) { - return new Map(); - } - - // create map for receivers to be get by name - return contactPoints.reduce((map, receiver) => { - return map.set(receiver.name, receiver); - }, new Map()); - }, [contactPoints]); - // match labels in the tree => map of notification policies and the alert instances (list of labels) in each one const { - value: matchingMap = new Map(), + value: treeMatchingResults = [], loading: matchingLoading, error: matchingError, } = useAsync(async () => { @@ -68,16 +37,14 @@ export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string, return; } - return await matchInstancesToRoute(rootRoute, potentialInstances, { + return await matchInstancesToRoutes(rootRoute, instances, { unquoteMatchers: alertmanager !== GRAFANA_RULES_SOURCE_NAME, }); - }, [rootRoute, potentialInstances]); + }, [rootRoute, instances]); return { - routesByIdMap, - receiversByName, - matchingMap, - loading: isPoliciesLoading || contactPointsLoading || matchingLoading, - error: policiesError ?? contactPointsError ?? matchingError, + treeMatchingResults, + isLoading: isPoliciesLoading || matchingLoading, + error: policiesError ?? matchingError, }; }; diff --git a/public/app/features/alerting/unified/home/AdCard.tsx b/public/app/features/alerting/unified/home/AdCard.tsx index 01577b83150..f70efc67b87 100644 --- a/public/app/features/alerting/unified/home/AdCard.tsx +++ b/public/app/features/alerting/unified/home/AdCard.tsx @@ -133,7 +133,7 @@ const getAddCardStyles = (theme: GrafanaTheme2) => ({ cardBody: css({ padding: `${theme.spacing(3)} ${theme.spacing(4)} ${theme.spacing(2.25)} ${theme.spacing(4)}`, backgroundColor: theme.colors.background.secondary, - borderRadius: theme.shape.radius.default, + borderRadius: theme.shape.radius.lg, border: `1px solid ${theme.colors.border.weak}`, flex: 1, }), diff --git a/public/app/features/alerting/unified/home/GettingStarted.tsx b/public/app/features/alerting/unified/home/GettingStarted.tsx index b45fe9d764a..e25e3da07e8 100644 --- a/public/app/features/alerting/unified/home/GettingStarted.tsx +++ b/public/app/features/alerting/unified/home/GettingStarted.tsx @@ -241,6 +241,6 @@ const getContentBoxStyles = (theme: GrafanaTheme2) => ({ box: css({ padding: theme.spacing(2), backgroundColor: theme.colors.background.secondary, - borderRadius: theme.shape.radius.default, + borderRadius: theme.shape.radius.lg, }), }); diff --git a/public/app/features/alerting/unified/routeGroupsMatcher.ts b/public/app/features/alerting/unified/routeGroupsMatcher.ts index bce330c7f78..61b74cea8ff 100644 --- a/public/app/features/alerting/unified/routeGroupsMatcher.ts +++ b/public/app/features/alerting/unified/routeGroupsMatcher.ts @@ -1,13 +1,10 @@ +import { InstanceMatchResult, matchInstancesToRoute } from '@grafana/alerting/unstable'; + import { AlertmanagerGroup, RouteWithID } from '../../../plugins/datasource/alertmanager/types'; import { Labels } from '../../../types/unified-alerting-dto'; -import { - AlertInstanceMatch, - findMatchingAlertGroups, - findMatchingRoutes, - normalizeRoute, - unquoteRouteMatchers, -} from './utils/notification-policies'; +import { findMatchingAlertGroups, normalizeRoute, unquoteRouteMatchers } from './utils/notification-policies'; +import { routeAdapter } from './utils/routeAdapter'; export interface MatchOptions { unquoteMatchers?: boolean; @@ -34,29 +31,39 @@ export const routeGroupsMatcher = { return routeGroupsMap; }, - matchInstancesToRoute( - routeTree: RouteWithID, - instancesToMatch: Labels[], - options?: MatchOptions - ): Map { - const result = new Map(); + matchInstancesToRoutes(routeTree: RouteWithID, instances: Labels[], options?: MatchOptions): InstanceMatchResult[] { + const normalizedRouteTree = getNormalizedRoute(routeTree, options); - const normalizedRootRoute = getNormalizedRoute(routeTree, options); + // Convert all instances to labels format and match them all at once + const allLabels = instances.map((instance) => Object.entries(instance)); - instancesToMatch.forEach((instance) => { - const matchingRoutes = findMatchingRoutes(normalizedRootRoute, Object.entries(instance)); - matchingRoutes.forEach(({ route, labelsMatch }) => { - const currentRoute = result.get(route.id); + // Convert the RouteWithID to the alerting package format to ensure compatibility + const convertedRoute = routeAdapter.toPackage(normalizedRouteTree); + const { expandedTree, matchedPolicies } = matchInstancesToRoute(convertedRoute, allLabels); - if (currentRoute) { - currentRoute.push({ instance, labelsMatch }); - } else { - result.set(route.id, [{ instance, labelsMatch }]); - } - }); + // Group results by instance + return instances.map((instance, index) => { + const labels = allLabels[index]; + + // Collect matches for this specific instance + const allMatchedRoutes = Array.from(matchedPolicies.entries()).flatMap(([route, results]) => + results + .filter((matchDetails) => matchDetails.labels === labels) + .map((matchDetails) => ({ + route, + routeTree: { + metadata: { name: 'user-defined' }, + expandedSpec: expandedTree, + }, + matchDetails, + })) + ); + + return { + labels, + matchedRoutes: allMatchedRoutes, + }; }); - - return result; }, }; diff --git a/public/app/features/alerting/unified/rule-editor/CloneRuleEditor.test.tsx b/public/app/features/alerting/unified/rule-editor/CloneRuleEditor.test.tsx index 8bd3b28e933..8b31e3a946a 100644 --- a/public/app/features/alerting/unified/rule-editor/CloneRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/CloneRuleEditor.test.tsx @@ -24,7 +24,7 @@ import { mockRulerGrafanaRule, mockRulerRuleGroup, } from '../mocks'; -import { grafanaRulerRule } from '../mocks/grafanaRulerApi'; +import { grafanaRulerRule, mockPreviewApiResponse } from '../mocks/grafanaRulerApi'; import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from '../mocks/rulerApi'; import { setFolderResponse } from '../mocks/server/configure'; import { AlertingQueryRunner } from '../state/AlertingQueryRunner'; @@ -103,6 +103,7 @@ describe('CloneRuleEditor', function () { }; setupDataSources(dataSources.default); setFolderResponse(mockFolder(folder)); + mockPreviewApiResponse(server, []); }); describe('Grafana-managed rules', function () { diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorExisting.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorExisting.test.tsx index eab7a703499..073fca99df5 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorExisting.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorExisting.test.tsx @@ -10,7 +10,7 @@ import { AccessControlAction } from 'app/types/accessControl'; import { setupMswServer } from '../mockApi'; import { grantUserPermissions, mockDataSource, mockFolder } from '../mocks'; -import { grafanaRulerRule } from '../mocks/grafanaRulerApi'; +import { grafanaRulerRule, mockPreviewApiResponse } from '../mocks/grafanaRulerApi'; import { MIMIR_DATASOURCE_UID } from '../mocks/server/constants'; import { setupDataSources } from '../testSetup/datasources'; import { Annotation } from '../utils/constants'; @@ -23,7 +23,7 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ jest.setTimeout(60 * 1000); -setupMswServer(); +const server = setupMswServer(); function renderRuleEditor(identifier: string) { return render( @@ -85,6 +85,7 @@ describe('RuleEditor grafana managed rules', () => { setupDataSources(dataSources.default); setFolderResponse(mockFolder(folder)); setFolderResponse(mockFolder(slashedFolder)); + mockPreviewApiResponse(server, []); }); it('can edit grafana managed rule', async () => { diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx index 1e8d537b3b7..d5ed0d39b6b 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx @@ -12,7 +12,12 @@ import { DashboardSearchItemType } from 'app/features/search/types'; import { AccessControlAction } from 'app/types/accessControl'; import { grantUserPermissions, mockDataSource, mockFolder } from '../mocks'; -import { grafanaRulerGroup, grafanaRulerGroup2, grafanaRulerRule } from '../mocks/grafanaRulerApi'; +import { + grafanaRulerGroup, + grafanaRulerGroup2, + grafanaRulerRule, + mockPreviewApiResponse, +} from '../mocks/grafanaRulerApi'; import { setFolderResponse } from '../mocks/server/configure'; import { captureRequests, serializeRequests } from '../mocks/server/events'; import { setupDataSources } from '../testSetup/datasources'; @@ -25,7 +30,7 @@ jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({ jest.setTimeout(60 * 1000); -setupMswServer(); +const server = setupMswServer(); const dataSources = { default: mockDataSource( @@ -62,6 +67,8 @@ describe('RuleEditor grafana managed rules', () => { AccessControlAction.AlertingRuleExternalRead, AccessControlAction.AlertingRuleExternalWrite, ]); + + mockPreviewApiResponse(server, []); }); it('can create new grafana managed alert', async () => { diff --git a/public/app/features/alerting/unified/styles/table.ts b/public/app/features/alerting/unified/styles/table.ts index 82bde70af85..7594b36e5b8 100644 --- a/public/app/features/alerting/unified/styles/table.ts +++ b/public/app/features/alerting/unified/styles/table.ts @@ -8,6 +8,7 @@ export const getAlertTableStyles = (theme: GrafanaTheme2) => ({ borderRadius: theme.shape.radius.default, border: `solid 1px ${theme.colors.border.weak}`, backgroundColor: theme.colors.background.secondary, + overflow: 'hidden', th: { padding: theme.spacing(1), diff --git a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts index 421afe78bde..3a157b66534 100644 --- a/public/app/features/alerting/unified/useRouteGroupsMatcher.ts +++ b/public/app/features/alerting/unified/useRouteGroupsMatcher.ts @@ -75,19 +75,19 @@ export function useRouteGroupsMatcher() { [] ); - const matchInstancesToRoute = useCallback( - async (rootRoute: RouteWithID, instancesToMatch: Labels[], options?: MatchOptions) => { + const matchInstancesToRoutes = useCallback( + async (rootRoute: RouteWithID, instances: Labels[], options?: MatchOptions) => { validateWorker(routeMatcher); const startTime = performance.now(); - const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch, options); + const result = await routeMatcher.matchInstancesToRoutes(rootRoute, instances, options); const timeSpent = performance.now() - startTime; logInfo(`Instances Matched in ${timeSpent} ms`, { matchingTime: timeSpent.toString(), - instancesToMatchCount: instancesToMatch.length.toString(), + instancesToMatchCount: instances.length.toString(), // Counting all nested routes might be too time-consuming, so we only count the first level topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0', }); @@ -97,5 +97,5 @@ export function useRouteGroupsMatcher() { [] ); - return { getRouteGroupsMap, matchInstancesToRoute }; + return { getRouteGroupsMap, matchInstancesToRoutes }; } diff --git a/public/app/features/alerting/unified/utils/__snapshots__/notification-policies.test.ts.snap b/public/app/features/alerting/unified/utils/__snapshots__/notification-policies.test.ts.snap deleted file mode 100644 index 891dc986133..00000000000 --- a/public/app/features/alerting/unified/utils/__snapshots__/notification-policies.test.ts.snap +++ /dev/null @@ -1,56 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`matchLabels should match with non-equal matchers 1`] = ` -Map { - [ - "team", - "operations", - ] => { - "match": true, - "matcher": [ - "team", - "=", - "operations", - ], - }, -} -`; - -exports[`matchLabels should match with non-matching matchers 1`] = ` -Map { - [ - "team", - "operations", - ] => { - "match": true, - "matcher": [ - "team", - "=", - "operations", - ], - }, -} -`; - -exports[`matchLabels should not match with a set of matchers 1`] = ` -Map { - [ - "team", - "operations", - ] => { - "match": true, - "matcher": [ - "team", - "=", - "operations", - ], - }, - [ - "foo", - "bar", - ] => { - "match": false, - "matcher": null, - }, -} -`; diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index 7b2e4f163b7..b7b3f28576a 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -1,5 +1,6 @@ import { isEqual, uniqWith } from 'lodash'; +import { matchLabelsSet } from '@grafana/alerting/unstable'; import { SelectableValue } from '@grafana/data'; import { AlertManagerCortexConfig, @@ -17,7 +18,12 @@ import { MatcherFieldValue } from '../types/silence-form'; import { getAllDataSources } from './config'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { objectLabelsToArray } from './labels'; -import { MatcherFormatter, matchLabelsSet, parsePromQLStyleMatcherLooseSafe, unquoteWithUnescape } from './matchers'; +import { + MatcherFormatter, + convertObjectMatcherToAlertingPackageMatcher, + parsePromQLStyleMatcherLooseSafe, + unquoteWithUnescape, +} from './matchers'; export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig { // add default receiver if it does not exist @@ -127,9 +133,9 @@ export function matcherToObjectMatcher(matcher: Matcher): ObjectMatcher { export function labelsMatchMatchers(labels: Labels, matchers: Matcher[]): boolean { const labelsArray = objectLabelsToArray(labels); - const objectMatchers = matchers.map(matcherToObjectMatcher); + const labelMatchers = matchers.map(matcherToObjectMatcher).map(convertObjectMatcherToAlertingPackageMatcher); - return matchLabelsSet(objectMatchers, labelsArray); + return matchLabelsSet(labelMatchers, labelsArray); } export function combineMatcherStrings(...matcherStrings: string[]): string { diff --git a/public/app/features/alerting/unified/utils/k8s/utils.ts b/public/app/features/alerting/unified/utils/k8s/utils.ts index 1a99f52b522..e75818cde66 100644 --- a/public/app/features/alerting/unified/utils/k8s/utils.ts +++ b/public/app/features/alerting/unified/utils/k8s/utils.ts @@ -49,3 +49,8 @@ export const canDeleteEntity = (k8sEntity: EntityToCheck) => export const encodeFieldSelector = (value: string): string => { return value.replaceAll(/\\/g, '\\\\').replaceAll(/\=/g, '\\=').replaceAll(/,/g, '\\,'); }; + +type FieldSelector = [string, string] | [string, string, '=' | '!=']; +export const stringifyFieldSelector = (fieldSelectors: FieldSelector[]): string => { + return fieldSelectors.map(([key, value, operator = '=']) => `${key}${operator}${value}`).join(','); +}; diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index 32e0f08555a..2103ee71eb4 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -7,7 +7,7 @@ import { chain, compact } from 'lodash'; -import { parseFlags } from '@grafana/data'; +import { type LabelMatcher } from '@grafana/alerting/unstable'; import { Matcher, MatcherOperator, ObjectMatcher, Route } from 'app/plugins/datasource/alertmanager/types'; import { Labels } from '../../../../types/unified-alerting-dto'; @@ -228,6 +228,8 @@ export const matcherFormatter = { }, } as const; +export type MatcherFormatter = keyof typeof matcherFormatter; + export function isPromQLStyleMatcher(input: string): boolean { return input.startsWith('{') && input.endsWith('}'); } @@ -251,81 +253,12 @@ function matcherToOperator(matcher: Matcher): MatcherOperator { } } -// Compare set of matchers to set of label -export function matchLabelsSet(matchers: ObjectMatcher[], labels: Label[]): boolean { - for (const matcher of matchers) { - if (!isLabelMatchInSet(matcher, labels)) { - return false; - } - } - return true; +export function convertObjectMatcherToAlertingPackageMatcher(matcher: ObjectMatcher): LabelMatcher { + const [label, type, value] = matcher; + + return { + label, + type, + value, + }; } - -type OperatorPredicate = (labelValue: string, matcherValue: string) => boolean; -const OperatorFunctions: Record = { - [MatcherOperator.equal]: (lv, mv) => lv === mv, - [MatcherOperator.notEqual]: (lv, mv) => lv !== mv, - // At the time of writing, Alertmanager compiles to another (anchored) Regular Expression, - // so we should also anchor our UI matches for consistency with this behaviour - // https://github.com/prometheus/alertmanager/blob/fd37ce9c95898ca68be1ab4d4529517174b73c33/pkg/labels/matcher.go#L69 - [MatcherOperator.regex]: (lv, mv) => { - const valueWithFlagsParsed = parseFlags(`^(?:${mv})$`); - const re = new RegExp(valueWithFlagsParsed.cleaned, valueWithFlagsParsed.flags); - return re.test(lv); - }, - [MatcherOperator.notRegex]: (lv, mv) => { - const valueWithFlagsParsed = parseFlags(`^(?:${mv})$`); - const re = new RegExp(valueWithFlagsParsed.cleaned, valueWithFlagsParsed.flags); - return !re.test(lv); - }, -}; - -function isLabelMatchInSet(matcher: ObjectMatcher, labels: Label[]): boolean { - const [matcherKey, operator, matcherValue] = matcher; - - let labelValue = ''; // matchers that have no labels are treated as empty string label values - const labelForMatcher = Object.fromEntries(labels)[matcherKey]; - if (labelForMatcher) { - labelValue = labelForMatcher; - } - - const matchFunction = OperatorFunctions[operator]; - if (!matchFunction) { - throw new Error(`no such operator: ${operator}`); - } - - try { - // This can throw because the regex operators use the JavaScript regex engine - // and "new RegExp()" throws on invalid regular expressions. - // - // This is usually a user-error (because matcher values are taken from user input) - // but we're still logging this as a warning because it _might_ be a programmer error. - return matchFunction(labelValue, matcherValue); - } catch (err) { - console.warn(err); - return false; - } -} - -// ⚠️ DO NOT USE THIS FUNCTION FOR ROUTE SELECTION ALGORITHM -// for route selection algorithm, always compare a single matcher to the entire label set -// see "matchLabelsSet" -export function isLabelMatch(matcher: ObjectMatcher, label: Label): boolean { - const [labelKey, labelValue] = label; - const [matcherKey, operator, matcherValue] = matcher; - - if (labelKey !== matcherKey) { - return false; - } - - const matchFunction = OperatorFunctions[operator]; - if (!matchFunction) { - throw new Error(`no such operator: ${operator}`); - } - - return matchFunction(labelValue, matcherValue); -} - -export type MatcherFormatter = keyof typeof matcherFormatter; - -export type Label = [string, string]; diff --git a/public/app/features/alerting/unified/utils/navigation.ts b/public/app/features/alerting/unified/utils/navigation.ts index 259207f41b7..a77828af2c6 100644 --- a/public/app/features/alerting/unified/utils/navigation.ts +++ b/public/app/features/alerting/unified/utils/navigation.ts @@ -1,3 +1,4 @@ +import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types'; import { RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting'; import { createReturnTo } from '../hooks/useReturnTo'; @@ -89,3 +90,12 @@ export const rulesNav = { { skipSubPath: options?.skipSubPath } ), }; + +export const notificationPolicies = { + viewLink: (matchers: ObjectMatcher[], alertmanagerSourceName?: string) => { + return createRelativeUrl('/alerting/routes', { + queryString: matchers.map((matcher) => matcher.join('')).join(','), + alertmanager: alertmanagerSourceName ?? 'grafana', + }); + }, +}; diff --git a/public/app/features/alerting/unified/utils/notification-policies.test.ts b/public/app/features/alerting/unified/utils/notification-policies.test.ts index d6b83406edb..05e19092012 100644 --- a/public/app/features/alerting/unified/utils/notification-policies.test.ts +++ b/public/app/features/alerting/unified/utils/notification-policies.test.ts @@ -1,399 +1,6 @@ -import { MatcherOperator, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; +import { MatcherOperator, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; -import { - InheritableProperties, - computeInheritedTree, - findMatchingRoutes, - getInheritedProperties, - matchLabels, - normalizeRoute, - unquoteRouteMatchers, -} from './notification-policies'; - -const CATCH_ALL_ROUTE: Route = { - receiver: 'ALL', - object_matchers: [], -}; - -describe('findMatchingRoutes', () => { - const policies: Route = { - receiver: 'ROOT', - group_by: ['grafana_folder'], - object_matchers: [], - routes: [ - { - receiver: 'A', - object_matchers: [['team', MatcherOperator.equal, 'operations']], - routes: [ - { - receiver: 'B1', - object_matchers: [['region', MatcherOperator.equal, 'europe']], - }, - { - receiver: 'B2', - object_matchers: [['region', MatcherOperator.equal, 'nasa']], - }, - ], - }, - { - receiver: 'C', - object_matchers: [['foo', MatcherOperator.equal, 'bar']], - }, - ], - group_wait: '10s', - group_interval: '1m', - }; - - it('should match root route with no matching labels', () => { - const matches = findMatchingRoutes(policies, []); - expect(matches).toHaveLength(1); - expect(matches[0].route).toHaveProperty('receiver', 'ROOT'); - }); - - it('should match parent route with no matching children', () => { - const matches = findMatchingRoutes(policies, [['team', 'operations']]); - expect(matches).toHaveLength(1); - expect(matches[0].route).toHaveProperty('receiver', 'A'); - }); - - it('should match route with negative matchers', () => { - const policiesWithNegative = { - ...policies, - routes: policies.routes?.concat({ - receiver: 'D', - object_matchers: [['name', MatcherOperator.notEqual, 'gilles']], - }), - }; - const matches = findMatchingRoutes(policiesWithNegative, [['name', 'konrad']]); - expect(matches).toHaveLength(1); - expect(matches[0].route).toHaveProperty('receiver', 'D'); - }); - - it('should match child route of matching parent', () => { - const matches = findMatchingRoutes(policies, [ - ['team', 'operations'], - ['region', 'europe'], - ]); - expect(matches).toHaveLength(1); - expect(matches[0].route).toHaveProperty('receiver', 'B1'); - }); - - it('should match simple policy', () => { - const matches = findMatchingRoutes(policies, [['foo', 'bar']]); - expect(matches).toHaveLength(1); - expect(matches[0].route).toHaveProperty('receiver', 'C'); - }); - - it('should match catch-all route', () => { - const policiesWithAll: Route = { - ...policies, - routes: [CATCH_ALL_ROUTE, ...(policies.routes ?? [])], - }; - - const matches = findMatchingRoutes(policiesWithAll, []); - expect(matches).toHaveLength(1); - expect(matches[0].route).toHaveProperty('receiver', 'ALL'); - }); - - it('should match multiple routes with continue', () => { - const policiesWithAll: Route = { - ...policies, - routes: [ - { - ...CATCH_ALL_ROUTE, - continue: true, - }, - ...(policies.routes ?? []), - ], - }; - - const matches = findMatchingRoutes(policiesWithAll, [['foo', 'bar']]); - expect(matches).toHaveLength(2); - expect(matches[0].route).toHaveProperty('receiver', 'ALL'); - expect(matches[1].route).toHaveProperty('receiver', 'C'); - }); - - it('should not match grandchild routes with same labels as parent', () => { - const policies: Route = { - receiver: 'PARENT', - group_by: ['grafana_folder'], - object_matchers: [['foo', MatcherOperator.equal, 'bar']], - routes: [ - { - receiver: 'CHILD', - object_matchers: [['baz', MatcherOperator.equal, 'qux']], - routes: [ - { - receiver: 'GRANDCHILD', - object_matchers: [['foo', MatcherOperator.equal, 'bar']], - }, - ], - }, - ], - group_wait: '10s', - group_interval: '1m', - }; - - const matches = findMatchingRoutes(policies, [['foo', 'bar']]); - expect(matches).toHaveLength(1); - expect(matches[0].route).toHaveProperty('receiver', 'PARENT'); - }); -}); - -describe('getInheritedProperties()', () => { - describe('group_by: []', () => { - it('should get group_by: [] from parent', () => { - const parent: Route = { - receiver: 'PARENT', - group_by: ['label'], - }; - - const child: Route = { - receiver: 'CHILD', - group_by: [], - }; - - const childInherited = getInheritedProperties(parent, child); - expect(childInherited).toHaveProperty('group_by', ['label']); - }); - - it('should get group_by: [] from parent inherited properties', () => { - const parent: Route = { - receiver: 'PARENT', - group_by: [], - }; - - const child: Route = { - receiver: 'CHILD', - group_by: [], - }; - - const parentInherited = { group_by: ['label'] }; - - const childInherited = getInheritedProperties(parent, child, parentInherited); - expect(childInherited).toHaveProperty('group_by', ['label']); - }); - - it('should not inherit if the child overrides an inheritable value (group_by)', () => { - const parent: Route = { - receiver: 'PARENT', - group_by: ['parentLabel'], - }; - - const child: Route = { - receiver: 'CHILD', - group_by: ['childLabel'], - }; - - const childInherited = getInheritedProperties(parent, child); - expect(childInherited).not.toHaveProperty('group_by'); - }); - - it('should inherit if group_by is undefined', () => { - const parent: Route = { - receiver: 'PARENT', - group_by: ['label'], - }; - - const child: Route = { - receiver: 'CHILD', - group_by: undefined, - }; - - const childInherited = getInheritedProperties(parent, child); - expect(childInherited).toHaveProperty('group_by', ['label']); - }); - - it('should inherit from grandparent when parent is inheriting', () => { - const parentInheritedProperties: InheritableProperties = { receiver: 'grandparent' }; - const parent: Route = { receiver: null, group_by: ['foo'] }; - const child: Route = { receiver: null }; - - const childInherited = getInheritedProperties(parent, child, parentInheritedProperties); - expect(childInherited).toHaveProperty('receiver', 'grandparent'); - expect(childInherited.group_by).toEqual(['foo']); - }); - }); - - describe('regular "undefined" or "null" values', () => { - it('should compute inherited properties being undefined', () => { - const parent: Route = { - receiver: 'PARENT', - group_wait: '10s', - }; - - const child: Route = { - receiver: 'CHILD', - }; - - const childInherited = getInheritedProperties(parent, child); - expect(childInherited).toHaveProperty('group_wait', '10s'); - }); - - it('should compute inherited properties being null', () => { - const parent: Route = { - receiver: 'PARENT', - group_wait: '10s', - }; - - const child: Route = { - receiver: null, - }; - - const childInherited = getInheritedProperties(parent, child); - expect(childInherited).toHaveProperty('receiver', 'PARENT'); - }); - - it('should compute inherited properties being undefined from parent inherited properties', () => { - const parent: Route = { - receiver: 'PARENT', - }; - - const child: Route = { - receiver: 'CHILD', - }; - - const childInherited = getInheritedProperties(parent, child, { group_wait: '10s' }); - expect(childInherited).toHaveProperty('group_wait', '10s'); - }); - - it('should not inherit if the child overrides an inheritable value', () => { - const parent: Route = { - receiver: 'PARENT', - group_wait: '10s', - }; - - const child: Route = { - receiver: 'CHILD', - group_wait: '30s', - }; - - const childInherited = getInheritedProperties(parent, child); - expect(childInherited).not.toHaveProperty('group_wait'); - }); - - it('should not inherit if the child overrides an inheritable value and the parent inherits', () => { - const parent: Route = { - receiver: 'PARENT', - }; - - const child: Route = { - receiver: 'CHILD', - group_wait: '30s', - }; - - const childInherited = getInheritedProperties(parent, child, { group_wait: '60s' }); - expect(childInherited).not.toHaveProperty('group_wait'); - }); - - it('should inherit if the child property is an empty string', () => { - const parent: Route = { - receiver: 'PARENT', - }; - - const child: Route = { - receiver: '', - group_wait: '30s', - }; - - const childInherited = getInheritedProperties(parent, child); - expect(childInherited).toHaveProperty('receiver', 'PARENT'); - }); - }); - - describe('timing options', () => { - it('should inherit timing options', () => { - const parent: Route = { - receiver: 'PARENT', - group_wait: '1m', - group_interval: '2m', - }; - - const child: Route = { - repeat_interval: '999s', - }; - - const childInherited = getInheritedProperties(parent, child); - expect(childInherited).toHaveProperty('group_wait', '1m'); - expect(childInherited).toHaveProperty('group_interval', '2m'); - }); - }); - it('should not inherit mute timings from parent route', () => { - const parent: Route = { - receiver: 'PARENT', - group_by: ['parentLabel'], - mute_time_intervals: ['Mon-Fri 09:00-17:00'], - }; - - const child: Route = { - receiver: 'CHILD', - group_by: ['childLabel'], - }; - - const childInherited = getInheritedProperties(parent, child); - expect(childInherited).not.toHaveProperty('mute_time_intervals'); - }); -}); - -describe('computeInheritedTree', () => { - it('should merge properties from parent', () => { - const parent: Route = { - receiver: 'PARENT', - group_wait: '1m', - group_interval: '2m', - repeat_interval: '3m', - routes: [ - { - repeat_interval: '999s', - }, - ], - }; - - const treeRoot = computeInheritedTree(parent); - expect(treeRoot).toHaveProperty('group_wait', '1m'); - expect(treeRoot).toHaveProperty('group_interval', '2m'); - expect(treeRoot).toHaveProperty('repeat_interval', '3m'); - - expect(treeRoot).toHaveProperty('routes.0.group_wait', '1m'); - expect(treeRoot).toHaveProperty('routes.0.group_interval', '2m'); - expect(treeRoot).toHaveProperty('routes.0.repeat_interval', '999s'); - }); - - it('should not regress #73573', () => { - const parent: Route = { - routes: [ - { - group_wait: '1m', - group_interval: '2m', - repeat_interval: '3m', - routes: [ - { - group_wait: '10m', - group_interval: '20m', - repeat_interval: '30m', - }, - { - repeat_interval: '999m', - }, - ], - }, - ], - }; - - const treeRoot = computeInheritedTree(parent); - expect(treeRoot).toHaveProperty('routes.0.group_wait', '1m'); - expect(treeRoot).toHaveProperty('routes.0.group_interval', '2m'); - expect(treeRoot).toHaveProperty('routes.0.repeat_interval', '3m'); - - expect(treeRoot).toHaveProperty('routes.0.routes.0.group_wait', '10m'); - expect(treeRoot).toHaveProperty('routes.0.routes.0.group_interval', '20m'); - expect(treeRoot).toHaveProperty('routes.0.routes.0.repeat_interval', '30m'); - - expect(treeRoot).toHaveProperty('routes.0.routes.1.group_wait', '1m'); - expect(treeRoot).toHaveProperty('routes.0.routes.1.group_interval', '2m'); - expect(treeRoot).toHaveProperty('routes.0.routes.1.repeat_interval', '999m'); - }); -}); +import { normalizeRoute, unquoteRouteMatchers } from './notification-policies'; describe('normalizeRoute', () => { it('should map matchers property to object_matchers', function () { @@ -432,66 +39,6 @@ describe('normalizeRoute', () => { }); }); -describe('matchLabels', () => { - it('should match with non-matching matchers', () => { - const result = matchLabels( - [ - ['foo', MatcherOperator.equal, ''], - ['team', MatcherOperator.equal, 'operations'], - ], - [['team', 'operations']] - ); - - expect(result).toHaveProperty('matches', true); - expect(result.labelsMatch).toMatchSnapshot(); - }); - - it('should match with non-equal matchers', () => { - const result = matchLabels( - [ - ['foo', MatcherOperator.notEqual, 'bar'], - ['team', MatcherOperator.equal, 'operations'], - ], - [['team', 'operations']] - ); - - expect(result).toHaveProperty('matches', true); - expect(result.labelsMatch).toMatchSnapshot(); - }); - - it('should not match with a set of matchers', () => { - const result = matchLabels( - [ - ['foo', MatcherOperator.notEqual, 'bar'], - ['team', MatcherOperator.equal, 'operations'], - ], - [ - ['team', 'operations'], - ['foo', 'bar'], - ] - ); - - expect(result).toHaveProperty('matches', false); - expect(result.labelsMatch).toMatchSnapshot(); - }); - - it('does not match unanchored regular expressions', () => { - const result = matchLabels([['foo', MatcherOperator.regex, 'bar']], [['foo', 'barbarbar']]); - // This may seem unintuitive, but this is how Alertmanager matches, as it anchors the regex - expect(result.matches).toEqual(false); - }); - - it('matches regular expressions with wildcards', () => { - const result = matchLabels([['foo', MatcherOperator.regex, '.*bar.*']], [['foo', 'barbarbar']]); - expect(result.matches).toEqual(true); - }); - - it('does match regular expressions with flags', () => { - const result = matchLabels([['foo', MatcherOperator.regex, '(?i).*BAr.*']], [['foo', 'barbarbar']]); - expect(result.matches).toEqual(true); - }); -}); - describe('unquoteRouteMatchers', () => { it('should unquote and unescape matchers values', () => { const route: RouteWithID = { diff --git a/public/app/features/alerting/unified/utils/notification-policies.ts b/public/app/features/alerting/unified/utils/notification-policies.ts index dc7f4923ed8..7f2e0437b3b 100644 --- a/public/app/features/alerting/unified/utils/notification-policies.ts +++ b/public/app/features/alerting/unified/utils/notification-policies.ts @@ -1,100 +1,12 @@ -import { isArray, pick, reduce } from 'lodash'; +import { findMatchingRoutes } from '@grafana/alerting/unstable'; +import { AlertmanagerGroup, Route } from 'app/plugins/datasource/alertmanager/types'; -import { AlertmanagerGroup, ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; -import { Labels } from 'app/types/unified-alerting-dto'; - -import { Label, isLabelMatch, matchLabelsSet, normalizeMatchers, unquoteWithUnescape } from './matchers'; - -// If a policy has no matchers it still can be a match, hence matchers can be empty and match can be true -// So we cannot use null as an indicator of no match -interface LabelMatchResult { - match: boolean; - matcher: ObjectMatcher | null; -} - -export const INHERITABLE_KEYS = ['receiver', 'group_by', 'group_wait', 'group_interval', 'repeat_interval'] as const; -export type InheritableKeys = typeof INHERITABLE_KEYS; -export type InheritableProperties = Pick; - -type LabelsMatch = Map; - -interface MatchingResult { - matches: boolean; - labelsMatch: LabelsMatch; -} - -// returns a match results for given set of matchers (from a policy for instance) and a set of labels -export function matchLabels(matchers: ObjectMatcher[], labels: Label[]): MatchingResult { - const matches = matchLabelsSet(matchers, labels); - - // create initial map of label => match result - const labelsMatch: LabelsMatch = new Map(labels.map((label) => [label, { match: false, matcher: null }])); - - // for each matcher, check which label it matched for - matchers.forEach((matcher) => { - const matchingLabel = labels.find((label) => isLabelMatch(matcher, label)); - - // record that matcher for the label - if (matchingLabel) { - labelsMatch.set(matchingLabel, { - match: true, - matcher, - }); - } - }); - - return { matches, labelsMatch }; -} - -export interface AlertInstanceMatch { - instance: Labels; - labelsMatch: LabelsMatch; -} - -export interface RouteMatchResult { - route: T; - labelsMatch: LabelsMatch; -} - -// Match does a depth-first left-to-right search through the route tree -// and returns the matching routing nodes. - -// If the current node is not a match, return nothing -// Normalization should have happened earlier in the code -function findMatchingRoutes(route: T, labels: Label[]): Array> { - let childMatches: Array> = []; - - // If the current node is not a match, return nothing - const matchResult = matchLabels(route.object_matchers ?? [], labels); - if (!matchResult.matches) { - return []; - } - - // If the current node matches, recurse through child nodes - if (route.routes) { - for (const child of route.routes) { - const matchingChildren = findMatchingRoutes(child, labels); - // TODO how do I solve this typescript thingy? It looks correct to me /shrug - // @ts-ignore - childMatches = childMatches.concat(matchingChildren); - // we have matching children and we don't want to continue, so break here - if (matchingChildren.length && !child.continue) { - break; - } - } - } - - // If no child nodes were matches, the current node itself is a match. - if (childMatches.length === 0) { - childMatches.push({ route, labelsMatch: matchResult.labelsMatch }); - } - - return childMatches; -} +import { normalizeMatchers, unquoteWithUnescape } from './matchers'; +import { routeAdapter } from './routeAdapter'; // This is a performance improvement to normalize matchers only once and use the normalized version later on -export function normalizeRoute(rootRoute: RouteWithID): RouteWithID { - function normalizeRoute(route: RouteWithID) { +export function normalizeRoute(rootRoute: T): T { + function normalizeRoute(route: T) { route.object_matchers = normalizeMatchers(route); delete route.matchers; delete route.match; @@ -108,8 +20,8 @@ export function normalizeRoute(rootRoute: RouteWithID): RouteWithID { return normalizedRootRoute; } -export function unquoteRouteMatchers(route: RouteWithID): RouteWithID { - function unquoteRoute(route: RouteWithID) { +export function unquoteRouteMatchers(route: T): T { + function unquoteRoute(route: Route) { route.object_matchers = route.object_matchers?.map(([name, operator, value]) => { return [unquoteWithUnescape(name), operator, unquoteWithUnescape(value)]; }); @@ -137,7 +49,11 @@ function findMatchingAlertGroups( // find matching alerts in the current group const matchingAlerts = group.alerts.filter((alert) => { const labels = Object.entries(alert.labels); - return findMatchingRoutes(routeTree, labels).some((matchingRoute) => matchingRoute.route === route); + const alertingRouteTree = routeAdapter.toPackage(routeTree); + const alertingRoute = routeAdapter.toPackage(route); + return findMatchingRoutes(alertingRouteTree, labels).some( + (matchingRoute) => matchingRoute.route === alertingRoute + ); }); // if the groups has any alerts left after matching, add it to the results @@ -152,66 +68,6 @@ function findMatchingAlertGroups( }, matchingGroups); } -// inherited properties are config properties that exist on the parent route (or its inherited properties) but not on the child route -function getInheritedProperties( - parentRoute: Route, - childRoute: Route, - propertiesParentInherited?: InheritableProperties -): InheritableProperties { - const propsFromParent: InheritableProperties = pick(parentRoute, INHERITABLE_KEYS); - const inheritableProperties: InheritableProperties = { - ...propsFromParent, - ...propertiesParentInherited, - }; - - const inherited = reduce( - inheritableProperties, - (inheritedProperties: InheritableProperties, parentValue, property) => { - const parentHasValue = parentValue != null; - - const inheritableValues = [undefined, '', null]; - // @ts-ignore - const childIsInheriting = inheritableValues.some((value) => childRoute[property] === value); - const inheritFromValue = childIsInheriting && parentHasValue; - - const inheritEmptyGroupByFromParent = - property === 'group_by' && - parentHasValue && - isArray(childRoute[property]) && - childRoute[property]?.length === 0; - - const inheritFromParent = inheritFromValue || inheritEmptyGroupByFromParent; - - if (inheritFromParent) { - // @ts-ignore - inheritedProperties[property] = parentValue; - } - - return inheritedProperties; - }, - {} - ); - - return inherited; -} - -/** - * This function will compute the full tree with inherited properties – this is mostly used for search and filtering - */ -export function computeInheritedTree(parent: T): T { - return { - ...parent, - routes: parent.routes?.map((child) => { - const inheritedProperties = getInheritedProperties(parent, child); - - return computeInheritedTree({ - ...child, - ...inheritedProperties, - }); - }), - }; -} - // recursive function to rename receivers in all routes (notification policies) function renameReceiverInRoute(route: Route, oldName: string, newName: string) { const updated: Route = { @@ -229,4 +85,4 @@ function renameReceiverInRoute(route: Route, oldName: string, newName: string) { return updated; } -export { findMatchingAlertGroups, findMatchingRoutes, getInheritedProperties, renameReceiverInRoute }; +export { findMatchingAlertGroups, renameReceiverInRoute }; diff --git a/public/app/features/alerting/unified/utils/routeAdapter.test.ts b/public/app/features/alerting/unified/utils/routeAdapter.test.ts new file mode 100644 index 00000000000..7f0239ca621 --- /dev/null +++ b/public/app/features/alerting/unified/utils/routeAdapter.test.ts @@ -0,0 +1,267 @@ +import { Factory } from 'fishery'; + +import { RouteFactory } from '@grafana/alerting/testing'; +import { RouteWithID as AlertingRouteWithID } from '@grafana/alerting/unstable'; +import { MatcherOperator, ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; + +import { routeAdapter } from './routeAdapter'; + +describe('routeAdapter', () => { + // Create RouteWithID factory that extends the base RouteFactory + const RouteWithIDFactory = Factory.define(({ sequence }) => ({ + ...RouteFactory.build(), + id: `route-${sequence}`, + routes: [], + })); + + const mockObjectMatchers: ObjectMatcher[] = [['severity', MatcherOperator.equal, 'critical']]; + const mockLabelMatchers = [ + { + label: 'severity', + type: '=' as const, + value: 'critical', + }, + ]; + + describe('toPackage', () => { + it('should convert basic Route to AlertingRoute', () => { + const route: Route = { + receiver: 'test-receiver', + continue: true, + group_by: ['alertname'], + object_matchers: mockObjectMatchers, + routes: [], + }; + + const result = routeAdapter.toPackage(route); + + expect(result).toEqual({ + receiver: 'test-receiver', + continue: true, + group_by: ['alertname'], + matchers: mockLabelMatchers, + routes: [], + group_wait: undefined, + group_interval: undefined, + repeat_interval: undefined, + mute_time_intervals: undefined, + active_time_intervals: undefined, + }); + }); + + it('should convert RouteWithID to AlertingRouteWithID', () => { + const routeWithId: RouteWithID = { + id: 'test-id', + receiver: 'test-receiver', + continue: false, + object_matchers: mockObjectMatchers, + routes: [], + }; + + const result = routeAdapter.toPackage(routeWithId); + + expect(result).toEqual({ + id: 'test-id', + receiver: 'test-receiver', + continue: false, + matchers: mockLabelMatchers, + routes: [], + group_by: undefined, + group_wait: undefined, + group_interval: undefined, + repeat_interval: undefined, + mute_time_intervals: undefined, + active_time_intervals: undefined, + }); + }); + + it('should handle undefined continue as false', () => { + const route: Route = { + receiver: 'test-receiver', + object_matchers: mockObjectMatchers, + routes: [], + }; + + const result = routeAdapter.toPackage(route); + + expect(result.continue).toBe(false); + }); + + it('should handle null receiver as undefined', () => { + const route: Route = { + receiver: null, + object_matchers: mockObjectMatchers, + routes: [], + }; + + const result = routeAdapter.toPackage(route); + + expect(result.receiver).toBeUndefined(); + }); + + it('should recursively convert child routes', () => { + const route: RouteWithID = { + id: 'parent', + receiver: 'parent-receiver', + routes: [ + { + id: 'child', + receiver: 'child-receiver', + object_matchers: mockObjectMatchers, + routes: [], + }, + ], + }; + + const result = routeAdapter.toPackage(route); + + expect(result.routes).toHaveLength(1); + expect(result.routes[0]).toEqual({ + id: 'child', + receiver: 'child-receiver', + continue: false, + matchers: mockLabelMatchers, + routes: [], + group_by: undefined, + group_wait: undefined, + group_interval: undefined, + repeat_interval: undefined, + mute_time_intervals: undefined, + active_time_intervals: undefined, + }); + }); + }); + + describe('fromPackage', () => { + it('should convert basic AlertingRoute to Route', () => { + const alertingRoute = RouteFactory.build({ + receiver: 'test-receiver', + continue: true, + group_by: ['alertname'], + matchers: mockLabelMatchers, + routes: [], + }); + + const result = routeAdapter.fromPackage(alertingRoute); + + expect(result.receiver).toBe('test-receiver'); + expect(result.continue).toBe(true); + expect(result.group_by).toEqual(['alertname']); + expect(result.object_matchers).toEqual(mockObjectMatchers); + expect(result.routes).toBeUndefined(); + }); + + it('should convert AlertingRouteWithID to RouteWithID', () => { + const alertingRouteWithId = RouteWithIDFactory.build({ + id: 'test-id', + receiver: 'test-receiver', + continue: false, + matchers: mockLabelMatchers, + routes: [], + }); + + const result = routeAdapter.fromPackage(alertingRouteWithId); + + expect(result.id).toBe('test-id'); + expect(result.receiver).toBe('test-receiver'); + expect(result.continue).toBe(false); + expect(result.object_matchers).toEqual(mockObjectMatchers); + expect(result.routes).toBeUndefined(); + }); + + it('should handle undefined receiver as null', () => { + const alertingRoute = RouteFactory.build(); + // Override receiver to undefined after building + const alertingRouteWithUndefinedReceiver = { + ...alertingRoute, + receiver: undefined, + }; + + const result = routeAdapter.fromPackage(alertingRouteWithUndefinedReceiver); + + expect(result.receiver).toBeNull(); + }); + + it('should recursively convert child routes', () => { + const childRoute = RouteWithIDFactory.build({ + id: 'child', + receiver: 'child-receiver', + continue: true, + matchers: mockLabelMatchers, + routes: [], + }); + + const alertingRoute = RouteWithIDFactory.build({ + id: 'parent', + receiver: 'parent-receiver', + continue: false, + routes: [childRoute], + }); + + const result = routeAdapter.fromPackage(alertingRoute); + + expect(result.routes).toHaveLength(1); + expect(result.routes![0].id).toBe('child'); + expect(result.routes![0].receiver).toBe('child-receiver'); + expect(result.routes![0].continue).toBe(true); + expect(result.routes![0].object_matchers).toEqual(mockObjectMatchers); + expect(result.routes![0].routes).toBeUndefined(); + }); + + it('should handle routes without matchers', () => { + const alertingRoute = RouteFactory.build(); + // Override matchers to undefined after building + const alertingRouteWithoutMatchers = { + ...alertingRoute, + matchers: undefined, + }; + + const result = routeAdapter.fromPackage(alertingRouteWithoutMatchers); + + expect(result.object_matchers).toBeUndefined(); + }); + }); + + describe('round-trip conversion', () => { + it('should maintain data integrity through round-trip conversion', () => { + const childRoute: RouteWithID = { + id: 'child-route', + receiver: 'child-receiver', + continue: false, + object_matchers: [['environment', MatcherOperator.equal, 'prod']], + routes: [], + }; + + const originalRoute: RouteWithID = { + id: 'test-route', + receiver: 'test-receiver', + continue: true, + group_by: ['alertname', 'severity'], + group_wait: '10s', + group_interval: '5m', + repeat_interval: '1h', + object_matchers: [ + ['severity', MatcherOperator.equal, 'critical'], + ['team', MatcherOperator.regex, 'frontend|backend'], + ], + mute_time_intervals: ['maintenance'], + active_time_intervals: ['business-hours'], + routes: [childRoute], + }; + + // Convert to package format and back + const packageRoute = routeAdapter.toPackage(originalRoute); + const backToOriginal = routeAdapter.fromPackage(packageRoute); + + expect(backToOriginal).toEqual({ + ...originalRoute, + routes: [ + { + ...childRoute, + routes: undefined, // fromPackage doesn't add routes array if empty + }, + ], + }); + }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/routeAdapter.ts b/public/app/features/alerting/unified/utils/routeAdapter.ts new file mode 100644 index 00000000000..e08c2fb7486 --- /dev/null +++ b/public/app/features/alerting/unified/utils/routeAdapter.ts @@ -0,0 +1,159 @@ +import { + Route as AlertingRoute, + RouteWithID as AlertingRouteWithID, + type LabelMatcher, +} from '@grafana/alerting/unstable'; +import { + MatcherOperator, + type ObjectMatcher, + type Route, + type RouteWithID, +} from 'app/plugins/datasource/alertmanager/types'; + +import { convertObjectMatcherToAlertingPackageMatcher, matcherToObjectMatcher, parseMatcherToArray } from './matchers'; + +/** + * Enhanced type guards using infer-based utility types + */ +function hasId(route: T): route is T & RouteWithID { + return 'id' in route && typeof route.id === 'string'; +} + +function hasAlertingId(route: T): route is T & AlertingRouteWithID { + return 'id' in route && typeof route.id === 'string'; +} + +/** + * Converts from package route format to alertmanager route format + */ +function fromPackageRoute(route: AlertingRouteWithID): RouteWithID; +function fromPackageRoute(route: AlertingRoute): Route; +function fromPackageRoute(route: AlertingRoute | AlertingRouteWithID): Route | RouteWithID { + // Convert matchers from LabelMatcher[] to ObjectMatcher[] + const object_matchers = route.matchers?.map(labelMatcherToObjectMatcher); + + // Recursively convert child routes + const routes = route.routes?.length ? route.routes.map(fromPackageRoute) : undefined; + + const baseRoute = { + receiver: route.receiver || null, + group_by: route.group_by, + continue: route.continue, + group_wait: route.group_wait, + group_interval: route.group_interval, + repeat_interval: route.repeat_interval, + mute_time_intervals: route.mute_time_intervals, + active_time_intervals: route.active_time_intervals, + }; + + const convertedRoute = { + ...baseRoute, + object_matchers, + routes, + }; + + // If the input route has an ID, include it in the output + if (hasAlertingId(route)) { + return { + ...convertedRoute, + id: route.id, + }; + } + + return convertedRoute; +} + +/** + * Converts from alertmanager route format to package route format + */ +function toPackageRoute(route: RouteWithID): AlertingRouteWithID; +function toPackageRoute(route: Route): AlertingRoute; +function toPackageRoute(route: Route | RouteWithID): AlertingRoute | AlertingRouteWithID { + // Convert matchers + let matchers: LabelMatcher[] = []; + + if (route.object_matchers) { + matchers = route.object_matchers.map(convertObjectMatcherToAlertingPackageMatcher); + } else if (route.matchers) { + matchers = []; + route.matchers.forEach((matcher) => { + const parsedMatchers = parseMatcherToArray(matcher) + .map(matcherToObjectMatcher) + .map(convertObjectMatcherToAlertingPackageMatcher); + matchers.push(...parsedMatchers); + }); + } + + // Recursively convert child routes + const routes = route.routes?.length ? route.routes.map(toPackageRoute) : []; + + const baseRoute = { + receiver: route.receiver ?? undefined, + group_by: route.group_by, + continue: route.continue ?? false, + group_wait: route.group_wait, + group_interval: route.group_interval, + repeat_interval: route.repeat_interval, + mute_time_intervals: route.mute_time_intervals, + active_time_intervals: route.active_time_intervals, + }; + + const convertedRoute = { + ...baseRoute, + matchers, + routes, + }; + + // If the input route has an ID, include it in the output + if (hasId(route)) { + return { + ...convertedRoute, + id: route.id, + }; + } + + return convertedRoute; +} + +/** + * Converts routes between alertmanager and package formats + */ +export const routeAdapter = { + /** + * Converts from package route format to alertmanager route format + * Handles both Route and RouteWithID variants + */ + fromPackage: fromPackageRoute, + + /** + * Converts from alertmanager route format to package route format + * Handles both Route and RouteWithID variants + */ + toPackage: toPackageRoute, +}; + +/** + * Safely converts a LabelMatcher type to MatcherOperator + */ +function convertToMatcherOperator(type: LabelMatcher['type']): MatcherOperator { + switch (type) { + case '=': + return MatcherOperator.equal; + case '!=': + return MatcherOperator.notEqual; + case '=~': + return MatcherOperator.regex; + case '!~': + return MatcherOperator.notRegex; + default: + const exhaustiveCheck: never = type; + throw new Error(`Unknown matcher type: ${exhaustiveCheck}`); + } +} + +/** + * Converts a LabelMatcher from the alerting package format to an ObjectMatcher for alertmanager format + */ +export function labelMatcherToObjectMatcher(matcher: LabelMatcher): ObjectMatcher { + return [matcher.label, convertToMatcherOperator(matcher.type), matcher.value]; +} diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx index 3faa6d4b686..dd88bf2679d 100644 --- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx +++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx @@ -122,8 +122,8 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) const menu = ( {canViewPermissions && setShowPermissionsDrawer(true)} label={managePermissionsLabel} />} - {canMoveFolder && } - {canDeleteFolders && ( + {canMoveFolder && !isReadOnlyRepo && } + {canDeleteFolders && !isReadOnlyRepo && ( + imageUrl} + onClipboardCopy={() => DashboardInteractions.copyImageUrlClicked({ shareResource: 'panel' })} + > + Copy image link + {disabled && ( diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts index 9990c4e197e..67615b083d1 100644 --- a/public/app/features/dashboard-scene/utils/interactions.ts +++ b/public/app/features/dashboard-scene/utils/interactions.ts @@ -187,6 +187,9 @@ export const DashboardInteractions = { downloadDashboardImageClicked: (properties?: Record) => { reportDashboardInteraction('dashboard_image_downloaded', properties); }, + copyImageUrlClicked: (properties?: Record) => { + reportDashboardInteraction('dashboard_image_url_copied', properties); + }, }; const reportDashboardInteraction: typeof reportInteraction = (name, properties) => { diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelpDisplay.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelpDisplay.tsx index 3bca17be7c8..5cb6ba0e684 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelpDisplay.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationEditorHelpDisplay.tsx @@ -1,8 +1,7 @@ import { TransformerRegistryItem } from '@grafana/data'; import { Drawer } from '@grafana/ui'; import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp'; - -import { getLinkToDocs } from '../../../transformers/docs/content'; +import { FALLBACK_DOCS_LINK } from 'app/features/transformers/docs/constants'; interface TransformationEditorHelpDisplayProps { isOpen: boolean; @@ -20,7 +19,8 @@ export const TransformationEditorHelpDisplay = ({ help, } = transformer; - const helpContent = help ? help : getLinkToDocs(); + const helpContent = help ? help : FALLBACK_DOCS_LINK; + const helpElement = ( onCloseClick(false)}> diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty.test.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty.test.tsx index 7dd3f6c4165..06f1438646d 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty.test.tsx @@ -28,6 +28,18 @@ jest.mock('app/features/dashboard/utils/dashboard', () => ({ onAddLibraryPanel: jest.fn(), })); +jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () => ({ + useGetResourceRepositoryView: jest.fn(() => ({ + isReadOnlyRepo: false, + isInstanceManaged: false, + isLoading: false, + })), +})); + +const mockUseGetResourceRepositoryView = jest.mocked( + require('app/features/provisioning/hooks/useGetResourceRepositoryView').useGetResourceRepositoryView +); + function setup(options?: Partial) { const props = { dashboard: createDashboardModelFixture(defaultDashboard), @@ -40,6 +52,12 @@ function setup(options?: Partial) { beforeEach(() => { jest.clearAllMocks(); + // Reset the mock to default state + mockUseGetResourceRepositoryView.mockReturnValue({ + isReadOnlyRepo: false, + isInstanceManaged: false, + isLoading: false, + }); }); it('renders page with correct title for an empty dashboard', () => { @@ -117,3 +135,18 @@ it('renders page without Add Widget button when feature flag is disabled', () => expect(screen.getByRole('button', { name: 'Add library panel' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Add widget' })).not.toBeInTheDocument(); }); + +it('renders with buttons disabled when repository is read-only', () => { + // Mock the hook to return read-only repository + mockUseGetResourceRepositoryView.mockReturnValue({ + isReadOnlyRepo: true, + isInstanceManaged: false, + isLoading: false, + }); + + setup({ canCreate: true }); + + expect(screen.getByRole('button', { name: 'Add visualization' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Import dashboard' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Add library panel' })).toBeDisabled(); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx index 9555bea733e..f5f8a6856d1 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx @@ -14,6 +14,7 @@ import { import { buildPanelEditScene } from 'app/features/dashboard-scene/panel-edit/PanelEditor'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; +import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; import { useDispatch, useSelector } from 'app/types/store'; import { setInitialDatasource } from '../state/reducers'; @@ -28,6 +29,11 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { const dispatch = useDispatch(); const initialDatasource = useSelector((state) => state.dashboard.initialDatasource); + // Get repository information to check if it's read-only + const { isReadOnlyRepo } = useGetResourceRepositoryView({ + folderName: dashboard instanceof DashboardScene ? dashboard.state.meta.folderUid : dashboard.meta.folderUid, + }); + const onAddVisualization = () => { let id; if (dashboard instanceof DashboardScene) { @@ -77,7 +83,7 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { icon="plus" data-testid={selectors.pages.AddDashboard.itemButton('Create new panel button')} onClick={onAddVisualization} - disabled={!canCreate} + disabled={!canCreate || isReadOnlyRepo} > Add visualization @@ -101,7 +107,7 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { fill="outline" data-testid={selectors.pages.AddDashboard.itemButton('Add a panel from the panel library button')} onClick={onAddLibraryPanel} - disabled={!canCreate || isProvisioned} + disabled={!canCreate || isProvisioned || isReadOnlyRepo} > Add library panel @@ -131,7 +137,7 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { DashboardInteractions.emptyDashboardButtonClicked({ item: 'import_dashboard' }); onImportDashboard(); }} - disabled={!canCreate} + disabled={!canCreate || isReadOnlyRepo} > Import dashboard diff --git a/public/app/features/migrate-to-cloud/cloud/Page.tsx b/public/app/features/migrate-to-cloud/cloud/Page.tsx index e08bd91e8cb..4f1a77240fd 100644 --- a/public/app/features/migrate-to-cloud/cloud/Page.tsx +++ b/public/app/features/migrate-to-cloud/cloud/Page.tsx @@ -6,7 +6,7 @@ import { MigrationTokenPane } from './MigrationTokenPane/MigrationTokenPane'; export const Page = () => { return ( - + diff --git a/public/app/features/migrate-to-cloud/onprem/EmptyState/EmptyState.tsx b/public/app/features/migrate-to-cloud/onprem/EmptyState/EmptyState.tsx index ee5a399b954..18bdd0279e1 100644 --- a/public/app/features/migrate-to-cloud/onprem/EmptyState/EmptyState.tsx +++ b/public/app/features/migrate-to-cloud/onprem/EmptyState/EmptyState.tsx @@ -6,7 +6,7 @@ import { InfoPaneRight } from './InfoPaneRight'; export const EmptyState = () => { return ( - + diff --git a/public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx b/public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx index 623ea12520b..34cfd410e4c 100644 --- a/public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx +++ b/public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx @@ -60,6 +60,7 @@ export function MigrationSummary(props: MigrationSummaryProps) { return ( - + {pluginExtentionsInfo.map((infoItem, index) => { return ( @@ -97,7 +97,13 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null { {shouldRenderLinks && ( <> - + {plugin.details?.repositoryUrl && ( )} {customLinks && customLinks?.length > 0 && ( - + )} {!plugin?.isCore && ( - + import('moment').then((module) => ({ ...module, __useDefault: true })), prismjs: () => import('prismjs'), react: () => import('react'), + // Externalise react/jsx-runtime to align with runtime version of react. + // This should make major react update easier to manage in the future. + 'react/jsx-runtime': () => import('react/jsx-runtime'), + 'react/jsx-dev-runtime': () => import('react/jsx-dev-runtime'), 'react-dom': () => import('react-dom'), // bundling grafana-ui in plugins requires sharing react-inlinesvg for the icon cache 'react-inlinesvg': () => import('react-inlinesvg'), diff --git a/public/app/features/provisioning/Config/ConfigForm.tsx b/public/app/features/provisioning/Config/ConfigForm.tsx index f7139f3a1ff..d206d6bc2fd 100644 --- a/public/app/features/provisioning/Config/ConfigForm.tsx +++ b/public/app/features/provisioning/Config/ConfigForm.tsx @@ -62,7 +62,12 @@ export function ConfigForm({ data }: ConfigFormProps) { setError, watch, getValues, - } = useForm({ defaultValues: getDefaultValues(data?.spec) }); + } = useForm({ + defaultValues: getDefaultValues({ + repository: data?.spec, + allowedTargets: settings.data?.allowedTargets, + }), + }); const isEdit = Boolean(repositoryName); const [tokenConfigured, setTokenConfigured] = useState(isEdit); diff --git a/public/app/features/provisioning/Config/defaults.ts b/public/app/features/provisioning/Config/defaults.ts index 32c96fffc53..65ab1bd84fa 100644 --- a/public/app/features/provisioning/Config/defaults.ts +++ b/public/app/features/provisioning/Config/defaults.ts @@ -1,11 +1,21 @@ import { t } from '@grafana/i18n'; -import { RepositorySpec } from '../../../api/clients/provisioning/v0alpha1'; +import { RepositorySpec, RepositoryViewList } from '../../../api/clients/provisioning/v0alpha1'; import { RepositoryFormData } from '../types'; import { specToData } from '../utils/data'; -export function getDefaultValues(repository?: RepositorySpec): RepositoryFormData { +export interface GetDefaultValuesOptions { + repository?: RepositorySpec; + allowedTargets?: RepositoryViewList['allowedTargets']; +} + +export function getDefaultValues({ + repository, + allowedTargets = ['instance', 'folder'], +}: GetDefaultValuesOptions = {}): RepositoryFormData { if (!repository) { + const defaultTarget = allowedTargets.includes('folder') ? 'folder' : 'instance'; + return { type: 'github', title: t('provisioning.get-default-values.title.repository', 'Repository'), @@ -18,7 +28,7 @@ export function getDefaultValues(repository?: RepositorySpec): RepositoryFormDat path: 'grafana/', sync: { enabled: false, - target: 'folder', // start with folder so we can shift to instance later (without an error) + target: defaultTarget, intervalSeconds: 60, }, }; diff --git a/public/app/features/provisioning/File/FileStatusPage.tsx b/public/app/features/provisioning/File/FileStatusPage.tsx index 7b122e3b666..5a946122e11 100644 --- a/public/app/features/provisioning/File/FileStatusPage.tsx +++ b/public/app/features/provisioning/File/FileStatusPage.tsx @@ -6,17 +6,18 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { urlUtil } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { isFetchError } from '@grafana/runtime'; -import { Alert, CodeEditor, LinkButton, Button, Stack, Tab, TabContent, TabsBar, DeleteButton } from '@grafana/ui'; +import { Alert, Button, CodeEditor, DeleteButton, LinkButton, Stack, Tab, TabContent, TabsBar } from '@grafana/ui'; import { - useGetRepositoryFilesWithPathQuery, ResourceWrapper, - useReplaceRepositoryFilesWithPathMutation, useDeleteRepositoryFilesWithPathMutation, + useGetRepositoryFilesWithPathQuery, + useReplaceRepositoryFilesWithPathMutation, } from 'app/api/clients/provisioning/v0alpha1'; import { Page } from 'app/core/components/Page/Page'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { PROVISIONING_URL } from '../constants'; +import { useGetResourceRepositoryView } from '../hooks/useGetResourceRepositoryView'; export default function FileStatusPage() { const params = useParams(); @@ -26,6 +27,7 @@ export default function FileStatusPage() { const name = params['name'] ?? ''; const path = params['*'] ?? ''; const file = useGetRepositoryFilesWithPathQuery({ name, path, ref }); + const { isReadOnlyRepo } = useGetResourceRepositoryView({ name }); return ( )} - {file.isSuccess && file.data && } + {file.isSuccess && file.data && ( + + )} @@ -59,9 +63,10 @@ interface Props { repo: string; repoRef?: string; tab: TabSelection; + isReadOnlyRepo: boolean; } -function ResourceView({ wrap, repo, repoRef, tab }: Props) { +function ResourceView({ wrap, repo, repoRef, tab, isReadOnlyRepo }: Props) { const isDashboard = wrap.resource?.type?.kind === 'Dashboard'; const existingName = wrap.resource?.existing?.metadata?.name; const location = useLocation(); @@ -159,8 +164,13 @@ function ResourceView({ wrap, repo, repoRef, tab }: Props) {
{ deleteFile({ name: repo, diff --git a/public/app/features/provisioning/GettingStarted/GettingStartedPage.tsx b/public/app/features/provisioning/GettingStarted/GettingStartedPage.tsx index ff9999698ba..0a9d6822780 100644 --- a/public/app/features/provisioning/GettingStarted/GettingStartedPage.tsx +++ b/public/app/features/provisioning/GettingStarted/GettingStartedPage.tsx @@ -1,7 +1,7 @@ import { GrafanaEdition } from '@grafana/data/internal'; import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; -import { Box, Text, TextLink } from '@grafana/ui'; +import { Alert, Stack, Text, TextLink } from '@grafana/ui'; import { Repository } from 'app/api/clients/provisioning/v0alpha1'; import { Page } from 'app/core/components/Page/Page'; @@ -24,8 +24,10 @@ export default function GettingStartedPage({ items }: Props) { }} > - - + + + + ); @@ -39,15 +41,7 @@ function Banner() { } return ( - + This feature is currently under active development. For the best experience and latest improvements, we @@ -58,6 +52,6 @@ function Banner() { of Grafana. - + ); } diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.tsx index b7a6dc9b57b..2088d575690 100644 --- a/public/app/features/provisioning/Wizard/BootstrapStep.tsx +++ b/public/app/features/provisioning/Wizard/BootstrapStep.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect } from 'react'; +import { memo, useEffect } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; @@ -20,7 +20,7 @@ export interface Props { repoName: string; } -export function BootstrapStep({ settingsData, repoName }: Props) { +export const BootstrapStep = memo(function BootstrapStep({ settingsData, repoName }: Props) { const { setStepStatusInfo } = useStepStatus(); const { register, @@ -139,7 +139,7 @@ export function BootstrapStep({ settingsData, repoName }: Props) { ); -} +}); const getStyles = (theme: GrafanaTheme2) => ({ divider: css({ diff --git a/public/app/features/provisioning/Wizard/ConnectPage.tsx b/public/app/features/provisioning/Wizard/ConnectPage.tsx index 07456f5acb5..2e9b60dd724 100644 --- a/public/app/features/provisioning/Wizard/ConnectPage.tsx +++ b/public/app/features/provisioning/Wizard/ConnectPage.tsx @@ -1,6 +1,7 @@ import { useParams } from 'react-router-dom-v5-compat'; import { t } from '@grafana/i18n'; +import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; import { Page } from 'app/core/components/Page/Page'; import { isGitProvider } from '../utils/repositoryTypes'; @@ -11,6 +12,7 @@ import { RepoType } from './types'; export default function ConnectPage() { const { type } = useParams<{ type: RepoType }>(); + const { data: settingsData } = useGetFrontendSettingsQuery(); if (!type) { return null; @@ -29,7 +31,7 @@ export default function ConnectPage() { > - + diff --git a/public/app/features/provisioning/Wizard/ConnectStep.tsx b/public/app/features/provisioning/Wizard/ConnectStep.tsx index 4a3f882c1b4..5f6d9db9267 100644 --- a/public/app/features/provisioning/Wizard/ConnectStep.tsx +++ b/public/app/features/provisioning/Wizard/ConnectStep.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { memo, useState } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { Combobox, Field, Input, SecretInput, Stack } from '@grafana/ui'; @@ -11,7 +11,7 @@ import { isGitProvider } from '../utils/repositoryTypes'; import { getGitProviderFields, getLocalProviderFields } from './fields'; import { WizardFormData } from './types'; -export function ConnectStep() { +export const ConnectStep = memo(function ConnectStep() { const { register, control, @@ -174,4 +174,4 @@ export function ConnectStep() { )} ); -} +}); diff --git a/public/app/features/provisioning/Wizard/FinishStep.tsx b/public/app/features/provisioning/Wizard/FinishStep.tsx index 7fd54c1d9a6..5414c1929e2 100644 --- a/public/app/features/provisioning/Wizard/FinishStep.tsx +++ b/public/app/features/provisioning/Wizard/FinishStep.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { memo, useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; import { Trans, t } from '@grafana/i18n'; @@ -11,7 +11,7 @@ import { isGitProvider } from '../utils/repositoryTypes'; import { getGitProviderFields } from './fields'; import { WizardFormData } from './types'; -export function FinishStep() { +export const FinishStep = memo(function FinishStep() { const { register, watch, setValue } = useFormContext(); const settings = useGetFrontendSettingsQuery(); @@ -115,4 +115,4 @@ export function FinishStep() { )} ); -} +}); diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx index 3fe7eeb57dc..e6d46be3471 100644 --- a/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx @@ -7,6 +7,7 @@ import { useCreateRepositoryJobsMutation, useGetFrontendSettingsQuery, useGetRepositoryFilesQuery, + useGetRepositoryStatusQuery, useGetResourceStatsQuery, } from 'app/api/clients/provisioning/v0alpha1'; @@ -29,6 +30,7 @@ jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ ...jest.requireActual('app/api/clients/provisioning/v0alpha1'), useGetFrontendSettingsQuery: jest.fn(), useGetRepositoryFilesQuery: jest.fn(), + useGetRepositoryStatusQuery: jest.fn(), useGetResourceStatsQuery: jest.fn(), useCreateRepositoryJobsMutation: jest.fn(), })); @@ -43,6 +45,9 @@ const mockUseGetFrontendSettingsQuery = useGetFrontendSettingsQuery as jest.Mock const mockUseGetRepositoryFilesQuery = useGetRepositoryFilesQuery as jest.MockedFunction< typeof useGetRepositoryFilesQuery >; +const mockUseGetRepositoryStatusQuery = useGetRepositoryStatusQuery as jest.MockedFunction< + typeof useGetRepositoryStatusQuery +>; const mockUseGetResourceStatsQuery = useGetResourceStatsQuery as jest.MockedFunction; const mockUseCreateRepositoryJobsMutation = useCreateRepositoryJobsMutation as jest.MockedFunction< typeof useCreateRepositoryJobsMutation @@ -131,6 +136,23 @@ describe('ProvisioningWizard', () => { refetch: jest.fn(), }); + mockUseGetRepositoryStatusQuery.mockReturnValue({ + data: { + status: { + health: { + healthy: true, + checked: true, + message: '', + }, + }, + }, + isLoading: false, + isFetching: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + mockUseGetResourceStatsQuery.mockReturnValue({ data: { instance: [], diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx index 0a7d0cf7ca6..5eb4fd174ee 100644 --- a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useCallback, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom-v5-compat'; @@ -7,7 +7,7 @@ import { AppEvents, GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { getAppEvents, isFetchError } from '@grafana/runtime'; import { Box, Button, ConfirmModal, Stack, Text, useStyles2 } from '@grafana/ui'; -import { useDeleteRepositoryMutation, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; +import { RepositoryViewList, useDeleteRepositoryMutation } from 'app/api/clients/provisioning/v0alpha1'; import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt'; import { getDefaultValues } from '../Config/defaults'; @@ -58,7 +58,13 @@ const getSteps = (): Array> => { ]; }; -export function ProvisioningWizard({ type }: { type: RepoType }) { +export const ProvisioningWizard = memo(function ProvisioningWizard({ + type, + settingsData, +}: { + type: RepoType; + settingsData?: RepositoryViewList; +}) { const [activeStep, setActiveStep] = useState('connection'); const [completedSteps, setCompletedSteps] = useState([]); @@ -66,6 +72,15 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { const [isCancelling, setIsCancelling] = useState(false); const [showCancelConfirmation, setShowCancelConfirmation] = useState(false); + const repositoryRequestFailed = t( + 'provisioning.provisioning-wizard.on-submit.title.repository-request-failed', + 'Repository request failed' + ); + const repositoryConnectionFailed = t( + 'provisioning.provisioning-wizard.on-submit.title.repository-connection-failed', + 'Repository connection failed' + ); + const { stepStatusInfo, setStepStatusInfo, isStepSuccess, isStepRunning, hasStepError, hasStepWarning } = useStepStatus(); @@ -74,14 +89,13 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { activeStep === 'finish' && (isStepSuccess || completedSteps.includes('synchronize')); const shouldUseCancelBehavior = activeStep === 'connection' || isSyncCompleted || isFinishWithSyncCompleted; - const { data } = useGetFrontendSettingsQuery(); - const isLegacyStorage = Boolean(data?.legacyStorage); + const isLegacyStorage = Boolean(settingsData?.legacyStorage); const navigate = useNavigate(); const steps = getSteps(); const styles = useStyles2(getStyles); - const values = getDefaultValues(); + const values = getDefaultValues({ allowedTargets: settingsData?.allowedTargets }); const methods = useForm({ defaultValues: { repository: { ...values, type }, @@ -124,7 +138,7 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { // A different repository is marked with instance target -- nothing will succeed useEffect(() => { - if (data?.items.some((item) => item.target === 'instance' && item.name !== repoName)) { + if (settingsData?.items.some((item) => item.target === 'instance' && item.name !== repoName)) { appEvents.publish({ type: AppEvents.alertError.name, payload: [ @@ -134,7 +148,7 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { navigate(PROVISIONING_URL); } - }, [navigate, repoName, data?.items]); + }, [navigate, repoName, settingsData?.items]); const handleRepositoryDeletion = async (name: string) => { setIsCancelling(true); @@ -283,10 +297,20 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { const spec = dataToSpec(formData.repository); const rsp = await submitData(spec, formData.repository.token); if (rsp.error) { - setStepStatusInfo({ - status: 'error', - error: 'Repository request failed', - }); + if (isFetchError(rsp.error)) { + setStepStatusInfo({ + status: 'error', + error: { + title: repositoryRequestFailed, + message: rsp.error.data.message, + }, + }); + } else { + setStepStatusInfo({ + status: 'error', + error: repositoryRequestFailed, + }); + } return; } @@ -304,11 +328,19 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { const [field, errorMessage] = getFormErrors(error.data.errors); if (field && errorMessage) { setError(field, errorMessage); + } else { + setStepStatusInfo({ + status: 'error', + error: { + title: repositoryConnectionFailed, + message: error.data.message, + }, + }); } } else { setStepStatusInfo({ status: 'error', - error: 'Repository connection failed', + error: repositoryConnectionFailed, }); } } finally { @@ -357,7 +389,7 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
{activeStep === 'connection' && } - {activeStep === 'bootstrap' && } + {activeStep === 'bootstrap' && } {activeStep === 'synchronize' && ( ); -} +}); const getStyles = (theme: GrafanaTheme2) => ({ form: css({ diff --git a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx index 09ab5ff7a24..a763605a9b0 100644 --- a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx +++ b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx @@ -1,5 +1,5 @@ import { skipToken } from '@reduxjs/toolkit/query'; -import { useState } from 'react'; +import { memo, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { Trans, t } from '@grafana/i18n'; @@ -20,7 +20,11 @@ export interface SynchronizeStepProps { isCancelling?: boolean; } -export function SynchronizeStep({ isLegacyStorage, onCancel, isCancelling }: SynchronizeStepProps) { +export const SynchronizeStep = memo(function SynchronizeStep({ + isLegacyStorage, + onCancel, + isCancelling, +}: SynchronizeStepProps) { const { getValues, register, watch } = useFormContext(); const { setStepStatusInfo } = useStepStatus(); const [repoName = '', repoType] = watch(['repositoryName', 'repository.type']); @@ -40,7 +44,9 @@ export function SynchronizeStep({ isLegacyStorage, onCancel, isCancelling }: Syn message: repositoryHealthMessages, checked, } = repositoryStatusQuery?.data?.status?.health || {}; - const isButtonDisabled = checked !== undefined && isRepositoryHealthy === false; + + const hasError = repositoryStatusQuery.isError; + const isButtonDisabled = hasError || (checked !== undefined && isRepositoryHealthy === false); const startSynchronization = async () => { const [history] = getValues(['migrate.history']); @@ -65,7 +71,18 @@ export function SynchronizeStep({ isLegacyStorage, onCancel, isCancelling }: Syn to the repository and provisioned back into the instance. - {repositoryHealthMessages && !isRepositoryHealthy && ( + {hasError && ( + + )} + {repositoryHealthMessages && !isRepositoryHealthy && !hasError && ( - {isRepositoryHealthy === false ? ( + {hasError || isRepositoryHealthy === false ? (