From 52cd096d92ac36509eeaad285a65cfe0fa1ad1a2 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Fri, 9 Jan 2026 14:25:29 +0100 Subject: [PATCH 01/28] Secrets: Propagate ctx cancel and label it on decryptions (#116058) --- pkg/extensions/enterprise_imports.go | 3 -- pkg/storage/secret/metadata/decrypt_store.go | 17 ++++++++++ .../secret/metadata/decrypt_store_test.go | 34 +++++++++++++++++++ .../secret/metadata/metrics/metrics.go | 3 ++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index b0e748422a1..472652cc103 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -11,9 +11,6 @@ import ( _ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault" _ "github.com/Azure/go-autorest/autorest" _ "github.com/Azure/go-autorest/autorest/adal" - _ "github.com/aws/aws-sdk-go-v2/credentials" - _ "github.com/aws/aws-sdk-go-v2/service/secretsmanager" - _ "github.com/aws/aws-sdk-go-v2/service/sts" _ "github.com/beevik/etree" _ "github.com/blugelabs/bluge" _ "github.com/blugelabs/bluge_segment_api" diff --git a/pkg/storage/secret/metadata/decrypt_store.go b/pkg/storage/secret/metadata/decrypt_store.go index 0896ab89df4..b8c241ccf7f 100644 --- a/pkg/storage/secret/metadata/decrypt_store.go +++ b/pkg/storage/secret/metadata/decrypt_store.go @@ -127,6 +127,10 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, // function call happens after this. sv, err := s.secureValueMetadataStorage.Read(ctx, namespace, name, contracts.ReadOpts{}) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while reading secure value metadata storage: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to read secure value metadata storage: %v (%w)", err, contracts.ErrDecryptNotFound) } @@ -137,6 +141,10 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, keeperConfig, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, namespace.String(), sv.Status.Keeper, contracts.ReadOpts{}) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while reading keeper config metadata storage: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to read keeper config metadata storage: %v (%w)", err, contracts.ErrDecryptFailed) } @@ -148,13 +156,22 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, if sv.Spec.Ref != nil { exposedValue, err := keeper.RetrieveReference(ctx, keeperConfig, *sv.Spec.Ref) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while exposing secret using reference: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to expose secret using reference: %v (%w)", err, contracts.ErrDecryptFailed) } + return exposedValue, nil } exposedValue, err := keeper.Expose(ctx, keeperConfig, namespace, name, sv.Status.Version) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while exposing secret: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to expose secret: %v (%w)", err, contracts.ErrDecryptFailed) } diff --git a/pkg/storage/secret/metadata/decrypt_store_test.go b/pkg/storage/secret/metadata/decrypt_store_test.go index 36e49df0146..9d8b1b46e76 100644 --- a/pkg/storage/secret/metadata/decrypt_store_test.go +++ b/pkg/storage/secret/metadata/decrypt_store_test.go @@ -68,6 +68,40 @@ func TestIntegrationDecrypt(t *testing.T) { } }) + t.Run("when the context is cancelled, it returns an error", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + + svcIdentity := "svc" + + // Create auth context with proper permissions that match the decrypters + authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues:decrypt"}, svcIdentity, types.TypeUser) + + // Setup service + sut := testutils.Setup(t) + + // Create a secure value + spec := secretv1beta1.SecureValueSpec{ + Description: "description", + Decrypters: []string{svcIdentity}, + Value: ptr.To(secretv1beta1.NewExposedSecureValue("value")), + } + sv := &secretv1beta1.SecureValue{Spec: spec} + sv.Name = "sv-test" + sv.Namespace = "default" + + _, err := sut.CreateSv(authCtx, testutils.CreateSvWithSv(sv)) + require.NoError(t, err) + + // Cancel immediately! + cancel() + + exposed, err := sut.DecryptStorage.Decrypt(authCtx, "default", "sv-test") + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, exposed) + }) + t.Run("when happy path with valid auth and permissions, it returns decrypted value", func(t *testing.T) { t.Parallel() diff --git a/pkg/storage/secret/metadata/metrics/metrics.go b/pkg/storage/secret/metadata/metrics/metrics.go index 094a19fa30d..3dce608d7d7 100644 --- a/pkg/storage/secret/metadata/metrics/metrics.go +++ b/pkg/storage/secret/metadata/metrics/metrics.go @@ -1,6 +1,7 @@ package metrics import ( + "context" "errors" "sync" @@ -185,6 +186,8 @@ func DecryptResultLabel(err error) string { return "error_not_found" } else if errors.Is(err, contracts.ErrDecryptNotAuthorized) { return "error_unauthorized" + } else if errors.Is(err, context.Canceled) { + return "error_context_canceled" } return "error_generic_failure" From 9cd811b9e64c7cbfeee727f2100877b7d71ef535 Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Fri, 9 Jan 2026 07:28:29 -0600 Subject: [PATCH 02/28] LogsDrilldownDefaultColumns: Upgrade API from alpha to beta (#116035) * chore: release logsdrilldown default columns v1beta1 --- .../definitions/logsdrilldown-manifest.json | 10 +- ...aultcolumns.logsdrilldown.grafana.app.json | 2 +- apps/logsdrilldown/kinds/logsdrilldown.cue | 8 +- apps/logsdrilldown/kinds/manifest.cue | 36 +- .../kinds/v1beta1/defaultcolumns.cue | 19 + .../logsdrilldown/v1beta1}/constants.go | 4 +- .../logsdrilldowndefaultcolumns_client_gen.go | 2 +- .../logsdrilldowndefaultcolumns_codec_gen.go | 2 +- ...ogsdrilldowndefaultcolumns_metadata_gen.go | 2 +- .../logsdrilldowndefaultcolumns_object_gen.go | 2 +- .../logsdrilldowndefaultcolumns_schema_gen.go | 4 +- .../logsdrilldowndefaultcolumns_spec_gen.go | 2 +- .../logsdrilldowndefaultcolumns_status_gen.go | 2 +- .../pkg/apis/logsdrilldown_manifest.go | 40 +- apps/logsdrilldown/pkg/app/app.go | 3 +- .../v1alpha1/logsdrilldown_client_gen.go | 99 - .../v1alpha1/logsdrilldown_codec_gen.go | 28 - .../v1alpha1/logsdrilldown_metadata_gen.go | 31 - .../v1alpha1/logsdrilldown_object_gen.go | 319 --- .../v1alpha1/logsdrilldown_schema_gen.go | 34 - .../v1alpha1/logsdrilldown_spec_gen.go | 18 - .../v1alpha1/logsdrilldown_status_gen.go | 44 - .../v1alpha1/constants.go | 18 - .../logsdrilldowndefaultcolumns_client_gen.go | 99 - .../logsdrilldowndefaultcolumns_codec_gen.go | 28 - ...ogsdrilldowndefaultcolumns_metadata_gen.go | 31 - .../logsdrilldowndefaultcolumns_object_gen.go | 319 --- .../logsdrilldowndefaultcolumns_schema_gen.go | 34 - .../logsdrilldowndefaultcolumns_spec_gen.go | 43 - .../logsdrilldowndefaultcolumns_status_gen.go | 44 - .../v1alpha1/constants.go | 18 - .../logsdrilldowndefaults_client_gen.go | 99 - .../logsdrilldowndefaults_codec_gen.go | 28 - .../logsdrilldowndefaults_metadata_gen.go | 31 - .../logsdrilldowndefaults_object_gen.go | 319 --- .../logsdrilldowndefaults_schema_gen.go | 34 - .../logsdrilldowndefaults_spec_gen.go | 18 - .../logsdrilldowndefaults_status_gen.go | 44 - .../manifestdata/logsdrilldown_manifest.go | 150 -- .../logsdrilldowndefaultcolumns_object_gen.ts | 49 + .../v1beta1/types.metadata.gen.ts | 30 + .../v1beta1/types.spec.gen.ts | 38 + .../v1beta1/types.status.gen.ts | 30 + packages/grafana-api-clients/package.json | 6 + .../src/clients/rtkq/index.ts | 3 + .../rtkq/logsdrilldown/v1beta1/baseAPI.ts | 16 + .../logsdrilldown/v1beta1/endpoints.gen.ts | 652 ++++++ .../rtkq/logsdrilldown/v1beta1/index.ts | 5 + .../src/scripts/generate-rtk-apis.ts | 1 + .../logsdrilldown.grafana.app-v1beta1.json | 1895 +++++++++++++++++ pkg/tests/apis/openapi_test.go | 2 +- 51 files changed, 2831 insertions(+), 1964 deletions(-) create mode 100644 apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue rename apps/logsdrilldown/pkg/{generated/logsdrilldown/v1alpha1 => apis/logsdrilldown/v1beta1}/constants.go (91%) rename apps/logsdrilldown/pkg/apis/logsdrilldown/{v1alpha1 => v1beta1}/logsdrilldowndefaultcolumns_client_gen.go (99%) rename apps/logsdrilldown/pkg/apis/logsdrilldown/{v1alpha1 => v1beta1}/logsdrilldowndefaultcolumns_codec_gen.go (98%) rename apps/logsdrilldown/pkg/apis/logsdrilldown/{v1alpha1 => v1beta1}/logsdrilldowndefaultcolumns_metadata_gen.go (98%) rename apps/logsdrilldown/pkg/apis/logsdrilldown/{v1alpha1 => v1beta1}/logsdrilldowndefaultcolumns_object_gen.go (99%) rename apps/logsdrilldown/pkg/apis/logsdrilldown/{v1alpha1 => v1beta1}/logsdrilldowndefaultcolumns_schema_gen.go (85%) rename apps/logsdrilldown/pkg/apis/logsdrilldown/{v1alpha1 => v1beta1}/logsdrilldowndefaultcolumns_spec_gen.go (99%) rename apps/logsdrilldown/pkg/apis/logsdrilldown/{v1alpha1 => v1beta1}/logsdrilldowndefaultcolumns_status_gen.go (99%) delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go delete mode 100644 apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go create mode 100644 apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts create mode 100644 apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.metadata.gen.ts create mode 100644 apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts create mode 100644 apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.status.gen.ts create mode 100644 packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts create mode 100644 packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts create mode 100644 packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts create mode 100644 pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json diff --git a/apps/logsdrilldown/definitions/logsdrilldown-manifest.json b/apps/logsdrilldown/definitions/logsdrilldown-manifest.json index da34c0c70f2..3ef92d3d5b8 100644 --- a/apps/logsdrilldown/definitions/logsdrilldown-manifest.json +++ b/apps/logsdrilldown/definitions/logsdrilldown-manifest.json @@ -191,7 +191,13 @@ } }, "conversion": false - }, + } + ] + }, + { + "name": "v1beta1", + "served": true, + "kinds": [ { "kind": "LogsDrilldownDefaultColumns", "plural": "LogsDrilldownDefaultColumns", @@ -314,6 +320,6 @@ ] } ], - "preferredVersion": "v1alpha1" + "preferredVersion": "v1beta1" } } diff --git a/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json b/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json index 28aa314311d..c8987741983 100644 --- a/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json +++ b/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json @@ -8,7 +8,7 @@ "group": "logsdrilldown.grafana.app", "versions": [ { - "name": "v1alpha1", + "name": "v1beta1", "served": true, "storage": true, "schema": { diff --git a/apps/logsdrilldown/kinds/logsdrilldown.cue b/apps/logsdrilldown/kinds/logsdrilldown.cue index d2752103820..4b2d815743d 100644 --- a/apps/logsdrilldown/kinds/logsdrilldown.cue +++ b/apps/logsdrilldown/kinds/logsdrilldown.cue @@ -1,7 +1,7 @@ package kinds import ( - "github.com/grafana/grafana/apps/logsdrilldown/kinds/v0alpha1" + "github.com/grafana/grafana/apps/logsdrilldown/kinds/v1beta1", ) LogsDrilldownSpecv1alpha1: { @@ -26,11 +26,11 @@ logsdrilldownDefaultsv1alpha1: { } } -// Default columns API -logsdrilldownDefaultColumnsv0alpha1: { +// Default columns API (beta) +logsdrilldownDefaultColumnsv1beta1: { kind: "LogsDrilldownDefaultColumns" pluralName: "LogsDrilldownDefaultColumns" schema: { - spec: v0alpha1.LogsDefaultColumns + spec: v1beta1.LogsDefaultColumns } } diff --git a/apps/logsdrilldown/kinds/manifest.cue b/apps/logsdrilldown/kinds/manifest.cue index ab717de6a92..cbd47ce2b65 100644 --- a/apps/logsdrilldown/kinds/manifest.cue +++ b/apps/logsdrilldown/kinds/manifest.cue @@ -15,6 +15,7 @@ manifest: { // If your app needs access to kinds managed by another app, use permissions.accessKinds to allow your app access. versions: { "v1alpha1": v1alpha1 + "v1beta1" : v1beta1 } // extraPermissions contains any additional permissions your app may require to function. // Your app will always have all permissions for each kind it manages (the items defined in 'kinds'). @@ -35,7 +36,40 @@ manifest: { // It includes kinds which the v1alpha1 API serves, and (future) custom routes served globally from the v1alpha1 version. v1alpha1: { // kinds is the list of kinds served by this version - kinds: [logsdrilldownv1alpha1, logsdrilldownDefaultsv1alpha1, logsdrilldownDefaultColumnsv0alpha1] + kinds: [logsdrilldownv1alpha1, logsdrilldownDefaultsv1alpha1] + // [OPTIONAL] + // served indicates whether this particular version is served by the API server. + // served should be set to false before a version is removed from the manifest entirely. + // served defaults to true if not present. + served: true + // [OPTIONAL] + // Codegen is a trait that tells the grafana-app-sdk, or other code generation tooling, how to process this kind. + // If not present, default values within the codegen trait are used. + // If you wish to specify codegen per-version, put this section in the version's object + // (for example, v1alpha1) instead. + codegen: { + // [OPTIONAL] + // ts contains TypeScript code generation properties for the kind + ts: { + // [OPTIONAL] + // enabled indicates whether the CLI should generate front-end TypeScript code for the kind. + // Defaults to true if not present. + enabled: true + } + // [OPTIONAL] + // go contains go code generation properties for the kind + go: { + // [OPTIONAL] + // enabled indicates whether the CLI should generate back-end go code for the kind. + // Defaults to true if not present. + enabled: true + } + } +} + +v1beta1: { + // kinds is the list of kinds served by this version + kinds: [logsdrilldownDefaultColumnsv1beta1] // [OPTIONAL] // served indicates whether this particular version is served by the API server. // served should be set to false before a version is removed from the manifest entirely. diff --git a/apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue b/apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue new file mode 100644 index 00000000000..a66e813766b --- /dev/null +++ b/apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue @@ -0,0 +1,19 @@ +package v1beta1 + +#LogsDefaultColumnsLabel: { + key: string + value: string +} + +#LogsDefaultColumnsLabels: [...#LogsDefaultColumnsLabel] + +#LogsDefaultColumnsRecord: { + columns: [...string] + labels: #LogsDefaultColumnsLabels +} + +#LogsDefaultColumnsRecords: [...#LogsDefaultColumnsRecord] + +LogsDefaultColumns: { + records: #LogsDefaultColumnsRecords +} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/constants.go similarity index 91% rename from apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/constants.go index 082bec7c874..ecfc11a456f 100644 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/constants.go @@ -1,4 +1,4 @@ -package v1alpha1 +package v1beta1 import "k8s.io/apimachinery/pkg/runtime/schema" @@ -6,7 +6,7 @@ const ( // APIGroup is the API group used by all kinds in this package APIGroup = "logsdrilldown.grafana.app" // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" + APIVersion = "v1beta1" ) var ( diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_client_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_client_gen.go index b5d573bc1dc..856c3af291b 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_client_gen.go @@ -1,4 +1,4 @@ -package v1alpha1 +package v1beta1 import ( "context" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_codec_gen.go similarity index 98% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_codec_gen.go index 311d2f02683..12622814e72 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_codec_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "encoding/json" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_metadata_gen.go similarity index 98% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_metadata_gen.go index a4bb052fe25..ee2d44fde2e 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_metadata_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 import ( time "time" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_object_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_object_gen.go index 4340a27714e..6822fbfb4d7 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_object_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "fmt" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_schema_gen.go similarity index 85% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_schema_gen.go index cc5363e16bb..49d234f47a5 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_schema_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "github.com/grafana/grafana-app-sdk/resource" @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaLogsDrilldownDefaultColumns = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", NewLogsDrilldownDefaultColumns(), &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), + schemaLogsDrilldownDefaultColumns = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1beta1", NewLogsDrilldownDefaultColumns(), &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), resource.WithPlural("logsdrilldowndefaultcolumns"), resource.WithScope(resource.NamespacedScope)) kindLogsDrilldownDefaultColumns = resource.Kind{ Schema: schemaLogsDrilldownDefaultColumns, diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_spec_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_spec_gen.go index ce12ebb0761..6163f66c52c 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_spec_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 // +k8s:openapi-gen=true type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords []LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_status_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_status_gen.go index c2183832095..e109592eb09 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_status_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 // +k8s:openapi-gen=true type LogsDrilldownDefaultColumnsstatusOperatorState struct { diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go b/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go index 2350b924dda..a242d11b2bf 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go @@ -17,24 +17,25 @@ import ( "k8s.io/kube-openapi/pkg/validation/spec" v1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1" + v1beta1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1" ) var ( - rawSchemaLogsDrilldownv1alpha1 = []byte(`{"LogsDrilldown":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownv1alpha1, &versionSchemaLogsDrilldownv1alpha1) - rawSchemaLogsDrilldownDefaultsv1alpha1 = []byte(`{"LogsDrilldownDefaults":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultsv1alpha1, &versionSchemaLogsDrilldownDefaultsv1alpha1) - rawSchemaLogsDrilldownDefaultColumnsv1alpha1 = []byte(`{"LogsDefaultColumnsLabel":{"additionalProperties":false,"properties":{"key":{"type":"string"},"value":{"type":"string"}},"required":["key","value"],"type":"object"},"LogsDefaultColumnsLabels":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsLabel"},"type":"array"},"LogsDefaultColumnsRecord":{"additionalProperties":false,"properties":{"columns":{"items":{"type":"string"},"type":"array"},"labels":{"$ref":"#/components/schemas/LogsDefaultColumnsLabels"}},"required":["columns","labels"],"type":"object"},"LogsDefaultColumnsRecords":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsRecord"},"type":"array"},"LogsDrilldownDefaultColumns":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"records":{"$ref":"#/components/schemas/LogsDefaultColumnsRecords"}},"required":["records"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultColumnsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultColumnsv1alpha1, &versionSchemaLogsDrilldownDefaultColumnsv1alpha1) + rawSchemaLogsDrilldownv1alpha1 = []byte(`{"LogsDrilldown":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaLogsDrilldownv1alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaLogsDrilldownv1alpha1, &versionSchemaLogsDrilldownv1alpha1) + rawSchemaLogsDrilldownDefaultsv1alpha1 = []byte(`{"LogsDrilldownDefaults":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaLogsDrilldownDefaultsv1alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultsv1alpha1, &versionSchemaLogsDrilldownDefaultsv1alpha1) + rawSchemaLogsDrilldownDefaultColumnsv1beta1 = []byte(`{"LogsDefaultColumnsLabel":{"additionalProperties":false,"properties":{"key":{"type":"string"},"value":{"type":"string"}},"required":["key","value"],"type":"object"},"LogsDefaultColumnsLabels":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsLabel"},"type":"array"},"LogsDefaultColumnsRecord":{"additionalProperties":false,"properties":{"columns":{"items":{"type":"string"},"type":"array"},"labels":{"$ref":"#/components/schemas/LogsDefaultColumnsLabels"}},"required":["columns","labels"],"type":"object"},"LogsDefaultColumnsRecords":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsRecord"},"type":"array"},"LogsDrilldownDefaultColumns":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"records":{"$ref":"#/components/schemas/LogsDefaultColumnsRecords"}},"required":["records"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaLogsDrilldownDefaultColumnsv1beta1 app.VersionSchema + _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultColumnsv1beta1, &versionSchemaLogsDrilldownDefaultColumnsv1beta1) ) var appManifestData = app.ManifestData{ AppName: "logsdrilldown", Group: "logsdrilldown.grafana.app", - PreferredVersion: "v1alpha1", + PreferredVersion: "v1beta1", Versions: []app.ManifestVersion{ { Name: "v1alpha1", @@ -55,13 +56,24 @@ var appManifestData = app.ManifestData{ Conversion: false, Schema: &versionSchemaLogsDrilldownDefaultsv1alpha1, }, + }, + Routes: app.ManifestVersionRoutes{ + Namespaced: map[string]spec3.PathProps{}, + Cluster: map[string]spec3.PathProps{}, + Schemas: map[string]spec.Schema{}, + }, + }, + { + Name: "v1beta1", + Served: true, + Kinds: []app.ManifestVersionKind{ { Kind: "LogsDrilldownDefaultColumns", Plural: "LogsDrilldownDefaultColumns", Scope: "Namespaced", Conversion: false, - Schema: &versionSchemaLogsDrilldownDefaultColumnsv1alpha1, + Schema: &versionSchemaLogsDrilldownDefaultColumnsv1beta1, }, }, Routes: app.ManifestVersionRoutes{ @@ -82,9 +94,9 @@ func RemoteManifest() app.Manifest { } var kindVersionToGoType = map[string]resource.Kind{ - "LogsDrilldown/v1alpha1": v1alpha1.LogsDrilldownKind(), - "LogsDrilldownDefaults/v1alpha1": v1alpha1.LogsDrilldownDefaultsKind(), - "LogsDrilldownDefaultColumns/v1alpha1": v1alpha1.LogsDrilldownDefaultColumnsKind(), + "LogsDrilldown/v1alpha1": v1alpha1.LogsDrilldownKind(), + "LogsDrilldownDefaults/v1alpha1": v1alpha1.LogsDrilldownDefaultsKind(), + "LogsDrilldownDefaultColumns/v1beta1": v1beta1.LogsDrilldownDefaultColumnsKind(), } // ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. diff --git a/apps/logsdrilldown/pkg/app/app.go b/apps/logsdrilldown/pkg/app/app.go index 23260270207..1e3e37851e2 100644 --- a/apps/logsdrilldown/pkg/app/app.go +++ b/apps/logsdrilldown/pkg/app/app.go @@ -11,6 +11,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" logsdrilldownv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1" + logsdrilldownv1beta1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1" ) func New(cfg app.Config) (app.App, error) { @@ -32,7 +33,7 @@ func New(cfg app.Config) (app.App, error) { Kind: logsdrilldownv1alpha1.LogsDrilldownDefaultsKind(), }, { - Kind: logsdrilldownv1alpha1.LogsDrilldownDefaultColumnsKind(), + Kind: logsdrilldownv1beta1.LogsDrilldownDefaultColumnsKind(), }, }, } diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go deleted file mode 100644 index c133b65f45b..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type LogsDrilldownClient struct { - client *resource.TypedClient[*LogsDrilldown, *LogsDrilldownList] -} - -func NewLogsDrilldownClient(client resource.Client) *LogsDrilldownClient { - return &LogsDrilldownClient{ - client: resource.NewTypedClient[*LogsDrilldown, *LogsDrilldownList](client, Kind()), - } -} - -func NewLogsDrilldownClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownClient, error) { - c, err := generator.ClientFor(Kind()) - if err != nil { - return nil, err - } - return NewLogsDrilldownClient(c), nil -} - -func (c *LogsDrilldownClient) Get(ctx context.Context, identifier resource.Identifier) (*LogsDrilldown, error) { - return c.client.Get(ctx, identifier) -} - -func (c *LogsDrilldownClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *LogsDrilldownClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *LogsDrilldownClient) Create(ctx context.Context, obj *LogsDrilldown, opts resource.CreateOptions) (*LogsDrilldown, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = Kind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *LogsDrilldownClient) Update(ctx context.Context, obj *LogsDrilldown, opts resource.UpdateOptions) (*LogsDrilldown, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *LogsDrilldownClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*LogsDrilldown, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *LogsDrilldownClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus Status, opts resource.UpdateOptions) (*LogsDrilldown, error) { - return c.client.Update(ctx, &LogsDrilldown{ - TypeMeta: metav1.TypeMeta{ - Kind: Kind().Kind(), - APIVersion: GroupVersion.Identifier(), - }, - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: opts.ResourceVersion, - Namespace: identifier.Namespace, - Name: identifier.Name, - }, - Status: newStatus, - }, resource.UpdateOptions{ - Subresource: "status", - ResourceVersion: opts.ResourceVersion, - }) -} - -func (c *LogsDrilldownClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go deleted file mode 100644 index bb458caeb88..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// JSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type JSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*JSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*JSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &JSONCodec{} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go deleted file mode 100644 index cb7233b22ab..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type Metadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewMetadata creates a new Metadata object. -func NewMetadata() *Metadata { - return &Metadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go deleted file mode 100644 index 5d40a873e6b..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go +++ /dev/null @@ -1,319 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type LogsDrilldown struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the LogsDrilldown - Spec Spec `json:"spec" yaml:"spec"` - - Status Status `json:"status" yaml:"status"` -} - -func (o *LogsDrilldown) GetSpec() any { - return o.Spec -} - -func (o *LogsDrilldown) SetSpec(spec any) error { - cast, ok := spec.(Spec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *LogsDrilldown) GetSubresources() map[string]any { - return map[string]any{ - "status": o.Status, - } -} - -func (o *LogsDrilldown) GetSubresource(name string) (any, bool) { - switch name { - case "status": - return o.Status, true - default: - return nil, false - } -} - -func (o *LogsDrilldown) SetSubresource(name string, value any) error { - switch name { - case "status": - cast, ok := value.(Status) - if !ok { - return fmt.Errorf("cannot set status type %#v, not of type Status", value) - } - o.Status = cast - return nil - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *LogsDrilldown) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *LogsDrilldown) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *LogsDrilldown) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *LogsDrilldown) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *LogsDrilldown) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *LogsDrilldown) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *LogsDrilldown) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *LogsDrilldown) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *LogsDrilldown) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *LogsDrilldown) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *LogsDrilldown) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *LogsDrilldown) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldown) DeepCopy() *LogsDrilldown { - cpy := &LogsDrilldown{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldown) DeepCopyInto(dst *LogsDrilldown) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) - o.Status.DeepCopyInto(&dst.Status) -} - -// Interface compliance compile-time check -var _ resource.Object = &LogsDrilldown{} - -// +k8s:openapi-gen=true -type LogsDrilldownList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []LogsDrilldown `json:"items" yaml:"items"` -} - -func (o *LogsDrilldownList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownList) Copy() resource.ListObject { - cpy := &LogsDrilldownList{ - TypeMeta: o.TypeMeta, - Items: make([]LogsDrilldown, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*LogsDrilldown); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *LogsDrilldownList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *LogsDrilldownList) SetItems(items []resource.Object) { - o.Items = make([]LogsDrilldown, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*LogsDrilldown) - } -} - -func (o *LogsDrilldownList) DeepCopy() *LogsDrilldownList { - cpy := &LogsDrilldownList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownList) DeepCopyInto(dst *LogsDrilldownList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &LogsDrilldownList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *Spec) DeepCopy() *Spec { - cpy := &Spec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *Spec) DeepCopyInto(dst *Spec) { - resource.CopyObjectInto(dst, s) -} - -// DeepCopy creates a full deep copy of Status -func (s *Status) DeepCopy() *Status { - cpy := &Status{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Status into another Status object -func (s *Status) DeepCopyInto(dst *Status) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go deleted file mode 100644 index 942794416e8..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaLogsDrilldown = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldown{}, &LogsDrilldownList{}, resource.WithKind("LogsDrilldown"), - resource.WithPlural("logsdrilldowns"), resource.WithScope(resource.NamespacedScope)) - kindLogsDrilldown = resource.Kind{ - Schema: schemaLogsDrilldown, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &JSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func Kind() resource.Kind { - return kindLogsDrilldown -} - -// Schema returns a resource.SimpleSchema representation of LogsDrilldown -func Schema() *resource.SimpleSchema { - return schemaLogsDrilldown -} - -// Interface compliance checks -var _ resource.Schema = kindLogsDrilldown diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go deleted file mode 100644 index faff5c108dd..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go +++ /dev/null @@ -1,18 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type Spec struct { - DefaultFields []string `json:"defaultFields"` - PrettifyJSON bool `json:"prettifyJSON"` - WrapLogMessage bool `json:"wrapLogMessage"` - InterceptDismissed bool `json:"interceptDismissed"` -} - -// NewSpec creates a new Spec object. -func NewSpec() *Spec { - return &Spec{ - DefaultFields: []string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go deleted file mode 100644 index 9b227b00f44..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type StatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State StatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewStatusOperatorState creates a new StatusOperatorState object. -func NewStatusOperatorState() *StatusOperatorState { - return &StatusOperatorState{} -} - -// +k8s:openapi-gen=true -type Status struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewStatus creates a new Status object. -func NewStatus() *Status { - return &Status{} -} - -// +k8s:openapi-gen=true -type StatusOperatorStateState string - -const ( - StatusOperatorStateStateSuccess StatusOperatorStateState = "success" - StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" - StatusOperatorStateStateFailed StatusOperatorStateState = "failed" -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go deleted file mode 100644 index 082bec7c874..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go +++ /dev/null @@ -1,18 +0,0 @@ -package v1alpha1 - -import "k8s.io/apimachinery/pkg/runtime/schema" - -const ( - // APIGroup is the API group used by all kinds in this package - APIGroup = "logsdrilldown.grafana.app" - // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" -) - -var ( - // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package - GroupVersion = schema.GroupVersion{ - Group: APIGroup, - Version: APIVersion, - } -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go deleted file mode 100644 index b66471eb4ba..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type LogsDrilldownDefaultColumnsClient struct { - client *resource.TypedClient[*LogsDrilldownDefaultColumns, *LogsDrilldownDefaultColumnsList] -} - -func NewLogsDrilldownDefaultColumnsClient(client resource.Client) *LogsDrilldownDefaultColumnsClient { - return &LogsDrilldownDefaultColumnsClient{ - client: resource.NewTypedClient[*LogsDrilldownDefaultColumns, *LogsDrilldownDefaultColumnsList](client, Kind()), - } -} - -func NewLogsDrilldownDefaultColumnsClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownDefaultColumnsClient, error) { - c, err := generator.ClientFor(Kind()) - if err != nil { - return nil, err - } - return NewLogsDrilldownDefaultColumnsClient(c), nil -} - -func (c *LogsDrilldownDefaultColumnsClient) Get(ctx context.Context, identifier resource.Identifier) (*LogsDrilldownDefaultColumns, error) { - return c.client.Get(ctx, identifier) -} - -func (c *LogsDrilldownDefaultColumnsClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultColumnsList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultColumnsList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *LogsDrilldownDefaultColumnsClient) Create(ctx context.Context, obj *LogsDrilldownDefaultColumns, opts resource.CreateOptions) (*LogsDrilldownDefaultColumns, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = Kind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) Update(ctx context.Context, obj *LogsDrilldownDefaultColumns, opts resource.UpdateOptions) (*LogsDrilldownDefaultColumns, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*LogsDrilldownDefaultColumns, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus Status, opts resource.UpdateOptions) (*LogsDrilldownDefaultColumns, error) { - return c.client.Update(ctx, &LogsDrilldownDefaultColumns{ - TypeMeta: metav1.TypeMeta{ - Kind: Kind().Kind(), - APIVersion: GroupVersion.Identifier(), - }, - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: opts.ResourceVersion, - Namespace: identifier.Namespace, - Name: identifier.Name, - }, - Status: newStatus, - }, resource.UpdateOptions{ - Subresource: "status", - ResourceVersion: opts.ResourceVersion, - }) -} - -func (c *LogsDrilldownDefaultColumnsClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go deleted file mode 100644 index bb458caeb88..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// JSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type JSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*JSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*JSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &JSONCodec{} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go deleted file mode 100644 index cb7233b22ab..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type Metadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewMetadata creates a new Metadata object. -func NewMetadata() *Metadata { - return &Metadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go deleted file mode 100644 index 3173c28330e..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go +++ /dev/null @@ -1,319 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type LogsDrilldownDefaultColumns struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the LogsDrilldownDefaultColumns - Spec Spec `json:"spec" yaml:"spec"` - - Status Status `json:"status" yaml:"status"` -} - -func (o *LogsDrilldownDefaultColumns) GetSpec() any { - return o.Spec -} - -func (o *LogsDrilldownDefaultColumns) SetSpec(spec any) error { - cast, ok := spec.(Spec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *LogsDrilldownDefaultColumns) GetSubresources() map[string]any { - return map[string]any{ - "status": o.Status, - } -} - -func (o *LogsDrilldownDefaultColumns) GetSubresource(name string) (any, bool) { - switch name { - case "status": - return o.Status, true - default: - return nil, false - } -} - -func (o *LogsDrilldownDefaultColumns) SetSubresource(name string, value any) error { - switch name { - case "status": - cast, ok := value.(Status) - if !ok { - return fmt.Errorf("cannot set status type %#v, not of type Status", value) - } - o.Status = cast - return nil - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *LogsDrilldownDefaultColumns) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *LogsDrilldownDefaultColumns) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *LogsDrilldownDefaultColumns) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *LogsDrilldownDefaultColumns) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *LogsDrilldownDefaultColumns) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *LogsDrilldownDefaultColumns) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *LogsDrilldownDefaultColumns) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *LogsDrilldownDefaultColumns) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *LogsDrilldownDefaultColumns) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *LogsDrilldownDefaultColumns) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *LogsDrilldownDefaultColumns) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *LogsDrilldownDefaultColumns) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaultColumns) DeepCopy() *LogsDrilldownDefaultColumns { - cpy := &LogsDrilldownDefaultColumns{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaultColumns) DeepCopyInto(dst *LogsDrilldownDefaultColumns) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) - o.Status.DeepCopyInto(&dst.Status) -} - -// Interface compliance compile-time check -var _ resource.Object = &LogsDrilldownDefaultColumns{} - -// +k8s:openapi-gen=true -type LogsDrilldownDefaultColumnsList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []LogsDrilldownDefaultColumns `json:"items" yaml:"items"` -} - -func (o *LogsDrilldownDefaultColumnsList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaultColumnsList) Copy() resource.ListObject { - cpy := &LogsDrilldownDefaultColumnsList{ - TypeMeta: o.TypeMeta, - Items: make([]LogsDrilldownDefaultColumns, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*LogsDrilldownDefaultColumns); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *LogsDrilldownDefaultColumnsList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *LogsDrilldownDefaultColumnsList) SetItems(items []resource.Object) { - o.Items = make([]LogsDrilldownDefaultColumns, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*LogsDrilldownDefaultColumns) - } -} - -func (o *LogsDrilldownDefaultColumnsList) DeepCopy() *LogsDrilldownDefaultColumnsList { - cpy := &LogsDrilldownDefaultColumnsList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaultColumnsList) DeepCopyInto(dst *LogsDrilldownDefaultColumnsList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &LogsDrilldownDefaultColumnsList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *Spec) DeepCopy() *Spec { - cpy := &Spec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *Spec) DeepCopyInto(dst *Spec) { - resource.CopyObjectInto(dst, s) -} - -// DeepCopy creates a full deep copy of Status -func (s *Status) DeepCopy() *Status { - cpy := &Status{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Status into another Status object -func (s *Status) DeepCopyInto(dst *Status) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go deleted file mode 100644 index b50be391fc7..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaLogsDrilldownDefaultColumns = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldownDefaultColumns{}, &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), - resource.WithPlural("logsdrilldowndefaultcolumns"), resource.WithScope(resource.NamespacedScope)) - kindLogsDrilldownDefaultColumns = resource.Kind{ - Schema: schemaLogsDrilldownDefaultColumns, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &JSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func Kind() resource.Kind { - return kindLogsDrilldownDefaultColumns -} - -// Schema returns a resource.SimpleSchema representation of LogsDrilldownDefaultColumns -func Schema() *resource.SimpleSchema { - return schemaLogsDrilldownDefaultColumns -} - -// Interface compliance checks -var _ resource.Schema = kindLogsDrilldownDefaultColumns diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go deleted file mode 100644 index d9cd977aeb9..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go +++ /dev/null @@ -1,43 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type LogsDefaultColumnsRecords []LogsDefaultColumnsRecord - -// +k8s:openapi-gen=true -type LogsDefaultColumnsRecord struct { - Columns []string `json:"columns"` - Labels LogsDefaultColumnsLabels `json:"labels"` -} - -// NewLogsDefaultColumnsRecord creates a new LogsDefaultColumnsRecord object. -func NewLogsDefaultColumnsRecord() *LogsDefaultColumnsRecord { - return &LogsDefaultColumnsRecord{ - Columns: []string{}, - } -} - -// +k8s:openapi-gen=true -type LogsDefaultColumnsLabels []LogsDefaultColumnsLabel - -// +k8s:openapi-gen=true -type LogsDefaultColumnsLabel struct { - Key string `json:"key"` - Value string `json:"value"` -} - -// NewLogsDefaultColumnsLabel creates a new LogsDefaultColumnsLabel object. -func NewLogsDefaultColumnsLabel() *LogsDefaultColumnsLabel { - return &LogsDefaultColumnsLabel{} -} - -// +k8s:openapi-gen=true -type Spec struct { - Records LogsDefaultColumnsRecords `json:"records"` -} - -// NewSpec creates a new Spec object. -func NewSpec() *Spec { - return &Spec{} -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go deleted file mode 100644 index 9b227b00f44..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type StatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State StatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewStatusOperatorState creates a new StatusOperatorState object. -func NewStatusOperatorState() *StatusOperatorState { - return &StatusOperatorState{} -} - -// +k8s:openapi-gen=true -type Status struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewStatus creates a new Status object. -func NewStatus() *Status { - return &Status{} -} - -// +k8s:openapi-gen=true -type StatusOperatorStateState string - -const ( - StatusOperatorStateStateSuccess StatusOperatorStateState = "success" - StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" - StatusOperatorStateStateFailed StatusOperatorStateState = "failed" -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go deleted file mode 100644 index 082bec7c874..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go +++ /dev/null @@ -1,18 +0,0 @@ -package v1alpha1 - -import "k8s.io/apimachinery/pkg/runtime/schema" - -const ( - // APIGroup is the API group used by all kinds in this package - APIGroup = "logsdrilldown.grafana.app" - // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" -) - -var ( - // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package - GroupVersion = schema.GroupVersion{ - Group: APIGroup, - Version: APIVersion, - } -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go deleted file mode 100644 index cc06a10b1e7..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type LogsDrilldownDefaultsClient struct { - client *resource.TypedClient[*LogsDrilldownDefaults, *LogsDrilldownDefaultsList] -} - -func NewLogsDrilldownDefaultsClient(client resource.Client) *LogsDrilldownDefaultsClient { - return &LogsDrilldownDefaultsClient{ - client: resource.NewTypedClient[*LogsDrilldownDefaults, *LogsDrilldownDefaultsList](client, Kind()), - } -} - -func NewLogsDrilldownDefaultsClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownDefaultsClient, error) { - c, err := generator.ClientFor(Kind()) - if err != nil { - return nil, err - } - return NewLogsDrilldownDefaultsClient(c), nil -} - -func (c *LogsDrilldownDefaultsClient) Get(ctx context.Context, identifier resource.Identifier) (*LogsDrilldownDefaults, error) { - return c.client.Get(ctx, identifier) -} - -func (c *LogsDrilldownDefaultsClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultsList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *LogsDrilldownDefaultsClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultsList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *LogsDrilldownDefaultsClient) Create(ctx context.Context, obj *LogsDrilldownDefaults, opts resource.CreateOptions) (*LogsDrilldownDefaults, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = Kind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultsClient) Update(ctx context.Context, obj *LogsDrilldownDefaults, opts resource.UpdateOptions) (*LogsDrilldownDefaults, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultsClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*LogsDrilldownDefaults, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *LogsDrilldownDefaultsClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus Status, opts resource.UpdateOptions) (*LogsDrilldownDefaults, error) { - return c.client.Update(ctx, &LogsDrilldownDefaults{ - TypeMeta: metav1.TypeMeta{ - Kind: Kind().Kind(), - APIVersion: GroupVersion.Identifier(), - }, - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: opts.ResourceVersion, - Namespace: identifier.Namespace, - Name: identifier.Name, - }, - Status: newStatus, - }, resource.UpdateOptions{ - Subresource: "status", - ResourceVersion: opts.ResourceVersion, - }) -} - -func (c *LogsDrilldownDefaultsClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go deleted file mode 100644 index bb458caeb88..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// JSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type JSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*JSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*JSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &JSONCodec{} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go deleted file mode 100644 index cb7233b22ab..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type Metadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewMetadata creates a new Metadata object. -func NewMetadata() *Metadata { - return &Metadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go deleted file mode 100644 index d9354522dd7..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go +++ /dev/null @@ -1,319 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type LogsDrilldownDefaults struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the LogsDrilldownDefaults - Spec Spec `json:"spec" yaml:"spec"` - - Status Status `json:"status" yaml:"status"` -} - -func (o *LogsDrilldownDefaults) GetSpec() any { - return o.Spec -} - -func (o *LogsDrilldownDefaults) SetSpec(spec any) error { - cast, ok := spec.(Spec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *LogsDrilldownDefaults) GetSubresources() map[string]any { - return map[string]any{ - "status": o.Status, - } -} - -func (o *LogsDrilldownDefaults) GetSubresource(name string) (any, bool) { - switch name { - case "status": - return o.Status, true - default: - return nil, false - } -} - -func (o *LogsDrilldownDefaults) SetSubresource(name string, value any) error { - switch name { - case "status": - cast, ok := value.(Status) - if !ok { - return fmt.Errorf("cannot set status type %#v, not of type Status", value) - } - o.Status = cast - return nil - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *LogsDrilldownDefaults) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *LogsDrilldownDefaults) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *LogsDrilldownDefaults) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *LogsDrilldownDefaults) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *LogsDrilldownDefaults) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *LogsDrilldownDefaults) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *LogsDrilldownDefaults) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *LogsDrilldownDefaults) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *LogsDrilldownDefaults) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *LogsDrilldownDefaults) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *LogsDrilldownDefaults) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *LogsDrilldownDefaults) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaults) DeepCopy() *LogsDrilldownDefaults { - cpy := &LogsDrilldownDefaults{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaults) DeepCopyInto(dst *LogsDrilldownDefaults) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) - o.Status.DeepCopyInto(&dst.Status) -} - -// Interface compliance compile-time check -var _ resource.Object = &LogsDrilldownDefaults{} - -// +k8s:openapi-gen=true -type LogsDrilldownDefaultsList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []LogsDrilldownDefaults `json:"items" yaml:"items"` -} - -func (o *LogsDrilldownDefaultsList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaultsList) Copy() resource.ListObject { - cpy := &LogsDrilldownDefaultsList{ - TypeMeta: o.TypeMeta, - Items: make([]LogsDrilldownDefaults, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*LogsDrilldownDefaults); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *LogsDrilldownDefaultsList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *LogsDrilldownDefaultsList) SetItems(items []resource.Object) { - o.Items = make([]LogsDrilldownDefaults, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*LogsDrilldownDefaults) - } -} - -func (o *LogsDrilldownDefaultsList) DeepCopy() *LogsDrilldownDefaultsList { - cpy := &LogsDrilldownDefaultsList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaultsList) DeepCopyInto(dst *LogsDrilldownDefaultsList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &LogsDrilldownDefaultsList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *Spec) DeepCopy() *Spec { - cpy := &Spec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *Spec) DeepCopyInto(dst *Spec) { - resource.CopyObjectInto(dst, s) -} - -// DeepCopy creates a full deep copy of Status -func (s *Status) DeepCopy() *Status { - cpy := &Status{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Status into another Status object -func (s *Status) DeepCopyInto(dst *Status) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go deleted file mode 100644 index bda3e49377d..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaLogsDrilldownDefaults = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldownDefaults{}, &LogsDrilldownDefaultsList{}, resource.WithKind("LogsDrilldownDefaults"), - resource.WithPlural("logsdrilldowndefaults"), resource.WithScope(resource.NamespacedScope)) - kindLogsDrilldownDefaults = resource.Kind{ - Schema: schemaLogsDrilldownDefaults, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &JSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func Kind() resource.Kind { - return kindLogsDrilldownDefaults -} - -// Schema returns a resource.SimpleSchema representation of LogsDrilldownDefaults -func Schema() *resource.SimpleSchema { - return schemaLogsDrilldownDefaults -} - -// Interface compliance checks -var _ resource.Schema = kindLogsDrilldownDefaults diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go deleted file mode 100644 index faff5c108dd..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go +++ /dev/null @@ -1,18 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type Spec struct { - DefaultFields []string `json:"defaultFields"` - PrettifyJSON bool `json:"prettifyJSON"` - WrapLogMessage bool `json:"wrapLogMessage"` - InterceptDismissed bool `json:"interceptDismissed"` -} - -// NewSpec creates a new Spec object. -func NewSpec() *Spec { - return &Spec{ - DefaultFields: []string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go deleted file mode 100644 index 9b227b00f44..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type StatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State StatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewStatusOperatorState creates a new StatusOperatorState object. -func NewStatusOperatorState() *StatusOperatorState { - return &StatusOperatorState{} -} - -// +k8s:openapi-gen=true -type Status struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewStatus creates a new Status object. -func NewStatus() *Status { - return &Status{} -} - -// +k8s:openapi-gen=true -type StatusOperatorStateState string - -const ( - StatusOperatorStateStateSuccess StatusOperatorStateState = "success" - StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" - StatusOperatorStateStateFailed StatusOperatorStateState = "failed" -) diff --git a/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go b/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go deleted file mode 100644 index 9deb5d5d3a1..00000000000 --- a/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go +++ /dev/null @@ -1,150 +0,0 @@ -// -// This file is generated by grafana-app-sdk -// DO NOT EDIT -// - -package manifestdata - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/resource" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/kube-openapi/pkg/spec3" - "k8s.io/kube-openapi/pkg/validation/spec" - - logsdrilldownv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1" - logsdrilldowndefaultcolumnsv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1" - logsdrilldowndefaultsv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1" -) - -var ( - rawSchemaLogsDrilldownv1alpha1 = []byte(`{"LogsDrilldown":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownv1alpha1, &versionSchemaLogsDrilldownv1alpha1) - rawSchemaLogsDrilldownDefaultsv1alpha1 = []byte(`{"LogsDrilldownDefaults":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultsv1alpha1, &versionSchemaLogsDrilldownDefaultsv1alpha1) - rawSchemaLogsDrilldownDefaultColumnsv1alpha1 = []byte(`{"LogsDefaultColumnsLabel":{"additionalProperties":false,"properties":{"key":{"type":"string"},"value":{"type":"string"}},"required":["key","value"],"type":"object"},"LogsDefaultColumnsLabels":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsLabel"},"type":"array"},"LogsDefaultColumnsRecord":{"additionalProperties":false,"properties":{"columns":{"items":{"type":"string"},"type":"array"},"labels":{"$ref":"#/components/schemas/LogsDefaultColumnsLabels"}},"required":["columns","labels"],"type":"object"},"LogsDefaultColumnsRecords":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsRecord"},"type":"array"},"LogsDrilldownDefaultColumns":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"records":{"$ref":"#/components/schemas/LogsDefaultColumnsRecords"}},"required":["records"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultColumnsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultColumnsv1alpha1, &versionSchemaLogsDrilldownDefaultColumnsv1alpha1) -) - -var appManifestData = app.ManifestData{ - AppName: "logsdrilldown", - Group: "logsdrilldown.grafana.app", - PreferredVersion: "v1alpha1", - Versions: []app.ManifestVersion{ - { - Name: "v1alpha1", - Served: true, - Kinds: []app.ManifestVersionKind{ - { - Kind: "LogsDrilldown", - Plural: "LogsDrilldowns", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaLogsDrilldownv1alpha1, - }, - - { - Kind: "LogsDrilldownDefaults", - Plural: "LogsDrilldownDefaults", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaLogsDrilldownDefaultsv1alpha1, - }, - - { - Kind: "LogsDrilldownDefaultColumns", - Plural: "LogsDrilldownDefaultColumns", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaLogsDrilldownDefaultColumnsv1alpha1, - }, - }, - Routes: app.ManifestVersionRoutes{ - Namespaced: map[string]spec3.PathProps{}, - Cluster: map[string]spec3.PathProps{}, - Schemas: map[string]spec.Schema{}, - }, - }, - }, -} - -func LocalManifest() app.Manifest { - return app.NewEmbeddedManifest(appManifestData) -} - -func RemoteManifest() app.Manifest { - return app.NewAPIServerManifest("logsdrilldown") -} - -var kindVersionToGoType = map[string]resource.Kind{ - "LogsDrilldown/v1alpha1": logsdrilldownv1alpha1.Kind(), - "LogsDrilldownDefaults/v1alpha1": logsdrilldowndefaultsv1alpha1.Kind(), - "LogsDrilldownDefaultColumns/v1alpha1": logsdrilldowndefaultcolumnsv1alpha1.Kind(), -} - -// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. -// If there is no association for the provided Kind and Version, exists will return false. -func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) { - goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)] - return goType, exists -} - -var customRouteToGoResponseType = map[string]any{} - -// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. -// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths. -// If there is no association for the provided kind, version, custom route path, and method, exists will return false. -// Resource routes (those without a kind) should prefix their route with "/" if the route is namespaced (otherwise the route is assumed to be cluster-scope) -func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoParamsType = map[string]runtime.Object{} - -func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoRequestBodyType = map[string]any{} - -func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -type GoTypeAssociator struct{} - -func NewGoTypeAssociator() *GoTypeAssociator { - return &GoTypeAssociator{} -} - -func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) { - return ManifestGoTypeAssociator(kind, version) -} -func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteResponsesAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) { - return ManifestCustomRouteQueryAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb) -} diff --git a/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts new file mode 100644 index 00000000000..f7ba7b0f223 --- /dev/null +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts @@ -0,0 +1,49 @@ +/* + * This file was generated by grafana-app-sdk. DO NOT EDIT. + */ +import { Spec } from './types.spec.gen'; +import { Status } from './types.status.gen'; + +export interface Metadata { + name: string; + namespace: string; + generateName?: string; + selfLink?: string; + uid?: string; + resourceVersion?: string; + generation?: number; + creationTimestamp?: string; + deletionTimestamp?: string; + deletionGracePeriodSeconds?: number; + labels?: Record; + annotations?: Record; + ownerReferences?: OwnerReference[]; + finalizers?: string[]; + managedFields?: ManagedFieldsEntry[]; +} + +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller?: boolean; + blockOwnerDeletion?: boolean; +} + +export interface ManagedFieldsEntry { + manager?: string; + operation?: string; + apiVersion?: string; + time?: string; + fieldsType?: string; + subresource?: string; +} + +export interface LogsDrilldownDefaultColumns { + kind: string; + apiVersion: string; + metadata: Metadata; + spec: Spec; + status: Status; +} diff --git a/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.metadata.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.metadata.gen.ts new file mode 100644 index 00000000000..4377f3c1d08 --- /dev/null +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.metadata.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +// metadata contains embedded CommonMetadata and can be extended with custom string fields +// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here +// without external reference as using the CommonMetadata reference breaks thema codegen. +export interface Metadata { + updateTimestamp: string; + createdBy: string; + uid: string; + creationTimestamp: string; + deletionTimestamp?: string; + finalizers: string[]; + resourceVersion: string; + generation: number; + updatedBy: string; + labels: Record; +} + +export const defaultMetadata = (): Metadata => ({ + updateTimestamp: "", + createdBy: "", + uid: "", + creationTimestamp: "", + finalizers: [], + resourceVersion: "", + generation: 0, + updatedBy: "", + labels: {}, +}); + diff --git a/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts new file mode 100644 index 00000000000..fde99894776 --- /dev/null +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts @@ -0,0 +1,38 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export type LogsDefaultColumnsRecords = LogsDefaultColumnsRecord[]; + +export const defaultLogsDefaultColumnsRecords = (): LogsDefaultColumnsRecords => ([]); + +export interface LogsDefaultColumnsRecord { + columns: string[]; + labels: LogsDefaultColumnsLabels; +} + +export const defaultLogsDefaultColumnsRecord = (): LogsDefaultColumnsRecord => ({ + columns: [], + labels: defaultLogsDefaultColumnsLabels(), +}); + +export type LogsDefaultColumnsLabels = LogsDefaultColumnsLabel[]; + +export const defaultLogsDefaultColumnsLabels = (): LogsDefaultColumnsLabels => ([]); + +export interface LogsDefaultColumnsLabel { + key: string; + value: string; +} + +export const defaultLogsDefaultColumnsLabel = (): LogsDefaultColumnsLabel => ({ + key: "", + value: "", +}); + +export interface Spec { + records: LogsDefaultColumnsRecords; +} + +export const defaultSpec = (): Spec => ({ + records: defaultLogsDefaultColumnsRecords(), +}); + diff --git a/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.status.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.status.gen.ts new file mode 100644 index 00000000000..01be8df7961 --- /dev/null +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.status.gen.ts @@ -0,0 +1,30 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export interface OperatorState { + // lastEvaluation is the ResourceVersion last evaluated + lastEvaluation: string; + // state describes the state of the lastEvaluation. + // It is limited to three possible states for machine evaluation. + state: "success" | "in_progress" | "failed"; + // descriptiveState is an optional more descriptive state field which has no requirements on format + descriptiveState?: string; + // details contains any extra information that is operator-specific + details?: Record; +} + +export const defaultOperatorState = (): OperatorState => ({ + lastEvaluation: "", + state: "success", +}); + +export interface Status { + // operatorStates is a map of operator ID to operator state evaluations. + // Any operator which consumes this kind SHOULD add its state evaluation information to this field. + operatorStates?: Record; + // additionalFields is reserved for future use + additionalFields?: Record; +} + +export const defaultStatus = (): Status => ({ +}); + diff --git a/packages/grafana-api-clients/package.json b/packages/grafana-api-clients/package.json index f424a729a74..f72eab55212 100644 --- a/packages/grafana-api-clients/package.json +++ b/packages/grafana-api-clients/package.json @@ -139,6 +139,12 @@ "types": "./dist/types/clients/rtkq/logsdrilldown/v1alpha1/index.d.ts", "import": "./dist/esm/clients/rtkq/logsdrilldown/v1alpha1/index.mjs", "require": "./dist/cjs/clients/rtkq/logsdrilldown/v1alpha1/index.cjs" + }, + "./rtkq/logsdrilldown/v1beta1": { + "@grafana-app/source": "./src/clients/rtkq/logsdrilldown/v1beta1/index.ts", + "types": "./dist/types/clients/rtkq/logsdrilldown/v1beta1/index.d.ts", + "import": "./dist/esm/clients/rtkq/logsdrilldown/v1beta1/index.mjs", + "require": "./dist/cjs/clients/rtkq/logsdrilldown/v1beta1/index.cjs" } }, "publishConfig": { diff --git a/packages/grafana-api-clients/src/clients/rtkq/index.ts b/packages/grafana-api-clients/src/clients/rtkq/index.ts index f7e6e3772c9..708a7b04d5b 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/index.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/index.ts @@ -9,6 +9,7 @@ import { generatedAPI as folderAPIv1beta1 } from './folder/v1beta1'; import { generatedAPI as historianAlertingAPIv0alpha1 } from './historian.alerting/v0alpha1'; import { generatedAPI as iamAPIv0alpha1 } from './iam/v0alpha1'; import { generatedAPI as logsdrilldownAPIv1alpha1 } from './logsdrilldown/v1alpha1'; +import { generatedAPI as logsdrilldownAPIv1beta1 } from './logsdrilldown/v1beta1'; import { generatedAPI as migrateToCloudAPI } from './migrate-to-cloud'; import { generatedAPI as notificationsAlertingAPIv0alpha1 } from './notifications.alerting/v0alpha1'; import { generatedAPI as playlistAPIv0alpha1 } from './playlist/v0alpha1'; @@ -38,6 +39,7 @@ export const allMiddleware = [ notificationsAlertingAPIv0alpha1.middleware, rulesAlertingAPIv0alpha1.middleware, historianAlertingAPIv0alpha1.middleware, + logsdrilldownAPIv1beta1.middleware, logsdrilldownAPIv1alpha1.middleware, // PLOP_INJECT_MIDDLEWARE ] as const; @@ -61,6 +63,7 @@ export const allReducers = { [rulesAlertingAPIv0alpha1.reducerPath]: rulesAlertingAPIv0alpha1.reducer, [historianAlertingAPIv0alpha1.reducerPath]: historianAlertingAPIv0alpha1.reducer, [logsdrilldownAPIv1alpha1.reducerPath]: logsdrilldownAPIv1alpha1.reducer, + [logsdrilldownAPIv1beta1.reducerPath]: logsdrilldownAPIv1beta1.reducer, // PLOP_INJECT_REDUCER }; diff --git a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts new file mode 100644 index 00000000000..484d5084e89 --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts @@ -0,0 +1,16 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; + +import { getAPIBaseURL } from '../../../../utils/utils'; +import { createBaseQuery } from '../../createBaseQuery'; + +export const API_GROUP = 'logsdrilldown.grafana.app' as const; +export const API_VERSION = 'v1beta1' as const; +export const BASE_URL = getAPIBaseURL(API_GROUP, API_VERSION); + +export const api = createApi({ + reducerPath: 'logsdrilldownAPIv1beta1', + baseQuery: createBaseQuery({ + baseURL: BASE_URL, + }), + endpoints: () => ({}), +}); diff --git a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts new file mode 100644 index 00000000000..65395d2279d --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts @@ -0,0 +1,652 @@ +import { api } from './baseAPI'; +export const addTagTypes = ['API Discovery', 'LogsDrilldownDefaultColumns'] as const; +const injectedRtkApi = api + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + getApiResources: build.query({ + query: () => ({ url: `/` }), + providesTags: ['API Discovery'], + }), + listLogsDrilldownDefaultColumns: build.query< + ListLogsDrilldownDefaultColumnsApiResponse, + ListLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns`, + params: { + pretty: queryArg.pretty, + allowWatchBookmarks: queryArg.allowWatchBookmarks, + continue: queryArg['continue'], + fieldSelector: queryArg.fieldSelector, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + watch: queryArg.watch, + }, + }), + providesTags: ['LogsDrilldownDefaultColumns'], + }), + createLogsDrilldownDefaultColumns: build.mutation< + CreateLogsDrilldownDefaultColumnsApiResponse, + CreateLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns`, + method: 'POST', + body: queryArg.logsDrilldownDefaultColumns, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + deletecollectionLogsDrilldownDefaultColumns: build.mutation< + DeletecollectionLogsDrilldownDefaultColumnsApiResponse, + DeletecollectionLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + continue: queryArg['continue'], + dryRun: queryArg.dryRun, + fieldSelector: queryArg.fieldSelector, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + getLogsDrilldownDefaultColumns: build.query< + GetLogsDrilldownDefaultColumnsApiResponse, + GetLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['LogsDrilldownDefaultColumns'], + }), + replaceLogsDrilldownDefaultColumns: build.mutation< + ReplaceLogsDrilldownDefaultColumnsApiResponse, + ReplaceLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + method: 'PUT', + body: queryArg.logsDrilldownDefaultColumns, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + deleteLogsDrilldownDefaultColumns: build.mutation< + DeleteLogsDrilldownDefaultColumnsApiResponse, + DeleteLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + updateLogsDrilldownDefaultColumns: build.mutation< + UpdateLogsDrilldownDefaultColumnsApiResponse, + UpdateLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + getLogsDrilldownDefaultColumnsStatus: build.query< + GetLogsDrilldownDefaultColumnsStatusApiResponse, + GetLogsDrilldownDefaultColumnsStatusApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}/status`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['LogsDrilldownDefaultColumns'], + }), + replaceLogsDrilldownDefaultColumnsStatus: build.mutation< + ReplaceLogsDrilldownDefaultColumnsStatusApiResponse, + ReplaceLogsDrilldownDefaultColumnsStatusApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}/status`, + method: 'PUT', + body: queryArg.logsDrilldownDefaultColumns, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + updateLogsDrilldownDefaultColumnsStatus: build.mutation< + UpdateLogsDrilldownDefaultColumnsStatusApiResponse, + UpdateLogsDrilldownDefaultColumnsStatusApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}/status`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + }), + overrideExisting: false, + }); +export { injectedRtkApi as generatedAPI }; +export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList; +export type GetApiResourcesApiArg = void; +export type ListLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ LogsDrilldownDefaultColumnsList; +export type ListLogsDrilldownDefaultColumnsApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ + allowWatchBookmarks?: boolean; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; + /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ + watch?: boolean; +}; +export type CreateLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns + | /** status 202 Accepted */ LogsDrilldownDefaultColumns; +export type CreateLogsDrilldownDefaultColumnsApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + logsDrilldownDefaultColumns: LogsDrilldownDefaultColumns; +}; +export type DeletecollectionLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ Status; +export type DeletecollectionLogsDrilldownDefaultColumnsApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; +}; +export type GetLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ LogsDrilldownDefaultColumns; +export type GetLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type ReplaceLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + logsDrilldownDefaultColumns: LogsDrilldownDefaultColumns; +}; +export type DeleteLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | Status + | /** status 202 Accepted */ Status; +export type DeleteLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; +export type UpdateLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type UpdateLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type GetLogsDrilldownDefaultColumnsStatusApiResponse = /** status 200 OK */ LogsDrilldownDefaultColumns; +export type GetLogsDrilldownDefaultColumnsStatusApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceLogsDrilldownDefaultColumnsStatusApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type ReplaceLogsDrilldownDefaultColumnsStatusApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + logsDrilldownDefaultColumns: LogsDrilldownDefaultColumns; +}; +export type UpdateLogsDrilldownDefaultColumnsStatusApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type UpdateLogsDrilldownDefaultColumnsStatusApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type ApiResource = { + /** categories is a list of the grouped resources this resource belongs to (e.g. 'all') */ + categories?: string[]; + /** group is the preferred group of the resource. Empty implies the group of the containing resource list. For subresources, this may have a different value, for example: Scale". */ + group?: string; + /** kind is the kind for the resource (e.g. 'Foo' is the kind for a resource 'foo') */ + kind: string; + /** name is the plural name of the resource. */ + name: string; + /** namespaced indicates if a resource is namespaced or not. */ + namespaced: boolean; + /** shortNames is a list of suggested short names of the resource. */ + shortNames?: string[]; + /** singularName is the singular name of the resource. This allows clients to handle plural and singular opaquely. The singularName is more correct for reporting status on a single item and both singular and plural are allowed from the kubectl CLI interface. */ + singularName: string; + /** The hash value of the storage version, the version this resource is converted to when written to the data store. Value must be treated as opaque by clients. Only equality comparison on the value is valid. This is an alpha feature and may change or be removed in the future. The field is populated by the apiserver only if the StorageVersionHash feature gate is enabled. This field will remain optional even if it graduates. */ + storageVersionHash?: string; + /** verbs is a list of supported kube verbs (this includes get, list, watch, create, update, patch, delete, deletecollection, and proxy) */ + verbs: string[]; + /** version is the preferred version of the resource. Empty implies the version of the containing resource list For subresources, this may have a different value, for example: v1 (while inside a v1beta1 version of the core resource's group)". */ + version?: string; +}; +export type ApiResourceList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** groupVersion is the group and version this APIResourceList is for. */ + groupVersion: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** resources contains the name of the resources and if they are namespaced. */ + resources: ApiResource[]; +}; +export type Time = string; +export type FieldsV1 = object; +export type ManagedFieldsEntry = { + /** APIVersion defines the version of this resource that this field set applies to. The format is "group/version" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted. */ + apiVersion?: string; + /** FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: "FieldsV1" */ + fieldsType?: string; + /** FieldsV1 holds the first JSON version format as described in the "FieldsV1" type. */ + fieldsV1?: FieldsV1; + /** Manager is an identifier of the workflow managing these fields. */ + manager?: string; + /** Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'. */ + operation?: string; + /** Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource. */ + subresource?: string; + /** Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over. */ + time?: Time; +}; +export type OwnerReference = { + /** API version of the referent. */ + apiVersion: string; + /** If true, AND if the owner has the "foregroundDeletion" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs "delete" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned. */ + blockOwnerDeletion?: boolean; + /** If true, this reference points to the managing controller. */ + controller?: boolean; + /** Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind: string; + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name: string; + /** UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid: string; +}; +export type ObjectMeta = { + /** Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations */ + annotations?: { + [key: string]: string; + }; + /** CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC. + + Populated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + creationTimestamp?: Time; + /** Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only. */ + deletionGracePeriodSeconds?: number; + /** DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested. + + Populated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + deletionTimestamp?: Time; + /** Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list. */ + finalizers?: string[]; + /** GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server. + + If this field is specified and the generated name exists, the server will return a 409. + + Applied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency */ + generateName?: string; + /** A sequence number representing a specific generation of the desired state. Populated by the system. Read-only. */ + generation?: number; + /** Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels */ + labels?: { + [key: string]: string; + }; + /** ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like "ci-cd". The set of fields is always in the version that the workflow used when modifying the object. */ + managedFields?: ManagedFieldsEntry[]; + /** Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name?: string; + /** Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. + + Must be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces */ + namespace?: string; + /** List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller. */ + ownerReferences?: OwnerReference[]; + /** An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. + + Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ + resourceVersion?: string; + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string; + /** UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. + + Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel = { + key: string; + value: string; +}; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels = LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel[]; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord = { + columns: string[]; + labels: LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels; +}; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords = + LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord[]; +export type LogsDrilldownDefaultColumnsSpec = { + records: LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords; +}; +export type LogsDrilldownDefaultColumnsOperatorState = { + /** descriptiveState is an optional more descriptive state field which has no requirements on format */ + descriptiveState?: string; + /** details contains any extra information that is operator-specific */ + details?: { + [key: string]: { + [key: string]: any; + }; + }; + /** lastEvaluation is the ResourceVersion last evaluated */ + lastEvaluation: string; + /** state describes the state of the lastEvaluation. + It is limited to three possible states for machine evaluation. */ + state: 'success' | 'in_progress' | 'failed'; +}; +export type LogsDrilldownDefaultColumnsStatus = { + /** additionalFields is reserved for future use */ + additionalFields?: { + [key: string]: { + [key: string]: any; + }; + }; + /** operatorStates is a map of operator ID to operator state evaluations. + Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ + operatorStates?: { + [key: string]: LogsDrilldownDefaultColumnsOperatorState; + }; +}; +export type LogsDrilldownDefaultColumns = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind: string; + metadata: ObjectMeta; + spec: LogsDrilldownDefaultColumnsSpec; + status?: LogsDrilldownDefaultColumnsStatus; +}; +export type ListMeta = { + /** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */ + continue?: string; + /** remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. */ + remainingItemCount?: number; + /** String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ + resourceVersion?: string; + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string; +}; +export type LogsDrilldownDefaultColumnsList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items: LogsDrilldownDefaultColumns[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata: ListMeta; +}; +export type StatusCause = { + /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. + + Examples: + "name" - the field "name" on the current resource + "items[0].name" - the field "name" on the first array entry in "items" */ + field?: string; + /** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */ + message?: string; + /** A machine-readable description of the cause of the error. If this value is empty there is no information available. */ + reason?: string; +}; +export type StatusDetails = { + /** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */ + causes?: StatusCause[]; + /** The group attribute of the resource associated with the status StatusReason. */ + group?: string; + /** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */ + name?: string; + /** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */ + retryAfterSeconds?: number; + /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type Status = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Suggested HTTP return code for this status, 0 if not set. */ + code?: number; + /** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */ + details?: StatusDetails; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** A human-readable description of the status of this operation. */ + message?: string; + /** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + metadata?: ListMeta; + /** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */ + reason?: string; + /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ + status?: string; +}; +export type Patch = object; +export const { + useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, + useListLogsDrilldownDefaultColumnsQuery, + useLazyListLogsDrilldownDefaultColumnsQuery, + useCreateLogsDrilldownDefaultColumnsMutation, + useDeletecollectionLogsDrilldownDefaultColumnsMutation, + useGetLogsDrilldownDefaultColumnsQuery, + useLazyGetLogsDrilldownDefaultColumnsQuery, + useReplaceLogsDrilldownDefaultColumnsMutation, + useDeleteLogsDrilldownDefaultColumnsMutation, + useUpdateLogsDrilldownDefaultColumnsMutation, + useGetLogsDrilldownDefaultColumnsStatusQuery, + useLazyGetLogsDrilldownDefaultColumnsStatusQuery, + useReplaceLogsDrilldownDefaultColumnsStatusMutation, + useUpdateLogsDrilldownDefaultColumnsStatusMutation, +} = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts new file mode 100644 index 00000000000..d80fd6d553a --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts @@ -0,0 +1,5 @@ +export { BASE_URL, API_GROUP, API_VERSION } from './baseAPI'; +import { generatedAPI as rawAPI } from './endpoints.gen'; + +export * from './endpoints.gen'; +export const generatedAPI = rawAPI.enhanceEndpoints({}); diff --git a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts index 5f529bfbf3d..8610da06920 100644 --- a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts +++ b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts @@ -111,6 +111,7 @@ const config: ConfigFile = { ...createAPIConfig('notifications.alerting', 'v0alpha1'), ...createAPIConfig('rules.alerting', 'v0alpha1'), ...createAPIConfig('historian.alerting', 'v0alpha1'), + ...createAPIConfig('logsdrilldown', 'v1beta1'), ...createAPIConfig('logsdrilldown', 'v1alpha1'), // PLOP_INJECT_API_CLIENT - Used by the API client generator }, diff --git a/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json b/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json new file mode 100644 index 00000000000..de166b1984d --- /dev/null +++ b/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json @@ -0,0 +1,1895 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "logsdrilldown.grafana.app/v1beta1" + }, + "paths": { + "/apis/logsdrilldown.grafana.app/v1beta1/": { + "get": { + "tags": [ + "API Discovery" + ], + "description": "Describe the available kubernetes resources", + "operationId": "getAPIResources", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList" + } + } + } + } + } + } + }, + "/apis/logsdrilldown.grafana.app/v1beta1/namespaces/{namespace}/logsdrilldowndefaultcolumns": { + "get": { + "tags": [ + "LogsDrilldownDefaultColumns" + ], + "description": "list or watch objects of kind LogsDrilldownDefaultColumns", + "operationId": "listLogsDrilldownDefaultColumns", + "parameters": [ + { + "name": "allowWatchBookmarks", + "in": "query", + "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "watch", + "in": "query", + "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" + } + }, + "application/json;stream=watch": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" + } + }, + "application/vnd.kubernetes.protobuf;stream=watch": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" + } + } + } + } + }, + "x-kubernetes-action": "list", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" + } + }, + "post": { + "tags": [ + "LogsDrilldownDefaultColumns" + ], + "description": "create LogsDrilldownDefaultColumns", + "operationId": "createLogsDrilldownDefaultColumns", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "post", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" + } + }, + "delete": { + "tags": [ + "LogsDrilldownDefaultColumns" + ], + "description": "delete collection of LogsDrilldownDefaultColumns", + "operationId": "deletecollectionLogsDrilldownDefaultColumns", + "parameters": [ + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "deletecollection", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" + } + }, + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "pretty", + "in": "query", + "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, + "/apis/logsdrilldown.grafana.app/v1beta1/namespaces/{namespace}/logsdrilldowndefaultcolumns/{name}": { + "get": { + "tags": [ + "LogsDrilldownDefaultColumns" + ], + "description": "read the specified LogsDrilldownDefaultColumns", + "operationId": "getLogsDrilldownDefaultColumns", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "get", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" + } + }, + "put": { + "tags": [ + "LogsDrilldownDefaultColumns" + ], + "description": "replace the specified LogsDrilldownDefaultColumns", + "operationId": "replaceLogsDrilldownDefaultColumns", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "put", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" + } + }, + "delete": { + "tags": [ + "LogsDrilldownDefaultColumns" + ], + "description": "delete LogsDrilldownDefaultColumns", + "operationId": "deleteLogsDrilldownDefaultColumns", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "delete", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" + } + }, + "patch": { + "tags": [ + "LogsDrilldownDefaultColumns" + ], + "description": "partially update the specified LogsDrilldownDefaultColumns", + "operationId": "updateLogsDrilldownDefaultColumns", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch).", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "force", + "in": "query", + "description": "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/apply-patch+yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/strategic-merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "patch", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the LogsDrilldownDefaultColumns", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "pretty", + "in": "query", + "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, + "/apis/logsdrilldown.grafana.app/v1beta1/namespaces/{namespace}/logsdrilldowndefaultcolumns/{name}/status": { + "get": { + "tags": [ + "LogsDrilldownDefaultColumns" + ], + "description": "read status of the specified LogsDrilldownDefaultColumns", + "operationId": "getLogsDrilldownDefaultColumnsStatus", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "get", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" + } + }, + "put": { + "tags": [ + "LogsDrilldownDefaultColumns" + ], + "description": "replace status of the specified LogsDrilldownDefaultColumns", + "operationId": "replaceLogsDrilldownDefaultColumnsStatus", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "put", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" + } + }, + "patch": { + "tags": [ + "LogsDrilldownDefaultColumns" + ], + "description": "partially update status of the specified LogsDrilldownDefaultColumns", + "operationId": "updateLogsDrilldownDefaultColumnsStatus", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch).", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "force", + "in": "query", + "description": "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/apply-patch+yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/strategic-merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "patch", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the LogsDrilldownDefaultColumns", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "pretty", + "in": "query", + "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + } + }, + "components": { + "schemas": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns": { + "type": "object", + "required": [ + "kind", + "apiVersion", + "metadata", + "spec" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + } + ] + }, + "spec": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsSpec" + }, + "status": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsStatus" + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "logsdrilldown.grafana.app", + "kind": "LogsDrilldownDefaultColumns", + "version": "v1beta1" + } + ] + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList": { + "type": "object", + "required": [ + "metadata", + "items" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" + } + ] + } + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "logsdrilldown.grafana.app", + "kind": "LogsDrilldownDefaultColumnsList", + "version": "v1beta1" + } + ] + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel" + } + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord": { + "type": "object", + "required": [ + "columns", + "labels" + ], + "properties": { + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "labels": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels" + } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords": { + "type": "array", + "items": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord" + } + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsOperatorState": { + "type": "object", + "required": [ + "lastEvaluation", + "state" + ], + "properties": { + "descriptiveState": { + "description": "descriptiveState is an optional more descriptive state field which has no requirements on format", + "type": "string" + }, + "details": { + "description": "details contains any extra information that is operator-specific", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + } + }, + "lastEvaluation": { + "description": "lastEvaluation is the ResourceVersion last evaluated", + "type": "string" + }, + "state": { + "description": "state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.", + "type": "string", + "enum": [ + "success", + "in_progress", + "failed" + ] + } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsSpec": { + "type": "object", + "required": [ + "records" + ], + "properties": { + "records": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords" + } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsStatus": { + "type": "object", + "properties": { + "additionalFields": { + "description": "additionalFields is reserved for future use", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + } + }, + "operatorStates": { + "description": "operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.", + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsOperatorState" + } + } + }, + "additionalProperties": false + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.APIResource": { + "description": "APIResource specifies the name of a resource and whether it is namespaced.", + "type": "object", + "required": [ + "name", + "singularName", + "namespaced", + "kind", + "verbs" + ], + "properties": { + "categories": { + "description": "categories is a list of the grouped resources this resource belongs to (e.g. 'all')", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "atomic" + }, + "group": { + "description": "group is the preferred group of the resource. Empty implies the group of the containing resource list. For subresources, this may have a different value, for example: Scale\".", + "type": "string" + }, + "kind": { + "description": "kind is the kind for the resource (e.g. 'Foo' is the kind for a resource 'foo')", + "type": "string", + "default": "" + }, + "name": { + "description": "name is the plural name of the resource.", + "type": "string", + "default": "" + }, + "namespaced": { + "description": "namespaced indicates if a resource is namespaced or not.", + "type": "boolean", + "default": false + }, + "shortNames": { + "description": "shortNames is a list of suggested short names of the resource.", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "atomic" + }, + "singularName": { + "description": "singularName is the singular name of the resource. This allows clients to handle plural and singular opaquely. The singularName is more correct for reporting status on a single item and both singular and plural are allowed from the kubectl CLI interface.", + "type": "string", + "default": "" + }, + "storageVersionHash": { + "description": "The hash value of the storage version, the version this resource is converted to when written to the data store. Value must be treated as opaque by clients. Only equality comparison on the value is valid. This is an alpha feature and may change or be removed in the future. The field is populated by the apiserver only if the StorageVersionHash feature gate is enabled. This field will remain optional even if it graduates.", + "type": "string" + }, + "verbs": { + "description": "verbs is a list of supported kube verbs (this includes get, list, watch, create, update, patch, delete, deletecollection, and proxy)", + "type": "array", + "items": { + "type": "string", + "default": "" + } + }, + "version": { + "description": "version is the preferred version of the resource. Empty implies the version of the containing resource list For subresources, this may have a different value, for example: v1 (while inside a v1beta1 version of the core resource's group)\".", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList": { + "description": "APIResourceList is a list of APIResource, it is used to expose the name of the resources supported in a specific group and version, and if the resource is namespaced.", + "type": "object", + "required": [ + "groupVersion", + "resources" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "groupVersion": { + "description": "groupVersion is the group and version this APIResourceList is for.", + "type": "string", + "default": "" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "resources": { + "description": "resources contains the name of the resources and if they are namespaced.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIResource" + } + ] + }, + "x-kubernetes-list-type": "atomic" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.DeleteOptions": { + "description": "DeleteOptions may be provided when deleting an API object.", + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "dryRun": { + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "atomic" + }, + "gracePeriodSeconds": { + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "type": "integer", + "format": "int64" + }, + "ignoreStoreReadErrorWithClusterBreakingPotential": { + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "type": "boolean" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "orphanDependents": { + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "type": "boolean" + }, + "preconditions": { + "description": "Must be fulfilled before a deletion is carried out. If not possible, a 409 Conflict status will be returned.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Preconditions" + } + ] + }, + "propagationPolicy": { + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1": { + "description": "FieldsV1 stores a set of fields in a data structure like a Trie, in JSON format.\n\nEach key is either a '.' representing the field itself, and will always map to an empty set, or a string representing a sub-field or item. The string will follow one of these four formats: 'f:\u003cname\u003e', where \u003cname\u003e is the name of a field in a struct, or key in a map 'v:\u003cvalue\u003e', where \u003cvalue\u003e is the exact json formatted value of a list item 'i:\u003cindex\u003e', where \u003cindex\u003e is position of a item in a list 'k:\u003ckeys\u003e', where \u003ckeys\u003e is a map of a list item's key fields to their unique values If a key maps to an empty Fields value, the field that key represents is part of the set.\n\nThe exact format is defined in sigs.k8s.io/structured-merge-diff", + "type": "object" + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta": { + "description": "ListMeta describes metadata that synthetic resources must have, including lists and various status objects. A resource may have only one of {ObjectMeta, ListMeta}.", + "type": "object", + "properties": { + "continue": { + "description": "continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message.", + "type": "string" + }, + "remainingItemCount": { + "description": "remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact.", + "type": "integer", + "format": "int64" + }, + "resourceVersion": { + "description": "String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency", + "type": "string" + }, + "selfLink": { + "description": "Deprecated: selfLink is a legacy read-only field that is no longer populated by the system.", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ManagedFieldsEntry": { + "description": "ManagedFieldsEntry is a workflow-id, a FieldSet and the group version of the resource that the fieldset applies to.", + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the version of this resource that this field set applies to. The format is \"group/version\" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted.", + "type": "string" + }, + "fieldsType": { + "description": "FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: \"FieldsV1\"", + "type": "string" + }, + "fieldsV1": { + "description": "FieldsV1 holds the first JSON version format as described in the \"FieldsV1\" type.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1" + } + ] + }, + "manager": { + "description": "Manager is an identifier of the workflow managing these fields.", + "type": "string" + }, + "operation": { + "description": "Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'.", + "type": "string" + }, + "subresource": { + "description": "Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource.", + "type": "string" + }, + "time": { + "description": "Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Time" + } + ] + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": { + "description": "ObjectMeta is metadata that all persisted resources must have, which includes all objects users must create.", + "type": "object", + "properties": { + "annotations": { + "description": "Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations", + "type": "object", + "additionalProperties": { + "type": "string", + "default": "" + } + }, + "creationTimestamp": { + "description": "CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\n\nPopulated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Time" + } + ] + }, + "deletionGracePeriodSeconds": { + "description": "Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only.", + "type": "integer", + "format": "int64" + }, + "deletionTimestamp": { + "description": "DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested.\n\nPopulated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Time" + } + ] + }, + "finalizers": { + "description": "Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list.", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "set", + "x-kubernetes-patch-strategy": "merge" + }, + "generateName": { + "description": "GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server.\n\nIf this field is specified and the generated name exists, the server will return a 409.\n\nApplied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency", + "type": "string" + }, + "generation": { + "description": "A sequence number representing a specific generation of the desired state. Populated by the system. Read-only.", + "type": "integer", + "format": "int64" + }, + "labels": { + "description": "Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels", + "type": "object", + "additionalProperties": { + "type": "string", + "default": "" + } + }, + "managedFields": { + "description": "ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like \"ci-cd\". The set of fields is always in the version that the workflow used when modifying the object.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ManagedFieldsEntry" + } + ] + }, + "x-kubernetes-list-type": "atomic" + }, + "name": { + "description": "Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names", + "type": "string" + }, + "namespace": { + "description": "Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the \"default\" namespace, but \"default\" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty.\n\nMust be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces", + "type": "string" + }, + "ownerReferences": { + "description": "List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.OwnerReference" + } + ] + }, + "x-kubernetes-list-map-keys": [ + "uid" + ], + "x-kubernetes-list-type": "map", + "x-kubernetes-patch-merge-key": "uid", + "x-kubernetes-patch-strategy": "merge" + }, + "resourceVersion": { + "description": "An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources.\n\nPopulated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency", + "type": "string" + }, + "selfLink": { + "description": "Deprecated: selfLink is a legacy read-only field that is no longer populated by the system.", + "type": "string" + }, + "uid": { + "description": "UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations.\n\nPopulated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.OwnerReference": { + "description": "OwnerReference contains enough information to let you identify an owning object. An owning object must be in the same namespace as the dependent, or be cluster-scoped, so there is no namespace field.", + "type": "object", + "required": [ + "apiVersion", + "kind", + "name", + "uid" + ], + "properties": { + "apiVersion": { + "description": "API version of the referent.", + "type": "string", + "default": "" + }, + "blockOwnerDeletion": { + "description": "If true, AND if the owner has the \"foregroundDeletion\" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs \"delete\" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned.", + "type": "boolean" + }, + "controller": { + "description": "If true, this reference points to the managing controller.", + "type": "boolean" + }, + "kind": { + "description": "Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string", + "default": "" + }, + "name": { + "description": "Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names", + "type": "string", + "default": "" + }, + "uid": { + "description": "UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids", + "type": "string", + "default": "" + } + }, + "x-kubernetes-map-type": "atomic" + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.Patch": { + "description": "Patch is provided to give a concrete name and type to the Kubernetes PATCH request body.", + "type": "object" + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.Preconditions": { + "description": "Preconditions must be fulfilled before an operation (update, delete, etc.) is carried out.", + "type": "object", + "properties": { + "resourceVersion": { + "description": "Specifies the target ResourceVersion", + "type": "string" + }, + "uid": { + "description": "Specifies the target UID.", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.Status": { + "description": "Status is a return value for calls that don't return other objects.", + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "code": { + "description": "Suggested HTTP return code for this status, 0 if not set.", + "type": "integer", + "format": "int32" + }, + "details": { + "description": "Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.StatusDetails" + } + ], + "x-kubernetes-list-type": "atomic" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "message": { + "description": "A human-readable description of the status of this operation.", + "type": "string" + }, + "metadata": { + "description": "Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + ] + }, + "reason": { + "description": "A machine-readable description of why this operation is in the \"Failure\" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it.", + "type": "string" + }, + "status": { + "description": "Status of the operation. One of: \"Success\" or \"Failure\". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.StatusCause": { + "description": "StatusCause provides more information about an api.Status failure, including cases when multiple errors are encountered.", + "type": "object", + "properties": { + "field": { + "description": "The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional.\n\nExamples:\n \"name\" - the field \"name\" on the current resource\n \"items[0].name\" - the field \"name\" on the first array entry in \"items\"", + "type": "string" + }, + "message": { + "description": "A human-readable description of the cause of the error. This field may be presented as-is to a reader.", + "type": "string" + }, + "reason": { + "description": "A machine-readable description of the cause of the error. If this value is empty there is no information available.", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.StatusDetails": { + "description": "StatusDetails is a set of additional properties that MAY be set by the server to provide additional information about a response. The Reason field of a Status object defines what attributes will be set. Clients must ignore fields that do not match the defined type of each attribute, and should assume that any attribute may be empty, invalid, or under defined.", + "type": "object", + "properties": { + "causes": { + "description": "The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.StatusCause" + } + ] + }, + "x-kubernetes-list-type": "atomic" + }, + "group": { + "description": "The group attribute of the resource associated with the status StatusReason.", + "type": "string" + }, + "kind": { + "description": "The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "name": { + "description": "The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described).", + "type": "string" + }, + "retryAfterSeconds": { + "description": "If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action.", + "type": "integer", + "format": "int32" + }, + "uid": { + "description": "UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.Time": { + "description": "Time is a wrapper around time.Time which supports correct marshaling to YAML and JSON. Wrappers are provided for many of the factory methods that the time package offers.", + "type": "string", + "format": "date-time" + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.WatchEvent": { + "description": "Event represents a single event to a watched resource.", + "type": "object", + "required": [ + "type", + "object" + ], + "properties": { + "object": { + "description": "Object is:\n * If Type is Added or Modified: the new state of the object.\n * If Type is Deleted: the state of the object immediately before deletion.\n * If Type is Error: *Status is recommended; other types may make sense\n depending on context.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.runtime.RawExtension" + } + ] + }, + "type": { + "type": "string", + "default": "" + } + } + }, + "io.k8s.apimachinery.pkg.runtime.RawExtension": { + "description": "RawExtension is used to hold extensions in external versions.\n\nTo use this, make a field which has RawExtension as its type in your external, versioned struct, and Object in your internal struct. You also need to register your various plugin types.\n\n// Internal package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.Object `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// External package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.RawExtension `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// On the wire, the JSON will look something like this:\n\n\t{\n\t\t\"kind\":\"MyAPIObject\",\n\t\t\"apiVersion\":\"v1\",\n\t\t\"myPlugin\": {\n\t\t\t\"kind\":\"PluginA\",\n\t\t\t\"aOption\":\"foo\",\n\t\t},\n\t}\n\nSo what happens? Decode first uses json or yaml to unmarshal the serialized data into your external MyAPIObject. That causes the raw JSON to be stored, but not unpacked. The next step is to copy (using pkg/conversion) into the internal struct. The runtime package's DefaultScheme has conversion functions installed which will unpack the JSON stored in RawExtension, turning it into the correct object type, and storing it in the Object. (TODO: In the case where the object is of an unknown type, a runtime.Unknown object will be created and stored.)", + "type": "object" + } + } + } +} \ No newline at end of file diff --git a/pkg/tests/apis/openapi_test.go b/pkg/tests/apis/openapi_test.go index 131634e6805..aeb629a5939 100644 --- a/pkg/tests/apis/openapi_test.go +++ b/pkg/tests/apis/openapi_test.go @@ -128,7 +128,7 @@ func TestIntegrationOpenAPIs(t *testing.T) { Version: "v0alpha1", }, { Group: "logsdrilldown.grafana.app", - Version: "v1alpha1", + Version: "v1beta1", }} for _, gv := range groups { VerifyOpenAPISnapshots(t, dir, gv, h) From ca6ab973b4ef143d762e02d468ca2447bf0fe4c9 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 9 Jan 2026 16:47:25 +0000 Subject: [PATCH 03/28] Modal/Drawer: Switch to use floating-ui's focus trapping (#116017) * Add awareness of a parent when toggletip is rendered to work inside other modals * switch modal + drawer to use floating-ui's focus trapping * remove outdated docs * fix some unit tests * fix scopes tests * remove duplicate aria-label * kick CI * fix e2e tests --------- Co-authored-by: tdbishop --- .../dashboard-keybindings.spec.ts | 2 + .../dashboard-keybindings.spec.ts | 2 + .../panels-suite/table-kitchenSink.spec.ts | 2 +- .../src/components/Drawer/Drawer.tsx | 45 +++----- .../grafana-ui/src/components/Modal/Modal.tsx | 58 +++++----- .../src/components/Toggletip/Toggletip.mdx | 2 - .../components/Toggletip/Toggletip.story.tsx | 87 +++++++++++++++ .../receivers/NewReceiverView.test.tsx | 3 + .../SqlExpressions/SqlExpr.test.tsx | 105 +++++++++--------- .../LibraryPanelsSearch.test.tsx | 18 +-- .../features/scopes/tests/selector.test.ts | 3 +- .../features/scopes/tests/utils/actions.ts | 2 +- 12 files changed, 207 insertions(+), 122 deletions(-) diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts index 19ad38f16d5..867337ba088 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts @@ -51,6 +51,8 @@ test.describe('Dashboard keybindings with new layouts', { tag: ['@dashboards'] } await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible(); + // Press Escape to close tooltip on the close button + await page.keyboard.press('Escape'); // Press Escape to close inspector await page.keyboard.press('Escape'); diff --git a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts index f874cefa27c..dd83b3a0cfd 100644 --- a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts @@ -58,6 +58,8 @@ test.describe( await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible(); + // Press Escape to close tooltip on the close button + await page.keyboard.press('Escape'); // Press Escape to close inspector await page.keyboard.press('Escape'); diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts index 6dddba81820..ac10cb2b735 100644 --- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts +++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @@ -82,9 +82,9 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); // click cell inspect, check that cell inspection pops open in the side as we'd expect. - await loremIpsumCell.getByLabel('Inspect value').click(); const loremIpsumText = await loremIpsumCell.textContent(); expect(loremIpsumText).toBeDefined(); + await loremIpsumCell.getByLabel('Inspect value').click(); await expect(page.getByRole('dialog').getByText(loremIpsumText!)).toBeVisible(); }); diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index 00039adfc3f..7d115e3f9d0 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -1,9 +1,7 @@ import { css, cx } from '@emotion/css'; +import { FloatingFocusManager, useFloating } from '@floating-ui/react'; import RcDrawer from '@rc-component/drawer'; -import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; -import { useOverlay } from '@react-aria/overlays'; -import { ReactNode, useCallback, useEffect, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useId, useState } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -81,17 +79,16 @@ export function Drawer({ const styles = useStyles2(getStyles); const wrapperStyles = useStyles2(getWrapperStyles, size); const dragStyles = useStyles2(getDragStyles); + const titleId = useId(); - const overlayRef = React.useRef(null); - const { dialogProps, titleProps } = useDialog({}, overlayRef); - const { overlayProps } = useOverlay( - { - isDismissable: false, - isOpen: true, - onClose, + const { context, refs } = useFloating({ + open: true, + onOpenChange: (open) => { + if (!open) { + onClose?.(); + } }, - overlayRef - ); + }); // Adds body class while open so the toolbar nav can hide some actions while drawer is open useBodyClassWhileOpen(); @@ -117,6 +114,8 @@ export function Drawer({ minWidth, }, }} + aria-label={typeof title === 'string' ? selectors.components.Drawer.General.title(title) : undefined} + aria-labelledby={typeof title !== 'string' ? titleId : undefined} width={''} motion={{ motionAppear: true, @@ -129,18 +128,8 @@ export function Drawer({ motionName: styles.maskMotion, }} > - -
+ +
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
{typeof title === 'string' ? ( - + {title} {subtitle && ( @@ -169,13 +158,13 @@ export function Drawer({ )} ) : ( - title +
{title}
)} {tabs &&
{tabs}
}
{!scrollableContent ? content : {content}}
- +
); } diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index aaeb2c3e426..f6ea73c3d31 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -1,9 +1,7 @@ import { cx } from '@emotion/css'; -import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; -import { OverlayContainer, useOverlay } from '@react-aria/overlays'; -import { PropsWithChildren, useRef, type JSX } from 'react'; -import * as React from 'react'; +import { FloatingFocusManager, useDismiss, useFloating, useInteractions, useRole } from '@floating-ui/react'; +import { OverlayContainer } from '@react-aria/overlays'; +import { PropsWithChildren, ReactNode, useId, type JSX } from 'react'; import { t } from '@grafana/i18n'; @@ -66,23 +64,26 @@ export function Modal(props: PropsWithChildren) { trapFocus = true, } = props; const styles = useStyles2(getModalStyles); + const titleId = useId(); - const ref = useRef(null); - - // Handle interacting outside the dialog and pressing - // the Escape key to close the modal. - const { overlayProps, underlayProps } = useOverlay( - { isKeyboardDismissDisabled: !closeOnEscape, isOpen, onClose: onDismiss }, - ref - ); - - // Get props for the dialog and its title - const { dialogProps, titleProps } = useDialog( - { - 'aria-label': ariaLabel, + const { context, refs } = useFloating({ + open: isOpen, + onOpenChange: (open) => { + if (!open) { + onDismiss?.(); + } }, - ref - ); + }); + + const dismiss = useDismiss(context, { + enabled: closeOnEscape, + }); + + const role = useRole(context, { + role: 'dialog', + }); + + const { getFloatingProps } = useInteractions([dismiss, role]); if (!isOpen) { return null; @@ -96,12 +97,17 @@ export function Modal(props: PropsWithChildren) { role="presentation" className={styles.modalBackdrop} onClick={onClickBackdrop || (closeOnBackdropClick ? onDismiss : undefined)} - {...underlayProps} /> - -
+ +
- {typeof title === 'string' && } + {typeof title === 'string' && } { // FIXME: custom title components won't get an accessible title. // Do we really want to support them or shall we just limit this ModalTabsHeader? @@ -118,12 +124,12 @@ export function Modal(props: PropsWithChildren) {
{children}
- +
); } -function ModalButtonRow({ leftItems, children }: { leftItems?: React.ReactNode; children: React.ReactNode }) { +function ModalButtonRow({ leftItems, children }: { leftItems?: ReactNode; children: ReactNode }) { const styles = useStyles2(getModalStyles); if (leftItems) { diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.mdx b/packages/grafana-ui/src/components/Toggletip/Toggletip.mdx index 53b75f5b5cb..fcfea97a45f 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.mdx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.mdx @@ -75,5 +75,3 @@ return ( ); ``` - - diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx index 1c3cbb57919..a7006fd70eb 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx @@ -1,6 +1,11 @@ import { Meta, StoryFn } from '@storybook/react'; +import { useState } from 'react'; import { Button } from '../Button/Button'; +import { Drawer } from '../Drawer/Drawer'; +import { Field } from '../Forms/Field'; +import { Input } from '../Input/Input'; +import { Modal } from '../Modal/Modal'; import { ScrollContainer } from '../ScrollContainer/ScrollContainer'; import mdx from '../Toggletip/Toggletip.mdx'; @@ -133,4 +138,86 @@ LongContent.parameters = { }, }; +export const InsideDrawer: StoryFn = () => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + return ( + <> + + {isDrawerOpen && ( + setIsDrawerOpen(false)}> +
+

This demonstrates using Toggletip inside a Drawer.

+ + + + + +
+ } + footer="Focus should work correctly within this Toggletip" + placement="bottom-start" + > + + +
+ + )} + + ); +}; + +InsideDrawer.parameters = { + controls: { + hideNoControlsWarning: true, + exclude: ['title', 'content', 'footer', 'children', 'placement', 'theme', 'closeButton', 'portalRoot'], + }, +}; + +export const InsideModal: StoryFn = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + + setIsModalOpen(false)}> +
+

This demonstrates using Toggletip inside a Modal.

+ + + + + + +
+ } + footer="Focus should work correctly within this Toggletip" + placement="bottom-start" + > + + + +
+ + + ); +}; + +InsideDrawer.parameters = { + controls: { + hideNoControlsWarning: true, + exclude: ['title', 'content', 'footer', 'children', 'placement', 'theme', 'closeButton', 'portalRoot'], + }, +}; + export default meta; diff --git a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx index ad6ee94d73e..b57242f0e49 100644 --- a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx @@ -79,6 +79,9 @@ describe('new receiver', () => { // click test await user.click(ui.testContactPoint.get()); + // close the modal + await user.click(screen.getByRole('button', { name: 'Close' })); + // we shouldn't be testing implementation details but when the request is successful // it can't seem to assert on the success toast await user.click(ui.saveContactButton.get()); diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx index 9e2cfd50b75..433ce7f1d91 100644 --- a/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, testWithFeatureToggles } from 'test/test-utils'; +import { render, testWithFeatureToggles, userEvent, waitFor } from 'test/test-utils'; import { ExpressionQuery, ExpressionQueryType } from '../../types'; @@ -72,12 +72,12 @@ describe('SqlExpr', () => { const refIds = [{ value: 'A' }]; const query = { refId: 'expr1', type: 'sql', expression: '' } as ExpressionQuery; - await act(async () => { - render(); - }); + render(); // Verify onChange was called - expect(onChange).toHaveBeenCalled(); + await waitFor(() => { + expect(onChange).toHaveBeenCalled(); + }); // Verify essential SQL structure without exact string matching const updatedQuery = onChange.mock.calls[0][0]; @@ -90,19 +90,12 @@ describe('SqlExpr', () => { const existingExpression = 'SELECT 1 AS foo'; const query = { refId: 'expr1', type: 'sql', expression: existingExpression } as ExpressionQuery; - await act(async () => { - render(); - }); - - // Check if onChange was called - if (onChange.mock.calls.length > 0) { - // If called, ensure it didn't change the expression value - const updatedQuery = onChange.mock.calls[0][0]; - expect(updatedQuery.expression).toBe(existingExpression); - } + render(); // The SQLEditor should receive the existing expression - expect(query.expression).toBe(existingExpression); + await waitFor(() => { + expect(query.expression).toBe(existingExpression); + }); }); it('adds alerting format when alerting prop is true', async () => { @@ -110,40 +103,12 @@ describe('SqlExpr', () => { const refIds = [{ value: 'A' }]; const query = { refId: 'expr1', type: 'sql' } as ExpressionQuery; - await act(async () => { - render(); + render(); + + await waitFor(() => { + const updatedQuery = onChange.mock.calls[0][0]; + expect(updatedQuery.format).toBe('alerting'); }); - - const updatedQuery = onChange.mock.calls[0][0]; - expect(updatedQuery.format).toBe('alerting'); - }); -}); - -describe('SqlExpr with GenAI features', () => { - const defaultProps: SqlExprProps = { - onChange: jest.fn(), - refIds: [{ value: 'A' }], - query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` }, - queries: [], - }; - - it('renders suggestions drawer when isDrawerOpen is true', async () => { - const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); - useSQLSuggestions.mockImplementation(() => ({ - isDrawerOpen: true, - suggestions: ['suggestion1', 'suggestion2'], - })); - - const { findByTestId } = render(); - expect(await findByTestId('suggestions-drawer')).toBeInTheDocument(); - }); - - it('renders explanation drawer when isExplanationOpen is true', async () => { - const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); - useSQLExplanations.mockImplementation(() => ({ isExplanationOpen: true })); - - const { findByTestId } = render(); - expect(await findByTestId('explanation-drawer')).toBeInTheDocument(); }); }); @@ -166,10 +131,10 @@ describe('Schema Inspector feature toggle', () => { }); }); - it('renders panel open by default', () => { - const { getByText } = render(); + it('renders panel open by default', async () => { + const { findByText } = render(); - expect(getByText('No schema information available')).toBeInTheDocument(); + expect(await findByText('No schema information available')).toBeInTheDocument(); }); it('closes panel and shows reopen button when close button clicked', async () => { @@ -178,7 +143,7 @@ describe('Schema Inspector feature toggle', () => { expect(queryByText('No schema information available')).toBeInTheDocument(); const closeButton = getByText('Schema inspector'); - await act(async () => fireEvent.click(closeButton)); + await userEvent.click(closeButton); expect(queryByText('No schema information available')).not.toBeInTheDocument(); expect(await findByText('Schema inspector')).toBeInTheDocument(); @@ -188,12 +153,12 @@ describe('Schema Inspector feature toggle', () => { const { queryByText, getByText } = render(); const closeButton = getByText('Schema inspector'); - await act(async () => fireEvent.click(closeButton)); + await userEvent.click(closeButton); expect(queryByText('No schema information available')).not.toBeInTheDocument(); const reopenButton = getByText('Schema inspector'); - await act(async () => fireEvent.click(reopenButton)); + await userEvent.click(reopenButton); expect(queryByText('No schema information available')).toBeInTheDocument(); }); @@ -233,3 +198,33 @@ describe('Schema Inspector feature toggle', () => { }); }); }); + +describe('SqlExpr with GenAI features', () => { + const defaultProps: SqlExprProps = { + onChange: jest.fn(), + refIds: [{ value: 'A' }], + query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` }, + queries: [], + }; + + it('renders suggestions drawer when isDrawerOpen is true', async () => { + // TODO this inline require breaks future tests - do it differently! + const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); + useSQLSuggestions.mockImplementation(() => ({ + isDrawerOpen: true, + suggestions: ['suggestion1', 'suggestion2'], + })); + + const { findByTestId } = render(); + expect(await findByTestId('suggestions-drawer')).toBeInTheDocument(); + }); + + it('renders explanation drawer when isExplanationOpen is true', async () => { + // TODO this inline require breaks future tests - do it differently! + const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); + useSQLExplanations.mockImplementation(() => ({ isExplanationOpen: true })); + + const { findByTestId } = render(); + expect(await findByTestId('explanation-drawer')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx index 941dca5416d..8645dfb4c4e 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx @@ -340,14 +340,16 @@ describe('LibraryPanelsSearch', () => { await user.click(screen.getAllByRole('button', { name: 'Delete' })[1]); await waitFor(() => - expect(getLibraryPanelsSpy).toHaveBeenCalledWith({ - searchString: '', - folderFilterUIDs: ['wfTJJL5Wz'], - page: 1, - typeFilter: [], - sortDirection: undefined, - perPage: 40, - }) + expect(getLibraryPanelsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + searchString: '', + folderFilterUIDs: ['wfTJJL5Wz'], + page: 1, + typeFilter: [], + sortDirection: undefined, + perPage: 40, + }) + ) ); }); }); diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts index f12f848657a..f4ac22b6850 100644 --- a/public/app/features/scopes/tests/selector.test.ts +++ b/public/app/features/scopes/tests/selector.test.ts @@ -105,7 +105,6 @@ describe('Selector', () => { // Lowercase because we don't have any backend that returns the correct case, then it falls back to the value in the URL expectScopesSelectorValue('grafana'); await openSelector(); - //screen.debug(undefined, 100000); expectResultApplicationsGrafanaSelected(); jest.spyOn(locationService, 'getLocation').mockRestore(); @@ -175,6 +174,7 @@ describe('Selector', () => { await applyScopes(); // Deselect all scopes + await hoverSelector(); await clearSelector(); // Recent scopes should still be available @@ -197,6 +197,7 @@ describe('Selector', () => { await selectResultApplicationsMimir(); await applyScopes(); + await hoverSelector(); await clearSelector(); // Check recent scopes are updated diff --git a/public/app/features/scopes/tests/utils/actions.ts b/public/app/features/scopes/tests/utils/actions.ts index fc5fde93193..e49acb8670a 100644 --- a/public/app/features/scopes/tests/utils/actions.ts +++ b/public/app/features/scopes/tests/utils/actions.ts @@ -47,7 +47,7 @@ const type = async (selector: () => HTMLInputElement, value: string) => { export const updateScopes = async (service: ScopesService, scopes: string[]) => act(async () => service.changeScopes(scopes)); export const openSelector = async () => click(getSelectorInput); -export const hoverSelector = async () => fireEvent.mouseOver(getSelectorInput()); +export const hoverSelector = async () => userEvent.hover(getSelectorInput()); export const clearSelector = async () => click(getSelectorClear); export const applyScopes = async () => { await click(getSelectorApply); From ad3763f04d97f637514db4eb7b3076baa0e76843 Mon Sep 17 00:00:00 2001 From: Matt Cowley Date: Fri, 9 Jan 2026 16:56:48 +0000 Subject: [PATCH 04/28] UI: Use computed z-index for UsersIndicator to fix tab order (#115894) * Use computed z-index for UsersIndicator to fix tab order * Apply z-index from UsersIndicator via nth-of-type --- .../UsersIndicator/UsersIndicator.tsx | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx index b32145da0da..04718af9493 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx +++ b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx @@ -23,7 +23,7 @@ export interface UsersIndicatorProps { * https://developers.grafana.com/ui/latest/index.html?path=/docs/iconography-usersindicator--docs */ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProps) => { - const styles = useStyles2(getStyles); + const styles = useStyles2(getStyles, limit); if (!users.length) { return null; } @@ -39,6 +39,9 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp className={styles.container} aria-label={t('grafana-ui.users-indicator.container-label', 'Users indicator container')} > + {users.slice(0, limitReached ? limit : limit + 1).map((userView, idx, arr) => ( + + ))} {limitReached && ( {tooManyUsers @@ -47,26 +50,30 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp : `+${extraUsers}`} )} - {users - .slice(0, limitReached ? limit : limit + 1) - .reverse() - .map((userView) => ( - - ))} ); }; -const getStyles = (theme: GrafanaTheme2) => { +const getStyles = (theme: GrafanaTheme2, limit: number) => { return { container: css({ display: 'flex', justifyContent: 'center', - flexDirection: 'row-reverse', marginLeft: theme.spacing(1), + isolation: 'isolate', '& > button': { marginLeft: theme.spacing(-1), // Overlay the elements a bit on top of each other + + // Ensure overlaying user icons are stacked correctly with z-index on each element + ...Object.fromEntries( + Array.from({ length: limit }).map((_, idx) => [ + `&:nth-of-type(${idx + 1})`, + { + zIndex: limit - idx, + }, + ]) + ), }, }), dots: css({ From 909ed022186037bf7e270ee87c654f84bd3c64e9 Mon Sep 17 00:00:00 2001 From: Eric Shields Date: Fri, 9 Jan 2026 09:26:02 -0800 Subject: [PATCH 05/28] Scopes: Implement defaultPath support and refactor path resolution (#114863) Implements support for the defaultPath field in Scope specifications * Faster performance: Batch API call (fetchMultipleScopeNodes) replaces N sequential calls * Instant selector opening: Pre-fetches all path nodes when applying scopes * Consistent resolution: Single source of truth for scopeNodeId and parentNodeId across UI and URLs * Correct URL syncing: scope_node parameter always reflects the canonical defaultPath * Backwards compatible: Gracefully falls back when defaultPath is unavailable Co-authored-by: Claude Co-authored-by: Tobias Skarhed --- devenv/scopes/scopes-config.yaml | 33 + devenv/scopes/scopes.go | 20 +- .../features/scopes/ScopesApiClient.test.ts | 362 ++++++ .../app/features/scopes/ScopesService.test.ts | 205 +++- public/app/features/scopes/ScopesService.ts | 45 +- .../features/scopes/selector/ScopesInput.tsx | 61 +- .../selector/ScopesSelectorService.test.ts | 1085 ++++++++++++++++- .../scopes/selector/ScopesSelectorService.ts | 235 +++- .../scopes/selector/scopesTreeUtils.ts | 28 +- .../features/scopes/tests/selector.test.ts | 7 +- 10 files changed, 1985 insertions(+), 96 deletions(-) create mode 100644 public/app/features/scopes/ScopesApiClient.test.ts diff --git a/devenv/scopes/scopes-config.yaml b/devenv/scopes/scopes-config.yaml index ef800415547..66ba7b6988c 100644 --- a/devenv/scopes/scopes-config.yaml +++ b/devenv/scopes/scopes-config.yaml @@ -48,6 +48,23 @@ scopes: operator: equals value: kids + # This scope appears in multiple places in the tree. + # The defaultPath determines which path is shown when this scope is selected + # (e.g., from a URL or programmatically), even if another path also links to it. + shared-service: + title: Shared Service + # Path from the root node down to the direct scopeNode. + # Node names are hierarchical (parent-child), so use the full names. + # This points to: gdev-scopes > production > shared-service-prod + defaultPath: + - gdev-scopes + - gdev-scopes-production + - gdev-scopes-production-shared-service-prod + filters: + - key: service + operator: equals + value: shared + tree: gdev-scopes: title: gdev-scopes @@ -68,6 +85,13 @@ tree: nodeType: leaf linkId: app2 linkType: scope + # This node links to 'shared-service' scope. + # The scope's defaultPath points here (production > gdev-scopes). + shared-service-prod: + title: Shared Service + nodeType: leaf + linkId: shared-service + linkType: scope test-cases: title: Test cases nodeType: container @@ -83,6 +107,15 @@ tree: nodeType: leaf linkId: test-case-2 linkType: scope + # This node also links to the same 'shared-service' scope. + # However, the scope's defaultPath points to the production path, + # so selecting this scope will expand the tree to production > shared-service-prod. + shared-service-test: + title: Shared Service (also in Production) + subTitle: defaultPath points to Production + nodeType: leaf + linkId: shared-service + linkType: scope test-case-redirect: title: Test case with redirect nodeType: leaf diff --git a/devenv/scopes/scopes.go b/devenv/scopes/scopes.go index e3f4de80d21..4487c5b69ef 100644 --- a/devenv/scopes/scopes.go +++ b/devenv/scopes/scopes.go @@ -51,8 +51,9 @@ type Config struct { // ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec type ScopeConfig struct { - Title string `yaml:"title"` - Filters []ScopeFilterConfig `yaml:"filters"` + Title string `yaml:"title"` + DefaultPath []string `yaml:"defaultPath,omitempty"` + Filters []ScopeFilterConfig `yaml:"filters"` } // ScopeFilterConfig is used for YAML parsing - converts to v0alpha1.ScopeFilter @@ -116,9 +117,20 @@ func convertScopeSpec(cfg ScopeConfig) v0alpha1.ScopeSpec { for i, f := range cfg.Filters { filters[i] = convertFilter(f) } + + // Prefix defaultPath elements with the gdev prefix + var defaultPath []string + if len(cfg.DefaultPath) > 0 { + defaultPath = make([]string, len(cfg.DefaultPath)) + for i, p := range cfg.DefaultPath { + defaultPath[i] = prefix + "-" + p + } + } + return v0alpha1.ScopeSpec{ - Title: cfg.Title, - Filters: filters, + Title: cfg.Title, + DefaultPath: defaultPath, + Filters: filters, } } diff --git a/public/app/features/scopes/ScopesApiClient.test.ts b/public/app/features/scopes/ScopesApiClient.test.ts new file mode 100644 index 00000000000..979da40b6ce --- /dev/null +++ b/public/app/features/scopes/ScopesApiClient.test.ts @@ -0,0 +1,362 @@ +import { getBackendSrv, config } from '@grafana/runtime'; + +import { ScopesApiClient } from './ScopesApiClient'; + +// Mock the runtime dependencies +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: jest.fn(), + config: { + featureToggles: { + useMultipleScopeNodesEndpoint: true, + useScopeSingleNodeEndpoint: true, + }, + }, +})); + +jest.mock('@grafana/api-clients', () => ({ + getAPIBaseURL: jest.fn().mockReturnValue('/apis/scope.grafana.app/v0alpha1'), +})); + +describe('ScopesApiClient', () => { + let apiClient: ScopesApiClient; + let mockBackendSrv: jest.Mocked<{ get: jest.Mock }>; + + beforeEach(() => { + mockBackendSrv = { + get: jest.fn(), + }; + (getBackendSrv as jest.Mock).mockReturnValue(mockBackendSrv); + apiClient = new ScopesApiClient(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchMultipleScopeNodes', () => { + it('should fetch multiple nodes by names', async () => { + const mockNodes = [ + { + metadata: { name: 'node-1' }, + spec: { nodeType: 'container', title: 'Node 1', parentName: '' }, + }, + { + metadata: { name: 'node-2' }, + spec: { nodeType: 'leaf', title: 'Node 2', parentName: 'node-1' }, + }, + ]; + + mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + + const result = await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2']); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + names: ['node-1', 'node-2'], + }); + expect(result).toEqual(mockNodes); + }); + + it('should return empty array when names array is empty', async () => { + const result = await apiClient.fetchMultipleScopeNodes([]); + + expect(mockBackendSrv.get).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it('should return empty array when feature toggle is disabled', async () => { + config.featureToggles.useMultipleScopeNodesEndpoint = false; + + const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + + expect(mockBackendSrv.get).not.toHaveBeenCalled(); + expect(result).toEqual([]); + + // Restore feature toggle + config.featureToggles.useMultipleScopeNodesEndpoint = true; + }); + + it('should handle API errors gracefully', async () => { + mockBackendSrv.get.mockRejectedValue(new Error('Network error')); + + const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + + expect(result).toEqual([]); + }); + + it('should handle response with no items field', async () => { + mockBackendSrv.get.mockResolvedValue({}); + + const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + + expect(result).toEqual([]); + }); + + it('should handle response with null items', async () => { + mockBackendSrv.get.mockResolvedValue({ items: null }); + + const result = await apiClient.fetchMultipleScopeNodes(['node-1']); + + expect(result).toEqual([]); + }); + + it('should handle large arrays of node names', async () => { + const names = Array.from({ length: 100 }, (_, i) => `node-${i}`); + const mockNodes = names.map((name) => ({ + metadata: { name }, + spec: { nodeType: 'leaf', title: name, parentName: '' }, + })); + + mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + + const result = await apiClient.fetchMultipleScopeNodes(names); + + expect(result).toEqual(mockNodes); + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + names, + }); + }); + + it('should pass through node names exactly as provided', async () => { + const names = ['node-with-special-chars_123', 'node.with.dots', 'node-with-dashes']; + mockBackendSrv.get.mockResolvedValue({ items: [] }); + + await apiClient.fetchMultipleScopeNodes(names); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + names, + }); + }); + }); + + describe('fetchScopeNode', () => { + it('should fetch a single scope node by ID', async () => { + const mockNode = { + metadata: { name: 'test-node' }, + spec: { nodeType: 'leaf', title: 'Test Node', parentName: 'parent' }, + }; + + mockBackendSrv.get.mockResolvedValue(mockNode); + + const result = await apiClient.fetchScopeNode('test-node'); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopenodes/test-node'); + expect(result).toEqual(mockNode); + }); + + it('should return undefined when feature toggle is disabled', async () => { + config.featureToggles.useScopeSingleNodeEndpoint = false; + + const result = await apiClient.fetchScopeNode('test-node'); + + expect(mockBackendSrv.get).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + + // Restore feature toggle + config.featureToggles.useScopeSingleNodeEndpoint = true; + }); + + it('should return undefined on API error', async () => { + mockBackendSrv.get.mockRejectedValue(new Error('Not found')); + + const result = await apiClient.fetchScopeNode('non-existent'); + + expect(result).toBeUndefined(); + }); + }); + + describe('fetchNodes', () => { + it('should fetch nodes with parent filter', async () => { + const mockNodes = [ + { + metadata: { name: 'child-1' }, + spec: { nodeType: 'leaf', title: 'Child 1', parentName: 'parent' }, + }, + ]; + + mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + + const result = await apiClient.fetchNodes({ parent: 'parent' }); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + parent: 'parent', + query: undefined, + limit: 1000, + }); + expect(result).toEqual(mockNodes); + }); + + it('should fetch nodes with query filter', async () => { + const mockNodes = [ + { + metadata: { name: 'matching-node' }, + spec: { nodeType: 'leaf', title: 'Matching Node', parentName: '' }, + }, + ]; + + mockBackendSrv.get.mockResolvedValue({ items: mockNodes }); + + const result = await apiClient.fetchNodes({ query: 'matching' }); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + parent: undefined, + query: 'matching', + limit: 1000, + }); + expect(result).toEqual(mockNodes); + }); + + it('should respect custom limit', async () => { + mockBackendSrv.get.mockResolvedValue({ items: [] }); + + await apiClient.fetchNodes({ limit: 50 }); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + parent: undefined, + query: undefined, + limit: 50, + }); + }); + + it('should throw error for invalid limit (too small)', async () => { + await expect(apiClient.fetchNodes({ limit: 0 })).rejects.toThrow('Limit must be between 1 and 10000'); + }); + + it('should throw error for invalid limit (too large)', async () => { + await expect(apiClient.fetchNodes({ limit: 10001 })).rejects.toThrow('Limit must be between 1 and 10000'); + }); + + it('should use default limit of 1000 when not specified', async () => { + mockBackendSrv.get.mockResolvedValue({ items: [] }); + + await apiClient.fetchNodes({}); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', { + parent: undefined, + query: undefined, + limit: 1000, + }); + }); + + it('should return empty array on API error', async () => { + mockBackendSrv.get.mockRejectedValue(new Error('API Error')); + + const result = await apiClient.fetchNodes({ parent: 'test' }); + + expect(result).toEqual([]); + }); + }); + + describe('fetchScope', () => { + it('should fetch a scope by name', async () => { + const mockScope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + filters: [], + }, + }; + + mockBackendSrv.get.mockResolvedValue(mockScope); + + const result = await apiClient.fetchScope('test-scope'); + + expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopes/test-scope'); + expect(result).toEqual(mockScope); + }); + + it('should return undefined on error', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockBackendSrv.get.mockRejectedValue(new Error('Not found')); + + const result = await apiClient.fetchScope('non-existent'); + + expect(result).toBeUndefined(); + consoleErrorSpy.mockRestore(); + }); + + it('should log error to console', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const error = new Error('Not found'); + mockBackendSrv.get.mockRejectedValue(error); + + await apiClient.fetchScope('non-existent'); + + expect(consoleErrorSpy).toHaveBeenCalledWith(error); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('fetchMultipleScopes', () => { + it('should fetch multiple scopes in parallel', async () => { + const mockScopes = [ + { + metadata: { name: 'scope-1' }, + spec: { title: 'Scope 1', filters: [] }, + }, + { + metadata: { name: 'scope-2' }, + spec: { title: 'Scope 2', filters: [] }, + }, + ]; + + mockBackendSrv.get.mockResolvedValueOnce(mockScopes[0]).mockResolvedValueOnce(mockScopes[1]); + + const result = await apiClient.fetchMultipleScopes(['scope-1', 'scope-2']); + + expect(mockBackendSrv.get).toHaveBeenCalledTimes(2); + expect(result).toEqual(mockScopes); + }); + + it('should filter out undefined scopes', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const mockScope = { + metadata: { name: 'scope-1' }, + spec: { title: 'Scope 1', filters: [] }, + }; + + mockBackendSrv.get.mockResolvedValueOnce(mockScope).mockRejectedValueOnce(new Error('Not found')); + + const result = await apiClient.fetchMultipleScopes(['scope-1', 'non-existent']); + + expect(result).toEqual([mockScope]); + consoleErrorSpy.mockRestore(); + }); + + it('should return empty array when no scopes provided', async () => { + const result = await apiClient.fetchMultipleScopes([]); + + expect(result).toEqual([]); + expect(mockBackendSrv.get).not.toHaveBeenCalled(); + }); + }); + + describe('performance considerations', () => { + it('should make single batched request with fetchMultipleScopeNodes', async () => { + mockBackendSrv.get.mockResolvedValue({ items: [] }); + + await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2', 'node-3', 'node-4', 'node-5']); + + // Should make exactly 1 API call + expect(mockBackendSrv.get).toHaveBeenCalledTimes(1); + }); + + it('should make N sequential requests with fetchScopeNode (old pattern)', async () => { + mockBackendSrv.get.mockResolvedValue({ + metadata: { name: 'test' }, + spec: { nodeType: 'leaf', title: 'Test', parentName: '' }, + }); + + // Simulate old pattern of fetching nodes one by one + await Promise.all([ + apiClient.fetchScopeNode('node-1'), + apiClient.fetchScopeNode('node-2'), + apiClient.fetchScopeNode('node-3'), + apiClient.fetchScopeNode('node-4'), + apiClient.fetchScopeNode('node-5'), + ]); + + // Should make 5 separate API calls + expect(mockBackendSrv.get).toHaveBeenCalledTimes(5); + }); + }); +}); diff --git a/public/app/features/scopes/ScopesService.test.ts b/public/app/features/scopes/ScopesService.test.ts index fdc0c8bd598..a90de0ef95b 100644 --- a/public/app/features/scopes/ScopesService.test.ts +++ b/public/app/features/scopes/ScopesService.test.ts @@ -1,5 +1,6 @@ import { BehaviorSubject } from 'rxjs'; +import { ScopeSpecFilter } from '@grafana/data'; import { LocationService } from '@grafana/runtime'; import { ScopesService } from './ScopesService'; @@ -16,8 +17,20 @@ describe('ScopesService', () => { let locationService: jest.Mocked; let selectorStateSubscription: | (( - state: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> }, - prevState: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> } + state: { + appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>; + scopes?: Record< + string, + { metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } } + >; + }, + prevState: { + appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>; + scopes?: Record< + string, + { metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } } + >; + } ) => void) | undefined; let dashboardsStateSubscription: @@ -274,9 +287,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, }, { appliedScopes: [], + scopes: {}, } ); @@ -298,9 +313,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1', parentNodeId: 'parent1' }], + scopes: {}, }, { appliedScopes: [], + scopes: {}, } ); @@ -320,9 +337,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node2' }], + scopes: {}, }, { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, } ); @@ -344,9 +363,11 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1' }], + scopes: {}, }, { appliedScopes: [], + scopes: {}, } ); @@ -370,15 +391,171 @@ describe('ScopesService', () => { selectorStateSubscription( { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, }, { appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }], + scopes: {}, } ); expect(locationService.partial).not.toHaveBeenCalled(); }); + describe('defaultPath support', () => { + it('should extract scope_node from defaultPath when available', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'old-node' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent-node', 'correct-node'], + filters: [], + }, + }, + }, + }, + { + appliedScopes: [], + scopes: {}, + } + ); + + // Should use 'correct-node' from defaultPath, not 'old-node' from appliedScopes + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'correct-node', + scope_parent: null, + }, + true + ); + }); + + it('should fallback to scopeNodeId when defaultPath is not available', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + filters: [], + }, + }, + }, + }, + { + appliedScopes: [], + scopes: {}, + } + ); + + // Should fallback to scopeNodeId from appliedScopes + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'fallback-node', + scope_parent: null, + }, + true + ); + }); + + it('should handle empty defaultPath gracefully', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: [], + filters: [], + }, + }, + }, + }, + { + appliedScopes: [], + scopes: {}, + } + ); + + // Should fallback to scopeNodeId when defaultPath is empty + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'fallback-node', + scope_parent: null, + }, + true + ); + }); + + it('should detect changes in defaultPath-derived scopeNodeId', () => { + if (!selectorStateSubscription) { + throw new Error('selectorStateSubscription not set'); + } + + selectorStateSubscription( + { + appliedScopes: [{ scopeId: 'scope1' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent', 'new-node'], + filters: [], + }, + }, + }, + }, + { + appliedScopes: [{ scopeId: 'scope1' }], + scopes: { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent', 'old-node'], + filters: [], + }, + }, + }, + } + ); + + // Should detect the change in defaultPath-derived scopeNodeId + expect(locationService.partial).toHaveBeenCalledWith( + { + scopes: ['scope1'], + scope_node: 'new-node', + scope_parent: null, + }, + true + ); + }); + }); + it('should write navigation_scope to URL when navigationScope changes', () => { if (!dashboardsStateSubscription) { throw new Error('dashboardsStateSubscription not set'); @@ -622,6 +799,30 @@ describe('ScopesService', () => { true ); }); + + it('should use defaultPath for scope_node when enabling scopes', () => { + selectorService.state.appliedScopes = [{ scopeId: 'scope1', scopeNodeId: 'old-node' }]; + selectorService.state.scopes = { + scope1: { + metadata: { name: 'scope1' }, + spec: { + title: 'Scope 1', + defaultPath: ['', 'parent', 'correct-node-from-defaultPath'], + filters: [], + }, + }, + }; + + service.setEnabled(true); + + // Should use defaultPath instead of scopeNodeId from appliedScopes + expect(locationService.partial).toHaveBeenCalledWith( + expect.objectContaining({ + scope_node: 'correct-node-from-defaultPath', + }), + true + ); + }); }); describe('back/forward navigation handling', () => { diff --git a/public/app/features/scopes/ScopesService.ts b/public/app/features/scopes/ScopesService.ts index c92d6328749..8dd40fd953f 100644 --- a/public/app/features/scopes/ScopesService.ts +++ b/public/app/features/scopes/ScopesService.ts @@ -151,12 +151,26 @@ export class ScopesService implements ScopesContextValue { // Update the URL based on change in the scopes state this.subscriptions.push( selectorService.subscribeToState((state, prevState) => { - const oldScopeNodeId = prevState.appliedScopes[0]?.scopeNodeId; - const newScopeNodeId = state.appliedScopes[0]?.scopeNodeId; - const oldScopeNames = prevState.appliedScopes.map((scope) => scope.scopeId); const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId); + // Extract scopeNodeId from defaultPath when available + const getScopeNodeId = (appliedScopes: typeof state.appliedScopes, scopes: typeof state.scopes) => { + const firstScope = appliedScopes[0]; + if (!firstScope) { + return undefined; + } + const scope = scopes[firstScope.scopeId]; + // Prefer defaultPath when available + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + return scope.spec.defaultPath[scope.spec.defaultPath.length - 1]; + } + return firstScope.scopeNodeId; + }; + + const oldScopeNodeId = getScopeNodeId(prevState.appliedScopes, prevState.scopes); + const newScopeNodeId = getScopeNodeId(state.appliedScopes, state.scopes); + const scopesChanged = !isEqual(oldScopeNames, newScopeNames); const scopeNodeChanged = oldScopeNodeId !== newScopeNodeId; @@ -230,7 +244,7 @@ export class ScopesService implements ScopesContextValue { if (this.state.enabled !== enabled) { this.updateState({ enabled }); if (enabled) { - const scopeNodeId = this.selectorService.state.appliedScopes[0]?.scopeNodeId; + const scopeNodeId = this.getScopeNodeIdForUrl(); this.locationService.partial( { scopes: this.selectorService.state.appliedScopes.map((s) => s.scopeId), @@ -243,6 +257,29 @@ export class ScopesService implements ScopesContextValue { } }; + /** + * Extracts the scopeNodeId for URL syncing, preferring defaultPath when available. + * When a scope has defaultPath, that is the source of truth for the node ID. + * @private + */ + private getScopeNodeIdForUrl(): string | undefined { + const firstScope = this.selectorService.state.appliedScopes[0]; + if (!firstScope) { + return undefined; + } + + const scope = this.selectorService.state.scopes[firstScope.scopeId]; + + // Prefer scopeNodeId from defaultPath if available (most reliable source) + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Extract scopeNodeId from the last element of defaultPath + return scope.spec.defaultPath[scope.spec.defaultPath.length - 1]; + } + + // Fallback to next in priority order: scopeNodeId from appliedScopes + return firstScope.scopeNodeId; + } + /** * Returns observable that emits when relevant parts of the selectorService state change. * @private diff --git a/public/app/features/scopes/selector/ScopesInput.tsx b/public/app/features/scopes/selector/ScopesInput.tsx index a180c523e6b..205c3b0356f 100644 --- a/public/app/features/scopes/selector/ScopesInput.tsx +++ b/public/app/features/scopes/selector/ScopesInput.tsx @@ -31,13 +31,33 @@ export function ScopesInput({ onInputClick, onRemoveAllClick, }: ScopesInputProps) { - const scopeNodeId = appliedScopes[0]?.scopeNodeId; + const firstScope = appliedScopes[0]; + const scope = scopes[firstScope?.scopeId]; const styles = useStyles2(getStyles); - const parentNodeIdFromRecentScopes = appliedScopes[0]?.parentNodeId; // This is only set from recent scopes TODO: remove after recent scopes refactor + + // Prefer scopeNodeId from defaultPath if available (most reliable source) + let scopeNodeId: string | undefined; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Extract scopeNodeId from the last element of defaultPath + scopeNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 1]; + } else { + // Fallback to next in priority order: scopeNodeId from appliedScopes + scopeNodeId = firstScope?.scopeNodeId; + } + const { node: scopeNode, isLoading: scopeNodeLoading } = useScopeNode(scopeNodeId); - // Get parent from scope node if available, otherwise fallback to parent - const parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes; + // Prefer parentNodeId from defaultPath if available + let parentNodeId: string | undefined; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 1) { + // Extract parentNodeId from the second-to-last element of defaultPath + parentNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 2]; + } else { + // Fallback to parent from scope node or recent scopes + const parentNodeIdFromRecentScopes = firstScope?.parentNodeId; + parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes; + } + const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId); // Prioritize scope node subtitle over parent node title @@ -99,16 +119,31 @@ export function ScopesInput({ ); } -const getScopesPath = (appliedScopes: SelectedScope[], nodes: NodesMap) => { +const getScopesPath = ( + appliedScopes: SelectedScope[], + nodes: NodesMap, + defaultPath?: string[] +): string[] | undefined => { let nicePath: string[] | undefined; - if (appliedScopes.length > 0 && appliedScopes[0].scopeNodeId) { - let path = getPathOfNode(appliedScopes[0].scopeNodeId, nodes); - // Get reed of empty root section and the actual scope node - path = path.slice(1, -1); + if (appliedScopes.length > 0) { + const firstScope = appliedScopes[0]; - // We may not have all the nodes in path loaded - nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p); + // Prefer defaultPath from scope metadata + if (defaultPath && defaultPath.length > 1) { + // Get all nodes except the last one (which is the scope itself) + const pathNodeIds = defaultPath.slice(0, -1); + nicePath = pathNodeIds.map((nodeId) => nodes[nodeId]?.spec.title).filter((title) => title); + } + // Fallback to walking the node tree + else if (firstScope.scopeNodeId) { + let path = getPathOfNode(firstScope.scopeNodeId, nodes); + // Get rid of empty root section and the actual scope node + path = path.slice(1, -1); + + // We may not have all the nodes in path loaded + nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p); + } } return nicePath; @@ -127,7 +162,9 @@ function ScopesTooltip({ nodes, scopes, appliedScopes, onRemoveAllClick, disable return t('scopes.selector.input.tooltip', 'Select scope'); } - const nicePath = getScopesPath(appliedScopes, nodes); + const firstScope = appliedScopes[0]; + const scope = scopes[firstScope?.scopeId]; + const nicePath = getScopesPath(appliedScopes, nodes, scope?.spec.defaultPath); const scopeNames = appliedScopes.map((s) => { if (s.scopeNodeId) { return nodes[s.scopeNodeId]?.spec.title || s.scopeNodeId; diff --git a/public/app/features/scopes/selector/ScopesSelectorService.test.ts b/public/app/features/scopes/selector/ScopesSelectorService.test.ts index 58ddb31a92f..e2102db5db7 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.test.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.test.ts @@ -77,7 +77,14 @@ describe('ScopesSelectorService', () => { }), fetchDashboards: jest.fn().mockResolvedValue([]), fetchScopeNavigations: jest.fn().mockResolvedValue([]), - fetchScopeNode: jest.fn().mockResolvedValue(mockNode), + fetchScopeNode: jest.fn().mockImplementation((id: string) => { + // Return undefined for empty string (root node) + if (id === '') { + return Promise.resolve(undefined); + } + return Promise.resolve(mockNode); + }), + fetchMultipleScopeNodes: jest.fn().mockResolvedValue([]), } as unknown as jest.Mocked; dashboardsService = { @@ -435,7 +442,7 @@ describe('ScopesSelectorService', () => { await service.filterNode('', ''); await service.selectScope('test-scope-node'); await service.apply(); - await service.removeAllScopes(); + service.removeAllScopes(); expect(service.state.appliedScopes).toEqual([]); }); @@ -443,7 +450,7 @@ describe('ScopesSelectorService', () => { await service.filterNode('', ''); await service.selectScope('test-scope-node'); await service.apply(); - await service.removeAllScopes(); + service.removeAllScopes(); expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined, undefined, undefined); }); }); @@ -1236,4 +1243,1076 @@ describe('ScopesSelectorService', () => { expect(locationService.push).toHaveBeenCalledWith('/d/dashboard1'); }); }); + + // Mock data for defaultPath and path helper tests + const regionNode: ScopeNode = { + metadata: { name: 'region-us-west' }, + spec: { + nodeType: 'container', + title: 'US West', + parentName: '', + linkType: undefined, + linkId: undefined, + }, + }; + + const countryNode: ScopeNode = { + metadata: { name: 'country-usa' }, + spec: { + nodeType: 'container', + title: 'USA', + parentName: 'region-us-west', + linkType: undefined, + linkId: undefined, + }, + }; + + const cityNode: ScopeNode = { + metadata: { name: 'city-seattle' }, + spec: { + nodeType: 'container', + title: 'Seattle', + parentName: 'country-usa', + linkType: undefined, + linkId: undefined, + }, + }; + + const datacenterNode: ScopeNode = { + metadata: { name: 'datacenter-sea-1' }, + spec: { + nodeType: 'leaf', + title: 'SEA-1', + parentName: 'city-seattle', + linkType: 'scope', + linkId: 'scope-sea-1', + }, + }; + + const scopeWithDefaultPath: Scope = { + metadata: { name: 'scope-sea-1' }, + spec: { + title: 'Seattle Datacenter 1', + defaultPath: ['region-us-west', 'country-usa', 'city-seattle', 'datacenter-sea-1'], + filters: [], + }, + }; + + const scopeWithoutDefaultPath: Scope = { + metadata: { name: 'scope-no-path' }, + spec: { + title: 'No Path Scope', + filters: [], + }, + }; + + const parentNode: ScopeNode = { + metadata: { name: 'parent' }, + spec: { + nodeType: 'container', + title: 'Parent', + parentName: '', + linkType: undefined, + linkId: undefined, + }, + }; + + const childNode: ScopeNode = { + metadata: { name: 'child' }, + spec: { + nodeType: 'leaf', + title: 'Child', + parentName: 'parent', + linkType: 'scope', + linkId: 'test-scope', + }, + }; + + const grandchildNode: ScopeNode = { + metadata: { name: 'grandchild' }, + spec: { + nodeType: 'leaf', + title: 'Grandchild', + parentName: 'child', + linkType: 'scope', + linkId: 'test-scope-2', + }, + }; + + /* eslint-disable @typescript-eslint/no-explicit-any */ + // Tests for defaultPath functionality + // Note: Tests access protected updateState method via (service as any) casting to set up test state + describe('getScopeNodes', () => { + it('should return cached nodes when available', async () => { + // Pre-populate cache + (service as any).updateState({ + nodes: { + 'region-us-west': regionNode, + 'country-usa': countryNode, + }, + }); + + const result = await service.getScopeNodes(['region-us-west', 'country-usa']); + + expect(result).toEqual([regionNode, countryNode]); + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + + it('should fetch only non-cached nodes', async () => { + // Pre-populate cache with one node + (service as any).updateState({ + nodes: { + 'region-us-west': regionNode, + }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([countryNode]); + + const result = await service.getScopeNodes(['region-us-west', 'country-usa']); + + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['country-usa']); + expect(result).toEqual([regionNode, countryNode]); + }); + + it('should maintain order of requested nodes', async () => { + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([cityNode, countryNode, regionNode]); + + const result = await service.getScopeNodes(['region-us-west', 'country-usa', 'city-seattle']); + + expect(result).toEqual([regionNode, countryNode, cityNode]); + }); + + it('should update state with fetched nodes', async () => { + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode, countryNode]); + + await service.getScopeNodes(['region-us-west', 'country-usa']); + + expect(service.state.nodes).toEqual({ + 'region-us-west': regionNode, + 'country-usa': countryNode, + }); + }); + + it('should handle empty array input', async () => { + const result = await service.getScopeNodes([]); + + expect(result).toEqual([]); + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + + it('should filter out undefined nodes', async () => { + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([]); + + const result = await service.getScopeNodes(['non-existent-node']); + + expect(result).toEqual([]); + }); + }); + + describe('resolvePathToRoot with defaultPath', () => { + beforeEach(() => { + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should use defaultPath when scope has it defined', async () => { + // Pre-populate scope cache + (service as any).updateState({ + scopes: { + 'scope-sea-1': scopeWithDefaultPath, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + // Should fetch all nodes in defaultPath at once + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith([ + 'region-us-west', + 'country-usa', + 'city-seattle', + 'datacenter-sea-1', + ]); + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should fall back to recursive path walking when no scopeId provided', async () => { + // Setup nodes in cache for recursive walking + (service as any).updateState({ + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree); + + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should fall back when scope has no defaultPath', async () => { + (service as any).updateState({ + scopes: { + 'scope-no-path': scopeWithoutDefaultPath, + }, + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-no-path'); + + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should insert path nodes into tree', async () => { + (service as any).updateState({ + scopes: { + 'scope-sea-1': scopeWithDefaultPath, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + expect(result.tree.children?.['region-us-west']).toBeDefined(); + expect(result.tree.children?.['region-us-west']?.children?.['country-usa']).toBeDefined(); + expect( + result.tree.children?.['region-us-west']?.children?.['country-usa']?.children?.['city-seattle'] + ).toBeDefined(); + }); + }); + + describe('applyScopes with defaultPath pre-fetching', () => { + it('should pre-fetch all nodes from defaultPath when applying scopes', async () => { + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithDefaultPath]); + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + + await service.changeScopes(['scope-sea-1']); + + // Should batch fetch all nodes in defaultPath + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith([ + 'region-us-west', + 'country-usa', + 'city-seattle', + 'datacenter-sea-1', + ]); + + // All nodes should be in cache + expect(service.state.nodes['region-us-west']).toEqual(regionNode); + expect(service.state.nodes['country-usa']).toEqual(countryNode); + expect(service.state.nodes['city-seattle']).toEqual(cityNode); + expect(service.state.nodes['datacenter-sea-1']).toEqual(datacenterNode); + }); + + it("should only pre-fetch the first scope's defaultPath", async () => { + const scope2: Scope = { + metadata: { name: 'scope-2' }, + spec: { + title: 'Scope 2', + defaultPath: ['region-us-west', 'country-usa', 'city-portland', 'datacenter-pdx-1'], + filters: [], + }, + }; + + const portlandNode: ScopeNode = { + metadata: { name: 'city-portland' }, + spec: { + nodeType: 'container', + title: 'Portland', + parentName: 'country-usa', + linkType: undefined, + linkId: undefined, + }, + }; + + const pdxDatacenterNode: ScopeNode = { + metadata: { name: 'datacenter-pdx-1' }, + spec: { + nodeType: 'leaf', + title: 'PDX-1', + parentName: 'city-portland', + linkType: 'scope', + linkId: 'scope-2', + }, + }; + + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithDefaultPath, scope2]); + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode, portlandNode, pdxDatacenterNode]); + + await service.changeScopes(['scope-sea-1', 'scope-2']); + + // Should only fetch the first scope's defaultPath (not the second scope's) + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith([ + 'region-us-west', + 'country-usa', + 'city-seattle', + 'datacenter-sea-1', + ]); + }); + + it('should not fetch when scopes have no defaultPath', async () => { + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithoutDefaultPath]); + + await service.changeScopes(['scope-no-path']); + + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + + it('should handle empty defaultPath array', async () => { + const scopeWithEmptyPath: Scope = { + metadata: { name: 'scope-empty' }, + spec: { + title: 'Scope Empty', + defaultPath: [], + filters: [], + }, + }; + + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithEmptyPath]); + + await service.changeScopes(['scope-empty']); + + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + }); + + describe('open selector with defaultPath expansion', () => { + beforeEach(() => { + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([regionNode]); + } else if (options.parent === 'region-us-west') { + return Promise.resolve([countryNode]); + } else if (options.parent === 'country-usa') { + return Promise.resolve([cityNode]); + } else if (options.parent === 'city-seattle') { + return Promise.resolve([datacenterNode]); + } + return Promise.resolve([]); + }); + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should expand to defaultPath when opening selector with applied scope', async () => { + // Apply a scope with defaultPath + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + appliedScopes: [{ scopeId: 'scope-sea-1', scopeNodeId: 'datacenter-sea-1' }], + selectedScopes: [{ scopeId: 'scope-sea-1', scopeNodeId: 'datacenter-sea-1' }], + }); + + await service.open(); + + // Should fetch all nodes in the path + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalled(); + + // Tree should be expanded to show the path + expect(service.state.tree.children?.['region-us-west']?.expanded).toBe(true); + expect(service.state.tree.children?.['region-us-west']?.children?.['country-usa']?.expanded).toBe(true); + expect( + service.state.tree.children?.['region-us-west']?.children?.['country-usa']?.children?.['city-seattle']?.expanded + ).toBe(true); + }); + + it('should fall back to parentNodeId when scope has no defaultPath', async () => { + // Pre-populate nodes for fallback behavior + (service as any).updateState({ + scopes: { 'scope-no-path': scopeWithoutDefaultPath }, + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + appliedScopes: [{ scopeId: 'scope-no-path', scopeNodeId: 'datacenter-sea-1', parentNodeId: 'city-seattle' }], + selectedScopes: [{ scopeId: 'scope-no-path', scopeNodeId: 'datacenter-sea-1', parentNodeId: 'city-seattle' }], + }); + + await service.open(); + + // Should still expand, but using parentNodeId logic + expect(service.state.opened).toBe(true); + }); + + it('should handle opening selector when scope is not yet loaded', async () => { + (service as any).updateState({ + appliedScopes: [{ scopeId: 'scope-sea-1' }], + selectedScopes: [{ scopeId: 'scope-sea-1' }], + }); + + await service.open(); + + // Should not crash, just open with root nodes + expect(service.state.opened).toBe(true); + }); + }); + + describe('performance improvements', () => { + it('should make only 1 API call for deep hierarchy with defaultPath', async () => { + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + }); + + apiClient.fetchMultipleScopeNodes = jest + .fn() + .mockResolvedValue([regionNode, countryNode, cityNode, datacenterNode]); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + // Should make exactly 1 API call + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledTimes(1); + }); + + it('should make N API calls for deep hierarchy without defaultPath (old behavior)', async () => { + // This test documents the old recursive behavior for comparison + apiClient.fetchScopeNode = jest.fn().mockImplementation((id: string) => { + const nodeMap: Record = { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }; + return Promise.resolve(nodeMap[id]); + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + await service.resolvePathToRoot('datacenter-sea-1', tree); + + // Would make 4 sequential calls in the old implementation + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('datacenter-sea-1'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('city-seattle'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('country-usa'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('region-us-west'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledTimes(4); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle defaultPath with missing nodes gracefully', async () => { + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + }); + + // API returns fewer nodes than requested + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode, countryNode]); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree, 'scope-sea-1'); + + // Should handle partial path gracefully + expect(result.path).toEqual([regionNode, countryNode]); + }); + + it('should handle API errors during batch fetch', async () => { + (service as any).updateState({ + scopes: { 'scope-sea-1': scopeWithDefaultPath }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([]); + + await service.changeScopes(['scope-sea-1']); + + // Should not crash, state should be consistent + expect(service.state.appliedScopes).toEqual([{ scopeId: 'scope-sea-1' }]); + }); + + it('should deduplicate node IDs in defaultPath', async () => { + const scopeWithDuplicates: Scope = { + metadata: { name: 'scope-dupe' }, + spec: { + title: 'Scope with Duplicates', + defaultPath: ['region-us-west', 'country-usa', 'region-us-west', 'country-usa'], + filters: [], + }, + }; + + apiClient.fetchMultipleScopes = jest.fn().mockResolvedValue([scopeWithDuplicates]); + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode, countryNode]); + + await service.changeScopes(['scope-dupe']); + + // Should only fetch unique nodes + const calledWith = apiClient.fetchMultipleScopeNodes.mock.calls[0][0]; + const uniqueNodes = [...new Set(calledWith)]; + expect(calledWith.length).toBe(uniqueNodes.length); + }); + + it('should handle defaultPath with only root node', async () => { + const scopeWithRootOnly: Scope = { + metadata: { name: 'scope-root' }, + spec: { + title: 'Scope Root Only', + defaultPath: ['region-us-west'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'scope-root': scopeWithRootOnly }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([regionNode]); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('region-us-west', tree, 'scope-root'); + + expect(result.path).toEqual([regionNode]); + }); + }); + + describe('backwards compatibility', () => { + it('should work with existing code that does not provide scopeId to resolvePathToRoot', async () => { + (service as any).updateState({ + nodes: { + 'datacenter-sea-1': datacenterNode, + 'city-seattle': cityNode, + 'country-usa': countryNode, + 'region-us-west': regionNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('datacenter-sea-1', tree); + + expect(result.path).toEqual([regionNode, countryNode, cityNode, datacenterNode]); + }); + + it('should not break when scope metadata is loaded after applying', async () => { + // This simulates the async nature of scope loading + apiClient.fetchMultipleScopes = jest.fn().mockImplementation(async () => { + // Simulate delay + await new Promise((resolve) => setTimeout(resolve, 10)); + return [scopeWithDefaultPath]; + }); + + await service.changeScopes(['scope-sea-1']); + + // Scope should eventually be in state + expect(service.state.scopes['scope-sea-1']).toEqual(scopeWithDefaultPath); + }); + }); + + // Tests for path helper methods + describe('getPathForScope (new helper method)', () => { + it('should prefer defaultPath from scope metadata', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + }); + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode]); + + // This tests the new getPathForScope method that should be created + // For now, this is testing the expected behavior through resolvePathToRoot + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('child', tree, 'test-scope'); + + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['parent', 'child']); + expect(result.path).toEqual([parentNode, childNode]); + }); + + it('should fall back to scopeNodeId when no defaultPath', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + nodes: { + parent: parentNode, + child: childNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('child', tree, 'test-scope'); + + expect(result.path).toEqual([parentNode, childNode]); + }); + + it('should return empty array when both scopeId and scopeNodeId are undefined', async () => { + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('', tree); + + expect(result.path).toEqual([]); + }); + + it('should handle scope not being in cache yet', async () => { + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + (service as any).updateState({ + nodes: { + parent: parentNode, + child: childNode, + }, + }); + + // Scope not in cache, but scopeNodeId is provided + const result = await service.resolvePathToRoot('child', tree, 'unknown-scope'); + + // Should fall back to node-based path + expect(result.path).toEqual([parentNode, childNode]); + }); + }); + + describe('getNodePath - optimized implementation', () => { + it('should build path from cached nodes without API calls', async () => { + (service as any).updateState({ + nodes: { + parent: parentNode, + child: childNode, + grandchild: grandchildNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('grandchild', tree); + + // Should not make any API calls since all nodes are cached + expect(apiClient.fetchScopeNode).not.toHaveBeenCalled(); + expect(result.path).toEqual([parentNode, childNode, grandchildNode]); + }); + + it('should fetch missing nodes in the path', async () => { + // Only grandchild is cached + (service as any).updateState({ + nodes: { + grandchild: grandchildNode, + }, + }); + + apiClient.fetchScopeNode = jest.fn().mockImplementation((id: string) => { + if (id === 'child') { + return Promise.resolve(childNode); + } + if (id === 'parent') { + return Promise.resolve(parentNode); + } + return Promise.resolve(undefined); + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + await service.resolvePathToRoot('grandchild', tree); + + // Should fetch missing parent nodes + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('child'); + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('parent'); + }); + + it('should handle circular references gracefully', async () => { + const circularNode1: ScopeNode = { + metadata: { name: 'node1' }, + spec: { + nodeType: 'container', + title: 'Node 1', + parentName: 'node2', + }, + }; + + const circularNode2: ScopeNode = { + metadata: { name: 'node2' }, + spec: { + nodeType: 'container', + title: 'Node 2', + parentName: 'node1', + }, + }; + + (service as any).updateState({ + nodes: { + node1: circularNode1, + node2: circularNode2, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + // This should not hang or crash + // Implementation should detect circular references and stop recursion + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const result = await service.resolvePathToRoot('node1', tree); + + expect(result).toBeDefined(); + // When circular reference is detected, it returns partial path (up to the circular point) + expect(result.path.length).toBeGreaterThan(0); + expect(consoleErrorSpy).toHaveBeenCalledWith('Circular reference detected in node path', expect.any(String)); + consoleErrorSpy.mockRestore(); + }); + + it('should stop at root node (empty parentName)', async () => { + (service as any).updateState({ + nodes: { + parent: parentNode, + child: childNode, + }, + }); + + const tree = { + expanded: false, + scopeNodeId: '', + query: '', + children: {}, + }; + + const result = await service.resolvePathToRoot('child', tree); + + expect(result.path).toEqual([parentNode, childNode]); + expect(result.path[0].spec.parentName).toBe(''); + }); + }); + + describe('expandToSelectedScope (new helper method)', () => { + beforeEach(() => { + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } + return Promise.resolve([]); + }); + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode]); + }); + + it('should expand tree to show selected scope path', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + }); + + await service.open(); + + // Tree should be expanded to show the path + expect(service.state.tree.children?.['parent']?.expanded).toBe(true); + expect(service.state.tree.children?.['parent']?.children?.['child']).toBeDefined(); + }); + + it('should not expand when no scopes are selected', async () => { + (service as any).updateState({ + selectedScopes: [], + appliedScopes: [], + }); + + await service.open(); + + // Root should have children loaded but not expanded beyond that + expect(service.state.tree.children).toBeDefined(); + }); + + it('should load children of the last node in the path', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child', parentNodeId: 'parent' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child', parentNodeId: 'parent' }], + }); + + // Mock API to return path nodes + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode]); + + // Mock fetchNodes to return children of the last node + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } + return Promise.resolve([]); + }); + + await service.open(); + + // Should have loaded root children (called once during tree initialization) + expect(apiClient.fetchNodes).toHaveBeenCalled(); + // Verify the path nodes were fetched (parent already in cache from fetchNodes, so only child is fetched) + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['child']); + }); + + it('should handle errors gracefully when expanding', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + }); + + // Mock API to fail + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + apiClient.fetchMultipleScopeNodes = jest.fn().mockRejectedValue(new Error('API Error')); + + // Should not crash + await expect(service.open()).resolves.not.toThrow(); + expect(service.state.opened).toBe(true); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('integration - full path resolution flow', () => { + it('should resolve path from defaultPath, insert into tree, and expand', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child', 'grandchild'], + filters: [], + }, + }; + + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([parentNode, childNode, grandchildNode]); + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } else if (options.parent === 'child') { + return Promise.resolve([grandchildNode]); + } + return Promise.resolve([]); + }); + + (service as any).updateState({ + scopes: { 'test-scope': scope }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'grandchild' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'grandchild' }], + }); + + await service.open(); + + // Path should be resolved (parent already in cache from fetchNodes, so only child and grandchild are fetched) + expect(apiClient.fetchMultipleScopeNodes).toHaveBeenCalledWith(['child', 'grandchild']); + + // Nodes should be in cache + expect(service.state.nodes['parent']).toEqual(parentNode); + expect(service.state.nodes['child']).toEqual(childNode); + expect(service.state.nodes['grandchild']).toEqual(grandchildNode); + + // Tree should be expanded + expect(service.state.tree.children?.['parent']?.expanded).toBe(true); + expect(service.state.tree.children?.['parent']?.children?.['child']?.expanded).toBe(true); + }); + + it('should use cached nodes and avoid unnecessary API calls', async () => { + const scope: Scope = { + metadata: { name: 'test-scope' }, + spec: { + title: 'Test Scope', + defaultPath: ['parent', 'child'], + filters: [], + }, + }; + + // Pre-populate cache + (service as any).updateState({ + scopes: { 'test-scope': scope }, + nodes: { + parent: parentNode, + child: childNode, + }, + selectedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + appliedScopes: [{ scopeId: 'test-scope', scopeNodeId: 'child' }], + }); + + apiClient.fetchNodes = jest.fn().mockImplementation((options) => { + // Return children based on parent + if (options.parent === '') { + return Promise.resolve([parentNode]); + } else if (options.parent === 'parent') { + return Promise.resolve([childNode]); + } + return Promise.resolve([]); + }); + apiClient.fetchMultipleScopeNodes = jest.fn().mockResolvedValue([]); + + await service.open(); + + // Should not fetch nodes that are already cached + expect(apiClient.fetchMultipleScopeNodes).not.toHaveBeenCalled(); + }); + }); + + describe('getScopeNode - caching behavior', () => { + it('should return cached node without API call', async () => { + (service as any).updateState({ + nodes: { + 'test-node': childNode, + }, + }); + + const result = await service.getScopeNode('test-node'); + + expect(result).toEqual(childNode); + expect(apiClient.fetchScopeNode).not.toHaveBeenCalled(); + }); + + it('should fetch and cache node when not in cache', async () => { + apiClient.fetchScopeNode = jest.fn().mockResolvedValue(childNode); + + const result = await service.getScopeNode('test-node'); + + expect(apiClient.fetchScopeNode).toHaveBeenCalledWith('test-node'); + expect(result).toEqual(childNode); + // Node is cached using its metadata.name, not the requested ID + expect(service.state.nodes['child']).toEqual(childNode); + }); + + it('should handle API errors gracefully', async () => { + apiClient.fetchScopeNode = jest.fn().mockResolvedValue(undefined); + + const result = await service.getScopeNode('non-existent'); + + expect(result).toBeUndefined(); + expect(service.state.nodes['non-existent']).toBeUndefined(); + }); + }); }); diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index abd837c7fb7..b7abee2a2cc 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -19,6 +19,7 @@ import { treeNodeAtPath, } from './scopesTreeUtils'; import { NodesMap, RecentScope, RecentScopeSchema, ScopeSchema, ScopesMap, SelectedScope, TreeNode } from './types'; + export const RECENT_SCOPES_KEY = 'grafana.scopes.recent'; export interface ScopesSelectorServiceState { @@ -101,22 +102,74 @@ export class ScopesSelectorService extends ScopesServiceBase => { + private getNodePath = async (scopeNodeId: string, visited: Set = new Set()): Promise => { + // Protect against circular references + if (visited.has(scopeNodeId)) { + console.error('Circular reference detected in node path', scopeNodeId); + return []; + } + const node = await this.getScopeNode(scopeNodeId); if (!node) { return []; } + + // Add current node to visited set + const newVisited = new Set(visited); + newVisited.add(scopeNodeId); + const parentPath = - node.spec.parentName && node.spec.parentName !== '' ? await this.getNodePath(node.spec.parentName) : []; + node.spec.parentName && node.spec.parentName !== '' + ? await this.getNodePath(node.spec.parentName, newVisited) + : []; return [...parentPath, node]; }; + /** + * Determines the path to a scope node, preferring defaultPath from scope metadata. + * This is the single source of truth for path resolution. + * + * TODO: Consider making this public and exposing via a hook to avoid duplication + * with getScopesPath in ScopesInput.tsx + * + * @param scopeId - The scope ID to get the path for + * @param scopeNodeId - Optional scope node ID to fall back to if no defaultPath + * @returns Promise resolving to array of ScopeNode objects representing the path + */ + private async getPathForScope(scopeId: string, scopeNodeId?: string): Promise { + // 1. Check if scope has defaultPath (preferred method) + const scope = this.state.scopes[scopeId]; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Batch fetch all nodes in defaultPath + return await this.getScopeNodes(scope.spec.defaultPath); + } + + // 2. Fall back to calculating path from scopeNodeId + if (scopeNodeId) { + return await this.getNodePath(scopeNodeId); + } + + return []; + } + public resolvePathToRoot = async ( scopeNodeId: string, - tree: TreeNode + tree: TreeNode, + scopeId?: string ): Promise<{ path: ScopeNode[]; tree: TreeNode }> => { - const nodePath = await this.getNodePath(scopeNodeId); + let nodePath: ScopeNode[]; + + // Check if scope has defaultPath for optimized resolution + const scope = scopeId ? this.state.scopes[scopeId] : undefined; + if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) { + // Use batch-fetched defaultPath (most efficient) + nodePath = await this.getPathForScope(scopeId!, scopeNodeId); + } else { + // Fall back to node-based path resolution + nodePath = await this.getNodePath(scopeNodeId); + } + const newTree = insertPathNodesIntoTree(tree, nodePath); this.updateState({ tree: newTree }); @@ -207,16 +260,39 @@ export class ScopesSelectorService extends ScopesServiceBase { - // Set parent query only when filtering within existing children - treeNode.children = {}; + // Preserve existing children that have nested structure (from insertPathNodesIntoTree) + const existingChildren = treeNode.children || {}; + const childrenToPreserve: Record = {}; + + // Keep children that have a children property (object, not undefined) + // This includes both empty objects {} (from path insertion) and populated ones + for (const [key, child] of Object.entries(existingChildren)) { + // Preserve if children is an object (not undefined) + if (child.children !== undefined && typeof child.children === 'object') { + childrenToPreserve[key] = child; + } + } + + // Start with preserved children, then add/update with fetched children + treeNode.children = { ...childrenToPreserve }; + for (const node of childNodes) { - treeNode.children[node.metadata.name] = { - expanded: false, - scopeNodeId: node.metadata.name, - // Only set query on tree nodes if parent already has children (filtering vs first expansion). This is used for saerch highlighting. - query: query || '', - children: undefined, - }; + // If this child was preserved, merge with fetched data + if (childrenToPreserve[node.metadata.name]) { + treeNode.children[node.metadata.name] = { + ...childrenToPreserve[node.metadata.name], + // Update query but keep nested children + query: query || '', + }; + } else { + // New child from API + treeNode.children[node.metadata.name] = { + expanded: false, + scopeNodeId: node.metadata.name, + query: query || '', + children: undefined, + }; + } } // Set loaded to true if node is a container treeNode.childrenLoaded = true; @@ -356,16 +432,54 @@ export class ScopesSelectorService extends ScopesServiceBase 0) { + // Deduplicate and filter out already cached nodes + const uniqueNodeIds = [...new Set(firstScope.spec.defaultPath)]; + const nodesToFetch = uniqueNodeIds.filter((nodeId) => !this.state.nodes[nodeId]); - this.addRecentScopes(fetchedScopes, parentNode, scopes[0]?.scopeNodeId); + if (nodesToFetch.length > 0) { + await this.getScopeNodes(nodesToFetch); + } + } + + // Get scopeNode and parentNode, preferring defaultPath as the source of truth + let parentNode: ScopeNode | undefined; + let scopeNodeId: string | undefined; + + if (firstScope?.spec.defaultPath && firstScope.spec.defaultPath.length > 1) { + // Extract from defaultPath (most reliable source) + // defaultPath format: ['', 'parent-id', 'scope-node-id', ...] + scopeNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 1]; + const parentNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 2]; + + scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined; + parentNode = parentNodeId && parentNodeId !== '' ? this.state.nodes[parentNodeId] : undefined; + } else { + // Fallback to next in priority order + scopeNodeId = scopes[0]?.scopeNodeId; + scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined; + + const parentNodeId = scopes[0]?.parentNodeId ?? scopeNode?.spec.parentName; + parentNode = parentNodeId ? this.state.nodes[parentNodeId] : undefined; + } + + this.addRecentScopes(fetchedScopes, parentNode, scopeNodeId); this.updateState({ scopes: newScopesState, loading: false }); } }; @@ -375,7 +489,7 @@ export class ScopesSelectorService extends ScopesServiceBase { - if (!('url' in s.spec) || typeof s.spec.url !== 'string') { + if (!('url' in s.spec)) { return false; } return isCurrentPath(currentPath, s.spec.url); @@ -386,7 +500,6 @@ export class ScopesSelectorService extends ScopesServiceBase [scopes[0]?.parentNode?.metadata?.name, scopes[0]?.parentNode]) .filter(([key, parentNode]) => parentNode !== undefined && key !== undefined) ); - - return parentNodes; }; /** @@ -499,40 +609,42 @@ export class ScopesSelectorService extends ScopesServiceBase n.metadata.name); - path.unshift(''); - nodeAtPath = treeNodeAtPath(newTree, path); - } catch (error) { - console.error('Failed to resolve path to root', error); - } - } - - // We have resolved to root, which means the parent node should be available - let parentPath = path.slice(0, -1); - let parentNodeAtPath = treeNodeAtPath(newTree, parentPath); - - if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) { - // This will update the tree with the children - const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, ''); - newTree = newTreeWithChildren; - } - - // Expand the nodes to the selected scope - must be done after loading children try { - newTree = expandNodes(newTree, parentPath); + // Get the path for the selected scope, preferring defaultPath from scope metadata + const pathNodes = await this.getPathForScope( + this.state.selectedScopes[0].scopeId, + this.state.selectedScopes[0].scopeNodeId + ); + + if (pathNodes.length > 0) { + // Convert to string path + const stringPath = pathNodes.map((n) => n.metadata.name); + stringPath.unshift(''); // Add root segment + + // Check if nodes are in tree + let nodeAtPath = treeNodeAtPath(newTree, stringPath); + + // If nodes aren't in tree yet, insert them + if (!nodeAtPath) { + newTree = insertPathNodesIntoTree(newTree, pathNodes); + // Update state so loadNodeChildren can see the inserted nodes + this.updateState({ tree: newTree }); + } + + // Load children of the parent node if needed to show all siblings + const parentPath = stringPath.slice(0, -1); + const parentNodeAtPath = treeNodeAtPath(newTree, parentPath); + + if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) { + const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, ''); + newTree = newTreeWithChildren; + } + + // Expand the nodes to show the selected scope + newTree = expandNodes(newTree, parentPath); + } } catch (error) { - console.error('Failed to expand nodes', error); + console.error('Failed to expand to selected scope', error); } } @@ -580,9 +692,14 @@ export class ScopesSelectorService extends ScopesServiceBase !nodesMap[name]); - const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch); - for (const node of nodes) { - nodesMap[node.metadata.name] = node; + if (nodesToFetch.length > 0) { + const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch); + // Handle case where API returns undefined or non-array + if (Array.isArray(nodes)) { + for (const node of nodes) { + nodesMap[node.metadata.name] = node; + } + } } const newNodes = { ...this.state.nodes, ...nodesMap }; diff --git a/public/app/features/scopes/selector/scopesTreeUtils.ts b/public/app/features/scopes/selector/scopesTreeUtils.ts index 8824742505d..d4001db68c1 100644 --- a/public/app/features/scopes/selector/scopesTreeUtils.ts +++ b/public/app/features/scopes/selector/scopesTreeUtils.ts @@ -127,17 +127,27 @@ export const insertPathNodesIntoTree = (tree: TreeNode, path: ScopeNode[]) => { if (!childNodeName) { console.warn('Failed to insert full path into tree. Did not find child to' + stringPath[index]); treeNode.childrenLoaded = treeNode.childrenLoaded ?? false; - return treeNode; + return; + } + // Create node if it doesn't exist + if (!treeNode.children[childNodeName]) { + treeNode.children[childNodeName] = { + expanded: false, + scopeNodeId: childNodeName, + query: '', + children: {}, + childrenLoaded: false, + }; + } else { + // Node exists, ensure it has children object for nested insertion + if (treeNode.children[childNodeName].children === undefined) { + treeNode.children[childNodeName] = { + ...treeNode.children[childNodeName], + children: {}, + }; + } } - treeNode.children[childNodeName] = { - expanded: false, - scopeNodeId: childNodeName, - query: '', - children: undefined, - childrenLoaded: false, - }; treeNode.childrenLoaded = treeNode.childrenLoaded ?? false; - return treeNode; }); } return newTree; diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts index f4ac22b6850..56921ac6f7d 100644 --- a/public/app/features/scopes/tests/selector.test.ts +++ b/public/app/features/scopes/tests/selector.test.ts @@ -132,11 +132,12 @@ describe('Selector', () => { expectRecentScope('Grafana Applications'); expectRecentScope('Grafana, Mimir Applications'); await selectRecentScope('Grafana Applications'); + await jest.runOnlyPendingTimersAsync(); expectScopesSelectorValue('Grafana'); await openSelector(); - // Close to root node so we can see the recent scopes + // Collapse tree to root level to see recent scopes section await expandResultApplications(); await expandRecentScopes(); @@ -155,8 +156,8 @@ describe('Selector', () => { await applyScopes(); await openSelector(); - // Close to root node so we can try to see the recent scopes - await expandResultApplications(); + // Tree expands to show selected scope, so recent scopes are not visible + // (recent scopes only show at root level with tree collapsed) expectRecentScopeNotPresentInDocument(); }); From e4ba1c1a6dd467f5d5959a91065b2ec2f22b8db4 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Fri, 9 Jan 2026 13:43:36 -0500 Subject: [PATCH 06/28] Alerting: Refactor encryption and receiver handling for improved clarity (#115959) * refactor: change GetReceiver to get by UID to avoid conversions of name to uid back an forth * refactor: consolidate all encrypt\decrypt functions * move error from temporary location --- .../notifications/receiver/legacy_storage.go | 15 +---- pkg/services/ngalert/models/receivers.go | 7 --- pkg/services/ngalert/notifier/crypto.go | 26 +++++++++ pkg/services/ngalert/notifier/errors.go | 30 +++++++++- pkg/services/ngalert/notifier/receiver_svc.go | 46 ++++++--------- .../ngalert/notifier/receiver_svc_err.go | 30 ---------- .../ngalert/notifier/receiver_svc_test.go | 56 +++++++------------ pkg/services/ngalert/remote/alertmanager.go | 20 ++----- .../ngalert/remote/alertmanager_test.go | 33 ++--------- 9 files changed, 100 insertions(+), 163 deletions(-) delete mode 100644 pkg/services/ngalert/notifier/receiver_svc_err.go diff --git a/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go b/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go index 84c0fe9f7fd..f8f663a8853 100644 --- a/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go +++ b/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" alertingac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" ) var ( @@ -25,7 +24,7 @@ var ( ) type ReceiverService interface { - GetReceiver(ctx context.Context, q ngmodels.GetReceiverQuery, user identity.Requester) (*ngmodels.Receiver, error) + GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*ngmodels.Receiver, error) GetReceivers(ctx context.Context, q ngmodels.GetReceiversQuery, user identity.Requester) ([]*ngmodels.Receiver, error) CreateReceiver(ctx context.Context, r *ngmodels.Receiver, orgID int64, user identity.Requester) (*ngmodels.Receiver, error) UpdateReceiver(ctx context.Context, r *ngmodels.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*ngmodels.Receiver, error) @@ -116,22 +115,12 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption return nil, err } - name, err := legacy_storage.UidToName(uid) - if err != nil { - return nil, apierrors.NewNotFound(ResourceInfo.GroupResource(), uid) - } - q := ngmodels.GetReceiverQuery{ - OrgID: info.OrgID, - Name: name, - Decrypt: false, - } - user, err := identity.GetRequester(ctx) if err != nil { return nil, err } - r, err := s.service.GetReceiver(ctx, q, user) + r, err := s.service.GetReceiver(ctx, uid, false, user) if err != nil { return nil, err } diff --git a/pkg/services/ngalert/models/receivers.go b/pkg/services/ngalert/models/receivers.go index 1601ddc94e5..106203e4e33 100644 --- a/pkg/services/ngalert/models/receivers.go +++ b/pkg/services/ngalert/models/receivers.go @@ -16,13 +16,6 @@ import ( "github.com/grafana/alerting/receivers/schema" ) -// GetReceiverQuery represents a query for a single receiver. -type GetReceiverQuery struct { - OrgID int64 - Name string - Decrypt bool -} - // GetReceiversQuery represents a query for receiver groups. type GetReceiversQuery struct { OrgID int64 diff --git a/pkg/services/ngalert/notifier/crypto.go b/pkg/services/ngalert/notifier/crypto.go index 246cd03d64d..04a651a174f 100644 --- a/pkg/services/ngalert/notifier/crypto.go +++ b/pkg/services/ngalert/notifier/crypto.go @@ -378,3 +378,29 @@ func EncryptedReceivers(receivers []*definitions.PostableApiReceiver, encryptFn } return encrypted, nil } + +// DecryptIntegrationSettings returns a function to decrypt integration settings. +func DecryptIntegrationSettings(ctx context.Context, ss secretService) models.DecryptFn { + return func(value string) (string, error) { + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return "", err + } + decrypted, err := ss.Decrypt(ctx, decoded) + if err != nil { + return "", err + } + return string(decrypted), nil + } +} + +// EncryptIntegrationSettings returns a function to encrypt integration settings. +func EncryptIntegrationSettings(ctx context.Context, ss secretService) models.EncryptFn { + return func(payload string) (string, error) { + encrypted, err := ss.Encrypt(ctx, []byte(payload), secrets.WithoutScope()) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(encrypted), nil + } +} diff --git a/pkg/services/ngalert/notifier/errors.go b/pkg/services/ngalert/notifier/errors.go index 337dfa7cb3b..08f4c967049 100644 --- a/pkg/services/ngalert/notifier/errors.go +++ b/pkg/services/ngalert/notifier/errors.go @@ -1,9 +1,37 @@ package notifier -import "github.com/grafana/grafana/pkg/apimachinery/errutil" +import ( + "errors" + "slices" + + "github.com/grafana/alerting/receivers/schema" + + "github.com/grafana/grafana/pkg/apimachinery/errutil" +) // WithPublicError sets the public message of an errutil error to the error message. func WithPublicError(err errutil.Error) error { err.PublicMessage = err.Error() return err } + +// If provided error is errutil.Error it appends fields that caused the error to public payload +func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error { + var authzErr errutil.Error + if !errors.As(err, &authzErr) { + return err + } + if authzErr.PublicPayload == nil { + authzErr.PublicPayload = map[string]interface{}{} + } + fields := make(map[string][]string, len(diff)) + for field, paths := range diff { + fields[field] = make([]string, len(paths)) + for i, path := range paths { + fields[field][i] = path.String() + } + slices.Sort(fields[field]) + } + authzErr.PublicPayload["changed_protected_fields"] = fields + return authzErr +} diff --git a/pkg/services/ngalert/notifier/receiver_svc.go b/pkg/services/ngalert/notifier/receiver_svc.go index e05b12a2920..dc3e9d4b8eb 100644 --- a/pkg/services/ngalert/notifier/receiver_svc.go +++ b/pkg/services/ngalert/notifier/receiver_svc.go @@ -2,7 +2,6 @@ package notifier import ( "context" - "encoding/base64" "errors" "fmt" "strings" @@ -133,30 +132,33 @@ func (rs *ReceiverService) loadProvenances(ctx context.Context, orgID int64) (ma return rs.provisioningStore.GetProvenances(ctx, orgID, (&models.Integration{}).ResourceType()) } -// GetReceiver returns a receiver by name. +// GetReceiver returns a receiver by its UID. // The receiver's secure settings are decrypted if requested and the user has access to do so. -func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (*models.Receiver, error) { +func (rs *ReceiverService) GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*models.Receiver, error) { + if user == nil { + return nil, errors.New("user is required") + } ctx, span := rs.tracer.Start(ctx, "alerting.receivers.get", trace.WithAttributes( - attribute.Int64("query_org_id", q.OrgID), - attribute.String("query_name", q.Name), - attribute.Bool("query_decrypt", q.Decrypt), + attribute.Int64("query_org_id", user.GetOrgID()), + attribute.String("query_uid", uid), + attribute.Bool("query_decrypt", decrypt), )) defer span.End() - revision, err := rs.cfgStore.Get(ctx, q.OrgID) + revision, err := rs.cfgStore.Get(ctx, user.GetOrgID()) if err != nil { return nil, err } - prov, err := rs.loadProvenances(ctx, q.OrgID) + prov, err := rs.loadProvenances(ctx, user.GetOrgID()) if err != nil { return nil, err } - rcv, err := revision.GetReceiver(legacy_storage.NameToUid(q.Name), prov) + rcv, err := revision.GetReceiver(uid, prov) if err != nil { if errors.Is(err, legacy_storage.ErrReceiverNotFound) && rs.includeImported { - imported := rs.getImportedReceivers(ctx, span, []string{legacy_storage.NameToUid(q.Name)}, revision) + imported := rs.getImportedReceivers(ctx, span, []string{uid}, revision) if len(imported) > 0 { rcv = imported[0] } @@ -171,14 +173,14 @@ func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiver )) auth := rs.authz.AuthorizeReadDecrypted - if !q.Decrypt { + if !decrypt { auth = rs.authz.AuthorizeRead } if err := auth(ctx, user, rcv); err != nil { return nil, err } - if q.Decrypt { + if decrypt { err := rcv.Decrypt(rs.decryptor(ctx)) if err != nil { rs.log.FromContext(ctx).Warn("Failed to decrypt secure settings", "name", rcv.Name, "error", err) @@ -684,28 +686,12 @@ func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, i // decryptor returns a models.DecryptFn that decrypts a secure setting. If decryption fails, the fallback value is used. func (rs *ReceiverService) decryptor(ctx context.Context) models.DecryptFn { - return func(value string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(value) - if err != nil { - return "", err - } - decrypted, err := rs.encryptionService.Decrypt(ctx, decoded) - if err != nil { - return "", err - } - return string(decrypted), nil - } + return DecryptIntegrationSettings(ctx, rs.encryptionService) } // encryptor creates an encrypt function that delegates to secrets.Service and returns the base64 encoded result. func (rs *ReceiverService) encryptor(ctx context.Context) models.EncryptFn { - return func(payload string) (string, error) { - s, err := rs.encryptionService.Encrypt(ctx, []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(s), nil - } + return EncryptIntegrationSettings(ctx, rs.encryptionService) } // checkOptimisticConcurrency checks if the existing receiver's version matches the desired version. diff --git a/pkg/services/ngalert/notifier/receiver_svc_err.go b/pkg/services/ngalert/notifier/receiver_svc_err.go deleted file mode 100644 index 06b27602f75..00000000000 --- a/pkg/services/ngalert/notifier/receiver_svc_err.go +++ /dev/null @@ -1,30 +0,0 @@ -package notifier - -import ( - "errors" - "slices" - - "github.com/grafana/alerting/receivers/schema" - - "github.com/grafana/grafana/pkg/apimachinery/errutil" -) - -func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error { - var authzErr errutil.Error - if !errors.As(err, &authzErr) { - return err - } - if authzErr.PublicPayload == nil { - authzErr.PublicPayload = map[string]interface{}{} - } - fields := make(map[string][]string, len(diff)) - for field, paths := range diff { - fields[field] = make([]string, len(paths)) - for i, path := range paths { - fields[field][i] = path.String() - } - slices.Sort(fields[field]) - } - authzErr.PublicPayload["changed_protected_fields"] = fields - return authzErr -} diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go index 10e84f33b62..08c66411f16 100644 --- a/pkg/services/ngalert/notifier/receiver_svc_test.go +++ b/pkg/services/ngalert/notifier/receiver_svc_test.go @@ -50,7 +50,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("service gets receiver from AM config", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - recv, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser) + recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser) require.NoError(t, err) require.Equal(t, "slack receiver", recv.Name) require.Len(t, recv.Integrations, 1) @@ -60,7 +60,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("service returns error when receiver does not exist", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - _, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + _, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser) require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) }) @@ -68,7 +68,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("gets imported receivers", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService, withImportedIncluded) - recv, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser) require.NoError(t, err) assert.Equal(t, models.ResourceOriginImported, recv.Origin) assert.Equal(t, "receiver1", recv.Name) @@ -81,9 +81,9 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("falls to only Grafana if cannot read imported receivers", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService, withImportedIncluded, withInvalidExtraConfig) - _, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + _, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser) require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) - _, err = sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser) + _, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser) require.NoError(t, err) }) }) @@ -187,12 +187,6 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { user: readUser, err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver", }, - { - name: "service returns error if user is nil and decrypt is true", - decrypt: true, - user: nil, - err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver", - }, { name: "service decrypts receivers with permission", decrypt: true, @@ -224,18 +218,16 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { }, } - for _, o := range origin { - for _, method := range getMethods { - t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { + for _, method := range getMethods { + t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { + for _, o := range origin { t.Run(fmt.Sprintf("%s %s (%s)", tc.name, method, o.origin), func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService, o.opts...) var res *models.Receiver var err error if method == "single" { - q := singleQ(1, o.receiver) - q.Decrypt = tc.decrypt - res, err = sut.GetReceiver(context.Background(), q, tc.user) + res, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(o.receiver), tc.decrypt, tc.user) } else { q := multiQ(1, o.receiver) q.Decrypt = tc.decrypt @@ -267,8 +259,8 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { require.NotEqual(t, o.decryptedSettingValue, res.Integrations[0].SecureSettings[o.secureSettingKey]) } }) - }) - } + } + }) } } } @@ -412,8 +404,7 @@ func TestReceiverService_Delete(t *testing.T) { // Ensure receiver saved to store is correct. name, err := legacy_storage.UidToName(tc.deleteUID) require.NoError(t, err) - q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: name} - _, err = sut.GetReceiver(context.Background(), q, writer) + _, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(name), false, writer) assert.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) provenances, err := sut.provisioningStore.GetProvenances(context.Background(), tc.user.GetOrgID(), (&definitions.EmbeddedContactPoint{}).ResourceType()) @@ -626,8 +617,7 @@ func TestReceiverService_Create(t *testing.T) { assert.Equal(t, tc.expectedCreate, *created) // Ensure receiver saved to store is correct. - q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true} - stored, err := sut.GetReceiver(context.Background(), q, decryptUser) + stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser) require.NoError(t, err) decrypted := models.CopyReceiverWith(tc.expectedCreate, models.ReceiverMuts.Decrypted(models.Base64Decrypt)) decrypted.Version = tc.expectedCreate.Version // Version is calculated before decryption. @@ -931,8 +921,7 @@ func TestReceiverService_Update(t *testing.T) { assert.Equal(t, tc.expectedUpdate, *updated) // Ensure receiver saved to store is correct. - q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true} - stored, err := sut.GetReceiver(context.Background(), q, decryptUser) + stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser) require.NoError(t, err) decrypted := models.CopyReceiverWith(tc.expectedUpdate, models.ReceiverMuts.Decrypted(models.Base64Decrypt)) decrypted.Version = tc.expectedUpdate.Version // Version is calculated before decryption. @@ -1054,7 +1043,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) { sut.ruleNotificationsStore = ruleStore newReceiverName = "receiver1" - actual, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer) + actual, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(newReceiverName), false, writer) require.NoError(t, err) require.Equal(t, models.ResourceOriginImported, actual.Origin) require.Equal(t, newReceiverName, actual.Name) @@ -1067,7 +1056,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) { require.NotEqual(t, actual, recv) require.Equal(t, models.ResourceOriginGrafana, recv.Origin) - actual, err = sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer) + actual, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(newReceiverName), false, writer) require.NoError(t, err) require.Equal(t, recv.Name, actual.Name) }) @@ -1185,7 +1174,7 @@ func TestReceiverServiceAC_Read(t *testing.T) { return false } for _, recv := range allReceivers() { - response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr) + response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr) if isVisible(recv.UID) { require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name) assert.NotNil(t, response) @@ -1207,7 +1196,7 @@ func TestReceiverServiceAC_Read(t *testing.T) { } sut.authz = ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), true) for _, recv := range allReceivers() { - response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr) + response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr) if isVisibleInProvisioning(recv.UID) { require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name) assert.NotNil(t, response) @@ -1766,7 +1755,7 @@ func TestReceiverService_AccessControlMetadata(t *testing.T) { }, }} - r, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: 1, Name: "receiver1"}, admin) + r, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, admin) require.NoError(t, err) t.Run("should override metadata for imported receivers", func(t *testing.T) { @@ -1842,13 +1831,6 @@ func createEncryptedConfig(t *testing.T, secretService secretService, extraConfi return string(bytes) } -func singleQ(orgID int64, name string) models.GetReceiverQuery { - return models.GetReceiverQuery{ - OrgID: orgID, - Name: name, - } -} - func multiQ(orgID int64, names ...string) models.GetReceiversQuery { return models.GetReceiversQuery{ OrgID: orgID, diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index a7c99f8518c..60740d935af 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -40,6 +40,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier" remoteClient "github.com/grafana/grafana/pkg/services/ngalert/remote/client" "github.com/grafana/grafana/pkg/services/ngalert/sender" + "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/util/cmputil" ) @@ -57,6 +58,7 @@ func NoopAutogenFn(_ context.Context, _ log.Logger, _ int64, _ *apimodels.Postab } type Crypto interface { + Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error) Decrypt(ctx context.Context, payload []byte) ([]byte, error) DecryptExtraConfigs(ctx context.Context, config *apimodels.PostableUserConfig) error } @@ -289,20 +291,6 @@ func (am *Alertmanager) isDefaultConfiguration(configHash string) bool { return configHash == am.defaultConfigHash } -func decrypter(ctx context.Context, crypto Crypto) models.DecryptFn { - return func(value string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(value) - if err != nil { - return "", err - } - decrypted, err := crypto.Decrypt(ctx, decoded) - if err != nil { - return "", err - } - return string(decrypted), nil - } -} - // buildConfiguration takes a raw Alertmanager configuration and returns a config that the remote Alertmanager can use. // It parses the initial configuration, adds auto-generated routes, decrypts receivers, and merges the extra configs. func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, createdAtEpoch int64, autogenInvalidReceiverAction notifier.InvalidReceiversAction) (remoteClient.UserGrafanaConfig, error) { @@ -317,7 +305,7 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, crea } // Decrypt the receivers in the configuration. - decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, decrypter(ctx, am.crypto)) + decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto)) if err != nil { return remoteClient.UserGrafanaConfig{}, fmt.Errorf("unable to decrypt receivers: %w", err) } @@ -619,7 +607,7 @@ func (am *Alertmanager) GetReceivers(ctx context.Context) ([]apimodels.Receiver, } func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*alertingNotify.TestReceiversResult, int, error) { - decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, decrypter(ctx, am.crypto)) + decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto)) if err != nil { return nil, 0, fmt.Errorf("failed to decrypt receivers: %w", err) } diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go index d6f66756454..1a1d3943324 100644 --- a/pkg/services/ngalert/remote/alertmanager_test.go +++ b/pkg/services/ngalert/remote/alertmanager_test.go @@ -43,7 +43,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/remote/client" ngfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" - "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/database" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -298,13 +297,7 @@ func TestIntegrationApplyConfig(t *testing.T) { var c apimodels.PostableUserConfig require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &c)) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t))) - encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) c.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) @@ -462,13 +455,7 @@ func TestCompareAndSendConfiguration(t *testing.T) { // Create a config with correctly encrypted and encoded secrets. var inputCfg apimodels.PostableUserConfig require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg)) - encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) testGrafanaConfigWithEncryptedSecret, err := json.Marshal(inputCfg) @@ -663,13 +650,7 @@ func Test_TestReceiversDecryptsSecureSettings(t *testing.T) { var inputCfg apimodels.PostableUserConfig require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg)) - encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) @@ -1037,13 +1018,7 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) { { postableCfg, err := notifier.Load([]byte(testGrafanaConfigWithSecret)) require.NoError(t, err) - encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) postableCfg.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) From c42421e616b249971ef6779acc8387ba70af7395 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:31:56 -0600 Subject: [PATCH 07/28] Suggestions: Fix legend options for preview (#116078) --- .../app/features/panel/suggestions/utils.ts | 14 +++++++++++++- .../app/plugins/panel/piechart/suggestions.ts | 16 ++++++++-------- .../plugins/panel/timeseries/suggestions.ts | 19 +++---------------- public/app/plugins/panel/trend/module.tsx | 12 +++--------- 4 files changed, 27 insertions(+), 34 deletions(-) diff --git a/public/app/features/panel/suggestions/utils.ts b/public/app/features/panel/suggestions/utils.ts index 07820587be5..eaf56bd99c0 100644 --- a/public/app/features/panel/suggestions/utils.ts +++ b/public/app/features/panel/suggestions/utils.ts @@ -5,7 +5,7 @@ import { VisualizationSuggestion, VisualizationSuggestionScore, } from '@grafana/data'; -import { ReduceDataOptions } from '@grafana/schema'; +import { LegendDisplayMode, ReduceDataOptions, VizLegendOptions } from '@grafana/schema'; /** * @internal @@ -62,3 +62,15 @@ export function defaultNumericVizOptions( export function hasData(data?: PanelData): boolean { return Boolean(data && data.series && data.series.length > 0 && data.series.some((frame) => frame.length > 0)); } + +/** + * @internal + * Hidden legend config for previewing suggestion cards. + * This should only be used in previewModifier. + */ +export const SUGGESTIONS_LEGEND_OPTIONS: VizLegendOptions = { + calcs: [], + displayMode: LegendDisplayMode.Hidden, + placement: 'right', + showLegend: false, +}; diff --git a/public/app/plugins/panel/piechart/suggestions.ts b/public/app/plugins/panel/piechart/suggestions.ts index c991c628334..559992a25da 100644 --- a/public/app/plugins/panel/piechart/suggestions.ts +++ b/public/app/plugins/panel/piechart/suggestions.ts @@ -7,8 +7,7 @@ import { VisualizationSuggestionsSupplier, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { LegendDisplayMode } from '@grafana/schema'; -import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils'; +import { defaultNumericVizOptions, SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils'; import { PieChartLabels, Options, PieChartType } from './panelcfg.gen'; @@ -16,12 +15,13 @@ const withDefaults = (suggestion: VisualizationSuggestion): Visualizati defaultsDeep(suggestion, { options: { displayLabels: [PieChartLabels.Percent], - legend: { - calcs: [], - displayMode: LegendDisplayMode.Hidden, - placement: 'right', - values: [], - showLegend: false, + }, + cardOptions: { + previewModifier: (s) => { + s.options!.legend = { + ...SUGGESTIONS_LEGEND_OPTIONS, + values: [], + }; }, }, } satisfies VisualizationSuggestion); diff --git a/public/app/plugins/panel/timeseries/suggestions.ts b/public/app/plugins/panel/timeseries/suggestions.ts index b3f6abd74ba..265ed619714 100644 --- a/public/app/plugins/panel/timeseries/suggestions.ts +++ b/public/app/plugins/panel/timeseries/suggestions.ts @@ -10,15 +10,9 @@ import { VisualizationSuggestionsSupplier, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { - GraphDrawStyle, - GraphFieldConfig, - GraphGradientMode, - LegendDisplayMode, - LineInterpolation, - StackingMode, -} from '@grafana/schema'; +import { GraphDrawStyle, GraphFieldConfig, GraphGradientMode, LineInterpolation, StackingMode } from '@grafana/schema'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils'; import { Options } from './panelcfg.gen'; @@ -29,14 +23,6 @@ const withDefaults = ( suggestion: VisualizationSuggestion ): VisualizationSuggestion => defaultsDeep(suggestion, { - options: { - legend: { - calcs: [], - displayMode: LegendDisplayMode.Hidden, - placement: 'right', - showLegend: false, - }, - }, fieldConfig: { defaults: { custom: {}, @@ -46,6 +32,7 @@ const withDefaults = ( cardOptions: { previewModifier: (s) => { s.options!.disableKeyboardEvents = true; + s.options!.legend = SUGGESTIONS_LEGEND_OPTIONS; if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); } diff --git a/public/app/plugins/panel/trend/module.tsx b/public/app/plugins/panel/trend/module.tsx index 67434a8c542..023c1ea8a0d 100644 --- a/public/app/plugins/panel/trend/module.tsx +++ b/public/app/plugins/panel/trend/module.tsx @@ -1,8 +1,9 @@ import { Field, FieldType, PanelPlugin, VisualizationSuggestionScore } from '@grafana/data'; import { t } from '@grafana/i18n'; import { GraphDrawStyle } from '@grafana/schema'; -import { commonOptionsBuilder, LegendDisplayMode } from '@grafana/ui'; +import { commonOptionsBuilder } from '@grafana/ui'; import { optsWithHideZeros } from '@grafana/ui/internal'; +import { SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils'; import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config'; @@ -49,14 +50,6 @@ export const plugin = new PanelPlugin(TrendPanel) return [ { score: VisualizationSuggestionScore.Good, - options: { - legend: { - calcs: [], - displayMode: LegendDisplayMode.Hidden, - placement: 'right', - showLegend: false, - }, - }, fieldConfig: { defaults: { custom: {}, @@ -65,6 +58,7 @@ export const plugin = new PanelPlugin(TrendPanel) }, cardOptions: { previewModifier: (s) => { + s.options!.legend = SUGGESTIONS_LEGEND_OPTIONS; if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); } From e983aac14126bad59f537cdb3d55afe1720b93e6 Mon Sep 17 00:00:00 2001 From: Victor Cinaglia Date: Fri, 9 Jan 2026 16:56:24 -0300 Subject: [PATCH 08/28] Users: Fix "Last active" displaying incorrect value instead of "Never" (#115825) --- .../src/clients/rtkq/legacy/endpoints.gen.ts | 2 ++ pkg/services/org/model.go | 2 +- pkg/services/user/model.go | 1 + pkg/services/user/userimpl/store.go | 2 +- public/api-enterprise-spec.json | 8 ++++++++ public/api-merged.json | 8 ++++++++ public/app/features/admin/Users/OrgUsersTable.tsx | 7 +++++-- public/app/features/admin/Users/UsersTable.tsx | 11 +++++++++-- public/app/types/user.ts | 2 ++ public/openapi3.json | 8 ++++++++ 10 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts index 95aac4ef570..4ceed793cad 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts @@ -5340,6 +5340,7 @@ export type OrgUserDto = { }; authLabels?: string[]; avatarUrl?: string; + created?: string; email?: string; isDisabled?: boolean; isExternallySynced?: boolean; @@ -6118,6 +6119,7 @@ export type ChangeUserPasswordCommand = { export type UserSearchHitDto = { authLabels?: string[]; avatarUrl?: string; + created?: string; email?: string; id?: number; isAdmin?: boolean; diff --git a/pkg/services/org/model.go b/pkg/services/org/model.go index ac0268e051c..fa11375d778 100644 --- a/pkg/services/org/model.go +++ b/pkg/services/org/model.go @@ -151,7 +151,7 @@ type OrgUserDTO struct { Role string `json:"role"` LastSeenAt time.Time `json:"lastSeenAt"` Updated time.Time `json:"-"` - Created time.Time `json:"-"` + Created time.Time `json:"created"` LastSeenAtAge string `json:"lastSeenAtAge"` AccessControl map[string]bool `json:"accessControl,omitempty"` IsDisabled bool `json:"isDisabled"` diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index fc8d9589357..12b0ac6ed57 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -146,6 +146,7 @@ type UserSearchHitDTO struct { LastSeenAtAge string `json:"lastSeenAtAge"` AuthLabels []string `json:"authLabels"` AuthModule AuthModuleConversion `json:"-"` + Created time.Time `json:"created" xorm:"created"` } type GetUserProfileQuery struct { diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index a7773147373..64780c5c2eb 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -541,7 +541,7 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (* sess.Limit(query.Limit, offset) } - sess.Cols("u.id", "u.uid", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module", "u.is_provisioned") + sess.Cols("u.id", "u.uid", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module", "u.is_provisioned", "u.created") if len(query.SortOpts) > 0 { for i := range query.SortOpts { diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index b381b7e38fd..4cb15addb0a 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -6002,6 +6002,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, @@ -8903,6 +8907,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, diff --git a/public/api-merged.json b/public/api-merged.json index 057251b12bc..d511139c7bd 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -18489,6 +18489,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, @@ -23410,6 +23414,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, diff --git a/public/app/features/admin/Users/OrgUsersTable.tsx b/public/app/features/admin/Users/OrgUsersTable.tsx index 3debbd886a8..a3d93b12bad 100644 --- a/public/app/features/admin/Users/OrgUsersTable.tsx +++ b/public/app/features/admin/Users/OrgUsersTable.tsx @@ -115,12 +115,15 @@ export const OrgUsersTable = ({ { id: 'lastSeenAtAge', header: 'Last active', - cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => { + cell: ({ cell: { value }, row: { original } }: Cell<'lastSeenAtAge'>) => { + // If lastSeenAt is before created, user has never logged in + const neverLoggedIn = + original.lastSeenAt && original.created && new Date(original.lastSeenAt) < new Date(original.created); return ( <> {value && ( <> - {value === '10 years' ? ( + {neverLoggedIn ? ( Never diff --git a/public/app/features/admin/Users/UsersTable.tsx b/public/app/features/admin/Users/UsersTable.tsx index a3a1f6be297..b4d34f0d438 100644 --- a/public/app/features/admin/Users/UsersTable.tsx +++ b/public/app/features/admin/Users/UsersTable.tsx @@ -135,12 +135,19 @@ export const UsersTable = ({ content: 'Time since user was seen using Grafana', iconName: 'question-circle', }, - cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => { + cell: ({ + cell: { value }, + row: { + original: { lastSeenAt, created }, + }, + }: Cell<'lastSeenAtAge'>) => { + // The user has never logged in if lastSeenAt is before its creation date. + const neverLoggedIn = lastSeenAt && created && new Date(lastSeenAt) < new Date(created); return ( <> {value && ( <> - {value === '10 years' ? ( + {neverLoggedIn ? ( Never diff --git a/public/app/types/user.ts b/public/app/types/user.ts index e5cd1805e7f..9e575e2ad93 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -5,6 +5,7 @@ import { Role } from './accessControl'; export interface OrgUser extends WithAccessControlMetadata { avatarUrl: string; email: string; + created?: string; lastSeenAt: string; lastSeenAtAge: string; login: string; @@ -49,6 +50,7 @@ export interface UserDTO extends WithAccessControlMetadata { theme?: string; avatarUrl?: string; orgId?: number; + created?: string; lastSeenAt?: string; lastSeenAtAge?: string; licensedRole?: string; diff --git a/public/openapi3.json b/public/openapi3.json index 589a49e68ba..4a7c5f6be08 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -8018,6 +8018,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "format": "date-time", + "type": "string" + }, "email": { "type": "string" }, @@ -12938,6 +12942,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "format": "date-time", + "type": "string" + }, "email": { "type": "string" }, From 622c75af6d8663da865d815871368a968bb23942 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:19:26 -0600 Subject: [PATCH 09/28] Suggestions: Add analytics (#115904) --- .../panel-edit/PanelOptionsPane.tsx | 2 + .../panel-edit/PanelVizTypePicker.tsx | 16 ++++++- .../dashboard-scene/panel-edit/interaction.ts | 1 + .../VisualizationSuggestions.tsx | 46 +++++++++++++++---- .../components/VizTypePicker/interactions.ts | 28 +++++++++++ .../panel/components/VizTypePicker/types.ts | 1 + 6 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 public/app/features/panel/components/VizTypePicker/interactions.ts diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx index b863bf2fc78..290e298c88c 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx @@ -71,6 +71,7 @@ export class PanelOptionsPane extends SceneObjectBase { reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.SELECT_PANEL_PLUGIN, plugin_id: pluginId, + from_suggestions: options.fromSuggestions ?? false, }); // clear custom options @@ -236,6 +237,7 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps )} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx index acc4ff208e6..71c82bcf6ab 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx @@ -26,6 +26,7 @@ export interface Props { editPreview: VizPanel; onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void; onClose: () => void; + isNewPanel?: boolean; } const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }> => { @@ -42,7 +43,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }> : [allVisualizationsTab, suggestionsTab]; }; -export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton }: Props) { +export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton, isNewPanel }: Props) { const styles = useStyles2(getStyles); const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]); const filterId = useId(); @@ -83,6 +84,16 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose [setListMode] ); + const handleBackButtonClick = useCallback(() => { + reportInteraction(INTERACTION_EVENT_NAME, { + item: INTERACTION_ITEM.BACK_BUTTON, + tab: VisualizationSelectPaneTab[listMode], + creator_team: 'grafana_plugins_catalog', + schema_version: '1.0.0', + }); + onClose(); + }, [listMode, onClose]); + return (
@@ -114,7 +125,7 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose variant="secondary" icon="arrow-left" data-testid={selectors.components.PanelEditor.toggleVizPicker} - onClick={onClose} + onClick={handleBackButtonClick} > Back @@ -136,6 +147,7 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose editPreview={editPreview} data={data} searchQuery={searchQuery} + isNewPanel={isNewPanel} /> )} {listMode === VisualizationSelectPaneTab.Visualizations && ( diff --git a/public/app/features/dashboard-scene/panel-edit/interaction.ts b/public/app/features/dashboard-scene/panel-edit/interaction.ts index 4ba515b5223..da147a60807 100644 --- a/public/app/features/dashboard-scene/panel-edit/interaction.ts +++ b/public/app/features/dashboard-scene/panel-edit/interaction.ts @@ -4,4 +4,5 @@ export const INTERACTION_ITEM = { SELECT_PANEL_PLUGIN: 'select_panel_plugin', CHANGE_TAB: 'change_tab', // for ref - PanelVizTypePicker SEARCH: 'search', // for ref - PanelVizTypePicker + BACK_BUTTON: 'back_button', // for ref - PanelVizTypePicker }; diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index 924b5f3b6bf..c29f1b60a21 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -22,6 +22,7 @@ import { getAllSuggestions } from '../../suggestions/getAllSuggestions'; import { hasData } from '../../suggestions/utils'; import { VisualizationSuggestionCard } from './VisualizationSuggestionCard'; +import { VizSuggestionsInteractions, PANEL_STATES, type PanelState } from './interactions'; import { VizTypeChangeDetails } from './types'; export interface Props { @@ -30,6 +31,7 @@ export interface Props { data?: PanelData; panel?: PanelModel; searchQuery?: string; + isNewPanel?: boolean; } const useSuggestions = (data: PanelData | undefined, searchQuery: string | undefined) => { @@ -62,7 +64,7 @@ const useSuggestions = (data: PanelData | undefined, searchQuery: string | undef return { value: filteredValue, loading, error, retry }; }; -export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery }: Props) { +export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery, isNewPanel }: Props) { const styles = useStyles2(getStyles); const { value: result, loading, error, retry } = useSuggestions(data, searchQuery); @@ -75,6 +77,18 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s const isNewVizSuggestionsEnabled = config.featureToggles.newVizSuggestions; const isUnconfiguredPanel = panel?.type === UNCONFIGURED_PANEL_PLUGIN_ID; + const panelState = useMemo((): PanelState => { + if (isUnconfiguredPanel) { + return PANEL_STATES.UNCONFIGURED_PANEL; + } + + if (isNewPanel) { + return PANEL_STATES.NEW_PANEL; + } + + return PANEL_STATES.EXISTING_PANEL; + }, [isUnconfiguredPanel, isNewPanel]); + const suggestionsByVizType = useMemo(() => { const meta = getAllPanelPluginMeta(); const record: Record = {}; @@ -96,22 +110,36 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s }, [suggestions]); const applySuggestion = useCallback( - (suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => { + (suggestion: PanelPluginVisualizationSuggestion, isPreview: boolean, isAutoSelected = false) => { + if (isPreview) { + VizSuggestionsInteractions.suggestionPreviewed({ + pluginId: suggestion.pluginId, + suggestionName: suggestion.name, + panelState, + isAutoSelected, + }); + + setSuggestionHash(suggestion.hash); + } else { + VizSuggestionsInteractions.suggestionAccepted({ + pluginId: suggestion.pluginId, + suggestionName: suggestion.name, + panelState, + }); + } + onChange( { pluginId: suggestion.pluginId, options: suggestion.options, fieldConfig: suggestion.fieldConfig, withModKey: isPreview, + fromSuggestions: true, }, isPreview ? editPreview : undefined ); - - if (isPreview) { - setSuggestionHash(suggestion.hash); - } }, - [onChange, editPreview] + [onChange, editPreview, panelState] ); useEffect(() => { @@ -124,7 +152,7 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s // the previously selected suggestion is no longer present in the list. const newFirstCardHash = suggestions?.[0]?.hash ?? null; if (firstCardHash !== newFirstCardHash || suggestions.every((s) => s.hash !== suggestionHash)) { - applySuggestion(suggestions[0], true); + applySuggestion(suggestions[0], true, true); setFirstCardHash(newFirstCardHash); return; } @@ -243,7 +271,7 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s suggestion={suggestion} width={width} tabIndex={index} - onClick={() => applySuggestion(suggestion)} + onClick={() => applySuggestion(suggestion, false)} />
))} diff --git a/public/app/features/panel/components/VizTypePicker/interactions.ts b/public/app/features/panel/components/VizTypePicker/interactions.ts new file mode 100644 index 00000000000..3062919c93a --- /dev/null +++ b/public/app/features/panel/components/VizTypePicker/interactions.ts @@ -0,0 +1,28 @@ +import { reportInteraction } from '@grafana/runtime'; + +export const PANEL_STATES = { + UNCONFIGURED_PANEL: 'unconfigured_panel', + NEW_PANEL: 'new_panel', + EXISTING_PANEL: 'existing_panel', +} as const; + +export type PanelState = (typeof PANEL_STATES)[keyof typeof PANEL_STATES]; + +export const VizSuggestionsInteractions = { + suggestionPreviewed: (properties: { + pluginId: string; + suggestionName: string; + panelState: PanelState; + isAutoSelected?: boolean; + }) => { + reportVizSuggestionsInteraction('suggestion_previewed', properties); + }, + + suggestionAccepted: (properties: { pluginId: string; suggestionName: string; panelState: PanelState }) => { + reportVizSuggestionsInteraction('suggestion_accepted', properties); + }, +}; + +const reportVizSuggestionsInteraction = (name: string, properties?: Record) => { + reportInteraction(`grafana_viz_suggestions_${name}`, properties); +}; diff --git a/public/app/features/panel/components/VizTypePicker/types.ts b/public/app/features/panel/components/VizTypePicker/types.ts index 4dbab692faf..6eac9011184 100644 --- a/public/app/features/panel/components/VizTypePicker/types.ts +++ b/public/app/features/panel/components/VizTypePicker/types.ts @@ -5,4 +5,5 @@ export interface VizTypeChangeDetails { options?: Record; fieldConfig?: FieldConfigSource; withModKey?: boolean; + fromSuggestions?: boolean; } From ba196958cd62d2409b6bf5bdd69f16572c2b5d57 Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:38:13 -0500 Subject: [PATCH 10/28] unified-storage: integration test to CRUD sql backend and sqlkv at the same time (#115907) * add tests to check that we can CRUD resources to both backends at the same time --- .../storage_backend_sql_compatibility.go | 813 +++++++++++++++--- 1 file changed, 676 insertions(+), 137 deletions(-) diff --git a/pkg/storage/unified/testing/storage_backend_sql_compatibility.go b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go index c133bdfbd7b..6584992f3cd 100644 --- a/pkg/storage/unified/testing/storage_backend_sql_compatibility.go +++ b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go @@ -2,9 +2,12 @@ package test import ( "context" + "encoding/json" "fmt" "strings" + "sync" "testing" + "time" "github.com/bwmarrin/snowflake" "github.com/stretchr/testify/require" @@ -67,6 +70,8 @@ func RunSQLStorageBackendCompatibilityTest(t *testing.T, newSqlBackend, newKvBac }{ {"key_path generation", runTestIntegrationBackendKeyPathGeneration}, {"sql backend fields compatibility", runTestSQLBackendFieldsCompatibility}, + {"cross backend consistency", runTestCrossBackendConsistency}, + {"concurrent operations stress", runTestConcurrentOperationsStress}, } for _, tc := range cases { @@ -111,38 +116,25 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri // Create 3 resources for i := 1; i <= 3; i++ { - key := &resourcepb.ResourceKey{ - Group: "playlist.grafana.app", - Resource: "playlists", - Namespace: nsPrefix, - Name: fmt.Sprintf("test-playlist-%d", i), + folder := "" + if i == 2 { + folder = "test-folder" // Resource 2 has folder annotation } - // Create resource JSON with folder annotation for resource 2 - resourceJSON := fmt.Sprintf(`{ - "apiVersion": "playlist.grafana.app/v0alpha1", - "kind": "Playlist", - "metadata": { - "name": "test-playlist-%d", - "namespace": "%s", - "uid": "test-uid-%d"%s - }, - "spec": { - "title": "My Test Playlist %d" - } - }`, i, nsPrefix, i, getAnnotationsJSON(i == 2), i) + opts := PlaylistResourceOptions{ + Name: fmt.Sprintf("test-playlist-%d", i), + Namespace: nsPrefix, + UID: fmt.Sprintf("test-uid-%d", i), + Generation: 1, + Title: fmt.Sprintf("My Test Playlist %d", i), + Folder: folder, + } - // Create the resource using server.Create - created, err := server.Create(ctx, &resourcepb.CreateRequest{ - Key: key, - Value: []byte(resourceJSON), - }) - require.NoError(t, err) - require.Nil(t, created.Error) - require.Greater(t, created.ResourceVersion, int64(0)) + created := createPlaylistResource(t, server, ctx, opts) currentRVs[i-1] = created.ResourceVersion // Verify created resource key_path (with folder for resource 2) + key := createPlaylistKey(nsPrefix, fmt.Sprintf("test-playlist-%d", i)) if i == 2 { verifyKeyPath(t, db, ctx, key, "created", created.ResourceVersion, "test-folder") } else { @@ -152,39 +144,25 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri // Update the 3 resources for i := 1; i <= 3; i++ { - key := &resourcepb.ResourceKey{ - Group: "playlist.grafana.app", - Resource: "playlists", - Namespace: nsPrefix, - Name: fmt.Sprintf("test-playlist-%d", i), + folder := "" + if i == 2 { + folder = "test-folder" // Resource 2 has folder annotation } - // Create updated resource JSON with folder annotation for resource 2 - updatedResourceJSON := fmt.Sprintf(`{ - "apiVersion": "playlist.grafana.app/v0alpha1", - "kind": "Playlist", - "metadata": { - "name": "test-playlist-%d", - "namespace": "%s", - "uid": "test-uid-%d"%s - }, - "spec": { - "title": "My Updated Playlist %d" - } - }`, i, nsPrefix, i, getAnnotationsJSON(i == 2), i) + opts := PlaylistResourceOptions{ + Name: fmt.Sprintf("test-playlist-%d", i), + Namespace: nsPrefix, + UID: fmt.Sprintf("test-uid-%d", i), + Generation: 2, + Title: fmt.Sprintf("My Updated Playlist %d", i), + Folder: folder, + } - // Update the resource using server.Update - updated, err := server.Update(ctx, &resourcepb.UpdateRequest{ - Key: key, - Value: []byte(updatedResourceJSON), - ResourceVersion: currentRVs[i-1], // Use the resource version returned by previous operation - }) - require.NoError(t, err) - require.Nil(t, updated.Error) - require.Greater(t, updated.ResourceVersion, currentRVs[i-1]) + updated := updatePlaylistResource(t, server, ctx, opts, currentRVs[i-1]) currentRVs[i-1] = updated.ResourceVersion // Update to the latest resource version // Verify updated resource key_path (with folder for resource 2) + key := createPlaylistKey(nsPrefix, fmt.Sprintf("test-playlist-%d", i)) if i == 2 { verifyKeyPath(t, db, ctx, key, "updated", updated.ResourceVersion, "test-folder") } else { @@ -194,22 +172,11 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri // Delete the 3 resources for i := 1; i <= 3; i++ { - key := &resourcepb.ResourceKey{ - Group: "playlist.grafana.app", - Resource: "playlists", - Namespace: nsPrefix, - Name: fmt.Sprintf("test-playlist-%d", i), - } - - // Delete the resource using server.Delete - deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{ - Key: key, - ResourceVersion: currentRVs[i-1], // Use the resource version from previous operation - }) - require.NoError(t, err) - require.Greater(t, deleted.ResourceVersion, currentRVs[i-1]) + name := fmt.Sprintf("test-playlist-%d", i) + deleted := deletePlaylistResource(t, server, ctx, nsPrefix, name, currentRVs[i-1]) // Verify deleted resource key_path (with folder for resource 2) + key := createPlaylistKey(nsPrefix, name) if i == 2 { verifyKeyPath(t, db, ctx, key, "deleted", deleted.ResourceVersion, "test-folder") } else { @@ -276,17 +243,6 @@ func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resource require.Equal(t, expectedActionCode, actualAction) } -// getAnnotationsJSON returns the annotations JSON string for the folder annotation if needed -func getAnnotationsJSON(withFolder bool) string { - if withFolder { - return `, - "annotations": { - "grafana.app/folder": "test-folder" - }` - } - return "" -} - // runTestSQLBackendFieldsCompatibility tests that KV backend with RvManager populates all SQL backend legacy fields func runTestSQLBackendFieldsCompatibility(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { ctx := testutil.NewDefaultTestContext(t) @@ -349,76 +305,35 @@ func runSQLBackendFieldsTest(t *testing.T, backend resource.StorageBackend, name // Create 3 resources for i, res := range resources { - key := &resourcepb.ResourceKey{ - Group: "playlist.grafana.app", - Resource: "playlists", - Namespace: namespace, - Name: res.name, + // Create the resource using helper function + opts := PlaylistResourceOptions{ + Name: res.name, + Namespace: namespace, + UID: fmt.Sprintf("test-uid-%d", i+1), + Generation: 1, + Title: fmt.Sprintf("Test Playlist %d", i+1), + Folder: res.folder, } - // Create resource JSON with folder annotation and generation=1 for creates - resourceJSON := fmt.Sprintf(`{ - "apiVersion": "playlist.grafana.app/v0alpha1", - "kind": "Playlist", - "metadata": { - "name": "%s", - "namespace": "%s", - "uid": "test-uid-%d", - "generation": 1%s - }, - "spec": { - "title": "Test Playlist %d" - } - }`, res.name, namespace, i+1, getAnnotationsJSON(res.folder != ""), i+1) - - // Create the resource - created, err := server.Create(ctx, &resourcepb.CreateRequest{ - Key: key, - Value: []byte(resourceJSON), - }) - require.NoError(t, err) - require.Nil(t, created.Error) - require.Greater(t, created.ResourceVersion, int64(0)) - + created := createPlaylistResource(t, server, ctx, opts) // Store the resource version resourceVersions[i] = append(resourceVersions[i], created.ResourceVersion) } // Update 3 resources for i, res := range resources { - key := &resourcepb.ResourceKey{ - Group: "playlist.grafana.app", - Resource: "playlists", - Namespace: namespace, - Name: res.name, + // Update the resource using helper function + opts := PlaylistResourceOptions{ + Name: res.name, + Namespace: namespace, + UID: fmt.Sprintf("test-uid-%d", i+1), + Generation: 2, + Title: fmt.Sprintf("Updated Test Playlist %d", i+1), + Folder: res.folder, } - // Update resource JSON with generation=2 for updates - resourceJSON := fmt.Sprintf(`{ - "apiVersion": "playlist.grafana.app/v0alpha1", - "kind": "Playlist", - "metadata": { - "name": "%s", - "namespace": "%s", - "uid": "test-uid-%d", - "generation": 2%s - }, - "spec": { - "title": "Updated Test Playlist %d" - } - }`, res.name, namespace, i+1, getAnnotationsJSON(res.folder != ""), i+1) - - // Update the resource using the current resource version currentRV := resourceVersions[i][len(resourceVersions[i])-1] - updated, err := server.Update(ctx, &resourcepb.UpdateRequest{ - Key: key, - Value: []byte(resourceJSON), - ResourceVersion: currentRV, - }) - require.NoError(t, err) - require.Nil(t, updated.Error) - require.Greater(t, updated.ResourceVersion, currentRV) - + updated := updatePlaylistResource(t, server, ctx, opts, currentRV) // Store the new resource version resourceVersions[i] = append(resourceVersions[i], updated.ResourceVersion) } @@ -714,3 +629,627 @@ func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, res // But it shouldn't be too much higher (within a reasonable range) require.LessOrEqual(t, record.ResourceVersion, maxRV+100, "resource_version shouldn't be much higher than expected") } + +// runTestCrossBackendConsistency tests basic consistency between SQL and KV backends (lightweight) +func runTestCrossBackendConsistency(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + // Create storage servers from both backends + sqlServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: sqlBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + kvServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: kvBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Create isolated namespaces for each test phase + sqlNamespace := nsPrefix + "-concurrent-sql" + kvNamespace := nsPrefix + "-concurrent-kv" + + t.Run("Write to SQL, Read from Both", func(t *testing.T) { + runWriteToOneReadFromBoth(t, sqlServer, kvServer, sqlNamespace+"-writeSQL", ctx, "sql") + }) + + t.Run("Write to KV, Read from Both", func(t *testing.T) { + runWriteToOneReadFromBoth(t, kvServer, sqlServer, kvNamespace+"-writeKV", ctx, "kv") + }) + + t.Run("Resource Version Consistency", func(t *testing.T) { + runResourceVersionConsistencyTest(t, sqlServer, kvServer, nsPrefix+"-rv-consistency", ctx) + }) +} + +// runTestConcurrentOperationsStress tests heavy concurrent operations between SQL and KV backends +func runTestConcurrentOperationsStress(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + // Skip on SQLite due to concurrency limitations + if db.DriverName() == "sqlite3" { + t.Skip("Skipping concurrent operations stress test on SQLite") + } + + ctx := testutil.NewDefaultTestContext(t) + + // Create storage servers from both backends + sqlServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: sqlBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + kvServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: kvBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Create isolated namespace for mixed operations + mixedNamespace := nsPrefix + "-concurrent-mixed" + + // do a single create using the sql backend to initialize the resource_version table + // without this, both backend may try to insert the same group+resource to the resource_version which breaks the + // tests + initNamespace := mixedNamespace + "-init" + initOpts := PlaylistResourceOptions{ + Name: "init-resource", + Namespace: initNamespace, + UID: "init-uid", + Generation: 1, + Title: "Init Resource", + Folder: "", + } + createPlaylistResource(t, sqlServer, ctx, initOpts) + + // Heavy Mixed Concurrent Operations + t.Run("Mixed Concurrent Operations", func(t *testing.T) { + runMixedConcurrentOperations(t, sqlServer, kvServer, mixedNamespace, ctx) + }) +} + +// runWriteToOneReadFromBoth writes resources to one backend then reads from both to verify consistency +func runWriteToOneReadFromBoth(t *testing.T, writeServer, readServer resource.ResourceServer, namespace string, ctx context.Context, writerBackend string) { + // Create 5 test resources + resourceNames := []string{ + fmt.Sprintf("resource-%s-1", writerBackend), + fmt.Sprintf("resource-%s-2", writerBackend), + fmt.Sprintf("resource-%s-3", writerBackend), + fmt.Sprintf("resource-%s-4", writerBackend), + fmt.Sprintf("resource-%s-5", writerBackend), + } + + createdResourceVersions := make([]int64, len(resourceNames)) + + // Write all resources to the write backend + for i, resourceName := range resourceNames { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: resourceName, + } + + resourceJSON := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "%s", + "namespace": "%s", + "uid": "test-uid-%d", + "generation": 1 + }, + "spec": { + "title": "Concurrent Test Playlist %d" + } + }`, resourceName, namespace, i+1, i+1) + + created, err := writeServer.Create(ctx, &resourcepb.CreateRequest{ + Key: key, + Value: []byte(resourceJSON), + }) + require.NoError(t, err) + require.Nil(t, created.Error) + require.Greater(t, created.ResourceVersion, int64(0)) + createdResourceVersions[i] = created.ResourceVersion + } + + // Add a small delay to ensure data propagates + time.Sleep(10 * time.Millisecond) + + // Read from both backends and compare payloads + for _, resourceName := range resourceNames { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: resourceName, + } + + // Read from write backend + writeResp, err := writeServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err, "Failed to read %s from write backend", resourceName) + require.Nil(t, writeResp.Error, "Read error from write backend %s: %s", resourceName, writeResp.Error) + require.Greater(t, writeResp.ResourceVersion, int64(0), "Invalid resource version for %s on write backend", resourceName) + + // Read from read backend + readResp, err := readServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err, "Failed to read %s from read backend", resourceName) + require.Nil(t, readResp.Error, "Read error from read backend %s: %s", resourceName, readResp.Error) + require.Greater(t, readResp.ResourceVersion, int64(0), "Invalid resource version for %s on read backend", resourceName) + + // Validate that both backends return identical payload content + require.JSONEq(t, string(writeResp.Value), string(readResp.Value), + "Payload mismatch for resource %s between write and read backends.\nWrite backend: %s\nRead backend: %s", + resourceName, string(writeResp.Value), string(readResp.Value)) + + // Validate that both backends return equivalent resource versions using rvmanager compatibility check + // Note: rvmanager.IsRvEqual expects snowflake format as first parameter, so we check both orderings + require.True(t, rvmanager.IsRvEqual(writeResp.ResourceVersion, readResp.ResourceVersion) || rvmanager.IsRvEqual(readResp.ResourceVersion, writeResp.ResourceVersion), + "Resource version mismatch for resource %s between backends.\nWrite backend (%s): %d\nRead backend (%s): %d", + resourceName, writerBackend, writeResp.ResourceVersion, getOtherBackendName(writerBackend), readResp.ResourceVersion) + + t.Logf("✓ Resource %s: payload and resource version (%d) consistency verified between %s (write) and %s (read) backends", + resourceName, writeResp.ResourceVersion, writerBackend, getOtherBackendName(writerBackend)) + } + + // Verify List consistency between backends + verifyListConsistencyBetweenServers(t, writeServer, readServer, namespace, len(resourceNames)) +} + +// getOtherBackendName returns the complementary backend name +func getOtherBackendName(backend string) string { + if backend == "sql" { + return "kv" + } + return "sql" +} + +// runMixedConcurrentOperations runs different operations simultaneously on both backends +func runMixedConcurrentOperations(t *testing.T, sqlServer, kvServer resource.ResourceServer, namespace string, ctx context.Context) { + var wg sync.WaitGroup + errors := make(chan error, 20) + startBarrier := make(chan struct{}) + + // Use higher operation counts to ensure concurrency + opCounts := BackendOperationCounts{ + Creates: 25, + Updates: 15, + Deletes: 10, + } + + // SQL backend operations + wg.Add(1) + go func() { + defer wg.Done() + <-startBarrier // Wait for signal to start + if err := runBackendOperationsWithCounts(ctx, sqlServer, namespace+"-sql", "sql", opCounts); err != nil { + errors <- fmt.Errorf("SQL backend operations failed: %w", err) + } + }() + + // KV backend operations + wg.Add(1) + go func() { + defer wg.Done() + <-startBarrier // Wait for signal to start + if err := runBackendOperationsWithCounts(ctx, kvServer, namespace+"-kv", "kv", opCounts); err != nil { + errors <- fmt.Errorf("KV backend operations failed: %w", err) + } + }() + + // Start both goroutines simultaneously + close(startBarrier) + + // Wait for operations to complete with timeout + done := make(chan bool) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // Operations completed + case <-time.After(10 * time.Second): + t.Fatal("Timeout waiting for mixed concurrent operations") + } + + // Check for errors + close(errors) + for err := range errors { + t.Error(err) + } + + // Allow some time for data propagation + time.Sleep(50 * time.Millisecond) + + // Calculate expected remaining resources based on operation counts + expectedRemaining := opCounts.Creates - opCounts.Deletes // Creates - Deletes = Remaining + + // Verify consistency of resources created by SQL backend operations + // Note: Skip resource version checking since these are separate operations on different backends + verifyListConsistencyBetweenServersWithRVCheck(t, sqlServer, kvServer, namespace+"-sql", expectedRemaining, false) + + // Verify consistency of resources created by KV backend operations + // Note: Skip resource version checking since these are separate operations on different backends + verifyListConsistencyBetweenServersWithRVCheck(t, sqlServer, kvServer, namespace+"-kv", expectedRemaining, false) +} + +// BackendOperationCounts defines how many operations of each type to perform +type BackendOperationCounts struct { + Creates int + Updates int + Deletes int +} + +// runBackendOperationsWithCounts performs configurable create, update, delete operations on a backend +func runBackendOperationsWithCounts(ctx context.Context, server resource.ResourceServer, namespace, backendType string, counts BackendOperationCounts) error { + // Create resources + resourceVersions := make([]int64, counts.Creates) + for i := 1; i <= counts.Creates; i++ { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: fmt.Sprintf("resource-%s-%d", backendType, i), + } + + resourceJSON := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "resource-%s-%d", + "namespace": "%s", + "uid": "test-uid-%s-%d", + "generation": 1 + }, + "spec": { + "title": "Mixed Test Playlist %s %d" + } + }`, backendType, i, namespace, backendType, i, backendType, i) + + created, err := server.Create(ctx, &resourcepb.CreateRequest{ + Key: key, + Value: []byte(resourceJSON), + }) + if err != nil { + return fmt.Errorf("failed to create resource %d: %w", i, err) + } + if created.Error != nil { + return fmt.Errorf("create error for resource %d: %s", i, created.Error.Message) + } + resourceVersions[i-1] = created.ResourceVersion + } + + // Update resources (only update as many as we have, limited by creates and updates count) + updateCount := counts.Updates + if updateCount > counts.Creates { + updateCount = counts.Creates // Can't update more resources than we created + } + for i := 1; i <= updateCount; i++ { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: fmt.Sprintf("resource-%s-%d", backendType, i), + } + + updatedJSON := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "resource-%s-%d", + "namespace": "%s", + "uid": "test-uid-%s-%d", + "generation": 2 + }, + "spec": { + "title": "Updated Mixed Test Playlist %s %d" + } + }`, backendType, i, namespace, backendType, i, backendType, i) + + updated, err := server.Update(ctx, &resourcepb.UpdateRequest{ + Key: key, + Value: []byte(updatedJSON), + ResourceVersion: resourceVersions[i-1], + }) + if err != nil { + return fmt.Errorf("failed to update resource %d: %w", i, err) + } + if updated.Error != nil { + return fmt.Errorf("update error for resource %d: %s", i, updated.Error.Message) + } + resourceVersions[i-1] = updated.ResourceVersion + } + + // Delete resources (only delete as many as we have, limited by creates and deletes count) + deleteCount := counts.Deletes + if deleteCount > updateCount { + deleteCount = updateCount // Can only delete resources that were updated (have latest RV) + } + for i := 1; i <= deleteCount; i++ { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: fmt.Sprintf("resource-%s-%d", backendType, i), + } + + deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{ + Key: key, + ResourceVersion: resourceVersions[i-1], // Use the resource version from updates + }) + if err != nil { + return fmt.Errorf("failed to delete resource %d: %w", i, err) + } + if deleted.Error != nil { + return fmt.Errorf("delete error for resource %d: %s", i, deleted.Error.Message) + } + } + + return nil +} + +// runResourceVersionConsistencyTest verifies resource version handling across backends +func runResourceVersionConsistencyTest(t *testing.T, sqlServer, kvServer resource.ResourceServer, namespace string, ctx context.Context) { + // Create a resource on SQL backend + opts := PlaylistResourceOptions{ + Name: "rv-test-resource", + Namespace: namespace, + UID: "test-uid-rv", + Generation: 1, + Title: "RV Test Playlist", + Folder: "", // No folder + } + + createPlaylistResource(t, sqlServer, ctx, opts) + + // Allow data to propagate + time.Sleep(10 * time.Millisecond) + + // Read from KV backend to get the same resource + key := createPlaylistKey(namespace, "rv-test-resource") + kvRead, err := kvServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, kvRead.Error) + // Note: Resource versions may differ between backends, but content should be the same + require.Greater(t, kvRead.ResourceVersion, int64(0), "KV backend should return a valid resource version") + + // Read from SQL backend to compare content + sqlReadInitial, err := sqlServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, sqlReadInitial.Error) + require.JSONEq(t, string(sqlReadInitial.Value), string(kvRead.Value), "Both backends should return the same initial content") + + // Update via KV backend + updateOpts := PlaylistResourceOptions{ + Name: "rv-test-resource", + Namespace: namespace, + UID: "test-uid-rv", + Generation: 2, + Title: "Updated RV Test Playlist", + Folder: "", // No folder + } + + updatePlaylistResource(t, kvServer, ctx, updateOpts, kvRead.ResourceVersion) + + // Allow data to propagate + time.Sleep(10 * time.Millisecond) + + // Read from SQL backend to verify consistency + sqlRead, err := sqlServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, sqlRead.Error) + // Note: Resource versions may differ, but content should be consistent + require.Greater(t, sqlRead.ResourceVersion, int64(0), "SQL backend should return a valid resource version") + + // Verify both backends return the same content - we need to read from KV again to get the Value + kvReadAfterUpdate, err := kvServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, kvReadAfterUpdate.Error) + require.JSONEq(t, string(kvReadAfterUpdate.Value), string(sqlRead.Value), "Both backends should return the same updated content") +} + +// verifyListConsistencyBetweenServers verifies that both servers return consistent list results +func verifyListConsistencyBetweenServers(t *testing.T, server1, server2 resource.ResourceServer, namespace string, expectedCount int) { + verifyListConsistencyBetweenServersWithRVCheck(t, server1, server2, namespace, expectedCount, true) +} + +// verifyListConsistencyBetweenServersWithRVCheck verifies list consistency with optional resource version checking +func verifyListConsistencyBetweenServersWithRVCheck(t *testing.T, server1, server2 resource.ResourceServer, namespace string, expectedCount int, checkResourceVersions bool) { + ctx := testutil.NewDefaultTestContext(t) + + // Get lists from both servers + list1, err := server1.List(ctx, &resourcepb.ListRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + }, + }, + }) + require.NoError(t, err) + require.Nil(t, list1.Error) + + list2, err := server2.List(ctx, &resourcepb.ListRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + }, + }, + }) + require.NoError(t, err) + require.Nil(t, list2.Error) + + // Create maps for easier comparison by extracting names from JSON + items1 := make(map[string]*resourcepb.ResourceWrapper) + for _, item := range list1.Items { + itemNamespace := extractResourceNamespaceFromJSON(t, item.Value) + if itemNamespace == namespace { // Only compare items from our exact namespace + name := extractResourceNameFromJSON(t, item.Value) + items1[name] = item + } + } + + items2 := make(map[string]*resourcepb.ResourceWrapper) + for _, item := range list2.Items { + itemNamespace := extractResourceNamespaceFromJSON(t, item.Value) + if itemNamespace == namespace { // Only compare items from our exact namespace + name := extractResourceNameFromJSON(t, item.Value) + items2[name] = item + } + } + + // Verify counts match after filtering by namespace + require.Equal(t, expectedCount, len(items1), "Server 1 should return expected count after filtering") + require.Equal(t, expectedCount, len(items2), "Server 2 should return expected count after filtering") + require.Equal(t, len(items1), len(items2), "Both servers should return same count after filtering") + + // Verify all items exist in both lists with same content and resource version + for name, item1 := range items1 { + item2, exists := items2[name] + require.True(t, exists, "Item %s should exist in both lists", name) + require.Greater(t, item1.ResourceVersion, int64(0), "Item1 should have valid resource version for %s", name) + require.Greater(t, item2.ResourceVersion, int64(0), "Item2 should have valid resource version for %s", name) + require.JSONEq(t, string(item1.Value), string(item2.Value), "Content should match for %s", name) + + // Validate that both backends return equivalent resource versions using rvmanager compatibility check + if checkResourceVersions { + require.True(t, rvmanager.IsRvEqual(item1.ResourceVersion, item2.ResourceVersion) || rvmanager.IsRvEqual(item2.ResourceVersion, item1.ResourceVersion), + "Resource version mismatch for item %s between backends. Item1: %d, Item2: %d", name, item1.ResourceVersion, item2.ResourceVersion) + } + } +} + +// extractResourceNameFromJSON extracts the resource name from JSON metadata +func extractResourceNameFromJSON(t *testing.T, jsonData []byte) string { + var obj map[string]interface{} + err := json.Unmarshal(jsonData, &obj) + require.NoError(t, err, "Failed to unmarshal JSON") + + metadata, ok := obj["metadata"].(map[string]interface{}) + require.True(t, ok, "metadata field not found or not an object") + + name, ok := metadata["name"].(string) + require.True(t, ok, "name field not found or not a string") + + return name +} + +// extractResourceNamespaceFromJSON extracts the resource namespace from JSON metadata +func extractResourceNamespaceFromJSON(t *testing.T, jsonData []byte) string { + var obj map[string]interface{} + err := json.Unmarshal(jsonData, &obj) + require.NoError(t, err, "Failed to unmarshal JSON") + + metadata, ok := obj["metadata"].(map[string]interface{}) + require.True(t, ok, "metadata field not found or not an object") + + namespace, ok := metadata["namespace"].(string) + require.True(t, ok, "namespace field not found or not a string") + + return namespace +} + +// PlaylistResourceOptions defines options for creating test playlist resources +type PlaylistResourceOptions struct { + Name string + Namespace string + UID string + Generation int + Title string + Folder string // optional - empty string means no folder +} + +// createPlaylistJSON creates standardized JSON for playlist resources +func createPlaylistJSON(opts PlaylistResourceOptions) []byte { + folderAnnotation := "" + if opts.Folder != "" { + folderAnnotation = fmt.Sprintf(`, + "annotations": { + "grafana.app/folder": "%s" + }`, opts.Folder) + } + + jsonStr := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "%s", + "namespace": "%s", + "uid": "%s", + "generation": %d%s + }, + "spec": { + "title": "%s" + } + }`, opts.Name, opts.Namespace, opts.UID, opts.Generation, folderAnnotation, opts.Title) + + return []byte(jsonStr) +} + +// createPlaylistKey creates standardized ResourceKey for playlist resources +func createPlaylistKey(namespace, name string) *resourcepb.ResourceKey { + return &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: name, + } +} + +// createPlaylistResource creates a playlist resource using the server with consistent error handling +func createPlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, opts PlaylistResourceOptions) *resourcepb.CreateResponse { + t.Helper() + key := createPlaylistKey(opts.Namespace, opts.Name) + value := createPlaylistJSON(opts) + + created, err := server.Create(ctx, &resourcepb.CreateRequest{ + Key: key, + Value: value, + }) + require.NoError(t, err) + require.Nil(t, created.Error) + require.Greater(t, created.ResourceVersion, int64(0)) + + return created +} + +// updatePlaylistResource updates a playlist resource using the server with consistent error handling +func updatePlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, opts PlaylistResourceOptions, resourceVersion int64) *resourcepb.UpdateResponse { + t.Helper() + key := createPlaylistKey(opts.Namespace, opts.Name) + value := createPlaylistJSON(opts) + + updated, err := server.Update(ctx, &resourcepb.UpdateRequest{ + Key: key, + Value: value, + ResourceVersion: resourceVersion, + }) + require.NoError(t, err) + require.Nil(t, updated.Error) + require.Greater(t, updated.ResourceVersion, int64(0)) // Just check it's positive, not necessarily greater than input + + return updated +} + +// deletePlaylistResource deletes a playlist resource using the server with consistent error handling +func deletePlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, namespace, name string, resourceVersion int64) *resourcepb.DeleteResponse { + t.Helper() + key := createPlaylistKey(namespace, name) + + deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{ + Key: key, + ResourceVersion: resourceVersion, + }) + require.NoError(t, err) + require.Nil(t, deleted.Error) + require.Greater(t, deleted.ResourceVersion, int64(0)) + + return deleted +} From 0c8c886930f31f805b4ee2f85e082cc0f5b5014e Mon Sep 17 00:00:00 2001 From: Jesse David Peterson Date: Fri, 9 Jan 2026 16:07:35 -0500 Subject: [PATCH 11/28] PieChart: Fix right-oriented legends (#116084) * chore(gdev-dashboard): reproduce CSS layout bug in a panel * fix(table-legend): tweak CSS for right oriented table legend in piechart * chore(gdev-dashboard): update migrated dashboard file to match --- .../panel_test_piechart.v42.json | 26 ++++++++++++++++--- .../panel-piechart/panel_test_piechart.json | 26 ++++++++++++++++--- .../components/VizLegend/VizLegendTable.tsx | 1 + .../VizLegend/VizLegendTableItem.tsx | 1 + 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json index f705124be5b..5b07f246ae3 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json @@ -290,7 +290,7 @@ ], "legend": { "displayMode": "table", - "placement": "bottom", + "placement": "right", "showLegend": true, "values": [ "percent" @@ -304,7 +304,7 @@ "fields": "", "values": false }, - "showLegend": false, + "showLegend": true, "strokeWidth": 1, "text": {} }, @@ -323,6 +323,15 @@ } ], "title": "Percent", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "^Backend-(.*)$", + "renamePattern": "b-$1" + } + } + ], "type": "piechart" }, { @@ -366,7 +375,7 @@ ], "legend": { "displayMode": "table", - "placement": "bottom", + "placement": "right", "showLegend": true, "values": [ "value" @@ -380,7 +389,7 @@ "fields": "", "values": false }, - "showLegend": false, + "showLegend": true, "strokeWidth": 1, "text": {} }, @@ -399,6 +408,15 @@ } ], "title": "Value", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "(.*)", + "renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood" + } + } + ], "type": "piechart" }, { diff --git a/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json b/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json index 4333993ea8e..ac11fd803b9 100644 --- a/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json +++ b/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json @@ -248,7 +248,7 @@ "legend": { "values": ["percent"], "displayMode": "table", - "placement": "bottom" + "placement": "right" }, "pieType": "pie", "reduceOptions": { @@ -256,7 +256,7 @@ "fields": "", "values": false }, - "showLegend": false, + "showLegend": true, "strokeWidth": 1, "text": {} }, @@ -272,6 +272,15 @@ "timeFrom": null, "timeShift": null, "title": "Percent", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "^Backend-(.*)$", + "renamePattern": "b-$1" + } + } + ], "type": "piechart" }, { @@ -311,7 +320,7 @@ "legend": { "values": ["value"], "displayMode": "table", - "placement": "bottom" + "placement": "right" }, "pieType": "pie", "reduceOptions": { @@ -319,7 +328,7 @@ "fields": "", "values": false }, - "showLegend": false, + "showLegend": true, "strokeWidth": 1, "text": {} }, @@ -335,6 +344,15 @@ "timeFrom": null, "timeShift": null, "title": "Value", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "(.*)", + "renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood" + } + } + ], "type": "piechart" }, { diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index 0c2859453eb..b654a2d3ac6 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -119,6 +119,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ table: css({ width: '100%', 'th:first-child': { + width: '100%', borderBottom: `1px solid ${theme.colors.border.weak}`, }, }), diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 56ec6cb733e..6de77fd660b 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -134,6 +134,7 @@ const getStyles = (theme: GrafanaTheme2) => { label: 'LegendLabelCell', maxWidth: 0, width: '100%', + minWidth: theme.spacing(16), }), labelCellInner: css({ label: 'LegendLabelCellInner', From 5ff18c0802e67bc17b53cc0e73155cd44a0f15f2 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Fri, 9 Jan 2026 14:53:56 -0700 Subject: [PATCH 12/28] Docs: Dashboards and folders: Add annotation and label restriction (#116049) --- .../api-reference/http-api/dashboard.md | 8 ++++++++ .../developer-resources/api-reference/http-api/folder.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/docs/sources/developer-resources/api-reference/http-api/dashboard.md b/docs/sources/developer-resources/api-reference/http-api/dashboard.md index abef6a8f5e6..1b5d32bb9f4 100644 --- a/docs/sources/developer-resources/api-reference/http-api/dashboard.md +++ b/docs/sources/developer-resources/api-reference/http-api/dashboard.md @@ -231,6 +231,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the folder under which the dashboard should be created. - **spec** – The dashboard json. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http @@ -521,6 +525,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/message** - Optional field, to set a commit message for the version history. - **spec** – The dashboard json. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http diff --git a/docs/sources/developer-resources/api-reference/http-api/folder.md b/docs/sources/developer-resources/api-reference/http-api/folder.md index f042a3a5a3e..6a6b18abeba 100644 --- a/docs/sources/developer-resources/api-reference/http-api/folder.md +++ b/docs/sources/developer-resources/api-reference/http-api/folder.md @@ -259,6 +259,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the parent folder under which the folder should be created. Requires nested folders to be enabled. - **spec.title** – The title of the folder. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http @@ -337,6 +341,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the parent folder under which the folder should be - update this to move the folder under a different parent folder. Requires nested folders to be enabled. - **spec.title** – The title of the folder. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http From f581a5a69bd961b9040248c3d8da8ec103b6fc40 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 00:33:39 +0000 Subject: [PATCH 13/28] I18n: Download translations from Crowdin (#116094) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 5 ++++- public/locales/de-DE/grafana.json | 5 ++++- public/locales/es-ES/grafana.json | 5 ++++- public/locales/fr-FR/grafana.json | 5 ++++- public/locales/hu-HU/grafana.json | 5 ++++- public/locales/id-ID/grafana.json | 5 ++++- public/locales/it-IT/grafana.json | 5 ++++- public/locales/ja-JP/grafana.json | 5 ++++- public/locales/ko-KR/grafana.json | 5 ++++- public/locales/nl-NL/grafana.json | 5 ++++- public/locales/pl-PL/grafana.json | 5 ++++- public/locales/pt-BR/grafana.json | 5 ++++- public/locales/pt-PT/grafana.json | 5 ++++- public/locales/ru-RU/grafana.json | 5 ++++- public/locales/sv-SE/grafana.json | 5 ++++- public/locales/tr-TR/grafana.json | 5 ++++- public/locales/zh-Hans/grafana.json | 5 ++++- public/locales/zh-Hant/grafana.json | 5 ++++- 18 files changed, 72 insertions(+), 18 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index d2ad07201c4..52d56bb948c 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -4652,6 +4652,7 @@ }, "canvas-actions": { "add-panel": "Přidat panel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Seskupit do řádku", @@ -5735,6 +5736,7 @@ "validation": { "invalid-dashboard-id": "Nelze najít platné ID na Grafana.com", "invalid-json": "Neplatný JSON", + "tag-too-long": "", "tags-expected-array": "očekávané pole tagů", "tags-expected-strings": "očekávané pole řetězců tagů" }, @@ -9325,7 +9327,8 @@ "tags-input": { "add": "Přidat", "placeholder-new-tag": "Nový štítek (pro přidání stiskněte klávesu Enter)", - "remove": "Odebrat tag: {{name}}" + "remove": "Odebrat tag: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Synchronizovat časy", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 1472f7a9d26..616960f15e7 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Panel hinzufügen", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "In Zeile gruppieren", @@ -5695,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Konnte keine gültige Grafana.com-ID finden", "invalid-json": "JSON ungültig", + "tag-too-long": "", "tags-expected-array": "Array der jeweiligen Tags", "tags-expected-strings": "String-Array der jeweiligen Tags" }, @@ -9253,7 +9255,8 @@ "tags-input": { "add": "Hinzufügen", "placeholder-new-tag": "Neues Tag (Eingabetaste zum Hinzufügen)", - "remove": "Tag entfernen: {{name}}" + "remove": "Tag entfernen: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Zeiten synchronisieren", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 440085580a7..e4a00ce24fe 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Añadir panel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Agrupar en fila", @@ -5695,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "No se ha podido encontrar un ID de Grafana.com válido", "invalid-json": "JSON no válido", + "tag-too-long": "", "tags-expected-array": "etiquetas: matriz prevista", "tags-expected-strings": "etiquetas: matriz de cadenas prevista" }, @@ -9253,7 +9255,8 @@ "tags-input": { "add": "Añadir", "placeholder-new-tag": "Nueva etiqueta (pulsa la tecla Intro para añadirla)", - "remove": "Eliminar etiqueta: {{name}}" + "remove": "Eliminar etiqueta: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Sincronizar tiempos", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index b45e13289a0..7d1ad65f31a 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Ajouter un panneau", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Grouper en ligne", @@ -5695,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Impossible de trouver un ID Grafana.com valide", "invalid-json": "JSON non valide", + "tag-too-long": "", "tags-expected-array": "balises attendues tableau", "tags-expected-strings": "balises attendues tableau de chaînes" }, @@ -9253,7 +9255,8 @@ "tags-input": { "add": "Ajouter", "placeholder-new-tag": "Nouvelle étiquette (appuyez sur Entrée pour ajouter)", - "remove": "Supprimer la balise : {{name}}" + "remove": "Supprimer la balise : {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Synchroniser les horaires", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 3514fca802d..88e927039ac 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Panel hozzáadása", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Csoportosítás sorba", @@ -5695,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Nem található érvényes Grafana.com-azonosító", "invalid-json": "Érvénytelen JSON", + "tag-too-long": "", "tags-expected-array": "címke tömböt várt", "tags-expected-strings": "címke karakterláncok tömbjét várta" }, @@ -9253,7 +9255,8 @@ "tags-input": { "add": "Hozzáadás", "placeholder-new-tag": "Új címke (nyomja le az Enter billentyűt a hozzáadáshoz)", - "remove": "Címke eltávolítása: {{name}}" + "remove": "Címke eltávolítása: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Időpontok szinkronizálása", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 6e58a6f949b..df8ee95d6aa 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -4595,6 +4595,7 @@ }, "canvas-actions": { "add-panel": "Tambah panel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Kelompokkan ke dalam baris", @@ -5675,6 +5676,7 @@ "validation": { "invalid-dashboard-id": "Tidak dapat menemukan ID Grafana.com yang valid", "invalid-json": "JSON Tidak Valid", + "tag-too-long": "", "tags-expected-array": "tag array yang diharapkan", "tags-expected-strings": "tag array yang diharapkan dari string" }, @@ -9217,7 +9219,8 @@ "tags-input": { "add": "Tambahkan", "placeholder-new-tag": "Tag baru (masukkan kunci untuk menambahkan)", - "remove": "Hapus tag: {{name}}" + "remove": "Hapus tag: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Sinkronkan waktu", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index d64d1b54255..554727b9577 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Aggiungi pannello", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Raggruppa in riga", @@ -5695,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Impossibile trovare un ID Grafana.com valido", "invalid-json": "JSON non valido", + "tag-too-long": "", "tags-expected-array": "array con tag previsti", "tags-expected-strings": "array di stringhe con tag previsti" }, @@ -9253,7 +9255,8 @@ "tags-input": { "add": "Aggiungi", "placeholder-new-tag": "Nuovo tag (inserisci chiave da aggiungere)", - "remove": "Rimuovi tag: {{name}}" + "remove": "Rimuovi tag: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Sincronizza orari", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 47ecf90b90a..16cd9eb957c 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -4595,6 +4595,7 @@ }, "canvas-actions": { "add-panel": "パネルを追加", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "行にグループ化", @@ -5675,6 +5676,7 @@ "validation": { "invalid-dashboard-id": "有効なGrafana.com IDが見つかりませんでした", "invalid-json": "無効なJSON", + "tag-too-long": "", "tags-expected-array": "タグは配列を期待しています", "tags-expected-strings": "タグは文字列の配列を期待しています" }, @@ -9217,7 +9219,8 @@ "tags-input": { "add": "追加", "placeholder-new-tag": "新しいタグ (Enterキーで追加)", - "remove": "タグ、{{name}}を削除" + "remove": "タグ、{{name}}を削除", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "時間を同期", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index ca5373ab022..2ea09fb8471 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -4595,6 +4595,7 @@ }, "canvas-actions": { "add-panel": "패널 추가", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "행으로 그룹화", @@ -5675,6 +5676,7 @@ "validation": { "invalid-dashboard-id": "유효한 Grafana.com ID를 찾지 못했습니다.", "invalid-json": "유효하지 않은 JSON", + "tag-too-long": "", "tags-expected-array": "태그로 배열을 입력해야 합니다.", "tags-expected-strings": "태그로 문자열 배열을 입력해야 합니다." }, @@ -9217,7 +9219,8 @@ "tags-input": { "add": "추가", "placeholder-new-tag": "새 태그(엔터 키를 눌러 추가하세요)", - "remove": "태그 제거: {{name}}" + "remove": "태그 제거: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "동기화 시간", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index 8fe54d956c6..7cf226932bc 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Paneel toevoegen", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Groeperen in rij", @@ -5695,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Kon geen geldige Grafana.com-ID vinden", "invalid-json": "Ongeldige JSON", + "tag-too-long": "", "tags-expected-array": "tags expected array", "tags-expected-strings": "tags expected array van strings" }, @@ -9253,7 +9255,8 @@ "tags-input": { "add": "Toevoegen", "placeholder-new-tag": "Nieuw label (enter-toets om toe te voegen)", - "remove": "Label verwijderen: {{name}}" + "remove": "Label verwijderen: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Tijden synchroniseren", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 6db3c217f3d..4cf8c601bfc 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -4652,6 +4652,7 @@ }, "canvas-actions": { "add-panel": "Dodaj panel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Grupuj w wierszu", @@ -5735,6 +5736,7 @@ "validation": { "invalid-dashboard-id": "Nie można znaleźć prawidłowego identyfikatora Grafana.com", "invalid-json": "Nieprawidłowy plik JSON", + "tag-too-long": "", "tags-expected-array": "znaczniki oczekiwały tablicy", "tags-expected-strings": "znaczniki oczekiwały tablicy łańcuchów" }, @@ -9325,7 +9327,8 @@ "tags-input": { "add": "Dodaj", "placeholder-new-tag": "Nowy tag (wprowadź klucz, aby dodać)", - "remove": "Usuń znacznik: {{name}}" + "remove": "Usuń znacznik: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Synchronizuj czas", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index c8516e379e3..aeb840530ca 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Adicionar painel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Agrupar em linha", @@ -5695,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Não foi possível encontrar um ID do Grafana.com válido", "invalid-json": "JSON inválido", + "tag-too-long": "", "tags-expected-array": "matriz de tags esperada", "tags-expected-strings": "matriz de strings de tags esperada" }, @@ -9253,7 +9255,8 @@ "tags-input": { "add": "Adicionar", "placeholder-new-tag": "Nova tag (pressione Enter para adicionar)", - "remove": "Remover etiqueta: {{name}}" + "remove": "Remover etiqueta: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Horários sincronizados", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 1a701840291..2a4c53052d8 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Adicionar painel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Agrupar em linha", @@ -5695,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Não foi possível encontrar uma ID válida de Grafana.com", "invalid-json": "JSON inválido", + "tag-too-long": "", "tags-expected-array": "matriz de etiquetas esperada", "tags-expected-strings": "matriz de strings de etiquetas esperada" }, @@ -9253,7 +9255,8 @@ "tags-input": { "add": "Adicionar", "placeholder-new-tag": "Nova etiqueta (introduzir chave para adicionar)", - "remove": "Remover etiqueta: {{name}}" + "remove": "Remover etiqueta: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Horas sincronizadas", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 60269a954eb..571918fa148 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -4652,6 +4652,7 @@ }, "canvas-actions": { "add-panel": "Добавить панель", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Группировать в строку", @@ -5735,6 +5736,7 @@ "validation": { "invalid-dashboard-id": "Не удалось найти действительный идентификатор Grafana.com", "invalid-json": "Неверный JSON-файл", + "tag-too-long": "", "tags-expected-array": "ожидаемый массив тегов", "tags-expected-strings": "ожидаемый массив строк тегов" }, @@ -9325,7 +9327,8 @@ "tags-input": { "add": "Добавить", "placeholder-new-tag": "Новый тег (введите ключ для добавления)", - "remove": "Удалить тег: {{name}} " + "remove": "Удалить тег: {{name}} ", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Синхронизировать время", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index c2ea21f798f..27b318f57e3 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Lägg till panel", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Gruppera i rad", @@ -5695,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Kunde inte hitta ett giltigt Grafana.com-ID\n", "invalid-json": "Felaktigt JSON-format", + "tag-too-long": "", "tags-expected-array": "taggar förväntade matris", "tags-expected-strings": "taggar förväntade strängmatris" }, @@ -9253,7 +9255,8 @@ "tags-input": { "add": "Lägg till", "placeholder-new-tag": "Ny tagg (ange nyckel för att lägga till)", - "remove": "Ta bort tagg: {{name}}" + "remove": "Ta bort tagg: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Synkronisera tider", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index d1eb1d9368a..59d394e89a2 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -4614,6 +4614,7 @@ }, "canvas-actions": { "add-panel": "Panel ekle", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "Satır olarak grupla", @@ -5695,6 +5696,7 @@ "validation": { "invalid-dashboard-id": "Geçerli bir Grafana.com kimliği bulunamadı", "invalid-json": "Geçerli JSON değil", + "tag-too-long": "", "tags-expected-array": "etiketler dizi olmalı", "tags-expected-strings": "etiklet dizi hâlinde metinlerden oluşmalı" }, @@ -9253,7 +9255,8 @@ "tags-input": { "add": "Ekle", "placeholder-new-tag": "Yeni etiket (eklemek için anahtarı girin)", - "remove": "Etiketi kaldır: {{name}}" + "remove": "Etiketi kaldır: {{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "Zamanları senkronize et", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 24739ca7ffe..2a3c4b999d7 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -4595,6 +4595,7 @@ }, "canvas-actions": { "add-panel": "添加面板", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "分组成行", @@ -5675,6 +5676,7 @@ "validation": { "invalid-dashboard-id": "找不到有效的 Grafana.com ID", "invalid-json": "无有效的 JSON", + "tag-too-long": "", "tags-expected-array": "标签预期数组", "tags-expected-strings": "标签预期字符串数组" }, @@ -9217,7 +9219,8 @@ "tags-input": { "add": "添加", "placeholder-new-tag": "新标签(按 Enter 键以添加)", - "remove": "移除标记:{{name}}" + "remove": "移除标记:{{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "同步次数", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 77ce4df8a24..246ad78138e 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -4595,6 +4595,7 @@ }, "canvas-actions": { "add-panel": "新增面板", + "disabled-child-contains-tabs": "", "disabled-nested-grouping": "", "disabled-nested-tabs": "", "group-into-row": "將群組分成列", @@ -5675,6 +5676,7 @@ "validation": { "invalid-dashboard-id": "沒有找到有效的 Grafana.com ID", "invalid-json": "無效的 JSON", + "tag-too-long": "", "tags-expected-array": "標籤預期陣列", "tags-expected-strings": "標籤預期字串陣列" }, @@ -9217,7 +9219,8 @@ "tags-input": { "add": "新增", "placeholder-new-tag": "新標記(輸入金鑰以新增)", - "remove": "移除標籤:{{name}}" + "remove": "移除標籤:{{name}}", + "tag-too-long": "" }, "time-sync-button": { "aria-label-sync": "同步次數", From 2a6a48ac39c1abb7bd61d62fd3f3c38b2937d278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 12 Jan 2026 05:57:09 +0100 Subject: [PATCH 14/28] chore: reduce Explore barrel files (#116051) chore: Explore barrel files --- eslint-suppressions.json | 5 ----- public/app/features/explore/Logs/Logs.tsx | 2 +- public/app/features/explore/Logs/LogsTableActionButtons.tsx | 2 +- public/app/features/explore/hooks/useStateSync/index.ts | 2 -- .../explore/hooks/useStateSync/synchronizer/fromURL.ts | 2 +- .../explore/hooks/useStateSync/synchronizer/init.ts | 2 +- .../explore/hooks/useStateSync/synchronizer/toURL.ts | 2 +- public/app/features/explore/utils/links.ts | 2 +- public/app/features/logs/components/logParser.test.ts | 6 ++++++ public/app/features/logs/components/panel/links.test.ts | 3 +++ 10 files changed, 15 insertions(+), 13 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 597f2b94a1a..f757ffb6df6 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2610,11 +2610,6 @@ "count": 2 } }, - "public/app/features/explore/hooks/useStateSync/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 1 - } - }, "public/app/features/explore/spec/helper/setup.tsx": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 1ff012f3fee..46930f67552 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -73,7 +73,7 @@ import { contentOutlineTrackUnpinClicked, } from '../ContentOutline/ContentOutlineAnalyticEvents'; import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext'; -import { getUrlStateFromPaneState } from '../hooks/useStateSync'; +import { getUrlStateFromPaneState } from '../hooks/useStateSync/external.utils'; import { changePanelState } from '../state/explorePane'; import { changeQueries, runQueries } from '../state/query'; diff --git a/public/app/features/explore/Logs/LogsTableActionButtons.tsx b/public/app/features/explore/Logs/LogsTableActionButtons.tsx index f60d5eaa072..e9f3acde38d 100644 --- a/public/app/features/explore/Logs/LogsTableActionButtons.tsx +++ b/public/app/features/explore/Logs/LogsTableActionButtons.tsx @@ -12,7 +12,7 @@ import { import { t } from '@grafana/i18n'; import { ClipboardButton, CustomCellRendererProps, IconButton, Modal, useTheme2 } from '@grafana/ui'; import { getLogsPermalinkRange } from 'app/core/utils/shortLinks'; -import { getUrlStateFromPaneState } from 'app/features/explore/hooks/useStateSync'; +import { getUrlStateFromPaneState } from 'app/features/explore/hooks/useStateSync/external.utils'; import { LogsFrame, DATAPLANE_ID_NAME } from 'app/features/logs/logsFrame'; import { getState } from 'app/store/store'; diff --git a/public/app/features/explore/hooks/useStateSync/index.ts b/public/app/features/explore/hooks/useStateSync/index.ts index 44462fa0d35..264b19e3d56 100644 --- a/public/app/features/explore/hooks/useStateSync/index.ts +++ b/public/app/features/explore/hooks/useStateSync/index.ts @@ -12,8 +12,6 @@ import { syncFromURL } from './synchronizer/fromURL'; import { initializeFromURL } from './synchronizer/init'; import { syncToURL, syncToURLPredicate } from './synchronizer/toURL'; -export { getUrlStateFromPaneState } from './external.utils'; - /** * Bi-directionally syncs URL changes with Explore's state. */ diff --git a/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts b/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts index a7856edc9af..5cc17e4a172 100644 --- a/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts +++ b/public/app/features/explore/hooks/useStateSync/synchronizer/fromURL.ts @@ -11,7 +11,7 @@ import { withUniqueRefIds } from 'app/features/explore/utils/queries'; import { ExploreItemState } from 'app/types/explore'; import { ThunkDispatch } from 'app/types/store'; -import { getUrlStateFromPaneState } from '../index'; +import { getUrlStateFromPaneState } from '../external.utils'; import { urlDiff } from '../internal.utils'; import { ExploreURLV1 } from '../migrators/v1'; diff --git a/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts b/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts index 201355d32b3..097e5549f01 100644 --- a/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts +++ b/public/app/features/explore/hooks/useStateSync/synchronizer/init.ts @@ -11,7 +11,7 @@ import { withUniqueRefIds } from 'app/features/explore/utils/queries'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { ThunkDispatch } from 'app/types/store'; -import { getUrlStateFromPaneState } from '../index'; +import { getUrlStateFromPaneState } from '../external.utils'; import { getDefaultQuery, getPaneDatasource, diff --git a/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts b/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts index c746d767048..d665592d80d 100644 --- a/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts +++ b/public/app/features/explore/hooks/useStateSync/synchronizer/toURL.ts @@ -11,7 +11,7 @@ import { runQueries } from 'app/features/explore/state/query'; import { changeRangeAction } from 'app/features/explore/state/time'; import { ExploreState } from 'app/types/explore'; -import { getUrlStateFromPaneState } from '../index'; +import { getUrlStateFromPaneState } from '../external.utils'; import { InitState } from '../internal.utils'; /* diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index 10b2acb3d54..c4644e1a709 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -30,7 +30,7 @@ import { parseDataplaneLogsFrame } from 'app/features/logs/logsFrame'; import { ExploreItemState } from 'app/types/explore'; import { getLinkSrv } from '../../panel/panellinks/link_srv'; -import { getUrlStateFromPaneState } from '../hooks/useStateSync'; +import { getUrlStateFromPaneState } from '../hooks/useStateSync/external.utils'; type DataLinkFilter = (link: DataLink, scopedVars: ScopedVars) => boolean; diff --git a/public/app/features/logs/components/logParser.test.ts b/public/app/features/logs/components/logParser.test.ts index a132f75b642..f03d628877d 100644 --- a/public/app/features/logs/components/logParser.test.ts +++ b/public/app/features/logs/components/logParser.test.ts @@ -1,6 +1,8 @@ import { DataFrameType, Field, FieldType, LogRowModel, MutableDataFrame } from '@grafana/data'; import { mockTimeRange } from '@grafana/plugin-ui'; +import { setTemplateSrv } from '@grafana/runtime'; import { ExploreFieldLinkModel, getFieldLinksForExplore } from 'app/features/explore/utils/links'; +import { TemplateSrv } from 'app/features/templating/template_srv'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { getAllFields, createLogLineLinks, FieldDef, getDataframeFields } from './logParser'; @@ -466,6 +468,10 @@ describe('logParser', () => { }); describe('getDataframeFields', () => { + beforeEach(() => { + setTemplateSrv(new TemplateSrv()); + }); + it('should add row labels as variables for links', () => { const row = createLogRow({ labels: { service_name: 'checkout', service_namespace: 'prod' }, diff --git a/public/app/features/logs/components/panel/links.test.ts b/public/app/features/logs/components/panel/links.test.ts index bb3da98fc43..545373fc78a 100644 --- a/public/app/features/logs/components/panel/links.test.ts +++ b/public/app/features/logs/components/panel/links.test.ts @@ -1,6 +1,8 @@ import { FieldType, getDefaultTimeRange, LogsSortOrder, toDataFrame } from '@grafana/data'; +import { setTemplateSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { getFieldLinksForExplore } from 'app/features/explore/utils/links'; +import { TemplateSrv } from 'app/features/templating/template_srv'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { createLogLine } from '../mocks/logRow'; @@ -70,6 +72,7 @@ describe('getTempoTraceFromLinks', () => { wrapLogMessage: true, } ); + setTemplateSrv(new TemplateSrv()); }); test('Gets the trace information from a link', () => { From 81b868ae9121796300aff91ed35a30d3139f1be8 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Mon, 12 Jan 2026 09:00:51 +0100 Subject: [PATCH 15/28] `grafana-iam`: Split AuthZ apis feature toggle per apis (#116010) * WIP: switched to feature toggles * Add timeout --- .../src/types/featureToggles.gen.ts | 15 +++++- pkg/registry/apis/iam/register.go | 30 ++++++++++-- pkg/services/featuremgmt/registry.go | 25 +++++++++- pkg/services/featuremgmt/toggles_gen.csv | 5 +- pkg/services/featuremgmt/toggles_gen.go | 14 +++++- pkg/services/featuremgmt/toggles_gen.json | 48 +++++++++++++++++-- 6 files changed, 124 insertions(+), 13 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 6e35e460055..5b24184ff6c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -957,7 +957,8 @@ export interface FeatureToggles { */ alertingBulkActionsInUI?: boolean; /** - * Registers AuthZ /apis endpoint + * Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead + * @deprecated */ kubernetesAuthzApis?: boolean; /** @@ -973,6 +974,18 @@ export interface FeatureToggles { */ kubernetesAuthzZanzanaSync?: boolean; /** + * Registers AuthZ Core Roles /apis endpoint + */ + kubernetesAuthzCoreRolesApi?: boolean; + /** + * Registers AuthZ Roles /apis endpoint + */ + kubernetesAuthzRolesApi?: boolean; + /** + * Registers AuthZ Role Bindings /apis endpoint + */ + kubernetesAuthzRoleBindingsApi?: boolean; + /** * Enables create, delete, and update mutations for resources owned by IAM identity */ kubernetesAuthnMutation?: boolean; diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 7c9d8c558c4..7f42d620987 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -5,7 +5,9 @@ import ( "fmt" "maps" "strings" + "time" + "github.com/open-feature/go-sdk/openfeature" "github.com/prometheus/client_golang/prometheus" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -209,8 +211,16 @@ func (b *IdentityAccessManagementAPIBuilder) GetGroupVersion() schema.GroupVersi } func (b *IdentityAccessManagementAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { - //nolint:staticcheck // not yet migrated to OpenFeature - if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) { + client := openfeature.NewDefaultClient() + ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*5) + defer cancelFn() + + // Check if any of the AuthZ APIs are enabled + enableCoreRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzCoreRolesApi, false, openfeature.TransactionContext(ctx)) + enableRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRolesApi, false, openfeature.TransactionContext(ctx)) + enableRoleBindingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRoleBindingsApi, false, openfeature.TransactionContext(ctx)) + + if enableCoreRolesApi || enableRolesApi || enableRoleBindingsApi { if err := iamv0.AddAuthZKnownTypes(scheme); err != nil { return err } @@ -244,10 +254,16 @@ func (b *IdentityAccessManagementAPIBuilder) AllowedV0Alpha1Resources() []string func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { storage := map[string]rest.Storage{} + client := openfeature.NewDefaultClient() + ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*5) + defer cancelFn() + //nolint:staticcheck // not yet migrated to OpenFeature enableZanzanaSync := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzZanzanaSync) - //nolint:staticcheck // not yet migrated to OpenFeature - enableAuthzApis := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) + + enableCoreRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzCoreRolesApi, false, openfeature.TransactionContext(ctx)) + enableRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRolesApi, false, openfeature.TransactionContext(ctx)) + enableRoleBindingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRoleBindingsApi, false, openfeature.TransactionContext(ctx)) // teams + users must have shorter names because they are often used as part of another name opts.StorageOptsRegister(iamv0.TeamResourceInfo.GroupResource(), apistore.StorageOptions{ @@ -283,17 +299,21 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge return err } - if enableAuthzApis { + if enableCoreRolesApi { // v0alpha1 if err := b.UpdateCoreRolesAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { return err } + } + if enableRolesApi { // Role registration is delegated to the RoleApiInstaller if err := b.roleApiInstaller.RegisterStorage(apiGroupInfo, &opts, storage); err != nil { return err } + } + if enableRoleBindingsApi { if err := b.UpdateRoleBindingsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { return err } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 885079a5f5a..b38d3d3d553 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1585,8 +1585,8 @@ var ( }, { Name: "kubernetesAuthzApis", - Description: "Registers AuthZ /apis endpoint", - Stage: FeatureStageExperimental, + Description: "Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead", + Stage: FeatureStageDeprecated, Owner: identityAccessTeam, HideFromDocs: true, }, @@ -1611,6 +1611,27 @@ var ( Owner: identityAccessTeam, HideFromDocs: true, }, + { + Name: "kubernetesAuthzCoreRolesApi", + Description: "Registers AuthZ Core Roles /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + }, + { + Name: "kubernetesAuthzRolesApi", + Description: "Registers AuthZ Roles /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + }, + { + Name: "kubernetesAuthzRoleBindingsApi", + Description: "Registers AuthZ Role Bindings /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + }, { Name: "kubernetesAuthnMutation", Description: "Enables create, delete, and update mutations for resources owned by IAM identity", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8ddc448ef52..c7626aee036 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -217,10 +217,13 @@ pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,fal alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true 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 +kubernetesAuthzApis,deprecated,@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 kubernetesAuthzZanzanaSync,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzCoreRolesApi,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzRolesApi,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzRoleBindingsApi,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,false kubernetesExternalGroupMapping,experimental,@grafana/identity-access-team,false,false,false restoreDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index c42229d8870..49f0366f429 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -631,7 +631,7 @@ const ( FlagAlertRuleUseFiredAtForStartsAt = "alertRuleUseFiredAtForStartsAt" // FlagKubernetesAuthzApis - // Registers AuthZ /apis endpoint + // Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead FlagKubernetesAuthzApis = "kubernetesAuthzApis" // FlagKubernetesAuthZHandlerRedirect @@ -646,6 +646,18 @@ const ( // Enable sync of Zanzana authorization store on AuthZ CRD mutations FlagKubernetesAuthzZanzanaSync = "kubernetesAuthzZanzanaSync" + // FlagKubernetesAuthzCoreRolesApi + // Registers AuthZ Core Roles /apis endpoint + FlagKubernetesAuthzCoreRolesApi = "kubernetesAuthzCoreRolesApi" + + // FlagKubernetesAuthzRolesApi + // Registers AuthZ Roles /apis endpoint + FlagKubernetesAuthzRolesApi = "kubernetesAuthzRolesApi" + + // FlagKubernetesAuthzRoleBindingsApi + // Registers AuthZ Role Bindings /apis endpoint + FlagKubernetesAuthzRoleBindingsApi = "kubernetesAuthzRoleBindingsApi" + // FlagKubernetesAuthnMutation // Enables create, delete, and update mutations for resources owned by IAM identity FlagKubernetesAuthnMutation = "kubernetesAuthnMutation" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 09c4d0c9760..124a4977c07 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1951,11 +1951,27 @@ { "metadata": { "name": "kubernetesAuthzApis", - "resourceVersion": "1764664939750", - "creationTimestamp": "2025-06-18T07:43:01Z" + "resourceVersion": "1767954559317", + "creationTimestamp": "2025-06-18T07:43:01Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-09 10:29:19.317164 +0000 UTC" + } }, "spec": { - "description": "Registers AuthZ /apis endpoint", + "description": "Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead", + "stage": "deprecated", + "codeowner": "@grafana/identity-access-team", + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "kubernetesAuthzCoreRolesApi", + "resourceVersion": "1767954459090", + "creationTimestamp": "2026-01-09T10:27:39Z" + }, + "spec": { + "description": "Registers AuthZ Core Roles /apis endpoint", "stage": "experimental", "codeowner": "@grafana/identity-access-team", "hideFromDocs": true @@ -1975,6 +1991,32 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "kubernetesAuthzRoleBindingsApi", + "resourceVersion": "1767954459090", + "creationTimestamp": "2026-01-09T10:27:39Z" + }, + "spec": { + "description": "Registers AuthZ Role Bindings /apis endpoint", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "kubernetesAuthzRolesApi", + "resourceVersion": "1767954459090", + "creationTimestamp": "2026-01-09T10:27:39Z" + }, + "spec": { + "description": "Registers AuthZ Roles /apis endpoint", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromDocs": true + } + }, { "metadata": { "name": "kubernetesAuthzZanzanaSync", From 0b4612330082cb4ee93cd9382149ec69c91c170f Mon Sep 17 00:00:00 2001 From: Gareth Date: Mon, 12 Jan 2026 17:21:48 +0900 Subject: [PATCH 16/28] Tempo: remove backend migration feature toggle (#116054) * remove unused frontend code * remove feature toggle definition * fix tests --- .../feature-toggles/index.md | 1 - eslint-suppressions.json | 2 +- .../src/types/featureToggles.gen.ts | 5 -- pkg/services/featuremgmt/registry.go | 8 --- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 -- .../datasource/tempo/datasource.test.ts | 5 -- .../plugins/datasource/tempo/datasource.ts | 67 +------------------ 8 files changed, 3 insertions(+), 90 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 16c97f6d10a..b7f55555e07 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -66,7 +66,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes | | `tabularNumbers` | Use fixed-width numbers globally in the UI | | | `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | Yes | -| `tempoSearchBackendMigration` | Run search queries through the tempo backend | | | `opentsdbBackendMigration` | Run queries through the data source backend | | ## Public preview feature toggles diff --git a/eslint-suppressions.json b/eslint-suppressions.json index f757ffb6df6..6d5cca4f36c 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -4098,7 +4098,7 @@ "count": 1 }, "@typescript-eslint/no-explicit-any": { - "count": 2 + "count": 1 } }, "public/app/plugins/datasource/tempo/resultTransformer.ts": { diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5b24184ff6c..3982bdc33e5 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1137,11 +1137,6 @@ export interface FeatureToggles { */ pluginContainers?: boolean; /** - * Run search queries through the tempo backend - * @default false - */ - tempoSearchBackendMigration?: boolean; - /** * Prioritize loading plugins from the CDN before other sources * @default false */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b38d3d3d553..ed591908042 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1880,14 +1880,6 @@ var ( Expression: "false", RequiresRestart: true, }, - { - Name: "tempoSearchBackendMigration", - Description: "Run search queries through the tempo backend", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaOSSBigTent, - Expression: "false", - RequiresRestart: true, - }, { Name: "cdnPluginsLoadFirst", Description: "Prioritize loading plugins from the CDN before other sources", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c7626aee036..15376f39fc1 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -256,7 +256,6 @@ graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,fals azureResourcePickerUpdates,GA,@grafana/partner-datasources,false,false,true prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false -tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,false cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 49f0366f429..e778a6fc3d1 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -742,10 +742,6 @@ const ( // Enables running plugins in containers FlagPluginContainers = "pluginContainers" - // FlagTempoSearchBackendMigration - // Run search queries through the tempo backend - FlagTempoSearchBackendMigration = "tempoSearchBackendMigration" - // FlagCdnPluginsLoadFirst // Prioritize loading plugins from the CDN before other sources FlagCdnPluginsLoadFirst = "cdnPluginsLoadFirst" diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts index 6e51e5511b1..9a0abe1cc27 100644 --- a/public/app/plugins/datasource/tempo/datasource.test.ts +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -61,7 +61,6 @@ describe('Tempo data source', () => { describe('runs correctly', () => { const handleStreamingQuery = jest.spyOn(TempoDatasource.prototype, 'handleStreamingQuery'); - const request = jest.spyOn(TempoDatasource.prototype, '_request'); const templateSrv: TemplateSrv = { replace: (s: string) => s } as unknown as TemplateSrv; const range = { @@ -97,7 +96,6 @@ describe('Tempo data source', () => { const ds = new TempoDatasource(defaultSettings, templateSrv); await lastValueFrom(ds.query(traceqlQuery as DataQueryRequest)); expect(handleStreamingQuery).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledTimes(0); }); it('for traceqlSearch queries when live is enabled', async () => { @@ -105,7 +103,6 @@ describe('Tempo data source', () => { const ds = new TempoDatasource(defaultSettings, templateSrv); await lastValueFrom(ds.query(traceqlSearchQuery as DataQueryRequest)); expect(handleStreamingQuery).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledTimes(0); }); it('for traceql queries when live is not enabled', async () => { @@ -113,7 +110,6 @@ describe('Tempo data source', () => { const ds = new TempoDatasource(defaultSettings, templateSrv); await lastValueFrom(ds.query(traceqlQuery as DataQueryRequest)); expect(handleStreamingQuery).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledTimes(1); }); it('for traceqlSearch queries when live is not enabled', async () => { @@ -121,7 +117,6 @@ describe('Tempo data source', () => { const ds = new TempoDatasource(defaultSettings, templateSrv); await lastValueFrom(ds.query(traceqlSearchQuery as DataQueryRequest)); expect(handleStreamingQuery).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledTimes(1); }); }); diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 4c926300cb3..b15c8d6146a 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -23,14 +23,11 @@ import { SelectableValue, TestDataSourceResponse, TimeRange, - urlUtil, } from '@grafana/data'; import { NodeGraphOptions, SpanBarOptions, TraceToLogsOptions } from '@grafana/o11y-ds-frontend'; import { - BackendSrvRequest, config, DataSourceWithBackend, - getBackendSrv, getDataSourceSrv, getTemplateSrv, reportInteraction, @@ -59,7 +56,6 @@ import { import TempoLanguageProvider from './language_provider'; import { enhanceTraceQlMetricsResponse, - formatTraceQLResponse, transformFromOTLP as transformFromOTEL, transformTrace, } from './resultTransformer'; @@ -419,12 +415,7 @@ export class TempoDatasource extends DataSourceWithBackend, - targets: { [type: string]: TempoQuery[] }, - queryValue: string - ) => { - const startTime = performance.now(); - const tableType = targets.traceqlSearch?.[0]?.tableType ?? targets.traceql?.[0]?.tableType; - - return this._request('/api/search', { - q: queryValue, - limit: options.targets[0].limit ?? DEFAULT_LIMIT, - spss: options.targets[0].spss ?? DEFAULT_SPSS, - start: options.range.from.unix(), - end: options.range.to.unix(), - }).pipe( - map((response) => { - reportTempoQueryMetrics('grafana_traces_traceql_response', options, { - success: true, - streaming: false, - latencyMs: Math.round(performance.now() - startTime), // rounded to nearest millisecond - query: queryValue ?? '', - }); - return { - data: formatTraceQLResponse(response.data.traces, this.instanceSettings, tableType), - }; - }), - catchError((err) => { - reportTempoQueryMetrics('grafana_traces_traceql_response', options, { - success: false, - streaming: false, - latencyMs: Math.round(performance.now() - startTime), // rounded to nearest millisecond - query: queryValue ?? '', - error: getErrorMessage(err.message), - statusCode: err.status, - statusText: err.statusText, - }); - return of({ error: { message: getErrorMessage(err?.data?.message) }, data: [] }); - }) - ); - }; - handleTraceQlMetricsQuery( options: DataQueryRequest, targets: TempoQuery[], @@ -926,13 +870,6 @@ export class TempoDatasource extends DataSourceWithBackend): Observable> { - const params = data ? urlUtil.serializeParams(data) : ''; - const url = `${this.instanceSettings.url}${apiUrl}${params.length ? `?${params}` : ''}`; - const req = { ...options, url }; - return getBackendSrv().fetch(req); - } - async testDatasource(): Promise { return await super.testDatasource(); } From f0c95a0a10b7ee9e298d6c6443ee9f72e85ccf25 Mon Sep 17 00:00:00 2001 From: Gonzalo Trigueros Manzanas <242162051+gttrigger@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:07:04 +0100 Subject: [PATCH 17/28] Provisioning: Add new error framework to handle folder creation failures gracefully. (#114824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement hierarchical error handling for folder creation failures This commit implements hierarchical error handling to improve sync robustness when folder creation fails. Instead of failing the entire sync, the system now: 1. Tracks failed folder creations and automatically skips nested resources 2. Records skipped resources with FileActionIgnored (doesn't count toward error limits) 3. Allows other folder hierarchies to continue processing 4. Prevents folder deletion when child resource deletions fail Key Changes: - Add PathCreationError type to track which folder path failed - Modify progress recorder to automatically detect and track failures via Record() - Add IsNestedUnderFailedCreation() and HasFailedDeletionsUnder() checks - Update full and incremental sync to skip nested resources after folder failures - Deletions proceed even if parent folder creation failed (resource may exist from previous sync) - FileActionIgnored results don't count toward error limits Example behavior improvement: Before: /monitoring folder creation fails → all nested resources fail → other folders never processed After: /monitoring folder creation fails → nested resources ignored → /applications folder succeeds 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * provisioning: refactor hierarchical errors in folder management. * Move test to the corresponding package * Refactor timeout handling in applyChanges functions - Introduced wrapWithTimeout function to streamline timeout context management for applyChange calls. - Updated applyFoldersSerially and applyIncrementalChanges to utilize the new timeout wrapper. - Removed redundant logging and error handling code related to timeout in favor of centralized handling in wrapWithTimeout. - Adjusted test expectations to reflect changes in error reporting for context deadlines. --------- Co-authored-by: Roberto Jimenez Sanchez Co-authored-by: Claude Sonnet 4.5 --- .../jobs/job_progress_recorder_mock.go | 92 +++ .../apis/provisioning/jobs/progress.go | 55 +- .../apis/provisioning/jobs/progress_test.go | 219 ++++++ pkg/registry/apis/provisioning/jobs/queue.go | 4 + .../apis/provisioning/jobs/sync/full.go | 82 ++- .../jobs/sync/full_hierarchical_test.go | 432 ++++++++++++ .../apis/provisioning/jobs/sync/full_test.go | 21 +- .../provisioning/jobs/sync/incremental.go | 33 +- .../sync/incremental_hierarchical_test.go | 623 ++++++++++++++++++ .../jobs/sync/incremental_test.go | 54 +- .../apis/provisioning/resources/folders.go | 21 +- .../provisioning/resources/folders_test.go | 68 ++ 12 files changed, 1651 insertions(+), 53 deletions(-) create mode 100644 pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go create mode 100644 pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go create mode 100644 pkg/registry/apis/provisioning/resources/folders_test.go diff --git a/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go b/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go index 45d8572e94a..efb2bd52697 100644 --- a/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go +++ b/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go @@ -71,6 +71,98 @@ func (_c *MockJobProgressRecorder_Complete_Call) RunAndReturn(run func(context.C return _c } +// HasDirPathFailedDeletion provides a mock function with given fields: folderPath +func (_m *MockJobProgressRecorder) HasDirPathFailedDeletion(folderPath string) bool { + ret := _m.Called(folderPath) + + if len(ret) == 0 { + panic("no return value specified for HasDirPathFailedDeletion") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(folderPath) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockJobProgressRecorder_HasDirPathFailedDeletion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasDirPathFailedDeletion' +type MockJobProgressRecorder_HasDirPathFailedDeletion_Call struct { + *mock.Call +} + +// HasDirPathFailedDeletion is a helper method to define mock.On call +// - folderPath string +func (_e *MockJobProgressRecorder_Expecter) HasDirPathFailedDeletion(folderPath interface{}) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + return &MockJobProgressRecorder_HasDirPathFailedDeletion_Call{Call: _e.mock.On("HasDirPathFailedDeletion", folderPath)} +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) Run(run func(folderPath string)) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) Return(_a0 bool) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) RunAndReturn(run func(string) bool) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + _c.Call.Return(run) + return _c +} + +// HasDirPathFailedCreation provides a mock function with given fields: path +func (_m *MockJobProgressRecorder) HasDirPathFailedCreation(path string) bool { + ret := _m.Called(path) + + if len(ret) == 0 { + panic("no return value specified for HasDirPathFailedCreation") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(path) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockJobProgressRecorder_HasDirPathFailedCreation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasDirPathFailedCreation' +type MockJobProgressRecorder_HasDirPathFailedCreation_Call struct { + *mock.Call +} + +// HasDirPathFailedCreation is a helper method to define mock.On call +// - path string +func (_e *MockJobProgressRecorder_Expecter) HasDirPathFailedCreation(path interface{}) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + return &MockJobProgressRecorder_HasDirPathFailedCreation_Call{Call: _e.mock.On("HasDirPathFailedCreation", path)} +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) Run(run func(path string)) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) Return(_a0 bool) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) RunAndReturn(run func(string) bool) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + _c.Call.Return(run) + return _c +} + // Record provides a mock function with given fields: ctx, result func (_m *MockJobProgressRecorder) Record(ctx context.Context, result JobResourceResult) { _m.Called(ctx, result) diff --git a/pkg/registry/apis/provisioning/jobs/progress.go b/pkg/registry/apis/provisioning/jobs/progress.go index 2cb9dc9ddcf..3ba61eb6278 100644 --- a/pkg/registry/apis/provisioning/jobs/progress.go +++ b/pkg/registry/apis/provisioning/jobs/progress.go @@ -2,6 +2,7 @@ package jobs import ( "context" + "errors" "fmt" "sync" "time" @@ -9,6 +10,8 @@ import ( "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/apps/provisioning/pkg/safepath" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" ) // maybeNotifyProgress will only notify if a certain amount of time has passed @@ -58,6 +61,8 @@ type jobProgressRecorder struct { notifyImmediatelyFn ProgressFn maybeNotifyFn ProgressFn summaries map[string]*provisioning.JobResourceSummary + failedCreations []string // Tracks folder paths that failed to be created + failedDeletions []string // Tracks resource paths that failed to be deleted } func newJobProgressRecorder(ProgressFn ProgressFn) JobProgressRecorder { @@ -84,10 +89,26 @@ func (r *jobProgressRecorder) Record(ctx context.Context, result JobResourceResu if result.Error != nil { shouldLogError = true logErr = result.Error - if len(r.errors) < 20 { - r.errors = append(r.errors, result.Error.Error()) + + // Don't count ignored actions as errors in error count or error list + if result.Action != repository.FileActionIgnored { + if len(r.errors) < 20 { + r.errors = append(r.errors, result.Error.Error()) + } + r.errorCount++ + } + + // Automatically track failed operations based on error type and action + // Check if this is a PathCreationError (folder creation failure) + var pathErr *resources.PathCreationError + if errors.As(result.Error, &pathErr) { + r.failedCreations = append(r.failedCreations, pathErr.Path) + } + + // Track failed deletions, any deletion will stop the deletion of the parent folder (as it won't be empty) + if result.Action == repository.FileActionDeleted { + r.failedDeletions = append(r.failedDeletions, result.Path) } - r.errorCount++ } r.updateSummary(result) @@ -112,6 +133,8 @@ func (r *jobProgressRecorder) ResetResults() { r.errorCount = 0 r.errors = nil r.summaries = make(map[string]*provisioning.JobResourceSummary) + r.failedCreations = nil + r.failedDeletions = nil } func (r *jobProgressRecorder) SetMessage(ctx context.Context, msg string) { @@ -309,3 +332,29 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision return jobStatus } + +// HasDirPathFailedCreation checks if a path is nested under any failed folder creation +func (r *jobProgressRecorder) HasDirPathFailedCreation(path string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, failedCreation := range r.failedCreations { + if safepath.InDir(path, failedCreation) { + return true + } + } + return false +} + +// HasDirPathFailedDeletion checks if any resource deletions failed under a folder path +func (r *jobProgressRecorder) HasDirPathFailedDeletion(folderPath string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, failedDeletion := range r.failedDeletions { + if safepath.InDir(failedDeletion, folderPath) { + return true + } + } + return false +} diff --git a/pkg/registry/apis/provisioning/jobs/progress_test.go b/pkg/registry/apis/provisioning/jobs/progress_test.go index 7e849491bbe..0879ba111af 100644 --- a/pkg/registry/apis/provisioning/jobs/progress_test.go +++ b/pkg/registry/apis/provisioning/jobs/progress_test.go @@ -7,6 +7,7 @@ import ( provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -252,3 +253,221 @@ func TestJobProgressRecorderWarningOnlyNoErrors(t *testing.T) { require.NotNil(t, finalStatus.Warnings) assert.Len(t, finalStatus.Warnings, 1) } + +func TestJobProgressRecorderFolderFailureTracking(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Record a folder creation failure with PathCreationError + pathErr := &resources.PathCreationError{ + Path: "folder1/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file.json", + Action: repository.FileActionCreated, + Error: pathErr, + }) + + // Record another PathCreationError for a different folder + pathErr2 := &resources.PathCreationError{ + Path: "folder2/subfolder/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/subfolder/file.json", + Action: repository.FileActionCreated, + Error: pathErr2, + }) + + // Record a deletion failure + recorder.Record(ctx, JobResourceResult{ + Path: "folder3/file1.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Record another deletion failure + recorder.Record(ctx, JobResourceResult{ + Path: "folder4/subfolder/file2.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Verify failed creations are tracked + recorder.mu.RLock() + assert.Len(t, recorder.failedCreations, 2) + assert.Contains(t, recorder.failedCreations, "folder1/") + assert.Contains(t, recorder.failedCreations, "folder2/subfolder/") + + // Verify failed deletions are tracked + assert.Len(t, recorder.failedDeletions, 2) + assert.Contains(t, recorder.failedDeletions, "folder3/file1.json") + assert.Contains(t, recorder.failedDeletions, "folder4/subfolder/file2.json") + recorder.mu.RUnlock() +} + +func TestJobProgressRecorderHasDirPathFailedCreation(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Add failed creations via Record + pathErr1 := &resources.PathCreationError{ + Path: "folder1/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file.json", + Action: repository.FileActionCreated, + Error: pathErr1, + }) + + pathErr2 := &resources.PathCreationError{ + Path: "folder2/subfolder/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/subfolder/file.json", + Action: repository.FileActionCreated, + Error: pathErr2, + }) + + // Test nested paths + assert.True(t, recorder.HasDirPathFailedCreation("folder1/file.json")) + assert.True(t, recorder.HasDirPathFailedCreation("folder1/nested/file.json")) + assert.True(t, recorder.HasDirPathFailedCreation("folder2/subfolder/file.json")) + + // Test non-nested paths + assert.False(t, recorder.HasDirPathFailedCreation("folder2/file2.json")) + assert.False(t, recorder.HasDirPathFailedCreation("folder2/othersubfolder/inside.json")) + assert.False(t, recorder.HasDirPathFailedCreation("other/file.json")) + assert.False(t, recorder.HasDirPathFailedCreation("folder3/file.json")) + assert.False(t, recorder.HasDirPathFailedCreation("file.json")) +} + +func TestJobProgressRecorderHasDirPathFailedDeletion(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Add failed deletions via Record + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file1.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/subfolder/file2.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + recorder.Record(ctx, JobResourceResult{ + Path: "folder3/nested/deep/file3.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Test folder paths with failed deletions + assert.True(t, recorder.HasDirPathFailedDeletion("folder1/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder2/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder2/subfolder/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder3/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder3/nested/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder3/nested/deep/")) + + // Test folder paths without failed deletions + assert.False(t, recorder.HasDirPathFailedDeletion("other/")) + assert.False(t, recorder.HasDirPathFailedDeletion("different/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder2/othersubfolder/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder2/subfolder/othersubfolder/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder3/nested/anotherdeep/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder3/nested/deep/insidedeep/")) +} + +func TestJobProgressRecorderResetResults(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Add some data via Record + pathErr := &resources.PathCreationError{ + Path: "folder1/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file.json", + Action: repository.FileActionCreated, + Error: pathErr, + }) + + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/file.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Verify data is stored + recorder.mu.RLock() + assert.Len(t, recorder.failedCreations, 1) + assert.Len(t, recorder.failedDeletions, 1) + recorder.mu.RUnlock() + + // Reset results + recorder.ResetResults() + + // Verify data is cleared + recorder.mu.RLock() + assert.Nil(t, recorder.failedCreations) + assert.Nil(t, recorder.failedDeletions) + recorder.mu.RUnlock() +} + +func TestJobProgressRecorderIgnoredActionsDontCountAsErrors(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Record an ignored action with error + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file1.json", + Action: repository.FileActionIgnored, + Error: assert.AnError, + }) + + // Record a real error for comparison + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/file2.json", + Action: repository.FileActionCreated, + Error: assert.AnError, + }) + + // Verify error count doesn't include ignored actions + recorder.mu.RLock() + assert.Equal(t, 1, recorder.errorCount, "ignored actions should not be counted as errors") + assert.Len(t, recorder.errors, 1, "ignored action errors should not be in error list") + recorder.mu.RUnlock() +} diff --git a/pkg/registry/apis/provisioning/jobs/queue.go b/pkg/registry/apis/provisioning/jobs/queue.go index e1992395efd..90b50aa4ee7 100644 --- a/pkg/registry/apis/provisioning/jobs/queue.go +++ b/pkg/registry/apis/provisioning/jobs/queue.go @@ -29,6 +29,10 @@ type JobProgressRecorder interface { StrictMaxErrors(maxErrors int) SetRefURLs(ctx context.Context, refURLs *provisioning.RepositoryURLs) Complete(ctx context.Context, err error) provisioning.JobStatus + // HasDirPathFailedCreation checks if a path has any folder creations that failed + HasDirPathFailedCreation(path string) bool + // HasDirPathFailedDeletion checks if a folderPath has any folder deletions that failed + HasDirPathFailedDeletion(folderPath string) bool } // Worker is a worker that can process a job diff --git a/pkg/registry/apis/provisioning/jobs/sync/full.go b/pkg/registry/apis/provisioning/jobs/sync/full.go index 10aad46693b..50ab5c39bcf 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/full.go +++ b/pkg/registry/apis/provisioning/jobs/sync/full.go @@ -75,11 +75,47 @@ func FullSync( return applyChanges(ctx, changes, clients, repositoryResources, progress, tracer, maxSyncWorkers, metrics) } +// shouldSkipChange checks if a change should be skipped based on previous failures on parent/child folders. +// If there is a previous failure on the path, we don't need to process the change as it will fail anyway. +func shouldSkipChange(ctx context.Context, change ResourceFileChange, progress jobs.JobProgressRecorder, tracer tracing.Tracer) bool { + if change.Action != repository.FileActionDeleted && progress.HasDirPathFailedCreation(change.Path) { + skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.skip_nested_resource") + skipSpan.SetAttributes(attribute.String("path", change.Path)) + progress.Record(skipCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Warning: fmt.Errorf("resource was not processed because the parent folder could not be created"), + }) + skipSpan.End() + return true + } + + if change.Action == repository.FileActionDeleted && safepath.IsDir(change.Path) && progress.HasDirPathFailedDeletion(change.Path) { + skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.skip_folder_with_failed_deletions") + skipSpan.SetAttributes(attribute.String("path", change.Path)) + progress.Record(skipCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Group: resources.FolderKind.Group, + Kind: resources.FolderKind.Kind, + Warning: fmt.Errorf("folder was not processed because children resources in its path could not be deleted"), + }) + skipSpan.End() + return true + } + + return false +} + func applyChange(ctx context.Context, change ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer) { if ctx.Err() != nil { return } + if shouldSkipChange(ctx, change, progress, tracer) { + return + } + if change.Action == repository.FileActionDeleted { deleteCtx, deleteSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.delete") result := jobs.JobResourceResult{ @@ -138,6 +174,7 @@ func applyChange(ctx context.Context, change ResourceFileChange, clients resourc ensureFolderSpan.RecordError(err) ensureFolderSpan.End() progress.Record(ctx, result) + return } @@ -253,8 +290,6 @@ func applyChanges(ctx context.Context, changes []ResourceFileChange, clients res } func applyFoldersSerially(ctx context.Context, folders []ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer) error { - logger := logging.FromContext(ctx) - for _, folder := range folders { if ctx.Err() != nil { return ctx.Err() @@ -264,23 +299,9 @@ func applyFoldersSerially(ctx context.Context, folders []ResourceFileChange, cli return err } - folderCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - - applyChange(folderCtx, folder, clients, repositoryResources, progress, tracer) - - if folderCtx.Err() == context.DeadlineExceeded { - logger.Error("operation timed out after 15 seconds", "path", folder.Path, "action", folder.Action) - - recordCtx, recordCancel := context.WithTimeout(context.Background(), 15*time.Second) - progress.Record(recordCtx, jobs.JobResourceResult{ - Path: folder.Path, - Action: folder.Action, - Error: fmt.Errorf("operation timed out after 15 seconds"), - }) - recordCancel() - } - - cancel() + wrapWithTimeout(ctx, 15*time.Second, func(timeoutCtx context.Context) { + applyChange(timeoutCtx, folder, clients, repositoryResources, progress, tracer) + }) } return nil @@ -318,7 +339,9 @@ loop: defer wg.Done() defer func() { <-sem }() - applyChangeWithTimeout(ctx, change, clients, repositoryResources, progress, tracer, logger) + wrapWithTimeout(ctx, 15*time.Second, func(timeoutCtx context.Context) { + applyChange(timeoutCtx, change, clients, repositoryResources, progress, tracer) + }) }(change) } @@ -331,21 +354,10 @@ loop: return ctx.Err() } -func applyChangeWithTimeout(ctx context.Context, change ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer, logger logging.Logger) { - changeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) +// wrapWithTimeout wraps a function call with a timeout context +func wrapWithTimeout(ctx context.Context, timeout time.Duration, fn func(context.Context)) { + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - applyChange(changeCtx, change, clients, repositoryResources, progress, tracer) - - if changeCtx.Err() == context.DeadlineExceeded { - logger.Error("operation timed out after 15 seconds", "path", change.Path, "action", change.Action) - - recordCtx, recordCancel := context.WithTimeout(context.Background(), 15*time.Second) - progress.Record(recordCtx, jobs.JobResourceResult{ - Path: change.Path, - Action: change.Action, - Error: fmt.Errorf("operation timed out after 15 seconds"), - }) - recordCancel() - } + fn(timeoutCtx) } diff --git a/pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go b/pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go new file mode 100644 index 00000000000..2bc0ea8779e --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go @@ -0,0 +1,432 @@ +package sync + +import ( + "context" + "fmt" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + k8testing "k8s.io/client-go/testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" +) + +/* +TestFullSync_HierarchicalErrorHandling tests the hierarchical error handling behavior: + +FOLDER CREATION FAILURES: +- When a folder fails to be created with PathCreationError, all nested resources are skipped +- Nested resources are recorded with FileActionIgnored and error "folder was not processed because children resources in its path could not be deleted" +- Only the folder creation error counts toward error limits +- Nested resource skips do NOT count toward error limits + +FOLDER DELETION FAILURES: +- When a file deletion fails, it's tracked in failedDeletions +- When cleaning up folders, we check HasDirPathFailedDeletion() +- If children failed to delete, folder deletion is skipped with FileActionIgnored +- This prevents orphaning resources that still exist + +DELETIONS NOT AFFECTED BY CREATION FAILURES: +- If a folder creation fails, deletion operations for resources in that folder still proceed +- This is because the resource might already exist from a previous sync +- Only creations/updates/renames are affected by failed folder creation + +AUTOMATIC TRACKING: +- Record() automatically detects PathCreationError and adds to failedCreations +- Record() automatically detects deletion failures and adds to failedDeletions +- No manual calls to AddFailedCreation/AddFailedDeletion needed +*/ +func TestFullSync_HierarchicalErrorHandling(t *testing.T) { // nolint:gocyclo + tests := []struct { + name string + setupMocks func(*repository.MockRepository, *resources.MockRepositoryResources, *resources.MockResourceClients, *jobs.MockJobProgressRecorder, *dynamicfake.FakeDynamicClient) + changes []ResourceFileChange + description string + expectError bool + errorContains string + }{ + { + name: "folder creation fails, nested file skipped", + description: "When folder1/ fails to create, folder1/file.json should be skipped with FileActionIgnored", + changes: []ResourceFileChange{ + {Path: "folder1/file.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // First, check if nested under failed creation - not yet + progress.On("HasDirPathFailedCreation", "folder1/file.json").Return(false).Once() + + // WriteResourceFromFile fails with PathCreationError for folder1/ + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + // File will be recorded with error, triggering automatic tracking of folder1/ failure + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file.json" && r.Error != nil && r.Action == repository.FileActionCreated + })).Return().Once() + }, + }, + { + name: "folder creation fails, multiple nested resources skipped", + description: "When folder1/ fails to create, all nested resources (subfolder, files) are skipped", + changes: []ResourceFileChange{ + {Path: "folder1/file1.json", Action: repository.FileActionCreated}, + {Path: "folder1/subfolder/file2.json", Action: repository.FileActionCreated}, + {Path: "folder1/file3.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // First file triggers folder creation failure + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Error != nil + })).Return().Once() + + // Subsequent files in same folder are skipped + progress.On("HasDirPathFailedCreation", "folder1/subfolder/file2.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/subfolder/file2.json" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && + r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "folder1/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file3.json" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && + r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + }, + }, + { + name: "file deletion failure tracked", + description: "When a file deletion fails, it's automatically tracked in failedDeletions", + changes: []ResourceFileChange{ + { + Path: "folder1/file.json", + Action: repository.FileActionDeleted, + Existing: &provisioning.ResourceListItem{ + Name: "file1", + Group: "dashboard.grafana.app", + Resource: "dashboards", + }, + }, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + // File deletion fails + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + // File deletion recorded with error, automatically tracked in failedDeletions + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file.json" && + r.Action == repository.FileActionDeleted && + r.Error != nil + })).Return().Once() + }, + }, + { + name: "deletion proceeds despite creation failure", + description: "When folder1/ fails to create, deletion of folder1/file2.json still proceeds (resource might exist from previous sync)", + changes: []ResourceFileChange{ + {Path: "folder1/file1.json", Action: repository.FileActionCreated}, + { + Path: "folder1/file2.json", + Action: repository.FileActionDeleted, + Existing: &provisioning.ResourceListItem{ + Name: "file2", + Group: "dashboard.grafana.app", + Resource: "dashboards", + }, + }, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + // Creation fails + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Error != nil + })).Return().Once() + + // Deletion proceeds (NOT checking HasDirPathFailedCreation for deletions) + // Note: deletion will fail because resource doesn't exist, but that's fine for this test + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + // Record deletion attempt (will have error since resource doesn't exist, but that's ok) + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file2.json" && + r.Action == repository.FileActionDeleted + // Not checking r.Error because resource doesn't exist in fake client + })).Return().Once() + }, + }, + { + name: "multi-level nesting - all skipped", + description: "When level1/ fails, level1/level2/level3/file.json is also skipped", + changes: []ResourceFileChange{ + {Path: "level1/file1.json", Action: repository.FileActionCreated}, + {Path: "level1/level2/file2.json", Action: repository.FileActionCreated}, + {Path: "level1/level2/level3/file3.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // First file triggers level1/ failure + progress.On("HasDirPathFailedCreation", "level1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "level1/file1.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/file1.json" && r.Error != nil + })).Return().Once() + + // All nested files are skipped + for _, path := range []string{"level1/level2/file2.json", "level1/level2/level3/file3.json"} { + progress.On("HasDirPathFailedCreation", path).Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + { + name: "mixed success and failure", + description: "When success/ works and failure/ fails, only failure/* are skipped", + changes: []ResourceFileChange{ + {Path: "success/file1.json", Action: repository.FileActionCreated}, + {Path: "failure/file2.json", Action: repository.FileActionCreated}, + {Path: "failure/nested/file3.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // Success path works + progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", ""). + Return("resource1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/file1.json" && r.Error == nil + })).Return().Once() + + // Failure path fails + progress.On("HasDirPathFailedCreation", "failure/file2.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")} + repoResources.On("WriteResourceFromFile", mock.Anything, "failure/file2.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/file2.json" && r.Error != nil + })).Return().Once() + + // Nested file in failure path is skipped + progress.On("HasDirPathFailedCreation", "failure/nested/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/nested/file3.json" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "folder creation fails with explicit folder in changes", + description: "When folder1/ is explicitly in changes and fails to create, all nested resources (subfolders and files) are skipped", + changes: []ResourceFileChange{ + {Path: "folder1/", Action: repository.FileActionCreated}, + {Path: "folder1/subfolder/", Action: repository.FileActionCreated}, + {Path: "folder1/file1.json", Action: repository.FileActionCreated}, + {Path: "folder1/subfolder/file2.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + progress.On("HasDirPathFailedCreation", "folder1/").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "folder1/").Return("", folderErr).Once() + + progress.On("HasDirPathFailedCreation", "folder1/subfolder/").Return(true).Once() + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(true).Once() + progress.On("HasDirPathFailedCreation", "folder1/subfolder/file2.json").Return(true).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/" && r.Error != nil + })).Return().Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/subfolder/" && r.Action == repository.FileActionIgnored + })).Return().Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Action == repository.FileActionIgnored + })).Return().Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/subfolder/file2.json" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "folder deletion prevented when child deletion fails", + description: "When a file deletion fails, folder deletion is skipped with FileActionIgnored to prevent orphaning resources", + changes: []ResourceFileChange{ + { + Path: "folder1/file1.json", + Action: repository.FileActionDeleted, + Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}, + }, + {Path: "folder1/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "folder1", Group: "folder.grafana.app", Resource: "Folder"}}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedDeletion", "folder1/").Return(true).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "multiple folder deletion failures", + description: "When multiple independent folders have child deletion failures, all folder deletions are skipped", + changes: []ResourceFileChange{ + {Path: "folder1/file1.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}}, + {Path: "folder1/", Action: repository.FileActionDeleted}, + {Path: "folder2/file2.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file2", Group: "dashboard.grafana.app", Resource: "dashboards"}}, + {Path: "folder2/", Action: repository.FileActionDeleted}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + for _, path := range []string{"folder1/file1.json", "folder2/file2.json"} { + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Error != nil + })).Return().Once() + } + + progress.On("HasDirPathFailedDeletion", "folder1/").Return(true).Once() + progress.On("HasDirPathFailedDeletion", "folder2/").Return(true).Once() + + for _, path := range []string{"folder1/", "folder2/"} { + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + { + name: "nested subfolder deletion failure", + description: "When a file deletion fails in a nested subfolder, both the subfolder and parent folder deletions are skipped", + changes: []ResourceFileChange{ + {Path: "parent/subfolder/file.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}}, + {Path: "parent/subfolder/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "subfolder", Group: "folder.grafana.app", Resource: "Folder"}}, + {Path: "parent/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "parent", Group: "folder.grafana.app", Resource: "Folder"}}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "parent/subfolder/file.json" && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedDeletion", "parent/subfolder/").Return(true).Once() + progress.On("HasDirPathFailedDeletion", "parent/").Return(true).Once() + + for _, path := range []string{"parent/subfolder/", "parent/"} { + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + dynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) + + repo := repository.NewMockRepository(t) + repoResources := resources.NewMockRepositoryResources(t) + clients := resources.NewMockResourceClients(t) + progress := jobs.NewMockJobProgressRecorder(t) + compareFn := NewMockCompareFn(t) + + repo.On("Config").Return(&provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{Name: "test-repo"}, + Spec: provisioning.RepositorySpec{Title: "Test Repo"}, + }) + + tt.setupMocks(repo, repoResources, clients, progress, dynamicClient) + + compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tt.changes, nil) + progress.On("SetTotal", mock.Anything, len(tt.changes)).Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + err := FullSync(context.Background(), repo, compareFn.Execute, clients, "ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + + progress.AssertExpectations(t) + repoResources.AssertExpectations(t) + }) + } +} diff --git a/pkg/registry/apis/provisioning/jobs/sync/full_test.go b/pkg/registry/apis/provisioning/jobs/sync/full_test.go index aaa61ee61db..d045c67c6be 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/full_test.go +++ b/pkg/registry/apis/provisioning/jobs/sync/full_test.go @@ -213,6 +213,10 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo return nil }) + progress.On("HasDirPathFailedCreation", mock.MatchedBy(func(path string) bool { + return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json" + })).Return(false).Maybe() + repoResources.On("WriteResourceFromFile", mock.Anything, mock.MatchedBy(func(path string) bool { return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json" }), "").Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil).Maybe() @@ -235,6 +239,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -259,6 +264,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error")) @@ -285,6 +291,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -309,6 +316,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error")) @@ -335,6 +343,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false) repoResources.On("EnsureFolderPathExist", mock.Anything, "one/two/three/").Return("some-folder", nil) progress.On("Record", mock.Anything, jobs.JobResourceResult{ @@ -357,6 +366,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false) repoResources.On( "EnsureFolderPathExist", @@ -581,6 +591,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false) scheme := runtime.NewScheme() require.NoError(t, metav1.AddMetaToScheme(scheme)) @@ -640,6 +651,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false) scheme := runtime.NewScheme() require.NoError(t, metav1.AddMetaToScheme(scheme)) @@ -695,6 +707,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/slow.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/slow.json", ""). Run(func(args mock.Arguments) { @@ -708,19 +721,13 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }). Return("", schema.GroupVersionKind{}, context.DeadlineExceeded) + // applyChange records the error from WriteResourceFromFile progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { return result.Action == repository.FileActionCreated && result.Path == "dashboards/slow.json" && result.Error != nil && result.Error.Error() == "writing resource from file dashboards/slow.json: context deadline exceeded" })).Return().Once() - - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Action == repository.FileActionCreated && - result.Path == "dashboards/slow.json" && - result.Error != nil && - result.Error.Error() == "operation timed out after 15 seconds" - })).Return().Once() }, }, } diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental.go b/pkg/registry/apis/provisioning/jobs/sync/incremental.go index daa94d94636..5ae33f1e4d1 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/incremental.go +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental.go @@ -60,7 +60,7 @@ func IncrementalSync(ctx context.Context, repo repository.Versioned, previousRef if len(affectedFolders) > 0 { cleanupStart := time.Now() span.AddEvent("checking if impacted folders should be deleted", trace.WithAttributes(attribute.Int("affected_folders", len(affectedFolders)))) - err := cleanupOrphanedFolders(ctx, repo, affectedFolders, repositoryResources, tracer) + err := cleanupOrphanedFolders(ctx, repo, affectedFolders, repositoryResources, tracer, progress) metrics.RecordIncrementalSyncPhase(jobs.IncrementalSyncPhaseCleanup, time.Since(cleanupStart)) if err != nil { return tracing.Error(span, fmt.Errorf("cleanup orphaned folders: %w", err)) @@ -85,6 +85,20 @@ func applyIncrementalChanges(ctx context.Context, diff []repository.VersionedFil return nil, tracing.Error(span, err) } + // Check if this resource is nested under a failed folder creation + // This only applies to creation/update/rename operations, not deletions + if change.Action != repository.FileActionDeleted && progress.HasDirPathFailedCreation(change.Path) { + // Skip this resource since its parent folder failed to be created + skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.incremental.skip_nested_resource") + progress.Record(skipCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Warning: fmt.Errorf("resource was not processed because the parent folder could not be created"), + }) + skipSpan.End() + continue + } + if err := resources.IsPathSupported(change.Path); err != nil { ensureFolderCtx, ensureFolderSpan := tracer.Start(ctx, "provisioning.sync.incremental.ensure_folder_path_exist") // Maintain the safe segment for empty folders @@ -98,7 +112,15 @@ func applyIncrementalChanges(ctx context.Context, diff []repository.VersionedFil if err != nil { ensureFolderSpan.RecordError(err) ensureFolderSpan.End() - return nil, tracing.Error(span, fmt.Errorf("unable to create empty file folder: %w", err)) + + progress.Record(ensureFolderCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Group: resources.FolderKind.Group, + Kind: resources.FolderKind.Kind, + Error: err, + }) + continue } progress.Record(ensureFolderCtx, jobs.JobResourceResult{ @@ -185,6 +207,7 @@ func cleanupOrphanedFolders( affectedFolders map[string]string, repositoryResources resources.RepositoryResources, tracer tracing.Tracer, + progress jobs.JobProgressRecorder, ) error { ctx, span := tracer.Start(ctx, "provisioning.sync.incremental.cleanup_orphaned_folders") defer span.End() @@ -198,6 +221,12 @@ func cleanupOrphanedFolders( for path, folderName := range affectedFolders { span.SetAttributes(attribute.String("folder", folderName)) + // Check if any resources under this folder failed to delete + if progress.HasDirPathFailedDeletion(path) { + span.AddEvent("skipping orphaned folder cleanup: a child resource in its path failed to be deleted") + continue + } + // if we can no longer find the folder in git, then we can delete it from grafana _, err := readerRepo.Read(ctx, path, "") if err != nil && (errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) { diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go b/pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go new file mode 100644 index 00000000000..ff4212eff1e --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go @@ -0,0 +1,623 @@ +package sync + +import ( + "context" + "fmt" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" +) + +/* +TestIncrementalSync_HierarchicalErrorHandling tests the hierarchical error handling behavior: + +FOLDER CREATION FAILURES: +- When EnsureFolderPathExist fails with PathCreationError, the path is tracked +- Subsequent resources under that path are skipped with FileActionIgnored +- Only the initial folder creation error counts toward error limits +- WriteResourceFromFile can also return PathCreationError for implicit folder creation + +FOLDER DELETION FAILURES (cleanupOrphanedFolders): +- When RemoveResourceFromFile fails, path is tracked in failedDeletions +- In cleanupOrphanedFolders, HasDirPathFailedDeletion() is checked before RemoveFolder +- If children failed to delete, folder cleanup is skipped with a span event + +DELETIONS NOT AFFECTED BY CREATION FAILURES: +- HasDirPathFailedCreation is NOT checked for FileActionDeleted +- Deletions proceed even if their parent folder failed to be created +- This handles cleanup of resources that exist from previous syncs + +RENAME OPERATIONS: +- RenameResourceFile can return PathCreationError for the destination folder +- Renames are affected by failed destination folder creation +- Renames are NOT skipped due to source folder creation failures + +AUTOMATIC TRACKING: +- Record() automatically detects PathCreationError via errors.As() and adds to failedCreations +- Record() automatically detects FileActionDeleted with error and adds to failedDeletions +- No manual tracking calls needed +*/ +func TestIncrementalSync_HierarchicalErrorHandling(t *testing.T) { // nolint:gocyclo + tests := []struct { + name string + setupMocks func(*repository.MockVersioned, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) + changes []repository.VersionedFileChange + previousRef string + currentRef string + description string + expectError bool + errorContains string + }{ + { + name: "folder creation fails, nested file skipped", + description: "When unsupported/ fails to create via EnsureFolderPathExist, nested file is skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "unsupported/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "unsupported/nested/file2.txt", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // First file triggers folder creation which fails + progress.On("HasDirPathFailedCreation", "unsupported/file.txt").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "unsupported/", Err: fmt.Errorf("permission denied")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/").Return("", folderErr).Once() + + // First file recorded with error (note: error is from folder creation, but recorded against file) + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/file.txt" && + r.Action == repository.FileActionIgnored && + r.Error != nil + })).Return().Once() + + // Second file is skipped because parent folder failed + progress.On("HasDirPathFailedCreation", "unsupported/nested/file2.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/nested/file2.txt" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && + r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + }, + }, + { + name: "WriteResourceFromFile returns PathCreationError, nested resources skipped", + description: "When WriteResourceFromFile implicitly creates a folder and fails, nested resources are skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "folder1/file1.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "folder1/file2.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "folder1/nested/file3.json", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // First file write fails with PathCreationError + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", "new-ref"). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + // First file recorded with error, automatically tracked + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && + r.Action == repository.FileActionCreated && + r.Error != nil + })).Return().Once() + + // Subsequent files are skipped + progress.On("HasDirPathFailedCreation", "folder1/file2.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file2.json" && r.Action == repository.FileActionIgnored && r.Warning != nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "folder1/nested/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/nested/file3.json" && r.Action == repository.FileActionIgnored && r.Warning != nil + })).Return().Once() + }, + }, + { + name: "file deletion fails, folder cleanup skipped", + description: "When RemoveResourceFromFile fails, cleanupOrphanedFolders skips folder removal", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionDeleted, Path: "dashboards/file1.json", PreviousRef: "old-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // File deletion fails (deletions don't check HasDirPathFailedCreation) + repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/file1.json", "old-ref"). + Return("dashboard-1", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard"}, fmt.Errorf("permission denied")).Once() + + // Error recorded, automatically tracked in failedDeletions + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "dashboards/file1.json" && + r.Action == repository.FileActionDeleted && + r.Error != nil + })).Return().Once() + + // During cleanup, folder deletion is skipped + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(true).Once() + + // Note: RemoveFolder should NOT be called (verified via AssertNotCalled in test) + }, + }, + { + name: "deletion proceeds despite creation failure", + description: "When folder1/ creation fails, deletion of folder1/old.json still proceeds", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "folder1/new.json", Ref: "new-ref"}, + {Action: repository.FileActionDeleted, Path: "folder1/old.json", PreviousRef: "old-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Creation fails + progress.On("HasDirPathFailedCreation", "folder1/new.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/new.json", "new-ref"). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/new.json" && r.Error != nil + })).Return().Once() + + // Deletion proceeds (NOT checking HasDirPathFailedCreation for deletions) + repoResources.On("RemoveResourceFromFile", mock.Anything, "folder1/old.json", "old-ref"). + Return("old-resource", "", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/old.json" && + r.Action == repository.FileActionDeleted && + r.Error == nil // Deletion succeeds! + })).Return().Once() + }, + }, + { + name: "multi-level nesting cascade", + description: "When level1/ fails, level1/level2/level3/file.json is also skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "level1/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/level3/file.txt", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // First file triggers level1/ failure + progress.On("HasDirPathFailedCreation", "level1/file.txt").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "level1/").Return("", folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/file.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + + // All nested files are skipped + for _, path := range []string{"level1/level2/file.txt", "level1/level2/level3/file.txt"} { + progress.On("HasDirPathFailedCreation", path).Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + { + name: "mixed success and failure", + description: "When success/ works and failure/ fails, only failure/* are skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "success/file1.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "success/nested/file2.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/file3.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/nested/file4.txt", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Success path works + progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", "new-ref"). + Return("resource-1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/file1.json" && r.Error == nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "success/nested/file2.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/nested/file2.json", "new-ref"). + Return("resource-2", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/nested/file2.json" && r.Error == nil + })).Return().Once() + + // Failure path fails + progress.On("HasDirPathFailedCreation", "failure/file3.txt").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "failure/").Return("", folderErr).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/file3.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + + // Nested file in failure path is skipped + progress.On("HasDirPathFailedCreation", "failure/nested/file4.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/nested/file4.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "rename with failed destination folder", + description: "When RenameResourceFile fails with PathCreationError for destination, rename is skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + { + Action: repository.FileActionRenamed, + Path: "newfolder/file.json", + PreviousPath: "oldfolder/file.json", + Ref: "new-ref", + PreviousRef: "old-ref", + }, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Rename fails with PathCreationError for destination folder + progress.On("HasDirPathFailedCreation", "newfolder/file.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "newfolder/", Err: fmt.Errorf("permission denied")} + repoResources.On("RenameResourceFile", mock.Anything, "oldfolder/file.json", "old-ref", "newfolder/file.json", "new-ref"). + Return("", "", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "newfolder/file.json" && + r.Action == repository.FileActionRenamed && + r.Error != nil + })).Return().Once() + }, + }, + { + name: "renamed file still checked, subsequent nested resources skipped", + description: "After rename fails for folder1/file.json, other folder1/* files are skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionRenamed, Path: "folder1/file1.json", PreviousPath: "old/file1.json", Ref: "new-ref", PreviousRef: "old-ref"}, + {Action: repository.FileActionCreated, Path: "folder1/file2.json", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Rename is NOT skipped for creation failures (it's checking the destination path) + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + // Second file also skipped + progress.On("HasDirPathFailedCreation", "folder1/file2.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file2.json" && r.Action == repository.FileActionIgnored && r.Warning != nil + })).Return().Once() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runHierarchicalErrorHandlingTest(t, tt) + }) + } +} + +type compositeRepoForTest struct { + *repository.MockVersioned + *repository.MockReader +} + +func runHierarchicalErrorHandlingTest(t *testing.T, tt struct { + name string + setupMocks func(*repository.MockVersioned, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) + changes []repository.VersionedFileChange + previousRef string + currentRef string + description string + expectError bool + errorContains string +}) { + var repo repository.Versioned + mockVersioned := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + // For tests that need cleanup (folder deletion), use composite repo + if tt.name == "file deletion fails, folder cleanup skipped" { + mockReader := repository.NewMockReader(t) + repo = &compositeRepoForTest{ + MockVersioned: mockVersioned, + MockReader: mockReader, + } + } else { + repo = mockVersioned + } + + mockVersioned.On("CompareFiles", mock.Anything, tt.previousRef, tt.currentRef).Return(tt.changes, nil) + progress.On("SetTotal", mock.Anything, len(tt.changes)).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + tt.setupMocks(mockVersioned, repoResources, progress) + + err := IncrementalSync(context.Background(), repo, tt.previousRef, tt.currentRef, repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + + progress.AssertExpectations(t) + repoResources.AssertExpectations(t) + // For deletion tests, verify RemoveFolder was NOT called + if tt.name == "file deletion fails, folder cleanup skipped" { + repoResources.AssertNotCalled(t, "RemoveFolder", mock.Anything, mock.Anything) + } +} + +// TestIncrementalSync_HierarchicalErrorHandling_FailedFolderCreation tests nested resource skipping +func TestIncrementalSync_HierarchicalErrorHandling_FailedFolderCreation(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "unsupported/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "unsupported/subfolder/file2.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "unsupported/file3.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "other/file.json", Ref: "new-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 4).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + folderErr := &resources.PathCreationError{Path: "unsupported/", Err: fmt.Errorf("permission denied")} + // First check is before it fails. + progress.On("HasDirPathFailedCreation", "unsupported/file.txt").Return(false).Once() + repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/").Return("", folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/file.txt" && r.Action == repository.FileActionIgnored && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "unsupported/subfolder/file2.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/subfolder/file2.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "unsupported/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/file3.json" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "other/file.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "other/file.json", "new-ref"). + Return("test-resource", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "other/file.json" && r.Action == repository.FileActionCreated && r.Error == nil + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_FailedFileDeletion tests folder cleanup prevention +func TestIncrementalSync_HierarchicalErrorHandling_FailedFileDeletion(t *testing.T) { + mockVersioned := repository.NewMockVersioned(t) + mockReader := repository.NewMockReader(t) + repo := &compositeRepoForTest{MockVersioned: mockVersioned, MockReader: mockReader} + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionDeleted, Path: "dashboards/file1.json", PreviousRef: "old-ref"}, + } + + mockVersioned.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 1).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + // Deletions don't check HasDirPathFailedCreation, they go straight to removal + repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/file1.json", "old-ref"). + Return("dashboard-1", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard"}, fmt.Errorf("permission denied")).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "dashboards/file1.json" && r.Action == repository.FileActionDeleted && + r.Error != nil && r.Error.Error() == "removing resource from file dashboards/file1.json: permission denied" + })).Return().Once() + + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(true).Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) + repoResources.AssertNotCalled(t, "RemoveFolder", mock.Anything, mock.Anything) +} + +// TestIncrementalSync_HierarchicalErrorHandling_DeletionNotAffectedByCreationFailure tests deletions proceed despite creation failures +func TestIncrementalSync_HierarchicalErrorHandling_DeletionNotAffectedByCreationFailure(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "folder1/file.json", Ref: "new-ref"}, + {Action: repository.FileActionDeleted, Path: "folder1/old.json", PreviousRef: "old-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 2).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + // Creation fails + progress.On("HasDirPathFailedCreation", "folder1/file.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file.json", "new-ref"). + Return("", schema.GroupVersionKind{}, &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")}).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file.json" && r.Error != nil + })).Return().Once() + + // Deletion should NOT be skipped (not checking HasDirPathFailedCreation for deletions) + // Deletions don't check HasDirPathFailedCreation, they go straight to removal + repoResources.On("RemoveResourceFromFile", mock.Anything, "folder1/old.json", "old-ref"). + Return("old-resource", "", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/old.json" && r.Action == repository.FileActionDeleted && r.Error == nil + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_MultiLevelNesting tests multi-level cascade +func TestIncrementalSync_HierarchicalErrorHandling_MultiLevelNesting(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "level1/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/level3/file.txt", Ref: "new-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 3).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")} + // First check is before it fails. + progress.On("HasDirPathFailedCreation", "level1/file.txt").Return(false).Once() + repoResources.On("EnsureFolderPathExist", mock.Anything, "level1/").Return("", folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/file.txt" && r.Action == repository.FileActionIgnored && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "level1/level2/file.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/level2/file.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "level1/level2/level3/file.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/level2/level3/file.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_MixedSuccessAndFailure tests partial failures +func TestIncrementalSync_HierarchicalErrorHandling_MixedSuccessAndFailure(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "success/file1.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "success/nested/file2.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/file3.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/nested/file4.txt", Ref: "new-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 4).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", "new-ref"). + Return("resource-1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/file1.json" && r.Action == repository.FileActionCreated && r.Error == nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "success/nested/file2.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/nested/file2.json", "new-ref"). + Return("resource-2", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/nested/file2.json" && r.Action == repository.FileActionCreated && r.Error == nil + })).Return().Once() + + folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")} + progress.On("HasDirPathFailedCreation", "failure/file3.txt").Return(false).Once() + repoResources.On("EnsureFolderPathExist", mock.Anything, "failure/").Return("", folderErr).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/file3.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "failure/nested/file4.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/nested/file4.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) + repoResources.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_RenameWithFailedFolderCreation tests rename operations affected by folder failures +func TestIncrementalSync_HierarchicalErrorHandling_RenameWithFailedFolderCreation(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionRenamed, Path: "newfolder/file.json", PreviousPath: "oldfolder/file.json", Ref: "new-ref", PreviousRef: "old-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 1).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + progress.On("HasDirPathFailedCreation", "newfolder/file.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "newfolder/file.json" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go b/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go index f694d7f5068..38c537635cf 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go @@ -92,6 +92,10 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation checks + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) + progress.On("HasDirPathFailedCreation", "alerts/alert.yaml").Return(false) + // Mock successful resource writes repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref"). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -127,6 +131,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "unsupported/path/file.txt").Return(false) + // Mock folder creation repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/"). Return("test-folder", nil) @@ -161,6 +168,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", ".unsupported/path/file.txt").Return(false) + progress.On("Record", mock.Anything, jobs.JobResourceResult{ Action: repository.FileActionIgnored, Path: ".unsupported/path/file.txt", @@ -222,6 +232,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "dashboards/new.json").Return(false) + // Mock resource rename repoResources.On("RenameResourceFile", mock.Anything, "dashboards/old.json", "old-ref", "dashboards/new.json", "new-ref"). Return("renamed-dashboard", "", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -254,6 +267,10 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetTotal", mock.Anything, 1).Return() progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "dashboards/ignored.json").Return(false) + progress.On("Record", mock.Anything, jobs.JobResourceResult{ Action: repository.FileActionIgnored, Path: "dashboards/ignored.json", @@ -277,16 +294,28 @@ func TestIncrementalSync(t *testing.T) { repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) progress.On("SetTotal", mock.Anything, 1).Return() progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "unsupported/path/file.txt").Return(false) // Mock folder creation error repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/"). Return("", fmt.Errorf("failed to create folder")) + // Mock progress recording with error + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionIgnored && + result.Path == "unsupported/path/file.txt" && + result.Error != nil && + result.Error.Error() == "failed to create folder" + })).Return() + progress.On("TooManyErrors").Return(nil) }, previousRef: "old-ref", currentRef: "new-ref", - expectedError: "unable to create empty file folder: failed to create folder", + expectedCalls: 1, }, { name: "error writing resource", @@ -303,6 +332,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) + // Mock resource write error repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref"). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write failed")) @@ -372,7 +404,8 @@ func TestIncrementalSync(t *testing.T) { repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) progress.On("SetTotal", mock.Anything, 1).Return() progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() - // Mock too many errors + + // Mock too many errors - this is checked before processing files, so HasDirPathFailedCreation won't be called progress.On("TooManyErrors").Return(fmt.Errorf("too many errors occurred")) }, previousRef: "old-ref", @@ -428,6 +461,9 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref"). Return("old-dashboard", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) + // Mock HasDirPathFailedDeletion check for cleanup + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false) + // if the folder is not found in git, there should be a call to remove the folder from grafana repo.MockReader.On("Read", mock.Anything, "dashboards/", ""). Return((*repository.FileInfo)(nil), repository.ErrFileNotFound) @@ -453,6 +489,10 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref"). Return("old-dashboard", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) + + // Mock HasDirPathFailedDeletion check for cleanup + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false) + // if the folder still exists in git, there should not be a call to delete it from grafana repo.MockReader.On("Read", mock.Anything, "dashboards/", ""). Return(&repository.FileInfo{}, nil) @@ -485,6 +525,13 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { repoResources.On("RemoveResourceFromFile", mock.Anything, "alerts/old-alert.yaml", "old-ref"). Return("old-alert", "folder-uid-2", schema.GroupVersionKind{Kind: "Alert", Group: "alerts"}, nil) + progress.On("Record", mock.Anything, mock.Anything).Return() + progress.On("TooManyErrors").Return(nil) + + // Mock HasDirPathFailedDeletion checks for cleanup + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false) + progress.On("HasDirPathFailedDeletion", "alerts/").Return(false) + // both not found in git, both should be deleted repo.MockReader.On("Read", mock.Anything, "dashboards/", ""). Return((*repository.FileInfo)(nil), repository.ErrFileNotFound) @@ -492,9 +539,6 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { Return((*repository.FileInfo)(nil), repository.ErrFileNotFound) repoResources.On("RemoveFolder", mock.Anything, "folder-uid-1").Return(nil) repoResources.On("RemoveFolder", mock.Anything, "folder-uid-2").Return(nil) - - progress.On("Record", mock.Anything, mock.Anything).Return() - progress.On("TooManyErrors").Return(nil) }, }, } diff --git a/pkg/registry/apis/provisioning/resources/folders.go b/pkg/registry/apis/provisioning/resources/folders.go index 8b8f4201745..b4a06f78691 100644 --- a/pkg/registry/apis/provisioning/resources/folders.go +++ b/pkg/registry/apis/provisioning/resources/folders.go @@ -20,6 +20,21 @@ import ( const MaxNumberOfFolders = 10000 +// PathCreationError represents an error that occurred while creating a folder path. +// It contains the path that failed and the underlying error. +type PathCreationError struct { + Path string + Err error +} + +func (e *PathCreationError) Unwrap() error { + return e.Err +} + +func (e *PathCreationError) Error() string { + return fmt.Sprintf("failed to create path %s: %v", e.Path, e.Err) +} + type FolderManager struct { repo repository.ReaderWriter tree FolderTree @@ -73,7 +88,11 @@ func (fm *FolderManager) EnsureFolderPathExist(ctx context.Context, filePath str } if err := fm.EnsureFolderExists(ctx, f, parent); err != nil { - return fmt.Errorf("ensure folder exists: %w", err) + // Wrap in PathCreationError to indicate which path failed + return &PathCreationError{ + Path: f.Path, + Err: fmt.Errorf("ensure folder exists: %w", err), + } } fm.tree.Add(f, parent) diff --git a/pkg/registry/apis/provisioning/resources/folders_test.go b/pkg/registry/apis/provisioning/resources/folders_test.go new file mode 100644 index 00000000000..ed593a7d26c --- /dev/null +++ b/pkg/registry/apis/provisioning/resources/folders_test.go @@ -0,0 +1,68 @@ +package resources_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" + "github.com/stretchr/testify/require" +) + +func TestPathCreationError(t *testing.T) { + t.Run("Error method returns formatted message", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + expectedMsg := "failed to create path grafana/folder-1: underlying error" + require.Equal(t, expectedMsg, pathErr.Error()) + }) + + t.Run("Unwrap returns underlying error", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + unwrapped := pathErr.Unwrap() + require.Equal(t, underlyingErr, unwrapped) + require.EqualError(t, unwrapped, "underlying error") + }) + + t.Run("errors.Is finds underlying error", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + require.True(t, errors.Is(pathErr, underlyingErr)) + require.False(t, errors.Is(pathErr, fmt.Errorf("different error"))) + }) + + t.Run("errors.As extracts PathCreationError", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + var extractedErr *resources.PathCreationError + require.True(t, errors.As(pathErr, &extractedErr)) + require.NotNil(t, extractedErr) + require.Equal(t, "grafana/folder-1", extractedErr.Path) + require.Equal(t, underlyingErr, extractedErr.Err) + }) + + t.Run("errors.As returns false for non-PathCreationError", func(t *testing.T) { + regularErr := fmt.Errorf("regular error") + + var extractedErr *resources.PathCreationError + require.False(t, errors.As(regularErr, &extractedErr)) + require.Nil(t, extractedErr) + }) +} From e0ad4eb7ed5030e75970da5c15e1299491a8c023 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Mon, 12 Jan 2026 10:17:02 +0100 Subject: [PATCH 18/28] Chore: Remove core actions barrel file (#98149) * refactor(frontend): update core/actions imports to avoid barrel file * chore(frontend): delete app/core/actions barrel file * refactor(frontend): replace more barrel file imports * refactor(frontend): replace more core/actions imports * rerun ci --- eslint-suppressions.json | 5 ----- public/app/api/clients/collections/v1alpha1/index.ts | 2 +- public/app/api/clients/playlist/v0alpha1/index.ts | 2 +- public/app/api/clients/provisioning/v0alpha1/index.ts | 2 +- public/app/api/utils.ts | 2 +- public/app/core/actions/index.ts | 4 ---- .../core/components/AppNotifications/AppNotificationList.tsx | 3 +-- public/app/core/copy/appNotification.ts | 2 +- public/app/core/utils/richHistory.ts | 2 +- public/app/core/utils/shortLinks.ts | 2 +- public/app/features/dashboard-scene/pages/utils.ts | 2 +- .../features/dashboard-scene/scene/AlertStatesDataLayer.ts | 2 +- .../app/features/dashboard-scene/scene/export/exporters.ts | 2 +- .../dashboard-scene/sharing/ExportButton/ExportAsCode.tsx | 2 +- .../features/dashboard-scene/sharing/ShareSnapshotTab.tsx | 2 +- public/app/features/dashboard/api/publicDashboardApi.ts | 2 +- public/app/features/dashboard/components/DashNav/DashNav.tsx | 3 +-- .../dashboard/components/PanelEditor/PanelEditor.tsx | 2 +- .../app/features/dashboard/containers/DashboardPage.test.tsx | 2 +- public/app/features/dashboard/containers/DashboardPage.tsx | 2 +- .../DashboardLibrary/utils/communityDashboardHelpers.ts | 2 +- public/app/features/dashboard/state/actions.ts | 2 +- public/app/features/dashboard/state/initDashboard.ts | 2 +- public/app/features/datasources/state/actions.ts | 2 +- public/app/features/explore/RichHistory/RichHistoryCard.tsx | 2 +- .../features/explore/RichHistory/RichHistorySettingsTab.tsx | 2 +- public/app/features/explore/state/correlations.ts | 2 +- public/app/features/explore/state/query.ts | 2 +- public/app/features/manage-dashboards/state/actions.ts | 2 +- public/app/features/org/state/actions.test.ts | 2 +- public/app/features/org/state/actions.ts | 2 +- public/app/features/teams/hooks.ts | 2 +- public/app/features/theme-playground/ThemePlayground.tsx | 2 +- public/app/features/variables/interval/actions.test.ts | 2 +- public/app/features/variables/state/actions.ts | 2 +- 35 files changed, 33 insertions(+), 44 deletions(-) delete mode 100644 public/app/core/actions/index.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 6d5cca4f36c..c92d2939837 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1021,11 +1021,6 @@ "count": 2 } }, - "public/app/core/actions/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 4 - } - }, "public/app/core/components/AccessControl/PermissionList.tsx": { "no-restricted-syntax": { "count": 1 diff --git a/public/app/api/clients/collections/v1alpha1/index.ts b/public/app/api/clients/collections/v1alpha1/index.ts index cd2102f6b29..c23fee00241 100644 --- a/public/app/api/clients/collections/v1alpha1/index.ts +++ b/public/app/api/clients/collections/v1alpha1/index.ts @@ -1,7 +1,7 @@ import { generatedAPI } from '@grafana/api-clients/rtkq/collections/v1alpha1'; import { t } from '@grafana/i18n'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification, createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; export const collectionsAPIv1alpha1 = generatedAPI.enhanceEndpoints({ endpoints: { diff --git a/public/app/api/clients/playlist/v0alpha1/index.ts b/public/app/api/clients/playlist/v0alpha1/index.ts index 8f264203f8d..67a5973190e 100644 --- a/public/app/api/clients/playlist/v0alpha1/index.ts +++ b/public/app/api/clients/playlist/v0alpha1/index.ts @@ -1,8 +1,8 @@ import { generatedAPI, type Playlist, type PlaylistSpec } from '@grafana/api-clients/rtkq/playlist/v0alpha1'; import { getBackendSrv } from '@grafana/runtime'; -import { notifyApp } from '../../../../core/actions'; import { createSuccessNotification } from '../../../../core/copy/appNotification'; +import { notifyApp } from '../../../../core/reducers/appNotification'; import { contextSrv } from '../../../../core/services/context_srv'; import { handleError } from '../../../utils'; diff --git a/public/app/api/clients/provisioning/v0alpha1/index.ts b/public/app/api/clients/provisioning/v0alpha1/index.ts index 7f41904cf94..1a6f26076fe 100644 --- a/public/app/api/clients/provisioning/v0alpha1/index.ts +++ b/public/app/api/clients/provisioning/v0alpha1/index.ts @@ -12,8 +12,8 @@ import { isFetchError } from '@grafana/runtime'; import { clearFolders } from 'app/features/browse-dashboards/state/slice'; import { getState } from 'app/store/store'; -import { notifyApp } from '../../../../core/actions'; import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification'; +import { notifyApp } from '../../../../core/reducers/appNotification'; import { PAGE_SIZE } from '../../../../features/browse-dashboards/api/services'; import { refetchChildren } from '../../../../features/browse-dashboards/state/actions'; import { handleError } from '../../../utils'; diff --git a/public/app/api/utils.ts b/public/app/api/utils.ts index 3866bbe977d..9efa6940650 100644 --- a/public/app/api/utils.ts +++ b/public/app/api/utils.ts @@ -1,8 +1,8 @@ import { normalizeError } from '@grafana/api-clients'; import { ThunkDispatch } from 'app/types/store'; -import { notifyApp } from '../core/actions'; import { createErrorNotification } from '../core/copy/appNotification'; +import { notifyApp } from '../core/reducers/appNotification'; /** * Handle an error from a k8s API call diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts deleted file mode 100644 index d73c489b33e..00000000000 --- a/public/app/core/actions/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { hideAppNotification, notifyApp } from '../reducers/appNotification'; -import { updateNavIndex, updateConfigurationSubtitle } from '../reducers/navModel'; - -export { updateNavIndex, updateConfigurationSubtitle, notifyApp, hideAppNotification }; diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx index 8d5a1d3161e..e194ada32ed 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -4,10 +4,9 @@ import { useLocation } from 'react-router-dom'; import { AlertErrorPayload, AlertPayload, AppEvents, GrafanaTheme2 } from '@grafana/data'; import { useStyles2, Stack } from '@grafana/ui'; -import { notifyApp, hideAppNotification } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; import { useGrafana } from 'app/core/context/GrafanaContext'; -import { selectVisible } from 'app/core/reducers/appNotification'; +import { hideAppNotification, notifyApp, selectVisible } from 'app/core/reducers/appNotification'; import { useSelector, useDispatch } from 'app/types/store'; import { diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts index 3f1dd8f484f..8dc671d303f 100644 --- a/public/app/core/copy/appNotification.ts +++ b/public/app/core/copy/appNotification.ts @@ -6,7 +6,7 @@ import { dispatch as storeDispatch } from 'app/store/store'; import { AppNotificationSeverity, AppNotification } from 'app/types/appNotifications'; import { useDispatch } from 'app/types/store'; -import { notifyApp } from '../actions'; +import { notifyApp } from '../reducers/appNotification'; const defaultSuccessNotification = { title: '', diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index e94929939f4..f8944dcd7d6 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -10,7 +10,6 @@ import { } from '@grafana/data'; import { t } from '@grafana/i18n'; import { getDataSourceSrv } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createWarningNotification } from 'app/core/copy/appNotification'; import { dispatch } from 'app/store/store'; import { RichHistoryQuery } from 'app/types/explore'; @@ -23,6 +22,7 @@ import { } from '../history/RichHistoryStorage'; import { createRetentionPeriodBoundary } from '../history/richHistoryLocalStorageUtils'; import { getLocalRichHistoryStorage, getRichHistoryStorage } from '../history/richHistoryStorageProvider'; +import { notifyApp } from '../reducers/appNotification'; import { contextSrv } from '../services/context_srv'; import { diff --git a/public/app/core/utils/shortLinks.ts b/public/app/core/utils/shortLinks.ts index 208f2ae0fcc..e6e260e6ccc 100644 --- a/public/app/core/utils/shortLinks.ts +++ b/public/app/core/utils/shortLinks.ts @@ -5,7 +5,6 @@ import { t } from '@grafana/i18n'; import { getBackendSrv, config, locationService } from '@grafana/runtime'; import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; import { shortURLAPIv1beta1 } from 'app/api/clients/shorturl/v1beta1'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; @@ -14,6 +13,7 @@ import { dispatch } from 'app/store/store'; import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen'; import { extractErrorMessage } from '../../api/utils'; import { ShareLinkConfiguration } from '../../features/dashboard-scene/sharing/ShareButton/utils'; +import { notifyApp } from '../reducers/appNotification'; import { copyStringToClipboard } from './explore'; diff --git a/public/app/features/dashboard-scene/pages/utils.ts b/public/app/features/dashboard-scene/pages/utils.ts index 6e786123a87..1c6e36e09f8 100644 --- a/public/app/features/dashboard-scene/pages/utils.ts +++ b/public/app/features/dashboard-scene/pages/utils.ts @@ -1,7 +1,7 @@ import { UrlQueryMap, getTimeZone, getDefaultTimeRange, dateMath } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { getFolderByUidFacade } from 'app/api/clients/folder/v1beta1/hooks'; -import { updateNavIndex } from 'app/core/actions'; +import { updateNavIndex } from 'app/core/reducers/navModel'; import { buildNavModel } from 'app/features/folders/state/navModel'; import { store } from 'app/store/store'; diff --git a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts index fc7039bb075..7d11d1518be 100644 --- a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts +++ b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts @@ -9,8 +9,8 @@ import { sceneGraph, SceneTimeRangeLike, } from '@grafana/scenes'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { contextSrv } from 'app/core/services/context_srv'; import { getMessageFromError } from 'app/core/utils/errors'; import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; diff --git a/public/app/features/dashboard-scene/scene/export/exporters.ts b/public/app/features/dashboard-scene/scene/export/exporters.ts index 96492361e8d..0878ee35307 100644 --- a/public/app/features/dashboard-scene/scene/export/exporters.ts +++ b/public/app/features/dashboard-scene/scene/export/exporters.ts @@ -12,9 +12,9 @@ import { LibraryPanelRef, LibraryPanelKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; -import { notifyApp } from 'app/core/actions'; import config from 'app/core/config'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { buildPanelKind } from 'app/features/dashboard/api/ResponseTransformers'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel, GridPos } from 'app/features/dashboard/state/PanelModel'; diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx index 9ffad2ef066..5673dff8068 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportAsCode.tsx @@ -9,8 +9,8 @@ import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { SceneComponentProps } from '@grafana/scenes'; import { Button, ClipboardButton, CodeEditor, Label, Spinner, Stack, Switch, useStyles2 } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { dispatch } from 'app/store/store'; import { ShareExportTab } from '../ShareExportTab'; diff --git a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx index 9c4abbc3a6d..0de2fbed8cf 100644 --- a/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareSnapshotTab.tsx @@ -6,8 +6,8 @@ import { Trans, t } from '@grafana/i18n'; import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes'; import { Dashboard } from '@grafana/schema'; import { Button, ClipboardButton, Field, Input, Modal, RadioButtonGroup, Stack } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; import { getDashboardSnapshotSrv, SnapshotSharingOptions } from 'app/features/dashboard/services/SnapshotSrv'; import { dispatch } from 'app/store/store'; diff --git a/public/app/features/dashboard/api/publicDashboardApi.ts b/public/app/features/dashboard/api/publicDashboardApi.ts index 59e9f81fef0..e6c9e77a852 100644 --- a/public/app/features/dashboard/api/publicDashboardApi.ts +++ b/public/app/features/dashboard/api/publicDashboardApi.ts @@ -3,8 +3,8 @@ import { createApi } from '@reduxjs/toolkit/query/react'; import { createBaseQuery } from '@grafana/api-clients/rtkq'; import { t } from '@grafana/i18n'; import { config, FetchError, isFetchError } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { PublicDashboard, PublicDashboardSettings, diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index be0d9cfcaea..394471cd770 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -14,7 +14,6 @@ import { ToolbarButtonRow, ConfirmModal, } from '@grafana/ui'; -import { updateNavIndex } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; @@ -22,7 +21,7 @@ import config from 'app/core/config'; import { useAppNotification } from 'app/core/copy/appNotification'; import { useBusEvent } from 'app/core/hooks/useBusEvent'; import { ID_PREFIX, setStarred } from 'app/core/reducers/navBarTree'; -import { removeNavIndex } from 'app/core/reducers/navModel'; +import { removeNavIndex, updateNavIndex } from 'app/core/reducers/navModel'; import AddPanelButton from 'app/features/dashboard/components/AddPanelButton/AddPanelButton'; import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 7363aff85f8..93119592712 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -24,6 +24,7 @@ import { appEvents } from 'app/core/app_events'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { Page } from 'app/core/components/Page/Page'; import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems'; import { SaveLibraryPanelModal } from 'app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal'; import { PanelModelWithLibraryPanel } from 'app/features/library-panels/types'; @@ -32,7 +33,6 @@ import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { PanelOptionsChangedEvent, ShowModalReactEvent } from 'app/types/events'; import { StoreState } from 'app/types/store'; -import { notifyApp } from '../../../../core/actions'; import { UnlinkModal } from '../../../dashboard-scene/scene/UnlinkModal'; import { isPanelModelLibraryPanel } from '../../../library-panels/guard'; import { getVariablesByKey } from '../../../variables/state/selectors'; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index b1ae5811f24..abd41458b64 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -8,10 +8,10 @@ import { createTheme } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, setDataSourceSrv } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; -import { notifyApp } from 'app/core/actions'; import { AppChrome } from 'app/core/components/AppChrome/AppChrome'; import { getRouteComponentProps } from 'app/core/navigation/mocks/routeProps'; import { RouteDescriptor } from 'app/core/navigation/types'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { HOME_NAV_ID } from 'app/core/reducers/navModel'; import { DashboardInitPhase, DashboardMeta, DashboardRoutes } from 'app/types/dashboard'; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 8733fe0311d..4b0b9ed1d3d 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -6,13 +6,13 @@ import { NavModel, NavModelItem, TimeRange, PageLayoutType, locationUtil, Grafan import { selectors } from '@grafana/e2e-selectors'; import { locationService } from '@grafana/runtime'; import { Themeable2, withTheme2 } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { ScrollRefElement } from 'app/core/components/NativeScrollbar'; import { Page } from 'app/core/components/Page/Page'; import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { getKioskMode } from 'app/core/navigation/kiosk'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { ID_PREFIX } from 'app/core/reducers/navBarTree'; import { getNavModel } from 'app/core/selectors/navModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts index 05c20ee1d9f..40ed686f350 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts @@ -1,8 +1,8 @@ import { PanelModel } from '@grafana/data'; import { t } from '@grafana/i18n'; import { locationService } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers'; import { DashboardJson } from 'app/features/manage-dashboards/types'; import { dispatch } from 'app/types/store'; diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 0395c6e9201..3d882c4d4c1 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -1,8 +1,8 @@ import { TimeZone } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; import { WeekStart } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { removeAllPanels } from 'app/features/panel/state/reducers'; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 86d64138dee..c2a02bca86b 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -1,9 +1,9 @@ import { DataQuery, locationUtil, setWeekStart, DashboardLoadedEvent } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config, isFetchError, locationService } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { backendSrv } from 'app/core/services/backend_srv'; import { KeybindingSrv } from 'app/core/services/keybindingSrv'; import store from 'app/core/store'; diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 961c78c8208..fa9db9f7abe 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -17,8 +17,8 @@ import { isFetchError, locationService, } from '@grafana/runtime'; -import { updateNavIndex } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; +import { updateNavIndex } from 'app/core/reducers/navModel'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { contextSrv } from 'app/core/services/context_srv'; import { DatasourceAPIVersions } from 'app/features/apiserver/client'; diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx index 4c9353254e0..421e4f35294 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -8,8 +8,8 @@ import { Trans, t } from '@grafana/i18n'; import { config, reportInteraction, getAppEvents } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { TextArea, Button, IconButton, useStyles2 } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { copyStringToClipboard } from 'app/core/utils/explore'; import { createUrlFromRichHistory, createQueryText } from 'app/core/utils/richHistory'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; diff --git a/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx b/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx index 05942398898..696e5d204ae 100644 --- a/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx @@ -4,9 +4,9 @@ import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { getAppEvents } from '@grafana/runtime'; import { useStyles2, Select, Button, Field, InlineField, InlineSwitch, Alert } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; import { MAX_HISTORY_ITEMS } from 'app/core/history/RichHistoryLocalStorage'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { dispatch } from 'app/store/store'; import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider'; diff --git a/public/app/features/explore/state/correlations.ts b/public/app/features/explore/state/correlations.ts index cc99f6388b7..5adf7868d0b 100644 --- a/public/app/features/explore/state/correlations.ts +++ b/public/app/features/explore/state/correlations.ts @@ -2,8 +2,8 @@ import { Observable } from 'rxjs'; import { DataLinkTransformationConfig } from '@grafana/data'; import { CorrelationData, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { CreateCorrelationParams } from 'app/features/correlations/types'; import { getCorrelationsBySourceUIDs, createCorrelation, generateDefaultLabel } from 'app/features/correlations/utils'; import { store } from 'app/store/store'; diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 39ad4ea5e73..2f921078135 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -22,6 +22,7 @@ import { import { combinePanelData } from '@grafana/o11y-ds-frontend'; import { config, getDataSourceSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { buildQueryTransaction, ensureQueries, @@ -48,7 +49,6 @@ import { } from 'app/types/explore'; import { createAsyncThunk, StoreState, ThunkDispatch, ThunkResult } from 'app/types/store'; -import { notifyApp } from '../../../core/actions'; import { createErrorNotification } from '../../../core/copy/appNotification'; import { runRequest } from '../../query/state/runRequest'; import { decorateData, decorateWithLogsResult } from '../utils/decorators'; diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index f897c23cfb9..049a3b920cf 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -6,8 +6,8 @@ import { PanelQueryKind, AnnotationQueryKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { browseDashboardsAPI, ImportInputs } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { ThunkResult } from 'app/types/store'; diff --git a/public/app/features/org/state/actions.test.ts b/public/app/features/org/state/actions.test.ts index b624430697a..f3429d8ea0b 100644 --- a/public/app/features/org/state/actions.test.ts +++ b/public/app/features/org/state/actions.test.ts @@ -2,7 +2,7 @@ import { thunkTester } from 'test/core/thunk/thunkTester'; import { OrgRole } from '@grafana/data'; import { BackendSrv } from '@grafana/runtime'; -import { updateConfigurationSubtitle } from 'app/core/actions'; +import { updateConfigurationSubtitle } from 'app/core/reducers/navModel'; import { updateOrganization, setUserOrganization, getUserOrganizations } from './actions'; diff --git a/public/app/features/org/state/actions.ts b/public/app/features/org/state/actions.ts index 1d580c5b3ae..672c2b2bdac 100644 --- a/public/app/features/org/state/actions.ts +++ b/public/app/features/org/state/actions.ts @@ -1,5 +1,5 @@ import { getBackendSrv } from '@grafana/runtime'; -import { updateConfigurationSubtitle } from 'app/core/actions'; +import { updateConfigurationSubtitle } from 'app/core/reducers/navModel'; import { ThunkResult } from 'app/types/store'; import { UserOrg } from 'app/types/user'; diff --git a/public/app/features/teams/hooks.ts b/public/app/features/teams/hooks.ts index 2c563ef3d2f..e0af32b6f3f 100644 --- a/public/app/features/teams/hooks.ts +++ b/public/app/features/teams/hooks.ts @@ -12,8 +12,8 @@ import { useUpdateTeamMutation, UpdateTeamCommand, } from 'app/api/clients/legacy'; -import { updateNavIndex } from 'app/core/actions'; import { addFilteredDisplayName } from 'app/core/components/RolePicker/utils'; +import { updateNavIndex } from 'app/core/reducers/navModel'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction, Role } from 'app/types/accessControl'; import { useDispatch } from 'app/types/store'; diff --git a/public/app/features/theme-playground/ThemePlayground.tsx b/public/app/features/theme-playground/ThemePlayground.tsx index 77df7b5db87..2a8da67b340 100644 --- a/public/app/features/theme-playground/ThemePlayground.tsx +++ b/public/app/features/theme-playground/ThemePlayground.tsx @@ -9,8 +9,8 @@ import { CodeEditor, Combobox, Field, Stack, useStyles2 } from '@grafana/ui'; import { ThemeDemo } from '@grafana/ui/internal'; import { Page } from 'app/core/components/Page/Page'; -import { notifyApp } from '../../core/actions'; import { createErrorNotification } from '../../core/copy/appNotification'; +import { notifyApp } from '../../core/reducers/appNotification'; import { HOME_NAV_ID } from '../../core/reducers/navModel'; import { getNavModel } from '../../core/selectors/navModel'; import { ThemeProvider } from '../../core/utils/ConfigProvider'; diff --git a/public/app/features/variables/interval/actions.test.ts b/public/app/features/variables/interval/actions.test.ts index a16d23b570a..f78ed1ecf41 100644 --- a/public/app/features/variables/interval/actions.test.ts +++ b/public/app/features/variables/interval/actions.test.ts @@ -1,8 +1,8 @@ import { dateTime } from '@grafana/data'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; -import { notifyApp } from '../../../core/actions'; import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv'; import { TemplateSrv } from '../../templating/template_srv'; import { variableAdapters } from '../adapters'; diff --git a/public/app/features/variables/state/actions.ts b/public/app/features/variables/state/actions.ts index a92193a0867..95f73bd610e 100644 --- a/public/app/features/variables/state/actions.ts +++ b/public/app/features/variables/state/actions.ts @@ -21,7 +21,7 @@ import { VariableWithOptions, } from '@grafana/data'; import { config, locationService, logWarning } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { contextSrv } from 'app/core/services/context_srv'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; From 86a3aae20419092be99d99caa0d7428eeab11887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Tolnai?= Date: Mon, 12 Jan 2026 10:30:38 +0100 Subject: [PATCH 19/28] InteractiveTable: Extend sort options with `disableSortRemove` and `sortDescFirst` (#115352) * add disableSortRemove option * add sortDescFirst to Column * pass sortDescFirst only if it is set --- .../src/components/InteractiveTable/InteractiveTable.tsx | 6 ++++++ .../grafana-ui/src/components/InteractiveTable/types.ts | 4 ++++ .../grafana-ui/src/components/InteractiveTable/utils.ts | 1 + 3 files changed, 11 insertions(+) diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx index d5a25e2e480..1b06c7daa87 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx @@ -153,6 +153,10 @@ interface BaseProps { * Optional way to set how the table is sorted from the beginning. Must be memoized. */ initialSortBy?: Array>; + /** + * Disable the ability to remove sorting on columns (none -> asc -> desc -> asc) + */ + disableSortRemove?: boolean; } interface WithExpandableRow extends BaseProps { @@ -191,6 +195,7 @@ export function InteractiveTable({ showExpandAll = false, fetchData, initialSortBy = [], + disableSortRemove, }: Props) { const styles = useStyles2(getStyles); const tableColumns = useMemo(() => { @@ -222,6 +227,7 @@ export function InteractiveTable({ disableMultiSort: true, // If fetchData is provided, we disable client-side sorting manualSortBy: Boolean(fetchData), + disableSortRemove, getRowId, initialState: { hiddenColumns: [ diff --git a/packages/grafana-ui/src/components/InteractiveTable/types.ts b/packages/grafana-ui/src/components/InteractiveTable/types.ts index 47263d4730e..5b84f4c568b 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/types.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/types.ts @@ -26,4 +26,8 @@ export interface Column { * If the provided function returns `false` the column will be hidden. */ visible?: (data: TableData[]) => boolean; + /** + * Determines starting sort direction when the column header is clicked. + */ + sortDescFirst?: boolean; } diff --git a/packages/grafana-ui/src/components/InteractiveTable/utils.ts b/packages/grafana-ui/src/components/InteractiveTable/utils.ts index 2b664b16f6d..050419fe1d1 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/utils.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/utils.ts @@ -33,6 +33,7 @@ export function getColumns( disableSortBy: !Boolean(column.sortType), width: column.disableGrow ? 0 : undefined, visible: column.visible, + ...(column.sortDescFirst !== undefined && { sortDescFirst: column.sortDescFirst }), ...(column.cell && { Cell: column.cell }), })), ]; From e4796b1de3589bc734960666335948af4b5f5631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Mon, 12 Jan 2026 10:31:25 +0100 Subject: [PATCH 20/28] Provisioning: Add fieldSelector for Repository by spec.connection.name (#116063) * Provisioning: Add fieldSelector for Repository by spec.connection.name This change adds the ability to filter repositories by their connection name using Kubernetes field selectors, enabling queries like: kubectl get repositories --field-selector spec.connection.name=my-connection Implementation: - Add RepositoryGetAttrs and RepositoryToSelectableFields functions - Register field label conversion for spec.connection.name in InstallSchema - Extend generic storage to support custom selectable fields via NewRegistryStoreWithSelectableFields - Add unit tests for repository field functions - Add integration tests for field selector functionality * Simplify predicateFunc handling with custom attrFunc Remove unnecessary custom predicateFunc wrapper when using a custom GetAttrs function. When attrFunc is provided via StoreOptions, passing nil for predicateFunc allows the default behavior to create the appropriate SelectionPredicate automatically. Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude Sonnet 4.5 --- pkg/apiserver/registry/generic/storage.go | 33 +++- pkg/registry/apis/provisioning/register.go | 27 ++- .../apis/provisioning/repository_fields.go | 44 +++++ .../provisioning/repository_fields_test.go | 184 ++++++++++++++++++ .../apis/provisioning/connection_test.go | 172 ++++++++++++++++ 5 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 pkg/registry/apis/provisioning/repository_fields.go create mode 100644 pkg/registry/apis/provisioning/repository_fields_test.go diff --git a/pkg/apiserver/registry/generic/storage.go b/pkg/apiserver/registry/generic/storage.go index 98e2f1fe9df..adbe54f5a1f 100644 --- a/pkg/apiserver/registry/generic/storage.go +++ b/pkg/apiserver/registry/generic/storage.go @@ -1,26 +1,55 @@ package generic import ( + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/storage" "github.com/grafana/grafana/pkg/apimachinery/utils" ) +// SelectableFieldsOptions allows customizing field selector behavior for a resource. +type SelectableFieldsOptions struct { + // GetAttrs returns labels and fields for the object. + // If nil, the default GetAttrs is used which only exposes metadata.name. + GetAttrs func(obj runtime.Object) (labels.Set, fields.Set, error) +} + func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter) (*registry.Store, error) { + return NewRegistryStoreWithSelectableFields(scheme, resourceInfo, optsGetter, SelectableFieldsOptions{}) +} + +// NewRegistryStoreWithSelectableFields creates a registry store with custom selectable fields support. +// Use this when you need to filter resources by custom fields like spec.connection.name. +func NewRegistryStoreWithSelectableFields(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter, fieldOpts SelectableFieldsOptions) (*registry.Store, error) { gv := resourceInfo.GroupVersion() gv.Version = runtime.APIVersionInternal strategy := NewStrategy(scheme, gv) if resourceInfo.IsClusterScoped() { strategy = strategy.WithClusterScope() } + + // Use custom GetAttrs if provided, otherwise use default + var attrFunc storage.AttrFunc + var predicateFunc func(label labels.Selector, field fields.Selector) storage.SelectionPredicate + if fieldOpts.GetAttrs != nil { + attrFunc = fieldOpts.GetAttrs + // Pass nil predicateFunc to use default behavior with custom attrFunc + predicateFunc = nil + } else { + attrFunc = GetAttrs + predicateFunc = Matcher + } + store := ®istry.Store{ NewFunc: resourceInfo.NewFunc, NewListFunc: resourceInfo.NewListFunc, KeyRootFunc: KeyRootFunc(resourceInfo.GroupResource()), KeyFunc: NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: Matcher, + PredicateFunc: predicateFunc, DefaultQualifiedResource: resourceInfo.GroupResource(), SingularQualifiedResource: resourceInfo.SingularGroupResource(), TableConvertor: resourceInfo.TableConverter(), @@ -28,7 +57,7 @@ func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, o UpdateStrategy: strategy, DeleteStrategy: strategy, } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: attrFunc} if err := store.CompleteWithOptions(options); err != nil { return nil, err } diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 026797eb474..e54a8c2fc28 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -559,6 +559,22 @@ func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error { return err } + // Register custom field label conversion for Repository to enable field selectors like spec.connection.name + err = scheme.AddFieldLabelConversionFunc( + provisioning.SchemeGroupVersion.WithKind("Repository"), + func(label, value string) (string, string, error) { + switch label { + case "metadata.name", "metadata.namespace", "spec.connection.name": + return label, value, nil + default: + return "", "", fmt.Errorf("field label not supported for Repository: %s", label) + } + }, + ) + if err != nil { + return err + } + metav1.AddToGroupVersion(scheme, provisioning.SchemeGroupVersion) // Only 1 version (for now?) return scheme.SetVersionPriority(provisioning.SchemeGroupVersion) @@ -569,10 +585,19 @@ func (b *APIBuilder) AllowedV0Alpha1Resources() []string { } func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { - repositoryStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, provisioning.RepositoryResourceInfo, opts.OptsGetter) + // Create repository storage with custom field selectors (e.g., spec.connection.name) + repositoryStorage, err := grafanaregistry.NewRegistryStoreWithSelectableFields( + opts.Scheme, + provisioning.RepositoryResourceInfo, + opts.OptsGetter, + grafanaregistry.SelectableFieldsOptions{ + GetAttrs: RepositoryGetAttrs, + }, + ) if err != nil { return fmt.Errorf("failed to create repository storage: %w", err) } + repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage) b.store = repositoryStorage diff --git a/pkg/registry/apis/provisioning/repository_fields.go b/pkg/registry/apis/provisioning/repository_fields.go new file mode 100644 index 00000000000..0849c558e2f --- /dev/null +++ b/pkg/registry/apis/provisioning/repository_fields.go @@ -0,0 +1,44 @@ +package provisioning + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +// RepositoryToSelectableFields returns a field set that can be used for field selectors. +// This includes standard metadata fields plus custom fields like spec.connection.name. +func RepositoryToSelectableFields(obj *provisioning.Repository) fields.Set { + objectMetaFields := generic.ObjectMetaFieldsSet(&obj.ObjectMeta, true) + + // Add custom selectable fields + specificFields := fields.Set{ + "spec.connection.name": getConnectionName(obj), + } + + return generic.MergeFieldsSets(objectMetaFields, specificFields) +} + +// getConnectionName safely extracts the connection name from a Repository. +// Returns empty string if no connection is configured. +func getConnectionName(obj *provisioning.Repository) string { + if obj == nil || obj.Spec.Connection == nil { + return "" + } + return obj.Spec.Connection.Name +} + +// RepositoryGetAttrs returns labels and fields of a Repository object. +// This is used by the storage layer for filtering. +func RepositoryGetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + repo, ok := obj.(*provisioning.Repository) + if !ok { + return nil, nil, fmt.Errorf("given object is not a Repository") + } + return labels.Set(repo.Labels), RepositoryToSelectableFields(repo), nil +} diff --git a/pkg/registry/apis/provisioning/repository_fields_test.go b/pkg/registry/apis/provisioning/repository_fields_test.go new file mode 100644 index 00000000000..89a2271477c --- /dev/null +++ b/pkg/registry/apis/provisioning/repository_fields_test.go @@ -0,0 +1,184 @@ +package provisioning + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +func TestGetConnectionName(t *testing.T) { + tests := []struct { + name string + repo *provisioning.Repository + expected string + }{ + { + name: "nil repository returns empty string", + repo: nil, + expected: "", + }, + { + name: "repository without connection returns empty string", + repo: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + }, + }, + expected: "", + }, + { + name: "repository with connection returns connection name", + repo: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + Connection: &provisioning.ConnectionInfo{ + Name: "my-connection", + }, + }, + }, + expected: "my-connection", + }, + { + name: "repository with empty connection name returns empty string", + repo: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + Connection: &provisioning.ConnectionInfo{ + Name: "", + }, + }, + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getConnectionName(tt.repo) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestRepositoryToSelectableFields(t *testing.T) { + tests := []struct { + name string + repo *provisioning.Repository + expectedFields map[string]string + }{ + { + name: "includes metadata.name and metadata.namespace", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Title: "Test Repository", + }, + }, + expectedFields: map[string]string{ + "metadata.name": "test-repo", + "metadata.namespace": "default", + "spec.connection.name": "", + }, + }, + { + name: "includes spec.connection.name when set", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "repo-with-connection", + Namespace: "org-1", + }, + Spec: provisioning.RepositorySpec{ + Title: "Repo With Connection", + Connection: &provisioning.ConnectionInfo{ + Name: "github-connection", + }, + }, + }, + expectedFields: map[string]string{ + "metadata.name": "repo-with-connection", + "metadata.namespace": "org-1", + "spec.connection.name": "github-connection", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := RepositoryToSelectableFields(tt.repo) + + for key, expectedValue := range tt.expectedFields { + actualValue, exists := fields[key] + assert.True(t, exists, "field %s should exist", key) + assert.Equal(t, expectedValue, actualValue, "field %s should have correct value", key) + } + }) + } +} + +func TestRepositoryGetAttrs(t *testing.T) { + t.Run("returns error for non-Repository object", func(t *testing.T) { + // Pass a different runtime.Object type instead of a Repository + connection := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "not-a-repository", + }, + } + _, _, err := RepositoryGetAttrs(connection) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a Repository") + }) + + t.Run("returns labels and fields for valid Repository", func(t *testing.T) { + repo := &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + Labels: map[string]string{ + "app": "grafana", + "env": "test", + }, + }, + Spec: provisioning.RepositorySpec{ + Title: "Test Repository", + Connection: &provisioning.ConnectionInfo{ + Name: "my-connection", + }, + }, + } + + labels, fields, err := RepositoryGetAttrs(repo) + require.NoError(t, err) + + // Check labels + assert.Equal(t, "grafana", labels["app"]) + assert.Equal(t, "test", labels["env"]) + + // Check fields + assert.Equal(t, "test-repo", fields["metadata.name"]) + assert.Equal(t, "default", fields["metadata.namespace"]) + assert.Equal(t, "my-connection", fields["spec.connection.name"]) + }) + + t.Run("returns empty connection name when not set", func(t *testing.T) { + repo := &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Title: "Test Repository", + }, + } + + _, fields, err := RepositoryGetAttrs(repo) + require.NoError(t, err) + assert.Equal(t, "", fields["spec.connection.name"]) + }) +} diff --git a/pkg/tests/apis/provisioning/connection_test.go b/pkg/tests/apis/provisioning/connection_test.go index 02c5436badb..99f32dffa93 100644 --- a/pkg/tests/apis/provisioning/connection_test.go +++ b/pkg/tests/apis/provisioning/connection_test.go @@ -559,3 +559,175 @@ func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) { assert.True(t, final.Status.Health.Healthy, "connection should remain healthy") }) } + +func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + + // Create a connection first + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "test-conn-for-field-selector", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "789012", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": "test-private-key", + }, + }, + }} + + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.NoError(t, err, "failed to create connection") + + t.Cleanup(func() { + // Clean up repositories first + _ = helper.Repositories.Resource.Delete(ctx, "repo-with-connection", metav1.DeleteOptions{}) + _ = helper.Repositories.Resource.Delete(ctx, "repo-without-connection", metav1.DeleteOptions{}) + _ = helper.Repositories.Resource.Delete(ctx, "repo-with-different-connection", metav1.DeleteOptions{}) + // Then clean up the connection + _ = helper.Connections.Resource.Delete(ctx, "test-conn-for-field-selector", metav1.DeleteOptions{}) + }) + + // Create a repository WITH the connection + repoWithConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": map[string]any{ + "name": "repo-with-connection", + "namespace": "default", + }, + "spec": map[string]any{ + "title": "Repo With Connection", + "type": "local", + "sync": map[string]any{ + "enabled": false, + "target": "folder", + }, + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + "connection": map[string]any{ + "name": "test-conn-for-field-selector", + }, + }, + }} + + _, err = helper.Repositories.Resource.Create(ctx, repoWithConnection, createOptions) + require.NoError(t, err, "failed to create repository with connection") + + // Create a repository WITHOUT the connection + repoWithoutConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": map[string]any{ + "name": "repo-without-connection", + "namespace": "default", + }, + "spec": map[string]any{ + "title": "Repo Without Connection", + "type": "local", + "sync": map[string]any{ + "enabled": false, + "target": "folder", + }, + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + }, + }} + + _, err = helper.Repositories.Resource.Create(ctx, repoWithoutConnection, createOptions) + require.NoError(t, err, "failed to create repository without connection") + + // Create a repository with a DIFFERENT connection name (non-existent) + repoWithDifferentConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": map[string]any{ + "name": "repo-with-different-connection", + "namespace": "default", + }, + "spec": map[string]any{ + "title": "Repo With Different Connection", + "type": "local", + "sync": map[string]any{ + "enabled": false, + "target": "folder", + }, + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + "connection": map[string]any{ + "name": "some-other-connection", + }, + }, + }} + + _, err = helper.Repositories.Resource.Create(ctx, repoWithDifferentConnection, createOptions) + require.NoError(t, err, "failed to create repository with different connection") + + t.Run("filter repositories by spec.connection.name", func(t *testing.T) { + // List repositories with field selector for the specific connection + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "spec.connection.name=test-conn-for-field-selector", + }) + require.NoError(t, err, "failed to list repositories with field selector") + + // Should only return the repository with the matching connection + assert.Len(t, list.Items, 1, "should return exactly one repository") + assert.Equal(t, "repo-with-connection", list.Items[0].GetName(), "should return the correct repository") + }) + + t.Run("filter repositories by non-existent connection returns empty", func(t *testing.T) { + // List repositories with field selector for a non-existent connection + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "spec.connection.name=non-existent-connection", + }) + require.NoError(t, err, "failed to list repositories with field selector") + + // Should return empty list + assert.Len(t, list.Items, 0, "should return no repositories for non-existent connection") + }) + + t.Run("filter repositories by empty connection name", func(t *testing.T) { + // List repositories with field selector for empty connection (repos without connection) + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "spec.connection.name=", + }) + require.NoError(t, err, "failed to list repositories with empty connection field selector") + + // Should return the repository without a connection + assert.Len(t, list.Items, 1, "should return exactly one repository without connection") + assert.Equal(t, "repo-without-connection", list.Items[0].GetName(), "should return the repository without connection") + }) + + t.Run("list all repositories without field selector", func(t *testing.T) { + // List all repositories without field selector + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "failed to list all repositories") + + // Should return all three repositories + assert.Len(t, list.Items, 3, "should return all three repositories") + + names := make([]string, len(list.Items)) + for i, item := range list.Items { + names[i] = item.GetName() + } + assert.Contains(t, names, "repo-with-connection") + assert.Contains(t, names, "repo-without-connection") + assert.Contains(t, names, "repo-with-different-connection") + }) +} From a0e894c6d8858e8ae0a87bfd67c831b60255fedb Mon Sep 17 00:00:00 2001 From: james-rms Date: Mon, 12 Jan 2026 20:57:06 +1100 Subject: [PATCH 21/28] Documentation: Fix typo in plugin-sign.md heading (#115812) --- docs/sources/administration/plugin-management/plugin-sign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/administration/plugin-management/plugin-sign.md b/docs/sources/administration/plugin-management/plugin-sign.md index 7850996d0f6..54d65baacde 100644 --- a/docs/sources/administration/plugin-management/plugin-sign.md +++ b/docs/sources/administration/plugin-management/plugin-sign.md @@ -25,7 +25,7 @@ Plugin signature verification, also known as _signing_, is a security measure to Learn more at [plugin policies](https://grafana.com/legal/plugins/). -## How does verifiction work? +## How does verification work? At startup, Grafana verifies the signatures of every plugin in the plugin directory. From 586410d8b5511cde2efad8bca07cd1d9d670aca5 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Mon, 12 Jan 2026 11:12:40 +0100 Subject: [PATCH 22/28] Build: Fix running e2e tests for Cypress with Dagger (#116105) --- pkg/build/e2e/README.md | 20 ++++++++++++++++++++ pkg/build/e2e/main.go | 4 ++++ pkg/build/e2e/run.go | 4 ++-- pkg/build/e2e/service.go | 14 ++++++++------ 4 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 pkg/build/e2e/README.md diff --git a/pkg/build/e2e/README.md b/pkg/build/e2e/README.md new file mode 100644 index 00000000000..3edc6946727 --- /dev/null +++ b/pkg/build/e2e/README.md @@ -0,0 +1,20 @@ +## Build artifacts + +Put the resulting tar in your `grafana` OSS path: +```sh +go -C grafana run ./pkg/build/cmd artifacts -a targz:enterprise:linux/amd64 --alpine-base=alpine:3.22 --tag-format='{{ .version }}-{{ .buildID }}-{{ .arch }}' --grafana-dir="${PWD}/grafana" --enterprise-dir="${PWD}/grafana-enterprise" +``` + +Also build the e2e test runner: +```sh +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o ./e2e-runner ./e2e/ +``` + +And then `chmod +x ./e2e-runner`. + +## Running tests + +Reporting tests with Image Renderer: +```sh +go run ./pkg/build/e2e --suite=e2e/extensions/enterprise/smtp-suite --license=e2e/extensions/enterprise/license.jwt --image-renderer +``` diff --git a/pkg/build/e2e/main.go b/pkg/build/e2e/main.go index 976a7883bdc..7cd85ad2825 100644 --- a/pkg/build/e2e/main.go +++ b/pkg/build/e2e/main.go @@ -138,6 +138,10 @@ func run(ctx context.Context, cmd *cli.Command) error { } if code != 0 { + if stdout, _ := c.Stdout(ctx); len(stdout) > 0 { + log.Printf("e2e test suite stdout:\n%s", stdout) + } + return fmt.Errorf("e2e tests failed with exit code %d", code) } diff --git a/pkg/build/e2e/run.go b/pkg/build/e2e/run.go index e5d36d34b7b..c9bf85df3c8 100644 --- a/pkg/build/e2e/run.go +++ b/pkg/build/e2e/run.go @@ -8,10 +8,10 @@ import ( func RunSuite(d *dagger.Client, svc *dagger.Service, src *dagger.Directory, cache *dagger.CacheVolume, suite, runnerFlags string) *dagger.Container { command := fmt.Sprintf( - "./e2e-runner cypress --start-grafana=false --cypress-video"+ + "./e2e-runner cypress --browser=electron --start-grafana=false --cypress-video"+ " --grafana-base-url http://grafana:3001 --suite %s %s", suite, runnerFlags) - return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:13.1.0"), src), cache). + return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:14.3.2"), src), cache). WithWorkdir("/src"). WithServiceBinding("grafana", svc). WithExec([]string{"yarn", "install", "--immutable"}). diff --git a/pkg/build/e2e/service.go b/pkg/build/e2e/service.go index 31463f63783..f55bd765f3f 100644 --- a/pkg/build/e2e/service.go +++ b/pkg/build/e2e/service.go @@ -99,13 +99,15 @@ func GrafanaService(ctx context.Context, d *dagger.Client, opts GrafanaServiceOp } if opts.StartImageRenderer { - container = container.WithEnvVariable("START_IMAGE_RENDERER", "true"). - WithExec([]string{"apt-get", "update"}). - WithExec([]string{"apt-get", "install", "-y", "ca-certificates"}) + imageRendererSvc := d.Container().From("grafana/grafana-image-renderer:" + opts.ImageRendererVersion). + WithExposedPort(8081). + AsService() - if opts.ImageRendererVersion != "" { - container = container.WithEnvVariable("IMAGE_RENDERER_VERSION", opts.ImageRendererVersion) - } + container = container.WithServiceBinding("image-renderer", imageRendererSvc). + WithExec([]string{"apt-get", "update"}). + WithExec([]string{"apt-get", "install", "-y", "ca-certificates"}). + WithEnvVariable("GF_RENDERING_CALLBACK_URL", "http://grafana:3001/"). + WithEnvVariable("GF_RENDERING_SERVER_URL", "http://image-renderer:8081/render") } // We add all GF_ environment variables to allow for overriding Grafana configuration. From 5cb4c311dc65c9b42fcc0fd14d1183a097aeba46 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Mon, 12 Jan 2026 12:13:11 +0100 Subject: [PATCH 23/28] Chore: Eslint ignore webpack.config barrel files (#116115) chore(eslint): ignore decoupled plugins webpack configs barrel files --- eslint-suppressions.json | 5 ----- eslint.config.js | 2 ++ .../plugins/datasource/cloud-monitoring/webpack.config.ts | 1 - .../grafana-postgresql-datasource/webpack.config.ts | 1 - .../datasource/grafana-testdata-datasource/webpack.config.ts | 1 - public/app/plugins/datasource/graphite/webpack.config.ts | 1 - public/app/plugins/datasource/loki/webpack.config.ts | 1 - public/app/plugins/datasource/mysql/webpack.config.ts | 1 - public/app/plugins/datasource/opentsdb/webpack.config.ts | 1 - public/app/plugins/datasource/tempo/webpack.config.ts | 1 - public/app/plugins/datasource/zipkin/webpack.config.ts | 1 - 11 files changed, 2 insertions(+), 14 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c92d2939837..250dbd20348 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -4020,11 +4020,6 @@ "count": 1 } }, - "public/app/plugins/datasource/parca/webpack.config.ts": { - "no-barrel-files/no-barrel-files": { - "count": 1 - } - }, "public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx": { "no-restricted-syntax": { "count": 1 diff --git a/eslint.config.js b/eslint.config.js index bd1be26465a..5e44ffebaf4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -585,6 +585,8 @@ module.exports = [ // FIXME: Remove once all enterprise issues are fixed - // we don't have a suppressions file/approach for enterprise code yet ...enterpriseIgnores, + // Ignore decoupled plugin webpack configs + 'public/app/**/webpack.config.ts', ], rules: { 'no-barrel-files/no-barrel-files': 'error', diff --git a/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts b/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts +++ b/public/app/plugins/datasource/cloud-monitoring/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/webpack.config.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/webpack.config.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/webpack.config.ts b/public/app/plugins/datasource/grafana-testdata-datasource/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/webpack.config.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/graphite/webpack.config.ts b/public/app/plugins/datasource/graphite/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/graphite/webpack.config.ts +++ b/public/app/plugins/datasource/graphite/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/loki/webpack.config.ts b/public/app/plugins/datasource/loki/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/loki/webpack.config.ts +++ b/public/app/plugins/datasource/loki/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/mysql/webpack.config.ts b/public/app/plugins/datasource/mysql/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/mysql/webpack.config.ts +++ b/public/app/plugins/datasource/mysql/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/opentsdb/webpack.config.ts b/public/app/plugins/datasource/opentsdb/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/opentsdb/webpack.config.ts +++ b/public/app/plugins/datasource/opentsdb/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/tempo/webpack.config.ts b/public/app/plugins/datasource/tempo/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/tempo/webpack.config.ts +++ b/public/app/plugins/datasource/tempo/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; diff --git a/public/app/plugins/datasource/zipkin/webpack.config.ts b/public/app/plugins/datasource/zipkin/webpack.config.ts index 7931eb9cb5c..31247bb11af 100644 --- a/public/app/plugins/datasource/zipkin/webpack.config.ts +++ b/public/app/plugins/datasource/zipkin/webpack.config.ts @@ -1,4 +1,3 @@ import config from '@grafana/plugin-configs/webpack.config.ts'; -// eslint-disable-next-line no-barrel-files/no-barrel-files export default config; From e61e40644034876022ab15ee6f6e77fc57fcad10 Mon Sep 17 00:00:00 2001 From: Naimesh Patel <70344829+naimeshpatel5295@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:49:47 +0530 Subject: [PATCH 24/28] Explore: Add keyboard shortcut to run queries (#111675) (#115811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Explore: add keyboard shortcut to run queries (#111675) * Update mock * Fix linting --------- Co-authored-by: Piotr Jamróz --- .../explore/hooks/useKeyboardShortcuts.ts | 28 ++++++++++++++++++- public/app/types/events.ts | 4 +++ public/test/mocks/getGrafanaContextMock.ts | 2 ++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/public/app/features/explore/hooks/useKeyboardShortcuts.ts b/public/app/features/explore/hooks/useKeyboardShortcuts.ts index 9ff1e32e5ed..47487690878 100644 --- a/public/app/features/explore/hooks/useKeyboardShortcuts.ts +++ b/public/app/features/explore/hooks/useKeyboardShortcuts.ts @@ -3,9 +3,19 @@ import { Unsubscribable } from 'rxjs'; import { getAppEvents } from '@grafana/runtime'; import { useGrafana } from 'app/core/context/GrafanaContext'; -import { AbsoluteTimeEvent, CopyTimeEvent, PasteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events'; +import { getState } from 'app/store/store'; +import { + AbsoluteTimeEvent, + CopyTimeEvent, + PasteTimeEvent, + RunQueriesEvent, + ShiftTimeEvent, + ZoomOutEvent, +} from 'app/types/events'; import { useDispatch } from 'app/types/store'; +import { runQueries } from '../state/query'; +import { selectPanesEntries } from '../state/selectors'; import { copyTimeRangeToClipboard, makeAbsoluteTime, @@ -21,8 +31,23 @@ export function useKeyboardShortcuts() { useEffect(() => { keybindings.setupTimeRangeBindings(false); + // Explore-specific: run queries shortcut + keybindings.bind('e r', () => { + getAppEvents().publish(new RunQueriesEvent()); + }); + const tearDown: Unsubscribable[] = []; + tearDown.push( + getAppEvents().subscribe(RunQueriesEvent, () => { + // Read panes at event time to avoid re-subscribing when panes change + const panes = selectPanesEntries(getState()); + panes.forEach(([exploreId]) => { + dispatch(runQueries({ exploreId })); + }); + }) + ); + tearDown.push( getAppEvents().subscribe(AbsoluteTimeEvent, () => { dispatch(makeAbsoluteTime()); @@ -54,6 +79,7 @@ export function useKeyboardShortcuts() { ); return () => { + keybindings.unbind('e r'); tearDown.forEach((u) => u.unsubscribe()); }; }, [dispatch, keybindings]); diff --git a/public/app/types/events.ts b/public/app/types/events.ts index 6fb728bb94c..95fbecc1e2d 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -159,6 +159,10 @@ export class AbsoluteTimeEvent extends BusEventWithPayload { static type = 'remove-panel'; } diff --git a/public/test/mocks/getGrafanaContextMock.ts b/public/test/mocks/getGrafanaContextMock.ts index 64e704986b7..aa1d061f7fe 100644 --- a/public/test/mocks/getGrafanaContextMock.ts +++ b/public/test/mocks/getGrafanaContextMock.ts @@ -19,6 +19,8 @@ export function getGrafanaContextMock(overrides: Partial = { clearAndInitGlobalBindings: jest.fn(), setupDashboardBindings: jest.fn(), setupTimeRangeBindings: jest.fn(), + bind: jest.fn(), + unbind: jest.fn(), } as unknown as KeybindingSrv, newAssetsChecker: { start: jest.fn(), From 1b52718c23f4e7542a3e74c5e9801ed11cb36974 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Mon, 12 Jan 2026 15:21:03 +0300 Subject: [PATCH 25/28] Search: include panel titles and types in index (#115742) --- .../rtkq/dashboard/v0alpha1/endpoints.gen.ts | 6 + pkg/registry/apis/dashboard/search.go | 51 ++++-- .../store/kind/dashboard/ds_lookup.go | 11 +- .../panel-with-library-panel-field-info.json | 8 +- pkg/services/store/kind/dashboard/types.go | 19 ++ pkg/storage/unified/search/bleve.go | 6 +- pkg/storage/unified/search/bleve_test.go | 1 - .../unified/search/builders/dashboard.go | 35 +++- .../testdata/doc/dashboard-aaa-out.json | 14 +- .../builders/testdata/doc/dashboard-aaa.json | 14 +- .../search/testdata/manual-dashboard.json | 12 +- pkg/tests/apis/dashboard/search_test.go | 168 ++++++++++++++++++ .../dashboard/testdata/searchV0/t00-all.json | 165 +++++++++++++++++ .../testdata/searchV0/t01-simple-query.json | 28 +++ .../searchV0/t02-with-text-panel.json | 18 ++ .../dashboard.grafana.app-v0alpha1.json | 16 ++ .../admin/components/PluginDetailsBody.tsx | 2 +- .../plugins/admin/components/PluginUsage.tsx | 5 +- .../admin/hooks/usePluginDetailsTabs.tsx | 2 +- public/app/features/search/service/types.ts | 1 - public/app/features/search/service/unified.ts | 8 + 21 files changed, 557 insertions(+), 33 deletions(-) create mode 100644 pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json create mode 100644 pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json create mode 100644 pkg/tests/apis/dashboard/testdata/searchV0/t02-with-text-panel.json diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index 326b53ccedd..9605b8e9355 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -246,6 +246,8 @@ const injectedRtkApi = api facetLimit: queryArg.facetLimit, tags: queryArg.tags, libraryPanel: queryArg.libraryPanel, + panelType: queryArg.panelType, + dataSourceType: queryArg.dataSourceType, permission: queryArg.permission, sort: queryArg.sort, limit: queryArg.limit, @@ -674,6 +676,10 @@ export type SearchDashboardsAndFoldersApiArg = { tags?: string[]; /** find dashboards that reference a given libraryPanel */ libraryPanel?: string; + /** find dashboards using panels of a given plugin type */ + panelType?: string; + /** find dashboards using datasources of a given plugin type */ + dataSourceType?: string; /** permission needed for the resource (view, edit, admin) */ permission?: 'view' | 'edit' | 'admin'; /** sortable field */ diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 08a943b8da6..8572dae8295 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -142,6 +142,24 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) * Schema: spec.StringProperty(), }, }, + { + ParameterProps: spec3.ParameterProps{ + Name: "panelType", + In: "query", + Description: "find dashboards using panels of a given plugin type", + Required: false, + Schema: spec.StringProperty(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "dataSourceType", + In: "query", + Description: "find dashboards using datasources of a given plugin type", + Required: false, + Schema: spec.StringProperty(), + }, + }, { ParameterProps: spec3.ParameterProps{ Name: "permission", @@ -430,14 +448,11 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use } } - // The facet term fields + // Apply facet terms if facets, ok := queryParams["facet"]; ok { if queryParams.Has("facetLimit") { if parsed, err := strconv.Atoi(queryParams.Get("facetLimit")); err == nil && parsed > 0 { - facetLimit = parsed - if facetLimit > 1000 { - facetLimit = 1000 - } + facetLimit = min(parsed, 1000) } } searchRequest.Facet = make(map[string]*resourcepb.ResourceSearchRequest_Facet) @@ -449,21 +464,35 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use } } - // The tags filter - if tags, ok := queryParams["tag"]; ok { + if v, ok := queryParams["tag"]; ok { searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ Key: "tags", Operator: "=", - Values: tags, + Values: v, }) } - // The libraryPanel filter - if libraryPanel, ok := queryParams["libraryPanel"]; ok { + if v, ok := queryParams["panelType"]; ok { + searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ + Key: resource.SEARCH_FIELD_PREFIX + builders.DASHBOARD_PANEL_TYPES, + Operator: "=", + Values: v, + }) + } + + if v, ok := queryParams["dataSourceType"]; ok { + searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ + Key: resource.SEARCH_FIELD_PREFIX + builders.DASHBOARD_DS_TYPES, + Operator: "=", + Values: v, + }) + } + + if v, ok := queryParams["libraryPanel"]; ok { searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ Key: builders.DASHBOARD_LIBRARY_PANEL_REFERENCE, Operator: "=", - Values: libraryPanel, + Values: v, }) } diff --git a/pkg/services/store/kind/dashboard/ds_lookup.go b/pkg/services/store/kind/dashboard/ds_lookup.go index 5d132d569be..7330e598409 100644 --- a/pkg/services/store/kind/dashboard/ds_lookup.go +++ b/pkg/services/store/kind/dashboard/ds_lookup.go @@ -100,6 +100,9 @@ func (d *DsLookup) ByRef(ref *DataSourceRef) *DataSourceRef { if ref == nil { return d.defaultDS } + if ref.UID == "default" && ref.Type == "" { + return d.defaultDS + } key := "" if ref.UID != "" { @@ -117,7 +120,13 @@ func (d *DsLookup) ByRef(ref *DataSourceRef) *DataSourceRef { return ds } - return d.byName[key] + ds, ok = d.byName[key] + if ok { + return ds + } + + // With nothing was found (or configured), use the original reference + return ref } func (d *DsLookup) ByType(dsType string) []DataSourceRef { diff --git a/pkg/services/store/kind/dashboard/testdata/panel-with-library-panel-field-info.json b/pkg/services/store/kind/dashboard/testdata/panel-with-library-panel-field-info.json index 1ffaecb605b..2a687ad8865 100644 --- a/pkg/services/store/kind/dashboard/testdata/panel-with-library-panel-field-info.json +++ b/pkg/services/store/kind/dashboard/testdata/panel-with-library-panel-field-info.json @@ -4,8 +4,8 @@ "tags": null, "datasource": [ { - "uid": "default.uid", - "type": "default.type" + "uid": "000000001", + "type": "graphite" } ], "panels": [ @@ -16,8 +16,8 @@ "libraryPanel": "dfkljg98345dkf", "datasource": [ { - "uid": "default.uid", - "type": "default.type" + "uid": "000000001", + "type": "graphite" } ] } diff --git a/pkg/services/store/kind/dashboard/types.go b/pkg/services/store/kind/dashboard/types.go index 51aa00a79fd..c1dea30abb7 100644 --- a/pkg/services/store/kind/dashboard/types.go +++ b/pkg/services/store/kind/dashboard/types.go @@ -1,5 +1,7 @@ package dashboard +import "iter" + type PanelSummaryInfo struct { ID int64 `json:"id"` Title string `json:"title"` @@ -30,3 +32,20 @@ type DashboardSummaryInfo struct { Refresh string `json:"refresh,omitempty"` ReadOnly bool `json:"readOnly,omitempty"` // editable = false } + +func (d *DashboardSummaryInfo) PanelIterator() iter.Seq[PanelSummaryInfo] { + return func(yield func(PanelSummaryInfo) bool) { + for _, p := range d.Panels { + if len(p.Collapsed) > 0 { + for _, c := range p.Collapsed { + if !yield(c) { // NOTE, rows can only be one level deep! + return + } + } + } + if !yield(p) { + return + } + } + } +} diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index 254c1080653..eec7290633b 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -1253,21 +1253,23 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resourcepb.R queryExact.SetField(resource.SEARCH_FIELD_TITLE) queryExact.Analyzer = keyword.Name // don't analyze the query input - treat it as a single token queryExact.Operator = query.MatchQueryOperatorAnd // This doesn't make a difference for keyword analyzer, we add it just to be explicit. + searchQuery := bleve.NewDisjunctionQuery(queryExact) // Query 2: Phrase query with standard analyzer queryPhrase := bleve.NewMatchPhraseQuery(req.Query) queryPhrase.SetBoost(5.0) queryPhrase.SetField(resource.SEARCH_FIELD_TITLE) queryPhrase.Analyzer = standard.Name + searchQuery.AddQuery(queryPhrase) // Query 3: Match query with standard analyzer queryAnalyzed := bleve.NewMatchQuery(removeSmallTerms(req.Query)) queryAnalyzed.SetField(resource.SEARCH_FIELD_TITLE) + queryAnalyzed.SetBoost(2.0) queryAnalyzed.Analyzer = standard.Name queryAnalyzed.Operator = query.MatchQueryOperatorAnd // Make sure all terms from the query are matched + searchQuery.AddQuery(queryAnalyzed) - // At least one of the queries must match - searchQuery := bleve.NewDisjunctionQuery(queryExact, queryAnalyzed, queryPhrase) queries = append(queries, searchQuery) } diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index d80e35f90e5..c9c3967cd58 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -23,7 +23,6 @@ import ( "go.uber.org/goleak" authlib "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/log" diff --git a/pkg/storage/unified/search/builders/dashboard.go b/pkg/storage/unified/search/builders/dashboard.go index 4f8d55111a9..a2963d71186 100644 --- a/pkg/storage/unified/search/builders/dashboard.go +++ b/pkg/storage/unified/search/builders/dashboard.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "slices" "sort" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -18,6 +19,7 @@ import ( const DASHBOARD_SCHEMA_VERSION = "schema_version" const DASHBOARD_LINK_COUNT = "link_count" const DASHBOARD_PANEL_TYPES = "panel_types" +const DASHBOARD_PANEL_TITLE = "panel_title" const DASHBOARD_DS_TYPES = "ds_types" const DASHBOARD_TRANSFORMATIONS = "transformation" const DASHBOARD_LIBRARY_PANEL_REFERENCE = "reference.LibraryPanel" @@ -53,11 +55,21 @@ func DashboardBuilder(namespaced resource.NamespacedDocumentSupplier) (resource. Type: resourcepb.ResourceTableColumnDefinition_INT32, Description: "How many links appear on the page", }, + { + Name: DASHBOARD_PANEL_TITLE, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + IsArray: true, + Description: "The panel title text", + Properties: &resourcepb.ResourceTableColumnDefinition_Properties{ + Filterable: false, // full text + FreeText: true, + }, + }, { Name: DASHBOARD_PANEL_TYPES, Type: resourcepb.ResourceTableColumnDefinition_STRING, IsArray: true, - Description: "How many links appear on the page", + Description: "The panel types used in this dashboard", Properties: &resourcepb.ResourceTableColumnDefinition_Properties{ Filterable: true, }, @@ -269,14 +281,22 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou doc.Description = summary.Description doc.Tags = summary.Tags + panelTitles := []string{} panelTypes := []string{} transformations := []string{} dsTypes := []string{} - for _, p := range summary.Panels { - if p.Type != "" { + for p := range summary.PanelIterator() { + switch p.Type { + case "": // ignore + case "row": // row should map to a layout type when we support v2 constructs + default: panelTypes = append(panelTypes, p.Type) } + + if len(p.Title) > 0 { + panelTitles = append(panelTitles, p.Title) + } if len(p.Transformer) > 0 { transformations = append(transformations, p.Transformer...) } @@ -309,17 +329,20 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou resource.SEARCH_FIELD_LEGACY_ID: summary.ID, } + if len(panelTitles) > 0 { + doc.Fields[DASHBOARD_PANEL_TITLE] = panelTitles + } if len(panelTypes) > 0 { sort.Strings(panelTypes) - doc.Fields[DASHBOARD_PANEL_TYPES] = panelTypes + doc.Fields[DASHBOARD_PANEL_TYPES] = slices.Compact(panelTypes) // distinct values } if len(dsTypes) > 0 { sort.Strings(dsTypes) - doc.Fields[DASHBOARD_DS_TYPES] = dsTypes + doc.Fields[DASHBOARD_DS_TYPES] = slices.Compact(dsTypes) // distinct values } if len(transformations) > 0 { sort.Strings(transformations) - doc.Fields[DASHBOARD_TRANSFORMATIONS] = transformations + doc.Fields[DASHBOARD_TRANSFORMATIONS] = slices.Compact(transformations) // distinct values } for k, v := range s.Stats[summary.UID] { diff --git a/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa-out.json b/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa-out.json index a77725e2cc2..fdb77b02c3b 100644 --- a/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa-out.json +++ b/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa-out.json @@ -32,10 +32,16 @@ "errors_last_7_days": 1, "grafana.app/deprecatedInternalID": 141, "link_count": 0, + "panel_title": [ + "green pie", + "red pie", + "blue pie", + "collapsed row" + ], "panel_types": [ "barchart", "graph", - "row" + "pie" ], "schema_version": 38 }, @@ -46,6 +52,12 @@ "kind": "DataSource", "name": "DSUID" }, + { + "relation": "depends-on", + "group": "dashboards.grafana.app", + "kind": "LibraryPanel", + "name": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" + }, { "relation": "depends-on", "group": "dashboards.grafana.app", diff --git a/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa.json b/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa.json index d9ecbfc6aec..24360ee929b 100644 --- a/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa.json +++ b/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa.json @@ -67,7 +67,7 @@ "name": "red pie", "uid": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee" }, - "title": "green pie" + "title": "red pie" }, { "id": 7, @@ -78,6 +78,14 @@ "id": 8, "type": "graph" }, + { + "id": 20, + "type": "graph" + }, + { + "id": 30, + "type": "graph" + }, { "collapsed": true, "gridPos": { @@ -101,6 +109,10 @@ "uid": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" }, "title": "blue pie" + }, + { + "id": 40, + "type": "pie" } ], "title": "collapsed row", diff --git a/pkg/storage/unified/search/testdata/manual-dashboard.json b/pkg/storage/unified/search/testdata/manual-dashboard.json index 4208a58dcd3..2ae346d072c 100644 --- a/pkg/storage/unified/search/testdata/manual-dashboard.json +++ b/pkg/storage/unified/search/testdata/manual-dashboard.json @@ -71,11 +71,18 @@ "description": "How many links appear on the page", "priority": 0 }, + { + "name": "panel_title", + "type": "string", + "format": "", + "description": "The panel title text", + "priority": 0 + }, { "name": "panel_types", "type": "string", "format": "", - "description": "How many links appear on the page", + "description": "The panel types used in this dashboard", "priority": 0 }, { @@ -214,6 +221,7 @@ null, null, null, + null, null ], "object": { @@ -239,6 +247,7 @@ "repo", null, null, + null, [ "timeseries" ], @@ -282,6 +291,7 @@ "repo", null, null, + null, [ "timeseries", "table" diff --git a/pkg/tests/apis/dashboard/search_test.go b/pkg/tests/apis/dashboard/search_test.go index 3fe35c51b3a..2227c67287e 100644 --- a/pkg/tests/apis/dashboard/search_test.go +++ b/pkg/tests/apis/dashboard/search_test.go @@ -4,10 +4,15 @@ import ( "context" "encoding/json" "fmt" + "io/fs" + "math" "net/http" + "os" + "path/filepath" "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" @@ -16,12 +21,167 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/util/testutil" ) +func TestIntegrationSearchDevDashboards(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + ctx := context.Background() + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5}, + "folders.folder.grafana.app": {DualWriterMode: rest.Mode5}, + }, + UnifiedStorageEnableSearch: true, + }) + defer helper.Shutdown() + + // Create devenv dashboards from legacy API + cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig()) + cfg.GroupVersion = &dashboardV0.GroupVersion + adminClient, err := k8srest.RESTClientFor(cfg) + require.NoError(t, err) + adminClient.Get() + + fileCount := 0 + devenv := "../../../../devenv/dev-dashboards/panel-timeseries" + err = filepath.WalkDir(devenv, func(p string, d fs.DirEntry, e error) error { + require.NoError(t, err) + if d.IsDir() || filepath.Ext(d.Name()) != ".json" { + return nil + } + + // use the filename as UID + uid := strings.TrimSuffix(d.Name(), ".json") + if len(uid) > 40 { + uid = uid[:40] // avoid uid too long, max 40 characters + } + + // nolint:gosec + data, err := os.ReadFile(p) + require.NoError(t, err) + + cmd := dashboards.SaveDashboardCommand{ + Dashboard: &simplejson.Json{}, + Overwrite: true, + } + err = cmd.Dashboard.FromDB(data) + require.NoError(t, err) + cmd.Dashboard.Set("id", nil) + cmd.Dashboard.Set("uid", uid) + data, err = json.Marshal(cmd) + require.NoError(t, err) + + var statusCode int + result := adminClient.Post().AbsPath("api", "dashboards", "db"). + Body(data). + SetHeader("Content-type", "application/json"). + Do(ctx). + StatusCode(&statusCode) + require.NoError(t, result.Error(), "file: [%d] %s [status:%d]", fileCount, d.Name(), statusCode) + require.Equal(t, int(http.StatusOK), statusCode) + fileCount++ + return nil + }) + require.NoError(t, err) + require.Equal(t, 16, fileCount, "file count from %s", devenv) + + // Helper to call search + callSearch := func(user apis.User, params string) dashboardV0.SearchResults { + require.NotNil(t, user) + ns := user.Identity.GetNamespace() + cfg := dynamic.ConfigFor(user.NewRestConfig()) + cfg.GroupVersion = &dashboardV0.GroupVersion + restClient, err := k8srest.RESTClientFor(cfg) + require.NoError(t, err) + + var statusCode int + req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search"). + Param("limit", "1000"). + Param("type", "dashboard") // Only search dashboards + + for kv := range strings.SplitSeq(params, "&") { + if kv == "" { + continue + } + parts := strings.SplitN(kv, "=", 2) + if len(parts) == 2 { + req = req.Param(parts[0], parts[1]) + } + } + res := req.Do(ctx).StatusCode(&statusCode) + require.NoError(t, res.Error()) + require.Equal(t, int(http.StatusOK), statusCode) + var sr dashboardV0.SearchResults + raw, err := res.Raw() + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &sr)) + + // Normalize scores and query cost for snapshot comparison + sr.QueryCost = 0 // this depends on the hardware + sr.MaxScore = roundTo(sr.MaxScore, 3) + for i := range sr.Hits { + sr.Hits[i].Score = roundTo(sr.Hits[i].Score, 3) // 0.6250571494814442 -> 0.625 + } + return sr + } + + // Compare a results to snapshots + testCases := []struct { + name string + user apis.User + params string + }{ + { + name: "all", + user: helper.Org1.Admin, + params: "", // only dashboards + }, + { + name: "simple-query", + user: helper.Org1.Admin, + params: "query=stacking", + }, + { + name: "with-text-panel", + user: helper.Org1.Admin, + params: "field=panel_types&panelType=text", + }, + } + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + res := callSearch(tc.user, tc.params) + jj, err := json.MarshalIndent(res, "", " ") + require.NoError(t, err) + + fname := fmt.Sprintf("testdata/searchV0/t%02d-%s.json", i, tc.name) + // nolint:gosec + snapshot, err := os.ReadFile(fname) + if err != nil { + assert.Failf(t, "Failed to read snapshot", "file: %s", fname) + err = os.WriteFile(fname, jj, 0o644) + require.NoErrorf(t, err, "Failed to write snapshot file %s", fname) + return + } + + if !assert.JSONEq(t, string(snapshot), string(jj)) { + err = os.WriteFile(fname, jj, 0o644) + require.NoErrorf(t, err, "Failed to write snapshot file %s", fname) + } + }) + } +} + func TestIntegrationSearchPermissionFiltering(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) @@ -285,3 +445,11 @@ func setFolderPermissions(t *testing.T, helper *apis.K8sTestHelper, actingUser a require.Equal(t, http.StatusOK, resp.Response.StatusCode, "Failed to set permissions for folder %s", folderUID) } + +// roundTo rounds a float64 to a specified number of decimal places. +func roundTo(n float64, decimals uint32) float64 { + // Calculate the power of 10 for the desired number of decimals + scale := math.Pow(10, float64(decimals)) + // Multiply, round to the nearest integer, and then divide back + return math.Round(n*scale) / scale +} diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json b/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json new file mode 100644 index 00000000000..35b7cff0302 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json @@ -0,0 +1,165 @@ +{ + "totalHits": 16, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries", + "title": "Panel Tests - Graph NG", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-by-value-color-schemes", + "title": "Panel Tests - Graph NG - By value color schemes", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-nulls", + "title": "Panel Tests - Graph NG - Discrete panels", + "tags": [ + "gdev", + "panel-tests", + "graph-ng", + "timeseries", + "trend", + "state-timeline", + "transform" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-gradient-area", + "title": "Panel Tests - Graph NG - Gradient Area Fills", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-soft-limits", + "title": "Panel Tests - Graph NG - softMin/softMax", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-yaxis-ticks", + "title": "Panel Tests - Graph NG - Y axis ticks", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-hue-gradients", + "title": "Panel Tests - GraphNG - Hue Gradients", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-time", + "title": "Panel Tests - GraphNG - Time Axis", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-thresholds", + "title": "Panel Tests - GraphNG Thresholds", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-shared-tooltip-cursor-positio", + "title": "Panel Tests - shared tooltips cursor positioning", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-bars-high-density", + "title": "Panel Tests - TimeSeries - bars high density (stroke + fill)", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-out-of-rage", + "title": "Panel Tests - Timeseries - Out of range", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-stacking", + "title": "Panel Tests - TimeSeries - stacking", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-formats", + "title": "Panel Tests - Timeseries - Supported input formats" + }, + { + "resource": "dashboards", + "name": "timeseries-stacking2", + "title": "TimeSeries \u0026 BarChart Stacking", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-y-ticks-zero-decimals", + "title": "Zero Decimals Y Ticks", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + } + ], + "maxScore": 1 +} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json b/pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json new file mode 100644 index 00000000000..6c9a935dfe8 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json @@ -0,0 +1,28 @@ +{ + "totalHits": 2, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries-stacking", + "title": "Panel Tests - TimeSeries - stacking", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "score": 0.658 + }, + { + "resource": "dashboards", + "name": "timeseries-stacking2", + "title": "TimeSeries \u0026 BarChart Stacking", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "score": 0.625 + } + ], + "maxScore": 0.658 +} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t02-with-text-panel.json b/pkg/tests/apis/dashboard/testdata/searchV0/t02-with-text-panel.json new file mode 100644 index 00000000000..b38cfb14b40 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t02-with-text-panel.json @@ -0,0 +1,18 @@ +{ + "totalHits": 1, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries-formats", + "title": "Panel Tests - Timeseries - Supported input formats", + "field": { + "panel_types": [ + "table", + "text", + "timeseries" + ] + } + } + ], + "maxScore": 1.778 +} \ No newline at end of file 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 4634143bd45..3ed64961b1f 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -1830,6 +1830,22 @@ "type": "string" } }, + { + "name": "panelType", + "in": "query", + "description": "find dashboards using panels of a given plugin type", + "schema": { + "type": "string" + } + }, + { + "name": "dataSourceType", + "in": "query", + "description": "find dashboards using datasources of a given plugin type", + "schema": { + "type": "string" + } + }, { "name": "permission", "in": "query", diff --git a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx index d93c5a913b5..b6c5b301f68 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx @@ -176,7 +176,7 @@ export function PluginDetailsBody({ plugin, queryParams, pageId, info, showDetai export const getStyles = (theme: GrafanaTheme2) => ({ wrap: css({ width: '100%', - height: '50vh', + height: '65vh', }), readme: css({ '& img': { diff --git a/public/app/features/plugins/admin/components/PluginUsage.tsx b/public/app/features/plugins/admin/components/PluginUsage.tsx index 0ad9ebb1738..b156fb39783 100644 --- a/public/app/features/plugins/admin/components/PluginUsage.tsx +++ b/public/app/features/plugins/admin/components/PluginUsage.tsx @@ -62,6 +62,7 @@ export function PluginUsage({ plugin }: Props) { keyboardEvents={of()} onTagSelected={() => {}} trackingSource="PluginDetailsPage_PluginUsage" + onClickItem={() => {}} /> ); }} @@ -74,13 +75,13 @@ export function PluginUsage({ plugin }: Props) { return ; } - if (!config.featureToggles.panelTitleSearch) { + if (!config.featureToggles.unifiedStorageSearchUI) { return ( diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx index 6cb8ec39af4..be6a6c73973 100644 --- a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx @@ -101,7 +101,7 @@ export const usePluginDetailsTabs = ( } if ( - config.featureToggles.panelTitleSearch && + config.featureToggles.unifiedStorageSearchUI && (pluginConfig.meta.type === PluginType.panel || pluginConfig.meta.type === PluginType.datasource) ) { navModelChildren.push({ diff --git a/public/app/features/search/service/types.ts b/public/app/features/search/service/types.ts index a670b054979..aaca2595bf0 100644 --- a/public/app/features/search/service/types.ts +++ b/public/app/features/search/service/types.ts @@ -25,7 +25,6 @@ export interface SearchQuery { sort?: string; ds_uid?: string; ds_type?: string; - saved_query_uid?: string; // TODO: not implemented yet tags?: string[]; kind?: string[]; panel_type?: string; diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 146a54d295d..15280602202 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -297,6 +297,14 @@ export class UnifiedSearcher implements GrafanaSearcher { uri += '&' + query.kind.map((kind) => `type=${kind}`).join('&'); } + if (query.ds_type?.length) { + uri += '&dataSourceType=' + query.ds_type; + } + + if (query.panel_type?.length) { + uri += '&panelType=' + query.panel_type; + } + if (query.tags?.length) { uri += '&' + query.tags.map((tag) => `tag=${encodeURIComponent(tag)}`).join('&'); } From 0d7f46c08a36587a482676ddb4f71b8cd8ae9896 Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Mon, 12 Jan 2026 13:22:48 +0100 Subject: [PATCH 26/28] chore(unified): remove unifiedStorageSearch feature toggle (#116109) --- packages/grafana-data/src/types/featureToggles.gen.ts | 4 ---- pkg/services/featuremgmt/registry.go | 7 ------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 3 ++- pkg/storage/unified/README.md | 1 - pkg/storage/unified/resource/search.go | 2 +- pkg/storage/unified/search/options.go | 2 +- pkg/tests/apis/folder/folders_test.go | 8 ++------ 9 files changed, 6 insertions(+), 26 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 3982bdc33e5..0bd17e2af72 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -657,10 +657,6 @@ export interface FeatureToggles { */ rolePickerDrawer?: boolean; /** - * Enable unified storage search - */ - unifiedStorageSearch?: boolean; - /** * Enable sprinkles on unified storage search */ unifiedStorageSearchSprinkles?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index ed591908042..6f2274f22e7 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1087,13 +1087,6 @@ var ( Stage: FeatureStageExperimental, Owner: identityAccessTeam, }, - { - Name: "unifiedStorageSearch", - Description: "Enable unified storage search", - Stage: FeatureStageExperimental, - Owner: grafanaSearchAndStorageSquad, - HideFromDocs: true, - }, { Name: "unifiedStorageSearchSprinkles", Description: "Enable sprinkles on unified storage search", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 15376f39fc1..c4d2c026d9a 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -150,7 +150,6 @@ alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true improvedExternalSessionHandling,GA,@grafana/identity-access-team,false,false,false useSessionStorageForRedirection,GA,@grafana/identity-access-team,false,false,false rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false -unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false unifiedStorageSearchSprinkles,experimental,@grafana/search-and-storage,false,false,false managedDualWriter,experimental,@grafana/search-and-storage,false,false,false pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index e778a6fc3d1..97ecf868a64 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -455,10 +455,6 @@ const ( // Enables the new role picker drawer design FlagRolePickerDrawer = "rolePickerDrawer" - // FlagUnifiedStorageSearch - // Enable unified storage search - FlagUnifiedStorageSearch = "unifiedStorageSearch" - // FlagUnifiedStorageSearchSprinkles // Enable sprinkles on unified storage search FlagUnifiedStorageSearchSprinkles = "unifiedStorageSearchSprinkles" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 124a4977c07..64d6ff7b5dd 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3697,7 +3697,8 @@ "metadata": { "name": "unifiedStorageSearch", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-09-30T19:46:14Z" + "creationTimestamp": "2024-09-30T19:46:14Z", + "deletionTimestamp": "2026-01-12T10:02:12Z" }, "spec": { "description": "Enable unified storage search", diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index aef27df8e05..9cd0d1fd01d 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -236,7 +236,6 @@ kubernetesDashboards = true kubernetesFolders = true unifiedStorage = true unifiedStorageHistoryPruner = true -unifiedStorageSearch = true unifiedStorageSearchPermissionFiltering = false unifiedStorageSearchSprinkles = false diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index bca5fb98491..ade86760502 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -863,7 +863,7 @@ func newRebuildRequest(key NamespacedResource, minBuildTime, lastImportTime time func (s *searchSupport) getOrCreateIndex(ctx context.Context, stats *SearchStats, key NamespacedResource, reason string) (ResourceIndex, error) { if s == nil || s.search == nil { - return nil, fmt.Errorf("search is not configured properly (missing unifiedStorageSearch feature toggle?)") + return nil, fmt.Errorf("search is not configured properly (missing enable_search config?)") } ctx, span := tracer.Start(ctx, "resource.searchSupport.getOrCreateIndex") diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go index 20a0874b598..d450e9ae24b 100644 --- a/pkg/storage/unified/search/options.go +++ b/pkg/storage/unified/search/options.go @@ -19,7 +19,7 @@ func NewSearchOptions( ownsIndexFn func(key resource.NamespacedResource) (bool, error), ) (resource.SearchOptions, error) { //nolint:staticcheck // not yet migrated to OpenFeature - if cfg.EnableSearch || features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearch) || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { + if cfg.EnableSearch || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { root := cfg.IndexPath if root == "" { root = filepath.Join(cfg.DataPath, "unified-search", "bleve") diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index ce7dd5f6795..cdcc798c93a 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -2054,9 +2054,7 @@ func TestIntegrationDeleteFolderWithProvisionedDashboards(t *testing.T) { DualWriterMode: modeDw, }, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, + UnifiedStorageEnableSearch: true, } setupProvisioningDir(t, &ops) @@ -2163,9 +2161,7 @@ func TestIntegrationProvisionedFolderPropagatesLabelsAndAnnotations(t *testing.T DualWriterMode: mode3, }, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, + UnifiedStorageEnableSearch: true, } setupProvisioningDir(t, &ops) From 2aedbdb76ff606febee27d235118bca28bd4a6ce Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Mon, 12 Jan 2026 13:37:17 +0100 Subject: [PATCH 27/28] processing: support duplicated keys when parsing json logs (#116116) * processing: support duplicated keys when parsing json logs * Add regression test * prettier --- .../logs/components/panel/processing.test.ts | 16 ++++++++++++++++ .../features/logs/components/panel/processing.ts | 4 +++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts index 5998ff36d21..748c53b1df4 100644 --- a/public/app/features/logs/components/panel/processing.test.ts +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -190,6 +190,22 @@ describe('preProcessLogs', () => { expect(logListModel.body).not.toBe(entry); }); + test('Prettifies JSON with duplicate keys', () => { + const entry = '{"key": "value", "key": "otherValue"}'; + const logListModel = createLogLine( + { entry }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + wrapLogMessage: true, // wrapped + prettifyJSON: true, + } + ); + expect(logListModel.entry).toBe(entry); + expect(logListModel.body).not.toBe(entry); + }); + test('Prettifies and escapes wrapped JSON', () => { const entry = '{"key": "value", "otherKey": "other\\nValue"}'; const logListModel = createLogLine( diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 9132c2b7e6e..814481e87f2 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -135,7 +135,9 @@ export class LogListModel implements LogRowModel { get body(): string { if (this._body === undefined) { try { - const parsed = parse(this.raw); + const parsed = parse(this.raw, undefined, { + onDuplicateKey: ({ newValue }) => newValue, + }); if (typeof parsed === 'object' && parsed !== null && !(parsed instanceof LosslessNumber)) { this._json = true; } From b91ca14f48894cedd18df87c9085a7cfd4638cc3 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:38:18 +0100 Subject: [PATCH 28/28] Icons: Add brain icon (#116023) * Icons: Add brain icon * lint * Add brain to cached icons --- packages/grafana-data/src/types/icon.ts | 1 + public/app/core/icons/cached.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 34e672b66f5..3dc7215a2cf 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -52,6 +52,7 @@ export const availableIconsIndex = { bookmark: true, 'book-open': true, 'brackets-curly': true, + brain: true, 'browser-alt': true, bug: true, building: true, diff --git a/public/app/core/icons/cached.json b/public/app/core/icons/cached.json index 6e35e64dd0c..9987d867738 100644 --- a/public/app/core/icons/cached.json +++ b/public/app/core/icons/cached.json @@ -29,6 +29,7 @@ "unicons/bookmark", "unicons/book-open", "unicons/brackets-curly", + "unicons/brain", "unicons/bug", "unicons/building", "unicons/calculator-alt",