From 6bbb00d0a28dbbf153b320842c60ec058901e385 Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Tue, 9 Dec 2025 12:23:27 -0800 Subject: [PATCH 001/139] Short URL: Change default expiration to never (#115029) * Short Links: Change default expiration to never expire (-1) Previously, short links defaulted to expiring after 7 days. This change updates the default to -1 (never expire) to prevent automatic deletion of shared dashboard links. Changes: - conf/defaults.ini: Set expire_time = -1 and update comment - conf/sample.ini: Set expire_time = -1 and update comment - pkg/setting/setting.go: Update MustInt default from 7 to -1 The cleanup logic already handles -1 correctly (only runs when > 0), so no changes needed there. This unblocks progress on short URL feature improvements by ensuring shared links remain accessible indefinitely by default. * fix go * update docs / comments * update missed comment in sample.ini * Revert "fix go" This reverts commit e0d099ae31dcd27bacc0086103f01da33bc76723. * chore: update workspace dependencies Run 'make update-workspace' to sync Go workspace dependencies. This updates go.mod and go.sum files to match the current workspace state. * chore: add modowner for apps/quotas dependency Assign @grafana/grafana-search-and-storage as owner for apps/quotas dependency to satisfy modowners CI check. --- conf/defaults.ini | 5 ++--- conf/sample.ini | 4 ++-- .../setup-grafana/configure-grafana/_index.md | 12 ++++-------- pkg/setting/setting.go | 2 +- 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index a72b0183290..c2d7e4da3b6 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1747,9 +1747,8 @@ enabled = true #################################### Short Links ############################# [short_links] -# Short links that are never accessed will be deleted as cleanup. Time is set up in days. The default is 7 days. Maximum value is 365. -# 0 means they will be deleted approximately every 10 minutes. A negative value (such as -1) will disable expiration. -expire_time = 7 +# Short links that are never accessed will be deleted as cleanup. Time is set up in days. The default is -1 (never expire). Maximum value is 365. +expire_time = -1 #################################### Internal Grafana Metrics ############ # Metrics available at HTTP URL /metrics and /metrics/plugins/:pluginId diff --git a/conf/sample.ini b/conf/sample.ini index d6397f894e4..d1d50f0a72a 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -1689,8 +1689,8 @@ default_datasource_uid = #################################### Short Links ############################# [short_links] -# Short links which are never accessed will be deleted as cleanup. Time is in days. Default is 7 days. Max is 365. 0 means they will be deleted approximately every 10 minutes. -;expire_time = 7 +# Short links that are never accessed will be deleted as cleanup. Time is set up in days. The default is -1 (never expire). Maximum value is 365. +;expire_time = -1 #################################### Internal Grafana Metrics ########################## # Metrics available at HTTP URL /metrics and /metrics/plugins/:pluginId diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 13c2b77fd2f..1a3e4aea652 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2142,17 +2142,13 @@ Configures settings around the short link feature. #### `expire_time` -Short links that are never accessed are considered expired or stale and are deleted as cleanup. +Short links that are never accessed are considered expired or stale and can be deleted as cleanup. Set the expiration time in days. -The default is `7` days. +The default is `-1` days (never expire). The maximum is `365` days. -A setting above the maximum uses the value `365` instead. -Setting `0` means the short links are cleaned up approximately every 10 minutes. -A negative value such as `-1` disables expiry. -{{< admonition type="caution" >}} -Short links without an expiration increase the size of the database and can't be deleted. Grafana recommends setting a duration based on your specific use case -{{< /admonition >}} +A setting above the maximum uses the value `365` instead. +A negative value such as `-1` disables expiry.
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index f6c0b3d3f19..cc59427da6f 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -1330,7 +1330,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { cfg.QueryHistoryEnabled = queryHistory.Key("enabled").MustBool(true) shortLinks := iniFile.Section("short_links") - cfg.ShortLinkExpiration = shortLinks.Key("expire_time").MustInt(7) + cfg.ShortLinkExpiration = shortLinks.Key("expire_time").MustInt(-1) if cfg.ShortLinkExpiration > 365 { cfg.Logger.Warn("short_links expire_time must be less than 366 days. Setting to 365 days") From d9fc183e393d2336edb2ae7faf700905a59dd58c Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Tue, 9 Dec 2025 14:00:01 -0700 Subject: [PATCH 002/139] Folders: Prevent circular dependencies on apis level (#115040) --- pkg/registry/apis/folders/validate.go | 11 +++++++++++ pkg/registry/apis/folders/validate_test.go | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/pkg/registry/apis/folders/validate.go b/pkg/registry/apis/folders/validate.go index 4f8ccd2250d..739c81951f8 100644 --- a/pkg/registry/apis/folders/validate.go +++ b/pkg/registry/apis/folders/validate.go @@ -53,11 +53,22 @@ func validateOnCreate(ctx context.Context, f *folders.Folder, getter parentsGett return folder.ErrFolderCannotBeParentOfItself } + // note: `parents` will include itself as the last item parents, err := getter(ctx, f) if err != nil { return fmt.Errorf("unable to create folder inside parent: %w", err) } + for i, parent := range parents.Items { + // skip the last item, which is itself + if i == len(parents.Items)-1 { + continue + } + if parent.Name == f.Name { + return folder.ErrCircularReference.Errorf("circular reference detected") + } + } + // Can not create a folder that will be too deep. // We need to add +1 as we also have the root folder as part of the parents. if len(parents.Items) > maxDepth+1 { diff --git a/pkg/registry/apis/folders/validate_test.go b/pkg/registry/apis/folders/validate_test.go index c4bf07bc71c..ac21ea4ead0 100644 --- a/pkg/registry/apis/folders/validate_test.go +++ b/pkg/registry/apis/folders/validate_test.go @@ -127,6 +127,27 @@ func TestValidateCreate(t *testing.T) { }, maxDepth: folder.MaxNestedFolderDepth, }, + { + name: "cannot create a circular reference", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "3", + Annotations: map[string]string{"grafana.app/folder": "2"}, + }, + Spec: folders.FolderSpec{ + Title: "some title", + }, + }, + expectedErr: "circular reference detected", + getter: &folders.FolderInfoList{ + Items: []folders.FolderInfo{ + {Name: "2", Parent: "1"}, + {Name: "1", Parent: "3"}, + {Name: "3", Parent: folder.GeneralFolderUID}, + {Name: folder.GeneralFolderUID}, + }, + }, + }, } for _, tt := range tests { From 0088e55b8ffdbf0c1e24135cad5f4144f937cc0d Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:01:22 -0500 Subject: [PATCH 003/139] Plugins App: PluginMeta -> Meta (#115034) --- apps/plugins/kinds/manifest.cue | 2 +- .../kinds/{pluginmeta.cue => meta.cue} | 5 +- ...nmeta_client_gen.go => meta_client_gen.go} | 38 +- ...ginmeta_codec_gen.go => meta_codec_gen.go} | 10 +- ...a_metadata_gen.go => meta_metadata_gen.go} | 8 +- ...nmeta_object_gen.go => meta_object_gen.go} | 109 ++-- .../apis/plugins/v0alpha1/meta_schema_gen.go | 34 ++ .../apis/plugins/v0alpha1/meta_spec_gen.go | 474 ++++++++++++++++++ ...nmeta_status_gen.go => meta_status_gen.go} | 28 +- .../plugins/v0alpha1/plugin_object_gen.go | 7 + .../plugins/v0alpha1/plugin_schema_gen.go | 2 +- .../plugins/v0alpha1/pluginmeta_schema_gen.go | 34 -- .../plugins/v0alpha1/pluginmeta_spec_gen.go | 474 ------------------ apps/plugins/pkg/apis/plugins_manifest.go | 22 +- apps/plugins/pkg/app/app.go | 6 +- apps/plugins/pkg/app/meta/cloud.go | 36 +- apps/plugins/pkg/app/meta/cloud_test.go | 8 +- apps/plugins/pkg/app/meta/core.go | 148 +++--- apps/plugins/pkg/app/meta/core_test.go | 18 +- apps/plugins/pkg/app/meta/manager.go | 2 +- apps/plugins/pkg/app/meta/manager_test.go | 44 +- apps/plugins/pkg/app/meta/provider.go | 4 +- apps/plugins/pkg/app/storage.go | 68 +-- pkg/extensions/enterprise_imports.go | 3 +- pkg/registry/apps/plugins/accesscontrol.go | 14 +- pkg/services/accesscontrol/permreg/permreg.go | 2 +- pkg/services/authz/rbac/mapper.go | 4 +- pkg/tests/apis/config_test.go | 8 +- pkg/tests/apis/plugins/discovery_test.go | 8 +- .../{pluginmeta_test.go => metas_test.go} | 78 ++- pkg/tests/apis/plugins/pluginmetas_test.go | 85 ---- ...plugininstalls_test.go => plugins_test.go} | 0 32 files changed, 888 insertions(+), 895 deletions(-) rename apps/plugins/kinds/{pluginmeta.cue => meta.cue} (98%) rename apps/plugins/pkg/apis/plugins/v0alpha1/{pluginmeta_client_gen.go => meta_client_gen.go} (50%) rename apps/plugins/pkg/apis/plugins/v0alpha1/{pluginmeta_codec_gen.go => meta_codec_gen.go} (56%) rename apps/plugins/pkg/apis/plugins/v0alpha1/{pluginmeta_metadata_gen.go => meta_metadata_gen.go} (85%) rename apps/plugins/pkg/apis/plugins/v0alpha1/{pluginmeta_object_gen.go => meta_object_gen.go} (68%) create mode 100644 apps/plugins/pkg/apis/plugins/v0alpha1/meta_schema_gen.go create mode 100644 apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go rename apps/plugins/pkg/apis/plugins/v0alpha1/{pluginmeta_status_gen.go => meta_status_gen.go} (52%) delete mode 100644 apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_schema_gen.go delete mode 100644 apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_spec_gen.go rename pkg/tests/apis/plugins/{pluginmeta_test.go => metas_test.go} (52%) delete mode 100644 pkg/tests/apis/plugins/pluginmetas_test.go rename pkg/tests/apis/plugins/{plugininstalls_test.go => plugins_test.go} (100%) diff --git a/apps/plugins/kinds/manifest.cue b/apps/plugins/kinds/manifest.cue index f9a08562cb0..f624dc117bc 100644 --- a/apps/plugins/kinds/manifest.cue +++ b/apps/plugins/kinds/manifest.cue @@ -16,6 +16,6 @@ v0alpha1Version: { } kinds: [ pluginV0Alpha1, - pluginMetaV0Alpha1, + metaV0Alpha1, ] } diff --git a/apps/plugins/kinds/pluginmeta.cue b/apps/plugins/kinds/meta.cue similarity index 98% rename from apps/plugins/kinds/pluginmeta.cue rename to apps/plugins/kinds/meta.cue index 03b1ff10ff2..34a7b9aaf12 100644 --- a/apps/plugins/kinds/pluginmeta.cue +++ b/apps/plugins/kinds/meta.cue @@ -1,8 +1,7 @@ package plugins -pluginMetaV0Alpha1: { - kind: "PluginMeta" - plural: "pluginsmeta" +metaV0Alpha1: { + kind: "Meta" scope: "Namespaced" schema: { spec: { diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_client_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_client_gen.go similarity index 50% rename from apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_client_gen.go rename to apps/plugins/pkg/apis/plugins/v0alpha1/meta_client_gen.go index e7788e27a33..4acc2b635e8 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_client_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_client_gen.go @@ -7,33 +7,33 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -type PluginMetaClient struct { - client *resource.TypedClient[*PluginMeta, *PluginMetaList] +type MetaClient struct { + client *resource.TypedClient[*Meta, *MetaList] } -func NewPluginMetaClient(client resource.Client) *PluginMetaClient { - return &PluginMetaClient{ - client: resource.NewTypedClient[*PluginMeta, *PluginMetaList](client, PluginMetaKind()), +func NewMetaClient(client resource.Client) *MetaClient { + return &MetaClient{ + client: resource.NewTypedClient[*Meta, *MetaList](client, MetaKind()), } } -func NewPluginMetaClientFromGenerator(generator resource.ClientGenerator) (*PluginMetaClient, error) { - c, err := generator.ClientFor(PluginMetaKind()) +func NewMetaClientFromGenerator(generator resource.ClientGenerator) (*MetaClient, error) { + c, err := generator.ClientFor(MetaKind()) if err != nil { return nil, err } - return NewPluginMetaClient(c), nil + return NewMetaClient(c), nil } -func (c *PluginMetaClient) Get(ctx context.Context, identifier resource.Identifier) (*PluginMeta, error) { +func (c *MetaClient) Get(ctx context.Context, identifier resource.Identifier) (*Meta, error) { return c.client.Get(ctx, identifier) } -func (c *PluginMetaClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*PluginMetaList, error) { +func (c *MetaClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*MetaList, error) { return c.client.List(ctx, namespace, opts) } -func (c *PluginMetaClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*PluginMetaList, error) { +func (c *MetaClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*MetaList, error) { resp, err := c.client.List(ctx, namespace, resource.ListOptions{ ResourceVersion: opts.ResourceVersion, Limit: opts.Limit, @@ -61,25 +61,25 @@ func (c *PluginMetaClient) ListAll(ctx context.Context, namespace string, opts r return resp, nil } -func (c *PluginMetaClient) Create(ctx context.Context, obj *PluginMeta, opts resource.CreateOptions) (*PluginMeta, error) { +func (c *MetaClient) Create(ctx context.Context, obj *Meta, opts resource.CreateOptions) (*Meta, error) { // Make sure apiVersion and kind are set obj.APIVersion = GroupVersion.Identifier() - obj.Kind = PluginMetaKind().Kind() + obj.Kind = MetaKind().Kind() return c.client.Create(ctx, obj, opts) } -func (c *PluginMetaClient) Update(ctx context.Context, obj *PluginMeta, opts resource.UpdateOptions) (*PluginMeta, error) { +func (c *MetaClient) Update(ctx context.Context, obj *Meta, opts resource.UpdateOptions) (*Meta, error) { return c.client.Update(ctx, obj, opts) } -func (c *PluginMetaClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*PluginMeta, error) { +func (c *MetaClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*Meta, error) { return c.client.Patch(ctx, identifier, req, opts) } -func (c *PluginMetaClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus PluginMetaStatus, opts resource.UpdateOptions) (*PluginMeta, error) { - return c.client.Update(ctx, &PluginMeta{ +func (c *MetaClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus MetaStatus, opts resource.UpdateOptions) (*Meta, error) { + return c.client.Update(ctx, &Meta{ TypeMeta: metav1.TypeMeta{ - Kind: PluginMetaKind().Kind(), + Kind: MetaKind().Kind(), APIVersion: GroupVersion.Identifier(), }, ObjectMeta: metav1.ObjectMeta{ @@ -94,6 +94,6 @@ func (c *PluginMetaClient) UpdateStatus(ctx context.Context, identifier resource }) } -func (c *PluginMetaClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { +func (c *MetaClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { return c.client.Delete(ctx, identifier, opts) } diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_codec_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_codec_gen.go similarity index 56% rename from apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_codec_gen.go rename to apps/plugins/pkg/apis/plugins/v0alpha1/meta_codec_gen.go index 77fb6f918a2..c152eb63e79 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_codec_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_codec_gen.go @@ -11,18 +11,18 @@ import ( "github.com/grafana/grafana-app-sdk/resource" ) -// PluginMetaJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type PluginMetaJSONCodec struct{} +// MetaJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding +type MetaJSONCodec struct{} // Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*PluginMetaJSONCodec) Read(reader io.Reader, into resource.Object) error { +func (*MetaJSONCodec) 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 (*PluginMetaJSONCodec) Write(writer io.Writer, from resource.Object) error { +func (*MetaJSONCodec) Write(writer io.Writer, from resource.Object) error { return json.NewEncoder(writer).Encode(from) } // Interface compliance checks -var _ resource.Codec = &PluginMetaJSONCodec{} +var _ resource.Codec = &MetaJSONCodec{} diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_metadata_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_metadata_gen.go similarity index 85% rename from apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_metadata_gen.go rename to apps/plugins/pkg/apis/plugins/v0alpha1/meta_metadata_gen.go index 7d3b3c9c6b8..9de0658352f 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_metadata_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_metadata_gen.go @@ -9,7 +9,7 @@ import ( // 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 PluginMetaMetadata struct { +type MetaMetadata struct { UpdateTimestamp time.Time `json:"updateTimestamp"` CreatedBy string `json:"createdBy"` Uid string `json:"uid"` @@ -22,9 +22,9 @@ type PluginMetaMetadata struct { Labels map[string]string `json:"labels"` } -// NewPluginMetaMetadata creates a new PluginMetaMetadata object. -func NewPluginMetaMetadata() *PluginMetaMetadata { - return &PluginMetaMetadata{ +// NewMetaMetadata creates a new MetaMetadata object. +func NewMetaMetadata() *MetaMetadata { + return &MetaMetadata{ Finalizers: []string{}, Labels: map[string]string{}, } diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_object_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_object_gen.go similarity index 68% rename from apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_object_gen.go rename to apps/plugins/pkg/apis/plugins/v0alpha1/meta_object_gen.go index dac431ebf12..e42a1ad5d80 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_object_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_object_gen.go @@ -15,22 +15,29 @@ import ( ) // +k8s:openapi-gen=true -type PluginMeta struct { +type Meta struct { metav1.TypeMeta `json:",inline" yaml:",inline"` metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - // Spec is the spec of the PluginMeta - Spec PluginMetaSpec `json:"spec" yaml:"spec"` + // Spec is the spec of the Meta + Spec MetaSpec `json:"spec" yaml:"spec"` - Status PluginMetaStatus `json:"status" yaml:"status"` + Status MetaStatus `json:"status" yaml:"status"` } -func (o *PluginMeta) GetSpec() any { +func NewMeta() *Meta { + return &Meta{ + Spec: *NewMetaSpec(), + Status: *NewMetaStatus(), + } +} + +func (o *Meta) GetSpec() any { return o.Spec } -func (o *PluginMeta) SetSpec(spec any) error { - cast, ok := spec.(PluginMetaSpec) +func (o *Meta) SetSpec(spec any) error { + cast, ok := spec.(MetaSpec) if !ok { return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) } @@ -38,13 +45,13 @@ func (o *PluginMeta) SetSpec(spec any) error { return nil } -func (o *PluginMeta) GetSubresources() map[string]any { +func (o *Meta) GetSubresources() map[string]any { return map[string]any{ "status": o.Status, } } -func (o *PluginMeta) GetSubresource(name string) (any, bool) { +func (o *Meta) GetSubresource(name string) (any, bool) { switch name { case "status": return o.Status, true @@ -53,12 +60,12 @@ func (o *PluginMeta) GetSubresource(name string) (any, bool) { } } -func (o *PluginMeta) SetSubresource(name string, value any) error { +func (o *Meta) SetSubresource(name string, value any) error { switch name { case "status": - cast, ok := value.(PluginMetaStatus) + cast, ok := value.(MetaStatus) if !ok { - return fmt.Errorf("cannot set status type %#v, not of type PluginMetaStatus", value) + return fmt.Errorf("cannot set status type %#v, not of type MetaStatus", value) } o.Status = cast return nil @@ -67,7 +74,7 @@ func (o *PluginMeta) SetSubresource(name string, value any) error { } } -func (o *PluginMeta) GetStaticMetadata() resource.StaticMetadata { +func (o *Meta) GetStaticMetadata() resource.StaticMetadata { gvk := o.GroupVersionKind() return resource.StaticMetadata{ Name: o.ObjectMeta.Name, @@ -78,7 +85,7 @@ func (o *PluginMeta) GetStaticMetadata() resource.StaticMetadata { } } -func (o *PluginMeta) SetStaticMetadata(metadata resource.StaticMetadata) { +func (o *Meta) SetStaticMetadata(metadata resource.StaticMetadata) { o.Name = metadata.Name o.Namespace = metadata.Namespace o.SetGroupVersionKind(schema.GroupVersionKind{ @@ -88,7 +95,7 @@ func (o *PluginMeta) SetStaticMetadata(metadata resource.StaticMetadata) { }) } -func (o *PluginMeta) GetCommonMetadata() resource.CommonMetadata { +func (o *Meta) GetCommonMetadata() resource.CommonMetadata { dt := o.DeletionTimestamp var deletionTimestamp *time.Time if dt != nil { @@ -120,7 +127,7 @@ func (o *PluginMeta) GetCommonMetadata() resource.CommonMetadata { } } -func (o *PluginMeta) SetCommonMetadata(metadata resource.CommonMetadata) { +func (o *Meta) SetCommonMetadata(metadata resource.CommonMetadata) { o.UID = types.UID(metadata.UID) o.ResourceVersion = metadata.ResourceVersion o.Generation = metadata.Generation @@ -165,7 +172,7 @@ func (o *PluginMeta) SetCommonMetadata(metadata resource.CommonMetadata) { } } -func (o *PluginMeta) GetCreatedBy() string { +func (o *Meta) GetCreatedBy() string { if o.ObjectMeta.Annotations == nil { o.ObjectMeta.Annotations = make(map[string]string) } @@ -173,7 +180,7 @@ func (o *PluginMeta) GetCreatedBy() string { return o.ObjectMeta.Annotations["grafana.com/createdBy"] } -func (o *PluginMeta) SetCreatedBy(createdBy string) { +func (o *Meta) SetCreatedBy(createdBy string) { if o.ObjectMeta.Annotations == nil { o.ObjectMeta.Annotations = make(map[string]string) } @@ -181,7 +188,7 @@ func (o *PluginMeta) SetCreatedBy(createdBy string) { o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy } -func (o *PluginMeta) GetUpdateTimestamp() time.Time { +func (o *Meta) GetUpdateTimestamp() time.Time { if o.ObjectMeta.Annotations == nil { o.ObjectMeta.Annotations = make(map[string]string) } @@ -190,7 +197,7 @@ func (o *PluginMeta) GetUpdateTimestamp() time.Time { return parsed } -func (o *PluginMeta) SetUpdateTimestamp(updateTimestamp time.Time) { +func (o *Meta) SetUpdateTimestamp(updateTimestamp time.Time) { if o.ObjectMeta.Annotations == nil { o.ObjectMeta.Annotations = make(map[string]string) } @@ -198,7 +205,7 @@ func (o *PluginMeta) SetUpdateTimestamp(updateTimestamp time.Time) { o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) } -func (o *PluginMeta) GetUpdatedBy() string { +func (o *Meta) GetUpdatedBy() string { if o.ObjectMeta.Annotations == nil { o.ObjectMeta.Annotations = make(map[string]string) } @@ -206,7 +213,7 @@ func (o *PluginMeta) GetUpdatedBy() string { return o.ObjectMeta.Annotations["grafana.com/updatedBy"] } -func (o *PluginMeta) SetUpdatedBy(updatedBy string) { +func (o *Meta) SetUpdatedBy(updatedBy string) { if o.ObjectMeta.Annotations == nil { o.ObjectMeta.Annotations = make(map[string]string) } @@ -214,21 +221,21 @@ func (o *PluginMeta) SetUpdatedBy(updatedBy string) { o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy } -func (o *PluginMeta) Copy() resource.Object { +func (o *Meta) Copy() resource.Object { return resource.CopyObject(o) } -func (o *PluginMeta) DeepCopyObject() runtime.Object { +func (o *Meta) DeepCopyObject() runtime.Object { return o.Copy() } -func (o *PluginMeta) DeepCopy() *PluginMeta { - cpy := &PluginMeta{} +func (o *Meta) DeepCopy() *Meta { + cpy := &Meta{} o.DeepCopyInto(cpy) return cpy } -func (o *PluginMeta) DeepCopyInto(dst *PluginMeta) { +func (o *Meta) DeepCopyInto(dst *Meta) { dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion dst.TypeMeta.Kind = o.TypeMeta.Kind o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) @@ -237,34 +244,34 @@ func (o *PluginMeta) DeepCopyInto(dst *PluginMeta) { } // Interface compliance compile-time check -var _ resource.Object = &PluginMeta{} +var _ resource.Object = &Meta{} // +k8s:openapi-gen=true -type PluginMetaList struct { +type MetaList struct { metav1.TypeMeta `json:",inline" yaml:",inline"` metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []PluginMeta `json:"items" yaml:"items"` + Items []Meta `json:"items" yaml:"items"` } -func (o *PluginMetaList) DeepCopyObject() runtime.Object { +func (o *MetaList) DeepCopyObject() runtime.Object { return o.Copy() } -func (o *PluginMetaList) Copy() resource.ListObject { - cpy := &PluginMetaList{ +func (o *MetaList) Copy() resource.ListObject { + cpy := &MetaList{ TypeMeta: o.TypeMeta, - Items: make([]PluginMeta, len(o.Items)), + Items: make([]Meta, len(o.Items)), } o.ListMeta.DeepCopyInto(&cpy.ListMeta) for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*PluginMeta); ok { + if item, ok := o.Items[i].Copy().(*Meta); ok { cpy.Items[i] = *item } } return cpy } -func (o *PluginMetaList) GetItems() []resource.Object { +func (o *MetaList) GetItems() []resource.Object { items := make([]resource.Object, len(o.Items)) for i := 0; i < len(o.Items); i++ { items[i] = &o.Items[i] @@ -272,48 +279,48 @@ func (o *PluginMetaList) GetItems() []resource.Object { return items } -func (o *PluginMetaList) SetItems(items []resource.Object) { - o.Items = make([]PluginMeta, len(items)) +func (o *MetaList) SetItems(items []resource.Object) { + o.Items = make([]Meta, len(items)) for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*PluginMeta) + o.Items[i] = *items[i].(*Meta) } } -func (o *PluginMetaList) DeepCopy() *PluginMetaList { - cpy := &PluginMetaList{} +func (o *MetaList) DeepCopy() *MetaList { + cpy := &MetaList{} o.DeepCopyInto(cpy) return cpy } -func (o *PluginMetaList) DeepCopyInto(dst *PluginMetaList) { +func (o *MetaList) DeepCopyInto(dst *MetaList) { resource.CopyObjectInto(dst, o) } // Interface compliance compile-time check -var _ resource.ListObject = &PluginMetaList{} +var _ resource.ListObject = &MetaList{} // Copy methods for all subresource types // DeepCopy creates a full deep copy of Spec -func (s *PluginMetaSpec) DeepCopy() *PluginMetaSpec { - cpy := &PluginMetaSpec{} +func (s *MetaSpec) DeepCopy() *MetaSpec { + cpy := &MetaSpec{} s.DeepCopyInto(cpy) return cpy } // DeepCopyInto deep copies Spec into another Spec object -func (s *PluginMetaSpec) DeepCopyInto(dst *PluginMetaSpec) { +func (s *MetaSpec) DeepCopyInto(dst *MetaSpec) { resource.CopyObjectInto(dst, s) } -// DeepCopy creates a full deep copy of PluginMetaStatus -func (s *PluginMetaStatus) DeepCopy() *PluginMetaStatus { - cpy := &PluginMetaStatus{} +// DeepCopy creates a full deep copy of MetaStatus +func (s *MetaStatus) DeepCopy() *MetaStatus { + cpy := &MetaStatus{} s.DeepCopyInto(cpy) return cpy } -// DeepCopyInto deep copies PluginMetaStatus into another PluginMetaStatus object -func (s *PluginMetaStatus) DeepCopyInto(dst *PluginMetaStatus) { +// DeepCopyInto deep copies MetaStatus into another MetaStatus object +func (s *MetaStatus) DeepCopyInto(dst *MetaStatus) { resource.CopyObjectInto(dst, s) } diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/meta_schema_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_schema_gen.go new file mode 100644 index 00000000000..38b45140ff6 --- /dev/null +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_schema_gen.go @@ -0,0 +1,34 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v0alpha1 + +import ( + "github.com/grafana/grafana-app-sdk/resource" +) + +// schema is unexported to prevent accidental overwrites +var ( + schemaMeta = resource.NewSimpleSchema("plugins.grafana.app", "v0alpha1", NewMeta(), &MetaList{}, resource.WithKind("Meta"), + resource.WithPlural("metas"), resource.WithScope(resource.NamespacedScope)) + kindMeta = resource.Kind{ + Schema: schemaMeta, + Codecs: map[resource.KindEncoding]resource.Codec{ + resource.KindEncodingJSON: &MetaJSONCodec{}, + }, + } +) + +// Kind returns a resource.Kind for this Schema with a JSON codec +func MetaKind() resource.Kind { + return kindMeta +} + +// Schema returns a resource.SimpleSchema representation of Meta +func MetaSchema() *resource.SimpleSchema { + return schemaMeta +} + +// Interface compliance checks +var _ resource.Schema = kindMeta diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go new file mode 100644 index 00000000000..9598da22ef7 --- /dev/null +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go @@ -0,0 +1,474 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// JSON configuration schema for Grafana plugins +// Converted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json +// +k8s:openapi-gen=true +type MetaJSONData struct { + // Unique name of the plugin + Id string `json:"id"` + // Plugin type + Type MetaJSONDataType `json:"type"` + // Human-readable name of the plugin + Name string `json:"name"` + // Metadata for the plugin + Info MetaInfo `json:"info"` + // Dependency information + Dependencies MetaDependencies `json:"dependencies"` + // Optional fields + Alerting *bool `json:"alerting,omitempty"` + Annotations *bool `json:"annotations,omitempty"` + AutoEnabled *bool `json:"autoEnabled,omitempty"` + Backend *bool `json:"backend,omitempty"` + BuildMode *string `json:"buildMode,omitempty"` + BuiltIn *bool `json:"builtIn,omitempty"` + Category *MetaJSONDataCategory `json:"category,omitempty"` + EnterpriseFeatures *MetaEnterpriseFeatures `json:"enterpriseFeatures,omitempty"` + Executable *string `json:"executable,omitempty"` + HideFromList *bool `json:"hideFromList,omitempty"` + // +listType=atomic + Includes []MetaInclude `json:"includes,omitempty"` + Logs *bool `json:"logs,omitempty"` + Metrics *bool `json:"metrics,omitempty"` + MultiValueFilterOperators *bool `json:"multiValueFilterOperators,omitempty"` + PascalName *string `json:"pascalName,omitempty"` + Preload *bool `json:"preload,omitempty"` + QueryOptions *MetaQueryOptions `json:"queryOptions,omitempty"` + // +listType=atomic + Routes []MetaRoute `json:"routes,omitempty"` + SkipDataQuery *bool `json:"skipDataQuery,omitempty"` + State *MetaJSONDataState `json:"state,omitempty"` + Streaming *bool `json:"streaming,omitempty"` + Suggestions *bool `json:"suggestions,omitempty"` + Tracing *bool `json:"tracing,omitempty"` + Iam *MetaIAM `json:"iam,omitempty"` + // +listType=atomic + Roles []MetaRole `json:"roles,omitempty"` + Extensions *MetaExtensions `json:"extensions,omitempty"` +} + +// NewMetaJSONData creates a new MetaJSONData object. +func NewMetaJSONData() *MetaJSONData { + return &MetaJSONData{ + Info: *NewMetaInfo(), + Dependencies: *NewMetaDependencies(), + } +} + +// +k8s:openapi-gen=true +type MetaInfo struct { + // Required fields + // +listType=set + Keywords []string `json:"keywords"` + Logos MetaV0alpha1InfoLogos `json:"logos"` + Updated string `json:"updated"` + Version string `json:"version"` + // Optional fields + Author *MetaV0alpha1InfoAuthor `json:"author,omitempty"` + Description *string `json:"description,omitempty"` + // +listType=atomic + Links []MetaV0alpha1InfoLinks `json:"links,omitempty"` + // +listType=atomic + Screenshots []MetaV0alpha1InfoScreenshots `json:"screenshots,omitempty"` +} + +// NewMetaInfo creates a new MetaInfo object. +func NewMetaInfo() *MetaInfo { + return &MetaInfo{ + Keywords: []string{}, + Logos: *NewMetaV0alpha1InfoLogos(), + } +} + +// +k8s:openapi-gen=true +type MetaDependencies struct { + // Required field + GrafanaDependency string `json:"grafanaDependency"` + // Optional fields + GrafanaVersion *string `json:"grafanaVersion,omitempty"` + // +listType=set + // +listMapKey=id + Plugins []MetaV0alpha1DependenciesPlugins `json:"plugins,omitempty"` + Extensions *MetaV0alpha1DependenciesExtensions `json:"extensions,omitempty"` +} + +// NewMetaDependencies creates a new MetaDependencies object. +func NewMetaDependencies() *MetaDependencies { + return &MetaDependencies{} +} + +// +k8s:openapi-gen=true +type MetaEnterpriseFeatures struct { + // Allow additional properties + HealthDiagnosticsErrors *bool `json:"healthDiagnosticsErrors,omitempty"` +} + +// NewMetaEnterpriseFeatures creates a new MetaEnterpriseFeatures object. +func NewMetaEnterpriseFeatures() *MetaEnterpriseFeatures { + return &MetaEnterpriseFeatures{ + HealthDiagnosticsErrors: (func(input bool) *bool { return &input })(false), + } +} + +// +k8s:openapi-gen=true +type MetaInclude struct { + Uid *string `json:"uid,omitempty"` + Type *MetaIncludeType `json:"type,omitempty"` + Name *string `json:"name,omitempty"` + Component *string `json:"component,omitempty"` + Role *MetaIncludeRole `json:"role,omitempty"` + Action *string `json:"action,omitempty"` + Path *string `json:"path,omitempty"` + AddToNav *bool `json:"addToNav,omitempty"` + DefaultNav *bool `json:"defaultNav,omitempty"` + Icon *string `json:"icon,omitempty"` +} + +// NewMetaInclude creates a new MetaInclude object. +func NewMetaInclude() *MetaInclude { + return &MetaInclude{} +} + +// +k8s:openapi-gen=true +type MetaQueryOptions struct { + MaxDataPoints *bool `json:"maxDataPoints,omitempty"` + MinInterval *bool `json:"minInterval,omitempty"` + CacheTimeout *bool `json:"cacheTimeout,omitempty"` +} + +// NewMetaQueryOptions creates a new MetaQueryOptions object. +func NewMetaQueryOptions() *MetaQueryOptions { + return &MetaQueryOptions{} +} + +// +k8s:openapi-gen=true +type MetaRoute struct { + Path *string `json:"path,omitempty"` + Method *string `json:"method,omitempty"` + Url *string `json:"url,omitempty"` + ReqSignedIn *bool `json:"reqSignedIn,omitempty"` + ReqRole *string `json:"reqRole,omitempty"` + ReqAction *string `json:"reqAction,omitempty"` + // +listType=atomic + Headers []string `json:"headers,omitempty"` + Body map[string]interface{} `json:"body,omitempty"` + TokenAuth *MetaV0alpha1RouteTokenAuth `json:"tokenAuth,omitempty"` + JwtTokenAuth *MetaV0alpha1RouteJwtTokenAuth `json:"jwtTokenAuth,omitempty"` + // +listType=atomic + UrlParams []MetaV0alpha1RouteUrlParams `json:"urlParams,omitempty"` +} + +// NewMetaRoute creates a new MetaRoute object. +func NewMetaRoute() *MetaRoute { + return &MetaRoute{} +} + +// +k8s:openapi-gen=true +type MetaIAM struct { + // +listType=atomic + Permissions []MetaV0alpha1IAMPermissions `json:"permissions,omitempty"` +} + +// NewMetaIAM creates a new MetaIAM object. +func NewMetaIAM() *MetaIAM { + return &MetaIAM{} +} + +// +k8s:openapi-gen=true +type MetaRole struct { + Role *MetaV0alpha1RoleRole `json:"role,omitempty"` + // +listType=set + Grants []string `json:"grants,omitempty"` +} + +// NewMetaRole creates a new MetaRole object. +func NewMetaRole() *MetaRole { + return &MetaRole{} +} + +// +k8s:openapi-gen=true +type MetaExtensions struct { + // +listType=atomic + AddedComponents []MetaV0alpha1ExtensionsAddedComponents `json:"addedComponents,omitempty"` + // +listType=atomic + AddedLinks []MetaV0alpha1ExtensionsAddedLinks `json:"addedLinks,omitempty"` + // +listType=set + // +listMapKey=id + ExposedComponents []MetaV0alpha1ExtensionsExposedComponents `json:"exposedComponents,omitempty"` + // +listType=set + // +listMapKey=id + ExtensionPoints []MetaV0alpha1ExtensionsExtensionPoints `json:"extensionPoints,omitempty"` +} + +// NewMetaExtensions creates a new MetaExtensions object. +func NewMetaExtensions() *MetaExtensions { + return &MetaExtensions{} +} + +// +k8s:openapi-gen=true +type MetaSpec struct { + PluginJSON MetaJSONData `json:"pluginJSON"` +} + +// NewMetaSpec creates a new MetaSpec object. +func NewMetaSpec() *MetaSpec { + return &MetaSpec{ + PluginJSON: *NewMetaJSONData(), + } +} + +// +k8s:openapi-gen=true +type MetaV0alpha1InfoLogos struct { + Small string `json:"small"` + Large string `json:"large"` +} + +// NewMetaV0alpha1InfoLogos creates a new MetaV0alpha1InfoLogos object. +func NewMetaV0alpha1InfoLogos() *MetaV0alpha1InfoLogos { + return &MetaV0alpha1InfoLogos{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1InfoAuthor struct { + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + Url *string `json:"url,omitempty"` +} + +// NewMetaV0alpha1InfoAuthor creates a new MetaV0alpha1InfoAuthor object. +func NewMetaV0alpha1InfoAuthor() *MetaV0alpha1InfoAuthor { + return &MetaV0alpha1InfoAuthor{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1InfoLinks struct { + Name *string `json:"name,omitempty"` + Url *string `json:"url,omitempty"` +} + +// NewMetaV0alpha1InfoLinks creates a new MetaV0alpha1InfoLinks object. +func NewMetaV0alpha1InfoLinks() *MetaV0alpha1InfoLinks { + return &MetaV0alpha1InfoLinks{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1InfoScreenshots struct { + Name *string `json:"name,omitempty"` + Path *string `json:"path,omitempty"` +} + +// NewMetaV0alpha1InfoScreenshots creates a new MetaV0alpha1InfoScreenshots object. +func NewMetaV0alpha1InfoScreenshots() *MetaV0alpha1InfoScreenshots { + return &MetaV0alpha1InfoScreenshots{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1DependenciesPlugins struct { + Id string `json:"id"` + Type MetaV0alpha1DependenciesPluginsType `json:"type"` + Name string `json:"name"` +} + +// NewMetaV0alpha1DependenciesPlugins creates a new MetaV0alpha1DependenciesPlugins object. +func NewMetaV0alpha1DependenciesPlugins() *MetaV0alpha1DependenciesPlugins { + return &MetaV0alpha1DependenciesPlugins{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1DependenciesExtensions struct { + // +listType=set + ExposedComponents []string `json:"exposedComponents,omitempty"` +} + +// NewMetaV0alpha1DependenciesExtensions creates a new MetaV0alpha1DependenciesExtensions object. +func NewMetaV0alpha1DependenciesExtensions() *MetaV0alpha1DependenciesExtensions { + return &MetaV0alpha1DependenciesExtensions{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1RouteTokenAuth struct { + Url *string `json:"url,omitempty"` + // +listType=set + Scopes []string `json:"scopes,omitempty"` + Params map[string]interface{} `json:"params,omitempty"` +} + +// NewMetaV0alpha1RouteTokenAuth creates a new MetaV0alpha1RouteTokenAuth object. +func NewMetaV0alpha1RouteTokenAuth() *MetaV0alpha1RouteTokenAuth { + return &MetaV0alpha1RouteTokenAuth{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1RouteJwtTokenAuth struct { + Url *string `json:"url,omitempty"` + // +listType=set + Scopes []string `json:"scopes,omitempty"` + Params map[string]interface{} `json:"params,omitempty"` +} + +// NewMetaV0alpha1RouteJwtTokenAuth creates a new MetaV0alpha1RouteJwtTokenAuth object. +func NewMetaV0alpha1RouteJwtTokenAuth() *MetaV0alpha1RouteJwtTokenAuth { + return &MetaV0alpha1RouteJwtTokenAuth{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1RouteUrlParams struct { + Name *string `json:"name,omitempty"` + Content *string `json:"content,omitempty"` +} + +// NewMetaV0alpha1RouteUrlParams creates a new MetaV0alpha1RouteUrlParams object. +func NewMetaV0alpha1RouteUrlParams() *MetaV0alpha1RouteUrlParams { + return &MetaV0alpha1RouteUrlParams{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1IAMPermissions struct { + Action *string `json:"action,omitempty"` + Scope *string `json:"scope,omitempty"` +} + +// NewMetaV0alpha1IAMPermissions creates a new MetaV0alpha1IAMPermissions object. +func NewMetaV0alpha1IAMPermissions() *MetaV0alpha1IAMPermissions { + return &MetaV0alpha1IAMPermissions{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1RoleRolePermissions struct { + Action *string `json:"action,omitempty"` + Scope *string `json:"scope,omitempty"` +} + +// NewMetaV0alpha1RoleRolePermissions creates a new MetaV0alpha1RoleRolePermissions object. +func NewMetaV0alpha1RoleRolePermissions() *MetaV0alpha1RoleRolePermissions { + return &MetaV0alpha1RoleRolePermissions{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1RoleRole struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + // +listType=atomic + Permissions []MetaV0alpha1RoleRolePermissions `json:"permissions,omitempty"` +} + +// NewMetaV0alpha1RoleRole creates a new MetaV0alpha1RoleRole object. +func NewMetaV0alpha1RoleRole() *MetaV0alpha1RoleRole { + return &MetaV0alpha1RoleRole{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1ExtensionsAddedComponents struct { + // +listType=set + Targets []string `json:"targets"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` +} + +// NewMetaV0alpha1ExtensionsAddedComponents creates a new MetaV0alpha1ExtensionsAddedComponents object. +func NewMetaV0alpha1ExtensionsAddedComponents() *MetaV0alpha1ExtensionsAddedComponents { + return &MetaV0alpha1ExtensionsAddedComponents{ + Targets: []string{}, + } +} + +// +k8s:openapi-gen=true +type MetaV0alpha1ExtensionsAddedLinks struct { + // +listType=set + Targets []string `json:"targets"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` +} + +// NewMetaV0alpha1ExtensionsAddedLinks creates a new MetaV0alpha1ExtensionsAddedLinks object. +func NewMetaV0alpha1ExtensionsAddedLinks() *MetaV0alpha1ExtensionsAddedLinks { + return &MetaV0alpha1ExtensionsAddedLinks{ + Targets: []string{}, + } +} + +// +k8s:openapi-gen=true +type MetaV0alpha1ExtensionsExposedComponents struct { + Id string `json:"id"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` +} + +// NewMetaV0alpha1ExtensionsExposedComponents creates a new MetaV0alpha1ExtensionsExposedComponents object. +func NewMetaV0alpha1ExtensionsExposedComponents() *MetaV0alpha1ExtensionsExposedComponents { + return &MetaV0alpha1ExtensionsExposedComponents{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1ExtensionsExtensionPoints struct { + Id string `json:"id"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` +} + +// NewMetaV0alpha1ExtensionsExtensionPoints creates a new MetaV0alpha1ExtensionsExtensionPoints object. +func NewMetaV0alpha1ExtensionsExtensionPoints() *MetaV0alpha1ExtensionsExtensionPoints { + return &MetaV0alpha1ExtensionsExtensionPoints{} +} + +// +k8s:openapi-gen=true +type MetaJSONDataType string + +const ( + MetaJSONDataTypeApp MetaJSONDataType = "app" + MetaJSONDataTypeDatasource MetaJSONDataType = "datasource" + MetaJSONDataTypePanel MetaJSONDataType = "panel" + MetaJSONDataTypeRenderer MetaJSONDataType = "renderer" +) + +// +k8s:openapi-gen=true +type MetaJSONDataCategory string + +const ( + MetaJSONDataCategoryTsdb MetaJSONDataCategory = "tsdb" + MetaJSONDataCategoryLogging MetaJSONDataCategory = "logging" + MetaJSONDataCategoryCloud MetaJSONDataCategory = "cloud" + MetaJSONDataCategoryTracing MetaJSONDataCategory = "tracing" + MetaJSONDataCategoryProfiling MetaJSONDataCategory = "profiling" + MetaJSONDataCategorySql MetaJSONDataCategory = "sql" + MetaJSONDataCategoryEnterprise MetaJSONDataCategory = "enterprise" + MetaJSONDataCategoryIot MetaJSONDataCategory = "iot" + MetaJSONDataCategoryOther MetaJSONDataCategory = "other" +) + +// +k8s:openapi-gen=true +type MetaJSONDataState string + +const ( + MetaJSONDataStateAlpha MetaJSONDataState = "alpha" + MetaJSONDataStateBeta MetaJSONDataState = "beta" +) + +// +k8s:openapi-gen=true +type MetaIncludeType string + +const ( + MetaIncludeTypeDashboard MetaIncludeType = "dashboard" + MetaIncludeTypePage MetaIncludeType = "page" + MetaIncludeTypePanel MetaIncludeType = "panel" + MetaIncludeTypeDatasource MetaIncludeType = "datasource" +) + +// +k8s:openapi-gen=true +type MetaIncludeRole string + +const ( + MetaIncludeRoleAdmin MetaIncludeRole = "Admin" + MetaIncludeRoleEditor MetaIncludeRole = "Editor" + MetaIncludeRoleViewer MetaIncludeRole = "Viewer" +) + +// +k8s:openapi-gen=true +type MetaV0alpha1DependenciesPluginsType string + +const ( + MetaV0alpha1DependenciesPluginsTypeApp MetaV0alpha1DependenciesPluginsType = "app" + MetaV0alpha1DependenciesPluginsTypeDatasource MetaV0alpha1DependenciesPluginsType = "datasource" + MetaV0alpha1DependenciesPluginsTypePanel MetaV0alpha1DependenciesPluginsType = "panel" +) diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_status_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_status_gen.go similarity index 52% rename from apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_status_gen.go rename to apps/plugins/pkg/apis/plugins/v0alpha1/meta_status_gen.go index 60fa37dbb32..5f37ac58fb7 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_status_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_status_gen.go @@ -3,42 +3,42 @@ package v0alpha1 // +k8s:openapi-gen=true -type PluginMetastatusOperatorState struct { +type MetastatusOperatorState 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 PluginMetaStatusOperatorStateState `json:"state"` + State MetaStatusOperatorStateState `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"` } -// NewPluginMetastatusOperatorState creates a new PluginMetastatusOperatorState object. -func NewPluginMetastatusOperatorState() *PluginMetastatusOperatorState { - return &PluginMetastatusOperatorState{} +// NewMetastatusOperatorState creates a new MetastatusOperatorState object. +func NewMetastatusOperatorState() *MetastatusOperatorState { + return &MetastatusOperatorState{} } // +k8s:openapi-gen=true -type PluginMetaStatus struct { +type MetaStatus 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]PluginMetastatusOperatorState `json:"operatorStates,omitempty"` + OperatorStates map[string]MetastatusOperatorState `json:"operatorStates,omitempty"` // additionalFields is reserved for future use AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` } -// NewPluginMetaStatus creates a new PluginMetaStatus object. -func NewPluginMetaStatus() *PluginMetaStatus { - return &PluginMetaStatus{} +// NewMetaStatus creates a new MetaStatus object. +func NewMetaStatus() *MetaStatus { + return &MetaStatus{} } // +k8s:openapi-gen=true -type PluginMetaStatusOperatorStateState string +type MetaStatusOperatorStateState string const ( - PluginMetaStatusOperatorStateStateSuccess PluginMetaStatusOperatorStateState = "success" - PluginMetaStatusOperatorStateStateInProgress PluginMetaStatusOperatorStateState = "in_progress" - PluginMetaStatusOperatorStateStateFailed PluginMetaStatusOperatorStateState = "failed" + MetaStatusOperatorStateStateSuccess MetaStatusOperatorStateState = "success" + MetaStatusOperatorStateStateInProgress MetaStatusOperatorStateState = "in_progress" + MetaStatusOperatorStateStateFailed MetaStatusOperatorStateState = "failed" ) diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_object_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_object_gen.go index d2cdedba399..b92b11f4cba 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_object_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_object_gen.go @@ -25,6 +25,13 @@ type Plugin struct { Status PluginStatus `json:"status" yaml:"status"` } +func NewPlugin() *Plugin { + return &Plugin{ + Spec: *NewPluginSpec(), + Status: *NewPluginStatus(), + } +} + func (o *Plugin) GetSpec() any { return o.Spec } diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_schema_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_schema_gen.go index 97024275a1c..144ddd5af89 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_schema_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaPlugin = resource.NewSimpleSchema("plugins.grafana.app", "v0alpha1", &Plugin{}, &PluginList{}, resource.WithKind("Plugin"), + schemaPlugin = resource.NewSimpleSchema("plugins.grafana.app", "v0alpha1", NewPlugin(), &PluginList{}, resource.WithKind("Plugin"), resource.WithPlural("plugins"), resource.WithScope(resource.NamespacedScope)) kindPlugin = resource.Kind{ Schema: schemaPlugin, diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_schema_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_schema_gen.go deleted file mode 100644 index a4022c8de97..00000000000 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaPluginMeta = resource.NewSimpleSchema("plugins.grafana.app", "v0alpha1", &PluginMeta{}, &PluginMetaList{}, resource.WithKind("PluginMeta"), - resource.WithPlural("pluginmetas"), resource.WithScope(resource.NamespacedScope)) - kindPluginMeta = resource.Kind{ - Schema: schemaPluginMeta, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &PluginMetaJSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func PluginMetaKind() resource.Kind { - return kindPluginMeta -} - -// Schema returns a resource.SimpleSchema representation of PluginMeta -func PluginMetaSchema() *resource.SimpleSchema { - return schemaPluginMeta -} - -// Interface compliance checks -var _ resource.Schema = kindPluginMeta diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_spec_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_spec_gen.go deleted file mode 100644 index 14a3b0515bd..00000000000 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/pluginmeta_spec_gen.go +++ /dev/null @@ -1,474 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -// JSON configuration schema for Grafana plugins -// Converted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json -// +k8s:openapi-gen=true -type PluginMetaJSONData struct { - // Unique name of the plugin - Id string `json:"id"` - // Plugin type - Type PluginMetaJSONDataType `json:"type"` - // Human-readable name of the plugin - Name string `json:"name"` - // Metadata for the plugin - Info PluginMetaInfo `json:"info"` - // Dependency information - Dependencies PluginMetaDependencies `json:"dependencies"` - // Optional fields - Alerting *bool `json:"alerting,omitempty"` - Annotations *bool `json:"annotations,omitempty"` - AutoEnabled *bool `json:"autoEnabled,omitempty"` - Backend *bool `json:"backend,omitempty"` - BuildMode *string `json:"buildMode,omitempty"` - BuiltIn *bool `json:"builtIn,omitempty"` - Category *PluginMetaJSONDataCategory `json:"category,omitempty"` - EnterpriseFeatures *PluginMetaEnterpriseFeatures `json:"enterpriseFeatures,omitempty"` - Executable *string `json:"executable,omitempty"` - HideFromList *bool `json:"hideFromList,omitempty"` - // +listType=atomic - Includes []PluginMetaInclude `json:"includes,omitempty"` - Logs *bool `json:"logs,omitempty"` - Metrics *bool `json:"metrics,omitempty"` - MultiValueFilterOperators *bool `json:"multiValueFilterOperators,omitempty"` - PascalName *string `json:"pascalName,omitempty"` - Preload *bool `json:"preload,omitempty"` - QueryOptions *PluginMetaQueryOptions `json:"queryOptions,omitempty"` - // +listType=atomic - Routes []PluginMetaRoute `json:"routes,omitempty"` - SkipDataQuery *bool `json:"skipDataQuery,omitempty"` - State *PluginMetaJSONDataState `json:"state,omitempty"` - Streaming *bool `json:"streaming,omitempty"` - Suggestions *bool `json:"suggestions,omitempty"` - Tracing *bool `json:"tracing,omitempty"` - Iam *PluginMetaIAM `json:"iam,omitempty"` - // +listType=atomic - Roles []PluginMetaRole `json:"roles,omitempty"` - Extensions *PluginMetaExtensions `json:"extensions,omitempty"` -} - -// NewPluginMetaJSONData creates a new PluginMetaJSONData object. -func NewPluginMetaJSONData() *PluginMetaJSONData { - return &PluginMetaJSONData{ - Info: *NewPluginMetaInfo(), - Dependencies: *NewPluginMetaDependencies(), - } -} - -// +k8s:openapi-gen=true -type PluginMetaInfo struct { - // Required fields - // +listType=set - Keywords []string `json:"keywords"` - Logos PluginMetaV0alpha1InfoLogos `json:"logos"` - Updated string `json:"updated"` - Version string `json:"version"` - // Optional fields - Author *PluginMetaV0alpha1InfoAuthor `json:"author,omitempty"` - Description *string `json:"description,omitempty"` - // +listType=atomic - Links []PluginMetaV0alpha1InfoLinks `json:"links,omitempty"` - // +listType=atomic - Screenshots []PluginMetaV0alpha1InfoScreenshots `json:"screenshots,omitempty"` -} - -// NewPluginMetaInfo creates a new PluginMetaInfo object. -func NewPluginMetaInfo() *PluginMetaInfo { - return &PluginMetaInfo{ - Keywords: []string{}, - Logos: *NewPluginMetaV0alpha1InfoLogos(), - } -} - -// +k8s:openapi-gen=true -type PluginMetaDependencies struct { - // Required field - GrafanaDependency string `json:"grafanaDependency"` - // Optional fields - GrafanaVersion *string `json:"grafanaVersion,omitempty"` - // +listType=set - // +listMapKey=id - Plugins []PluginMetaV0alpha1DependenciesPlugins `json:"plugins,omitempty"` - Extensions *PluginMetaV0alpha1DependenciesExtensions `json:"extensions,omitempty"` -} - -// NewPluginMetaDependencies creates a new PluginMetaDependencies object. -func NewPluginMetaDependencies() *PluginMetaDependencies { - return &PluginMetaDependencies{} -} - -// +k8s:openapi-gen=true -type PluginMetaEnterpriseFeatures struct { - // Allow additional properties - HealthDiagnosticsErrors *bool `json:"healthDiagnosticsErrors,omitempty"` -} - -// NewPluginMetaEnterpriseFeatures creates a new PluginMetaEnterpriseFeatures object. -func NewPluginMetaEnterpriseFeatures() *PluginMetaEnterpriseFeatures { - return &PluginMetaEnterpriseFeatures{ - HealthDiagnosticsErrors: (func(input bool) *bool { return &input })(false), - } -} - -// +k8s:openapi-gen=true -type PluginMetaInclude struct { - Uid *string `json:"uid,omitempty"` - Type *PluginMetaIncludeType `json:"type,omitempty"` - Name *string `json:"name,omitempty"` - Component *string `json:"component,omitempty"` - Role *PluginMetaIncludeRole `json:"role,omitempty"` - Action *string `json:"action,omitempty"` - Path *string `json:"path,omitempty"` - AddToNav *bool `json:"addToNav,omitempty"` - DefaultNav *bool `json:"defaultNav,omitempty"` - Icon *string `json:"icon,omitempty"` -} - -// NewPluginMetaInclude creates a new PluginMetaInclude object. -func NewPluginMetaInclude() *PluginMetaInclude { - return &PluginMetaInclude{} -} - -// +k8s:openapi-gen=true -type PluginMetaQueryOptions struct { - MaxDataPoints *bool `json:"maxDataPoints,omitempty"` - MinInterval *bool `json:"minInterval,omitempty"` - CacheTimeout *bool `json:"cacheTimeout,omitempty"` -} - -// NewPluginMetaQueryOptions creates a new PluginMetaQueryOptions object. -func NewPluginMetaQueryOptions() *PluginMetaQueryOptions { - return &PluginMetaQueryOptions{} -} - -// +k8s:openapi-gen=true -type PluginMetaRoute struct { - Path *string `json:"path,omitempty"` - Method *string `json:"method,omitempty"` - Url *string `json:"url,omitempty"` - ReqSignedIn *bool `json:"reqSignedIn,omitempty"` - ReqRole *string `json:"reqRole,omitempty"` - ReqAction *string `json:"reqAction,omitempty"` - // +listType=atomic - Headers []string `json:"headers,omitempty"` - Body map[string]interface{} `json:"body,omitempty"` - TokenAuth *PluginMetaV0alpha1RouteTokenAuth `json:"tokenAuth,omitempty"` - JwtTokenAuth *PluginMetaV0alpha1RouteJwtTokenAuth `json:"jwtTokenAuth,omitempty"` - // +listType=atomic - UrlParams []PluginMetaV0alpha1RouteUrlParams `json:"urlParams,omitempty"` -} - -// NewPluginMetaRoute creates a new PluginMetaRoute object. -func NewPluginMetaRoute() *PluginMetaRoute { - return &PluginMetaRoute{} -} - -// +k8s:openapi-gen=true -type PluginMetaIAM struct { - // +listType=atomic - Permissions []PluginMetaV0alpha1IAMPermissions `json:"permissions,omitempty"` -} - -// NewPluginMetaIAM creates a new PluginMetaIAM object. -func NewPluginMetaIAM() *PluginMetaIAM { - return &PluginMetaIAM{} -} - -// +k8s:openapi-gen=true -type PluginMetaRole struct { - Role *PluginMetaV0alpha1RoleRole `json:"role,omitempty"` - // +listType=set - Grants []string `json:"grants,omitempty"` -} - -// NewPluginMetaRole creates a new PluginMetaRole object. -func NewPluginMetaRole() *PluginMetaRole { - return &PluginMetaRole{} -} - -// +k8s:openapi-gen=true -type PluginMetaExtensions struct { - // +listType=atomic - AddedComponents []PluginMetaV0alpha1ExtensionsAddedComponents `json:"addedComponents,omitempty"` - // +listType=atomic - AddedLinks []PluginMetaV0alpha1ExtensionsAddedLinks `json:"addedLinks,omitempty"` - // +listType=set - // +listMapKey=id - ExposedComponents []PluginMetaV0alpha1ExtensionsExposedComponents `json:"exposedComponents,omitempty"` - // +listType=set - // +listMapKey=id - ExtensionPoints []PluginMetaV0alpha1ExtensionsExtensionPoints `json:"extensionPoints,omitempty"` -} - -// NewPluginMetaExtensions creates a new PluginMetaExtensions object. -func NewPluginMetaExtensions() *PluginMetaExtensions { - return &PluginMetaExtensions{} -} - -// +k8s:openapi-gen=true -type PluginMetaSpec struct { - PluginJSON PluginMetaJSONData `json:"pluginJSON"` -} - -// NewPluginMetaSpec creates a new PluginMetaSpec object. -func NewPluginMetaSpec() *PluginMetaSpec { - return &PluginMetaSpec{ - PluginJSON: *NewPluginMetaJSONData(), - } -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1InfoLogos struct { - Small string `json:"small"` - Large string `json:"large"` -} - -// NewPluginMetaV0alpha1InfoLogos creates a new PluginMetaV0alpha1InfoLogos object. -func NewPluginMetaV0alpha1InfoLogos() *PluginMetaV0alpha1InfoLogos { - return &PluginMetaV0alpha1InfoLogos{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1InfoAuthor struct { - Name *string `json:"name,omitempty"` - Email *string `json:"email,omitempty"` - Url *string `json:"url,omitempty"` -} - -// NewPluginMetaV0alpha1InfoAuthor creates a new PluginMetaV0alpha1InfoAuthor object. -func NewPluginMetaV0alpha1InfoAuthor() *PluginMetaV0alpha1InfoAuthor { - return &PluginMetaV0alpha1InfoAuthor{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1InfoLinks struct { - Name *string `json:"name,omitempty"` - Url *string `json:"url,omitempty"` -} - -// NewPluginMetaV0alpha1InfoLinks creates a new PluginMetaV0alpha1InfoLinks object. -func NewPluginMetaV0alpha1InfoLinks() *PluginMetaV0alpha1InfoLinks { - return &PluginMetaV0alpha1InfoLinks{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1InfoScreenshots struct { - Name *string `json:"name,omitempty"` - Path *string `json:"path,omitempty"` -} - -// NewPluginMetaV0alpha1InfoScreenshots creates a new PluginMetaV0alpha1InfoScreenshots object. -func NewPluginMetaV0alpha1InfoScreenshots() *PluginMetaV0alpha1InfoScreenshots { - return &PluginMetaV0alpha1InfoScreenshots{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1DependenciesPlugins struct { - Id string `json:"id"` - Type PluginMetaV0alpha1DependenciesPluginsType `json:"type"` - Name string `json:"name"` -} - -// NewPluginMetaV0alpha1DependenciesPlugins creates a new PluginMetaV0alpha1DependenciesPlugins object. -func NewPluginMetaV0alpha1DependenciesPlugins() *PluginMetaV0alpha1DependenciesPlugins { - return &PluginMetaV0alpha1DependenciesPlugins{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1DependenciesExtensions struct { - // +listType=set - ExposedComponents []string `json:"exposedComponents,omitempty"` -} - -// NewPluginMetaV0alpha1DependenciesExtensions creates a new PluginMetaV0alpha1DependenciesExtensions object. -func NewPluginMetaV0alpha1DependenciesExtensions() *PluginMetaV0alpha1DependenciesExtensions { - return &PluginMetaV0alpha1DependenciesExtensions{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1RouteTokenAuth struct { - Url *string `json:"url,omitempty"` - // +listType=set - Scopes []string `json:"scopes,omitempty"` - Params map[string]interface{} `json:"params,omitempty"` -} - -// NewPluginMetaV0alpha1RouteTokenAuth creates a new PluginMetaV0alpha1RouteTokenAuth object. -func NewPluginMetaV0alpha1RouteTokenAuth() *PluginMetaV0alpha1RouteTokenAuth { - return &PluginMetaV0alpha1RouteTokenAuth{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1RouteJwtTokenAuth struct { - Url *string `json:"url,omitempty"` - // +listType=set - Scopes []string `json:"scopes,omitempty"` - Params map[string]interface{} `json:"params,omitempty"` -} - -// NewPluginMetaV0alpha1RouteJwtTokenAuth creates a new PluginMetaV0alpha1RouteJwtTokenAuth object. -func NewPluginMetaV0alpha1RouteJwtTokenAuth() *PluginMetaV0alpha1RouteJwtTokenAuth { - return &PluginMetaV0alpha1RouteJwtTokenAuth{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1RouteUrlParams struct { - Name *string `json:"name,omitempty"` - Content *string `json:"content,omitempty"` -} - -// NewPluginMetaV0alpha1RouteUrlParams creates a new PluginMetaV0alpha1RouteUrlParams object. -func NewPluginMetaV0alpha1RouteUrlParams() *PluginMetaV0alpha1RouteUrlParams { - return &PluginMetaV0alpha1RouteUrlParams{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1IAMPermissions struct { - Action *string `json:"action,omitempty"` - Scope *string `json:"scope,omitempty"` -} - -// NewPluginMetaV0alpha1IAMPermissions creates a new PluginMetaV0alpha1IAMPermissions object. -func NewPluginMetaV0alpha1IAMPermissions() *PluginMetaV0alpha1IAMPermissions { - return &PluginMetaV0alpha1IAMPermissions{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1RoleRolePermissions struct { - Action *string `json:"action,omitempty"` - Scope *string `json:"scope,omitempty"` -} - -// NewPluginMetaV0alpha1RoleRolePermissions creates a new PluginMetaV0alpha1RoleRolePermissions object. -func NewPluginMetaV0alpha1RoleRolePermissions() *PluginMetaV0alpha1RoleRolePermissions { - return &PluginMetaV0alpha1RoleRolePermissions{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1RoleRole struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - // +listType=atomic - Permissions []PluginMetaV0alpha1RoleRolePermissions `json:"permissions,omitempty"` -} - -// NewPluginMetaV0alpha1RoleRole creates a new PluginMetaV0alpha1RoleRole object. -func NewPluginMetaV0alpha1RoleRole() *PluginMetaV0alpha1RoleRole { - return &PluginMetaV0alpha1RoleRole{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1ExtensionsAddedComponents struct { - // +listType=set - Targets []string `json:"targets"` - Title string `json:"title"` - Description *string `json:"description,omitempty"` -} - -// NewPluginMetaV0alpha1ExtensionsAddedComponents creates a new PluginMetaV0alpha1ExtensionsAddedComponents object. -func NewPluginMetaV0alpha1ExtensionsAddedComponents() *PluginMetaV0alpha1ExtensionsAddedComponents { - return &PluginMetaV0alpha1ExtensionsAddedComponents{ - Targets: []string{}, - } -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1ExtensionsAddedLinks struct { - // +listType=set - Targets []string `json:"targets"` - Title string `json:"title"` - Description *string `json:"description,omitempty"` -} - -// NewPluginMetaV0alpha1ExtensionsAddedLinks creates a new PluginMetaV0alpha1ExtensionsAddedLinks object. -func NewPluginMetaV0alpha1ExtensionsAddedLinks() *PluginMetaV0alpha1ExtensionsAddedLinks { - return &PluginMetaV0alpha1ExtensionsAddedLinks{ - Targets: []string{}, - } -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1ExtensionsExposedComponents struct { - Id string `json:"id"` - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` -} - -// NewPluginMetaV0alpha1ExtensionsExposedComponents creates a new PluginMetaV0alpha1ExtensionsExposedComponents object. -func NewPluginMetaV0alpha1ExtensionsExposedComponents() *PluginMetaV0alpha1ExtensionsExposedComponents { - return &PluginMetaV0alpha1ExtensionsExposedComponents{} -} - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1ExtensionsExtensionPoints struct { - Id string `json:"id"` - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` -} - -// NewPluginMetaV0alpha1ExtensionsExtensionPoints creates a new PluginMetaV0alpha1ExtensionsExtensionPoints object. -func NewPluginMetaV0alpha1ExtensionsExtensionPoints() *PluginMetaV0alpha1ExtensionsExtensionPoints { - return &PluginMetaV0alpha1ExtensionsExtensionPoints{} -} - -// +k8s:openapi-gen=true -type PluginMetaJSONDataType string - -const ( - PluginMetaJSONDataTypeApp PluginMetaJSONDataType = "app" - PluginMetaJSONDataTypeDatasource PluginMetaJSONDataType = "datasource" - PluginMetaJSONDataTypePanel PluginMetaJSONDataType = "panel" - PluginMetaJSONDataTypeRenderer PluginMetaJSONDataType = "renderer" -) - -// +k8s:openapi-gen=true -type PluginMetaJSONDataCategory string - -const ( - PluginMetaJSONDataCategoryTsdb PluginMetaJSONDataCategory = "tsdb" - PluginMetaJSONDataCategoryLogging PluginMetaJSONDataCategory = "logging" - PluginMetaJSONDataCategoryCloud PluginMetaJSONDataCategory = "cloud" - PluginMetaJSONDataCategoryTracing PluginMetaJSONDataCategory = "tracing" - PluginMetaJSONDataCategoryProfiling PluginMetaJSONDataCategory = "profiling" - PluginMetaJSONDataCategorySql PluginMetaJSONDataCategory = "sql" - PluginMetaJSONDataCategoryEnterprise PluginMetaJSONDataCategory = "enterprise" - PluginMetaJSONDataCategoryIot PluginMetaJSONDataCategory = "iot" - PluginMetaJSONDataCategoryOther PluginMetaJSONDataCategory = "other" -) - -// +k8s:openapi-gen=true -type PluginMetaJSONDataState string - -const ( - PluginMetaJSONDataStateAlpha PluginMetaJSONDataState = "alpha" - PluginMetaJSONDataStateBeta PluginMetaJSONDataState = "beta" -) - -// +k8s:openapi-gen=true -type PluginMetaIncludeType string - -const ( - PluginMetaIncludeTypeDashboard PluginMetaIncludeType = "dashboard" - PluginMetaIncludeTypePage PluginMetaIncludeType = "page" - PluginMetaIncludeTypePanel PluginMetaIncludeType = "panel" - PluginMetaIncludeTypeDatasource PluginMetaIncludeType = "datasource" -) - -// +k8s:openapi-gen=true -type PluginMetaIncludeRole string - -const ( - PluginMetaIncludeRoleAdmin PluginMetaIncludeRole = "Admin" - PluginMetaIncludeRoleEditor PluginMetaIncludeRole = "Editor" - PluginMetaIncludeRoleViewer PluginMetaIncludeRole = "Viewer" -) - -// +k8s:openapi-gen=true -type PluginMetaV0alpha1DependenciesPluginsType string - -const ( - PluginMetaV0alpha1DependenciesPluginsTypeApp PluginMetaV0alpha1DependenciesPluginsType = "app" - PluginMetaV0alpha1DependenciesPluginsTypeDatasource PluginMetaV0alpha1DependenciesPluginsType = "datasource" - PluginMetaV0alpha1DependenciesPluginsTypePanel PluginMetaV0alpha1DependenciesPluginsType = "panel" -) diff --git a/apps/plugins/pkg/apis/plugins_manifest.go b/apps/plugins/pkg/apis/plugins_manifest.go index e09c70244a7..1a351eb2baf 100644 --- a/apps/plugins/pkg/apis/plugins_manifest.go +++ b/apps/plugins/pkg/apis/plugins_manifest.go @@ -20,12 +20,12 @@ import ( ) var ( - rawSchemaPluginv0alpha1 = []byte(`{"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"},"Plugin":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"class":{"enum":["core","external"],"type":"string"},"id":{"type":"string"},"url":{"type":"string"},"version":{"type":"string"}},"required":["id","version","class"],"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"}}`) - versionSchemaPluginv0alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaPluginv0alpha1, &versionSchemaPluginv0alpha1) - rawSchemaPluginMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"suggestions":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"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"},"PluginMeta":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"pluginJSON":{"$ref":"#/components/schemas/JSONData"}},"required":["pluginJSON"],"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"}}`) - versionSchemaPluginMetav0alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaPluginMetav0alpha1, &versionSchemaPluginMetav0alpha1) + rawSchemaPluginv0alpha1 = []byte(`{"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"},"Plugin":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"class":{"enum":["core","external"],"type":"string"},"id":{"type":"string"},"url":{"type":"string"},"version":{"type":"string"}},"required":["id","version","class"],"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"}}`) + versionSchemaPluginv0alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaPluginv0alpha1, &versionSchemaPluginv0alpha1) + rawSchemaMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"suggestions":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"Meta":{"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"},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"pluginJSON":{"$ref":"#/components/schemas/JSONData"}},"required":["pluginJSON"],"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"}}`) + versionSchemaMetav0alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaMetav0alpha1, &versionSchemaMetav0alpha1) ) var appManifestData = app.ManifestData{ @@ -46,11 +46,11 @@ var appManifestData = app.ManifestData{ }, { - Kind: "PluginMeta", - Plural: "PluginMetas", + Kind: "Meta", + Plural: "Metas", Scope: "Namespaced", Conversion: false, - Schema: &versionSchemaPluginMetav0alpha1, + Schema: &versionSchemaMetav0alpha1, }, }, Routes: app.ManifestVersionRoutes{ @@ -71,8 +71,8 @@ func RemoteManifest() app.Manifest { } var kindVersionToGoType = map[string]resource.Kind{ - "Plugin/v0alpha1": v0alpha1.PluginKind(), - "PluginMeta/v0alpha1": v0alpha1.PluginMetaKind(), + "Plugin/v0alpha1": v0alpha1.PluginKind(), + "Meta/v0alpha1": v0alpha1.MetaKind(), } // ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. diff --git a/apps/plugins/pkg/app/app.go b/apps/plugins/pkg/app/app.go index 3d17cc34221..7d8614f2a3c 100644 --- a/apps/plugins/pkg/app/app.go +++ b/apps/plugins/pkg/app/app.go @@ -43,7 +43,7 @@ func New(cfg app.Config) (app.App, error) { Kind: pluginsv0alpha1.PluginKind(), }, { - Kind: pluginsv0alpha1.PluginMetaKind(), + Kind: pluginsv0alpha1.MetaKind(), }, }, } @@ -137,9 +137,9 @@ func (p *PluginAppInstaller) InstallAPIs( return client, nil } - pluginMetaGVR := pluginsv0alpha1.PluginMetaKind().GroupVersionResource() + pluginMetaGVR := pluginsv0alpha1.MetaKind().GroupVersionResource() replacedStorage := map[schema.GroupVersionResource]rest.Storage{ - pluginMetaGVR: NewPluginMetaStorage(p.metaManager, clientFactory), + pluginMetaGVR: NewMetaStorage(p.metaManager, clientFactory), } wrappedServer := &customStorageWrapper{ wrapped: server, diff --git a/apps/plugins/pkg/app/meta/cloud.go b/apps/plugins/pkg/app/meta/cloud.go index f49445d093a..799480eab96 100644 --- a/apps/plugins/pkg/app/meta/cloud.go +++ b/apps/plugins/pkg/app/meta/cloud.go @@ -96,24 +96,24 @@ func (p *CloudProvider) GetMeta(ctx context.Context, pluginID, version string) ( // grafanaComPluginVersionMeta represents the response from grafana.com API // GET /api/plugins/{pluginId}/versions/{version} type grafanaComPluginVersionMeta struct { - PluginID string `json:"pluginSlug"` - Version string `json:"version"` - URL string `json:"url"` - Commit string `json:"commit"` - Description string `json:"description"` - Keywords []string `json:"keywords"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - JSON pluginsv0alpha1.PluginMetaJSONData `json:"json"` - Readme string `json:"readme"` - Downloads int `json:"downloads"` - Verified bool `json:"verified"` - Status string `json:"status"` - StatusContext string `json:"statusContext"` - DownloadSlug string `json:"downloadSlug"` - SignatureType string `json:"signatureType"` - SignedByOrg string `json:"signedByOrg"` - SignedByOrgName string `json:"signedByOrgName"` + PluginID string `json:"pluginSlug"` + Version string `json:"version"` + URL string `json:"url"` + Commit string `json:"commit"` + Description string `json:"description"` + Keywords []string `json:"keywords"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + JSON pluginsv0alpha1.MetaJSONData `json:"json"` + Readme string `json:"readme"` + Downloads int `json:"downloads"` + Verified bool `json:"verified"` + Status string `json:"status"` + StatusContext string `json:"statusContext"` + DownloadSlug string `json:"downloadSlug"` + SignatureType string `json:"signatureType"` + SignedByOrg string `json:"signedByOrg"` + SignedByOrgName string `json:"signedByOrgName"` Packages struct { Any struct { Md5 string `json:"md5"` diff --git a/apps/plugins/pkg/app/meta/cloud_test.go b/apps/plugins/pkg/app/meta/cloud_test.go index bef2d1a5041..ea6368f1b82 100644 --- a/apps/plugins/pkg/app/meta/cloud_test.go +++ b/apps/plugins/pkg/app/meta/cloud_test.go @@ -19,10 +19,10 @@ func TestCloudProvider_GetMeta(t *testing.T) { ctx := context.Background() t.Run("successfully fetches plugin metadata", func(t *testing.T) { - expectedMeta := pluginsv0alpha1.PluginMetaJSONData{ + expectedMeta := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Test Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -108,10 +108,10 @@ func TestCloudProvider_GetMeta(t *testing.T) { t.Run("uses custom TTL when provided", func(t *testing.T) { customTTL := 2 * time.Hour - expectedMeta := pluginsv0alpha1.PluginMetaJSONData{ + expectedMeta := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Test Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/apps/plugins/pkg/app/meta/core.go b/apps/plugins/pkg/app/meta/core.go index 3452f95b24b..e16f7a23db5 100644 --- a/apps/plugins/pkg/app/meta/core.go +++ b/apps/plugins/pkg/app/meta/core.go @@ -23,7 +23,7 @@ const ( // CoreProvider retrieves plugin metadata for core plugins. type CoreProvider struct { mu sync.RWMutex - loadedPlugins map[string]pluginsv0alpha1.PluginMetaJSONData + loadedPlugins map[string]pluginsv0alpha1.MetaJSONData initialized bool ttl time.Duration } @@ -36,7 +36,7 @@ func NewCoreProvider() *CoreProvider { // NewCoreProviderWithTTL creates a new CoreProvider with a custom TTL. func NewCoreProviderWithTTL(ttl time.Duration) *CoreProvider { return &CoreProvider{ - loadedPlugins: make(map[string]pluginsv0alpha1.PluginMetaJSONData), + loadedPlugins: make(map[string]pluginsv0alpha1.MetaJSONData), ttl: ttl, } } @@ -119,17 +119,17 @@ func (p *CoreProvider) loadPlugins(ctx context.Context) error { } for _, bundle := range ps { - meta := jsonDataToPluginMetaJSONData(bundle.Primary.JSONData) + meta := jsonDataToMetaJSONData(bundle.Primary.JSONData) p.loadedPlugins[bundle.Primary.JSONData.ID] = meta } return nil } -// jsonDataToPluginMetaJSONData converts a plugins.JSONData to a pluginsv0alpha1.PluginMetaJSONData. +// jsonDataToMetaJSONData converts a plugins.JSONData to a pluginsv0alpha1.MetaJSONData. // nolint:gocyclo -func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.PluginMetaJSONData { - meta := pluginsv0alpha1.PluginMetaJSONData{ +func jsonDataToMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.MetaJSONData { + meta := pluginsv0alpha1.MetaJSONData{ Id: jsonData.ID, Name: jsonData.Name, } @@ -137,19 +137,19 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu // Map plugin type switch jsonData.Type { case plugins.TypeApp: - meta.Type = pluginsv0alpha1.PluginMetaJSONDataTypeApp + meta.Type = pluginsv0alpha1.MetaJSONDataTypeApp case plugins.TypeDataSource: - meta.Type = pluginsv0alpha1.PluginMetaJSONDataTypeDatasource + meta.Type = pluginsv0alpha1.MetaJSONDataTypeDatasource case plugins.TypePanel: - meta.Type = pluginsv0alpha1.PluginMetaJSONDataTypePanel + meta.Type = pluginsv0alpha1.MetaJSONDataTypePanel case plugins.TypeRenderer: - meta.Type = pluginsv0alpha1.PluginMetaJSONDataTypeRenderer + meta.Type = pluginsv0alpha1.MetaJSONDataTypeRenderer } // Map Info - meta.Info = pluginsv0alpha1.PluginMetaInfo{ + meta.Info = pluginsv0alpha1.MetaInfo{ Keywords: jsonData.Info.Keywords, - Logos: pluginsv0alpha1.PluginMetaV0alpha1InfoLogos{ + Logos: pluginsv0alpha1.MetaV0alpha1InfoLogos{ Small: jsonData.Info.Logos.Small, Large: jsonData.Info.Logos.Large, }, @@ -162,7 +162,7 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu } if jsonData.Info.Author.Name != "" || jsonData.Info.Author.URL != "" { - author := &pluginsv0alpha1.PluginMetaV0alpha1InfoAuthor{} + author := &pluginsv0alpha1.MetaV0alpha1InfoAuthor{} if jsonData.Info.Author.Name != "" { author.Name = &jsonData.Info.Author.Name } @@ -173,9 +173,9 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu } if len(jsonData.Info.Links) > 0 { - meta.Info.Links = make([]pluginsv0alpha1.PluginMetaV0alpha1InfoLinks, 0, len(jsonData.Info.Links)) + meta.Info.Links = make([]pluginsv0alpha1.MetaV0alpha1InfoLinks, 0, len(jsonData.Info.Links)) for _, link := range jsonData.Info.Links { - v0Link := pluginsv0alpha1.PluginMetaV0alpha1InfoLinks{} + v0Link := pluginsv0alpha1.MetaV0alpha1InfoLinks{} if link.Name != "" { v0Link.Name = &link.Name } @@ -187,9 +187,9 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu } if len(jsonData.Info.Screenshots) > 0 { - meta.Info.Screenshots = make([]pluginsv0alpha1.PluginMetaV0alpha1InfoScreenshots, 0, len(jsonData.Info.Screenshots)) + meta.Info.Screenshots = make([]pluginsv0alpha1.MetaV0alpha1InfoScreenshots, 0, len(jsonData.Info.Screenshots)) for _, screenshot := range jsonData.Info.Screenshots { - v0Screenshot := pluginsv0alpha1.PluginMetaV0alpha1InfoScreenshots{} + v0Screenshot := pluginsv0alpha1.MetaV0alpha1InfoScreenshots{} if screenshot.Name != "" { v0Screenshot.Name = &screenshot.Name } @@ -201,7 +201,7 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu } // Map Dependencies - meta.Dependencies = pluginsv0alpha1.PluginMetaDependencies{ + meta.Dependencies = pluginsv0alpha1.MetaDependencies{ GrafanaDependency: jsonData.Dependencies.GrafanaDependency, } @@ -210,18 +210,18 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu } if len(jsonData.Dependencies.Plugins) > 0 { - meta.Dependencies.Plugins = make([]pluginsv0alpha1.PluginMetaV0alpha1DependenciesPlugins, 0, len(jsonData.Dependencies.Plugins)) + meta.Dependencies.Plugins = make([]pluginsv0alpha1.MetaV0alpha1DependenciesPlugins, 0, len(jsonData.Dependencies.Plugins)) for _, dep := range jsonData.Dependencies.Plugins { - var depType pluginsv0alpha1.PluginMetaV0alpha1DependenciesPluginsType + var depType pluginsv0alpha1.MetaV0alpha1DependenciesPluginsType switch dep.Type { case "app": - depType = pluginsv0alpha1.PluginMetaV0alpha1DependenciesPluginsTypeApp + depType = pluginsv0alpha1.MetaV0alpha1DependenciesPluginsTypeApp case "datasource": - depType = pluginsv0alpha1.PluginMetaV0alpha1DependenciesPluginsTypeDatasource + depType = pluginsv0alpha1.MetaV0alpha1DependenciesPluginsTypeDatasource case "panel": - depType = pluginsv0alpha1.PluginMetaV0alpha1DependenciesPluginsTypePanel + depType = pluginsv0alpha1.MetaV0alpha1DependenciesPluginsTypePanel } - meta.Dependencies.Plugins = append(meta.Dependencies.Plugins, pluginsv0alpha1.PluginMetaV0alpha1DependenciesPlugins{ + meta.Dependencies.Plugins = append(meta.Dependencies.Plugins, pluginsv0alpha1.MetaV0alpha1DependenciesPlugins{ Id: dep.ID, Type: depType, Name: dep.Name, @@ -230,7 +230,7 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu } if len(jsonData.Dependencies.Extensions.ExposedComponents) > 0 { - meta.Dependencies.Extensions = &pluginsv0alpha1.PluginMetaV0alpha1DependenciesExtensions{ + meta.Dependencies.Extensions = &pluginsv0alpha1.MetaV0alpha1DependenciesExtensions{ ExposedComponents: jsonData.Dependencies.Extensions.ExposedComponents, } } @@ -278,40 +278,40 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu // Map category if jsonData.Category != "" { - var category pluginsv0alpha1.PluginMetaJSONDataCategory + var category pluginsv0alpha1.MetaJSONDataCategory switch jsonData.Category { case "tsdb": - category = pluginsv0alpha1.PluginMetaJSONDataCategoryTsdb + category = pluginsv0alpha1.MetaJSONDataCategoryTsdb case "logging": - category = pluginsv0alpha1.PluginMetaJSONDataCategoryLogging + category = pluginsv0alpha1.MetaJSONDataCategoryLogging case "cloud": - category = pluginsv0alpha1.PluginMetaJSONDataCategoryCloud + category = pluginsv0alpha1.MetaJSONDataCategoryCloud case "tracing": - category = pluginsv0alpha1.PluginMetaJSONDataCategoryTracing + category = pluginsv0alpha1.MetaJSONDataCategoryTracing case "profiling": - category = pluginsv0alpha1.PluginMetaJSONDataCategoryProfiling + category = pluginsv0alpha1.MetaJSONDataCategoryProfiling case "sql": - category = pluginsv0alpha1.PluginMetaJSONDataCategorySql + category = pluginsv0alpha1.MetaJSONDataCategorySql case "enterprise": - category = pluginsv0alpha1.PluginMetaJSONDataCategoryEnterprise + category = pluginsv0alpha1.MetaJSONDataCategoryEnterprise case "iot": - category = pluginsv0alpha1.PluginMetaJSONDataCategoryIot + category = pluginsv0alpha1.MetaJSONDataCategoryIot case "other": - category = pluginsv0alpha1.PluginMetaJSONDataCategoryOther + category = pluginsv0alpha1.MetaJSONDataCategoryOther default: - category = pluginsv0alpha1.PluginMetaJSONDataCategoryOther + category = pluginsv0alpha1.MetaJSONDataCategoryOther } meta.Category = &category } // Map state if jsonData.State != "" { - var state pluginsv0alpha1.PluginMetaJSONDataState + var state pluginsv0alpha1.MetaJSONDataState switch jsonData.State { case plugins.ReleaseStateAlpha: - state = pluginsv0alpha1.PluginMetaJSONDataStateAlpha + state = pluginsv0alpha1.MetaJSONDataStateAlpha case plugins.ReleaseStateBeta: - state = pluginsv0alpha1.PluginMetaJSONDataStateBeta + state = pluginsv0alpha1.MetaJSONDataStateBeta default: } if state != "" { @@ -326,7 +326,7 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu // Map QueryOptions if len(jsonData.QueryOptions) > 0 { - queryOptions := &pluginsv0alpha1.PluginMetaQueryOptions{} + queryOptions := &pluginsv0alpha1.MetaQueryOptions{} if val, ok := jsonData.QueryOptions["maxDataPoints"]; ok { queryOptions.MaxDataPoints = &val } @@ -341,23 +341,23 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu // Map Includes if len(jsonData.Includes) > 0 { - meta.Includes = make([]pluginsv0alpha1.PluginMetaInclude, 0, len(jsonData.Includes)) + meta.Includes = make([]pluginsv0alpha1.MetaInclude, 0, len(jsonData.Includes)) for _, include := range jsonData.Includes { - v0Include := pluginsv0alpha1.PluginMetaInclude{} + v0Include := pluginsv0alpha1.MetaInclude{} if include.UID != "" { v0Include.Uid = &include.UID } if include.Type != "" { - var includeType pluginsv0alpha1.PluginMetaIncludeType + var includeType pluginsv0alpha1.MetaIncludeType switch include.Type { case "dashboard": - includeType = pluginsv0alpha1.PluginMetaIncludeTypeDashboard + includeType = pluginsv0alpha1.MetaIncludeTypeDashboard case "page": - includeType = pluginsv0alpha1.PluginMetaIncludeTypePage + includeType = pluginsv0alpha1.MetaIncludeTypePage case "panel": - includeType = pluginsv0alpha1.PluginMetaIncludeTypePanel + includeType = pluginsv0alpha1.MetaIncludeTypePanel case "datasource": - includeType = pluginsv0alpha1.PluginMetaIncludeTypeDatasource + includeType = pluginsv0alpha1.MetaIncludeTypeDatasource } v0Include.Type = &includeType } @@ -368,14 +368,14 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu v0Include.Component = &include.Component } if include.Role != "" { - var role pluginsv0alpha1.PluginMetaIncludeRole + var role pluginsv0alpha1.MetaIncludeRole switch include.Role { case "Admin": - role = pluginsv0alpha1.PluginMetaIncludeRoleAdmin + role = pluginsv0alpha1.MetaIncludeRoleAdmin case "Editor": - role = pluginsv0alpha1.PluginMetaIncludeRoleEditor + role = pluginsv0alpha1.MetaIncludeRoleEditor case "Viewer": - role = pluginsv0alpha1.PluginMetaIncludeRoleViewer + role = pluginsv0alpha1.MetaIncludeRoleViewer } v0Include.Role = &role } @@ -400,9 +400,9 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu // Map Routes if len(jsonData.Routes) > 0 { - meta.Routes = make([]pluginsv0alpha1.PluginMetaRoute, 0, len(jsonData.Routes)) + meta.Routes = make([]pluginsv0alpha1.MetaRoute, 0, len(jsonData.Routes)) for _, route := range jsonData.Routes { - v0Route := pluginsv0alpha1.PluginMetaRoute{} + v0Route := pluginsv0alpha1.MetaRoute{} if route.Path != "" { v0Route.Path = &route.Path } @@ -427,9 +427,9 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu v0Route.Headers = headers } if len(route.URLParams) > 0 { - v0Route.UrlParams = make([]pluginsv0alpha1.PluginMetaV0alpha1RouteUrlParams, 0, len(route.URLParams)) + v0Route.UrlParams = make([]pluginsv0alpha1.MetaV0alpha1RouteUrlParams, 0, len(route.URLParams)) for _, param := range route.URLParams { - v0Param := pluginsv0alpha1.PluginMetaV0alpha1RouteUrlParams{} + v0Param := pluginsv0alpha1.MetaV0alpha1RouteUrlParams{} if param.Name != "" { v0Param.Name = ¶m.Name } @@ -440,7 +440,7 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu } } if route.TokenAuth != nil { - v0Route.TokenAuth = &pluginsv0alpha1.PluginMetaV0alpha1RouteTokenAuth{} + v0Route.TokenAuth = &pluginsv0alpha1.MetaV0alpha1RouteTokenAuth{} if route.TokenAuth.Url != "" { v0Route.TokenAuth.Url = &route.TokenAuth.Url } @@ -455,7 +455,7 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu } } if route.JwtTokenAuth != nil { - v0Route.JwtTokenAuth = &pluginsv0alpha1.PluginMetaV0alpha1RouteJwtTokenAuth{} + v0Route.JwtTokenAuth = &pluginsv0alpha1.MetaV0alpha1RouteJwtTokenAuth{} if route.JwtTokenAuth.Url != "" { v0Route.JwtTokenAuth.Url = &route.JwtTokenAuth.Url } @@ -482,12 +482,12 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu // Map Extensions if len(jsonData.Extensions.AddedLinks) > 0 || len(jsonData.Extensions.AddedComponents) > 0 || len(jsonData.Extensions.ExposedComponents) > 0 || len(jsonData.Extensions.ExtensionPoints) > 0 { - extensions := &pluginsv0alpha1.PluginMetaExtensions{} + extensions := &pluginsv0alpha1.MetaExtensions{} if len(jsonData.Extensions.AddedLinks) > 0 { - extensions.AddedLinks = make([]pluginsv0alpha1.PluginMetaV0alpha1ExtensionsAddedLinks, 0, len(jsonData.Extensions.AddedLinks)) + extensions.AddedLinks = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsAddedLinks, 0, len(jsonData.Extensions.AddedLinks)) for _, link := range jsonData.Extensions.AddedLinks { - v0Link := pluginsv0alpha1.PluginMetaV0alpha1ExtensionsAddedLinks{ + v0Link := pluginsv0alpha1.MetaV0alpha1ExtensionsAddedLinks{ Targets: link.Targets, Title: link.Title, } @@ -499,9 +499,9 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu } if len(jsonData.Extensions.AddedComponents) > 0 { - extensions.AddedComponents = make([]pluginsv0alpha1.PluginMetaV0alpha1ExtensionsAddedComponents, 0, len(jsonData.Extensions.AddedComponents)) + extensions.AddedComponents = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsAddedComponents, 0, len(jsonData.Extensions.AddedComponents)) for _, comp := range jsonData.Extensions.AddedComponents { - v0Comp := pluginsv0alpha1.PluginMetaV0alpha1ExtensionsAddedComponents{ + v0Comp := pluginsv0alpha1.MetaV0alpha1ExtensionsAddedComponents{ Targets: comp.Targets, Title: comp.Title, } @@ -513,9 +513,9 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu } if len(jsonData.Extensions.ExposedComponents) > 0 { - extensions.ExposedComponents = make([]pluginsv0alpha1.PluginMetaV0alpha1ExtensionsExposedComponents, 0, len(jsonData.Extensions.ExposedComponents)) + extensions.ExposedComponents = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsExposedComponents, 0, len(jsonData.Extensions.ExposedComponents)) for _, comp := range jsonData.Extensions.ExposedComponents { - v0Comp := pluginsv0alpha1.PluginMetaV0alpha1ExtensionsExposedComponents{ + v0Comp := pluginsv0alpha1.MetaV0alpha1ExtensionsExposedComponents{ Id: comp.Id, } if comp.Title != "" { @@ -529,9 +529,9 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu } if len(jsonData.Extensions.ExtensionPoints) > 0 { - extensions.ExtensionPoints = make([]pluginsv0alpha1.PluginMetaV0alpha1ExtensionsExtensionPoints, 0, len(jsonData.Extensions.ExtensionPoints)) + extensions.ExtensionPoints = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsExtensionPoints, 0, len(jsonData.Extensions.ExtensionPoints)) for _, point := range jsonData.Extensions.ExtensionPoints { - v0Point := pluginsv0alpha1.PluginMetaV0alpha1ExtensionsExtensionPoints{ + v0Point := pluginsv0alpha1.MetaV0alpha1ExtensionsExtensionPoints{ Id: point.Id, } if point.Title != "" { @@ -549,13 +549,13 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu // Map Roles if len(jsonData.Roles) > 0 { - meta.Roles = make([]pluginsv0alpha1.PluginMetaRole, 0, len(jsonData.Roles)) + meta.Roles = make([]pluginsv0alpha1.MetaRole, 0, len(jsonData.Roles)) for _, role := range jsonData.Roles { - v0Role := pluginsv0alpha1.PluginMetaRole{ + v0Role := pluginsv0alpha1.MetaRole{ Grants: role.Grants, } if role.Role.Name != "" || role.Role.Description != "" || len(role.Role.Permissions) > 0 { - v0RoleRole := &pluginsv0alpha1.PluginMetaV0alpha1RoleRole{} + v0RoleRole := &pluginsv0alpha1.MetaV0alpha1RoleRole{} if role.Role.Name != "" { v0RoleRole.Name = &role.Role.Name } @@ -563,9 +563,9 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu v0RoleRole.Description = &role.Role.Description } if len(role.Role.Permissions) > 0 { - v0RoleRole.Permissions = make([]pluginsv0alpha1.PluginMetaV0alpha1RoleRolePermissions, 0, len(role.Role.Permissions)) + v0RoleRole.Permissions = make([]pluginsv0alpha1.MetaV0alpha1RoleRolePermissions, 0, len(role.Role.Permissions)) for _, perm := range role.Role.Permissions { - v0Perm := pluginsv0alpha1.PluginMetaV0alpha1RoleRolePermissions{} + v0Perm := pluginsv0alpha1.MetaV0alpha1RoleRolePermissions{} if perm.Action != "" { v0Perm.Action = &perm.Action } @@ -583,11 +583,11 @@ func jsonDataToPluginMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.Plu // Map IAM if jsonData.IAM != nil && len(jsonData.IAM.Permissions) > 0 { - iam := &pluginsv0alpha1.PluginMetaIAM{ - Permissions: make([]pluginsv0alpha1.PluginMetaV0alpha1IAMPermissions, 0, len(jsonData.IAM.Permissions)), + iam := &pluginsv0alpha1.MetaIAM{ + Permissions: make([]pluginsv0alpha1.MetaV0alpha1IAMPermissions, 0, len(jsonData.IAM.Permissions)), } for _, perm := range jsonData.IAM.Permissions { - v0Perm := pluginsv0alpha1.PluginMetaV0alpha1IAMPermissions{} + v0Perm := pluginsv0alpha1.MetaV0alpha1IAMPermissions{} if perm.Action != "" { v0Perm.Action = &perm.Action } diff --git a/apps/plugins/pkg/app/meta/core_test.go b/apps/plugins/pkg/app/meta/core_test.go index a9d7103e4a2..d5235c9d120 100644 --- a/apps/plugins/pkg/app/meta/core_test.go +++ b/apps/plugins/pkg/app/meta/core_test.go @@ -22,10 +22,10 @@ func TestCoreProvider_GetMeta(t *testing.T) { t.Run("returns cached plugin when available", func(t *testing.T) { provider := NewCoreProvider() - expectedMeta := pluginsv0alpha1.PluginMetaJSONData{ + expectedMeta := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Test Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } provider.mu.Lock() @@ -58,10 +58,10 @@ func TestCoreProvider_GetMeta(t *testing.T) { t.Run("ignores version parameter", func(t *testing.T) { provider := NewCoreProvider() - expectedMeta := pluginsv0alpha1.PluginMetaJSONData{ + expectedMeta := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Test Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } provider.mu.Lock() @@ -81,10 +81,10 @@ func TestCoreProvider_GetMeta(t *testing.T) { customTTL := 2 * time.Hour provider := NewCoreProviderWithTTL(customTTL) - expectedMeta := pluginsv0alpha1.PluginMetaJSONData{ + expectedMeta := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Test Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } provider.mu.Lock() @@ -271,11 +271,11 @@ func TestJsonDataToMeta(t *testing.T) { }, } - meta := jsonDataToPluginMetaJSONData(jsonData) + meta := jsonDataToMetaJSONData(jsonData) assert.Equal(t, "test-plugin", meta.Id) assert.Equal(t, "Test Plugin", meta.Name) - assert.Equal(t, pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, meta.Type) + assert.Equal(t, pluginsv0alpha1.MetaJSONDataTypeDatasource, meta.Type) assert.Equal(t, "1.0.0", meta.Info.Version) assert.Equal(t, "Test description", *meta.Info.Description) assert.Equal(t, []string{"test", "plugin"}, meta.Info.Keywords) @@ -293,7 +293,7 @@ func TestJsonDataToMeta(t *testing.T) { }, } - meta := jsonDataToPluginMetaJSONData(jsonData) + meta := jsonDataToMetaJSONData(jsonData) assert.Nil(t, meta.Info.Description) assert.Nil(t, meta.Info.Author) diff --git a/apps/plugins/pkg/app/meta/manager.go b/apps/plugins/pkg/app/meta/manager.go index e694e88bba7..7e99d0cc197 100644 --- a/apps/plugins/pkg/app/meta/manager.go +++ b/apps/plugins/pkg/app/meta/manager.go @@ -16,7 +16,7 @@ const ( // cachedMeta represents a cached metadata entry with expiration time type cachedMeta struct { - meta pluginsv0alpha1.PluginMetaJSONData + meta pluginsv0alpha1.MetaJSONData ttl time.Duration expiresAt time.Time } diff --git a/apps/plugins/pkg/app/meta/manager_test.go b/apps/plugins/pkg/app/meta/manager_test.go index 3d3fe3c936c..31a75424a23 100644 --- a/apps/plugins/pkg/app/meta/manager_test.go +++ b/apps/plugins/pkg/app/meta/manager_test.go @@ -35,10 +35,10 @@ func TestProviderManager_GetMeta(t *testing.T) { ctx := context.Background() t.Run("returns cached result when available and not expired", func(t *testing.T) { - cachedMeta := pluginsv0alpha1.PluginMetaJSONData{ + cachedMeta := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Test Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } provider := &mockProvider{ @@ -60,7 +60,7 @@ func TestProviderManager_GetMeta(t *testing.T) { provider.getMetaFunc = func(ctx context.Context, pluginID, version string) (*Result, error) { return &Result{ - Meta: pluginsv0alpha1.PluginMetaJSONData{Id: "different"}, + Meta: pluginsv0alpha1.MetaJSONData{Id: "different"}, TTL: time.Hour, }, nil } @@ -73,10 +73,10 @@ func TestProviderManager_GetMeta(t *testing.T) { }) t.Run("fetches from provider when not cached", func(t *testing.T) { - expectedMeta := pluginsv0alpha1.PluginMetaJSONData{ + expectedMeta := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Test Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } expectedTTL := 2 * time.Hour @@ -108,15 +108,15 @@ func TestProviderManager_GetMeta(t *testing.T) { }) t.Run("does not cache result with zero TTL and tries next provider", func(t *testing.T) { - zeroTTLMeta := pluginsv0alpha1.PluginMetaJSONData{ + zeroTTLMeta := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Zero TTL Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } - expectedMeta := pluginsv0alpha1.PluginMetaJSONData{ + expectedMeta := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Test Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } provider1 := &mockProvider{ @@ -154,10 +154,10 @@ func TestProviderManager_GetMeta(t *testing.T) { }) t.Run("tries next provider when first returns ErrMetaNotFound", func(t *testing.T) { - expectedMeta := pluginsv0alpha1.PluginMetaJSONData{ + expectedMeta := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Test Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } provider1 := &mockProvider{ @@ -229,15 +229,15 @@ func TestProviderManager_GetMeta(t *testing.T) { }) t.Run("skips expired cache entries", func(t *testing.T) { - expiredMeta := pluginsv0alpha1.PluginMetaJSONData{ + expiredMeta := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Expired Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } - expectedMeta := pluginsv0alpha1.PluginMetaJSONData{ + expectedMeta := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Test Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } callCount := 0 @@ -272,15 +272,15 @@ func TestProviderManager_GetMeta(t *testing.T) { }) t.Run("uses first successful provider", func(t *testing.T) { - expectedMeta1 := pluginsv0alpha1.PluginMetaJSONData{ + expectedMeta1 := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Provider 1 Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } - expectedMeta2 := pluginsv0alpha1.PluginMetaJSONData{ + expectedMeta2 := pluginsv0alpha1.MetaJSONData{ Id: "test-plugin", Name: "Provider 2 Plugin", - Type: pluginsv0alpha1.PluginMetaJSONDataTypeDatasource, + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, } provider1 := &mockProvider{ @@ -331,9 +331,9 @@ func TestProviderManager_Run(t *testing.T) { func TestProviderManager_cleanupExpired(t *testing.T) { t.Run("removes expired entries", func(t *testing.T) { - validMeta := pluginsv0alpha1.PluginMetaJSONData{Id: "valid"} - expiredMeta1 := pluginsv0alpha1.PluginMetaJSONData{Id: "expired1"} - expiredMeta2 := pluginsv0alpha1.PluginMetaJSONData{Id: "expired2"} + validMeta := pluginsv0alpha1.MetaJSONData{Id: "valid"} + expiredMeta1 := pluginsv0alpha1.MetaJSONData{Id: "expired1"} + expiredMeta2 := pluginsv0alpha1.MetaJSONData{Id: "expired2"} provider := &mockProvider{ getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) { diff --git a/apps/plugins/pkg/app/meta/provider.go b/apps/plugins/pkg/app/meta/provider.go index 8b5d8b0fcbd..818c0da9dc5 100644 --- a/apps/plugins/pkg/app/meta/provider.go +++ b/apps/plugins/pkg/app/meta/provider.go @@ -14,14 +14,14 @@ var ( // Result contains plugin metadata along with its recommended TTL. type Result struct { - Meta pluginsv0alpha1.PluginMetaJSONData + Meta pluginsv0alpha1.MetaJSONData TTL time.Duration } // Provider is used for retrieving plugin metadata. type Provider interface { // GetMeta retrieves plugin metadata for the given plugin ID and version. - // Returns the Result containing the PluginMetaJSONData and its recommended TTL. + // Returns the Result containing the MetaJSONData and its recommended TTL. // If the plugin is not found, returns ErrMetaNotFound. GetMeta(ctx context.Context, pluginID, version string) (*Result, error) } diff --git a/apps/plugins/pkg/app/storage.go b/apps/plugins/pkg/app/storage.go index 575b1ca21c8..f2dd2ac98d5 100644 --- a/apps/plugins/pkg/app/storage.go +++ b/apps/plugins/pkg/app/storage.go @@ -22,15 +22,15 @@ import ( ) var ( - _ rest.Scoper = (*PluginMetaStorage)(nil) - _ rest.SingularNameProvider = (*PluginMetaStorage)(nil) - _ rest.Getter = (*PluginMetaStorage)(nil) - _ rest.Lister = (*PluginMetaStorage)(nil) - _ rest.Storage = (*PluginMetaStorage)(nil) - _ rest.TableConvertor = (*PluginMetaStorage)(nil) + _ rest.Scoper = (*MetaStorage)(nil) + _ rest.SingularNameProvider = (*MetaStorage)(nil) + _ rest.Getter = (*MetaStorage)(nil) + _ rest.Lister = (*MetaStorage)(nil) + _ rest.Storage = (*MetaStorage)(nil) + _ rest.TableConvertor = (*MetaStorage)(nil) ) -type PluginMetaStorage struct { +type MetaStorage struct { metaManager *meta.ProviderManager client *pluginsv0alpha1.PluginClient clientFactory func(context.Context) (*pluginsv0alpha1.PluginClient, error) @@ -41,16 +41,16 @@ type PluginMetaStorage struct { tableConverter rest.TableConvertor } -func NewPluginMetaStorage( +func NewMetaStorage( metaManager *meta.ProviderManager, clientFactory func(context.Context) (*pluginsv0alpha1.PluginClient, error), -) *PluginMetaStorage { +) *MetaStorage { gr := schema.GroupResource{ Group: pluginsv0alpha1.APIGroup, - Resource: strings.ToLower(pluginsv0alpha1.PluginMetaKind().Plural()), + Resource: strings.ToLower(pluginsv0alpha1.MetaKind().Plural()), } - return &PluginMetaStorage{ + return &MetaStorage{ metaManager: metaManager, clientFactory: clientFactory, gr: gr, @@ -58,7 +58,7 @@ func NewPluginMetaStorage( } } -func (s *PluginMetaStorage) getClient(ctx context.Context) (*pluginsv0alpha1.PluginClient, error) { +func (s *MetaStorage) getClient(ctx context.Context) (*pluginsv0alpha1.PluginClient, error) { s.clientOnce.Do(func() { client, err := s.clientFactory(ctx) if err != nil { @@ -72,29 +72,29 @@ func (s *PluginMetaStorage) getClient(ctx context.Context) (*pluginsv0alpha1.Plu return s.client, s.clientErr } -func (s *PluginMetaStorage) New() runtime.Object { - return pluginsv0alpha1.PluginMetaKind().ZeroValue() +func (s *MetaStorage) New() runtime.Object { + return pluginsv0alpha1.MetaKind().ZeroValue() } -func (s *PluginMetaStorage) Destroy() {} +func (s *MetaStorage) Destroy() {} -func (s *PluginMetaStorage) NamespaceScoped() bool { +func (s *MetaStorage) NamespaceScoped() bool { return true } -func (s *PluginMetaStorage) GetSingularName() string { - return strings.ToLower(pluginsv0alpha1.PluginMetaKind().Kind()) +func (s *MetaStorage) GetSingularName() string { + return strings.ToLower(pluginsv0alpha1.MetaKind().Kind()) } -func (s *PluginMetaStorage) NewList() runtime.Object { - return pluginsv0alpha1.PluginMetaKind().ZeroListValue() +func (s *MetaStorage) NewList() runtime.Object { + return pluginsv0alpha1.MetaKind().ZeroListValue() } -func (s *PluginMetaStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { +func (s *MetaStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { return s.tableConverter.ConvertToTable(ctx, object, tableOptions) } -func (s *PluginMetaStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { +func (s *MetaStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { ns, err := request.NamespaceInfoFrom(ctx, true) if err != nil { return nil, err @@ -111,8 +111,8 @@ func (s *PluginMetaStorage) List(ctx context.Context, options *internalversion.L return nil, apierrors.NewInternalError(fmt.Errorf("failed to list plugins: %w", err)) } - // Convert each Plugin to PluginMeta - metaItems := make([]pluginsv0alpha1.PluginMeta, 0, len(plugins.Items)) + // Convert each Plugin to Meta + metaItems := make([]pluginsv0alpha1.Meta, 0, len(plugins.Items)) for _, plugin := range plugins.Items { result, err := s.metaManager.GetMeta(ctx, plugin.Spec.Id, plugin.Spec.Version) if err != nil { @@ -121,14 +121,14 @@ func (s *PluginMetaStorage) List(ctx context.Context, options *internalversion.L continue } - pluginMeta := createPluginMetaFromPluginMetaJSONData(result.Meta, plugin.Name, plugin.Namespace) + pluginMeta := createMetaFromMetaJSONData(result.Meta, plugin.Name, plugin.Namespace) metaItems = append(metaItems, *pluginMeta) } - list := &pluginsv0alpha1.PluginMetaList{ + list := &pluginsv0alpha1.MetaList{ TypeMeta: metav1.TypeMeta{ APIVersion: pluginsv0alpha1.APIGroup + "/" + pluginsv0alpha1.APIVersion, - Kind: pluginsv0alpha1.PluginMetaKind().Kind() + "List", + Kind: pluginsv0alpha1.MetaKind().Kind() + "List", }, Items: metaItems, } @@ -136,7 +136,7 @@ func (s *PluginMetaStorage) List(ctx context.Context, options *internalversion.L return list, nil } -func (s *PluginMetaStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { +func (s *MetaStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { ns, err := request.NamespaceInfoFrom(ctx, true) if err != nil { return nil, err @@ -169,17 +169,17 @@ func (s *PluginMetaStorage) Get(ctx context.Context, name string, options *metav return nil, apierrors.NewInternalError(fmt.Errorf("failed to fetch plugin metadata: %w", err)) } - return createPluginMetaFromPluginMetaJSONData(result.Meta, name, ns.Value), nil + return createMetaFromMetaJSONData(result.Meta, name, ns.Value), nil } -// createPluginMetaFromPluginMetaJSONData creates a PluginMeta k8s object from PluginMetaJSONData and plugin metadata. -func createPluginMetaFromPluginMetaJSONData(pluginJSON pluginsv0alpha1.PluginMetaJSONData, name, namespace string) *pluginsv0alpha1.PluginMeta { - pluginMeta := &pluginsv0alpha1.PluginMeta{ +// createMetaFromMetaJSONData creates a Meta k8s object from MetaJSONData and plugin metadata. +func createMetaFromMetaJSONData(pluginJSON pluginsv0alpha1.MetaJSONData, name, namespace string) *pluginsv0alpha1.Meta { + pluginMeta := &pluginsv0alpha1.Meta{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: pluginsv0alpha1.PluginMetaSpec{ + Spec: pluginsv0alpha1.MetaSpec{ PluginJSON: pluginJSON, }, } @@ -188,7 +188,7 @@ func createPluginMetaFromPluginMetaJSONData(pluginJSON pluginsv0alpha1.PluginMet pluginMeta.SetGroupVersionKind(schema.GroupVersionKind{ Group: pluginsv0alpha1.APIGroup, Version: pluginsv0alpha1.APIVersion, - Kind: pluginsv0alpha1.PluginMetaKind().Kind(), + Kind: pluginsv0alpha1.MetaKind().Kind(), }) return pluginMeta diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index fbff17523fc..472652cc103 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -56,8 +56,7 @@ import ( _ "github.com/grafana/e2e" _ "github.com/grafana/gofpdf" _ "github.com/grafana/gomemcache/memcache" - _ "github.com/grafana/tempo/pkg/traceql" - _ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1" _ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1" + _ "github.com/grafana/tempo/pkg/traceql" ) diff --git a/pkg/registry/apps/plugins/accesscontrol.go b/pkg/registry/apps/plugins/accesscontrol.go index d41efa86f97..e0a8c9b6c23 100644 --- a/pkg/registry/apps/plugins/accesscontrol.go +++ b/pkg/registry/apps/plugins/accesscontrol.go @@ -13,15 +13,15 @@ const ( ActionPluginsPluginsDelete = "plugins.plugins:delete" // DELETE. // PluginMetas - ActionPluginsPluginsMetaCreate = "plugins.pluginsmeta:create" // CREATE. - ActionPluginsPluginsMetaWrite = "plugins.pluginsmeta:write" // UPDATE. - ActionPluginsPluginsMetaRead = "plugins.pluginsmeta:read" // GET + LIST. - ActionPluginsPluginsMetaDelete = "plugins.pluginsmeta:delete" // DELETE. + ActionPluginsPluginsMetaCreate = "plugins.metas:create" // CREATE. + ActionPluginsPluginsMetaWrite = "plugins.metas:write" // UPDATE. + ActionPluginsPluginsMetaRead = "plugins.metas:read" // GET + LIST. + ActionPluginsPluginsMetaDelete = "plugins.metas:delete" // DELETE. ) var ( ScopeProviderPluginsPlugins = accesscontrol.NewScopeProvider("plugins.plugins") - ScopeProviderPluginsPluginsMeta = accesscontrol.NewScopeProvider("plugins.pluginsmeta") + ScopeProviderPluginsPluginsMeta = accesscontrol.NewScopeProvider("plugins.metas") ScopeAllPluginsPlugins = ScopeProviderPluginsPlugins.GetResourceAllScope() ScopeAllPluginsPluginsMeta = ScopeProviderPluginsPluginsMeta.GetResourceAllScope() @@ -76,7 +76,7 @@ func registerAccessControlRoles(service accesscontrol.Service) error { // PluginMetas pluginsMetaReader := accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ - Name: "fixed:plugins.pluginsmeta:reader", + Name: "fixed:plugins.metas:reader", DisplayName: "Plugin Metas Reader", Description: "Read and list plugin metadata.", Group: "Plugins", @@ -92,7 +92,7 @@ func registerAccessControlRoles(service accesscontrol.Service) error { pluginsMetaWriter := accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ - Name: "fixed:plugins.pluginsmeta:writer", + Name: "fixed:plugins.metas:writer", DisplayName: "Plugin Metas Writer", Description: "Create, update and delete plugin metadata.", Group: "Plugins", diff --git a/pkg/services/accesscontrol/permreg/permreg.go b/pkg/services/accesscontrol/permreg/permreg.go index c9f010c2909..e975681ff03 100644 --- a/pkg/services/accesscontrol/permreg/permreg.go +++ b/pkg/services/accesscontrol/permreg/permreg.go @@ -85,7 +85,7 @@ func newPermissionRegistry() *permissionRegistry { "orgs": "orgs:id:", "plugins": "plugins:id:", "plugins.plugins": "plugins.plugins:uid:", - "plugins.pluginsmeta": "plugins.pluginsmeta:uid:", + "plugins.metas": "plugins.metas:uid:", "provisioners": "provisioners:", "reports": "reports:id:", "permissions": "permissions:type:", diff --git a/pkg/services/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go index e34965df999..d50ca050cdb 100644 --- a/pkg/services/authz/rbac/mapper.go +++ b/pkg/services/authz/rbac/mapper.go @@ -298,8 +298,8 @@ func NewMapperRegistry() MapperRegistry { }, }, "plugins.grafana.app": { - "plugins": newResourceTranslation("plugins.plugins", "uid", false, nil), - "pluginsmeta": newResourceTranslation("plugins.pluginsmeta", "uid", false, nil), + "plugins": newResourceTranslation("plugins.plugins", "uid", false, nil), + "metas": newResourceTranslation("plugins.metas", "uid", false, nil), }, }) diff --git a/pkg/tests/apis/config_test.go b/pkg/tests/apis/config_test.go index bf00affdfff..a9e8d87e0a0 100644 --- a/pkg/tests/apis/config_test.go +++ b/pkg/tests/apis/config_test.go @@ -15,19 +15,19 @@ const pluginsDiscoveryJSON = `[ "freshness": "Current", "resources": [ { - "resource": "pluginmetas", + "resource": "metas", "responseKind": { "group": "", - "kind": "PluginMeta", + "kind": "Meta", "version": "" }, "scope": "Namespaced", - "singularResource": "pluginmeta", + "singularResource": "meta", "subresources": [ { "responseKind": { "group": "", - "kind": "PluginMeta", + "kind": "Meta", "version": "" }, "subresource": "status", diff --git a/pkg/tests/apis/plugins/discovery_test.go b/pkg/tests/apis/plugins/discovery_test.go index a6e507a630b..5e211cc45a1 100644 --- a/pkg/tests/apis/plugins/discovery_test.go +++ b/pkg/tests/apis/plugins/discovery_test.go @@ -21,19 +21,19 @@ func TestIntegrationPluginsIntegrationDiscovery(t *testing.T) { "freshness": "Current", "resources": [ { - "resource": "pluginmetas", + "resource": "metas", "responseKind": { "group": "", - "kind": "PluginMeta", + "kind": "Meta", "version": "" }, "scope": "Namespaced", - "singularResource": "pluginmeta", + "singularResource": "meta", "subresources": [ { "responseKind": { "group": "", - "kind": "PluginMeta", + "kind": "Meta", "version": "" }, "subresource": "status", diff --git a/pkg/tests/apis/plugins/pluginmeta_test.go b/pkg/tests/apis/plugins/metas_test.go similarity index 52% rename from pkg/tests/apis/plugins/pluginmeta_test.go rename to pkg/tests/apis/plugins/metas_test.go index af70f02a6f8..c240d3fb91d 100644 --- a/pkg/tests/apis/plugins/pluginmeta_test.go +++ b/pkg/tests/apis/plugins/metas_test.go @@ -15,6 +15,72 @@ import ( func TestIntegrationPluginMeta(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) + t.Run("list plugin metas", func(t *testing.T) { + helper := setupHelper(t) + ctx := context.Background() + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + GVR: gvrPlugins, + }) + + plugin1Name := "test-plugin-metas-1" + plugin1 := helper.LoadYAMLOrJSON(fmt.Sprintf(`{ + "apiVersion": "plugins.grafana.app/v0alpha1", + "kind": "Plugin", + "metadata": {"name": "%s"}, + "spec": {"id": "grafana-piechart-panel", "version": "1.0.0"} + }`, plugin1Name)) + _, err := client.Resource.Create(ctx, plugin1, metav1.CreateOptions{}) + require.NoError(t, err) + + plugin2Name := "test-plugin-metas-2" + plugin2 := helper.LoadYAMLOrJSON(fmt.Sprintf(`{ + "apiVersion": "plugins.grafana.app/v0alpha1", + "kind": "Plugin", + "metadata": {"name": "%s"}, + "spec": {"id": "grafana-clock-panel", "version": "1.0.0"} + }`, plugin2Name)) + _, err = client.Resource.Create(ctx, plugin2, metav1.CreateOptions{}) + require.NoError(t, err) + + namespace := helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()) + path := fmt.Sprintf("/apis/plugins.grafana.app/v0alpha1/namespaces/%s/metas", namespace) + response := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "GET", + Path: path, + }, &pluginsv0alpha1.MetaList{}) + + require.NotNil(t, response.Result) + require.NotNil(t, response.Result.Items) + require.GreaterOrEqual(t, len(response.Result.Items), 2) + + foundIDs := make(map[string]bool) + for _, item := range response.Result.Items { + require.NotNil(t, item.Spec.PluginJSON) + foundIDs[item.Spec.PluginJSON.Id] = true + require.NotEmpty(t, item.Spec.PluginJSON.Id) + require.NotEmpty(t, item.Spec.PluginJSON.Type) + require.NotEmpty(t, item.Spec.PluginJSON.Name) + } + require.True(t, foundIDs["grafana-piechart-panel"]) + require.True(t, foundIDs["grafana-clock-panel"]) + }) + + t.Run("list plugin metas with no plugins", func(t *testing.T) { + helper := setupHelper(t) + namespace := helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()) + path := fmt.Sprintf("/apis/plugins.grafana.app/v0alpha1/namespaces/%s/metas", namespace) + response := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "GET", + Path: path, + }, &pluginsv0alpha1.MetaList{}) + + require.NotNil(t, response.Result) + require.NotNil(t, response.Result.Items) + require.GreaterOrEqual(t, len(response.Result.Items), 0) + }) t.Run("get plugin meta", func(t *testing.T) { helper := setupHelper(t) @@ -35,12 +101,12 @@ func TestIntegrationPluginMeta(t *testing.T) { require.NoError(t, err) namespace := helper.Org1.Admin.Identity.GetNamespace() - path := fmt.Sprintf("/apis/plugins.grafana.app/v0alpha1/namespaces/%s/pluginmetas/%s", namespace, pluginName) + path := fmt.Sprintf("/apis/plugins.grafana.app/v0alpha1/namespaces/%s/metas/%s", namespace, pluginName) response := apis.DoRequest(helper, apis.RequestParams{ User: helper.Org1.Admin, Method: "GET", Path: path, - }, &pluginsv0alpha1.PluginMeta{}) + }, &pluginsv0alpha1.Meta{}) require.NotNil(t, response.Result) require.NotNil(t, response.Result.Spec.PluginJSON) @@ -52,12 +118,12 @@ func TestIntegrationPluginMeta(t *testing.T) { t.Run("get plugin meta for non-existent plugin", func(t *testing.T) { helper := setupHelper(t) namespace := helper.Org1.Admin.Identity.GetNamespace() - path := fmt.Sprintf("/apis/plugins.grafana.app/v0alpha1/namespaces/%s/pluginmetas/non-existent-plugin", namespace) + path := fmt.Sprintf("/apis/plugins.grafana.app/v0alpha1/namespaces/%s/metas/non-existent-plugin", namespace) response := apis.DoRequest(helper, apis.RequestParams{ User: helper.Org1.Admin, Method: "GET", Path: path, - }, &pluginsv0alpha1.PluginMeta{}) + }, &pluginsv0alpha1.Meta{}) require.NotNil(t, response.Status) require.Equal(t, int32(404), response.Status.Code) @@ -82,12 +148,12 @@ func TestIntegrationPluginMeta(t *testing.T) { require.NoError(t, err) namespace := helper.Org1.Admin.Identity.GetNamespace() - path := fmt.Sprintf("/apis/plugins.grafana.app/v0alpha1/namespaces/%s/pluginmetas/%s", namespace, pluginName) + path := fmt.Sprintf("/apis/plugins.grafana.app/v0alpha1/namespaces/%s/metas/%s", namespace, pluginName) response := apis.DoRequest(helper, apis.RequestParams{ User: helper.Org1.Admin, Method: "GET", Path: path, - }, &pluginsv0alpha1.PluginMeta{}) + }, &pluginsv0alpha1.Meta{}) require.NotNil(t, response.Status) require.Equal(t, int32(404), response.Status.Code) diff --git a/pkg/tests/apis/plugins/pluginmetas_test.go b/pkg/tests/apis/plugins/pluginmetas_test.go deleted file mode 100644 index 1ffe8d35a50..00000000000 --- a/pkg/tests/apis/plugins/pluginmetas_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package plugins - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" - "github.com/grafana/grafana/pkg/tests/apis" - "github.com/grafana/grafana/pkg/util/testutil" -) - -func TestIntegrationPluginMetas(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - - t.Run("list plugin metas", func(t *testing.T) { - helper := setupHelper(t) - ctx := context.Background() - client := helper.GetResourceClient(apis.ResourceClientArgs{ - User: helper.Org1.Admin, - GVR: gvrPlugins, - }) - - plugin1Name := "test-plugin-metas-1" - plugin1 := helper.LoadYAMLOrJSON(fmt.Sprintf(`{ - "apiVersion": "plugins.grafana.app/v0alpha1", - "kind": "Plugin", - "metadata": {"name": "%s"}, - "spec": {"id": "grafana-piechart-panel", "version": "1.0.0"} - }`, plugin1Name)) - _, err := client.Resource.Create(ctx, plugin1, metav1.CreateOptions{}) - require.NoError(t, err) - - plugin2Name := "test-plugin-metas-2" - plugin2 := helper.LoadYAMLOrJSON(fmt.Sprintf(`{ - "apiVersion": "plugins.grafana.app/v0alpha1", - "kind": "Plugin", - "metadata": {"name": "%s"}, - "spec": {"id": "grafana-clock-panel", "version": "1.0.0"} - }`, plugin2Name)) - _, err = client.Resource.Create(ctx, plugin2, metav1.CreateOptions{}) - require.NoError(t, err) - - namespace := helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()) - path := fmt.Sprintf("/apis/plugins.grafana.app/v0alpha1/namespaces/%s/pluginmetas", namespace) - response := apis.DoRequest(helper, apis.RequestParams{ - User: helper.Org1.Admin, - Method: "GET", - Path: path, - }, &pluginsv0alpha1.PluginMetaList{}) - - require.NotNil(t, response.Result) - require.NotNil(t, response.Result.Items) - require.GreaterOrEqual(t, len(response.Result.Items), 2) - - foundIDs := make(map[string]bool) - for _, item := range response.Result.Items { - require.NotNil(t, item.Spec.PluginJSON) - foundIDs[item.Spec.PluginJSON.Id] = true - require.NotEmpty(t, item.Spec.PluginJSON.Id) - require.NotEmpty(t, item.Spec.PluginJSON.Type) - require.NotEmpty(t, item.Spec.PluginJSON.Name) - } - require.True(t, foundIDs["grafana-piechart-panel"]) - require.True(t, foundIDs["grafana-clock-panel"]) - }) - - t.Run("list plugin metas with no plugins", func(t *testing.T) { - helper := setupHelper(t) - namespace := helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()) - path := fmt.Sprintf("/apis/plugins.grafana.app/v0alpha1/namespaces/%s/pluginmetas", namespace) - response := apis.DoRequest(helper, apis.RequestParams{ - User: helper.Org1.Admin, - Method: "GET", - Path: path, - }, &pluginsv0alpha1.PluginMetaList{}) - - require.NotNil(t, response.Result) - require.NotNil(t, response.Result.Items) - require.GreaterOrEqual(t, len(response.Result.Items), 0) - }) -} diff --git a/pkg/tests/apis/plugins/plugininstalls_test.go b/pkg/tests/apis/plugins/plugins_test.go similarity index 100% rename from pkg/tests/apis/plugins/plugininstalls_test.go rename to pkg/tests/apis/plugins/plugins_test.go From ae03b08c250fd471f3cfb85625b48840c766d78e Mon Sep 17 00:00:00 2001 From: Jacob Valdez Date: Tue, 9 Dec 2025 16:12:49 -0600 Subject: [PATCH 004/139] Docs: Creating upgrade guide content for annotation table bloat (#114883) --- .../upgrade-guide/upgrade-v12.0/index.md | 50 ++++++++++++++++++ .../upgrade-guide/upgrade-v12.1/index.md | 50 ++++++++++++++++++ .../upgrade-guide/upgrade-v12.2/index.md | 52 +++++++++++++++++++ .../upgrade-guide/upgrade-v12.3/index.md | 52 +++++++++++++++++++ 4 files changed, 204 insertions(+) diff --git a/docs/sources/upgrade-guide/upgrade-v12.0/index.md b/docs/sources/upgrade-guide/upgrade-v12.0/index.md index 779950c0fd4..feb1061dceb 100644 --- a/docs/sources/upgrade-guide/upgrade-v12.0/index.md +++ b/docs/sources/upgrade-guide/upgrade-v12.0/index.md @@ -80,3 +80,53 @@ Since Grafana 10.2, the endpoint to check compatible versions when installing a #### What if I want to ignore the compatibility check? We _do not_ recommend installing plugins declared as incompatible. However, if you need to force install a plugin despite it being declared as incompatible, refer to the [Installing a plugin from a ZIP](https://grafana.com/docs/grafana/latest/administration/plugin-management/#install-a-plugin-from-a-zip-file) guidance. + +### PostgreSQL annotation table migration + +**Plan for increased disk usage when upgrading from Grafana v11.x** + +Upgrading from Grafana v11.x to Grafana v12.x triggers a full-table rewrite of the PostgreSQL `annotation` table. The migration populates the new `dashboard_uid` column, which causes PostgreSQL to rewrite the entire table and rebuild its indexes. + +Environments with large annotation datasets can experience significant temporary disk usage increase, which may lead to: + +- Rapid disk consumption on the PostgreSQL data volume +- Database migration failures (for example, "could not extend file: No space left on device") +- Grafana startup failures +- Extended downtime during the upgrade process + +#### How do I know if I'm affected? + +You're affected if you're upgrading from Grafana v11.x to v12.x and you have a large `annotation` table in your PostgreSQL database. + +To check your annotation table size, connect to your PostgreSQL database and run the following query: + +```sql +SELECT + pg_size_pretty(pg_relation_size('annotation')) AS table_size, + pg_size_pretty(pg_indexes_size('annotation')) AS indexes_size, + pg_size_pretty(pg_total_relation_size('annotation')) AS total_size; +``` + +If your total size is several gigabytes or more, you should plan accordingly before upgrading. + +#### What should I do before upgrading? + +Before you upgrade, take the following steps: + +1. **Verify available disk space**: Ensure you have at least 2-3 times the current `annotation` table size available as free disk space on your PostgreSQL data volume. + +2. **Review your annotation data**: Consider whether you need to retain all historical annotations. + +3. **Clean up old annotations (optional)**: If you have annotations you don't need, remove them before upgrading. + +4. **Back up your database**: Always back up your Grafana database before performing an upgrade. For more information, refer to [Back up Grafana](#back-up-grafana). + +#### What should I do after upgrading? + +After successfully upgrading to Grafana v12.x, you can reclaim disk space by running a `VACUUM FULL` operation on the `annotation` table during a maintenance window: + +```sql +VACUUM FULL annotation; +``` + +This operation requires a lock on the table and may take significant time depending on the table size. Plan to run this during a low-traffic period. diff --git a/docs/sources/upgrade-guide/upgrade-v12.1/index.md b/docs/sources/upgrade-guide/upgrade-v12.1/index.md index 74095298c90..7dbcb11ece9 100644 --- a/docs/sources/upgrade-guide/upgrade-v12.1/index.md +++ b/docs/sources/upgrade-guide/upgrade-v12.1/index.md @@ -20,3 +20,53 @@ weight: 499 {{< docs/shared lookup="upgrade/upgrade-common-tasks.md" source="grafana" version="" >}} ## Technical notes + +### PostgreSQL annotation table migration + +**Plan for increased disk usage when upgrading from Grafana v11.x** + +Upgrading from Grafana v11.x to Grafana v12.x triggers a full-table rewrite of the PostgreSQL `annotation` table. The migration populates the new `dashboard_uid` column, which causes PostgreSQL to rewrite the entire table and rebuild its indexes. + +Environments with large annotation datasets can experience significant temporary disk usage increase, which may lead to: + +- Rapid disk consumption on the PostgreSQL data volume +- Database migration failures (for example, "could not extend file: No space left on device") +- Grafana startup failures +- Extended downtime during the upgrade process + +#### How do I know if I'm affected? + +You're affected if you're upgrading from Grafana v11.x to v12.x and you have a large `annotation` table in your PostgreSQL database. + +To check your annotation table size, connect to your PostgreSQL database and run the following query: + +```sql +SELECT + pg_size_pretty(pg_relation_size('annotation')) AS table_size, + pg_size_pretty(pg_indexes_size('annotation')) AS indexes_size, + pg_size_pretty(pg_total_relation_size('annotation')) AS total_size; +``` + +If your total size is several gigabytes or more, you should plan accordingly before upgrading. + +#### What should I do before upgrading? + +Before you upgrade, take the following steps: + +1. **Verify available disk space**: Ensure you have at least 2-3 times the current `annotation` table size available as free disk space on your PostgreSQL data volume. + +2. **Review your annotation data**: Consider whether you need to retain all historical annotations. + +3. **Clean up old annotations (optional)**: If you have annotations you don't need, remove them before upgrading. + +4. **Back up your database**: Always back up your Grafana database before performing an upgrade. For more information, refer to [Back up Grafana](#back-up-grafana). + +#### What should I do after upgrading? + +After successfully upgrading to Grafana v12.x, you can reclaim disk space by running a `VACUUM FULL` operation on the `annotation` table during a maintenance window: + +```sql +VACUUM FULL annotation; +``` + +This operation requires a lock on the table and may take significant time depending on the table size. Plan to run this during a low-traffic period. diff --git a/docs/sources/upgrade-guide/upgrade-v12.2/index.md b/docs/sources/upgrade-guide/upgrade-v12.2/index.md index d2605fc51d1..1e900d65793 100644 --- a/docs/sources/upgrade-guide/upgrade-v12.2/index.md +++ b/docs/sources/upgrade-guide/upgrade-v12.2/index.md @@ -18,3 +18,55 @@ weight: 498 {{< docs/shared lookup="back-up/back-up-grafana.md" source="grafana" version="" leveloffset="+1" >}} {{< docs/shared lookup="upgrade/upgrade-common-tasks.md" source="grafana" version="" >}} + +## Technical notes + +### PostgreSQL annotation table migration + +**Plan for increased disk usage when upgrading from Grafana v11.x** + +Upgrading from Grafana v11.x to Grafana v12.x triggers a full-table rewrite of the PostgreSQL `annotation` table. The migration populates the new `dashboard_uid` column, which causes PostgreSQL to rewrite the entire table and rebuild its indexes. + +Environments with large annotation datasets can experience significant temporary disk usage increase, which may lead to: + +- Rapid disk consumption on the PostgreSQL data volume +- Database migration failures (for example, "could not extend file: No space left on device") +- Grafana startup failures +- Extended downtime during the upgrade process + +#### How do I know if I'm affected? + +You're affected if you're upgrading from Grafana v11.x to v12.x and you have a large `annotation` table in your PostgreSQL database. + +To check your annotation table size, connect to your PostgreSQL database and run the following query: + +```sql +SELECT + pg_size_pretty(pg_relation_size('annotation')) AS table_size, + pg_size_pretty(pg_indexes_size('annotation')) AS indexes_size, + pg_size_pretty(pg_total_relation_size('annotation')) AS total_size; +``` + +If your total size is several gigabytes or more, you should plan accordingly before upgrading. + +#### What should I do before upgrading? + +Before you upgrade, take the following steps: + +1. **Verify available disk space**: Ensure you have at least 2-3 times the current `annotation` table size available as free disk space on your PostgreSQL data volume. + +2. **Review your annotation data**: Consider whether you need to retain all historical annotations. + +3. **Clean up old annotations (optional)**: If you have annotations you don't need, remove them before upgrading. + +4. **Back up your database**: Always back up your Grafana database before performing an upgrade. For more information, refer to [Back up Grafana](#back-up-grafana). + +#### What should I do after upgrading? + +After successfully upgrading to Grafana v12.x, you can reclaim disk space by running a `VACUUM FULL` operation on the `annotation` table during a maintenance window: + +```sql +VACUUM FULL annotation; +``` + +This operation requires a lock on the table and may take significant time depending on the table size. Plan to run this during a low-traffic period. diff --git a/docs/sources/upgrade-guide/upgrade-v12.3/index.md b/docs/sources/upgrade-guide/upgrade-v12.3/index.md index 8d3dacbf396..dc8b1b8e398 100644 --- a/docs/sources/upgrade-guide/upgrade-v12.3/index.md +++ b/docs/sources/upgrade-guide/upgrade-v12.3/index.md @@ -18,3 +18,55 @@ weight: 497 {{< docs/shared lookup="back-up/back-up-grafana.md" source="grafana" version="" leveloffset="+1" >}} {{< docs/shared lookup="upgrade/upgrade-common-tasks.md" source="grafana" version="" >}} + +## Technical notes + +### PostgreSQL annotation table migration + +**Plan for increased disk usage when upgrading from Grafana v11.x** + +Upgrading from Grafana v11.x to Grafana v12.x triggers a full-table rewrite of the PostgreSQL `annotation` table. The migration populates the new `dashboard_uid` column, which causes PostgreSQL to rewrite the entire table and rebuild its indexes. + +Environments with large annotation datasets can experience significant temporary disk usage increase, which may lead to: + +- Rapid disk consumption on the PostgreSQL data volume +- Database migration failures (for example, "could not extend file: No space left on device") +- Grafana startup failures +- Extended downtime during the upgrade process + +#### How do I know if I'm affected? + +You're affected if you're upgrading from Grafana v11.x to v12.x and you have a large `annotation` table in your PostgreSQL database. + +To check your annotation table size, connect to your PostgreSQL database and run the following query: + +```sql +SELECT + pg_size_pretty(pg_relation_size('annotation')) AS table_size, + pg_size_pretty(pg_indexes_size('annotation')) AS indexes_size, + pg_size_pretty(pg_total_relation_size('annotation')) AS total_size; +``` + +If your total size is several gigabytes or more, you should plan accordingly before upgrading. + +#### What should I do before upgrading? + +Before you upgrade, take the following steps: + +1. **Verify available disk space**: Ensure you have at least 2-3 times the current `annotation` table size available as free disk space on your PostgreSQL data volume. + +2. **Review your annotation data**: Consider whether you need to retain all historical annotations. + +3. **Clean up old annotations (optional)**: If you have annotations you don't need, remove them before upgrading. + +4. **Back up your database**: Always back up your Grafana database before performing an upgrade. For more information, refer to [Back up Grafana](#back-up-grafana). + +#### What should I do after upgrading? + +After successfully upgrading to Grafana v12.x, you can reclaim disk space by running a `VACUUM FULL` operation on the `annotation` table during a maintenance window: + +```sql +VACUUM FULL annotation; +``` + +This operation requires a lock on the table and may take significant time depending on the table size. Plan to run this during a low-traffic period. From ff43c175c80f287f271f95963cf28a59fe79fc04 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Tue, 9 Dec 2025 15:15:25 -0700 Subject: [PATCH 005/139] Folders: Remove duplicate check; improve unit test (#115048) --- pkg/registry/apis/folders/validate.go | 10 -- pkg/registry/apis/folders/validate_test.go | 164 +++++++++++++++++---- 2 files changed, 134 insertions(+), 40 deletions(-) diff --git a/pkg/registry/apis/folders/validate.go b/pkg/registry/apis/folders/validate.go index 739c81951f8..eb0c29b30a1 100644 --- a/pkg/registry/apis/folders/validate.go +++ b/pkg/registry/apis/folders/validate.go @@ -59,16 +59,6 @@ func validateOnCreate(ctx context.Context, f *folders.Folder, getter parentsGett return fmt.Errorf("unable to create folder inside parent: %w", err) } - for i, parent := range parents.Items { - // skip the last item, which is itself - if i == len(parents.Items)-1 { - continue - } - if parent.Name == f.Name { - return folder.ErrCircularReference.Errorf("circular reference detected") - } - } - // Can not create a folder that will be too deep. // We need to add +1 as we also have the root folder as part of the parents. if len(parents.Items) > maxDepth+1 { diff --git a/pkg/registry/apis/folders/validate_test.go b/pkg/registry/apis/folders/validate_test.go index ac21ea4ead0..7fdb3cfae12 100644 --- a/pkg/registry/apis/folders/validate_test.go +++ b/pkg/registry/apis/folders/validate_test.go @@ -20,8 +20,7 @@ func TestValidateCreate(t *testing.T) { tests := []struct { name string folder *folders.Folder - getter *folders.FolderInfoList - getterError error + mockFolders map[string]*folders.Folder expectedErr string maxDepth int // defaults to 5 unless set }{ @@ -36,10 +35,23 @@ func TestValidateCreate(t *testing.T) { Title: "some title", }, }, - getter: &folders.FolderInfoList{ - Items: []folders.FolderInfo{ - {Name: "p2", Parent: "p3"}, - {Name: "p3"}, + mockFolders: map[string]*folders.Folder{ + "p2": { + ObjectMeta: metav1.ObjectMeta{ + Name: "p2", + Annotations: map[string]string{"grafana.app/folder": "p3"}, + }, + Spec: folders.FolderSpec{ + Title: "p2 title", + }, + }, + "p3": { + ObjectMeta: metav1.ObjectMeta{ + Name: "p3", + }, + Spec: folders.FolderSpec{ + Title: "p3 title", + }, }, }, }, @@ -94,12 +106,41 @@ func TestValidateCreate(t *testing.T) { Title: "some title", }, }, - getter: &folders.FolderInfoList{ - Items: []folders.FolderInfo{ - {Name: "p2", Parent: "p3"}, - {Name: "p3", Parent: "p4"}, - {Name: "p4", Parent: folder.GeneralFolderUID}, - {Name: folder.GeneralFolderUID}, + mockFolders: map[string]*folders.Folder{ + "p2": { + ObjectMeta: metav1.ObjectMeta{ + Name: "p2", + Annotations: map[string]string{"grafana.app/folder": "p3"}, + }, + Spec: folders.FolderSpec{ + Title: "p2 title", + }, + }, + "p3": { + ObjectMeta: metav1.ObjectMeta{ + Name: "p3", + Annotations: map[string]string{"grafana.app/folder": "p4"}, + }, + Spec: folders.FolderSpec{ + Title: "p3 title", + }, + }, + "p4": { + ObjectMeta: metav1.ObjectMeta{ + Name: "p4", + Annotations: map[string]string{"grafana.app/folder": folder.GeneralFolderUID}, + }, + Spec: folders.FolderSpec{ + Title: "p4 title", + }, + }, + folder.GeneralFolderUID: { + ObjectMeta: metav1.ObjectMeta{ + Name: folder.GeneralFolderUID, + }, + Spec: folders.FolderSpec{ + Title: "General", + }, }, }, maxDepth: 2, @@ -116,13 +157,41 @@ func TestValidateCreate(t *testing.T) { Title: "some title", }, }, - getter: &folders.FolderInfoList{ - Items: []folders.FolderInfo{ - {Name: "4", Parent: "3"}, - {Name: "3", Parent: "2"}, - {Name: "2", Parent: "1"}, - {Name: "1", Parent: folder.GeneralFolderUID}, - {Name: folder.GeneralFolderUID}, + mockFolders: map[string]*folders.Folder{ + "4": { + ObjectMeta: metav1.ObjectMeta{ + Name: "4", + Annotations: map[string]string{"grafana.app/folder": "3"}, + }, + Spec: folders.FolderSpec{ + Title: "4 title", + }, + }, + "3": { + ObjectMeta: metav1.ObjectMeta{ + Name: "3", + Annotations: map[string]string{"grafana.app/folder": "2"}, + }, + Spec: folders.FolderSpec{ + Title: "3 title", + }, + }, + "2": { + ObjectMeta: metav1.ObjectMeta{ + Name: "2", + Annotations: map[string]string{"grafana.app/folder": "1"}, + }, + Spec: folders.FolderSpec{ + Title: "2 title", + }, + }, + "1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "1", + }, + Spec: folders.FolderSpec{ + Title: "1 title", + }, }, }, maxDepth: folder.MaxNestedFolderDepth, @@ -138,13 +207,42 @@ func TestValidateCreate(t *testing.T) { Title: "some title", }, }, - expectedErr: "circular reference detected", - getter: &folders.FolderInfoList{ - Items: []folders.FolderInfo{ - {Name: "2", Parent: "1"}, - {Name: "1", Parent: "3"}, - {Name: "3", Parent: folder.GeneralFolderUID}, - {Name: folder.GeneralFolderUID}, + expectedErr: "cyclic folder references found", + mockFolders: map[string]*folders.Folder{ + "2": { + ObjectMeta: metav1.ObjectMeta{ + Name: "2", + Annotations: map[string]string{"grafana.app/folder": "1"}, + }, + Spec: folders.FolderSpec{ + Title: "2 title", + }, + }, + "1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "1", + Annotations: map[string]string{"grafana.app/folder": "3"}, + }, + Spec: folders.FolderSpec{ + Title: "1 title", + }, + }, + "3": { + ObjectMeta: metav1.ObjectMeta{ + Name: "3", + Annotations: map[string]string{"grafana.app/folder": folder.GeneralFolderUID}, + }, + Spec: folders.FolderSpec{ + Title: "3 title", + }, + }, + folder.GeneralFolderUID: { + ObjectMeta: metav1.ObjectMeta{ + Name: folder.GeneralFolderUID, + }, + Spec: folders.FolderSpec{ + Title: "General", + }, }, }, }, @@ -156,10 +254,16 @@ func TestValidateCreate(t *testing.T) { if maxDepth == 0 { maxDepth = 5 } - err := validateOnCreate(context.Background(), tt.folder, - func(ctx context.Context, folder *folders.Folder) (*folders.FolderInfoList, error) { - return tt.getter, tt.getterError - }, maxDepth) + + mockStorage := grafanarest.NewMockStorage(t) + for name, f := range tt.mockFolders { + f.Name = name + mockStorage.On("Get", context.Background(), name, &metav1.GetOptions{}).Return(f, nil).Maybe() + } + + getter := newParentsGetter(mockStorage, maxDepth) + + err := validateOnCreate(context.Background(), tt.folder, getter, maxDepth) if tt.expectedErr == "" { require.NoError(t, err) From 6b50e2d730b0e3e9042d05e01ffea232a3c1b4a1 Mon Sep 17 00:00:00 2001 From: owensmallwood Date: Tue, 9 Dec 2025 17:14:22 -0600 Subject: [PATCH 006/139] Unified Storage: Update yaml decoding for quotas to accomodate top-level overrides key (#115049) * update yaml decoding for quotas to accomodate top-level overrides key * update test * fix test indentation --- pkg/storage/unified/resource/quotas.go | 19 +-- pkg/storage/unified/resource/quotas_test.go | 136 +++++++++++--------- pkg/storage/unified/resource/server_test.go | 9 +- 3 files changed, 90 insertions(+), 74 deletions(-) diff --git a/pkg/storage/unified/resource/quotas.go b/pkg/storage/unified/resource/quotas.go index d956a6da017..03fe515559e 100644 --- a/pkg/storage/unified/resource/quotas.go +++ b/pkg/storage/unified/resource/quotas.go @@ -47,13 +47,14 @@ type Overrides struct { /* This service loads overrides (currently just quotas) from a YAML file with the following yaml structure: -"123": +overrides: + "123": quotas: - grafana.dashboard.app/dashboards: - limit: 1500 - grafana.folder.app/folders: - limit: 1500 + dashboard.grafana.app/dashboards: + limit: 1500 + folder.grafana.app/folders: + limit: 1500 */ func NewOverridesService(_ context.Context, logger log.Logger, reg prometheus.Registerer, tracer trace.Tracer, opts ReloadOptions) (*OverridesService, error) { // shouldn't be empty since we use file path existence to determine if we should enable the service @@ -76,12 +77,14 @@ func NewOverridesService(_ context.Context, logger log.Logger, reg prometheus.Re ReloadPeriod: opts.ReloadPeriod, LoadPath: []string{opts.FilePath}, Loader: func(r io.Reader) (interface{}, error) { - var tenants map[string]NamespaceOverrides + var raw struct { + Overrides map[string]NamespaceOverrides `yaml:"overrides"` + } decoder := yaml.NewDecoder(r) - if err := decoder.Decode(&tenants); err != nil { + if err := decoder.Decode(&raw); err != nil { return nil, err } - return &Overrides{Namespaces: tenants}, nil + return &Overrides{Namespaces: raw.Overrides}, nil }, } diff --git a/pkg/storage/unified/resource/quotas_test.go b/pkg/storage/unified/resource/quotas_test.go index 97f9f355af6..097a02d7444 100644 --- a/pkg/storage/unified/resource/quotas_test.go +++ b/pkg/storage/unified/resource/quotas_test.go @@ -27,10 +27,11 @@ func TestNewQuotaService(t *testing.T) { opts: ReloadOptions{}, setupFile: func(t *testing.T) string { tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") - content := `"123": - quotas: - grafana.dashboard.app/dashboards: - limit: 1500 + content := `overrides: + "123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 ` require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) return tmpFile @@ -105,10 +106,11 @@ func TestQuotaService_ConfigReload(t *testing.T) { // Create a temporary config file tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") - initialConfig := `"123": - quotas: - grafana.dashboard.app/dashboards: - limit: 1500 + initialConfig := `overrides: + "123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 ` require.NoError(t, os.WriteFile(tmpFile, []byte(initialConfig), 0644)) @@ -139,14 +141,15 @@ func TestQuotaService_ConfigReload(t *testing.T) { assert.Equal(t, 1500, quota.Limit, "initial quota should be 1500") // Update the config file with new values - updatedConfig := `"123": - quotas: - grafana.dashboard.app/dashboards: - limit: 2500 -"456": - quotas: - grafana.folder.app/folders: - limit: 3000 + updatedConfig := `overrides: + "123": + quotas: + grafana.dashboard.app/dashboards: + limit: 2500 + "456": + quotas: + grafana.folder.app/folders: + limit: 3000 ` require.NoError(t, os.WriteFile(tmpFile, []byte(updatedConfig), 0644)) @@ -183,10 +186,11 @@ func TestQuotaService_GetQuota(t *testing.T) { name: "returns custom quota for matching tenant and resource", setupFile: func(t *testing.T) string { tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") - content := `"123": - quotas: - grafana.dashboard.app/dashboards: - limit: 1500 + content := `overrides: + "123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 ` require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) return tmpFile @@ -204,10 +208,11 @@ func TestQuotaService_GetQuota(t *testing.T) { name: "returns default quota when tenant not found", setupFile: func(t *testing.T) string { tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") - content := `"123": - quotas: - grafana.dashboard.app/dashboards: - limit: 1500 + content := `overrides: + "123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 ` require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) return tmpFile @@ -225,10 +230,11 @@ func TestQuotaService_GetQuota(t *testing.T) { name: "returns default quota when resource not found", setupFile: func(t *testing.T) string { tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") - content := `"123": - quotas: - grafana.dashboard.app/dashboards: - limit: 1500 + content := `overrides: + "123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 ` require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) return tmpFile @@ -246,10 +252,11 @@ func TestQuotaService_GetQuota(t *testing.T) { name: "handles namespace without stacks- prefix", setupFile: func(t *testing.T) string { tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") - content := `"123": - quotas: - grafana.dashboard.app/dashboards: - limit: 1500 + content := `overrides: + "123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 ` require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) return tmpFile @@ -284,12 +291,13 @@ func TestQuotaService_GetQuota(t *testing.T) { name: "handles multiple resources for same tenant", setupFile: func(t *testing.T) string { tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") - content := `"123": - quotas: - grafana.dashboard.app/dashboards: - limit: 1500 - grafana.folder.app/folders: - limit: 2500 + content := `overrides: + "123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 ` require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) return tmpFile @@ -307,12 +315,13 @@ func TestQuotaService_GetQuota(t *testing.T) { name: "returns error when namespace is empty", setupFile: func(t *testing.T) string { tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") - content := `"123": - quotas: - grafana.dashboard.app/dashboards: - limit: 1500 - grafana.folder.app/folders: - limit: 2500 + content := `overrides: + "123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 ` require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) return tmpFile @@ -330,12 +339,13 @@ func TestQuotaService_GetQuota(t *testing.T) { name: "returns error when group is empty", setupFile: func(t *testing.T) string { tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") - content := `"123": - quotas: - grafana.dashboard.app/dashboards: - limit: 1500 - grafana.folder.app/folders: - limit: 2500 + content := `overrides: + "123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 ` require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) return tmpFile @@ -353,12 +363,13 @@ func TestQuotaService_GetQuota(t *testing.T) { name: "returns error when resource is empty", setupFile: func(t *testing.T) string { tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") - content := `"123": - quotas: - grafana.dashboard.app/dashboards: - limit: 1500 - grafana.folder.app/folders: - limit: 2500 + content := `overrides: + "123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 ` require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) return tmpFile @@ -376,12 +387,13 @@ func TestQuotaService_GetQuota(t *testing.T) { name: "returns error when all fields are empty", setupFile: func(t *testing.T) string { tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") - content := `"123": - quotas: - grafana.dashboard.app/dashboards: - limit: 1500 - grafana.folder.app/folders: - limit: 2500 + content := `overrides: + "123": + quotas: + grafana.dashboard.app/dashboards: + limit: 1500 + grafana.folder.app/folders: + limit: 2500 ` require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) return tmpFile diff --git a/pkg/storage/unified/resource/server_test.go b/pkg/storage/unified/resource/server_test.go index b4ab0cdff2a..0b0bbcfba13 100644 --- a/pkg/storage/unified/resource/server_test.go +++ b/pkg/storage/unified/resource/server_test.go @@ -643,10 +643,11 @@ func TestGetQuotaUsage(t *testing.T) { t.Run("returns usage and limit successfully", func(t *testing.T) { // Create a temporary overrides config file tmpFile := filepath.Join(t.TempDir(), "overrides.yaml") - content := `"123": - quotas: - dashboard.grafana.app/dashboards: - limit: 500 + content := `overrides: + "123": + quotas: + dashboard.grafana.app/dashboards: + limit: 500 ` require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) From 4fe481ec81a88b7bf2ba71f2f12407cc9d33477b Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 00:42:27 +0000 Subject: [PATCH 007/139] I18n: Download translations from Crowdin (#115054) 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 | 21 +++++++++++++++++++++ public/locales/de-DE/grafana.json | 21 +++++++++++++++++++++ public/locales/es-ES/grafana.json | 21 +++++++++++++++++++++ public/locales/fr-FR/grafana.json | 21 +++++++++++++++++++++ public/locales/hu-HU/grafana.json | 21 +++++++++++++++++++++ public/locales/id-ID/grafana.json | 21 +++++++++++++++++++++ public/locales/it-IT/grafana.json | 21 +++++++++++++++++++++ public/locales/ja-JP/grafana.json | 21 +++++++++++++++++++++ public/locales/ko-KR/grafana.json | 21 +++++++++++++++++++++ public/locales/nl-NL/grafana.json | 21 +++++++++++++++++++++ public/locales/pl-PL/grafana.json | 21 +++++++++++++++++++++ public/locales/pt-BR/grafana.json | 21 +++++++++++++++++++++ public/locales/pt-PT/grafana.json | 21 +++++++++++++++++++++ public/locales/ru-RU/grafana.json | 21 +++++++++++++++++++++ public/locales/sv-SE/grafana.json | 21 +++++++++++++++++++++ public/locales/tr-TR/grafana.json | 21 +++++++++++++++++++++ public/locales/zh-Hans/grafana.json | 21 +++++++++++++++++++++ public/locales/zh-Hant/grafana.json | 21 +++++++++++++++++++++ 18 files changed, 378 insertions(+) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 6ce52307b99..d5cd731a4ea 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -4819,6 +4819,8 @@ "remove": "Odebrat {{typeName}}", "row-title": "", "switch-layout": "Přepnout rozvržení", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5929,6 +5931,17 @@ "label-value": "Hodnota", "placeholder-your-metric-prefix": "Předpona metriky" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Vlastní možnosti", "name-values-separated-comma": "Hodnoty oddělené čárkou", @@ -6302,12 +6315,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Volitelné, pokud chcete extrahovat část názvu řady nebo segmentu uzlu metriky.", + "description-regex-apply-to": "", "label-data-source": "Zdroj dat", + "label-regex-apply-to": "", "label-static-options-sort": "Statické možnosti třídění", "label-target-data-source": "Cílový zdroj dat", "label-use-static-options": "Použít statické možnosti", "name-regex": "Regulární výraz", "query-options": "Možnosti dotazu", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Možnosti výběru", "static-options-legend": "Statické možnosti" }, diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 5619e7c8ab5..c225d5a460a 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -4781,6 +4781,8 @@ "remove": "{{typeName}} entfernen", "row-title": "", "switch-layout": "Layout wechseln", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5887,6 +5889,17 @@ "label-value": "Wert", "placeholder-your-metric-prefix": "Ihr Metrik-Präfix" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Benutzerdefinierte Optionen", "name-values-separated-comma": "Werte werden durch Komma getrennt", @@ -6258,12 +6271,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Optional, wenn Sie einen Teil eines Reihennamens oder eines metrischen Knotensegments extrahieren möchten.", + "description-regex-apply-to": "", "label-data-source": "Datenquelle", + "label-regex-apply-to": "", "label-static-options-sort": "Sortierung der statischen Optionen", "label-target-data-source": "Zieldatenquelle", "label-use-static-options": "Statische Optionen nutzen", "name-regex": "Regex", "query-options": "Abfrageoptionen", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Auswahloptionen", "static-options-legend": "Statische Optionen" }, diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index dc07b47b5c6..a02d2517fa8 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -4781,6 +4781,8 @@ "remove": "Eliminar {{typeName}}", "row-title": "", "switch-layout": "Cambiar diseño", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5887,6 +5889,17 @@ "label-value": "Valor", "placeholder-your-metric-prefix": "Tu prefijo métrico" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Opciones personalizadas", "name-values-separated-comma": "Valores separados por comas", @@ -6258,12 +6271,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Es opcional si quieres extraer parte del nombre de una serie o segmento de nodo métrico.", + "description-regex-apply-to": "", "label-data-source": "Fuente de datos", + "label-regex-apply-to": "", "label-static-options-sort": "Ordenar opciones estáticas", "label-target-data-source": "Fuente de datos de destino", "label-use-static-options": "Usar opciones estáticas", "name-regex": "RegEx", "query-options": "Opciones de consulta", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Opciones de selección", "static-options-legend": "Opciones estáticas" }, diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 4adc55064e4..25d9783c652 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -4781,6 +4781,8 @@ "remove": "Supprimer {{typeName}}", "row-title": "", "switch-layout": "Changer la disposition", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5887,6 +5889,17 @@ "label-value": "Valeur", "placeholder-your-metric-prefix": "Votre préfixe de métrique" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Personnaliser les options", "name-values-separated-comma": "Valeurs séparées par des virgules", @@ -6258,12 +6271,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Facultatif, si vous souhaitez extraire une partie d’un nom de série ou d’un segment de nœud de métrique.", + "description-regex-apply-to": "", "label-data-source": "Source de données", + "label-regex-apply-to": "", "label-static-options-sort": "Tri des options statiques", "label-target-data-source": "Source de données cible", "label-use-static-options": "Utiliser des options statiques", "name-regex": "Expression régulière", "query-options": "Options de recherche", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Options de sélection", "static-options-legend": "Options statiques" }, diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 409c3249166..31d51ce3843 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -4781,6 +4781,8 @@ "remove": "{{typeName}} eltávolítása", "row-title": "", "switch-layout": "Kiosztás váltása", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5887,6 +5889,17 @@ "label-value": "Érték", "placeholder-your-metric-prefix": "Az Ön metrikaelőtagja" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Egyéni opciók", "name-values-separated-comma": "Értékek vesszővel elválasztva", @@ -6258,12 +6271,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Opcionális, ha egy sorozatnév vagy metrikai csomópontszegmens egy részét szeretné kinyerni.", + "description-regex-apply-to": "", "label-data-source": "Adatforrás", + "label-regex-apply-to": "", "label-static-options-sort": "Statikus opciók rendezése", "label-target-data-source": "Céladatforrás", "label-use-static-options": "Statikus opciók használata", "name-regex": "Reguláris kifejezés", "query-options": "Lekérdezési beállítások", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Kijelölés beállításai", "static-options-legend": "Statikus opciók" }, diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 75f6c32dcba..3bab7931da8 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -4762,6 +4762,8 @@ "remove": "Hapus {{typeName}}", "row-title": "", "switch-layout": "Ganti tata letak", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5866,6 +5868,17 @@ "label-value": "Nilai", "placeholder-your-metric-prefix": "Awalan metrik Anda" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Opsi kustom", "name-values-separated-comma": "Nilai dipisahkan dengan koma", @@ -6236,12 +6249,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Opsional, jika Anda ingin mengekstrak bagian dari nama seri atau segmen node metrik.", + "description-regex-apply-to": "", "label-data-source": "Sumber data", + "label-regex-apply-to": "", "label-static-options-sort": "Urutan opsi statis", "label-target-data-source": "Sumber data target", "label-use-static-options": "Gunakan opsi statis", "name-regex": "Regex", "query-options": "Opsi kueri", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Opsi pemilihan", "static-options-legend": "Opsi statis" }, diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index aa13b629973..1bbbd8daee3 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -4781,6 +4781,8 @@ "remove": "Rimuovi {{typeName}}", "row-title": "", "switch-layout": "Cambia layout", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5887,6 +5889,17 @@ "label-value": "Valore", "placeholder-your-metric-prefix": "Il tuo prefisso della metrica" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Opzioni personalizzate", "name-values-separated-comma": "Valori separati da virgola", @@ -6258,12 +6271,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Facoltativo, se desideri estrarre parte di un nome di serie o di un segmento di nodo della metrica.", + "description-regex-apply-to": "", "label-data-source": "Sorgente dati", + "label-regex-apply-to": "", "label-static-options-sort": "Ordinamento opzioni statiche", "label-target-data-source": "Origine dati di destinazione", "label-use-static-options": "Usa opzioni statiche", "name-regex": "Regex", "query-options": "Opzioni query", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Seleziona opzioni", "static-options-legend": "Opzioni statiche" }, diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index f24c3eaa678..5031d88730e 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -4762,6 +4762,8 @@ "remove": "{{typeName}}を削除", "row-title": "", "switch-layout": "レイアウトを切り替え", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5866,6 +5868,17 @@ "label-value": "値", "placeholder-your-metric-prefix": "メトリックのプレフィックス" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "カスタムオプション", "name-values-separated-comma": "カンマ区切りの値", @@ -6236,12 +6249,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "シリーズ名やメトリックノードセグメントの一部を抽出したい場合の任意設定です。", + "description-regex-apply-to": "", "label-data-source": "データソース", + "label-regex-apply-to": "", "label-static-options-sort": "スタティックオプションの並び替え", "label-target-data-source": "ターゲットデータソース", "label-use-static-options": "スタティックオプションを使用", "name-regex": "正規表現", "query-options": "クエリオプション", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "選択オプション", "static-options-legend": "スタティックオプション" }, diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 51978f07fb9..f3505d1f337 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -4762,6 +4762,8 @@ "remove": "{{typeName}} 제거", "row-title": "", "switch-layout": "레이아웃 전환", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5866,6 +5868,17 @@ "label-value": "값", "placeholder-your-metric-prefix": "메트릭 접두사" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "사용자 지정 옵션", "name-values-separated-comma": "쉼표로 구분된 값", @@ -6236,12 +6249,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "선택 사항, 시리즈 이름 또는 메트릭 노드 세그먼트의 일부를 추출하려는 경우.", + "description-regex-apply-to": "", "label-data-source": "데이터 소스", + "label-regex-apply-to": "", "label-static-options-sort": "정적 옵션 정렬", "label-target-data-source": "대상 데이터 소스", "label-use-static-options": "정적 옵션 사용", "name-regex": "정규식", "query-options": "쿼리 옵션", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "선택 옵션", "static-options-legend": "정적 옵션" }, diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index 96765f7630e..ad8203942df 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -4781,6 +4781,8 @@ "remove": "{{typeName}} verwijderen", "row-title": "", "switch-layout": "Indeling wisselen", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5887,6 +5889,17 @@ "label-value": "Waarde", "placeholder-your-metric-prefix": "Je metrisch voorvoegsel" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Aangepaste opties", "name-values-separated-comma": "Waarden gescheiden door komma", @@ -6258,12 +6271,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Optioneel, als je een deel van een serienaam of metrisch knooppuntsegment wilt extraheren.", + "description-regex-apply-to": "", "label-data-source": "Gegevensbron", + "label-regex-apply-to": "", "label-static-options-sort": "Statische opties sorteren", "label-target-data-source": "Doelgegevensbron", "label-use-static-options": "Statische opties gebruiken", "name-regex": "Regex", "query-options": "Opties voor query's", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Selectiemogelijkheden", "static-options-legend": "Statische opties" }, diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 96fe66f6ae0..f34809b9cbe 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -4819,6 +4819,8 @@ "remove": "Usuń: {{typeName}}", "row-title": "", "switch-layout": "Przełącz układ", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5929,6 +5931,17 @@ "label-value": "Wartość", "placeholder-your-metric-prefix": "Prefiks metryki" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Opcje niestandardowe", "name-values-separated-comma": "Wartości rozdzielone przecinkami", @@ -6302,12 +6315,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Opcjonalnie pozwalają wyodrębnić część nazwy serii lub segmentu węzła metryki.", + "description-regex-apply-to": "", "label-data-source": "Źródło danych", + "label-regex-apply-to": "", "label-static-options-sort": "Sortowanie opcji statycznych", "label-target-data-source": "Docelowe źródło danych", "label-use-static-options": "Użyj opcji statycznych", "name-regex": "Wyrażenie regularne", "query-options": "Opcje zapytania", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Opcje wyboru", "static-options-legend": "Opcje statyczne" }, diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index dc5bd71fadc..d8822bc8635 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -4781,6 +4781,8 @@ "remove": "Remover {{typeName}}", "row-title": "", "switch-layout": "Alternar layout", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5887,6 +5889,17 @@ "label-value": "Valor", "placeholder-your-metric-prefix": "Seu prefixo de métrica" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Opções personalizadas", "name-values-separated-comma": "Valores separados por vírgula", @@ -6258,12 +6271,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Opcional, se você quiser extrair parte de um nome de série ou segmento de node de métrica.", + "description-regex-apply-to": "", "label-data-source": "Fonte de dados", + "label-regex-apply-to": "", "label-static-options-sort": "Classificação de opções estáticas", "label-target-data-source": "Fonte de dados de destino", "label-use-static-options": "Usar opções estáticas", "name-regex": "Regex", "query-options": "Opções de consulta", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Opções de seleção", "static-options-legend": "Opções estáticas" }, diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index cfe6b435e73..1fed27f2a62 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -4781,6 +4781,8 @@ "remove": "Remover {{typeName}}", "row-title": "", "switch-layout": "Mudar o layout", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5887,6 +5889,17 @@ "label-value": "Valor", "placeholder-your-metric-prefix": "O seu prefixo métrico" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Opções personalizadas", "name-values-separated-comma": "Valores separados por vírgulas", @@ -6258,12 +6271,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Opcional, se pretender extrair parte de um nome de série ou segmento de nó métrico.", + "description-regex-apply-to": "", "label-data-source": "Origem dos dados", + "label-regex-apply-to": "", "label-static-options-sort": "Ordenação de opções estáticas", "label-target-data-source": "Origem de dados de destino", "label-use-static-options": "Usar opções estáticas", "name-regex": "Regex", "query-options": "Opções de consulta", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Opções de seleção", "static-options-legend": "Opções estáticas" }, diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 56fc00e836c..43edc80f5dc 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -4819,6 +4819,8 @@ "remove": "Удалить {{typeName}}", "row-title": "", "switch-layout": "Переключить макет", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5929,6 +5931,17 @@ "label-value": "Значение", "placeholder-your-metric-prefix": "Префикс метрик" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Пользовательские параметры", "name-values-separated-comma": "Значения, разделенные запятыми", @@ -6302,12 +6315,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Дополнительно, если вы хотите извлечь часть имени ряда или сегмента узла метрики.", + "description-regex-apply-to": "", "label-data-source": "Источник данных", + "label-regex-apply-to": "", "label-static-options-sort": "Сортировка статических параметров", "label-target-data-source": "Целевой источник данных", "label-use-static-options": "Использовать статические параметры", "name-regex": "Регулярное выражение", "query-options": "Параметры запросов", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Параметры выбора", "static-options-legend": "Статические параметры" }, diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index ed1313039a7..d983d726e59 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -4781,6 +4781,8 @@ "remove": "Ta bort {{typeName}}", "row-title": "", "switch-layout": "Byt layout", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5887,6 +5889,17 @@ "label-value": "Värde", "placeholder-your-metric-prefix": "Ditt metrikprefix" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Anpassade alternativ", "name-values-separated-comma": "Värden åtskilda med kommatecken", @@ -6258,12 +6271,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "Valfritt, om du vill extrahera en del av ett serienamn eller metriskt nodsegment.", + "description-regex-apply-to": "", "label-data-source": "Datakälla", + "label-regex-apply-to": "", "label-static-options-sort": "Sortering av statiska alternativ", "label-target-data-source": "Måldatakälla", "label-use-static-options": "Använd statiska alternativ", "name-regex": "Regex", "query-options": "Frågealternativ", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Urvalsalternativ", "static-options-legend": "Statiska alternativ" }, diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 8f574384a75..30d0c3601ae 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -4781,6 +4781,8 @@ "remove": "{{typeName}} ögesini kaldır", "row-title": "", "switch-layout": "Düzeni değiştir", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5887,6 +5889,17 @@ "label-value": "Değer", "placeholder-your-metric-prefix": "Metrik ön ekiniz" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "Özel seçenekler", "name-values-separated-comma": "Virgülle ayrılmış değerler", @@ -6258,12 +6271,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "İsteğe bağlıdır, bir seri adının veya metrik düğüm parçasının bir kısmını çıkarmak istiyorsanız kullanılır.", + "description-regex-apply-to": "", "label-data-source": "Veri kaynağı", + "label-regex-apply-to": "", "label-static-options-sort": "Statik seçenekleri sırala", "label-target-data-source": "Hedef veri kaynağı", "label-use-static-options": "Statik seçenekleri kullan", "name-regex": "Düzenli İfade", "query-options": "Sorgu seçenekleri", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "Seçim ayarları", "static-options-legend": "Statik seçenekler" }, diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 302ea2645f3..33bb2fc3d08 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -4762,6 +4762,8 @@ "remove": "移除{{typeName}}", "row-title": "", "switch-layout": "切换布局", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5866,6 +5868,17 @@ "label-value": "值", "placeholder-your-metric-prefix": "您的指标前缀" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "自定义选项", "name-values-separated-comma": "以逗号分隔的值", @@ -6236,12 +6249,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "可选,如果您想要提取序列名称或指标节点段的一部分。", + "description-regex-apply-to": "", "label-data-source": "数据源", + "label-regex-apply-to": "", "label-static-options-sort": "静态选项排序", "label-target-data-source": "目标数据源", "label-use-static-options": "使用静态选项", "name-regex": "正则表达式", "query-options": "查询选项", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "选择内容选项", "static-options-legend": "静态选项" }, diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index a0beece480a..6fbcd57b192 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -4762,6 +4762,8 @@ "remove": "移除 {{typeName}}", "row-title": "", "switch-layout": "切換版面配置", + "switch-layout-row": "", + "switch-layout-tab": "", "tab-title": "" }, "edit-pane": { @@ -5866,6 +5868,17 @@ "label-value": "數值", "placeholder-your-metric-prefix": "您的指標前綴" }, + "conversion-warning-banner": { + "detail-conditional": "", + "detail-grids": "", + "detail-tabs": "", + "details": "", + "message": "", + "read-less": "", + "read-more": "", + "recommendation": "", + "save-warning": "" + }, "custom-variable-form": { "custom-options": "自訂選項", "name-values-separated-comma": "以逗號分隔的值", @@ -6236,12 +6249,20 @@ "query-variable-editor-form": { "description-examples": "", "description-optional": "若想擷取系列名稱或指標節點區段的一部分,則為可選。", + "description-regex-apply-to": "", "label-data-source": "資料來源", + "label-regex-apply-to": "", "label-static-options-sort": "靜態選項排序", "label-target-data-source": "目標資料來源", "label-use-static-options": "使用靜態選項", "name-regex": "Regex", "query-options": "查詢選項", + "regex-apply-to-options": { + "label": { + "text": "", + "value": "" + } + }, "selection-options": "選擇選項", "static-options-legend": "靜態選項" }, From d1761606fb3f1c698e597276ec0e0f1fa0e14f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 10 Dec 2025 06:44:46 +0100 Subject: [PATCH 008/139] Plugins: Add PluginContext to plugins when scenes is disabled (#114989) Plugins: Add PluginContext to plugins when scenes is disabled --- .../dashboard/dashgrid/PanelStateWrapper.tsx | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index 20c702edd8b..7087c9c611c 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -17,6 +17,7 @@ import { PanelData, PanelPlugin, PanelPluginMeta, + PluginContextProvider, SetPanelAttentionEvent, TimeRange, toDataFrameDTO, @@ -524,27 +525,29 @@ export class PanelStateWrapper extends PureComponent { return ( <> - - {this.state.errorMessage === undefined && ( - - )} + + + {this.state.errorMessage === undefined && ( + + )} + ); From 4fd03bc05e90c20a28a7d0e879b5e8a11bbd70c8 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Wed, 10 Dec 2025 01:45:22 -0700 Subject: [PATCH 009/139] Folders: Fix error handling for zanzana (#115056) --- pkg/api/apierrors/folder.go | 19 +++++++++- pkg/api/apierrors/folder_test.go | 62 +++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/pkg/api/apierrors/folder.go b/pkg/api/apierrors/folder.go index 81b16c3899e..9509ff4ff55 100644 --- a/pkg/api/apierrors/folder.go +++ b/pkg/api/apierrors/folder.go @@ -57,7 +57,11 @@ func ToFolderErrorResponse(err error) response.Response { // --- Kubernetes status errors --- var statusErr *k8sErrors.StatusError if errors.As(err, &statusErr) { - return response.Error(int(statusErr.ErrStatus.Code), statusErr.ErrStatus.Message, err) + message := statusErr.ErrStatus.Message + if message == "" { + message = getDefaultMessageForStatus(int(statusErr.ErrStatus.Code)) + } + return response.Error(int(statusErr.ErrStatus.Code), message, err) } return response.ErrOrFallback(http.StatusInternalServerError, fmt.Sprintf("Folder API error: %s", err.Error()), err) @@ -100,6 +104,19 @@ func ToFolderStatusError(err error) k8sErrors.StatusError { } } +func getDefaultMessageForStatus(statusCode int) string { + switch statusCode { + case http.StatusForbidden: + return "Access denied" + case http.StatusNotFound: + return "Folder not found" + case http.StatusBadRequest: + return "Invalid request" + default: + return "Folder API error" + } +} + func IsForbidden(err error) bool { return k8sErrors.IsForbidden(err) || errors.Is(err, dashboards.ErrFolderAccessDenied) } diff --git a/pkg/api/apierrors/folder_test.go b/pkg/api/apierrors/folder_test.go index 2def7fb48a4..0ca8b16fc87 100644 --- a/pkg/api/apierrors/folder_test.go +++ b/pkg/api/apierrors/folder_test.go @@ -125,7 +125,7 @@ func TestToFolderErrorResponse(t *testing.T) { }, // --- Kubernetes status errors --- { - name: "kubernetes status error", + name: "kubernetes status error with message", input: &k8sErrors.StatusError{ ErrStatus: metav1.Status{ Code: 412, @@ -139,6 +139,66 @@ func TestToFolderErrorResponse(t *testing.T) { }, }), }, + { + name: "kubernetes status error with empty message - 403 forbidden", + input: &k8sErrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusForbidden, + Message: "", + }, + }, + want: response.Error(http.StatusForbidden, "Access denied", &k8sErrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusForbidden, + Message: "", + }, + }), + }, + { + name: "kubernetes status error with empty message - 404 not found", + input: &k8sErrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusNotFound, + Message: "", + }, + }, + want: response.Error(http.StatusNotFound, "Folder not found", &k8sErrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusNotFound, + Message: "", + }, + }), + }, + { + name: "kubernetes status error with empty message - 400 bad request", + input: &k8sErrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusBadRequest, + Message: "", + }, + }, + want: response.Error(http.StatusBadRequest, "Invalid request", &k8sErrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusBadRequest, + Message: "", + }, + }), + }, + { + name: "kubernetes status error with empty message - default fallback", + input: &k8sErrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusInternalServerError, + Message: "", + }, + }, + want: response.Error(http.StatusInternalServerError, "Folder API error", &k8sErrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusInternalServerError, + Message: "", + }, + }), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 8911785fdf8ac4e605133c49dc1b08b57503d5ae Mon Sep 17 00:00:00 2001 From: Misi Date: Wed, 10 Dec 2025 10:15:12 +0100 Subject: [PATCH 010/139] Chore: Regenerate iam app objects with 0.48.5 sdk (#115035) * Regenerate iam app with 0.48.5 sdk * update ws --------- Co-authored-by: Ryan McKinley --- .../pkg/apis/iam/v0alpha1/corerole_object_gen.go | 6 ++++++ .../pkg/apis/iam/v0alpha1/corerole_schema_gen.go | 2 +- .../v0alpha1/externalgroupmapping_object_gen.go | 6 ++++++ .../v0alpha1/externalgroupmapping_schema_gen.go | 2 +- .../pkg/apis/iam/v0alpha1/globalrole_object_gen.go | 6 ++++++ .../pkg/apis/iam/v0alpha1/globalrole_schema_gen.go | 2 +- .../iam/v0alpha1/globalrolebinding_object_gen.go | 6 ++++++ .../iam/v0alpha1/globalrolebinding_schema_gen.go | 2 +- .../iam/v0alpha1/resourcepermission_object_gen.go | 6 ++++++ .../iam/v0alpha1/resourcepermission_schema_gen.go | 2 +- apps/iam/pkg/apis/iam/v0alpha1/role_object_gen.go | 6 ++++++ apps/iam/pkg/apis/iam/v0alpha1/role_schema_gen.go | 2 +- .../apis/iam/v0alpha1/rolebinding_object_gen.go | 6 ++++++ .../apis/iam/v0alpha1/rolebinding_schema_gen.go | 2 +- .../apis/iam/v0alpha1/serviceaccount_object_gen.go | 6 ++++++ .../apis/iam/v0alpha1/serviceaccount_schema_gen.go | 2 +- apps/iam/pkg/apis/iam/v0alpha1/team_object_gen.go | 6 ++++++ apps/iam/pkg/apis/iam/v0alpha1/team_schema_gen.go | 2 +- .../apis/iam/v0alpha1/teambinding_object_gen.go | 6 ++++++ .../apis/iam/v0alpha1/teambinding_schema_gen.go | 2 +- apps/iam/pkg/apis/iam/v0alpha1/user_object_gen.go | 6 ++++++ apps/iam/pkg/apis/iam/v0alpha1/user_schema_gen.go | 2 +- apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go | 5 ----- apps/iam/pkg/apis/iam_manifest.go | 14 ++++++++++++++ go.work.sum | 7 +------ 25 files changed, 92 insertions(+), 22 deletions(-) diff --git a/apps/iam/pkg/apis/iam/v0alpha1/corerole_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/corerole_object_gen.go index 625cced10b9..9a99aadddee 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/corerole_object_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/corerole_object_gen.go @@ -23,6 +23,12 @@ type CoreRole struct { Spec CoreRoleSpec `json:"spec" yaml:"spec"` } +func NewCoreRole() *CoreRole { + return &CoreRole{ + Spec: *NewCoreRoleSpec(), + } +} + func (o *CoreRole) GetSpec() any { return o.Spec } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/corerole_schema_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/corerole_schema_gen.go index 637f78355f8..82c0a8102e0 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/corerole_schema_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/corerole_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaCoreRole = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", &CoreRole{}, &CoreRoleList{}, resource.WithKind("CoreRole"), + schemaCoreRole = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", NewCoreRole(), &CoreRoleList{}, resource.WithKind("CoreRole"), resource.WithPlural("coreroles"), resource.WithScope(resource.NamespacedScope)) kindCoreRole = resource.Kind{ Schema: schemaCoreRole, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_object_gen.go index db20616c355..bbc7c2f65c4 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_object_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_object_gen.go @@ -23,6 +23,12 @@ type ExternalGroupMapping struct { Spec ExternalGroupMappingSpec `json:"spec" yaml:"spec"` } +func NewExternalGroupMapping() *ExternalGroupMapping { + return &ExternalGroupMapping{ + Spec: *NewExternalGroupMappingSpec(), + } +} + func (o *ExternalGroupMapping) GetSpec() any { return o.Spec } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_schema_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_schema_gen.go index 91090a9b460..943c41d8e3e 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_schema_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/externalgroupmapping_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaExternalGroupMapping = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", &ExternalGroupMapping{}, &ExternalGroupMappingList{}, resource.WithKind("ExternalGroupMapping"), + schemaExternalGroupMapping = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", NewExternalGroupMapping(), &ExternalGroupMappingList{}, resource.WithKind("ExternalGroupMapping"), resource.WithPlural("externalgroupmappings"), resource.WithScope(resource.NamespacedScope)) kindExternalGroupMapping = resource.Kind{ Schema: schemaExternalGroupMapping, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/globalrole_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/globalrole_object_gen.go index 27165fe70bd..66ad21c83cf 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/globalrole_object_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/globalrole_object_gen.go @@ -23,6 +23,12 @@ type GlobalRole struct { Spec GlobalRoleSpec `json:"spec" yaml:"spec"` } +func NewGlobalRole() *GlobalRole { + return &GlobalRole{ + Spec: *NewGlobalRoleSpec(), + } +} + func (o *GlobalRole) GetSpec() any { return o.Spec } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/globalrole_schema_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/globalrole_schema_gen.go index ce3322f97c0..be220cb0e95 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/globalrole_schema_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/globalrole_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaGlobalRole = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", &GlobalRole{}, &GlobalRoleList{}, resource.WithKind("GlobalRole"), + schemaGlobalRole = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", NewGlobalRole(), &GlobalRoleList{}, resource.WithKind("GlobalRole"), resource.WithPlural("globalroles"), resource.WithScope(resource.NamespacedScope)) kindGlobalRole = resource.Kind{ Schema: schemaGlobalRole, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/globalrolebinding_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/globalrolebinding_object_gen.go index 3bd4609d25d..bb8b0644f62 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/globalrolebinding_object_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/globalrolebinding_object_gen.go @@ -23,6 +23,12 @@ type GlobalRoleBinding struct { Spec GlobalRoleBindingSpec `json:"spec" yaml:"spec"` } +func NewGlobalRoleBinding() *GlobalRoleBinding { + return &GlobalRoleBinding{ + Spec: *NewGlobalRoleBindingSpec(), + } +} + func (o *GlobalRoleBinding) GetSpec() any { return o.Spec } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/globalrolebinding_schema_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/globalrolebinding_schema_gen.go index 9b4e65aa5e6..2e39da946f9 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/globalrolebinding_schema_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/globalrolebinding_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaGlobalRoleBinding = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", &GlobalRoleBinding{}, &GlobalRoleBindingList{}, resource.WithKind("GlobalRoleBinding"), + schemaGlobalRoleBinding = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", NewGlobalRoleBinding(), &GlobalRoleBindingList{}, resource.WithKind("GlobalRoleBinding"), resource.WithPlural("globalrolebindings"), resource.WithScope(resource.NamespacedScope)) kindGlobalRoleBinding = resource.Kind{ Schema: schemaGlobalRoleBinding, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/resourcepermission_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/resourcepermission_object_gen.go index 996beb7e002..8cc4c28c209 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/resourcepermission_object_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/resourcepermission_object_gen.go @@ -23,6 +23,12 @@ type ResourcePermission struct { Spec ResourcePermissionSpec `json:"spec" yaml:"spec"` } +func NewResourcePermission() *ResourcePermission { + return &ResourcePermission{ + Spec: *NewResourcePermissionSpec(), + } +} + func (o *ResourcePermission) GetSpec() any { return o.Spec } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/resourcepermission_schema_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/resourcepermission_schema_gen.go index 257255a58fe..aa709f0d42a 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/resourcepermission_schema_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/resourcepermission_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaResourcePermission = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", &ResourcePermission{}, &ResourcePermissionList{}, resource.WithKind("ResourcePermission"), + schemaResourcePermission = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", NewResourcePermission(), &ResourcePermissionList{}, resource.WithKind("ResourcePermission"), resource.WithPlural("resourcepermissions"), resource.WithScope(resource.NamespacedScope)) kindResourcePermission = resource.Kind{ Schema: schemaResourcePermission, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/role_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/role_object_gen.go index 20bb587157e..0673c1a17c5 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/role_object_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/role_object_gen.go @@ -23,6 +23,12 @@ type Role struct { Spec RoleSpec `json:"spec" yaml:"spec"` } +func NewRole() *Role { + return &Role{ + Spec: *NewRoleSpec(), + } +} + func (o *Role) GetSpec() any { return o.Spec } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/role_schema_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/role_schema_gen.go index 74cc8730026..3aacfa2e060 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/role_schema_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/role_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaRole = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", &Role{}, &RoleList{}, resource.WithKind("Role"), + schemaRole = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", NewRole(), &RoleList{}, resource.WithKind("Role"), resource.WithPlural("roles"), resource.WithScope(resource.NamespacedScope)) kindRole = resource.Kind{ Schema: schemaRole, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/rolebinding_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/rolebinding_object_gen.go index dfd7741e05a..996fdc3df65 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/rolebinding_object_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/rolebinding_object_gen.go @@ -23,6 +23,12 @@ type RoleBinding struct { Spec RoleBindingSpec `json:"spec" yaml:"spec"` } +func NewRoleBinding() *RoleBinding { + return &RoleBinding{ + Spec: *NewRoleBindingSpec(), + } +} + func (o *RoleBinding) GetSpec() any { return o.Spec } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/rolebinding_schema_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/rolebinding_schema_gen.go index abb2b3eddf9..0cf1d4dee8b 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/rolebinding_schema_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/rolebinding_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaRoleBinding = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", &RoleBinding{}, &RoleBindingList{}, resource.WithKind("RoleBinding"), + schemaRoleBinding = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", NewRoleBinding(), &RoleBindingList{}, resource.WithKind("RoleBinding"), resource.WithPlural("rolebindings"), resource.WithScope(resource.NamespacedScope)) kindRoleBinding = resource.Kind{ Schema: schemaRoleBinding, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_object_gen.go index fe3cd7f3609..e2cfc9f33a7 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_object_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_object_gen.go @@ -23,6 +23,12 @@ type ServiceAccount struct { Spec ServiceAccountSpec `json:"spec" yaml:"spec"` } +func NewServiceAccount() *ServiceAccount { + return &ServiceAccount{ + Spec: *NewServiceAccountSpec(), + } +} + func (o *ServiceAccount) GetSpec() any { return o.Spec } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_schema_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_schema_gen.go index ea48c864739..71b171e0756 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_schema_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaServiceAccount = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", &ServiceAccount{}, &ServiceAccountList{}, resource.WithKind("ServiceAccount"), + schemaServiceAccount = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", NewServiceAccount(), &ServiceAccountList{}, resource.WithKind("ServiceAccount"), resource.WithPlural("serviceaccounts"), resource.WithScope(resource.NamespacedScope)) kindServiceAccount = resource.Kind{ Schema: schemaServiceAccount, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/team_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/team_object_gen.go index 4030bebb9d1..85b57abf9d5 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/team_object_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/team_object_gen.go @@ -23,6 +23,12 @@ type Team struct { Spec TeamSpec `json:"spec" yaml:"spec"` } +func NewTeam() *Team { + return &Team{ + Spec: *NewTeamSpec(), + } +} + func (o *Team) GetSpec() any { return o.Spec } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/team_schema_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/team_schema_gen.go index 7a4875cf2d5..299d846d10e 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/team_schema_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/team_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaTeam = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", &Team{}, &TeamList{}, resource.WithKind("Team"), + schemaTeam = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", NewTeam(), &TeamList{}, resource.WithKind("Team"), resource.WithPlural("teams"), resource.WithScope(resource.NamespacedScope)) kindTeam = resource.Kind{ Schema: schemaTeam, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/teambinding_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/teambinding_object_gen.go index a958c55f5e7..0d388192448 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/teambinding_object_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/teambinding_object_gen.go @@ -23,6 +23,12 @@ type TeamBinding struct { Spec TeamBindingSpec `json:"spec" yaml:"spec"` } +func NewTeamBinding() *TeamBinding { + return &TeamBinding{ + Spec: *NewTeamBindingSpec(), + } +} + func (o *TeamBinding) GetSpec() any { return o.Spec } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/teambinding_schema_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/teambinding_schema_gen.go index e3c2c11a8b5..d089b966e9b 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/teambinding_schema_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/teambinding_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaTeamBinding = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", &TeamBinding{}, &TeamBindingList{}, resource.WithKind("TeamBinding"), + schemaTeamBinding = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", NewTeamBinding(), &TeamBindingList{}, resource.WithKind("TeamBinding"), resource.WithPlural("teambindings"), resource.WithScope(resource.NamespacedScope)) kindTeamBinding = resource.Kind{ Schema: schemaTeamBinding, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/user_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/user_object_gen.go index 373112a1d87..bd3bf8fae0e 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/user_object_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/user_object_gen.go @@ -23,6 +23,12 @@ type User struct { Spec UserSpec `json:"spec" yaml:"spec"` } +func NewUser() *User { + return &User{ + Spec: *NewUserSpec(), + } +} + func (o *User) GetSpec() any { return o.Spec } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/user_schema_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/user_schema_gen.go index a44d9dfcf2d..ba48b19c015 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/user_schema_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/user_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaUser = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", &User{}, &UserList{}, resource.WithKind("User"), + schemaUser = resource.NewSimpleSchema("iam.grafana.app", "v0alpha1", NewUser(), &UserList{}, resource.WithKind("User"), resource.WithPlural("users"), resource.WithScope(resource.NamespacedScope)) kindUser = resource.Kind{ Schema: schemaUser, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go index a41d51e8d70..ece208e8d68 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go @@ -1,8 +1,3 @@ -//go:build !ignore_autogenerated -// +build !ignore_autogenerated - -// Code generated by grafana-app-sdk. DO NOT EDIT. - package v0alpha1 import ( diff --git a/apps/iam/pkg/apis/iam_manifest.go b/apps/iam/pkg/apis/iam_manifest.go index 5c27c228ed2..0106984f7f2 100644 --- a/apps/iam/pkg/apis/iam_manifest.go +++ b/apps/iam/pkg/apis/iam_manifest.go @@ -109,6 +109,13 @@ var appManifestData = app.ManifestData{ "items": { SchemaProps: spec.SchemaProps{ Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + + Ref: spec.MustCreateRef("#/components/schemas/getGroupsExternalGroupMapping"), + }}, + }, }, }, "kind": { @@ -200,6 +207,13 @@ var appManifestData = app.ManifestData{ "hits": { SchemaProps: spec.SchemaProps{ Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + + Ref: spec.MustCreateRef("#/components/schemas/getSearchTeamsTeamHit"), + }}, + }, }, }, "kind": { diff --git a/go.work.sum b/go.work.sum index eaa5da46cf0..72f38753b02 100644 --- a/go.work.sum +++ b/go.work.sum @@ -267,8 +267,6 @@ gioui.org v0.0.0-20210308172011-57750fc8a0a6 h1:K72hopUosKG3ntOPNG4OzzbuhxGuVf06 git.sr.ht/~sbinet/gg v0.6.0 h1:RIzgkizAk+9r7uPzf/VfbJHBMKUr0F5hRFxTUGMnt38= git.sr.ht/~sbinet/gg v0.6.0/go.mod h1:uucygbfC9wVPQIfrmwM2et0imr8L7KQWywX0xpFMm94= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0/go.mod h1:OahwfttHWG6eJ0clwcfBAHoDI6X/LV/15hx/wlMZSrU= github.com/Azure/azure-amqp-common-go/v3 v3.2.3 h1:uDF62mbd9bypXWi19V1bN5NZEO84JqgmI5G73ibAmrk= github.com/Azure/azure-amqp-common-go/v3 v3.2.3/go.mod h1:7rPmbSfszeovxGfc5fSAXE4ehlXQZHpMja2OtxC2Tas= @@ -604,8 +602,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo= @@ -928,6 +924,7 @@ github.com/grafana/grafana/apps/preferences v0.0.0-20250805120145-0c5a00302924 h github.com/grafana/grafana/apps/preferences v0.0.0-20250805120145-0c5a00302924/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= github.com/grafana/grafana/apps/preferences v0.0.0-20250805123034-066163d71001 h1:y2AHkdji2I+zXv8rsSC8OjWEzJJjqW5OlmCsZR5+RuU= github.com/grafana/grafana/apps/preferences v0.0.0-20250805123034-066163d71001/go.mod h1:NQlHMO5fHhjexw71wVjv522532NRvFg5F4tcjUEktjs= +github.com/grafana/grafana/apps/quotas v0.0.0-20251209171149-4b999cd94388/go.mod h1:M7bV60iRB61y0ISPG1HX/oNLZtlh0ZF22rUYwNkAKjo= github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA= github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c= github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d/go.mod h1:CXpwZ3Mkw6xVlGKc0SqUxqXCP3Uv182q6qAQnLaLxRg= @@ -2078,7 +2075,6 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go. google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= -google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822 h1:zWFRixYR5QlotL+Uv3YfsPRENIrQFXiGs+iwqel6fOQ= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822/go.mod h1:h6yxum/C2qRb4txaZRLDHK8RyS0H/o2oEDeKY4onY/Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY= @@ -2108,7 +2104,6 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA= From 633332c7509a19fd640f3e7a424b07d615c77153 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:34:23 +0100 Subject: [PATCH 011/139] deps(docker): bump alpine from 3.22.2 to 3.23.0 (#114816) Bumps alpine from 3.22.2 to 3.23.0. --- updated-dependencies: - dependency-name: alpine dependency-version: 3.23.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 558672951e6..2b16926a836 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ ARG JS_SRC=js-builder # Dependabot cannot update dependencies listed in ARGs # By using FROM instructions we can delegate dependency updates to dependabot -FROM alpine:3.22.2 AS alpine-base +FROM alpine:3.23.0 AS alpine-base FROM ubuntu:22.04 AS ubuntu-base FROM golang:1.25.5-alpine AS go-builder-base FROM --platform=${JS_PLATFORM} node:24-alpine AS js-builder-base From a7bbca34513c726e51d9f309a13327fcc9445329 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Wed, 10 Dec 2025 11:19:31 +0100 Subject: [PATCH 012/139] Logs Panel: Emphasize log line, rename field (#114579) * Logs: Rename attributes field * LogLine: emphasize log line body * LogLine: improve light mode * Lint * Update tests * Only override colors if displayed fields are used * Fix small font size ignored with displayed fields * Fix types --- .../logs/components/panel/InfiniteScroll.tsx | 2 +- .../logs/components/panel/LogLine.tsx | 47 +++++++++++++++---- .../logs/components/panel/LogList.test.tsx | 2 +- .../logs/components/panel/LogList.tsx | 2 +- .../logs/components/panel/processing.ts | 2 +- public/locales/en-US/grafana.json | 2 +- 6 files changed, 43 insertions(+), 14 deletions(-) diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx index 3c7de64b6be..fd5fb15a014 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -72,7 +72,7 @@ export const InfiniteScroll = ({ const lastEvent = useRef(null); const countRef = useRef(0); const lastLogOfPage = useRef([]); - const styles = useStyles2(getStyles, virtualization); + const styles = useStyles2(getStyles, virtualization, displayedFields); const resetStateTimeout = useRef | null>(null); const scrollToLogLineRef = useRef(undefined); const noScrollRef = useRef(undefined); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 292f35ea75a..a1914607600 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -202,7 +202,7 @@ const LogLineComponent = memo( {/* A button element could be used but in Safari it prevents text selection. Fallback available for a11y in LogLineMenu */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
+ {' '} ); @@ -448,12 +448,12 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles highlightClassName={styles.matchHighLight} /> ) : ( - {log.body} + {log.body} ); } return ( - + {' '} ); @@ -468,7 +468,30 @@ export function getGridTemplateColumns(dimensions: LogFieldDimension[], displaye } export type LogLineStyles = ReturnType; -export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtualization) => { +export const getStyles = ( + theme: GrafanaTheme2, + virtualization: LogLineVirtualization | undefined = undefined, + displayedFields: string[] = [] +) => { + const base = tinycolor(theme.colors.background.primary); + + let maxContrast = theme.isDark + ? tinycolor(theme.colors.text.maxContrast).darken(10).toRgbString() + : tinycolor(theme.colors.text.maxContrast).lighten(10).toRgbString(); + let colorDefault = theme.isDark + ? theme.colors.text.primary + : tinycolor(theme.colors.text.maxContrast).lighten(30).toRgbString(); + const contrast1 = tinycolor.readability(base, maxContrast); + const contrast2 = tinycolor.readability(base, colorDefault); + + if (!displayedFields.length || (displayedFields.length === 1 && displayedFields.includes(LOG_LINE_BODY_FIELD_NAME))) { + colorDefault = theme.colors.text.primary; + maxContrast = theme.colors.text.primary; + } else if (contrast1 < contrast2) { + colorDefault = maxContrast; + maxContrast = theme.colors.text.primary; + } + const colors = { critical: '#B877D9', error: theme.colors.error.text, @@ -477,8 +500,9 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali trace: '#6ed0e0', info: '#6CCF8E', metadata: theme.colors.text.secondary, - default: theme.colors.text.primary, + default: colorDefault, parsedField: theme.colors.text.secondary, + logLineBody: maxContrast, }; const hoverColor = tinycolor(theme.colors.background.canvas).darken(11).toRgbString(); @@ -490,8 +514,6 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali gap: theme.spacing(0.5), flexDirection: 'row', fontFamily: theme.typography.fontFamilyMonospace, - fontSize: theme.typography.fontSize, - lineHeight: theme.typography.body.lineHeight, wordBreak: 'break-all', '&:hover': { background: hoverColor, @@ -509,7 +531,7 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali }, '& .log-syntax-highlight': { '.log-token-string': { - color: colors.default, + color: colors.logLineBody, }, '.log-token-duration': { color: theme.colors.success.text, @@ -540,6 +562,9 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali color: theme.components.textHighlight.text, backgroundColor: theme.components.textHighlight.background, }, + '&.log-line-body': { + color: colors.logLineBody, + }, }, '& .no-highlighting': { color: theme.colors.text.primary, @@ -553,6 +578,10 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali fontSize: theme.typography.bodySmall.fontSize, lineHeight: theme.typography.bodySmall.lineHeight, }), + fontSizeDefault: css({ + fontSize: theme.typography.fontSize, + lineHeight: theme.typography.body.lineHeight, + }), detailsDisplayed: css({ background: tinycolor(theme.colors.background.canvas) .darken(theme.isDark ? 2 : 5) diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index 6d0ad667527..468cd63a3b3 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -457,7 +457,7 @@ describe('LogList', () => { // Default displayed fields expect(screen.getByText('Log line')).toBeInTheDocument(); - expect(screen.getByText('OTel attributes')).toBeInTheDocument(); + expect(screen.getByText('Log attributes')).toBeInTheDocument(); // Suggested field expect(screen.getByText('scope_name')).toBeInTheDocument(); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 8a8a9b085dc..4e457fc5b24 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -550,7 +550,7 @@ const LogListComponent = ({ function getStyles( theme: GrafanaTheme2, dimensions: LogFieldDimension[], - displayedFields: string[], + displayedFields: string[] = [], { showTime }: { showTime: boolean } ) { const columns = showTime ? dimensions : dimensions.filter((_, index) => index > 0); diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 0f3ea0ac6b1..dcb391c8e07 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -364,7 +364,7 @@ export function getNormalizedFieldName(field: string) { if (field === LOG_LINE_BODY_FIELD_NAME) { return t('logs.log-line-details.log-line-field', 'Log line'); } else if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME) { - return t('logs.log-line-details.log-attributes-field', 'OTel attributes'); + return t('logs.log-line-details.log-attributes-field', 'Log attributes'); } return field; } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 41abf7bbd9c..90c8f67b1bf 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -9937,7 +9937,7 @@ "inline-mode": "Display inline", "link-value-tooltip": "Link value", "links-section": "Links", - "log-attributes-field": "OTel attributes", + "log-attributes-field": "Log attributes", "log-line-field": "Log line", "log-line-section": "Log line", "move-displayed-field-down": "Move down", From 532a2e5f4d4dd6fe0010079dcffe7cbda57c8c00 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Wed, 10 Dec 2025 13:04:42 +0100 Subject: [PATCH 013/139] VariablesEditableElement: Set margin correctly (#115079) --- .../settings/variables/VariableEditableElement.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx index f14bfd9b176..9cbe786a05b 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx @@ -150,7 +150,12 @@ function VariableNameInput({ variable, isNewElement }: { variable: SceneVariable const oldName = useRef(name); return ( - + Date: Wed, 10 Dec 2025 07:06:06 -0500 Subject: [PATCH 014/139] unified-storage: make sql backend update key_path for kv store (#114879) * unified-storage: update resource_history_update_rv.sql to populate key_path in resource_history --- .../sql/data/resource_history_update_rv.sql | 19 ++ pkg/storage/unified/sql/queries.go | 19 +- pkg/storage/unified/sql/rv_manager.go | 14 +- .../unified/sql/test/benchmark_test.go | 3 +- .../unified/sql/test/integration_test.go | 35 ++- ...resource_history_update_rv-single path.sql | 3 + ...resource_history_update_rv-single path.sql | 3 + ...resource_history_update_rv-single path.sql | 3 + .../unified/testing/storage_backend.go | 256 ++++++++++++++++++ 9 files changed, 346 insertions(+), 9 deletions(-) diff --git a/pkg/storage/unified/sql/data/resource_history_update_rv.sql b/pkg/storage/unified/sql/data/resource_history_update_rv.sql index 767338ddb34..266227b5317 100644 --- a/pkg/storage/unified/sql/data/resource_history_update_rv.sql +++ b/pkg/storage/unified/sql/data/resource_history_update_rv.sql @@ -5,6 +5,25 @@ SET {{ .Ident "resource_version" }} = ( WHEN {{ $.Ident "guid" }} = {{ $.Arg $guid }} THEN CAST({{ $.Arg $rv }} AS {{ if eq $.DialectName "postgres" }}BIGINT{{ else }}SIGNED{{ end }}) {{ end }} END +), {{ .Ident "key_path" }} = ( + CASE + {{ range $guid, $snowflakeRv := .GUIDToSnowflakeRV }} + WHEN {{ $.Ident "guid" }} = {{ $.Arg $guid }} THEN CONCAT( + 'unified', {{ $.SlashFunc }}, 'data', {{ $.SlashFunc }}, + {{ $.Ident "group" }}, {{ $.SlashFunc }}, + {{ $.Ident "resource" }}, {{ $.SlashFunc }}, + {{ $.Ident "namespace" }}, {{ $.SlashFunc }}, + {{ $.Ident "name" }}, {{ $.SlashFunc }}, + CAST({{ $.Arg $snowflakeRv }} AS {{ if eq $.DialectName "postgres" }}BIGINT{{ else }}SIGNED{{ end }}), + {{ $.TildeFunc }}, + CASE {{ $.Ident "action" }} + WHEN 1 THEN 'created' + WHEN 2 THEN 'updated' + WHEN 3 THEN 'deleted' + END, {{ $.TildeFunc }}, + COALESCE({{ $.Ident "folder" }}, '')) + {{ end }} + END ) WHERE {{ .Ident "guid" }} IN ( {{$first := true}} diff --git a/pkg/storage/unified/sql/queries.go b/pkg/storage/unified/sql/queries.go index e51cf943041..5b9a177e17c 100644 --- a/pkg/storage/unified/sql/queries.go +++ b/pkg/storage/unified/sql/queries.go @@ -369,13 +369,30 @@ func (r sqlResourceBlobQueryRequest) Validate() error { type sqlResourceUpdateRVRequest struct { sqltemplate.SQLTemplate - GUIDToRV map[string]int64 + GUIDToRV map[string]int64 + GUIDToSnowflakeRV map[string]int64 } func (r sqlResourceUpdateRVRequest) Validate() error { return nil // TODO } +func (r sqlResourceUpdateRVRequest) SlashFunc() string { + if r.DialectName() == "postgres" { + return "CHR(47)" + } + + return "CHAR(47)" +} + +func (r sqlResourceUpdateRVRequest) TildeFunc() string { + if r.DialectName() == "postgres" { + return "CHR(126)" + } + + return "CHAR(126)" +} + // resource_version table requests. type resourceVersionResponse struct { ResourceVersion int64 diff --git a/pkg/storage/unified/sql/rv_manager.go b/pkg/storage/unified/sql/rv_manager.go index 1232aa9c700..858345b1fc2 100644 --- a/pkg/storage/unified/sql/rv_manager.go +++ b/pkg/storage/unified/sql/rv_manager.go @@ -8,6 +8,7 @@ import ( "sync" "time" + "github.com/bwmarrin/snowflake" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "go.opentelemetry.io/otel/attribute" @@ -240,6 +241,7 @@ func (m *resourceVersionManager) execBatch(ctx context.Context, group, resource defer cancel() guidToRV := make(map[string]int64, len(batch)) + guidToSnowflakeRV := make(map[string]int64, len(batch)) guids := make([]string, len(batch)) // The GUIDs of the created resources in the same order as the batch rvs := make([]int64, len(batch)) // The RVs of the created resources in the same order as the batch @@ -285,6 +287,7 @@ func (m *resourceVersionManager) execBatch(ctx context.Context, group, resource // Allocate the RVs for i, guid := range guids { guidToRV[guid] = rv + guidToSnowflakeRV[guid] = snowflakeFromRv(rv) rvs[i] = rv rv++ } @@ -301,8 +304,9 @@ func (m *resourceVersionManager) execBatch(ctx context.Context, group, resource span.AddEvent("resource_versions_updated") if _, err := dbutil.Exec(ctx, tx, sqlResourceHistoryUpdateRV, sqlResourceUpdateRVRequest{ - SQLTemplate: sqltemplate.New(m.dialect), - GUIDToRV: guidToRV, + SQLTemplate: sqltemplate.New(m.dialect), + GUIDToRV: guidToRV, + GUIDToSnowflakeRV: guidToSnowflakeRV, }); err != nil { span.AddEvent("resource_history_update_rv_failed", trace.WithAttributes( attribute.String("error", err.Error()), @@ -340,6 +344,12 @@ func (m *resourceVersionManager) execBatch(ctx context.Context, group, resource } } +// takes a unix microsecond rv and transforms into a snowflake format. The timestamp is converted from microsecond to +// millisecond (the integer division) and the remainder is saved in the stepbits section. machine id is always 0 +func snowflakeFromRv(rv int64) int64 { + return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000) +} + // lock locks the resource version for the given key func (m *resourceVersionManager) lock(ctx context.Context, x db.ContextExecer, group, resource string) (nextRV int64, err error) { // 1. Lock the row and prevent concurrent updates until the transaction is committed diff --git a/pkg/storage/unified/sql/test/benchmark_test.go b/pkg/storage/unified/sql/test/benchmark_test.go index f9665c83b7d..8bf65cbd6f7 100644 --- a/pkg/storage/unified/sql/test/benchmark_test.go +++ b/pkg/storage/unified/sql/test/benchmark_test.go @@ -15,5 +15,6 @@ func TestIntegrationBenchmarkSQLStorageBackend(t *testing.T) { if db.IsTestDbSQLite() { opts.Concurrency = 1 // to avoid SQLite database is locked error } - test.BenchmarkStorageBackend(t, newTestBackend(t, true, 2*time.Millisecond), opts) + backend, _ := newTestBackend(t, true, 2*time.Millisecond) + test.BenchmarkStorageBackend(t, backend, opts) } diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index cf45ee64d43..eaf78de0779 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/storage/unified/search" "github.com/grafana/grafana/pkg/storage/unified/sql" + sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" unitest "github.com/grafana/grafana/pkg/storage/unified/testing" "github.com/grafana/grafana/pkg/tests/testsuite" @@ -38,7 +39,7 @@ var initMutex = &sync.Mutex{} // newTestBackend creates a fresh database and backend for a test. // It uses a mutex to ensure the entire initialization and migration // process is atomic and does not race with other parallel tests. -func newTestBackend(t *testing.T, isHA bool, simulatedNetworkLatency time.Duration) resource.StorageBackend { +func newTestBackend(t *testing.T, isHA bool, simulatedNetworkLatency time.Duration) (resource.StorageBackend, sqldb.DB) { // Lock to ensure the entire init block is atomic. initMutex.Lock() // Unlock once the function returns the initialized backend. @@ -61,7 +62,11 @@ func newTestBackend(t *testing.T, isHA bool, simulatedNetworkLatency time.Durati // Use a context with a reasonable timeout for migrations. err = backend.Init(testutil.NewTestContext(t, time.Now().Add(1*time.Minute))) require.NoError(t, err) - return backend + + sqlDB, err := eDB.Init(testutil.NewTestContext(t, time.Now().Add(1*time.Minute))) + require.NoError(t, err) + + return backend, sqlDB } func TestMain(m *testing.M) { @@ -73,7 +78,8 @@ func TestIntegrationStorageServer(t *testing.T) { t.Cleanup(db.CleanupTestDB) unitest.RunStorageServerTest(t, func(ctx context.Context) resource.StorageBackend { - return newTestBackend(t, true, 0) + backend, _ := newTestBackend(t, true, 0) + return backend }) } @@ -84,12 +90,31 @@ func TestIntegrationSQLStorageBackend(t *testing.T) { t.Run("IsHA (polling notifier)", func(t *testing.T) { unitest.RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { - return newTestBackend(t, true, 0) + backend, _ := newTestBackend(t, true, 0) + return backend }, nil) }) t.Run("NotHA (in process notifier)", func(t *testing.T) { unitest.RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { + backend, _ := newTestBackend(t, false, 0) + return backend + }, nil) + }) +} + +func TestIntegrationSQLStorageAndSQLKVCompatibilityTests(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + t.Cleanup(db.CleanupTestDB) + + t.Run("IsHA (polling notifier)", func(t *testing.T) { + unitest.RunSQLStorageBackendCompatibilityTest(t, func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { + return newTestBackend(t, true, 0) + }, nil) + }) + + t.Run("NotHA (in process notifier)", func(t *testing.T) { + unitest.RunSQLStorageBackendCompatibilityTest(t, func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { return newTestBackend(t, false, 0) }, nil) }) @@ -110,7 +135,7 @@ func TestIntegrationSearchAndStorage(t *testing.T) { t.Cleanup(search.Stop) // Create a new resource backend - storage := newTestBackend(t, false, 0) + storage, _ := newTestBackend(t, false, 0) require.NotNil(t, storage) // Run the shared storage and search tests diff --git a/pkg/storage/unified/sql/testdata/mysql--resource_history_update_rv-single path.sql b/pkg/storage/unified/sql/testdata/mysql--resource_history_update_rv-single path.sql index 6b07ff5e55b..62bb64c8300 100755 --- a/pkg/storage/unified/sql/testdata/mysql--resource_history_update_rv-single path.sql +++ b/pkg/storage/unified/sql/testdata/mysql--resource_history_update_rv-single path.sql @@ -4,6 +4,9 @@ SET `resource_version` = ( WHEN `guid` = 'guid1' THEN CAST(123 AS SIGNED) WHEN `guid` = 'guid2' THEN CAST(456 AS SIGNED) END +), `key_path` = ( + CASE + END ) WHERE `guid` IN ( 'guid1', 'guid2' diff --git a/pkg/storage/unified/sql/testdata/postgres--resource_history_update_rv-single path.sql b/pkg/storage/unified/sql/testdata/postgres--resource_history_update_rv-single path.sql index 85352e9ead4..6529fb1e4d5 100755 --- a/pkg/storage/unified/sql/testdata/postgres--resource_history_update_rv-single path.sql +++ b/pkg/storage/unified/sql/testdata/postgres--resource_history_update_rv-single path.sql @@ -4,6 +4,9 @@ SET "resource_version" = ( WHEN "guid" = 'guid1' THEN CAST(123 AS BIGINT) WHEN "guid" = 'guid2' THEN CAST(456 AS BIGINT) END +), "key_path" = ( + CASE + END ) WHERE "guid" IN ( 'guid1', 'guid2' diff --git a/pkg/storage/unified/sql/testdata/sqlite--resource_history_update_rv-single path.sql b/pkg/storage/unified/sql/testdata/sqlite--resource_history_update_rv-single path.sql index 9fb23fb3956..707fbad6cb8 100755 --- a/pkg/storage/unified/sql/testdata/sqlite--resource_history_update_rv-single path.sql +++ b/pkg/storage/unified/sql/testdata/sqlite--resource_history_update_rv-single path.sql @@ -4,6 +4,9 @@ SET "resource_version" = ( WHEN "guid" = 'guid1' THEN CAST(123 AS SIGNED) WHEN "guid" = 'guid2' THEN CAST(456 AS SIGNED) END +), "key_path" = ( + CASE + END ) WHERE "guid" IN ( 'guid1', 'guid2' diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go index ce36d8836bc..730efe418f6 100644 --- a/pkg/storage/unified/testing/storage_backend.go +++ b/pkg/storage/unified/testing/storage_backend.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/bwmarrin/snowflake" "github.com/go-jose/go-jose/v4/jwt" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -25,6 +26,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db" "github.com/grafana/grafana/pkg/util/testutil" ) @@ -42,10 +44,14 @@ const ( TestCreateNewResource = "create new resource" TestGetResourceLastImportTime = "get resource last import time" TestOptimisticLocking = "optimistic locking on concurrent writes" + TestKeyPathGeneration = "key_path generation" ) type NewBackendFunc func(ctx context.Context) resource.StorageBackend +// NewBackendWithDBFunc creates a backend with database access for testing +type NewBackendWithDBFunc func(ctx context.Context) (resource.StorageBackend, sqldb.DB) + // TestOptions configures which tests to run type TestOptions struct { SkipTests map[string]bool // tests to skip @@ -100,6 +106,37 @@ func RunStorageBackendTest(t *testing.T, newBackend NewBackendFunc, opts *TestOp } } +func RunSQLStorageBackendCompatibilityTest(t *testing.T, newBackend NewBackendWithDBFunc, opts *TestOptions) { + if opts == nil { + opts = &TestOptions{} + } + + if opts.NSPrefix == "" { + opts.NSPrefix = GenerateRandomNSPrefix() + } + + t.Logf("Running tests with namespace prefix: %s", opts.NSPrefix) + + cases := []struct { + name string + fn func(*testing.T, resource.StorageBackend, string, sqldb.DB) + }{ + {TestKeyPathGeneration, runTestIntegrationBackendKeyPathGeneration}, + } + + for _, tc := range cases { + if shouldSkip := opts.SkipTests[tc.name]; shouldSkip { + t.Logf("Skipping test: %s", tc.name) + continue + } + + t.Run(tc.name, func(t *testing.T) { + backend, db := newBackend(context.Background()) + tc.fn(t, backend, opts.NSPrefix, db) + }) + } +} + func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBackend, nsPrefix string) { ctx := types.WithAuthInfo(context.Background(), authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{ Claims: jwt.Claims{ @@ -1722,3 +1759,222 @@ func runTestIntegrationBackendOptimisticLocking(t *testing.T, backend resource.S require.LessOrEqual(t, successes, 1, "at most one create should succeed (errors: %v)", errorMessages) }) } + +func runTestIntegrationBackendKeyPathGeneration(t *testing.T, backend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + t.Run("Create resource", func(t *testing.T) { + // Create a test resource + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: nsPrefix + "-default", + Name: "test-playlist-crud", + } + + // Create the K8s unstructured object + testObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": map[string]interface{}{ + "name": "test-playlist-crud", + "namespace": nsPrefix + "-default", + "uid": "test-uid-crud-123", + }, + "spec": map[string]interface{}{ + "title": "My Test Playlist", + }, + }, + } + + // Get metadata accessor + metaAccessor, err := utils.MetaAccessor(testObj) + require.NoError(t, err) + + // Serialize to JSON + jsonBytes, err := testObj.MarshalJSON() + require.NoError(t, err) + + // Create WriteEvent + writeEvent := resource.WriteEvent{ + Type: resourcepb.WatchEvent_ADDED, + Key: key, + Value: jsonBytes, + Object: metaAccessor, + PreviousRV: 0, // Always 0 for new resources + GUID: "create-guid-crud-123", + } + + // Create the resource using WriteEvent + createRV, err := backend.WriteEvent(ctx, writeEvent) + require.NoError(t, err) + require.Greater(t, createRV, int64(0)) + + // Verify created resource key_path + verifyKeyPath(t, db, ctx, key, "created", createRV, "") + + t.Run("Update resource", func(t *testing.T) { + // Update the resource + testObj.Object["spec"] = map[string]interface{}{ + "title": "My Updated Playlist", + } + + updatedMetaAccessor, err := utils.MetaAccessor(testObj) + require.NoError(t, err) + + updatedJsonBytes, err := testObj.MarshalJSON() + require.NoError(t, err) + + updateEvent := resource.WriteEvent{ + Type: resourcepb.WatchEvent_MODIFIED, + Key: key, + Value: updatedJsonBytes, + Object: updatedMetaAccessor, + PreviousRV: createRV, + GUID: fmt.Sprintf("update-guid-%d", createRV), + } + + // Update the resource + updateRV, err := backend.WriteEvent(ctx, updateEvent) + require.NoError(t, err) + require.Greater(t, updateRV, createRV) + + // Verify updated resource key_path + verifyKeyPath(t, db, ctx, key, "updated", updateRV, "") + + t.Run("Delete resource", func(t *testing.T) { + deleteEvent := resource.WriteEvent{ + Type: resourcepb.WatchEvent_DELETED, + Key: key, + Value: updatedJsonBytes, // Keep the last known value + Object: updatedMetaAccessor, + PreviousRV: updateRV, + GUID: fmt.Sprintf("delete-guid-%d", updateRV), + } + + // Delete the resource + deleteRV, err := backend.WriteEvent(ctx, deleteEvent) + require.NoError(t, err) + require.Greater(t, deleteRV, updateRV) + + // Verify deleted resource key_path + verifyKeyPath(t, db, ctx, key, "deleted", deleteRV, "") + }) + }) + }) + + t.Run("Resource with folder", func(t *testing.T) { + // Create a resource in a folder + folderKey := &resourcepb.ResourceKey{ + Group: "dashboard.grafana.app", + Resource: "dashboards", + Namespace: nsPrefix + "-default", + Name: "my-dashboard", + } + + // Create dashboard object with folder + dashboardObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v0alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "my-dashboard", + "namespace": nsPrefix + "-default", + "uid": "dash-uid-456", + "annotations": map[string]interface{}{ + "grafana.app/folder": "test-folder", + }, + }, + "spec": map[string]interface{}{ + "title": "My Dashboard", + }, + }, + } + + folderMetaAccessor, err := utils.MetaAccessor(dashboardObj) + require.NoError(t, err) + + folderJsonBytes, err := dashboardObj.MarshalJSON() + require.NoError(t, err) + + folderWriteEvent := resource.WriteEvent{ + Type: resourcepb.WatchEvent_ADDED, + Key: folderKey, + Value: folderJsonBytes, + Object: folderMetaAccessor, + PreviousRV: 0, + GUID: "folder-guid-456", + } + + // Create the dashboard in folder + folderRV, err := backend.WriteEvent(ctx, folderWriteEvent) + require.NoError(t, err) + require.Greater(t, folderRV, int64(0)) + + // Verify folder resource key_path includes folder + verifyKeyPath(t, db, ctx, folderKey, "created", folderRV, "test-folder") + }) +} + +// verifyKeyPath is a helper function to verify key_path generation +func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resourcepb.ResourceKey, action string, resourceVersion int64, expectedFolder string) { + var query string + if db.DriverName() == "postgres" { + query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = $1 AND name = $2 AND resource_version = $3" + } else { + query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = ? AND name = ? AND resource_version = ?" + } + rows, err := db.QueryContext(ctx, query, key.Namespace, key.Name, resourceVersion) + require.NoError(t, err) + + require.True(t, rows.Next()) + + var keyPath string + var actualRV int64 + var actualAction int + var actualFolder string + + err = rows.Scan(&keyPath, &actualRV, &actualAction, &actualFolder) + require.NoError(t, err) + err = rows.Close() + require.NoError(t, err) + + // Verify basic key_path format + require.Contains(t, keyPath, "unified/data/") + require.Contains(t, keyPath, key.Group) + require.Contains(t, keyPath, key.Resource) + require.Contains(t, keyPath, key.Namespace) + require.Contains(t, keyPath, key.Name) + + // Verify action suffix + require.Contains(t, keyPath, fmt.Sprintf("~%s~", action)) + + // Verify snowflake calculation + expectedSnowflake := (((resourceVersion / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (resourceVersion % 1000) + require.Contains(t, keyPath, fmt.Sprintf("/%d~", expectedSnowflake), fmt.Sprintf("actual RV: %d", actualRV)) + + // Verify folder if specified + if expectedFolder != "" { + require.Equal(t, expectedFolder, actualFolder) + require.Contains(t, keyPath, expectedFolder) + } + + // Verify action code matches + var expectedActionCode int + switch action { + case "created": + expectedActionCode = 1 + case "updated": + expectedActionCode = 2 + case "deleted": + expectedActionCode = 3 + } + require.Equal(t, expectedActionCode, actualAction) + + t.Logf("Action: %s, RV: %d, Snowflake: %d", action, resourceVersion, expectedSnowflake) + t.Logf("Key_path: %s", keyPath) + if expectedFolder != "" { + t.Logf("Folder: %s", actualFolder) + } +} From 39f4b2a959b799166d201c7d5d69d3fa60f07597 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Wed, 10 Dec 2025 12:22:05 +0000 Subject: [PATCH 015/139] Plugins: Rename current meta provider to catalog provider (#114966) rename cloud provider to catalog provider --- .../pkg/app/meta/{cloud.go => catalog.go} | 20 +++++------ .../meta/{cloud_test.go => catalog_test.go} | 34 +++++++++---------- pkg/registry/apps/plugins/register.go | 2 +- 3 files changed, 28 insertions(+), 28 deletions(-) rename apps/plugins/pkg/app/meta/{cloud.go => catalog.go} (84%) rename apps/plugins/pkg/app/meta/{cloud_test.go => catalog_test.go} (82%) diff --git a/apps/plugins/pkg/app/meta/cloud.go b/apps/plugins/pkg/app/meta/catalog.go similarity index 84% rename from apps/plugins/pkg/app/meta/cloud.go rename to apps/plugins/pkg/app/meta/catalog.go index 799480eab96..6e6a47fa0bd 100644 --- a/apps/plugins/pkg/app/meta/cloud.go +++ b/apps/plugins/pkg/app/meta/catalog.go @@ -15,29 +15,29 @@ import ( ) const ( - defaultCloudTTL = 1 * time.Hour + defaultCatalogTTL = 1 * time.Hour ) -// CloudProvider retrieves plugin metadata from the grafana.com API. -type CloudProvider struct { +// CatalogProvider retrieves plugin metadata from the grafana.com API. +type CatalogProvider struct { httpClient *http.Client grafanaComAPIURL string log logging.Logger ttl time.Duration } -// NewCloudProvider creates a new CloudProvider that fetches metadata from grafana.com. -func NewCloudProvider(grafanaComAPIURL string) *CloudProvider { - return NewCloudProviderWithTTL(grafanaComAPIURL, defaultCloudTTL) +// NewCatalogProvider creates a new CatalogProvider that fetches metadata from grafana.com. +func NewCatalogProvider(grafanaComAPIURL string) *CatalogProvider { + return NewCatalogProviderWithTTL(grafanaComAPIURL, defaultCatalogTTL) } -// NewCloudProviderWithTTL creates a new CloudProvider with a custom TTL. -func NewCloudProviderWithTTL(grafanaComAPIURL string, ttl time.Duration) *CloudProvider { +// NewCatalogProviderWithTTL creates a new CatalogProvider with a custom TTL. +func NewCatalogProviderWithTTL(grafanaComAPIURL string, ttl time.Duration) *CatalogProvider { if grafanaComAPIURL == "" { grafanaComAPIURL = "https://grafana.com/api/plugins" } - return &CloudProvider{ + return &CatalogProvider{ httpClient: &http.Client{ Timeout: 10 * time.Second, }, @@ -49,7 +49,7 @@ func NewCloudProviderWithTTL(grafanaComAPIURL string, ttl time.Duration) *CloudP // GetMeta fetches plugin metadata from grafana.com API endpoint: // GET /api/plugins/{pluginId}/versions/{version} -func (p *CloudProvider) GetMeta(ctx context.Context, pluginID, version string) (*Result, error) { +func (p *CatalogProvider) GetMeta(ctx context.Context, pluginID, version string) (*Result, error) { u, err := url.Parse(p.grafanaComAPIURL) if err != nil { return nil, fmt.Errorf("invalid grafana.com API URL: %w", err) diff --git a/apps/plugins/pkg/app/meta/cloud_test.go b/apps/plugins/pkg/app/meta/catalog_test.go similarity index 82% rename from apps/plugins/pkg/app/meta/cloud_test.go rename to apps/plugins/pkg/app/meta/catalog_test.go index ea6368f1b82..845afc7cd54 100644 --- a/apps/plugins/pkg/app/meta/cloud_test.go +++ b/apps/plugins/pkg/app/meta/catalog_test.go @@ -15,7 +15,7 @@ import ( pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" ) -func TestCloudProvider_GetMeta(t *testing.T) { +func TestCatalogProvider_GetMeta(t *testing.T) { ctx := context.Background() t.Run("successfully fetches plugin metadata", func(t *testing.T) { @@ -44,13 +44,13 @@ func TestCloudProvider_GetMeta(t *testing.T) { })) defer server.Close() - provider := NewCloudProvider(server.URL + "/api/plugins") + provider := NewCatalogProvider(server.URL + "/api/plugins") result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0") require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, expectedMeta, result.Meta) - assert.Equal(t, defaultCloudTTL, result.TTL) + assert.Equal(t, defaultCatalogTTL, result.TTL) }) t.Run("returns ErrMetaNotFound for 404 status", func(t *testing.T) { @@ -59,7 +59,7 @@ func TestCloudProvider_GetMeta(t *testing.T) { })) defer server.Close() - provider := NewCloudProvider(server.URL + "/api/plugins") + provider := NewCatalogProvider(server.URL + "/api/plugins") result, err := provider.GetMeta(ctx, "nonexistent-plugin", "1.0.0") assert.Error(t, err) @@ -73,7 +73,7 @@ func TestCloudProvider_GetMeta(t *testing.T) { })) defer server.Close() - provider := NewCloudProvider(server.URL + "/api/plugins") + provider := NewCatalogProvider(server.URL + "/api/plugins") result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0") assert.Error(t, err) @@ -89,7 +89,7 @@ func TestCloudProvider_GetMeta(t *testing.T) { })) defer server.Close() - provider := NewCloudProvider(server.URL + "/api/plugins") + provider := NewCatalogProvider(server.URL + "/api/plugins") result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0") assert.Error(t, err) @@ -98,7 +98,7 @@ func TestCloudProvider_GetMeta(t *testing.T) { }) t.Run("returns error for invalid API URL", func(t *testing.T) { - provider := NewCloudProvider("://invalid-url") + provider := NewCatalogProvider("://invalid-url") result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0") assert.Error(t, err) @@ -127,7 +127,7 @@ func TestCloudProvider_GetMeta(t *testing.T) { })) defer server.Close() - provider := NewCloudProviderWithTTL(server.URL+"/api/plugins", customTTL) + provider := NewCatalogProviderWithTTL(server.URL+"/api/plugins", customTTL) result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0") require.NoError(t, err) @@ -145,7 +145,7 @@ func TestCloudProvider_GetMeta(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - provider := NewCloudProvider(server.URL + "/api/plugins") + provider := NewCatalogProvider(server.URL + "/api/plugins") result, err := provider.GetMeta(ctx, "test-plugin", "1.0.0") assert.Error(t, err) @@ -153,34 +153,34 @@ func TestCloudProvider_GetMeta(t *testing.T) { }) } -func TestNewCloudProvider(t *testing.T) { +func TestNewCatalogProvider(t *testing.T) { t.Run("creates provider with default TTL", func(t *testing.T) { - provider := NewCloudProvider("https://grafana.com/api/plugins") - assert.Equal(t, defaultCloudTTL, provider.ttl) + provider := NewCatalogProvider("https://grafana.com/api/plugins") + assert.Equal(t, defaultCatalogTTL, provider.ttl) assert.NotNil(t, provider.httpClient) assert.Equal(t, "https://grafana.com/api/plugins", provider.grafanaComAPIURL) }) t.Run("uses default URL when empty", func(t *testing.T) { - provider := NewCloudProvider("") + provider := NewCatalogProvider("") assert.Equal(t, "https://grafana.com/api/plugins", provider.grafanaComAPIURL) }) } -func TestNewCloudProviderWithTTL(t *testing.T) { +func TestNewCatalogProviderWithTTL(t *testing.T) { t.Run("creates provider with custom TTL", func(t *testing.T) { customTTL := 2 * time.Hour - provider := NewCloudProviderWithTTL("https://grafana.com/api/plugins", customTTL) + provider := NewCatalogProviderWithTTL("https://grafana.com/api/plugins", customTTL) assert.Equal(t, customTTL, provider.ttl) }) t.Run("accepts zero TTL", func(t *testing.T) { - provider := NewCloudProviderWithTTL("https://grafana.com/api/plugins", 0) + provider := NewCatalogProviderWithTTL("https://grafana.com/api/plugins", 0) assert.Equal(t, time.Duration(0), provider.ttl) }) t.Run("uses default URL when empty", func(t *testing.T) { - provider := NewCloudProviderWithTTL("", defaultCloudTTL) + provider := NewCatalogProviderWithTTL("", defaultCatalogTTL) assert.Equal(t, "https://grafana.com/api/plugins", provider.grafanaComAPIURL) }) } diff --git a/pkg/registry/apps/plugins/register.go b/pkg/registry/apps/plugins/register.go index 6831d31ef9b..aebb8e98178 100644 --- a/pkg/registry/apps/plugins/register.go +++ b/pkg/registry/apps/plugins/register.go @@ -34,7 +34,7 @@ func ProvideAppInstaller(accessControlService accesscontrol.Service, accessClien } coreProvider := meta.NewCoreProvider() - cloudProvider := meta.NewCloudProvider(grafanaComAPIURL) + cloudProvider := meta.NewCatalogProvider(grafanaComAPIURL) metaProviderManager := meta.NewProviderManager(coreProvider, cloudProvider) i, err := pluginsapp.ProvideAppInstaller(metaProviderManager) From baee9fb214306168c023876f212daad321cb4af0 Mon Sep 17 00:00:00 2001 From: Steve Simpson Date: Wed, 10 Dec 2025 14:05:10 +0100 Subject: [PATCH 016/139] Alerting: Add historian.alerting app permissions to service identity. (#115082) --- pkg/apimachinery/identity/context.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go index 984a9b24831..4362c048b0a 100644 --- a/pkg/apimachinery/identity/context.go +++ b/pkg/apimachinery/identity/context.go @@ -161,6 +161,7 @@ var serviceIdentityTokenPermissions = []string{ "preferences.grafana.app:*", // user, team, and org preferences "collections.grafana.app:*", // user stars "plugins.grafana.app:*", + "historian.alerting.grafana.app:*", // Secrets Manager uses a custom verb for secret decryption, and its authorizer does not allow wildcard permissions. "secret.grafana.app/securevalues:decrypt", From ea331dc0d35fa8d970e7aa81889e3146dc7b21bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ida=20=C5=A0tambuk?= Date: Wed, 10 Dec 2025 14:39:58 +0100 Subject: [PATCH 017/139] Dashboards: Add variables with datasource to tracking (#114110) --- .../DashboardSceneSerializer.test.ts | 21 +++++- .../testfiles/nested_dashboard.json | 15 +++++ .../dashboard-scene/utils/tracking.test.ts | 23 ++++++- .../features/dashboard/utils/tracking.test.ts | 8 ++- .../app/features/dashboard/utils/tracking.ts | 64 +++++++++++++++---- 5 files changed, 112 insertions(+), 19 deletions(-) diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts index c17cc0cfbdb..aa2d81dcc52 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts @@ -330,10 +330,16 @@ describe('DashboardSceneSerializer', () => { { type: 'query', name: 'server', + datasource: { + type: 'influxdb', + }, }, { type: 'query', name: 'host', + datasource: { + type: 'elasticsearch', + }, }, { type: 'textbox', @@ -355,6 +361,10 @@ describe('DashboardSceneSerializer', () => { variable_type_textbox_count: 1, settings_nowdelay: undefined, settings_livenow: true, + varsWithDataSource: [ + { type: 'query', datasource: 'influxdb' }, + { type: 'query', datasource: 'elasticsearch' }, + ], }); }); }); @@ -688,16 +698,23 @@ describe('DashboardSceneSerializer', () => { schemaVersion: DASHBOARD_SCHEMA_VERSION, settings_nowdelay: undefined, settings_livenow: true, + panel_type_timeseries_count: 6, + variable_type_adhoc_count: 1, + variable_type_datasource_count: 1, variable_type_custom_count: 1, variable_type_query_count: 1, - panel_type_timeseries_count: 6, + varsWithDataSource: [ + { type: 'query', datasource: 'cloudwatch' }, + { type: 'adhoc', datasource: 'opensearch' }, + { type: 'datasource', datasource: 'bigquery' }, + ], }); expect(dashboard.getDynamicDashboardsTrackingInformation()).toEqual({ panelCount: 6, rowCount: 6, tabCount: 4, - templateVariableCount: 2, + templateVariableCount: 4, maxNestingLevel: 3, dashStructure: '[{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"},{"kind":"panel"},{"kind":"panel"}]},{"kind":"tab","children":[]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"panel"}]}]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"}]},{"kind":"tab","children":[{"kind":"panel"}]}]}]}]', diff --git a/public/app/features/dashboard-scene/serialization/testfiles/nested_dashboard.json b/public/app/features/dashboard-scene/serialization/testfiles/nested_dashboard.json index 133bcd890ee..4322333ceb1 100644 --- a/public/app/features/dashboard-scene/serialization/testfiles/nested_dashboard.json +++ b/public/app/features/dashboard-scene/serialization/testfiles/nested_dashboard.json @@ -1132,6 +1132,14 @@ "sort": "disabled" } }, + { + "kind": "AdhocVariable", + "datasource": { "name": "esmce00tbim8" }, + "group": "opensearch", + "spec": { + "allowCustomValue": true + } + }, { "kind": "CustomVariable", "spec": { @@ -1154,6 +1162,13 @@ "query": "test", "skipUrlSync": false } + }, + { + "kind": "DatasourceVariable", + "spec": { + "name": "datasourceVar", + "pluginId": "bigquery" + } } ] } diff --git a/public/app/features/dashboard-scene/utils/tracking.test.ts b/public/app/features/dashboard-scene/utils/tracking.test.ts index 45bee1f4583..5fb968a4148 100644 --- a/public/app/features/dashboard-scene/utils/tracking.test.ts +++ b/public/app/features/dashboard-scene/utils/tracking.test.ts @@ -16,6 +16,11 @@ jest.mock('@grafana/runtime', () => ({ dashboardNewLayouts: true, }, }, + getDataSourceSrv: () => ({ + getInstanceSettings: () => { + return { apiVersion: 'v1', meta: { multiValueFilterOperators: true } }; + }, + }), })); // mock useSaveDashboardMutation @@ -72,7 +77,7 @@ describe('dashboard tracking', () => { isScene: true, tabCount: 4, rowCount: 2, - templateVariableCount: 2, + templateVariableCount: 4, maxNestingLevel: 3, panel_type_timeseries_count: 6, panels_count: 6, @@ -89,6 +94,22 @@ describe('dashboard tracking', () => { uid: 'dashboard-test', variable_type_custom_count: 1, variable_type_query_count: 1, + variable_type_datasource_count: 1, + variable_type_adhoc_count: 1, + varsWithDataSource: [ + { + datasource: 'cloudwatch', + type: 'query', + }, + { + datasource: 'opensearch', + type: 'adhoc', + }, + { + datasource: 'bigquery', + type: 'datasource', + }, + ], hasEditPermissions: true, hasSavePermissions: true, }); diff --git a/public/app/features/dashboard/utils/tracking.test.ts b/public/app/features/dashboard/utils/tracking.test.ts index a8e9fb1ec8a..c02ae06d1fc 100644 --- a/public/app/features/dashboard/utils/tracking.test.ts +++ b/public/app/features/dashboard/utils/tracking.test.ts @@ -20,9 +20,9 @@ describe('trackDashboardLoaded', () => { ], templating: { list: [ - { type: 'query', name: 'Query 1' }, + { type: 'query', name: 'Query 1', datasource: { type: 'prometheus' } }, { type: 'interval', name: 'Interval 1' }, - { type: 'query', name: 'Query 2' }, + { type: 'query', name: 'Query 2', datasource: { type: 'cloudwatch' } }, ], }, timepicker: { @@ -52,6 +52,10 @@ describe('trackDashboardLoaded', () => { panel_type_geomap_count: 2, settings_nowdelay: '1m', settings_livenow: true, + varsWithDataSource: [ + { type: 'query', datasource: 'prometheus' }, + { type: 'query', datasource: 'cloudwatch' }, + ], }); }); }); diff --git a/public/app/features/dashboard/utils/tracking.ts b/public/app/features/dashboard/utils/tracking.ts index 3dc32eb05db..cffc7999fff 100644 --- a/public/app/features/dashboard/utils/tracking.ts +++ b/public/app/features/dashboard/utils/tracking.ts @@ -1,5 +1,10 @@ import { VariableModel } from '@grafana/schema/dist/esm/index'; -import { VariableKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { + AdhocVariableKind, + DatasourceVariableKind, + QueryVariableKind, + VariableKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; import { DashboardModel } from '../state/DashboardModel'; @@ -41,12 +46,23 @@ export function getPanelPluginCounts(panels: string[]) { } export function getV1SchemaVariables(variableList: VariableModel[]) { - return variableList - .map((v) => v.type) - .reduce((r: Record, k) => { - r[variableName(k)] = 1 + r[variableName(k)] || 1; - return r; - }, {}); + return { + // Count variable types + ...variableList.reduce>((variables, current) => { + variables[variableName(current.type)] = 1 + (variables[variableName(current.type)] || 0); + return variables; + }, {}), + // List of variables with data source types + varsWithDataSource: variableList.reduce>((variablesWithDs, current) => { + if (current.datasource?.type) { + variablesWithDs.push({ + type: current.type, + datasource: current.datasource.type, + }); + } + return variablesWithDs; + }, []), + }; } function mapNewToOldTypes(type: VariableKind['kind']): VariableModel['type'] | undefined { @@ -73,14 +89,34 @@ function mapNewToOldTypes(type: VariableKind['kind']): VariableModel['type'] | u } export function getV2SchemaVariables(variableList: VariableKind[]) { - return variableList - .map((v) => mapNewToOldTypes(v.kind)) - .filter((v) => v !== undefined) - .reduce((r: Record, k) => { - r[variableName(k)] = 1 + r[variableName(k)] || 1; - return r; - }, {}); + return { + // Count variable types + ...variableList.reduce>((variables, current) => { + const type = mapNewToOldTypes(current.kind); + if (type) { + variables[variableName(type)] = 1 + (variables[variableName(type)] || 0); + } + return variables; + }, {}), + // List of variables with data source types + varsWithDataSource: variableList.reduce>((variablesWithDs, current) => { + let datasource = ''; + const type = mapNewToOldTypes(current.kind); + datasource = getDatasourceFromVar(current); + if (datasource && type) { + variablesWithDs.push({ type, datasource }); + } + return variablesWithDs; + }, []), + }; } export const variableName = (type: string) => `variable_type_${type}_count`; const panelName = (type: string) => `panel_type_${type}_count`; + +const isAdhocVar: (v: VariableKind) => v is AdhocVariableKind = (v) => v.kind === 'AdhocVariable'; +const isDatasourceVar: (v: VariableKind) => v is DatasourceVariableKind = (v) => v.kind === 'DatasourceVariable'; +const isQueryVar: (v: VariableKind) => v is QueryVariableKind = (v) => v.kind === 'QueryVariable'; + +const getDatasourceFromVar = (v: VariableKind) => + isAdhocVar(v) ? v.group : isDatasourceVar(v) ? v.spec.pluginId : isQueryVar(v) ? v.spec?.query.group : ''; From 27482194e306debfae19970da322819134605588 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 10 Dec 2025 13:44:08 +0000 Subject: [PATCH 018/139] InteractiveTable: Improve accessibility and reenable tests (#115002) * attempt at fixing some stuff * tidy up * prettier * fix suppressions --- eslint-suppressions.json | 2 +- .../components/InteractiveTable/Expander/index.tsx | 13 +++++++++++-- .../InteractiveTable/InteractiveTable.story.tsx | 2 -- .../InteractiveTable/InteractiveTable.tsx | 11 +++++++++-- .../src/components/InteractiveTable/utils.ts | 6 ++---- public/locales/en-US/grafana.json | 2 ++ 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ee6e1a1ba31..a6386359869 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -669,7 +669,7 @@ }, "packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx": { "no-restricted-syntax": { - "count": 3 + "count": 2 } }, "packages/grafana-ui/src/components/JSONFormatter/json_explorer/json_explorer.ts": { diff --git a/packages/grafana-ui/src/components/InteractiveTable/Expander/index.tsx b/packages/grafana-ui/src/components/InteractiveTable/Expander/index.tsx index 346d18b38fb..4aa0d0f634f 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/Expander/index.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/Expander/index.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { CellProps, HeaderProps } from 'react-table'; -import { t } from '@grafana/i18n'; +import { t, Trans } from '@grafana/i18n'; import { IconButton } from '../../IconButton/IconButton'; @@ -16,8 +16,9 @@ export function ExpanderCell({ row, __rowID }: CellProps ({ row, __rowID }: CellProps + Row expander + + ); +} + export function ExpanderHeader({ isAllRowsExpanded, toggleAllRowsExpanded }: HeaderProps) { return (
diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx index c8cf16cf2b6..e0df9d9782f 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx @@ -112,8 +112,6 @@ const meta: Meta> = { controls: { exclude: EXCLUDED_PROPS, }, - // TODO fix a11y issue in story and remove this - a11y: { test: 'off' }, }, args: { columns: [ diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx index a046f24b89c..d5a25e2e480 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx @@ -14,6 +14,7 @@ import { } from 'react-table'; import { GrafanaTheme2, IconName, isTruthy } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; import { Icon } from '../Icon/Icon'; @@ -345,7 +346,7 @@ const getColumnHeaderStyles = (theme: GrafanaTheme2) => ({ }); function ColumnHeader({ - column: { canSort, render, isSorted, isSortedDesc, getSortByToggleProps }, + column: { canSort, render, isSorted, isSortedDesc, getSortByToggleProps, Header, id }, headerTooltip, }: { column: HeaderGroup; @@ -376,7 +377,13 @@ function ColumnHeader({ if (canSort) { return ( - ); diff --git a/packages/grafana-ui/src/components/InteractiveTable/utils.ts b/packages/grafana-ui/src/components/InteractiveTable/utils.ts index 68017bbfd19..2b664b16f6d 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/utils.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/utils.ts @@ -1,6 +1,6 @@ import { Column as RTColumn } from 'react-table'; -import { ExpanderCell, ExpanderHeader } from './Expander'; +import { EmptyExpanderHeader, ExpanderCell, ExpanderHeader } from './Expander'; import { Column } from './types'; export const EXPANDER_CELL_ID = '__expander' as const; @@ -18,9 +18,7 @@ export function getColumns( { id: EXPANDER_CELL_ID, Cell: ExpanderCell, - ...(showExpandAll && { - Header: ExpanderHeader, - }), + Header: showExpandAll ? ExpanderHeader : EmptyExpanderHeader, disableSortBy: true, width: 0, }, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 90c8f67b1bf..d6ecdd24882 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -9024,6 +9024,8 @@ "interactive-table": { "aria-label-collapse-all": "Collapse all rows", "aria-label-expand-all": "Expand all rows", + "aria-label-sort-column": "Sort column {{columnName}}", + "expand-row-header": "Row expander", "expand-row-tooltip": "Toggle row expanded", "tooltip-collapse-all": "Collapse all rows", "tooltip-expand-all": "Expand all rows" From cc1bba85e42c2e2705c151b2968f2f1bc14faad0 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 10 Dec 2025 13:44:21 +0000 Subject: [PATCH 019/139] VizLegend: Always display header for screenreader users (#115003) always display vizlegend header for screenreader users --- eslint-suppressions.json | 5 ----- .../src/components/VizLegend/VizLegend.story.tsx | 4 ---- .../src/components/VizLegend/VizLegendTable.tsx | 16 ++++++++-------- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a6386359869..c5169c289b0 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -886,11 +886,6 @@ "count": 1 } }, - "packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, "packages/grafana-ui/src/components/VizLegend/types.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx index cba6f984501..b9af4d23fc4 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx @@ -16,10 +16,6 @@ const meta: Meta = { containerWidth: '100%', seriesCount: 5, }, - parameters: { - // TODO fix a11y issue in story and remove this - a11y: { test: 'off' }, - }, argTypes: { containerWidth: { control: { diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index b0f578fae79..b654a2d3ac6 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -29,11 +29,9 @@ export const VizLegendTable = ({ isSortable, }: VizLegendTableProps): JSX.Element => { const styles = useStyles2(getStyles); - const header: Record = {}; - - if (isSortable) { - header[nameSortKey] = ''; - } + const header: Record = { + [nameSortKey]: '', + }; for (const item of items) { if (item.getDisplayValues) { @@ -90,16 +88,18 @@ export const VizLegendTable = ({ - {!isSortable && } {Object.keys(header).map((columnTitle) => ( + + +
{ - if (onToggleSort) { + if (onToggleSort && isSortable) { onToggleSort(columnTitle); } }} From 92a8dd8b53eaad0111fd762b2a9e6e29c6913ef1 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:52:48 +0100 Subject: [PATCH 020/139] Alerting: Add gh in CLAUDE.md (#114992) add gh in CLAUDE.md --- .../app/features/alerting/unified/CLAUDE.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/public/app/features/alerting/unified/CLAUDE.md b/public/app/features/alerting/unified/CLAUDE.md index 6fc487dd46d..124b9da622f 100644 --- a/public/app/features/alerting/unified/CLAUDE.md +++ b/public/app/features/alerting/unified/CLAUDE.md @@ -417,6 +417,60 @@ Check https://testing-library.com/docs/queries/about/ for what selectors to pref - [ ] Async operations use `await` and `findBy*` - [ ] Permissions tested with `grantUserPermissions` +## Using GitHub CLI for Context + +When working on issues, PRs, or needing repository context, use the GitHub CLI (`gh`) to fetch information directly: + +### Common Commands + +```bash +# View issue details +gh issue view + +# View PR details and diff +gh pr view +gh pr diff + +# List recent issues +gh issue list --limit 10 + +# List PRs with specific labels +gh pr list --label "alerting" + +# Search issues +gh issue list --search "keyword" + +# View PR reviews and comments +gh pr view --comments + +# Check CI status +gh pr checks + +# View repository info +gh repo view +``` + +### When to Use + +- **Understanding issue context**: Fetch issue descriptions, comments, and linked PRs +- **Reviewing PR changes**: Get diffs, review comments, and CI status +- **Finding related work**: Search for similar issues or existing implementations +- **Checking project status**: List open issues/PRs for the alerting team + +### Example Workflow + +```bash +# Working on issue #12345 +gh issue view 12345 + +# Check if there's an existing PR +gh pr list --search "fixes #12345" + +# Review a related PR +gh pr view 67890 +gh pr diff 67890 +``` + ## Getting Help - Check patterns in existing `components/` code @@ -425,6 +479,7 @@ Check https://testing-library.com/docs/queries/about/ for what selectors to pref - See `mocks.ts` for data factories - Read [./TESTING.md](./TESTING.md) for testing details - Review Grafana style guides (linked at top) +- Use `gh` CLI to fetch issue/PR context from GitHub --- From c4c1708e38a224c27d80984b1383c7a9f0f5e5bd Mon Sep 17 00:00:00 2001 From: Will Browne Date: Wed, 10 Dec 2025 14:07:55 +0000 Subject: [PATCH 021/139] Plugins: Sync channel close for app installer readiness (#115078) sync channel close --- apps/plugins/pkg/app/app.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/plugins/pkg/app/app.go b/apps/plugins/pkg/app/app.go index 7d8614f2a3c..4c1ce4cfe0f 100644 --- a/apps/plugins/pkg/app/app.go +++ b/apps/plugins/pkg/app/app.go @@ -3,7 +3,9 @@ package app import ( "context" "fmt" + "sync" + authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana-app-sdk/app" "github.com/grafana/grafana-app-sdk/k8s" appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" @@ -16,7 +18,6 @@ import ( restclient "k8s.io/client-go/rest" "k8s.io/klog/v2" - authlib "github.com/grafana/authlib/types" pluginsappapis "github.com/grafana/grafana/apps/plugins/pkg/apis" pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" "github.com/grafana/grafana/apps/plugins/pkg/app/meta" @@ -106,12 +107,15 @@ type PluginAppInstaller struct { // restConfig is set during InitializeApp and used by the client factory restConfig *restclient.Config ready chan struct{} + readyOnce sync.Once } func (p *PluginAppInstaller) InitializeApp(restConfig restclient.Config) error { if p.restConfig == nil { p.restConfig = &restConfig - close(p.ready) + p.readyOnce.Do(func() { + close(p.ready) + }) } return p.AppInstaller.InitializeApp(restConfig) } From ac55fad1baa98e939a10fd731f0434ea8a1e99e3 Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Wed, 10 Dec 2025 09:12:26 -0500 Subject: [PATCH 022/139] Plugins App: Switch to resource authorizer (#115019) --- apps/plugins/go.mod | 4 +- apps/plugins/pkg/app/app.go | 40 ++----------------- apps/plugins/pkg/app/authorizer.go | 32 --------------- pkg/registry/apps/plugins/register.go | 11 ++--- .../backgroundsvcs/adapter/service_test.go | 15 +------ .../apiserver/auth/authorizer/resource.go | 4 +- 6 files changed, 13 insertions(+), 93 deletions(-) delete mode 100644 apps/plugins/pkg/app/authorizer.go diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index 669c7c46844..287c2ff0bbe 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -10,11 +10,9 @@ replace github.com/grafana/grafana/pkg/apiserver => ../../pkg/apiserver require ( github.com/emicklei/go-restful/v3 v3.13.0 - github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 github.com/grafana/grafana v0.0.0-00010101000000-000000000000 github.com/grafana/grafana-app-sdk v0.48.5 github.com/grafana/grafana-app-sdk/logging v0.48.3 - github.com/grafana/grafana/pkg/apimachinery v0.0.0 github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.2 k8s.io/apiserver v0.34.2 @@ -78,11 +76,13 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grafana/alerting v0.0.0-20251204145817-de8c2bbf9eba // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect github.com/grafana/grafana-aws-sdk v1.3.0 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect + github.com/grafana/grafana/pkg/apimachinery v0.0.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect diff --git a/apps/plugins/pkg/app/app.go b/apps/plugins/pkg/app/app.go index 4c1ce4cfe0f..7c0187dd59d 100644 --- a/apps/plugins/pkg/app/app.go +++ b/apps/plugins/pkg/app/app.go @@ -70,6 +70,7 @@ type PluginAppConfig struct { } func ProvideAppInstaller( + authorizer authorizer.Authorizer, metaProviderManager *meta.ProviderManager, ) (*PluginAppInstaller, error) { specificConfig := &PluginAppConfig{ @@ -88,21 +89,17 @@ func ProvideAppInstaller( appInstaller := &PluginAppInstaller{ AppInstaller: defaultInstaller, + authorizer: authorizer, metaManager: metaProviderManager, ready: make(chan struct{}), } return appInstaller, nil } -func (p *PluginAppInstaller) WithAccessChecker(access authlib.AccessChecker) *PluginAppInstaller { - p.access = access - return p -} - type PluginAppInstaller struct { appsdkapiserver.AppInstaller metaManager *meta.ProviderManager - access authlib.AccessChecker + authorizer authorizer.Authorizer // restConfig is set during InitializeApp and used by the client factory restConfig *restclient.Config @@ -153,34 +150,5 @@ func (p *PluginAppInstaller) InstallAPIs( } func (p *PluginAppInstaller) GetAuthorizer() authorizer.Authorizer { - if p.access == nil { - return nil - } - - return authorizer.AuthorizerFunc( - func(ctx context.Context, a authorizer.Attributes) (decision authorizer.Decision, reason string, err error) { - info, ok := authlib.AuthInfoFrom(ctx) - if !ok { - return authorizer.DecisionDeny, "failed to get auth info", nil - } - - res, err := p.access.Check(ctx, info, authlib.CheckRequest{ - Verb: a.GetVerb(), - Group: a.GetAPIGroup(), - Resource: a.GetResource(), - Name: a.GetName(), - Namespace: a.GetNamespace(), - Subresource: a.GetSubresource(), - Path: a.GetPath(), - }, "") - if err != nil { - return authorizer.DecisionDeny, "failed to perform authorization", err - } - - if !res.Allowed { - return authorizer.DecisionDeny, "permission denied", nil - } - - return authorizer.DecisionAllow, "", nil - }) + return p.authorizer } diff --git a/apps/plugins/pkg/app/authorizer.go b/apps/plugins/pkg/app/authorizer.go deleted file mode 100644 index b6a800694f7..00000000000 --- a/apps/plugins/pkg/app/authorizer.go +++ /dev/null @@ -1,32 +0,0 @@ -package app - -import ( - "context" - - "k8s.io/apiserver/pkg/authorization/authorizer" - - "github.com/grafana/grafana/pkg/apimachinery/identity" -) - -func GetAuthorizer() authorizer.Authorizer { - return authorizer.AuthorizerFunc(func( - ctx context.Context, attr authorizer.Attributes, - ) (authorized authorizer.Decision, reason string, err error) { - if !attr.IsResourceRequest() { - return authorizer.DecisionNoOpinion, "", nil - } - - // require a user - u, err := identity.GetRequester(ctx) - if err != nil { - return authorizer.DecisionDeny, "valid user is required", err - } - - // check if is admin - if u.HasRole(identity.RoleAdmin) { - return authorizer.DecisionAllow, "", nil - } - - return authorizer.DecisionDeny, "forbidden", nil - }) -} diff --git a/pkg/registry/apps/plugins/register.go b/pkg/registry/apps/plugins/register.go index aebb8e98178..9113d927a29 100644 --- a/pkg/registry/apps/plugins/register.go +++ b/pkg/registry/apps/plugins/register.go @@ -6,12 +6,12 @@ import ( authlib "github.com/grafana/authlib/types" appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" - "k8s.io/apiserver/pkg/authorization/authorizer" pluginsapp "github.com/grafana/grafana/apps/plugins/pkg/app" "github.com/grafana/grafana/apps/plugins/pkg/app/meta" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/appinstaller" + grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer" ) var ( @@ -37,18 +37,13 @@ func ProvideAppInstaller(accessControlService accesscontrol.Service, accessClien cloudProvider := meta.NewCatalogProvider(grafanaComAPIURL) metaProviderManager := meta.NewProviderManager(coreProvider, cloudProvider) - i, err := pluginsapp.ProvideAppInstaller(metaProviderManager) + authorizer := grafanaauthorizer.NewResourceAuthorizer(accessClient) + i, err := pluginsapp.ProvideAppInstaller(authorizer, metaProviderManager) if err != nil { return nil, err } - i.WithAccessChecker(accessClient) - return &AppInstaller{ PluginAppInstaller: i, }, nil } - -func (a *AppInstaller) GetAuthorizer() authorizer.Authorizer { - return pluginsapp.GetAuthorizer() -} diff --git a/pkg/registry/backgroundsvcs/adapter/service_test.go b/pkg/registry/backgroundsvcs/adapter/service_test.go index ed357d66443..275068967c0 100644 --- a/pkg/registry/backgroundsvcs/adapter/service_test.go +++ b/pkg/registry/backgroundsvcs/adapter/service_test.go @@ -55,15 +55,9 @@ func TestServiceAdapter_ErrorHandling(t *testing.T) { adapter := asNamedService(mockSvc) - t.Cleanup(func() { - adapter.StopAsync() - err := adapter.AwaitTerminated(context.Background()) - require.ErrorIs(t, err, expectedErr) - }) - err := adapter.StartAsync(context.Background()) require.NoError(t, err) - err = adapter.AwaitRunning(context.Background()) + err = adapter.AwaitTerminated(context.Background()) require.ErrorIs(t, err, expectedErr) require.True(t, mockSvc.runCalled) }) @@ -95,14 +89,9 @@ func TestServiceAdapter_ErrorHandling(t *testing.T) { adapter := asNamedService(mockSvc) - t.Cleanup(func() { - adapter.StopAsync() - err := adapter.AwaitTerminated(context.Background()) - require.ErrorIs(t, err, expectedErr) - }) err := adapter.StartAsync(context.Background()) require.NoError(t, err) - err = adapter.AwaitRunning(context.Background()) + err = adapter.AwaitTerminated(context.Background()) require.ErrorIs(t, err, expectedErr) require.True(t, mockSvc.runCalled) }) diff --git a/pkg/services/apiserver/auth/authorizer/resource.go b/pkg/services/apiserver/auth/authorizer/resource.go index b86f6b40f09..f2f959f9381 100644 --- a/pkg/services/apiserver/auth/authorizer/resource.go +++ b/pkg/services/apiserver/auth/authorizer/resource.go @@ -9,13 +9,13 @@ import ( claims "github.com/grafana/authlib/types" ) -func NewResourceAuthorizer(c claims.AccessClient) authorizer.Authorizer { +func NewResourceAuthorizer(c claims.AccessChecker) authorizer.Authorizer { return ResourceAuthorizer{c} } // ResourceAuthorizer is used to translate authorizer.Authorizer calls to claims.AccessClient calls type ResourceAuthorizer struct { - c claims.AccessClient + c claims.AccessChecker } func (r ResourceAuthorizer) Authorize(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) { From e56c2c5156b2a0bd6cf95be296f7f128e5a23928 Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Wed, 10 Dec 2025 09:59:12 -0500 Subject: [PATCH 023/139] Plugins App: Remove unused import (#115096) --- apps/plugins/pkg/app/app.go | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/plugins/pkg/app/app.go b/apps/plugins/pkg/app/app.go index 7c0187dd59d..c296bccd9e1 100644 --- a/apps/plugins/pkg/app/app.go +++ b/apps/plugins/pkg/app/app.go @@ -5,7 +5,6 @@ import ( "fmt" "sync" - authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana-app-sdk/app" "github.com/grafana/grafana-app-sdk/k8s" appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" From 8272edda9624affb14c578ed8800f52e0055a6d5 Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Wed, 10 Dec 2025 09:02:14 -0600 Subject: [PATCH 024/139] Logs: Default columns API (#114309) * Logs Drilldown(app-platform): add LogsDrilldownDefaultColumns api --------- Co-authored-by: L2D2Grafana Co-authored-by: Austin Pond --- .../definitions/logsdrilldown-manifest.json | 319 ++++ ...gsdrilldown.logsdrilldown.grafana.app.json | 92 ++ ...aultcolumns.logsdrilldown.grafana.app.json | 107 ++ ...owndefaults.logsdrilldown.grafana.app.json | 92 ++ apps/logsdrilldown/kinds/logsdrilldown.cue | 13 + apps/logsdrilldown/kinds/manifest.cue | 6 +- .../kinds/v0alpha1/defaultcolumns.cue | 19 + .../v1alpha1/logsdrilldown_object_gen.go | 7 + .../v1alpha1/logsdrilldown_schema_gen.go | 2 +- .../logsdrilldowndefaultcolumns_client_gen.go | 99 ++ .../logsdrilldowndefaultcolumns_codec_gen.go | 28 + ...ogsdrilldowndefaultcolumns_metadata_gen.go | 31 + .../logsdrilldowndefaultcolumns_object_gen.go | 326 ++++ .../logsdrilldowndefaultcolumns_schema_gen.go | 34 + .../logsdrilldowndefaultcolumns_spec_gen.go | 43 + .../logsdrilldowndefaultcolumns_status_gen.go | 44 + .../logsdrilldowndefaults_object_gen.go | 7 + .../logsdrilldowndefaults_schema_gen.go | 2 +- .../pkg/apis/logsdrilldown_manifest.go | 28 +- apps/logsdrilldown/pkg/app/app.go | 3 + .../logsdrilldown/v1alpha1/constants.go | 18 + .../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 + .../v1alpha1/types.metadata.gen.ts | 30 + .../v1alpha1/types.spec.gen.ts | 38 + .../v1alpha1/types.status.gen.ts | 30 + .../logsdrilldown/v1alpha1/endpoints.gen.ts | 494 +++++- .../logsdrilldown.grafana.app-v1alpha1.json | 1318 +++++++++++++++++ 51 files changed, 5182 insertions(+), 27 deletions(-) create mode 100644 apps/logsdrilldown/definitions/logsdrilldown-manifest.json create mode 100644 apps/logsdrilldown/definitions/logsdrilldown.logsdrilldown.grafana.app.json create mode 100644 apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json create mode 100644 apps/logsdrilldown/definitions/logsdrilldowndefaults.logsdrilldown.grafana.app.json create mode 100644 apps/logsdrilldown/kinds/v0alpha1/defaultcolumns.cue create mode 100644 apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go create mode 100644 apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go create mode 100644 apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go create mode 100644 apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go create mode 100644 apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go create mode 100644 apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go create mode 100644 apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go create mode 100644 apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go create mode 100644 apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.ts create mode 100644 apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1alpha1/types.metadata.gen.ts create mode 100644 apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1alpha1/types.spec.gen.ts create mode 100644 apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1alpha1/types.status.gen.ts diff --git a/apps/logsdrilldown/definitions/logsdrilldown-manifest.json b/apps/logsdrilldown/definitions/logsdrilldown-manifest.json new file mode 100644 index 00000000000..da34c0c70f2 --- /dev/null +++ b/apps/logsdrilldown/definitions/logsdrilldown-manifest.json @@ -0,0 +1,319 @@ +{ + "apiVersion": "apps.grafana.com/v1alpha2", + "kind": "AppManifest", + "metadata": { + "name": "logsdrilldown" + }, + "spec": { + "appName": "logsdrilldown", + "group": "logsdrilldown.grafana.app", + "versions": [ + { + "name": "v1alpha1", + "served": true, + "kinds": [ + { + "kind": "LogsDrilldown", + "plural": "LogsDrilldowns", + "scope": "Namespaced", + "schemas": { + "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" + } + }, + "conversion": false + }, + { + "kind": "LogsDrilldownDefaults", + "plural": "LogsDrilldownDefaults", + "scope": "Namespaced", + "schemas": { + "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" + } + }, + "conversion": false + }, + { + "kind": "LogsDrilldownDefaultColumns", + "plural": "LogsDrilldownDefaultColumns", + "scope": "Namespaced", + "schemas": { + "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" + } + }, + "conversion": false + } + ] + } + ], + "preferredVersion": "v1alpha1" + } +} diff --git a/apps/logsdrilldown/definitions/logsdrilldown.logsdrilldown.grafana.app.json b/apps/logsdrilldown/definitions/logsdrilldown.logsdrilldown.grafana.app.json new file mode 100644 index 00000000000..e0259f421ad --- /dev/null +++ b/apps/logsdrilldown/definitions/logsdrilldown.logsdrilldown.grafana.app.json @@ -0,0 +1,92 @@ +{ + "kind": "CustomResourceDefinition", + "apiVersion": "apiextensions.k8s.io/v1", + "metadata": { + "name": "logsdrilldowns.logsdrilldown.grafana.app" + }, + "spec": { + "group": "logsdrilldown.grafana.app", + "versions": [ + { + "name": "v1alpha1", + "served": true, + "storage": true, + "schema": { + "openAPIV3Schema": { + "properties": { + "spec": { + "properties": { + "defaultFields": { + "items": { + "type": "string" + }, + "type": "array" + }, + "interceptDismissed": { + "type": "boolean" + }, + "prettifyJSON": { + "type": "boolean" + }, + "wrapLogMessage": { + "type": "boolean" + } + }, + "required": ["defaultFields", "prettifyJSON", "wrapLogMessage", "interceptDismissed"], + "type": "object" + }, + "status": { + "properties": { + "additionalFields": { + "description": "additionalFields is reserved for future use", + "type": "object", + "x-kubernetes-preserve-unknown-fields": true + }, + "operatorStates": { + "additionalProperties": { + "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", + "x-kubernetes-preserve-unknown-fields": true + }, + "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" + }, + "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" + } + }, + "required": ["spec"], + "type": "object" + } + }, + "subresources": { + "status": {} + } + } + ], + "names": { + "kind": "LogsDrilldown", + "plural": "logsdrilldowns" + }, + "scope": "Namespaced" + } +} diff --git a/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json b/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json new file mode 100644 index 00000000000..28aa314311d --- /dev/null +++ b/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json @@ -0,0 +1,107 @@ +{ + "kind": "CustomResourceDefinition", + "apiVersion": "apiextensions.k8s.io/v1", + "metadata": { + "name": "logsdrilldowndefaultcolumns.logsdrilldown.grafana.app" + }, + "spec": { + "group": "logsdrilldown.grafana.app", + "versions": [ + { + "name": "v1alpha1", + "served": true, + "storage": true, + "schema": { + "openAPIV3Schema": { + "properties": { + "spec": { + "properties": { + "records": { + "items": { + "properties": { + "columns": { + "items": { + "type": "string" + }, + "type": "array" + }, + "labels": { + "items": { + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": ["key", "value"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["columns", "labels"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["records"], + "type": "object" + }, + "status": { + "properties": { + "additionalFields": { + "description": "additionalFields is reserved for future use", + "type": "object", + "x-kubernetes-preserve-unknown-fields": true + }, + "operatorStates": { + "additionalProperties": { + "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", + "x-kubernetes-preserve-unknown-fields": true + }, + "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" + }, + "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" + } + }, + "required": ["spec"], + "type": "object" + } + }, + "subresources": { + "status": {} + } + } + ], + "names": { + "kind": "LogsDrilldownDefaultColumns", + "plural": "logsdrilldowndefaultcolumns" + }, + "scope": "Namespaced" + } +} diff --git a/apps/logsdrilldown/definitions/logsdrilldowndefaults.logsdrilldown.grafana.app.json b/apps/logsdrilldown/definitions/logsdrilldowndefaults.logsdrilldown.grafana.app.json new file mode 100644 index 00000000000..f2ab4b77e80 --- /dev/null +++ b/apps/logsdrilldown/definitions/logsdrilldowndefaults.logsdrilldown.grafana.app.json @@ -0,0 +1,92 @@ +{ + "kind": "CustomResourceDefinition", + "apiVersion": "apiextensions.k8s.io/v1", + "metadata": { + "name": "logsdrilldowndefaults.logsdrilldown.grafana.app" + }, + "spec": { + "group": "logsdrilldown.grafana.app", + "versions": [ + { + "name": "v1alpha1", + "served": true, + "storage": true, + "schema": { + "openAPIV3Schema": { + "properties": { + "spec": { + "properties": { + "defaultFields": { + "items": { + "type": "string" + }, + "type": "array" + }, + "interceptDismissed": { + "type": "boolean" + }, + "prettifyJSON": { + "type": "boolean" + }, + "wrapLogMessage": { + "type": "boolean" + } + }, + "required": ["defaultFields", "prettifyJSON", "wrapLogMessage", "interceptDismissed"], + "type": "object" + }, + "status": { + "properties": { + "additionalFields": { + "description": "additionalFields is reserved for future use", + "type": "object", + "x-kubernetes-preserve-unknown-fields": true + }, + "operatorStates": { + "additionalProperties": { + "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", + "x-kubernetes-preserve-unknown-fields": true + }, + "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" + }, + "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" + } + }, + "required": ["spec"], + "type": "object" + } + }, + "subresources": { + "status": {} + } + } + ], + "names": { + "kind": "LogsDrilldownDefaults", + "plural": "logsdrilldowndefaults" + }, + "scope": "Namespaced" + } +} diff --git a/apps/logsdrilldown/kinds/logsdrilldown.cue b/apps/logsdrilldown/kinds/logsdrilldown.cue index 4a560ae3c6e..d2752103820 100644 --- a/apps/logsdrilldown/kinds/logsdrilldown.cue +++ b/apps/logsdrilldown/kinds/logsdrilldown.cue @@ -1,5 +1,9 @@ package kinds +import ( + "github.com/grafana/grafana/apps/logsdrilldown/kinds/v0alpha1" +) + LogsDrilldownSpecv1alpha1: { defaultFields: [...string] | *[] prettifyJSON: bool @@ -21,3 +25,12 @@ logsdrilldownDefaultsv1alpha1: { spec: LogsDrilldownSpecv1alpha1 } } + +// Default columns API +logsdrilldownDefaultColumnsv0alpha1: { + kind: "LogsDrilldownDefaultColumns" + pluralName: "LogsDrilldownDefaultColumns" + schema: { + spec: v0alpha1.LogsDefaultColumns + } +} diff --git a/apps/logsdrilldown/kinds/manifest.cue b/apps/logsdrilldown/kinds/manifest.cue index e2f2fb236b1..ab717de6a92 100644 --- a/apps/logsdrilldown/kinds/manifest.cue +++ b/apps/logsdrilldown/kinds/manifest.cue @@ -35,12 +35,12 @@ 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] + kinds: [logsdrilldownv1alpha1, logsdrilldownDefaultsv1alpha1, logsdrilldownDefaultColumnsv0alpha1] // [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 + 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. @@ -64,4 +64,4 @@ v1alpha1: { enabled: true } } -} \ No newline at end of file +} diff --git a/apps/logsdrilldown/kinds/v0alpha1/defaultcolumns.cue b/apps/logsdrilldown/kinds/v0alpha1/defaultcolumns.cue new file mode 100644 index 00000000000..123d5129c15 --- /dev/null +++ b/apps/logsdrilldown/kinds/v0alpha1/defaultcolumns.cue @@ -0,0 +1,19 @@ +package v0alpha1 + +#LogsDefaultColumnsLabel: { + key: string + value: string +} + +#LogsDefaultColumnsLabels: [...#LogsDefaultColumnsLabel] + +#LogsDefaultColumnsRecord: { + columns: [...string] + labels: #LogsDefaultColumnsLabels +} + +#LogsDefaultColumnsRecords: [...#LogsDefaultColumnsRecord] + +LogsDefaultColumns: { + records: #LogsDefaultColumnsRecords +} diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go index 4ede8bb4ee6..50bdc230cd1 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go @@ -25,6 +25,13 @@ type LogsDrilldown struct { Status LogsDrilldownStatus `json:"status" yaml:"status"` } +func NewLogsDrilldown() *LogsDrilldown { + return &LogsDrilldown{ + Spec: *NewLogsDrilldownSpec(), + Status: *NewLogsDrilldownStatus(), + } +} + func (o *LogsDrilldown) GetSpec() any { return o.Spec } diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go index e6864c965a7..8e88e870346 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaLogsDrilldown = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldown{}, &LogsDrilldownList{}, resource.WithKind("LogsDrilldown"), + schemaLogsDrilldown = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", NewLogsDrilldown(), &LogsDrilldownList{}, resource.WithKind("LogsDrilldown"), resource.WithPlural("logsdrilldowns"), resource.WithScope(resource.NamespacedScope)) kindLogsDrilldown = resource.Kind{ Schema: schemaLogsDrilldown, diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go new file mode 100644 index 00000000000..b5d573bc1dc --- /dev/null +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go @@ -0,0 +1,99 @@ +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, LogsDrilldownDefaultColumnsKind()), + } +} + +func NewLogsDrilldownDefaultColumnsClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownDefaultColumnsClient, error) { + c, err := generator.ClientFor(LogsDrilldownDefaultColumnsKind()) + 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 = LogsDrilldownDefaultColumnsKind().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 LogsDrilldownDefaultColumnsStatus, opts resource.UpdateOptions) (*LogsDrilldownDefaultColumns, error) { + return c.client.Update(ctx, &LogsDrilldownDefaultColumns{ + TypeMeta: metav1.TypeMeta{ + Kind: LogsDrilldownDefaultColumnsKind().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/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go new file mode 100644 index 00000000000..311d2f02683 --- /dev/null +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go @@ -0,0 +1,28 @@ +// +// Code generated by grafana-app-sdk. DO NOT EDIT. +// + +package v1alpha1 + +import ( + "encoding/json" + "io" + + "github.com/grafana/grafana-app-sdk/resource" +) + +// LogsDrilldownDefaultColumnsJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding +type LogsDrilldownDefaultColumnsJSONCodec struct{} + +// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` +func (*LogsDrilldownDefaultColumnsJSONCodec) 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 (*LogsDrilldownDefaultColumnsJSONCodec) Write(writer io.Writer, from resource.Object) error { + return json.NewEncoder(writer).Encode(from) +} + +// Interface compliance checks +var _ resource.Codec = &LogsDrilldownDefaultColumnsJSONCodec{} diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go new file mode 100644 index 00000000000..a4bb052fe25 --- /dev/null +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go @@ -0,0 +1,31 @@ +// 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 LogsDrilldownDefaultColumnsMetadata 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"` +} + +// NewLogsDrilldownDefaultColumnsMetadata creates a new LogsDrilldownDefaultColumnsMetadata object. +func NewLogsDrilldownDefaultColumnsMetadata() *LogsDrilldownDefaultColumnsMetadata { + return &LogsDrilldownDefaultColumnsMetadata{ + Finalizers: []string{}, + Labels: map[string]string{}, + } +} diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go new file mode 100644 index 00000000000..4340a27714e --- /dev/null +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go @@ -0,0 +1,326 @@ +// +// 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 LogsDrilldownDefaultColumnsSpec `json:"spec" yaml:"spec"` + + Status LogsDrilldownDefaultColumnsStatus `json:"status" yaml:"status"` +} + +func NewLogsDrilldownDefaultColumns() *LogsDrilldownDefaultColumns { + return &LogsDrilldownDefaultColumns{ + Spec: *NewLogsDrilldownDefaultColumnsSpec(), + Status: *NewLogsDrilldownDefaultColumnsStatus(), + } +} + +func (o *LogsDrilldownDefaultColumns) GetSpec() any { + return o.Spec +} + +func (o *LogsDrilldownDefaultColumns) SetSpec(spec any) error { + cast, ok := spec.(LogsDrilldownDefaultColumnsSpec) + 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.(LogsDrilldownDefaultColumnsStatus) + if !ok { + return fmt.Errorf("cannot set status type %#v, not of type LogsDrilldownDefaultColumnsStatus", 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 *LogsDrilldownDefaultColumnsSpec) DeepCopy() *LogsDrilldownDefaultColumnsSpec { + cpy := &LogsDrilldownDefaultColumnsSpec{} + s.DeepCopyInto(cpy) + return cpy +} + +// DeepCopyInto deep copies Spec into another Spec object +func (s *LogsDrilldownDefaultColumnsSpec) DeepCopyInto(dst *LogsDrilldownDefaultColumnsSpec) { + resource.CopyObjectInto(dst, s) +} + +// DeepCopy creates a full deep copy of LogsDrilldownDefaultColumnsStatus +func (s *LogsDrilldownDefaultColumnsStatus) DeepCopy() *LogsDrilldownDefaultColumnsStatus { + cpy := &LogsDrilldownDefaultColumnsStatus{} + s.DeepCopyInto(cpy) + return cpy +} + +// DeepCopyInto deep copies LogsDrilldownDefaultColumnsStatus into another LogsDrilldownDefaultColumnsStatus object +func (s *LogsDrilldownDefaultColumnsStatus) DeepCopyInto(dst *LogsDrilldownDefaultColumnsStatus) { + resource.CopyObjectInto(dst, s) +} diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go new file mode 100644 index 00000000000..cc5363e16bb --- /dev/null +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go @@ -0,0 +1,34 @@ +// +// 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", NewLogsDrilldownDefaultColumns(), &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), + resource.WithPlural("logsdrilldowndefaultcolumns"), resource.WithScope(resource.NamespacedScope)) + kindLogsDrilldownDefaultColumns = resource.Kind{ + Schema: schemaLogsDrilldownDefaultColumns, + Codecs: map[resource.KindEncoding]resource.Codec{ + resource.KindEncodingJSON: &LogsDrilldownDefaultColumnsJSONCodec{}, + }, + } +) + +// Kind returns a resource.Kind for this Schema with a JSON codec +func LogsDrilldownDefaultColumnsKind() resource.Kind { + return kindLogsDrilldownDefaultColumns +} + +// Schema returns a resource.SimpleSchema representation of LogsDrilldownDefaultColumns +func LogsDrilldownDefaultColumnsSchema() *resource.SimpleSchema { + return schemaLogsDrilldownDefaultColumns +} + +// Interface compliance checks +var _ resource.Schema = kindLogsDrilldownDefaultColumns diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go new file mode 100644 index 00000000000..ce12ebb0761 --- /dev/null +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go @@ -0,0 +1,43 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v1alpha1 + +// +k8s:openapi-gen=true +type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords []LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord + +// +k8s:openapi-gen=true +type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord struct { + Columns []string `json:"columns"` + Labels LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels `json:"labels"` +} + +// NewLogsDrilldownDefaultColumnsLogsDefaultColumnsRecord creates a new LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord object. +func NewLogsDrilldownDefaultColumnsLogsDefaultColumnsRecord() *LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord { + return &LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord{ + Columns: []string{}, + } +} + +// +k8s:openapi-gen=true +type LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels []LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel + +// +k8s:openapi-gen=true +type LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// NewLogsDrilldownDefaultColumnsLogsDefaultColumnsLabel creates a new LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel object. +func NewLogsDrilldownDefaultColumnsLogsDefaultColumnsLabel() *LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel { + return &LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel{} +} + +// +k8s:openapi-gen=true +type LogsDrilldownDefaultColumnsSpec struct { + Records LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords `json:"records"` +} + +// NewLogsDrilldownDefaultColumnsSpec creates a new LogsDrilldownDefaultColumnsSpec object. +func NewLogsDrilldownDefaultColumnsSpec() *LogsDrilldownDefaultColumnsSpec { + return &LogsDrilldownDefaultColumnsSpec{} +} diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go new file mode 100644 index 00000000000..c2183832095 --- /dev/null +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go @@ -0,0 +1,44 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v1alpha1 + +// +k8s:openapi-gen=true +type LogsDrilldownDefaultColumnsstatusOperatorState 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 LogsDrilldownDefaultColumnsStatusOperatorStateState `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"` +} + +// NewLogsDrilldownDefaultColumnsstatusOperatorState creates a new LogsDrilldownDefaultColumnsstatusOperatorState object. +func NewLogsDrilldownDefaultColumnsstatusOperatorState() *LogsDrilldownDefaultColumnsstatusOperatorState { + return &LogsDrilldownDefaultColumnsstatusOperatorState{} +} + +// +k8s:openapi-gen=true +type LogsDrilldownDefaultColumnsStatus 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]LogsDrilldownDefaultColumnsstatusOperatorState `json:"operatorStates,omitempty"` + // additionalFields is reserved for future use + AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` +} + +// NewLogsDrilldownDefaultColumnsStatus creates a new LogsDrilldownDefaultColumnsStatus object. +func NewLogsDrilldownDefaultColumnsStatus() *LogsDrilldownDefaultColumnsStatus { + return &LogsDrilldownDefaultColumnsStatus{} +} + +// +k8s:openapi-gen=true +type LogsDrilldownDefaultColumnsStatusOperatorStateState string + +const ( + LogsDrilldownDefaultColumnsStatusOperatorStateStateSuccess LogsDrilldownDefaultColumnsStatusOperatorStateState = "success" + LogsDrilldownDefaultColumnsStatusOperatorStateStateInProgress LogsDrilldownDefaultColumnsStatusOperatorStateState = "in_progress" + LogsDrilldownDefaultColumnsStatusOperatorStateStateFailed LogsDrilldownDefaultColumnsStatusOperatorStateState = "failed" +) diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaults_object_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaults_object_gen.go index 8dfb90a1bfb..ff5ce10adce 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaults_object_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaults_object_gen.go @@ -25,6 +25,13 @@ type LogsDrilldownDefaults struct { Status LogsDrilldownDefaultsStatus `json:"status" yaml:"status"` } +func NewLogsDrilldownDefaults() *LogsDrilldownDefaults { + return &LogsDrilldownDefaults{ + Spec: *NewLogsDrilldownDefaultsSpec(), + Status: *NewLogsDrilldownDefaultsStatus(), + } +} + func (o *LogsDrilldownDefaults) GetSpec() any { return o.Spec } diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaults_schema_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaults_schema_gen.go index 20e952ce021..f488c319db2 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaults_schema_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaults_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaLogsDrilldownDefaults = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldownDefaults{}, &LogsDrilldownDefaultsList{}, resource.WithKind("LogsDrilldownDefaults"), + schemaLogsDrilldownDefaults = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", NewLogsDrilldownDefaults(), &LogsDrilldownDefaultsList{}, resource.WithKind("LogsDrilldownDefaults"), resource.WithPlural("logsdrilldowndefaults"), resource.WithScope(resource.NamespacedScope)) kindLogsDrilldownDefaults = resource.Kind{ Schema: schemaLogsDrilldownDefaults, diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go b/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go index ef0d5801511..2350b924dda 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go @@ -20,12 +20,15 @@ import ( ) 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) + 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{ @@ -52,6 +55,14 @@ var appManifestData = app.ManifestData{ Conversion: false, Schema: &versionSchemaLogsDrilldownDefaultsv1alpha1, }, + + { + Kind: "LogsDrilldownDefaultColumns", + Plural: "LogsDrilldownDefaultColumns", + Scope: "Namespaced", + Conversion: false, + Schema: &versionSchemaLogsDrilldownDefaultColumnsv1alpha1, + }, }, Routes: app.ManifestVersionRoutes{ Namespaced: map[string]spec3.PathProps{}, @@ -71,8 +82,9 @@ func RemoteManifest() app.Manifest { } var kindVersionToGoType = map[string]resource.Kind{ - "LogsDrilldown/v1alpha1": v1alpha1.LogsDrilldownKind(), - "LogsDrilldownDefaults/v1alpha1": v1alpha1.LogsDrilldownDefaultsKind(), + "LogsDrilldown/v1alpha1": v1alpha1.LogsDrilldownKind(), + "LogsDrilldownDefaults/v1alpha1": v1alpha1.LogsDrilldownDefaultsKind(), + "LogsDrilldownDefaultColumns/v1alpha1": v1alpha1.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 7fda1ecb99f..23260270207 100644 --- a/apps/logsdrilldown/pkg/app/app.go +++ b/apps/logsdrilldown/pkg/app/app.go @@ -31,6 +31,9 @@ func New(cfg app.Config) (app.App, error) { { Kind: logsdrilldownv1alpha1.LogsDrilldownDefaultsKind(), }, + { + Kind: logsdrilldownv1alpha1.LogsDrilldownDefaultColumnsKind(), + }, }, } diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go new file mode 100644 index 00000000000..082bec7c874 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go @@ -0,0 +1,18 @@ +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/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go new file mode 100644 index 00000000000..c133b65f45b --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go @@ -0,0 +1,99 @@ +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 new file mode 100644 index 00000000000..bb458caeb88 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go @@ -0,0 +1,28 @@ +// +// 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 new file mode 100644 index 00000000000..cb7233b22ab --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go @@ -0,0 +1,31 @@ +// 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 new file mode 100644 index 00000000000..5d40a873e6b --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go @@ -0,0 +1,319 @@ +// +// 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 new file mode 100644 index 00000000000..942794416e8 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go @@ -0,0 +1,34 @@ +// +// 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 new file mode 100644 index 00000000000..faff5c108dd --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go @@ -0,0 +1,18 @@ +// 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 new file mode 100644 index 00000000000..9b227b00f44 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go @@ -0,0 +1,44 @@ +// 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 new file mode 100644 index 00000000000..082bec7c874 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go @@ -0,0 +1,18 @@ +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 new file mode 100644 index 00000000000..b66471eb4ba --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go @@ -0,0 +1,99 @@ +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 new file mode 100644 index 00000000000..bb458caeb88 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go @@ -0,0 +1,28 @@ +// +// 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 new file mode 100644 index 00000000000..cb7233b22ab --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go @@ -0,0 +1,31 @@ +// 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 new file mode 100644 index 00000000000..3173c28330e --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go @@ -0,0 +1,319 @@ +// +// 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 new file mode 100644 index 00000000000..b50be391fc7 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go @@ -0,0 +1,34 @@ +// +// 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 new file mode 100644 index 00000000000..d9cd977aeb9 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 00000000000..9b227b00f44 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go @@ -0,0 +1,44 @@ +// 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 new file mode 100644 index 00000000000..082bec7c874 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go @@ -0,0 +1,18 @@ +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 new file mode 100644 index 00000000000..cc06a10b1e7 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go @@ -0,0 +1,99 @@ +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 new file mode 100644 index 00000000000..bb458caeb88 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go @@ -0,0 +1,28 @@ +// +// 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 new file mode 100644 index 00000000000..cb7233b22ab --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go @@ -0,0 +1,31 @@ +// 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 new file mode 100644 index 00000000000..d9354522dd7 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go @@ -0,0 +1,319 @@ +// +// 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 new file mode 100644 index 00000000000..bda3e49377d --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go @@ -0,0 +1,34 @@ +// +// 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 new file mode 100644 index 00000000000..faff5c108dd --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go @@ -0,0 +1,18 @@ +// 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 new file mode 100644 index 00000000000..9b227b00f44 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go @@ -0,0 +1,44 @@ +// 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 new file mode 100644 index 00000000000..9deb5d5d3a1 --- /dev/null +++ b/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go @@ -0,0 +1,150 @@ +// +// 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/v1alpha1/logsdrilldowndefaultcolumns_object_gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.ts new file mode 100644 index 00000000000..f7ba7b0f223 --- /dev/null +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1alpha1/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/v1alpha1/types.metadata.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1alpha1/types.metadata.gen.ts new file mode 100644 index 00000000000..4377f3c1d08 --- /dev/null +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1alpha1/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/v1alpha1/types.spec.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1alpha1/types.spec.gen.ts new file mode 100644 index 00000000000..fde99894776 --- /dev/null +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1alpha1/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/v1alpha1/types.status.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1alpha1/types.status.gen.ts new file mode 100644 index 00000000000..01be8df7961 --- /dev/null +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1alpha1/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/src/clients/rtkq/logsdrilldown/v1alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1alpha1/endpoints.gen.ts index 835194b59e5..22464a2b3fa 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1alpha1/endpoints.gen.ts @@ -1,5 +1,10 @@ import { api } from './baseAPI'; -export const addTagTypes = ['API Discovery', 'LogsDrilldownDefaults', 'LogsDrilldown'] as const; +export const addTagTypes = [ + 'API Discovery', + 'LogsDrilldownDefaultColumns', + 'LogsDrilldownDefaults', + 'LogsDrilldown', +] as const; const injectedRtkApi = api .enhanceEndpoints({ addTagTypes, @@ -10,6 +15,183 @@ const injectedRtkApi = api 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'], + }), listLogsDrilldownDefaults: build.query({ query: (queryArg) => ({ url: `/logsdrilldowndefaults`, @@ -340,6 +522,218 @@ const injectedRtkApi = api 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 ListLogsDrilldownDefaultsApiResponse = /** status 200 OK */ LogsDrilldownDefaultsList; export type ListLogsDrilldownDefaultsApiArg = { /** 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). */ @@ -874,13 +1268,21 @@ export type ObjectMeta = { Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ uid?: string; }; -export type LogsDrilldownDefaultsSpec = { - defaultFields: string[]; - interceptDismissed: boolean; - prettifyJSON: boolean; - wrapLogMessage: boolean; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel = { + key: string; + value: string; }; -export type LogsDrilldownDefaultsOperatorState = { +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 */ @@ -895,7 +1297,7 @@ export type LogsDrilldownDefaultsOperatorState = { It is limited to three possible states for machine evaluation. */ state: 'success' | 'in_progress' | 'failed'; }; -export type LogsDrilldownDefaultsStatus = { +export type LogsDrilldownDefaultColumnsStatus = { /** additionalFields is reserved for future use */ additionalFields?: { [key: string]: { @@ -905,17 +1307,17 @@ export type LogsDrilldownDefaultsStatus = { /** 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]: LogsDrilldownDefaultsOperatorState; + [key: string]: LogsDrilldownDefaultColumnsOperatorState; }; }; -export type LogsDrilldownDefaults = { +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: LogsDrilldownDefaultsSpec; - status?: LogsDrilldownDefaultsStatus; + 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. */ @@ -927,10 +1329,10 @@ export type ListMeta = { /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ selfLink?: string; }; -export type LogsDrilldownDefaultsList = { +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: LogsDrilldownDefaults[]; + 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; @@ -980,6 +1382,57 @@ export type Status = { status?: string; }; export type Patch = object; +export type LogsDrilldownDefaultsSpec = { + defaultFields: string[]; + interceptDismissed: boolean; + prettifyJSON: boolean; + wrapLogMessage: boolean; +}; +export type LogsDrilldownDefaultsOperatorState = { + /** 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 LogsDrilldownDefaultsStatus = { + /** 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]: LogsDrilldownDefaultsOperatorState; + }; +}; +export type LogsDrilldownDefaults = { + /** 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: LogsDrilldownDefaultsSpec; + status?: LogsDrilldownDefaultsStatus; +}; +export type LogsDrilldownDefaultsList = { + /** 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: LogsDrilldownDefaults[]; + /** 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 LogsDrilldownSpec = { defaultFields: string[]; interceptDismissed: boolean; @@ -1034,6 +1487,19 @@ export type LogsDrilldownList = { export const { useGetApiResourcesQuery, useLazyGetApiResourcesQuery, + useListLogsDrilldownDefaultColumnsQuery, + useLazyListLogsDrilldownDefaultColumnsQuery, + useCreateLogsDrilldownDefaultColumnsMutation, + useDeletecollectionLogsDrilldownDefaultColumnsMutation, + useGetLogsDrilldownDefaultColumnsQuery, + useLazyGetLogsDrilldownDefaultColumnsQuery, + useReplaceLogsDrilldownDefaultColumnsMutation, + useDeleteLogsDrilldownDefaultColumnsMutation, + useUpdateLogsDrilldownDefaultColumnsMutation, + useGetLogsDrilldownDefaultColumnsStatusQuery, + useLazyGetLogsDrilldownDefaultColumnsStatusQuery, + useReplaceLogsDrilldownDefaultColumnsStatusMutation, + useUpdateLogsDrilldownDefaultColumnsStatusMutation, useListLogsDrilldownDefaultsQuery, useLazyListLogsDrilldownDefaultsQuery, useCreateLogsDrilldownDefaultsMutation, diff --git a/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1alpha1.json b/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1alpha1.json index 25d6cd87807..93be607fd41 100644 --- a/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1alpha1.json @@ -35,6 +35,1126 @@ } } }, + "/apis/logsdrilldown.grafana.app/v1alpha1/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.v1alpha1.LogsDrilldownDefaultColumnsList" + } + }, + "application/json;stream=watch": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumnsList" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumnsList" + } + }, + "application/vnd.kubernetes.protobuf;stream=watch": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumnsList" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumnsList" + } + } + } + } + }, + "x-kubernetes-action": "list", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1alpha1", + "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.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "post", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1alpha1", + "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": "v1alpha1", + "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/v1alpha1/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.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "get", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1alpha1", + "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.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "put", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1alpha1", + "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": "v1alpha1", + "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.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "patch", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1alpha1", + "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/v1alpha1/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.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "get", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1alpha1", + "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.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "put", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1alpha1", + "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.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumns" + } + } + } + } + }, + "x-kubernetes-action": "patch", + "x-kubernetes-group-version-kind": { + "group": "logsdrilldown.grafana.app", + "version": "v1alpha1", + "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/v1alpha1/namespaces/{namespace}/logsdrilldowndefaults": { "get": { "tags": [ @@ -2318,6 +3438,204 @@ } ] }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.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.v1alpha1.LogsDrilldownDefaultColumnsSpec" + }, + "status": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumnsStatus" + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "logsdrilldown.grafana.app", + "kind": "LogsDrilldownDefaultColumns", + "version": "v1alpha1" + } + ] + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.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.v1alpha1.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": "v1alpha1" + } + ] + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel" + } + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.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.v1alpha1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels" + } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords": { + "type": "array", + "items": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord" + } + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.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.v1alpha1.LogsDrilldownDefaultColumnsSpec": { + "type": "object", + "required": [ + "records" + ], + "properties": { + "records": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords" + } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.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.v1alpha1.LogsDrilldownDefaultColumnsOperatorState" + } + } + }, + "additionalProperties": false + }, "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1alpha1.LogsDrilldownDefaults": { "type": "object", "required": [ From 85c643ece979cb5093cb521025572b229050e0c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:07:22 -0500 Subject: [PATCH 025/139] Elasticsearch: Add default query mode config setting (#112540) * elasticsearch: Add default query mode config setting * doc * syncing default query mode with url * addressing PR comments --- .../configure-elasticsearch-data-source.md | 2 + .../datasource/elasticsearch/QueryBuilder.ts | 13 +- .../QueryEditor/ElasticsearchQueryContext.tsx | 4 +- .../MetricAggregationsEditor/state/reducer.ts | 5 +- .../QueryEditor/QueryTypeSelector.test.tsx | 117 ++++++++++++++++++ .../QueryEditor/QueryTypeSelector.tsx | 34 ++--- .../components/QueryEditor/state.ts | 3 +- .../configuration/ElasticDetails.test.tsx | 12 ++ .../configuration/ElasticDetails.tsx | 32 ++++- .../configuration/mocks/configOptions.ts | 1 + .../elasticsearch/configuration/utils.ts | 14 ++- .../datasource/elasticsearch/datasource.ts | 11 +- .../datasource/elasticsearch/queryDef.test.ts | 48 ++++++- .../datasource/elasticsearch/queryDef.ts | 19 +++ .../plugins/datasource/elasticsearch/types.ts | 1 + 15 files changed, 273 insertions(+), 43 deletions(-) create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.test.tsx diff --git a/docs/sources/datasources/elasticsearch/configure-elasticsearch-data-source.md b/docs/sources/datasources/elasticsearch/configure-elasticsearch-data-source.md index 6b145841bbf..d43e0ec2978 100644 --- a/docs/sources/datasources/elasticsearch/configure-elasticsearch-data-source.md +++ b/docs/sources/datasources/elasticsearch/configure-elasticsearch-data-source.md @@ -174,6 +174,8 @@ You can also override this setting in a dashboard panel under its data source op Frozen indices are [deprecated in Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/frozen-indices.html) since v7.14. {{< /admonition >}} +- **Default query mode** - Specifies which query mode the data source uses by default. Options are `Metrics`, `Logs`, `Raw data`, and `Raw document`. The default is `Metrics`. + ### Logs In this section you can configure which fields the data source uses for log messages and log levels. diff --git a/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts b/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts index 22ed3d8d68e..3d36ee9a806 100644 --- a/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts +++ b/public/app/plugins/datasource/elasticsearch/QueryBuilder.ts @@ -18,12 +18,12 @@ import { } from './dataquery.gen'; import { defaultBucketAgg, - defaultMetricAgg, findMetricById, highlightTags, defaultGeoHashPrecisionString, + queryTypeToMetricType, } from './queryDef'; -import { TermsQuery } from './types'; +import { QueryType, TermsQuery } from './types'; import { convertOrderByToMetricId, getScriptValue } from './utils'; // Omitting 1m, 1h, 1d for now, as these cover the main use cases for calendar_interval @@ -31,9 +31,11 @@ export const calendarIntervals: string[] = ['1w', '1M', '1q', '1y']; export class ElasticQueryBuilder { timeField: string; + defaultQueryMode?: QueryType; - constructor(options: { timeField: string }) { + constructor(options: { timeField: string; defaultQueryMode?: QueryType }) { this.timeField = options.timeField; + this.defaultQueryMode = options.defaultQueryMode; } getRangeFilter() { @@ -174,7 +176,10 @@ export class ElasticQueryBuilder { build(target: ElasticsearchDataQuery) { // make sure query has defaults; - target.metrics = target.metrics || [defaultMetricAgg()]; + if (!target.metrics || target.metrics.length === 0) { + const metricType = queryTypeToMetricType(this.defaultQueryMode); + target.metrics = [{ type: metricType, id: '1' }]; + } target.bucketAggs = target.bucketAggs || [defaultBucketAgg()]; target.timeField = this.timeField; let metric: MetricAggregation; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx index 57092ae9477..22102290fe1 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx @@ -62,10 +62,10 @@ export const ElasticsearchProvider = ({ // useStatelessReducer will then call `onChange` with the newly generated query useEffect(() => { if (shouldRunInit && isUninitialized) { - dispatch(initQuery()); + dispatch(initQuery(datasource.defaultQueryMode)); setShouldRunInit(false); } - }, [shouldRunInit, dispatch, isUninitialized]); + }, [shouldRunInit, dispatch, isUninitialized, datasource.defaultQueryMode]); if (isUninitialized) { return null; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts index d44a57569f7..966bd71d6c8 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts @@ -2,7 +2,7 @@ import { Action } from '@reduxjs/toolkit'; import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen'; -import { defaultMetricAgg } from '../../../../queryDef'; +import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef'; import { removeEmpty } from '../../../../utils'; import { initQuery } from '../../state'; import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations'; @@ -162,7 +162,8 @@ export const reducer = ( if (state && state.length > 0) { return state; } - return [defaultMetricAgg('1')]; + const metricType = queryTypeToMetricType(action.payload); + return [{ type: metricType, id: '1' }]; } return state; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.test.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.test.tsx new file mode 100644 index 00000000000..915d35a3bf8 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.test.tsx @@ -0,0 +1,117 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ElasticsearchDataQuery } from '../../dataquery.gen'; +import { useDispatch } from '../../hooks/useStatelessReducer'; +import { renderWithESProvider } from '../../test-helpers/render'; + +import { changeMetricType } from './MetricAggregationsEditor/state/actions'; +import { QueryTypeSelector } from './QueryTypeSelector'; + +jest.mock('../../hooks/useStatelessReducer'); + +describe('QueryTypeSelector', () => { + let dispatch: jest.Mock; + + beforeEach(() => { + dispatch = jest.fn(); + jest.mocked(useDispatch).mockReturnValue(dispatch); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render radio buttons with correct options', () => { + const query: ElasticsearchDataQuery = { + refId: 'A', + query: '', + metrics: [{ id: '1', type: 'count' }], + bucketAggs: [{ type: 'date_histogram', id: '2' }], + }; + + renderWithESProvider(, { providerProps: { query } }); + + expect(screen.getByRole('radio', { name: 'Metrics' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Logs' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Raw Data' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Raw Document' })).toBeInTheDocument(); + }); + + it('should dispatch changeMetricType action when radio button is changed', async () => { + const query: ElasticsearchDataQuery = { + refId: 'A', + query: '', + metrics: [{ id: '1', type: 'count' }], + bucketAggs: [{ type: 'date_histogram', id: '2' }], + }; + + renderWithESProvider(, { providerProps: { query } }); + + const logsRadio = screen.getByRole('radio', { name: 'Logs' }); + await userEvent.click(logsRadio); + + expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'logs' })); + }); + + it('should convert query type to metric type correctly for raw_data', async () => { + const query: ElasticsearchDataQuery = { + refId: 'A', + query: '', + metrics: [{ id: '1', type: 'count' }], + bucketAggs: [{ type: 'date_histogram', id: '2' }], + }; + + renderWithESProvider(, { providerProps: { query } }); + + const rawDataRadio = screen.getByRole('radio', { name: 'Raw Data' }); + await userEvent.click(rawDataRadio); + + expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_data' })); + }); + + it('should convert query type to metric type correctly for raw_document', async () => { + const query: ElasticsearchDataQuery = { + refId: 'A', + query: '', + metrics: [{ id: '1', type: 'count' }], + bucketAggs: [{ type: 'date_histogram', id: '2' }], + }; + + renderWithESProvider(, { providerProps: { query } }); + + const rawDocumentRadio = screen.getByRole('radio', { name: 'Raw Document' }); + await userEvent.click(rawDocumentRadio); + + expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_document' })); + }); + + it('should convert metrics query type to count metric type', async () => { + const query: ElasticsearchDataQuery = { + refId: 'A', + query: '', + metrics: [{ id: '1', type: 'logs' }], + bucketAggs: [{ type: 'date_histogram', id: '2' }], + }; + + renderWithESProvider(, { providerProps: { query } }); + + const metricsRadio = screen.getByRole('radio', { name: 'Metrics' }); + await userEvent.click(metricsRadio); + + expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'count' })); + }); + + it('should return null when query has no metrics', () => { + const query: ElasticsearchDataQuery = { + refId: 'A', + query: '', + metrics: [], + bucketAggs: [{ type: 'date_histogram', id: '2' }], + }; + + const { container } = renderWithESProvider(, { providerProps: { query } }); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.tsx index bdfd1336d8b..207646a9514 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/QueryTypeSelector.tsx @@ -1,35 +1,14 @@ -import { SelectableValue } from '@grafana/data'; import { RadioButtonGroup } from '@grafana/ui'; -import { MetricAggregation } from '../../dataquery.gen'; +import { QUERY_TYPE_SELECTOR_OPTIONS } from '../../configuration/utils'; import { useDispatch } from '../../hooks/useStatelessReducer'; +import { queryTypeToMetricType } from '../../queryDef'; import { QueryType } from '../../types'; import { useQuery } from './ElasticsearchQueryContext'; import { changeMetricType } from './MetricAggregationsEditor/state/actions'; import { metricAggregationConfig } from './MetricAggregationsEditor/utils'; -const OPTIONS: Array> = [ - { value: 'metrics', label: 'Metrics' }, - { value: 'logs', label: 'Logs' }, - { value: 'raw_data', label: 'Raw Data' }, - { value: 'raw_document', label: 'Raw Document' }, -]; - -function queryTypeToMetricType(type: QueryType): MetricAggregation['type'] { - switch (type) { - case 'logs': - case 'raw_data': - case 'raw_document': - return type; - case 'metrics': - return 'count'; - default: - // should never happen - throw new Error(`invalid query type: ${type}`); - } -} - export const QueryTypeSelector = () => { const query = useQuery(); const dispatch = useDispatch(); @@ -47,5 +26,12 @@ export const QueryTypeSelector = () => { dispatch(changeMetricType({ id: firstMetric.id, type: queryTypeToMetricType(newQueryType) })); }; - return fullWidth={false} options={OPTIONS} value={queryType} onChange={onChange} />; + return ( + + fullWidth={false} + options={QUERY_TYPE_SELECTOR_OPTIONS} + value={queryType} + onChange={onChange} + /> + ); }; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts index 4785e371642..a0ac6504049 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts @@ -1,12 +1,13 @@ import { Action, createAction } from '@reduxjs/toolkit'; import { ElasticsearchDataQuery } from '../../dataquery.gen'; +import { QueryType } from '../../types'; /** * When the `initQuery` Action is dispatched, the query gets populated with default values where values are not present. * This means it won't override any existing value in place, but just ensure the query is in a "runnable" state. */ -export const initQuery = createAction('init'); +export const initQuery = createAction('init'); export const changeQuery = createAction('change_query'); diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx index a0df1278d9f..1b4c284303a 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.test.tsx @@ -41,4 +41,16 @@ describe('ElasticDetails', () => { }) ); }); + + it('should change default query mode when selected', async () => { + const onChangeMock = jest.fn(); + render(); + const selectEl = screen.getByLabelText('Default query mode'); + + await selectEvent.select(selectEl, 'Logs', { container: document.body }); + + expect(onChangeMock).toHaveBeenLastCalledWith( + expect.objectContaining({ jsonData: expect.objectContaining({ defaultQueryMode: 'logs' }) }) + ); + }); }); diff --git a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx index 232276d0124..fee941b3fc3 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx +++ b/public/app/plugins/datasource/elasticsearch/configuration/ElasticDetails.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { DataSourceSettings, SelectableValue } from '@grafana/data'; +import type { DataSourceSettings, SelectableValue } from '@grafana/data'; import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui'; import { InlineField, Input, Select, InlineSwitch } from '@grafana/ui'; -import { ElasticsearchOptions, Interval } from '../types'; +import type { ElasticsearchOptions, Interval, QueryType } from '../types'; + +import { QUERY_TYPE_SELECTOR_OPTIONS } from './utils'; const indexPatternTypes: Array> = [ { label: 'No pattern', value: 'none' }, @@ -127,6 +129,29 @@ export const ElasticDetails = ({ value, onChange }: Props) => { onChange={jsonDataSwitchChangeHandler('includeFrozen', value, onChange)} /> + + +
grafanactl + A command-line tool that enables users to authenticate, manage multiple environments, and perform administrative tasks through Grafana's REST API. +
mixin From d83b216a32431b2dda92a8bf4007c3a655e06ef7 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 11 Dec 2025 17:56:40 +0000 Subject: [PATCH 079/139] FS: Fix rendering of public dashboards in MT frontend service (#115162) * pass publicDashboardAccessToken to ST backend via bootdata * slightly cleaner * slightly tidy up go templating * add HandleView middleware --- pkg/api/api.go | 9 +++++++++ pkg/services/frontend/frontend_service.go | 6 ++++++ pkg/services/frontend/index.go | 6 +++++- pkg/services/frontend/index.html | 9 ++++++++- public/app/features/dashboard/routes.ts | 1 + 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 88f432c206c..da1efa5067f 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -187,6 +187,15 @@ func (hs *HTTPServer) registerRoutes() { publicdashboardsapi.CountPublicDashboardRequest(), hs.Index, ) + + r.Get("/bootdata/:accessToken", + reqNoAuth, + hs.PublicDashboardsApi.Middleware.HandleView, + publicdashboardsapi.SetPublicDashboardAccessToken, + publicdashboardsapi.SetPublicDashboardOrgIdOnContext(hs.PublicDashboardsApi.PublicDashboardService), + publicdashboardsapi.CountPublicDashboardRequest(), + hs.GetBootdata, + ) } r.Get("/explore", authorize(ac.EvalPermission(ac.ActionDatasourcesExplore)), hs.Index) diff --git a/pkg/services/frontend/frontend_service.go b/pkg/services/frontend/frontend_service.go index f730bfba376..943509024d3 100644 --- a/pkg/services/frontend/frontend_service.go +++ b/pkg/services/frontend/frontend_service.go @@ -22,6 +22,7 @@ import ( fswebassets "github.com/grafana/grafana/pkg/services/frontend/webassets" "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/licensing" + publicdashboardsapi "github.com/grafana/grafana/pkg/services/publicdashboards/api" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -164,6 +165,11 @@ func (s *frontendService) registerRoutes(m *web.Mux) { // uses cache busting to ensure requests aren't cached. s.routeGet(m, "/-/fe-boot-error", s.handleBootError) + s.routeGet(m, "/public-dashboards/:accessToken", + publicdashboardsapi.SetPublicDashboardAccessToken, + s.index.HandleRequest, + ) + // All other requests return index.html s.routeGet(m, "/*", s.index.HandleRequest) } diff --git a/pkg/services/frontend/index.go b/pkg/services/frontend/index.go index 22d04234a36..e87ca894d20 100644 --- a/pkg/services/frontend/index.go +++ b/pkg/services/frontend/index.go @@ -45,6 +45,8 @@ type IndexViewData struct { // Nonce is a cryptographic identifier for use with Content Security Policy. Nonce string + + PublicDashboardAccessToken string } // Templates setup. @@ -138,9 +140,12 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http. return } + reqCtx := contexthandler.FromContext(ctx) + // TODO -- restructure so the static stuff is under one variable and the rest is dynamic data := p.data // copy everything data.Nonce = nonce + data.PublicDashboardAccessToken = reqCtx.PublicDashboardAccessToken if data.CSPEnabled { data.CSPContent = middleware.ReplacePolicyVariables(p.data.CSPContent, p.data.AppSubUrl, data.Nonce) @@ -150,7 +155,6 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http. writer.Header().Set("Content-Security-Policy-Report-Only", policy) } - reqCtx := contexthandler.FromContext(ctx) p.runIndexDataHooks(reqCtx, &data) writer.Header().Set("Content-Type", "text/html; charset=UTF-8") diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html index b0364fcbac9..198b8216189 100644 --- a/pkg/services/frontend/index.html +++ b/pkg/services/frontend/index.html @@ -188,6 +188,7 @@ // Wrap in an IIFE to avoid polluting the global scope. Intentionally global-scope properties // are explicitly assigned to the `window` object. (() => { + const publicDashboardAccessToken = [[.PublicDashboardAccessToken]] // Grafana can only fail to load once // However, it can fail to load in multiple different places // To avoid double reporting the error, we use this boolean to check if we've already failed @@ -271,9 +272,15 @@ async function fetchBootData() { const queryParams = new URLSearchParams(window.location.search); + let path = '/bootdata'; + // call a special bootdata url with the public access token + // this is needed to set the access token and correct org for public dashboards on the ST backend + if (publicDashboardAccessToken) { + path += `/${publicDashboardAccessToken}`; + } // pass the search params through to the bootdata request // this allows for overriding the theme/language etc - const bootDataUrl = new URL('/bootdata', window.location.origin); + const bootDataUrl = new URL(path, window.location.origin); for (const [key, value] of queryParams.entries()) { bootDataUrl.searchParams.append(key, value); } diff --git a/public/app/features/dashboard/routes.ts b/public/app/features/dashboard/routes.ts index 84e7d514b99..57702d1a35d 100644 --- a/public/app/features/dashboard/routes.ts +++ b/public/app/features/dashboard/routes.ts @@ -24,6 +24,7 @@ export const getPublicDashboardRoutes = (): RouteDescriptor[] => { { path: '/public-dashboards/:accessToken', pageClass: 'page-dashboard', + allowAnonymous: true, routeName: DashboardRoutes.Public, chromeless: true, component: SafeDynamicImport( From 0c264b7a5fa5a68157c60b7139fdafe425a65001 Mon Sep 17 00:00:00 2001 From: Misi Date: Thu, 11 Dec 2025 19:54:48 +0100 Subject: [PATCH 080/139] IAM: Add user search endpoint (#114542) * wip: initial changes, api registration * wip * LegacySearch working with sorting * Revert mapper change for now * Clean up * Cleanup, add integration tests * Improve tests * OpenAPI def regen * Use wildcard search, fix lastSeenAt handling, add lastSeenAtAge * Add missing files * Fix merge * Fixes * Add tests, regen openapi def * Address feedback * Address feedback batch 2 * Chores * regen openapidef * Address feedback * Add tests for paging * gen apis * Revert go.mod, go.sum. go.work.sum * Fix + remove extra tracer parameter --- apps/iam/kinds/manifest.cue | 36 ++ apps/iam/kinds/user.cue | 3 + ...etsearchusers_request_params_object_gen.go | 33 + ...getsearchusers_request_params_types_gen.go | 15 + .../getsearchusers_response_types_gen.go | 37 ++ .../pkg/apis/iam/v0alpha1/user_client_gen.go | 19 + .../pkg/apis/iam/v0alpha1/user_object_gen.go | 31 +- .../pkg/apis/iam/v0alpha1/user_status_gen.go | 33 +- .../pkg/apis/iam/v0alpha1/zz_openapi_gen.go | 230 ++++--- apps/iam/pkg/apis/iam_manifest.go | 176 ++++++ .../rtkq/iam/v0alpha1/endpoints.gen.ts | 31 + pkg/registry/apis/iam/authorizer.go | 4 +- pkg/registry/apis/iam/models.go | 9 +- pkg/registry/apis/iam/register.go | 32 +- pkg/registry/apis/iam/user/legacy_search.go | 180 ++++-- .../apis/iam/user/legacy_search_fake.go | 8 +- .../apis/iam/user/legacy_search_test.go | 44 +- pkg/registry/apis/iam/user/search.go | 399 ++++++++++++ pkg/registry/apis/iam/user/search_test.go | 169 ++++++ pkg/registry/apis/iam/user/store.go | 39 +- pkg/registry/apis/iam/user/validate.go | 4 +- pkg/registry/apis/iam/user/validate_test.go | 12 +- pkg/server/wire_gen.go | 4 +- .../unified/search/builders/document_test.go | 5 +- .../user-with-last-seen-at-and-role-out.json | 16 + .../doc/user-with-last-seen-at-and-role.json | 14 + ...son => user-with-login-and-email-out.json} | 8 +- ...il.json => user-with-login-and-email.json} | 2 +- ...out.json => user-with-login-only-out.json} | 8 +- ...in-only.json => user-with-login-only.json} | 2 +- pkg/storage/unified/search/builders/user.go | 32 +- .../apis/iam/user_search_integration_test.go | 568 ++++++++++++++++++ .../iam.grafana.app-v0alpha1.json | 318 ++++++++-- 33 files changed, 2222 insertions(+), 299 deletions(-) create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/getsearchusers_request_params_object_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/getsearchusers_request_params_types_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/getsearchusers_response_types_gen.go create mode 100644 pkg/registry/apis/iam/user/search.go create mode 100644 pkg/registry/apis/iam/user/search_test.go create mode 100644 pkg/storage/unified/search/builders/testdata/doc/user-with-last-seen-at-and-role-out.json create mode 100644 pkg/storage/unified/search/builders/testdata/doc/user-with-last-seen-at-and-role.json rename pkg/storage/unified/search/builders/testdata/doc/{user-user-with-login-and-email-out.json => user-with-login-and-email-out.json} (55%) rename pkg/storage/unified/search/builders/testdata/doc/{user-user-with-login-and-email.json => user-with-login-and-email.json} (79%) rename pkg/storage/unified/search/builders/testdata/doc/{user-user-with-login-only-out.json => user-with-login-only-out.json} (51%) rename pkg/storage/unified/search/builders/testdata/doc/{user-user-with-login-only.json => user-with-login-only.json} (77%) create mode 100644 pkg/tests/apis/iam/user_search_integration_test.go diff --git a/apps/iam/kinds/manifest.cue b/apps/iam/kinds/manifest.cue index c6f609cbe15..0cb8d534b2f 100644 --- a/apps/iam/kinds/manifest.cue +++ b/apps/iam/kinds/manifest.cue @@ -22,8 +22,32 @@ v0alpha1: { serviceaccountv0alpha1, externalGroupMappingv0alpha1 ] + routes: { namespaced: { + "/searchUsers": { + "GET": { + request: { + query: { + query?: string + limit?: int64 | 10 + offset?: int64 | 0 + page?: int64 | 1 + } + } + response: { + offset: int64 + totalHits: int64 + hits: [...#UserHit] + queryCost: float64 + maxScore: float64 + } + responseMetadata: { + typeMeta: false + objectMeta: false + } + } + } "/searchTeams": { "GET": { request: { @@ -51,3 +75,15 @@ v0alpha1: { } } } + +#UserHit: { + name: string + title: string + login: string + email: string + role: string + lastSeenAt: int64 + lastSeenAtAge: string + provisioned: bool + score: float64 +} diff --git a/apps/iam/kinds/user.cue b/apps/iam/kinds/user.cue index a67c0949e1f..fc5bb8fb153 100644 --- a/apps/iam/kinds/user.cue +++ b/apps/iam/kinds/user.cue @@ -29,6 +29,9 @@ userv0alpha1: userKind & { // } schema: { spec: v0alpha1.UserSpec + status: { + lastSeenAt: int64 | 0 + } } // TODO: Uncomment when the custom routes implementation is done // routes: { diff --git a/apps/iam/pkg/apis/iam/v0alpha1/getsearchusers_request_params_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/getsearchusers_request_params_object_gen.go new file mode 100644 index 00000000000..3a6c04a1fa0 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/getsearchusers_request_params_object_gen.go @@ -0,0 +1,33 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +import ( + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +type GetSearchUsersRequestParamsObject struct { + metav1.TypeMeta `json:",inline"` + GetSearchUsersRequestParams `json:",inline"` +} + +func NewGetSearchUsersRequestParamsObject() *GetSearchUsersRequestParamsObject { + return &GetSearchUsersRequestParamsObject{} +} + +func (o *GetSearchUsersRequestParamsObject) DeepCopyObject() runtime.Object { + dst := NewGetSearchUsersRequestParamsObject() + o.DeepCopyInto(dst) + return dst +} + +func (o *GetSearchUsersRequestParamsObject) DeepCopyInto(dst *GetSearchUsersRequestParamsObject) { + dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion + dst.TypeMeta.Kind = o.TypeMeta.Kind + dstGetSearchUsersRequestParams := GetSearchUsersRequestParams{} + _ = resource.CopyObjectInto(&dstGetSearchUsersRequestParams, &o.GetSearchUsersRequestParams) +} + +var _ runtime.Object = NewGetSearchUsersRequestParamsObject() diff --git a/apps/iam/pkg/apis/iam/v0alpha1/getsearchusers_request_params_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/getsearchusers_request_params_types_gen.go new file mode 100644 index 00000000000..22b73ea80d8 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/getsearchusers_request_params_types_gen.go @@ -0,0 +1,15 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +type GetSearchUsersRequestParams struct { + Query *string `json:"query,omitempty"` + Limit int64 `json:"limit,omitempty"` + Offset int64 `json:"offset,omitempty"` + Page int64 `json:"page,omitempty"` +} + +// NewGetSearchUsersRequestParams creates a new GetSearchUsersRequestParams object. +func NewGetSearchUsersRequestParams() *GetSearchUsersRequestParams { + return &GetSearchUsersRequestParams{} +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/getsearchusers_response_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/getsearchusers_response_types_gen.go new file mode 100644 index 00000000000..d56cbfa3a3c --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/getsearchusers_response_types_gen.go @@ -0,0 +1,37 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type UserHit struct { + Name string `json:"name"` + Title string `json:"title"` + Login string `json:"login"` + Email string `json:"email"` + Role string `json:"role"` + LastSeenAt int64 `json:"lastSeenAt"` + LastSeenAtAge string `json:"lastSeenAtAge"` + Provisioned bool `json:"provisioned"` + Score float64 `json:"score"` +} + +// NewUserHit creates a new UserHit object. +func NewUserHit() *UserHit { + return &UserHit{} +} + +// +k8s:openapi-gen=true +type GetSearchUsers struct { + Offset int64 `json:"offset"` + TotalHits int64 `json:"totalHits"` + Hits []UserHit `json:"hits"` + QueryCost float64 `json:"queryCost"` + MaxScore float64 `json:"maxScore"` +} + +// NewGetSearchUsers creates a new GetSearchUsers object. +func NewGetSearchUsers() *GetSearchUsers { + return &GetSearchUsers{ + Hits: []UserHit{}, + } +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/user_client_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/user_client_gen.go index 665df84327e..bd7af9b3361 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/user_client_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/user_client_gen.go @@ -4,6 +4,7 @@ import ( "context" "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type UserClient struct { @@ -75,6 +76,24 @@ func (c *UserClient) Patch(ctx context.Context, identifier resource.Identifier, return c.client.Patch(ctx, identifier, req, opts) } +func (c *UserClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus UserStatus, opts resource.UpdateOptions) (*User, error) { + return c.client.Update(ctx, &User{ + TypeMeta: metav1.TypeMeta{ + Kind: UserKind().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 *UserClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { return c.client.Delete(ctx, identifier, opts) } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/user_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/user_object_gen.go index bd3bf8fae0e..f4bc19dccbb 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/user_object_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/user_object_gen.go @@ -21,11 +21,14 @@ type User struct { // Spec is the spec of the User Spec UserSpec `json:"spec" yaml:"spec"` + + Status UserStatus `json:"status" yaml:"status"` } func NewUser() *User { return &User{ - Spec: *NewUserSpec(), + Spec: *NewUserSpec(), + Status: *NewUserStatus(), } } @@ -43,11 +46,15 @@ func (o *User) SetSpec(spec any) error { } func (o *User) GetSubresources() map[string]any { - return map[string]any{} + return map[string]any{ + "status": o.Status, + } } func (o *User) GetSubresource(name string) (any, bool) { switch name { + case "status": + return o.Status, true default: return nil, false } @@ -55,6 +62,13 @@ func (o *User) GetSubresource(name string) (any, bool) { func (o *User) SetSubresource(name string, value any) error { switch name { + case "status": + cast, ok := value.(UserStatus) + if !ok { + return fmt.Errorf("cannot set status type %#v, not of type UserStatus", value) + } + o.Status = cast + return nil default: return fmt.Errorf("subresource '%s' does not exist", name) } @@ -226,6 +240,7 @@ func (o *User) DeepCopyInto(dst *User) { 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 @@ -297,3 +312,15 @@ func (s *UserSpec) DeepCopy() *UserSpec { func (s *UserSpec) DeepCopyInto(dst *UserSpec) { resource.CopyObjectInto(dst, s) } + +// DeepCopy creates a full deep copy of UserStatus +func (s *UserStatus) DeepCopy() *UserStatus { + cpy := &UserStatus{} + s.DeepCopyInto(cpy) + return cpy +} + +// DeepCopyInto deep copies UserStatus into another UserStatus object +func (s *UserStatus) DeepCopyInto(dst *UserStatus) { + resource.CopyObjectInto(dst, s) +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/user_status_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/user_status_gen.go index 34a138f59ce..dd0f3e46767 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/user_status_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/user_status_gen.go @@ -2,43 +2,12 @@ package v0alpha1 -// +k8s:openapi-gen=true -type UserstatusOperatorState 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 UserStatusOperatorStateState `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"` -} - -// NewUserstatusOperatorState creates a new UserstatusOperatorState object. -func NewUserstatusOperatorState() *UserstatusOperatorState { - return &UserstatusOperatorState{} -} - // +k8s:openapi-gen=true type UserStatus 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]UserstatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` + LastSeenAt int64 `json:"lastSeenAt"` } // NewUserStatus creates a new UserStatus object. func NewUserStatus() *UserStatus { return &UserStatus{} } - -// +k8s:openapi-gen=true -type UserStatusOperatorStateState string - -const ( - UserStatusOperatorStateStateSuccess UserStatusOperatorStateState = "success" - UserStatusOperatorStateStateInProgress UserStatusOperatorStateState = "in_progress" - UserStatusOperatorStateStateFailed UserStatusOperatorStateState = "failed" -) diff --git a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go index ece208e8d68..87128d27699 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go @@ -21,6 +21,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetGroupsBody": schema_pkg_apis_iam_v0alpha1_GetGroupsBody(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams": schema_pkg_apis_iam_v0alpha1_GetSearchTeams(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeamsBody": schema_pkg_apis_iam_v0alpha1_GetSearchTeamsBody(ref), + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchUsers": schema_pkg_apis_iam_v0alpha1_GetSearchUsers(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRole": schema_pkg_apis_iam_v0alpha1_GlobalRole(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRoleBinding": schema_pkg_apis_iam_v0alpha1_GlobalRoleBinding(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRoleBindingList": schema_pkg_apis_iam_v0alpha1_GlobalRoleBindingList(ref), @@ -72,10 +73,10 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.TeamStatus": schema_pkg_apis_iam_v0alpha1_TeamStatus(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.TeamstatusOperatorState": schema_pkg_apis_iam_v0alpha1_TeamstatusOperatorState(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.User": schema_pkg_apis_iam_v0alpha1_User(ref), + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserHit": schema_pkg_apis_iam_v0alpha1_UserHit(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserList": schema_pkg_apis_iam_v0alpha1_UserList(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserSpec": schema_pkg_apis_iam_v0alpha1_UserSpec(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserStatus": schema_pkg_apis_iam_v0alpha1_UserStatus(ref), - "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserstatusOperatorState": schema_pkg_apis_iam_v0alpha1_UserstatusOperatorState(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1Kinds7RoutesGroupsGETResponseExternalGroupMapping": schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1Kinds7RoutesGroupsGETResponseExternalGroupMapping(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit": schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit(ref), } @@ -688,6 +689,62 @@ func schema_pkg_apis_iam_v0alpha1_GetSearchTeamsBody(ref common.ReferenceCallbac } } +func schema_pkg_apis_iam_v0alpha1_GetSearchUsers(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "offset": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "totalHits": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "hits": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserHit"), + }, + }, + }, + }, + }, + "queryCost": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"number"}, + Format: "double", + }, + }, + "maxScore": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"number"}, + Format: "double", + }, + }, + }, + Required: []string{"offset", "totalHits", "hits", "queryCost", "maxScore"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserHit"}, + } +} + func schema_pkg_apis_iam_v0alpha1_GlobalRole(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -2833,12 +2890,94 @@ func schema_pkg_apis_iam_v0alpha1_User(ref common.ReferenceCallback) common.Open Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserSpec"), }, }, + "status": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserStatus"), + }, + }, }, - Required: []string{"metadata", "spec"}, + Required: []string{"metadata", "spec", "status"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserSpec", "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_iam_v0alpha1_UserHit(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "login": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "email": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "role": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "lastSeenAt": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "lastSeenAtAge": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "provisioned": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "score": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"number"}, + Format: "double", + }, + }, + }, + Required: []string{"name", "title", "login", "email", "role", "lastSeenAt", "lastSeenAtAge", "provisioned", "score"}, + }, + }, } } @@ -2965,90 +3104,15 @@ func schema_pkg_apis_iam_v0alpha1_UserStatus(ref common.ReferenceCallback) commo SchemaProps: spec.SchemaProps{ Type: []string{"object"}, Properties: map[string]spec.Schema{ - "operatorStates": { + "lastSeenAt": { SchemaProps: spec.SchemaProps{ - Description: "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.", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserstatusOperatorState"), - }, - }, - }, - }, - }, - "additionalFields": { - SchemaProps: spec.SchemaProps{ - Description: "additionalFields is reserved for future use", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, + Default: 0, + Type: []string{"integer"}, + Format: "int64", }, }, }, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserstatusOperatorState"}, - } -} - -func schema_pkg_apis_iam_v0alpha1_UserstatusOperatorState(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "lastEvaluation": { - SchemaProps: spec.SchemaProps{ - Description: "lastEvaluation is the ResourceVersion last evaluated", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "state": { - SchemaProps: spec.SchemaProps{ - Description: "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "descriptiveState": { - SchemaProps: spec.SchemaProps{ - Description: "descriptiveState is an optional more descriptive state field which has no requirements on format", - Type: []string{"string"}, - Format: "", - }, - }, - "details": { - SchemaProps: spec.SchemaProps{ - Description: "details contains any extra information that is operator-specific", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - Required: []string{"lastEvaluation", "state"}, + Required: []string{"lastSeenAt"}, }, }, } diff --git a/apps/iam/pkg/apis/iam_manifest.go b/apps/iam/pkg/apis/iam_manifest.go index 0106984f7f2..37552d1bdf3 100644 --- a/apps/iam/pkg/apis/iam_manifest.go +++ b/apps/iam/pkg/apis/iam_manifest.go @@ -261,6 +261,118 @@ var appManifestData = app.ManifestData{ }, }, }, + "/searchUsers": { + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + + OperationId: "getSearchUsers", + + Parameters: []*spec3.Parameter{ + + { + ParameterProps: spec3.ParameterProps{ + Name: "limit", + In: "query", + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{}, + }, + }, + }, + + { + ParameterProps: spec3.ParameterProps{ + Name: "offset", + In: "query", + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{}, + }, + }, + }, + + { + ParameterProps: spec3.ParameterProps{ + Name: "page", + In: "query", + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{}, + }, + }, + }, + + { + ParameterProps: spec3.ParameterProps{ + Name: "query", + In: "query", + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + }, + }, + }, + + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + Default: &spec3.Response{ + ResponseProps: spec3.ResponseProps{ + Description: "Default OK response", + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "hits": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + + Ref: spec.MustCreateRef("#/components/schemas/getSearchUsersUserHit"), + }}, + }, + }, + }, + "maxScore": { + SchemaProps: spec.SchemaProps{ + Type: []string{"number"}, + }, + }, + "offset": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + }, + }, + "queryCost": { + SchemaProps: spec.SchemaProps{ + Type: []string{"number"}, + }, + }, + "totalHits": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + }, + }, + }, + Required: []string{ + "offset", + "totalHits", + "hits", + "queryCost", + "maxScore", + }, + }}, + }}, + }, + }, + }, + }}, + }, + }, + }, }, Cluster: map[string]spec3.PathProps{}, Schemas: map[string]spec.Schema{ @@ -303,6 +415,69 @@ var appManifestData = app.ManifestData{ }, }, }, + "getSearchUsersUserHit": { + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "email": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + "lastSeenAt": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + }, + }, + "lastSeenAtAge": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + "login": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + "provisioned": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + }, + }, + "role": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + "score": { + SchemaProps: spec.SchemaProps{ + Type: []string{"number"}, + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + }, + Required: []string{ + "name", + "title", + "login", + "email", + "role", + "lastSeenAt", + "lastSeenAtAge", + "provisioned", + "score", + }, + }, + }, }, }, }, @@ -342,6 +517,7 @@ var customRouteToGoResponseType = map[string]any{ "v0alpha1|Team|groups|GET": v0alpha1.GetGroups{}, "v0alpha1||/searchTeams|GET": v0alpha1.GetSearchTeams{}, + "v0alpha1||/searchUsers|GET": v0alpha1.GetSearchUsers{}, } // ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. diff --git a/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts index 8b0690f57ba..790db19a6e3 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts @@ -165,6 +165,19 @@ const injectedRtkApi = api }), providesTags: ['Search'], }), + getSearchUsers: build.query({ + query: (queryArg) => ({ + url: `/searchUsers`, + params: { + query: queryArg.query, + limit: queryArg.limit, + page: queryArg.page, + offset: queryArg.offset, + sort: queryArg.sort, + }, + }), + providesTags: ['Search'], + }), listServiceAccount: build.query({ query: (queryArg) => ({ url: `/serviceaccounts`, @@ -896,6 +909,18 @@ export type GetSearchTeamsApiArg = { /** page number to start from */ page?: number; }; +export type GetSearchUsersApiResponse = unknown; +export type GetSearchUsersApiArg = { + query?: string; + /** number of results to return */ + limit?: number; + /** page number (starting from 1) */ + page?: number; + /** number of results to skip */ + offset?: number; + /** sortable field */ + sort?: string; +}; export type ListServiceAccountApiResponse = /** status 200 OK */ ServiceAccountList; export type ListServiceAccountApiArg = { /** 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). */ @@ -2067,6 +2092,9 @@ export type UserSpec = { role: string; title: string; }; +export type UserStatus = { + lastSeenAt: number; +}; export type User = { /** 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; @@ -2075,6 +2103,7 @@ export type User = { metadata: ObjectMeta; /** Spec is the spec of the User */ spec: UserSpec; + status: UserStatus; }; export type UserList = { /** 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 */ @@ -2120,6 +2149,8 @@ export const { useUpdateExternalGroupMappingMutation, useGetSearchTeamsQuery, useLazyGetSearchTeamsQuery, + useGetSearchUsersQuery, + useLazyGetSearchUsersQuery, useListServiceAccountQuery, useLazyListServiceAccountQuery, useCreateServiceAccountMutation, diff --git a/pkg/registry/apis/iam/authorizer.go b/pkg/registry/apis/iam/authorizer.go index a3997638e1e..026254ebab6 100644 --- a/pkg/registry/apis/iam/authorizer.go +++ b/pkg/registry/apis/iam/authorizer.go @@ -22,6 +22,7 @@ type iamAuthorizer struct { func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient authlib.AccessClient) authorizer.Authorizer { resourceAuthorizer := make(map[string]authorizer.Authorizer) + serviceAuthorizer := gfauthorizer.NewServiceAuthorizer() // Authorizer that allows any authenticated user // To be used when authorization is handled at the storage layer allowAuthorizer := authorizer.AuthorizerFunc(func( @@ -50,8 +51,7 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth resourceAuthorizer[iamv0.UserResourceInfo.GetName()] = authorizer resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer - - serviceAuthorizer := gfauthorizer.NewServiceAuthorizer() + resourceAuthorizer["searchUsers"] = serviceAuthorizer resourceAuthorizer["searchTeams"] = serviceAuthorizer return &iamAuthorizer{resourceAuthorizer: resourceAuthorizer} diff --git a/pkg/registry/apis/iam/models.go b/pkg/registry/apis/iam/models.go index b851122ffd9..d47afabee5d 100644 --- a/pkg/registry/apis/iam/models.go +++ b/pkg/registry/apis/iam/models.go @@ -77,10 +77,11 @@ type IdentityAccessManagementAPIBuilder struct { reg prometheus.Registerer logger log.Logger - dual dualwrite.Service - unified resource.ResourceClient - userSearchClient resourcepb.ResourceIndexClient - teamSearch *TeamSearchHandler + dual dualwrite.Service + unified resource.ResourceClient + userSearchClient resourcepb.ResourceIndexClient + userSearchHandler *user.SearchHandler + teamSearch *TeamSearchHandler teamGroupsHandler externalgroupmapping.TeamGroupsHandler diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 85d8bc4e4f1..d6b795f1d2e 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -46,6 +46,7 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/ssosettings" teamservice "github.com/grafana/grafana/pkg/services/team" legacyuser "github.com/grafana/grafana/pkg/services/user" @@ -76,6 +77,7 @@ func RegisterAPIService( teamGroupsHandlerImpl externalgroupmapping.TeamGroupsHandler, dual dualwrite.Service, unified resource.ResourceClient, + orgService org.Service, userService legacyuser.Service, teamService teamservice.Service, ) (*IdentityAccessManagementAPIBuilder, error) { @@ -114,9 +116,11 @@ func RegisterAPIService( dual: dual, unified: unified, userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(), - unified, user.NewUserLegacySearchClient(userService, tracing), features), + unified, user.NewUserLegacySearchClient(orgService, tracing, cfg), features), teamSearch: NewTeamSearchHandler(tracing, dual, team.NewLegacyTeamSearchClient(teamService), unified, features), } + builder.userSearchHandler = user.NewSearchHandler(tracing, builder.userSearchClient, features, cfg) + apiregistration.RegisterAPI(builder) return builder, nil @@ -510,10 +514,18 @@ func (b *IdentityAccessManagementAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenA func (b *IdentityAccessManagementAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes { defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} }) - routes := b.teamSearch.GetAPIRoutes(defs) - routes.Namespace = append(routes.Namespace, b.display.GetAPIRoutes(defs).Namespace...) + searchRoutes := make([]*builder.APIRoutes, 0, 2) + if b.userSearchHandler != nil { + searchRoutes = append(searchRoutes, b.userSearchHandler.GetAPIRoutes(defs)) + } - return routes + if b.teamSearch != nil { + searchRoutes = append(searchRoutes, b.teamSearch.GetAPIRoutes(defs)) + } + + routes := []*builder.APIRoutes{b.display.GetAPIRoutes(defs)} + routes = append(routes, searchRoutes...) + return mergeAPIRoutes(routes...) } func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authorizer { @@ -621,3 +633,15 @@ func NewLocalStore(resourceInfo utils.ResourceInfo, scheme *runtime.Scheme, defa store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) return store, err } + +func mergeAPIRoutes(routes ...*builder.APIRoutes) *builder.APIRoutes { + merged := &builder.APIRoutes{} + for _, r := range routes { + if r == nil { + continue + } + merged.Root = append(merged.Root, r.Root...) + merged.Namespace = append(merged.Namespace, r.Namespace...) + } + return merged +} diff --git a/pkg/registry/apis/iam/user/legacy_search.go b/pkg/registry/apis/iam/user/legacy_search.go index 7fb6c13f7a6..41b54995c38 100644 --- a/pkg/registry/apis/iam/user/legacy_search.go +++ b/pkg/registry/apis/iam/user/legacy_search.go @@ -2,16 +2,22 @@ package user import ( "context" + "encoding/binary" "fmt" "log/slog" "math" + "regexp" + "sort" "go.opentelemetry.io/otel/trace" "google.golang.org/grpc" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/user" - res "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/search/model" + "github.com/grafana/grafana/pkg/services/searchusers/sortopts" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/storage/unified/search/builders" ) @@ -21,28 +27,36 @@ const ( UserResourceGroup = "iam.grafana.com" ) -var _ resourcepb.ResourceIndexClient = (*UserLegacySearchClient)(nil) +var ( + _ resourcepb.ResourceIndexClient = (*UserLegacySearchClient)(nil) + fieldLogin = fmt.Sprintf("%s%s", resource.SEARCH_FIELD_PREFIX, builders.USER_LOGIN) + fieldEmail = fmt.Sprintf("%s%s", resource.SEARCH_FIELD_PREFIX, builders.USER_EMAIL) + fieldLastSeenAt = fmt.Sprintf("%s%s", resource.SEARCH_FIELD_PREFIX, builders.USER_LAST_SEEN_AT) + fieldRole = fmt.Sprintf("%s%s", resource.SEARCH_FIELD_PREFIX, builders.USER_ROLE) + wildcardsMatcher = regexp.MustCompile(`[\*\?\\]`) +) // UserLegacySearchClient is a client for searching for users in the legacy search engine. type UserLegacySearchClient struct { resourcepb.ResourceIndexClient - userService user.Service - log *slog.Logger - tracer trace.Tracer + orgService org.Service + log *slog.Logger + tracer trace.Tracer + cfg *setting.Cfg } // NewUserLegacySearchClient creates a new UserLegacySearchClient. -func NewUserLegacySearchClient(userService user.Service, tracer trace.Tracer) *UserLegacySearchClient { +func NewUserLegacySearchClient(orgService org.Service, tracer trace.Tracer, cfg *setting.Cfg) *UserLegacySearchClient { return &UserLegacySearchClient{ - userService: userService, - log: slog.Default().With("logger", "legacy-user-search-client"), - tracer: tracer, + orgService: orgService, + log: slog.Default().With("logger", "legacy-user-search-client"), + tracer: tracer, + cfg: cfg, } } // Search searches for users in the legacy search engine. // It only supports exact matching for title, login, or email. -// FIXME: This implementation only supports a single field query and will be extended in the future. func (c *UserLegacySearchClient) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest, _ ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { ctx, span := c.tracer.Start(ctx, "user.Search") defer span.End() @@ -52,21 +66,30 @@ func (c *UserLegacySearchClient) Search(ctx context.Context, req *resourcepb.Res return nil, err } - if req.Limit > 100 { - req.Limit = 100 + if req.Limit > maxLimit { + req.Limit = maxLimit } if req.Limit <= 0 { - req.Limit = 1 + req.Limit = 30 } if req.Page > math.MaxInt32 || req.Page < 0 { return nil, fmt.Errorf("invalid page number: %d", req.Page) } - query := &user.SearchUsersQuery{ - SignedInUser: signedInUser, - Limit: int(req.Limit), - Page: int(req.Page), + if req.Page < 1 { + req.Page = 1 + } + + legacySortOptions := convertToSortOptions(req.SortBy) + + query := &org.SearchOrgUsersQuery{ + OrgID: signedInUser.GetOrgID(), + Limit: int(req.Limit), + Page: int(req.Page), + SortOpts: legacySortOptions, + + User: signedInUser, } var title, login, email string @@ -76,19 +99,15 @@ func (c *UserLegacySearchClient) Search(ctx context.Context, req *resourcepb.Res c.log.Warn("only single value fields are supported for legacy search, using first value", "field", field.Key, "values", vals) } switch field.Key { - case res.SEARCH_FIELD_TITLE: + case resource.SEARCH_FIELD_TITLE: title = vals[0] - case "fields.login": + case fieldLogin: login = vals[0] - case "fields.email": + case fieldEmail: email = vals[0] } } - if title == "" && login == "" && email == "" { - return nil, fmt.Errorf("at least one of title, login, or email must be provided for the query") - } - // The user store's Search method combines these into an OR. // For legacy search we can only supply one. if title != "" { @@ -99,20 +118,35 @@ func (c *UserLegacySearchClient) Search(ctx context.Context, req *resourcepb.Res query.Query = email } - columns := getColumns(req.Fields) + // Unified search `query` has wildcards, but legacy search does not support them. + // We have to remove them here to make legacy search work as expected with SQL LIKE queries. + if req.Query != "" { + query.Query = wildcardsMatcher.ReplaceAllString(req.Query, "") + } + + fields := req.Fields + if len(fields) == 0 { + fields = []string{resource.SEARCH_FIELD_TITLE, fieldEmail, fieldLogin, fieldLastSeenAt, fieldRole} + } + + columns := getColumns(fields) list := &resourcepb.ResourceSearchResponse{ Results: &resourcepb.ResourceTable{ Columns: columns, }, } - res, err := c.userService.Search(ctx, query) + res, err := c.orgService.SearchOrgUsers(ctx, query) if err != nil { return nil, err } - for _, u := range res.Users { - cells := createBaseCells(u, req.Fields) + for _, u := range res.OrgUsers { + if c.isHiddenUser(u.Login, signedInUser) { + continue + } + + cells := createCells(u, req.Fields) list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{ Key: getResourceKey(u, req.Options.Key.Namespace), Cells: cells, @@ -123,7 +157,19 @@ func (c *UserLegacySearchClient) Search(ctx context.Context, req *resourcepb.Res return list, nil } -func getResourceKey(item *user.UserSearchHitDTO, namespace string) *resourcepb.ResourceKey { +func (c *UserLegacySearchClient) isHiddenUser(login string, signedInUser identity.Requester) bool { + if login == "" || signedInUser.GetIsGrafanaAdmin() || login == signedInUser.GetUsername() { + return false + } + + if _, hidden := c.cfg.HiddenUsers[login]; hidden { + return true + } + + return false +} + +func getResourceKey(item *org.OrgUserDTO, namespace string) *resourcepb.ResourceKey { return &resourcepb.ResourceKey{ Namespace: namespace, Group: UserResourceGroup, @@ -133,42 +179,74 @@ func getResourceKey(item *user.UserSearchHitDTO, namespace string) *resourcepb.R } func getColumns(fields []string) []*resourcepb.ResourceTableColumnDefinition { - columns := defaultColumns() + cols := make([]*resourcepb.ResourceTableColumnDefinition, 0, len(fields)) + standardSearchFields := resource.StandardSearchFields() for _, field := range fields { switch field { - case "email": - columns = append(columns, builders.UserTableColumnDefinitions[builders.USER_EMAIL]) - case "login": - columns = append(columns, builders.UserTableColumnDefinitions[builders.USER_LOGIN]) + case resource.SEARCH_FIELD_TITLE: + cols = append(cols, standardSearchFields.Field(resource.SEARCH_FIELD_TITLE)) + case fieldLastSeenAt: + cols = append(cols, builders.UserTableColumnDefinitions[builders.USER_LAST_SEEN_AT]) + case fieldRole: + cols = append(cols, builders.UserTableColumnDefinitions[builders.USER_ROLE]) + case fieldEmail: + cols = append(cols, builders.UserTableColumnDefinitions[builders.USER_EMAIL]) + case fieldLogin: + cols = append(cols, builders.UserTableColumnDefinitions[builders.USER_LOGIN]) } } - return columns + return cols } -func createBaseCells(u *user.UserSearchHitDTO, fields []string) [][]byte { - cells := createDefaultCells(u) +func createCells(u *org.OrgUserDTO, fields []string) [][]byte { + cells := make([][]byte, 0, len(fields)) for _, field := range fields { switch field { - case "email": + case resource.SEARCH_FIELD_TITLE: + cells = append(cells, []byte(u.Name)) + case fieldEmail: cells = append(cells, []byte(u.Email)) - case "login": + case fieldLogin: cells = append(cells, []byte(u.Login)) + case fieldLastSeenAt: + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, uint64(u.LastSeenAt.Unix())) + cells = append(cells, b) + case fieldRole: + cells = append(cells, []byte(u.Role)) } } return cells } -func createDefaultCells(u *user.UserSearchHitDTO) [][]byte { - return [][]byte{ - []byte(u.UID), - []byte(u.Name), - } -} +func convertToSortOptions(sortBy []*resourcepb.ResourceSearchRequest_Sort) []model.SortOption { + opts := []model.SortOption{} + for _, s := range sortBy { + field := s.Field + // Handle mapping if necessary + switch field { + case fieldLastSeenAt: + field = "lastSeenAtAge" + case resource.SEARCH_FIELD_TITLE: + field = "name" + case fieldLogin: + field = "login" + case fieldEmail: + field = "email" + } -func defaultColumns() []*resourcepb.ResourceTableColumnDefinition { - searchFields := res.StandardSearchFields() - return []*resourcepb.ResourceTableColumnDefinition{ - searchFields.Field(res.SEARCH_FIELD_NAME), - searchFields.Field(res.SEARCH_FIELD_TITLE), + suffix := "asc" + if s.Desc { + suffix = "desc" + } + key := fmt.Sprintf("%s-%s", field, suffix) + + if opt, ok := sortopts.SortOptionsByQueryParam[key]; ok { + opts = append(opts, opt) + } } + sort.Slice(opts, func(i, j int) bool { + return opts[i].Index < opts[j].Index || (opts[i].Index == opts[j].Index && opts[i].Name < opts[j].Name) + }) + return opts } diff --git a/pkg/registry/apis/iam/user/legacy_search_fake.go b/pkg/registry/apis/iam/user/legacy_search_fake.go index b68e944c84c..e2effd7947e 100644 --- a/pkg/registry/apis/iam/user/legacy_search_fake.go +++ b/pkg/registry/apis/iam/user/legacy_search_fake.go @@ -5,7 +5,7 @@ import ( "google.golang.org/grpc" - "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) @@ -13,7 +13,7 @@ import ( type FakeUserLegacySearchClient struct { resourcepb.ResourceIndexClient SearchFunc func(ctx context.Context, req *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) - Users []*user.UserSearchHitDTO + Users []*org.OrgUserDTO } // Search calls the underlying SearchFunc or simulates a search over the Users slice. @@ -23,7 +23,7 @@ func (c *FakeUserLegacySearchClient) Search(ctx context.Context, req *resourcepb } // Basic filtering for testing purposes - var filteredUsers []*user.UserSearchHitDTO + var filteredUsers []*org.OrgUserDTO var queryValue string for _, field := range req.Options.Fields { @@ -43,7 +43,7 @@ func (c *FakeUserLegacySearchClient) Search(ctx context.Context, req *resourcepb for _, u := range filteredUsers { rows = append(rows, &resourcepb.ResourceTableRow{ Key: getResourceKey(u, req.Options.Key.Namespace), - Cells: createBaseCells(u, req.Fields), + Cells: createCells(u, req.Fields), }) } diff --git a/pkg/registry/apis/iam/user/legacy_search_test.go b/pkg/registry/apis/iam/user/legacy_search_test.go index 6bd49185786..e3dc873fe3c 100644 --- a/pkg/registry/apis/iam/user/legacy_search_test.go +++ b/pkg/registry/apis/iam/user/legacy_search_test.go @@ -9,29 +9,15 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/usertest" + "github.com/grafana/grafana/pkg/setting" res "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) func TestUserLegacySearchClient_Search(t *testing.T) { - t.Run("should return error if no query fields are provided", func(t *testing.T) { - mockUserService := usertest.NewMockService(t) - client := NewUserLegacySearchClient(mockUserService, tracing.NewNoopTracerService()) - ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1}) - req := &resourcepb.ResourceSearchRequest{ - Options: &resourcepb.ListOptions{ - Key: &resourcepb.ResourceKey{Namespace: "default"}, - }, - } - - _, err := client.Search(ctx, req) - - require.Error(t, err) - require.Equal(t, "at least one of title, login, or email must be provided for the query", err.Error()) - }) - testCases := []struct { name string fieldKey string @@ -66,8 +52,8 @@ func TestUserLegacySearchClient_Search(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - mockUserService := usertest.NewMockService(t) - client := NewUserLegacySearchClient(mockUserService, tracing.NewNoopTracerService()) + mockOrgService := orgtest.NewMockService(t) + client := NewUserLegacySearchClient(mockOrgService, tracing.NewNoopTracerService(), &setting.Cfg{}) ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1}) req := &resourcepb.ResourceSearchRequest{ Limit: 10, @@ -81,14 +67,14 @@ func TestUserLegacySearchClient_Search(t *testing.T) { Fields: []string{"email", "login"}, } - mockUsers := []*user.UserSearchHitDTO{ - {ID: 1, UID: "uid1", Name: "Test User 1", Email: "test1@example.com", Login: "testlogin1"}, + mockUsers := []*org.OrgUserDTO{ + {UID: "uid1", Name: "Test User 1", Email: "test1@example.com", Login: "testlogin1"}, } - mockUserService.On("Search", mock.Anything, mock.MatchedBy(func(q *user.SearchUsersQuery) bool { + mockOrgService.On("SearchOrgUsers", mock.Anything, mock.MatchedBy(func(q *org.SearchOrgUsersQuery) bool { return q.Query == tc.expectedQuery && q.Limit == 10 && q.Page == 1 - })).Return(&user.SearchUserQueryResult{ - Users: mockUsers, + })).Return(&org.SearchOrgUsersQueryResult{ + OrgUsers: mockUsers, TotalCount: 1, }, nil) @@ -113,7 +99,7 @@ func TestUserLegacySearchClient_Search(t *testing.T) { require.Equal(t, UserResource, row.Key.Resource) require.Equal(t, u.UID, row.Key.Name) - expectedCells := createBaseCells(&user.UserSearchHitDTO{ + expectedCells := createCells(&org.OrgUserDTO{ UID: u.UID, Name: u.Name, Email: u.Email, @@ -125,8 +111,8 @@ func TestUserLegacySearchClient_Search(t *testing.T) { } t.Run("title should have precedence over login and email", func(t *testing.T) { - mockUserService := usertest.NewMockService(t) - client := NewUserLegacySearchClient(mockUserService, tracing.NewNoopTracerService()) + mockOrgService := orgtest.NewMockService(t) + client := NewUserLegacySearchClient(mockOrgService, tracing.NewNoopTracerService(), &setting.Cfg{}) ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1}) req := &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{ @@ -139,9 +125,9 @@ func TestUserLegacySearchClient_Search(t *testing.T) { }, } - mockUserService.On("Search", mock.Anything, mock.MatchedBy(func(q *user.SearchUsersQuery) bool { + mockOrgService.On("SearchOrgUsers", mock.Anything, mock.MatchedBy(func(q *org.SearchOrgUsersQuery) bool { return q.Query == "title" - })).Return(&user.SearchUserQueryResult{Users: []*user.UserSearchHitDTO{}, TotalCount: 0}, nil) + })).Return(&org.SearchOrgUsersQueryResult{OrgUsers: []*org.OrgUserDTO{}, TotalCount: 0}, nil) _, err := client.Search(ctx, req) require.NoError(t, err) diff --git a/pkg/registry/apis/iam/user/search.go b/pkg/registry/apis/iam/user/search.go new file mode 100644 index 00000000000..d2de838105a --- /dev/null +++ b/pkg/registry/apis/iam/user/search.go @@ -0,0 +1,399 @@ +package user + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "go.opentelemetry.io/otel/trace" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/apiserver/builder" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/storage/unified/search/builders" + "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/errhttp" +) + +const maxLimit = 100 + +type SearchHandler struct { + log *slog.Logger + client resourcepb.ResourceIndexClient + tracer trace.Tracer + features featuremgmt.FeatureToggles + cfg *setting.Cfg +} + +func NewSearchHandler(tracer trace.Tracer, searchClient resourcepb.ResourceIndexClient, features featuremgmt.FeatureToggles, cfg *setting.Cfg) *SearchHandler { + return &SearchHandler{ + client: searchClient, + log: slog.Default().With("logger", "grafana-apiserver.user.search"), + tracer: tracer, + features: features, + cfg: cfg, + } +} + +func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *builder.APIRoutes { + searchResults := defs["github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchUsers"].Schema + return &builder.APIRoutes{ + Namespace: []builder.APIRouteHandler{ + { + Path: "searchUsers", + Spec: &spec3.PathProps{ + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + Description: "User search", + Tags: []string{"Search"}, + OperationId: "getSearchUsers", + Parameters: []*spec3.Parameter{ + { + ParameterProps: spec3.ParameterProps{ + Name: "namespace", + In: "path", + Required: true, + Example: "default", + Description: "workspace", + Schema: spec.StringProperty(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "query", + In: "query", + Required: false, + Schema: spec.StringProperty(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "limit", + In: "query", + Description: "number of results to return", + Example: 30, + Required: false, + Schema: spec.Int64Property(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "page", + In: "query", + Description: "page number (starting from 1)", + Example: 1, + Required: false, + Schema: spec.Int64Property(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "offset", + In: "query", + Description: "number of results to skip", + Example: 0, + Required: false, + Schema: spec.Int64Property(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "sort", + In: "query", + Description: "sortable field", + Example: "", + Examples: map[string]*spec3.Example{ + "": { + ExampleProps: spec3.ExampleProps{ + Summary: "default sorting", + Value: "", + }, + }, + "title": { + ExampleProps: spec3.ExampleProps{ + Summary: "title ascending", + Value: "title", + }, + }, + "-title": { + ExampleProps: spec3.ExampleProps{ + Summary: "title descending", + Value: "-title", + }, + }, + "lastSeenAt": { + ExampleProps: spec3.ExampleProps{ + Summary: "last seen at ascending", + Value: "lastSeenAt", + }, + }, + "-lastSeenAt": { + ExampleProps: spec3.ExampleProps{ + Summary: "last seen at descending", + Value: "-lastSeenAt", + }, + }, + "email": { + ExampleProps: spec3.ExampleProps{ + Summary: "email ascending", + Value: "email", + }, + }, + "-email": { + ExampleProps: spec3.ExampleProps{ + Summary: "email descending", + Value: "-email", + }, + }, + "login": { + ExampleProps: spec3.ExampleProps{ + Summary: "login ascending", + Value: "login", + }, + }, + "-login": { + ExampleProps: spec3.ExampleProps{ + Summary: "login descending", + Value: "-login", + }, + }, + }, + Required: false, + Schema: spec.StringProperty(), + }, + }, + }, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + Default: &spec3.Response{ + ResponseProps: spec3.ResponseProps{ + Description: "Default OK response", + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &searchResults, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Handler: s.DoSearch, + }, + }, + } +} + +func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { + ctx, span := s.tracer.Start(r.Context(), "user.search") + defer span.End() + + queryParams, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + requester, err := identity.GetRequester(ctx) + if err != nil { + errhttp.Write(ctx, fmt.Errorf("no identity found for request: %w", err), w) + return + } + + limit := 30 + offset := 0 + page := 1 + if queryParams.Has("limit") { + limit, _ = strconv.Atoi(queryParams.Get("limit")) + } + if queryParams.Has("offset") { + offset, _ = strconv.Atoi(queryParams.Get("offset")) + if offset > 0 && limit > 0 { + page = (offset / limit) + 1 + } + } else if queryParams.Has("page") { + page, _ = strconv.Atoi(queryParams.Get("page")) + offset = (page - 1) * limit + } + + // Escape characters that are used by bleve wildcard search to be literal strings. + rawQuery := escapeBleveQuery(queryParams.Get("query")) + + searchQuery := fmt.Sprintf(`*%s*`, rawQuery) + + userGvr := iamv0.UserResourceInfo.GroupResource() + request := &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Group: userGvr.Group, + Resource: userGvr.Resource, + Namespace: requester.GetNamespace(), + }, + }, + Query: searchQuery, + Fields: []string{resource.SEARCH_FIELD_TITLE, fieldEmail, fieldLogin, fieldLastSeenAt, fieldRole}, + Limit: int64(limit), + Page: int64(page), + Offset: int64(offset), + } + + if !requester.GetIsGrafanaAdmin() { + // FIXME: Use the new config service instead of the legacy one + hiddenUsers := []string{} + for user := range s.cfg.HiddenUsers { + if user != requester.GetUsername() { + hiddenUsers = append(hiddenUsers, user) + } + } + if len(hiddenUsers) > 0 { + request.Options.Fields = append(request.Options.Fields, &resourcepb.Requirement{ + Key: fieldLogin, + Operator: string(selection.NotIn), + Values: hiddenUsers, + }) + } + } + + if queryParams.Has("sort") { + for _, sort := range queryParams["sort"] { + currField := sort + desc := false + if strings.HasPrefix(sort, "-") { + currField = sort[1:] + desc = true + } + if slices.Contains(builders.UserSortableExtraFields, currField) { + sort = resource.SEARCH_FIELD_PREFIX + currField + } else { + sort = currField + } + s := &resourcepb.ResourceSearchRequest_Sort{ + Field: sort, + Desc: desc, + } + request.SortBy = append(request.SortBy, s) + } + } + + resp, err := s.client.Search(ctx, request) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + result, err := ParseResults(resp) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + s.write(w, result) +} + +func (s *SearchHandler) write(w http.ResponseWriter, obj any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(obj); err != nil { + s.log.Error("failed to encode JSON response", "error", err) + } +} + +func ParseResults(result *resourcepb.ResourceSearchResponse) (*iamv0.GetSearchUsers, error) { + if result == nil { + return iamv0.NewGetSearchUsers(), nil + } else if result.Error != nil { + return iamv0.NewGetSearchUsers(), fmt.Errorf("%d error searching: %s: %s", result.Error.Code, result.Error.Message, result.Error.Details) + } else if result.Results == nil { + return iamv0.NewGetSearchUsers(), nil + } + + titleIDX := -1 + emailIDX := -1 + loginIDX := -1 + lastSeenAtIDX := -1 + roleIDX := -1 + + for i, v := range result.Results.Columns { + switch v.Name { + case resource.SEARCH_FIELD_TITLE: + titleIDX = i + case builders.USER_EMAIL: + emailIDX = i + case builders.USER_LOGIN: + loginIDX = i + case builders.USER_LAST_SEEN_AT: + lastSeenAtIDX = i + case builders.USER_ROLE: + roleIDX = i + } + } + + sr := iamv0.NewGetSearchUsers() + sr.TotalHits = result.TotalHits + sr.QueryCost = result.QueryCost + sr.MaxScore = result.MaxScore + sr.Hits = make([]iamv0.UserHit, 0, len(result.Results.Rows)) + + for _, row := range result.Results.Rows { + if len(row.Cells) != len(result.Results.Columns) { + return iamv0.NewGetSearchUsers(), fmt.Errorf("error parsing user search response: mismatch number of columns and cells") + } + + var login string + if loginIDX >= 0 && row.Cells[loginIDX] != nil { + login = string(row.Cells[loginIDX]) + } + + hit := iamv0.UserHit{ + Name: row.Key.Name, + Login: login, + } + + if titleIDX >= 0 && row.Cells[titleIDX] != nil { + hit.Title = string(row.Cells[titleIDX]) + } + + if emailIDX >= 0 && row.Cells[emailIDX] != nil { + hit.Email = string(row.Cells[emailIDX]) + } + + if roleIDX >= 0 && row.Cells[roleIDX] != nil { + hit.Role = string(row.Cells[roleIDX]) + } + + if lastSeenAtIDX >= 0 && row.Cells[lastSeenAtIDX] != nil { + if len(row.Cells[lastSeenAtIDX]) == 8 { + hit.LastSeenAt = int64(binary.BigEndian.Uint64(row.Cells[lastSeenAtIDX])) + hit.LastSeenAtAge = util.GetAgeString(time.Unix(hit.LastSeenAt, 0)) + } + } + + sr.Hits = append(sr.Hits, hit) + } + + return sr, nil +} + +var bleveEscapeRegex = regexp.MustCompile(`([\\*?])`) + +func escapeBleveQuery(query string) string { + return bleveEscapeRegex.ReplaceAllString(query, `\$1`) +} diff --git a/pkg/registry/apis/iam/user/search_test.go b/pkg/registry/apis/iam/user/search_test.go new file mode 100644 index 00000000000..588ab37da39 --- /dev/null +++ b/pkg/registry/apis/iam/user/search_test.go @@ -0,0 +1,169 @@ +package user + +import ( + "context" + "net/http/httptest" + "testing" + + "google.golang.org/grpc" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" + legacyuser "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" +) + +func TestSearchFallback(t *testing.T) { + tests := []struct { + name string + mode rest.DualWriterMode + expectUnified bool + }{ + {name: "should hit legacy search handler on mode 0", mode: rest.Mode0, expectUnified: false}, + {name: "should hit legacy search handler on mode 1", mode: rest.Mode1, expectUnified: false}, + {name: "should hit legacy search handler on mode 2", mode: rest.Mode2, expectUnified: false}, + {name: "should hit unified storage search handler on mode 3", mode: rest.Mode3, expectUnified: true}, + {name: "should hit unified storage search handler on mode 4", mode: rest.Mode4, expectUnified: true}, + {name: "should hit unified storage search handler on mode 5", mode: rest.Mode5, expectUnified: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &MockClient{} + mockLegacyClient := &MockClient{} + + cfg := &setting.Cfg{ + UnifiedStorage: map[string]setting.UnifiedStorageConfig{ + "users.iam.grafana.app": {DualWriterMode: tt.mode}, + }, + } + dual := dualwrite.ProvideStaticServiceForTests(cfg) + + searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(), mockClient, mockLegacyClient, featuremgmt.WithFeatures()) + searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), searchClient, featuremgmt.WithFeatures(), cfg) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/searchUsers", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &legacyuser.SignedInUser{Namespace: "test"})) + + searchHandler.DoSearch(rr, req) + + if tt.expectUnified { + if mockClient.LastSearchRequest == nil { + t.Fatalf("expected Unified Search to be called, but it was not") + } + } else { + if mockLegacyClient.LastSearchRequest == nil { + t.Fatalf("expected Legacy Search to be called, but it was not") + } + } + }) + } +} + +// MockClient implements the ResourceIndexClient interface for testing +type MockClient struct { + resourcepb.ResourceIndexClient + resource.ResourceIndex + + LastSearchRequest *resourcepb.ResourceSearchRequest + + MockResponses []*resourcepb.ResourceSearchResponse + MockCalls []*resourcepb.ResourceSearchRequest + CallCount int +} + +func (m *MockClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { + m.LastSearchRequest = in + m.MockCalls = append(m.MockCalls, in) + + var response *resourcepb.ResourceSearchResponse + if m.CallCount < len(m.MockResponses) { + response = m.MockResponses[m.CallCount] + } + + m.CallCount = m.CallCount + 1 + + if response == nil { + response = &resourcepb.ResourceSearchResponse{} + } + + return response, nil +} +func (m *MockClient) GetStats(ctx context.Context, in *resourcepb.ResourceStatsRequest, opts ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) { + return nil, nil +} +func (m *MockClient) CountManagedObjects(ctx context.Context, in *resourcepb.CountManagedObjectsRequest, opts ...grpc.CallOption) (*resourcepb.CountManagedObjectsResponse, error) { + return nil, nil +} +func (m *MockClient) Watch(ctx context.Context, in *resourcepb.WatchRequest, opts ...grpc.CallOption) (resourcepb.ResourceStore_WatchClient, error) { + return nil, nil +} +func (m *MockClient) Delete(ctx context.Context, in *resourcepb.DeleteRequest, opts ...grpc.CallOption) (*resourcepb.DeleteResponse, error) { + return nil, nil +} +func (m *MockClient) Create(ctx context.Context, in *resourcepb.CreateRequest, opts ...grpc.CallOption) (*resourcepb.CreateResponse, error) { + return nil, nil +} +func (m *MockClient) Update(ctx context.Context, in *resourcepb.UpdateRequest, opts ...grpc.CallOption) (*resourcepb.UpdateResponse, error) { + return nil, nil +} +func (m *MockClient) Read(ctx context.Context, in *resourcepb.ReadRequest, opts ...grpc.CallOption) (*resourcepb.ReadResponse, error) { + return nil, nil +} +func (m *MockClient) GetBlob(ctx context.Context, in *resourcepb.GetBlobRequest, opts ...grpc.CallOption) (*resourcepb.GetBlobResponse, error) { + return nil, nil +} +func (m *MockClient) PutBlob(ctx context.Context, in *resourcepb.PutBlobRequest, opts ...grpc.CallOption) (*resourcepb.PutBlobResponse, error) { + return nil, nil +} +func (m *MockClient) List(ctx context.Context, in *resourcepb.ListRequest, opts ...grpc.CallOption) (*resourcepb.ListResponse, error) { + return nil, nil +} +func (m *MockClient) ListManagedObjects(ctx context.Context, in *resourcepb.ListManagedObjectsRequest, opts ...grpc.CallOption) (*resourcepb.ListManagedObjectsResponse, error) { + return nil, nil +} +func (m *MockClient) IsHealthy(ctx context.Context, in *resourcepb.HealthCheckRequest, opts ...grpc.CallOption) (*resourcepb.HealthCheckResponse, error) { + return nil, nil +} +func (m *MockClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error) { + return nil, nil +} +func (m *MockClient) UpdateIndex(ctx context.Context, reason string) error { + return nil +} + +func (m *MockClient) GetQuotaUsage(ctx context.Context, req *resourcepb.QuotaUsageRequest, opts ...grpc.CallOption) (*resourcepb.QuotaUsageResponse, error) { + return nil, nil +} + +func TestEscapeBleveQuery(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {input: "normal", expected: "normal"}, + {input: "*", expected: "\\*"}, + {input: "?", expected: "\\?"}, + {input: "\\", expected: "\\\\"}, + {input: "\\*", expected: "\\\\\\*"}, + {input: "*\\?", expected: "\\*\\\\\\?"}, + {input: "foo*bar", expected: "foo\\*bar"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := escapeBleveQuery(tt.input) + if got != tt.expected { + t.Errorf("escapeBleveQuery(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} diff --git a/pkg/registry/apis/iam/user/store.go b/pkg/registry/apis/iam/user/store.go index 97daee3af18..53a146412ea 100644 --- a/pkg/registry/apis/iam/user/store.go +++ b/pkg/registry/apis/iam/user/store.go @@ -3,7 +3,6 @@ package user import ( "context" "fmt" - "time" "go.opentelemetry.io/otel/trace" "k8s.io/apimachinery/pkg/apis/meta/internalversion" @@ -35,7 +34,7 @@ var ( _ rest.TableConvertor = (*LegacyStore)(nil) ) -var resource = iamv0alpha1.UserResourceInfo +var userResource = iamv0alpha1.UserResourceInfo func NewLegacyStore(store legacy.LegacyIdentityStore, ac claims.AccessClient, enableAuthnMutation bool, tracer trace.Tracer) *LegacyStore { return &LegacyStore{store, ac, enableAuthnMutation, tracer} @@ -54,7 +53,7 @@ func (s *LegacyStore) Update(ctx context.Context, name string, objInfo rest.Upda defer span.End() if !s.enableAuthnMutation { - return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "update") + return nil, false, apierrors.NewMethodNotSupported(userResource.GroupResource(), "update") } ns, err := request.NamespaceInfoFrom(ctx, true) @@ -105,7 +104,7 @@ func (s *LegacyStore) Update(ctx context.Context, name string, objInfo rest.Upda // DeleteCollection implements rest.CollectionDeleter. func (s *LegacyStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { - return nil, apierrors.NewMethodNotSupported(resource.GroupResource(), "deletecollection") + return nil, apierrors.NewMethodNotSupported(userResource.GroupResource(), "deletecollection") } // Delete implements rest.GracefulDeleter. @@ -114,7 +113,7 @@ func (s *LegacyStore) Delete(ctx context.Context, name string, deleteValidation defer span.End() if !s.enableAuthnMutation { - return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "delete") + return nil, false, apierrors.NewMethodNotSupported(userResource.GroupResource(), "delete") } ns, err := request.NamespaceInfoFrom(ctx, true) @@ -131,7 +130,7 @@ func (s *LegacyStore) Delete(ctx context.Context, name string, deleteValidation return nil, false, err } if found == nil || len(found.Items) < 1 { - return nil, false, resource.NewNotFound(name) + return nil, false, userResource.NewNotFound(name) } userToDelete := &found.Items[0] @@ -157,7 +156,7 @@ func (s *LegacyStore) Delete(ctx context.Context, name string, deleteValidation } func (s *LegacyStore) New() runtime.Object { - return resource.NewFunc() + return userResource.NewFunc() } func (s *LegacyStore) Destroy() {} @@ -167,15 +166,15 @@ func (s *LegacyStore) NamespaceScoped() bool { } func (s *LegacyStore) GetSingularName() string { - return resource.GetSingularName() + return userResource.GetSingularName() } func (s *LegacyStore) NewList() runtime.Object { - return resource.NewListFunc() + return userResource.NewListFunc() } func (s *LegacyStore) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { - return resource.TableConverter().ConvertToTable(ctx, object, tableOptions) + return userResource.TableConverter().ConvertToTable(ctx, object, tableOptions) } func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { @@ -183,7 +182,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt defer span.End() res, err := common.List( - ctx, resource, s.ac, common.PaginationFromListOptions(options), + ctx, userResource, s.ac, common.PaginationFromListOptions(options), func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[iamv0alpha1.User], error) { found, err := s.store.ListUsers(ctx, ns, legacy.ListUserQuery{ Pagination: p, @@ -231,10 +230,10 @@ func (s *LegacyStore) Get(ctx context.Context, name string, options *metav1.GetO Pagination: common.Pagination{Limit: 1}, }) if found == nil || err != nil { - return nil, resource.NewNotFound(name) + return nil, userResource.NewNotFound(name) } if len(found.Items) < 1 { - return nil, resource.NewNotFound(name) + return nil, userResource.NewNotFound(name) } obj := toUserItem(&found.Items[0], ns.Value) @@ -247,7 +246,7 @@ func (s *LegacyStore) Create(ctx context.Context, obj runtime.Object, createVali defer span.End() if !s.enableAuthnMutation { - return nil, apierrors.NewMethodNotSupported(resource.GroupResource(), "create") + return nil, apierrors.NewMethodNotSupported(userResource.GroupResource(), "create") } ns, err := request.NamespaceInfoFrom(ctx, true) @@ -310,18 +309,12 @@ func toUserItem(u *common.UserWithRole, ns string) iamv0alpha1.User { Provisioned: u.IsProvisioned, Role: u.Role, }, + Status: iamv0alpha1.UserStatus{ + LastSeenAt: u.LastSeenAt.Unix(), + }, } obj, _ := utils.MetaAccessor(item) obj.SetUpdatedTimestamp(&u.Updated) - obj.SetAnnotation(AnnoKeyLastSeenAt, formatTime(&u.LastSeenAt)) obj.SetDeprecatedInternalID(u.ID) // nolint:staticcheck return *item } - -func formatTime(v *time.Time) string { - txt := "" - if v != nil && v.Unix() != 0 { - txt = v.UTC().Format(time.RFC3339) - } - return txt -} diff --git a/pkg/registry/apis/iam/user/validate.go b/pkg/registry/apis/iam/user/validate.go index fb4e0791689..1fa1d28a875 100644 --- a/pkg/registry/apis/iam/user/validate.go +++ b/pkg/registry/apis/iam/user/validate.go @@ -128,7 +128,7 @@ func validateEmail(ctx context.Context, searchClient resourcepb.ResourceIndexCli Operator: string(selection.Equals), Values: []string{email}, }, - }, []string{"name", "email", "login"}) + }, []string{"fields.email", "fields.login"}) resp, err := searchClient.Search(ctx, req) if err != nil { @@ -159,7 +159,7 @@ func validateLogin(ctx context.Context, searchClient resourcepb.ResourceIndexCli Operator: string(selection.Equals), Values: []string{login}, }, - }, []string{"name", "email", "login"}) + }, []string{"fields.email", "fields.login"}) resp, err := searchClient.Search(ctx, req) if err != nil { return err diff --git a/pkg/registry/apis/iam/user/validate_test.go b/pkg/registry/apis/iam/user/validate_test.go index fb558912ab0..17909f2ca7a 100644 --- a/pkg/registry/apis/iam/user/validate_test.go +++ b/pkg/registry/apis/iam/user/validate_test.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/authlib/types" iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -178,7 +178,7 @@ func TestValidateOnCreate(t *testing.T) { IsGrafanaAdmin: false, }, searchClient: &FakeUserLegacySearchClient{ - Users: []*user.UserSearchHitDTO{ + Users: []*org.OrgUserDTO{ {Email: "existing@example"}, }, }, @@ -202,7 +202,7 @@ func TestValidateOnCreate(t *testing.T) { IsGrafanaAdmin: false, }, searchClient: &FakeUserLegacySearchClient{ - Users: []*user.UserSearchHitDTO{ + Users: []*org.OrgUserDTO{ {Login: "existinguser"}, }, }, @@ -490,7 +490,7 @@ func TestValidateOnUpdate(t *testing.T) { IsGrafanaAdmin: true, }, searchClient: &FakeUserLegacySearchClient{ - Users: []*user.UserSearchHitDTO{ + Users: []*org.OrgUserDTO{ {Email: "two@example"}, }, }, @@ -516,7 +516,7 @@ func TestValidateOnUpdate(t *testing.T) { IsGrafanaAdmin: true, }, searchClient: &FakeUserLegacySearchClient{ - Users: []*user.UserSearchHitDTO{ + Users: []*org.OrgUserDTO{ {Name: "other", UID: "uid456", Login: "two"}, }, }, @@ -536,7 +536,7 @@ func TestValidateOnUpdate(t *testing.T) { IsGrafanaAdmin: true, }, searchClient: &FakeUserLegacySearchClient{ - Users: []*user.UserSearchHitDTO{ + Users: []*org.OrgUserDTO{ {Login: "testuser", Email: "test@example"}, }, }, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index f31f8c18948..566393e935f 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -879,7 +879,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST() - identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService, teamService) + identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, orgService, userService, teamService) if err != nil { return nil, err } @@ -1537,7 +1537,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST() - identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService, teamService) + identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, orgService, userService, teamService) if err != nil { return nil, err } diff --git a/pkg/storage/unified/search/builders/document_test.go b/pkg/storage/unified/search/builders/document_test.go index 6c8d02d6cb8..5fcae177696 100644 --- a/pkg/storage/unified/search/builders/document_test.go +++ b/pkg/storage/unified/search/builders/document_test.go @@ -53,8 +53,9 @@ func TestUserDocumentBuilder(t *testing.T) { Group: "iam.grafana.app", Resource: "users", }, []string{ - "user-with-login-and-email", - "user-with-login-only", + "with-login-and-email", + "with-login-only", + "with-last-seen-at-and-role", }) } diff --git a/pkg/storage/unified/search/builders/testdata/doc/user-with-last-seen-at-and-role-out.json b/pkg/storage/unified/search/builders/testdata/doc/user-with-last-seen-at-and-role-out.json new file mode 100644 index 00000000000..84e06fd5b90 --- /dev/null +++ b/pkg/storage/unified/search/builders/testdata/doc/user-with-last-seen-at-and-role-out.json @@ -0,0 +1,16 @@ +{ + "key": { + "namespace": "default", + "group": "iam.grafana.app", + "resource": "users", + "name": "with-last-seen-at-and-role" + }, + "name": "with-last-seen-at-and-role", + "rv": 1234, + "fields": { + "email": "user.three@test.com", + "lastSeenAt": 1698321600, + "login": "user.three", + "role": "Editor" + } +} diff --git a/pkg/storage/unified/search/builders/testdata/doc/user-with-last-seen-at-and-role.json b/pkg/storage/unified/search/builders/testdata/doc/user-with-last-seen-at-and-role.json new file mode 100644 index 00000000000..fdd964240a6 --- /dev/null +++ b/pkg/storage/unified/search/builders/testdata/doc/user-with-last-seen-at-and-role.json @@ -0,0 +1,14 @@ +{ + "metadata": { + "name": "with-last-seen-at-and-role", + "namespace": "default" + }, + "spec": { + "login": "user.three", + "email": "user.three@test.com", + "role": "Editor" + }, + "status": { + "lastSeenAt": 1698321600 + } +} diff --git a/pkg/storage/unified/search/builders/testdata/doc/user-user-with-login-and-email-out.json b/pkg/storage/unified/search/builders/testdata/doc/user-with-login-and-email-out.json similarity index 55% rename from pkg/storage/unified/search/builders/testdata/doc/user-user-with-login-and-email-out.json rename to pkg/storage/unified/search/builders/testdata/doc/user-with-login-and-email-out.json index b7f75247149..04fd081d011 100644 --- a/pkg/storage/unified/search/builders/testdata/doc/user-user-with-login-and-email-out.json +++ b/pkg/storage/unified/search/builders/testdata/doc/user-with-login-and-email-out.json @@ -3,12 +3,14 @@ "namespace": "default", "group": "iam.grafana.app", "resource": "users", - "name": "user-with-login-and-email" + "name": "with-login-and-email" }, - "name": "user-with-login-and-email", + "name": "with-login-and-email", "rv": 1234, "fields": { "email": "user.one@test.com", - "login": "user.one" + "lastSeenAt": 0, + "login": "user.one", + "role": "Viewer" } } \ No newline at end of file diff --git a/pkg/storage/unified/search/builders/testdata/doc/user-user-with-login-and-email.json b/pkg/storage/unified/search/builders/testdata/doc/user-with-login-and-email.json similarity index 79% rename from pkg/storage/unified/search/builders/testdata/doc/user-user-with-login-and-email.json rename to pkg/storage/unified/search/builders/testdata/doc/user-with-login-and-email.json index a7eebbc754a..894bf6e977b 100644 --- a/pkg/storage/unified/search/builders/testdata/doc/user-user-with-login-and-email.json +++ b/pkg/storage/unified/search/builders/testdata/doc/user-with-login-and-email.json @@ -1,6 +1,6 @@ { "metadata": { - "name": "user-with-login-and-email", + "name": "with-login-and-email", "namespace": "default" }, "spec": { diff --git a/pkg/storage/unified/search/builders/testdata/doc/user-user-with-login-only-out.json b/pkg/storage/unified/search/builders/testdata/doc/user-with-login-only-out.json similarity index 51% rename from pkg/storage/unified/search/builders/testdata/doc/user-user-with-login-only-out.json rename to pkg/storage/unified/search/builders/testdata/doc/user-with-login-only-out.json index 7672d4b2a09..3aeaeec020e 100644 --- a/pkg/storage/unified/search/builders/testdata/doc/user-user-with-login-only-out.json +++ b/pkg/storage/unified/search/builders/testdata/doc/user-with-login-only-out.json @@ -3,11 +3,13 @@ "namespace": "default", "group": "iam.grafana.app", "resource": "users", - "name": "user-with-login-only" + "name": "with-login-only" }, - "name": "user-with-login-only", + "name": "with-login-only", "rv": 1234, "fields": { - "login": "user.two" + "lastSeenAt": 0, + "login": "user.two", + "role": "Viewer" } } \ No newline at end of file diff --git a/pkg/storage/unified/search/builders/testdata/doc/user-user-with-login-only.json b/pkg/storage/unified/search/builders/testdata/doc/user-with-login-only.json similarity index 77% rename from pkg/storage/unified/search/builders/testdata/doc/user-user-with-login-only.json rename to pkg/storage/unified/search/builders/testdata/doc/user-with-login-only.json index 6b3adf2dc1d..affbe8a9c5e 100644 --- a/pkg/storage/unified/search/builders/testdata/doc/user-user-with-login-only.json +++ b/pkg/storage/unified/search/builders/testdata/doc/user-with-login-only.json @@ -1,6 +1,6 @@ { "metadata": { - "name": "user-with-login-only", + "name": "with-login-only", "namespace": "default" }, "spec": { diff --git a/pkg/storage/unified/search/builders/user.go b/pkg/storage/unified/search/builders/user.go index b17cdb99b15..c18c9d33cdc 100644 --- a/pkg/storage/unified/search/builders/user.go +++ b/pkg/storage/unified/search/builders/user.go @@ -12,10 +12,20 @@ import ( ) const ( - USER_EMAIL = "email" - USER_LOGIN = "login" + USER_EMAIL = "email" + USER_LOGIN = "login" + USER_LAST_SEEN_AT = "lastSeenAt" + USER_ROLE = "role" ) +// UserSortableExtraFields are the additional fields that can be used for sorting user search results. +// Should not include standard fields like title. +var UserSortableExtraFields = []string{ + USER_EMAIL, + USER_LOGIN, + USER_LAST_SEEN_AT, +} + var UserTableColumnDefinitions = map[string]*resourcepb.ResourceTableColumnDefinition{ USER_EMAIL: { Name: USER_EMAIL, @@ -35,6 +45,22 @@ var UserTableColumnDefinitions = map[string]*resourcepb.ResourceTableColumnDefin Filterable: true, }, }, + USER_LAST_SEEN_AT: { + Name: USER_LAST_SEEN_AT, + Type: resourcepb.ResourceTableColumnDefinition_INT64, + Description: "The last seen timestamp of the user", + Properties: &resourcepb.ResourceTableColumnDefinition_Properties{ + Filterable: true, + }, + }, + USER_ROLE: { + Name: USER_ROLE, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + Description: "The role of the user", + Properties: &resourcepb.ResourceTableColumnDefinition_Properties{ + Filterable: true, + }, + }, } func GetUserBuilder() (resource.DocumentBuilderInfo, error) { @@ -75,6 +101,8 @@ func (u *userDocumentBuilder) BuildDocument(ctx context.Context, key *resourcepb if user.Spec.Login != "" { doc.Fields[USER_LOGIN] = user.Spec.Login } + doc.Fields[USER_LAST_SEEN_AT] = user.Status.LastSeenAt + doc.Fields[USER_ROLE] = user.Spec.Role return doc, nil } diff --git a/pkg/tests/apis/iam/user_search_integration_test.go b/pkg/tests/apis/iam/user_search_integration_test.go new file mode 100644 index 00000000000..1f689b701b0 --- /dev/null +++ b/pkg/tests/apis/iam/user_search_integration_test.go @@ -0,0 +1,568 @@ +package identity + +import ( + "context" + "fmt" + "net/url" + "sort" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "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 TestIntegrationUserSearch(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5} + for _, mode := range modes { + t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "users.iam.grafana.app": { + DualWriterMode: mode, + }, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, + featuremgmt.FlagKubernetesAuthnMutation, + }, + UnifiedStorageEnableSearch: true, + }) + + t.Cleanup(func() { + helper.Shutdown() + }) + + setupUsers(t, helper) + + t.Run("search by title", func(t *testing.T) { + res := searchUsers(t, helper, "Alice") + require.Len(t, res.Hits, 1) + require.Equal(t, "TestUser Alice", res.Hits[0].Title) + }) + + t.Run("search by login", func(t *testing.T) { + res := searchUsers(t, helper, "bob") + require.Len(t, res.Hits, 1) + require.Equal(t, "bob", res.Hits[0].Login) + }) + + t.Run("search by email", func(t *testing.T) { + res := searchUsers(t, helper, "charlie@example.com") + require.Len(t, res.Hits, 1) + require.Equal(t, "charlie@example.com", res.Hits[0].Email) + }) + }) + } +} + +func TestIntegrationUserSearch_WithSorting(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5} + for _, mode := range modes { + t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "users.iam.grafana.app": { + DualWriterMode: mode, + }, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, + featuremgmt.FlagKubernetesAuthnMutation, + }, + UnifiedStorageEnableSearch: true, + }) + + t.Cleanup(func() { + helper.Shutdown() + }) + + setupUsers(t, helper) + + tests := []struct { + field string + extractor func(iamv0.UserHit) string + expected []string + }{ + { + field: "title", + extractor: func(h iamv0.UserHit) string { return h.Title }, + expected: []string{"TestUser Alice", "TestUser Bob", "TestUser Charlie", "TestUser Editor", "TestUser Viewer"}, + }, + { + field: "login", + extractor: func(h iamv0.UserHit) string { return h.Login }, + expected: []string{"alice", "bob", "charlie", "testuser-editor", "testuser-viewer"}, + }, + { + field: "email", + extractor: func(h iamv0.UserHit) string { return h.Email }, + expected: []string{"alice@example.com", "bob@example.com", "charlie@example.com", "testuser-editor@example.com", "testuser-viewer@example.com"}, + }, + } + + for _, tc := range tests { + t.Run("sort by "+tc.field, func(t *testing.T) { + // ASC + res := searchUsersWithSort(t, helper, "TestUser", tc.field) + require.GreaterOrEqual(t, len(res.Hits), 5) + verifyOrder(t, res.Hits, tc.expected, tc.extractor) + + // DESC + res = searchUsersWithSort(t, helper, "TestUser", "-"+tc.field) + require.GreaterOrEqual(t, len(res.Hits), 5) + + // Reverse expected + reversed := make([]string, len(tc.expected)) + copy(reversed, tc.expected) + sort.Sort(sort.Reverse(sort.StringSlice(reversed))) + verifyOrder(t, res.Hits, reversed, tc.extractor) + }) + } + + t.Run("sort by lastSeenAt", func(t *testing.T) { + if mode >= rest.Mode3 { + t.Skip("Skipping lastSeenAt sort test for Mode >= 3: API does not persist status.lastSeenAt") + } + // Populate lastSeenAt + // Alice: 30 minutes ago + // Bob: 1 minute ago + // Charlie: 2 hours ago + // Editor: 40 minutes ago + // Viewer: 1h 30 mins ago + now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + updateLastSeenAt(t, helper, "alice", now.Add(-30*time.Minute), mode) + updateLastSeenAt(t, helper, "bob", now.Add(-1*time.Minute), mode) + updateLastSeenAt(t, helper, "charlie", now.Add(-2*time.Hour), mode) + updateLastSeenAt(t, helper, "testuser-editor", now.Add(-40*time.Minute), mode) + updateLastSeenAt(t, helper, "testuser-viewer", now.Add(-90*time.Minute), mode) + + // lastSeenAt ASC means oldest date first to match legacy behavior + res := searchUsersWithSort(t, helper, "TestUser", "lastSeenAt") + require.GreaterOrEqual(t, len(res.Hits), 5) + verifyOrder(t, res.Hits, []string{"charlie", "testuser-viewer", "testuser-editor", "alice", "bob"}, func(h iamv0.UserHit) string { return h.Login }) + + res = searchUsersWithSort(t, helper, "TestUser", "-lastSeenAt") + require.GreaterOrEqual(t, len(res.Hits), 5) + verifyOrder(t, res.Hits, []string{"bob", "alice", "testuser-editor", "testuser-viewer", "charlie"}, func(h iamv0.UserHit) string { return h.Login }) + }) + }) + } +} + +func TestIntegrationUserSearch_SortCompareLegacy(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + modes := []rest.DualWriterMode{rest.Mode2} + for _, mode := range modes { + t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "users.iam.grafana.app": { + DualWriterMode: mode, + }, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, + featuremgmt.FlagKubernetesAuthnMutation, + }, + UnifiedStorageEnableSearch: true, + }) + + t.Cleanup(func() { + helper.Shutdown() + }) + + setupUsers(t, helper) + + // Populate lastSeenAt for sorting comparison + now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + updateLastSeenAt(t, helper, "alice", now.Add(-30*time.Minute), mode) + updateLastSeenAt(t, helper, "bob", now.Add(-1*time.Minute), mode) + updateLastSeenAt(t, helper, "charlie", now.Add(-2*time.Hour), mode) + updateLastSeenAt(t, helper, "testuser-editor", now.Add(-40*time.Minute), mode) + updateLastSeenAt(t, helper, "testuser-viewer", now.Add(-90*time.Minute), mode) + + fields := []string{"login", "email", "name", "lastSeenAt"} + for _, field := range fields { + for _, order := range []string{"asc", "desc"} { + t.Run(fmt.Sprintf("compare %s %s", field, order), func(t *testing.T) { + // Legacy API uses "name" for Name/Title, "login" for Login, "email" for Email. + // "lastSeenAt" maps to "lastSeenAtAge" in legacy. + legacySort := field + if field == "lastSeenAt" { + legacySort = "lastSeenAtAge" + } + legacySort += "-" + order + + newSort := field + if order == "desc" { + newSort = "-" + field + } + + legacyRes := searchUsersLegacy(t, helper, "TestUser", legacySort) + newRes := searchUsersWithSort(t, helper, "TestUser", newSort) + + require.Equal(t, len(legacyRes), len(newRes.Hits)) + for i := range legacyRes { + require.Equal(t, legacyRes[i].Login, newRes.Hits[i].Login, "Mismatch at index %d for sort %s", i, newSort) + } + }) + } + } + }) + } +} + +func TestIntegrationUserSearch_Paging(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5} + for _, mode := range modes { + t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "users.iam.grafana.app": { + DualWriterMode: mode, + }, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, + featuremgmt.FlagKubernetesAuthnMutation, + }, + UnifiedStorageEnableSearch: true, + }) + + t.Cleanup(func() { + helper.Shutdown() + }) + + setupUsers(t, helper) + + t.Run("paging with page and limit", func(t *testing.T) { + // There are 5 users matching "TestUser" + query := "TestUser" + + // Page 1, Limit 2 + res1 := searchUsersWithPaging(t, helper, query, 1, 2) + require.Equal(t, int64(5), res1.TotalHits) + require.Len(t, res1.Hits, 2) + + // Page 2, Limit 2 + res2 := searchUsersWithPaging(t, helper, query, 2, 2) + require.Equal(t, int64(5), res2.TotalHits) + require.Len(t, res2.Hits, 2) + + // Page 3, Limit 2 + res3 := searchUsersWithPaging(t, helper, query, 3, 2) + require.Equal(t, int64(5), res3.TotalHits) + require.Len(t, res3.Hits, 1) + + seen := make(map[string]bool) + for _, h := range res1.Hits { + seen[h.Login] = true + } + for _, h := range res2.Hits { + require.False(t, seen[h.Login], "User %s seen in page 1 and 2", h.Login) + seen[h.Login] = true + } + for _, h := range res3.Hits { + require.False(t, seen[h.Login], "User %s seen in previous pages", h.Login) + seen[h.Login] = true + } + require.Len(t, seen, 5) + }) + + t.Run("paging with offset and limit", func(t *testing.T) { + // There are 5 users matching "TestUser" + query := "TestUser" + + // Offset 0, Limit 2 (equivalent to Page 1) + res1 := searchUsersWithOffset(t, helper, query, 0, 2) + require.Equal(t, int64(5), res1.TotalHits) + require.Len(t, res1.Hits, 2) + + // Offset 2, Limit 2 (equivalent to Page 2) + res2 := searchUsersWithOffset(t, helper, query, 2, 2) + require.Equal(t, int64(5), res2.TotalHits) + require.Len(t, res2.Hits, 2) + + // Offset 4, Limit 2 (equivalent to Page 3) + res3 := searchUsersWithOffset(t, helper, query, 4, 2) + require.Equal(t, int64(5), res3.TotalHits) + require.Len(t, res3.Hits, 1) + + // Verify uniqueness + seen := make(map[string]bool) + for _, h := range res1.Hits { + seen[h.Login] = true + } + for _, h := range res2.Hits { + require.False(t, seen[h.Login], "User %s seen in offset 0 and 2", h.Login) + seen[h.Login] = true + } + for _, h := range res3.Hits { + require.False(t, seen[h.Login], "User %s seen in previous offsets", h.Login) + seen[h.Login] = true + } + require.Len(t, seen, 5) + }) + }) + } +} + +func setupUsers(t *testing.T, helper *apis.K8sTestHelper) { + ctx := context.Background() + userClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), + GVR: gvrUsers, + }) + + users := []iamv0.User{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser-editor", + }, + Spec: iamv0.UserSpec{ + Title: "TestUser Editor", + Login: "testuser-editor", + Email: "testuser-editor@example.com", + Role: "Editor", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser-viewer", + }, + Spec: iamv0.UserSpec{ + Title: "TestUser Viewer", + Login: "testuser-viewer", + Email: "testuser-viewer@example.com", + Role: "Viewer", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "alice", + }, + Spec: iamv0.UserSpec{ + Title: "TestUser Alice", + Login: "alice", + Email: "alice@example.com", + Role: "Viewer", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "bob", + }, + Spec: iamv0.UserSpec{ + Title: "TestUser Bob", + Login: "bob", + Email: "bob@example.com", + Role: "Viewer", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "charlie", + }, + Spec: iamv0.UserSpec{ + Title: "TestUser Charlie", + Login: "charlie", + Email: "charlie@example.com", + Role: "Viewer", + }, + }, + } + + for _, u := range users { + uMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&u) + require.NoError(t, err) + _, err = userClient.Resource.Create(ctx, &unstructured.Unstructured{Object: uMap}, metav1.CreateOptions{}) + require.NoError(t, err) + } + + // Wait for indexing + time.Sleep(2 * time.Second) +} + +func searchUsers(t *testing.T, helper *apis.K8sTestHelper, query string) *iamv0.GetSearchUsers { + return searchUsersWithSort(t, helper, query, "") +} + +func searchUsersWithSort(t *testing.T, helper *apis.K8sTestHelper, query string, sort string) *iamv0.GetSearchUsers { + q := url.Values{} + q.Set("query", query) + if sort != "" { + q.Set("sort", sort) + } + q.Set("limit", "100") + + path := fmt.Sprintf("/apis/iam.grafana.app/v0alpha1/namespaces/default/searchUsers?%s", q.Encode()) + + res := &iamv0.GetSearchUsers{} + rsp := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "GET", + Path: path, + }, res) + + require.Equal(t, 200, rsp.Response.StatusCode) + return res +} + +func searchUsersWithPaging(t *testing.T, helper *apis.K8sTestHelper, query string, page, limit int) *iamv0.GetSearchUsers { + q := url.Values{} + q.Set("query", query) + q.Set("page", fmt.Sprintf("%d", page)) + q.Set("limit", fmt.Sprintf("%d", limit)) + // Sort by login to ensure deterministic paging + q.Set("sort", "login") + + path := fmt.Sprintf("/apis/iam.grafana.app/v0alpha1/namespaces/default/searchUsers?%s", q.Encode()) + + res := &iamv0.GetSearchUsers{} + rsp := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "GET", + Path: path, + }, res) + + require.Equal(t, 200, rsp.Response.StatusCode) + return res +} + +func searchUsersWithOffset(t *testing.T, helper *apis.K8sTestHelper, query string, offset, limit int) *iamv0.GetSearchUsers { + q := url.Values{} + q.Set("query", query) + q.Set("offset", fmt.Sprintf("%d", offset)) + q.Set("limit", fmt.Sprintf("%d", limit)) + // Sort by login to ensure deterministic paging + q.Set("sort", "login") + + path := fmt.Sprintf("/apis/iam.grafana.app/v0alpha1/namespaces/default/searchUsers?%s", q.Encode()) + + res := &iamv0.GetSearchUsers{} + rsp := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "GET", + Path: path, + }, res) + + require.Equal(t, 200, rsp.Response.StatusCode) + return res +} + +type LegacyUserSearchHit struct { + UserId int64 `json:"userId"` + Name string `json:"name"` + Login string `json:"login"` + Email string `json:"email"` +} + +func searchUsersLegacy(t *testing.T, helper *apis.K8sTestHelper, query string, sort string) []LegacyUserSearchHit { + q := url.Values{} + q.Set("query", query) + + if sort != "" { + q.Set("sort", sort) + } + q.Set("perpage", "100") + q.Set("page", "1") + + path := fmt.Sprintf("/api/org/users/search?%s", q.Encode()) + + var res struct { + OrgUsers []LegacyUserSearchHit `json:"orgUsers"` + } + + rsp := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "GET", + Path: path, + }, &res) + + require.Equal(t, 200, rsp.Response.StatusCode) + return res.OrgUsers +} + +// verifyOrder checks that the extracted values from hits are in the expected order. +// It filters hits to only include those with expected values, because search returns more results than just the test users. +// Like other users in the system that have been created by the test framework. +func verifyOrder(t *testing.T, hits []iamv0.UserHit, expectedValues []string, extractor func(iamv0.UserHit) string) { + // Filter hits to only include expected values + var actualValues []string + expectedSet := make(map[string]bool) + for _, v := range expectedValues { + expectedSet[v] = true + } + + for _, h := range hits { + val := extractor(h) + if expectedSet[val] { + actualValues = append(actualValues, val) + } + } + + require.Equal(t, expectedValues, actualValues) +} + +func updateLastSeenAt(t *testing.T, helper *apis.K8sTestHelper, login string, lastSeen time.Time, mode rest.DualWriterMode) { + if mode < rest.Mode3 { + err := helper.GetEnv().SQLStore.WithDbSession(context.Background(), func(sess *db.Session) error { + _, err := sess.Table("user").Where("login = ?", login).Update(map[string]interface{}{ + "last_seen_at": lastSeen, + }) + return err + }) + require.NoError(t, err) + } + + // Use the new APIs to update the user resource status in Mode3+ + if mode >= rest.Mode3 { + ctx := context.Background() + userClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), + GVR: gvrUsers, + }) + + u, err := userClient.Resource.Get(ctx, login, metav1.GetOptions{}) + require.NoError(t, err) + + err = unstructured.SetNestedField(u.Object, lastSeen.Unix(), "status", "lastSeenAt") + require.NoError(t, err) + + _, err = userClient.Resource.Update(ctx, u, metav1.UpdateOptions{}) + require.NoError(t, err) + } +} diff --git a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json index ea0e4039581..dab9f3cd8b1 100644 --- a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json @@ -1021,6 +1021,156 @@ } } }, + "/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/searchUsers": { + "get": { + "tags": [ + "Search" + ], + "description": "User search", + "operationId": "getSearchUsers", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "workspace", + "required": true, + "schema": { + "type": "string" + }, + "example": "default" + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "number of results to return", + "schema": { + "type": "integer", + "format": "int64" + }, + "example": 30 + }, + { + "name": "page", + "in": "query", + "description": "page number (starting from 1)", + "schema": { + "type": "integer", + "format": "int64" + }, + "example": 1 + }, + { + "name": "offset", + "in": "query", + "description": "number of results to skip", + "schema": { + "type": "integer", + "format": "int64" + }, + "example": 0 + }, + { + "name": "sort", + "in": "query", + "description": "sortable field", + "schema": { + "type": "string" + }, + "examples": { + "": { + "summary": "default sorting" + }, + "-email": { + "summary": "email descending", + "value": "-email" + }, + "-lastSeenAt": { + "summary": "last seen at descending", + "value": "-lastSeenAt" + }, + "-login": { + "summary": "login descending", + "value": "-login" + }, + "-title": { + "summary": "title descending", + "value": "-title" + }, + "email": { + "summary": "email ascending", + "value": "email" + }, + "lastSeenAt": { + "summary": "last seen at ascending", + "value": "lastSeenAt" + }, + "login": { + "summary": "login ascending", + "value": "login" + }, + "title": { + "summary": "title ascending", + "value": "title" + } + } + } + ], + "responses": { + "default": { + "description": "Default OK response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "offset", + "totalHits", + "hits", + "queryCost", + "maxScore" + ], + "properties": { + "hits": { + "type": "array", + "items": { + "default": {} + } + }, + "maxScore": { + "type": "number", + "format": "double", + "default": 0 + }, + "offset": { + "type": "integer", + "format": "int64", + "default": 0 + }, + "queryCost": { + "type": "number", + "format": "double", + "default": 0 + }, + "totalHits": { + "type": "integer", + "format": "int64", + "default": 0 + } + } + } + } + } + } + } + } + }, "/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/serviceaccounts": { "get": { "tags": [ @@ -5614,7 +5764,8 @@ "type": "object", "required": [ "metadata", - "spec" + "spec", + "status" ], "properties": { "apiVersion": { @@ -5641,6 +5792,14 @@ "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.UserSpec" } ] + }, + "status": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.UserStatus" + } + ] } }, "x-kubernetes-group-version-kind": [ @@ -5741,6 +5900,19 @@ } } }, + "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.UserStatus": { + "type": "object", + "required": [ + "lastSeenAt" + ], + "properties": { + "lastSeenAt": { + "type": "integer", + "format": "int64", + "default": 0 + } + } + }, "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.VersionsV0alpha1Kinds7RoutesGroupsGETResponseExternalGroupMapping": { "type": "object", "required": [ @@ -6566,6 +6738,44 @@ } } }, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchUsers": { + "type": "object", + "required": [ + "offset", + "totalHits", + "hits", + "queryCost", + "maxScore" + ], + "properties": { + "hits": { + "type": "array", + "items": { + "default": {} + } + }, + "maxScore": { + "type": "number", + "format": "double", + "default": 0 + }, + "offset": { + "type": "integer", + "format": "int64", + "default": 0 + }, + "queryCost": { + "type": "number", + "format": "double", + "default": 0 + }, + "totalHits": { + "type": "integer", + "format": "int64", + "default": 0 + } + } + }, "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRole": { "type": "object", "required": [ @@ -7760,7 +7970,8 @@ "type": "object", "required": [ "metadata", - "spec" + "spec", + "status" ], "properties": { "apiVersion": { @@ -7777,6 +7988,63 @@ "spec": { "description": "Spec is the spec of the User", "default": {} + }, + "status": { + "default": {} + } + } + }, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserHit": { + "type": "object", + "required": [ + "name", + "title", + "login", + "email", + "role", + "lastSeenAt", + "lastSeenAtAge", + "provisioned", + "score" + ], + "properties": { + "email": { + "type": "string", + "default": "" + }, + "lastSeenAt": { + "type": "integer", + "format": "int64", + "default": 0 + }, + "lastSeenAtAge": { + "type": "string", + "default": "" + }, + "login": { + "type": "string", + "default": "" + }, + "name": { + "type": "string", + "default": "" + }, + "provisioned": { + "type": "boolean", + "default": false + }, + "role": { + "type": "string", + "default": "" + }, + "score": { + "type": "number", + "format": "double", + "default": 0 + }, + "title": { + "type": "string", + "default": "" } } }, @@ -7854,51 +8122,15 @@ } }, "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserStatus": { - "type": "object", - "properties": { - "additionalFields": { - "description": "additionalFields is reserved for future use", - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "operatorStates": { - "description": "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.", - "type": "object", - "additionalProperties": { - "default": {} - } - } - } - }, - "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserstatusOperatorState": { "type": "object", "required": [ - "lastEvaluation", - "state" + "lastSeenAt" ], "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" - } - }, - "lastEvaluation": { - "description": "lastEvaluation is the ResourceVersion last evaluated", - "type": "string", - "default": "" - }, - "state": { - "description": "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", - "type": "string", - "default": "" + "lastSeenAt": { + "type": "integer", + "format": "int64", + "default": 0 } } }, From f5b2dde4a16f1d4891ad3378d65c3a2d129364a4 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Thu, 11 Dec 2025 14:13:33 -0500 Subject: [PATCH 081/139] Suggestions: Add keyboard support (#114517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Suggestions: hashes on suggestions, update logic to select first suggestion * fix types * Suggestions: New UI style updates * update some styles * getting styles just right * remove grouping when not on flag * adjust minimum width for sidebar * CI cleanups * updates from ad hoc review * add loading and error states to suggestions * remove unused import * update header ui for panel editor * restore back button to vizpicker * fix e2e test * fix e2e * add i18n update * use new util for setVisualization operation * Apply suggestions from code review Co-authored-by: Torkel Ödegaard * comments from review * updates from review * Suggestions: Add keyboard support * fix selector for PluginVisualization.item --------- Co-authored-by: Torkel Ödegaard --- eslint-suppressions.json | 5 -- .../src/selectors/components.ts | 1 + .../panelcfg/x/TablePanelCfg_types.gen.ts | 4 ++ .../x/TimeSeriesPanelCfg_types.gen.ts | 1 + .../src/components/Table/TableNG/TableNG.tsx | 5 +- .../src/components/Table/TableNG/types.ts | 2 + .../panel-edit/PanelVizTypePicker.tsx | 60 +++++++++++-------- .../LibraryPanelCard/LibraryPanelCard.tsx | 2 +- .../LibraryPanelsSearch.test.tsx | 4 +- .../VizTypePicker/PanelTypeCard.tsx | 24 +++++--- .../VisualizationSuggestionCard.tsx | 3 +- .../VisualizationSuggestions.tsx | 12 +++- .../VizTypePicker/VizTypePicker.tsx | 6 +- .../VizTypePicker/VizTypePickerPlugin.tsx | 8 +-- .../app/plugins/panel/heatmap/suggestions.ts | 11 +++- public/app/plugins/panel/histogram/module.tsx | 12 +++- .../plugins/panel/status-history/module.tsx | 8 ++- public/app/plugins/panel/table/TablePanel.tsx | 8 ++- public/app/plugins/panel/table/panelcfg.cue | 2 + .../app/plugins/panel/table/panelcfg.gen.ts | 4 ++ public/app/plugins/panel/table/suggestions.ts | 2 + .../panel/timeseries/TimeSeriesPanel.tsx | 2 +- .../app/plugins/panel/timeseries/panelcfg.cue | 1 + .../plugins/panel/timeseries/panelcfg.gen.ts | 1 + .../plugins/panel/timeseries/suggestions.ts | 1 + 25 files changed, 132 insertions(+), 57 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index bc54f1c116e..94a0b4c6b3c 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2882,11 +2882,6 @@ "count": 1 } }, - "public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx": { - "@grafana/no-aria-label-selectors": { - "count": 1 - } - }, "public/app/features/panel/panellinks/linkSuppliers.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index aca18844459..d6e34db61ca 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -1057,6 +1057,7 @@ export const versionedComponents = { }, PluginVisualization: { item: { + '12.4.0': (title: string) => `data-testid Plugin visualization item ${title}`, [MIN_GRAFANA_VERSION]: (title: string) => `Plugin visualization item ${title}`, }, current: { diff --git a/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts index c45151fed2b..62c64d3384a 100644 --- a/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts @@ -17,6 +17,10 @@ export interface Options { * Controls the height of the rows */ cellHeight?: ui.TableCellHeight; + /** + * If true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions) + */ + disableKeyboardEvents?: boolean; /** * Enable pagination on the table */ diff --git a/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts index d0213051004..6cf92ecb15d 100644 --- a/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts @@ -13,6 +13,7 @@ import * as common from '@grafana/schema'; export const pluginVersion = "12.4.0-pre"; export interface Options extends common.OptionsWithTimezones, common.OptionsWithAnnotations { + disableKeyboardEvents?: boolean; legend: common.VizLegendOptions; orientation?: common.VizOrientation; timeCompare?: common.TimeCompareOptions; diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 798ca4e1ae0..0a7c69edbf3 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -105,6 +105,7 @@ export function TableNG(props: TableNGProps) { const { cellHeight, data, + disableKeyboardEvents, disableSanitizeHtml, enablePagination = false, enableSharedCrosshair = false, @@ -819,9 +820,9 @@ export function TableNG(props: TableNGProps) { } }} onCellKeyDown={ - hasNestedFrames + hasNestedFrames || disableKeyboardEvents ? (_, event) => { - if (event.isDefaultPrevented()) { + if (disableKeyboardEvents || event.isDefaultPrevented()) { // skip parent grid keyboard navigation if nested grid handled it event.preventGridDefault(); } diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index b828dfddbb6..3a641de5ac6 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -138,6 +138,8 @@ export interface BaseTableProps { enableVirtualization?: boolean; // for MarkdownCell, this flag disables sanitization of HTML content. Configured via config.ini. disableSanitizeHtml?: boolean; + // if true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions) + disableKeyboardEvents?: boolean; } /* ---------------------------- Table cell props ---------------------------- */ diff --git a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx index cb0d9fbcd3a..86024c3769c 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { debounce } from 'lodash'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useId, useMemo, useState } from 'react'; import { useSessionStorage } from 'react-use'; import { GrafanaTheme2, PanelData } from '@grafana/data'; @@ -8,7 +8,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; import { VizPanel } from '@grafana/scenes'; -import { Button, FilterInput, ScrollContainer, Stack, Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui'; +import { Button, Field, FilterInput, ScrollContainer, Stack, Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui'; import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants'; import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types'; import { VisualizationSuggestions } from 'app/features/panel/components/VizTypePicker/VisualizationSuggestions'; @@ -44,6 +44,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }> export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackButton }: Props) { const styles = useStyles2(getStyles); const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]); + const filterId = useId(); /** SEARCH */ const [searchQuery, setSearchQuery] = useState(''); @@ -101,26 +102,36 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackBut )} {listMode === VisualizationSelectPaneTab.Visualizations && ( - - {showBackButton && ( - - )} - - + + + {showBackButton && ( + + )} + + + + ({ height: '100%', gap: theme.spacing(2), }), - searchRow: css({ - display: 'flex', - marginBottom: theme.spacing(2), + searchField: css({ + marginTop: theme.spacing(0.5), // input glow with the boundary without this }), tabs: css({ width: '100%', diff --git a/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx b/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx index 1faa611d888..ebf2c37ea41 100644 --- a/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx +++ b/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx @@ -39,7 +39,7 @@ const LibraryPanelCardComponent = ({ libraryPanel, onClick, onDelete, showSecond title={libraryPanel.name} description={libraryPanel.description} plugin={panelPlugin} - onClick={() => onClick?.(libraryPanel)} + onSelect={() => onClick?.(libraryPanel)} onDelete={showSecondaryActions ? () => setShowDeletionModal(true) : undefined} > 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 7cb1a99a864..941dca5416d 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx @@ -252,7 +252,7 @@ describe('LibraryPanelsSearch', () => { } ); - const card = () => screen.getByLabelText(/plugin visualization item time series/i); + const card = () => screen.getByTestId(/plugin visualization item time series/i); expect(screen.queryByText(/you haven\'t created any library panels yet/i)).not.toBeInTheDocument(); expect(card()).toBeInTheDocument(); @@ -293,7 +293,7 @@ describe('LibraryPanelsSearch', () => { } ); - const card = () => screen.getByLabelText(/plugin visualization item time series/i); + const card = () => screen.getByTestId(/plugin visualization item time series/i); expect(screen.queryByText(/you haven\'t created any library panels yet/i)).not.toBeInTheDocument(); expect(card()).toBeInTheDocument(); diff --git a/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx b/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx index 2125e645e60..3a5c55d2c1b 100644 --- a/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx +++ b/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx @@ -1,5 +1,4 @@ import { css, cx } from '@emotion/css'; -import { MouseEventHandler } from 'react'; import * as React from 'react'; import Skeleton from 'react-loading-skeleton'; @@ -14,11 +13,12 @@ interface Props { isCurrent: boolean; plugin: PanelPluginMeta; title: string; - onClick: MouseEventHandler; + onSelect: (withModKey?: boolean) => void; onDelete?: () => void; disabled?: boolean; showBadge?: boolean; description?: string; + tabIndex?: number; } const IMAGE_SIZE = 38; @@ -27,12 +27,13 @@ const PanelTypeCardComponent = ({ isCurrent, title, plugin, - onClick, + onSelect, onDelete, disabled, showBadge, description, children, + tabIndex = 0, }: React.PropsWithChildren) => { const styles = useStyles2(getStyles); @@ -44,13 +45,22 @@ const PanelTypeCardComponent = ({ }); return ( - // TODO: fix keyboard a11y - // eslint-disable-next-line jsx-a11y/click-events-have-key-events
onSelect(ev.metaKey || ev.ctrlKey || ev.altKey)} + role="button" + tabIndex={0} + onKeyDown={ + isDisabled + ? undefined + : (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + onSelect(ev.metaKey || ev.ctrlKey || ev.altKey); + } + } + } title={ isCurrent ? t('panel.panel-type-card.title-click-to-close', 'Click again to close this section') : plugin.name } diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx index cf9fafd0b9b..7248dbcc1e5 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx @@ -34,8 +34,9 @@ export function VisualizationSuggestionCard({ className: cx(className, styles.vizBox), 'data-testid': selectors.components.VisualizationPreview.card(suggestion.name), style: outerStyles, + tabIndex: -1, // selection is handled by parent container ...restProps, - }; + } satisfies HTMLAttributes & { 'data-testid': string }; let content: ReactNode; diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index 269f564d833..d7eed4ed2e7 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -147,10 +147,21 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
{ + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + applySuggestion(suggestion, isNewVizSuggestionsEnabled && !isCardSelected); + } + }} ref={index === 0 ? firstCardRef : undefined} > {isCardSelected && (
diff --git a/public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx b/public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx index 8bf12da90c8..f9d3ae7d92c 100644 --- a/public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx +++ b/public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx @@ -41,16 +41,16 @@ export function VizTypePicker({ pluginId, searchQuery, onChange, trackSearch }: return (
- {filteredPluginTypes.map((plugin) => ( + {filteredPluginTypes.map((plugin, idx) => ( + onSelect={(withModKey) => onChange({ pluginId: plugin.id, - withModKey: e.metaKey || e.ctrlKey || e.altKey, + withModKey, }) } /> diff --git a/public/app/features/panel/components/VizTypePicker/VizTypePickerPlugin.tsx b/public/app/features/panel/components/VizTypePicker/VizTypePickerPlugin.tsx index 1ece804df3b..fc277b8f77c 100644 --- a/public/app/features/panel/components/VizTypePicker/VizTypePickerPlugin.tsx +++ b/public/app/features/panel/components/VizTypePicker/VizTypePickerPlugin.tsx @@ -1,5 +1,3 @@ -import { MouseEventHandler } from 'react'; - import { PanelPluginMeta } from '@grafana/data'; import { PanelTypeCard } from './PanelTypeCard'; @@ -7,17 +5,17 @@ import { PanelTypeCard } from './PanelTypeCard'; interface Props { isCurrent: boolean; plugin: PanelPluginMeta; - onClick: MouseEventHandler; + onSelect: (withModKey?: boolean) => void; disabled: boolean; } -export const VizTypePickerPlugin = ({ isCurrent, plugin, onClick, disabled }: Props) => { +export const VizTypePickerPlugin = ({ isCurrent, plugin, onSelect, disabled }: Props) => { return ( { + s.options!.legend = { show: false }; + }, + }, + }, + ]; }; diff --git a/public/app/plugins/panel/histogram/module.tsx b/public/app/plugins/panel/histogram/module.tsx index 8855287cc93..3b3254d784e 100644 --- a/public/app/plugins/panel/histogram/module.tsx +++ b/public/app/plugins/panel/histogram/module.tsx @@ -10,7 +10,7 @@ import { DataFrameType, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { commonOptionsBuilder, getGraphFieldOptions } from '@grafana/ui'; +import { commonOptionsBuilder, getGraphFieldOptions, LegendDisplayMode } from '@grafana/ui'; import { StackingEditor } from '@grafana/ui/internal'; import { HistogramPanel } from './HistogramPanel'; @@ -160,6 +160,16 @@ export const plugin = new PanelPlugin(HistogramPanel) score: ds.hasDataFrameType(DataFrameType.Histogram) ? VisualizationSuggestionScore.Best : VisualizationSuggestionScore.OK, + cardOptions: { + previewModifier: (s) => { + s.options!.legend = { + calcs: [], + displayMode: LegendDisplayMode.Hidden, + placement: 'bottom', + showLegend: false, + }; + }, + }, }, ]; } diff --git a/public/app/plugins/panel/status-history/module.tsx b/public/app/plugins/panel/status-history/module.tsx index eed5e3546a8..f4ea47412ee 100644 --- a/public/app/plugins/panel/status-history/module.tsx +++ b/public/app/plugins/panel/status-history/module.tsx @@ -1,6 +1,6 @@ import { FieldColorModeId, FieldConfigProperty, FieldType, PanelPlugin } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { AxisPlacement, VisibilityMode } from '@grafana/schema'; +import { AxisPlacement, LegendDisplayMode, VisibilityMode } from '@grafana/schema'; import { commonOptionsBuilder } from '@grafana/ui'; import { StatusHistoryPanel } from './StatusHistoryPanel'; @@ -144,6 +144,12 @@ export const plugin = new PanelPlugin(StatusHistoryPanel) { cardOptions: { previewModifier: (s) => { + s.options!.legend = { + displayMode: LegendDisplayMode.Hidden, + placement: 'bottom', + calcs: [], + showLegend: false, + }; s.options!.colWidth = 0.7; }, }, diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx index 5784ff7dafa..2ac469eddb8 100644 --- a/public/app/plugins/panel/table/TablePanel.tsx +++ b/public/app/plugins/panel/table/TablePanel.tsx @@ -89,6 +89,7 @@ export function TablePanel(props: Props) { structureRev={data.structureRev} transparent={transparent} disableSanitizeHtml={disableSanitizeHtml} + disableKeyboardEvents={options.disableKeyboardEvents} /> ); @@ -107,7 +108,12 @@ export function TablePanel(props: Props) {
{tableElement}
- onChangeTableSelection(val, props)} + />
); diff --git a/public/app/plugins/panel/table/panelcfg.cue b/public/app/plugins/panel/table/panelcfg.cue index 234f91342dd..e72b0192b40 100644 --- a/public/app/plugins/panel/table/panelcfg.cue +++ b/public/app/plugins/panel/table/panelcfg.cue @@ -43,6 +43,8 @@ composableKinds: PanelCfg: { frozenColumns?: { left?: number | *0 } + // If true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions) + disableKeyboardEvents?: bool } @cuetsy(kind="interface") FieldConfig: { ui.TableFieldOptions diff --git a/public/app/plugins/panel/table/panelcfg.gen.ts b/public/app/plugins/panel/table/panelcfg.gen.ts index 6f1194bc9d3..64ff0c9476a 100644 --- a/public/app/plugins/panel/table/panelcfg.gen.ts +++ b/public/app/plugins/panel/table/panelcfg.gen.ts @@ -15,6 +15,10 @@ export interface Options { * Controls the height of the rows */ cellHeight?: ui.TableCellHeight; + /** + * If true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions) + */ + disableKeyboardEvents?: boolean; /** * Enable pagination on the table */ diff --git a/public/app/plugins/panel/table/suggestions.ts b/public/app/plugins/panel/table/suggestions.ts index 6616d345388..8e0d6e44953 100644 --- a/public/app/plugins/panel/table/suggestions.ts +++ b/public/app/plugins/panel/table/suggestions.ts @@ -21,6 +21,8 @@ export const tableSuggestionsSupplier: VisualizationSuggestionsSupplier { + s.options!.showHeader = false; + s.options!.disableKeyboardEvents = true; if (s.fieldConfig && s.fieldConfig.defaults.custom) { s.fieldConfig.defaults.custom.minWidth = 50; } diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index dd51ccdac51..eb3b226a248 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -145,7 +145,7 @@ export const TimeSeriesPanel = ({ {(uplotConfig, alignedFrame) => { return ( <> - + {!options.disableKeyboardEvents && } {cursorSync !== DashboardCursorSync.Off && ( )} diff --git a/public/app/plugins/panel/timeseries/panelcfg.cue b/public/app/plugins/panel/timeseries/panelcfg.cue index 32960683c92..ff9784f5f44 100644 --- a/public/app/plugins/panel/timeseries/panelcfg.cue +++ b/public/app/plugins/panel/timeseries/panelcfg.cue @@ -31,6 +31,7 @@ composableKinds: PanelCfg: lineage: { timeCompare?: common.TimeCompareOptions orientation?: common.VizOrientation annotations?: common.VizAnnotations + disableKeyboardEvents?: bool } @cuetsy(kind="interface") FieldConfig: common.GraphFieldConfig & {} @cuetsy(kind="interface") diff --git a/public/app/plugins/panel/timeseries/panelcfg.gen.ts b/public/app/plugins/panel/timeseries/panelcfg.gen.ts index 3d39e9c9bdd..b9dec59e240 100644 --- a/public/app/plugins/panel/timeseries/panelcfg.gen.ts +++ b/public/app/plugins/panel/timeseries/panelcfg.gen.ts @@ -11,6 +11,7 @@ import * as common from '@grafana/schema'; export interface Options extends common.OptionsWithTimezones, common.OptionsWithAnnotations { + disableKeyboardEvents?: boolean; legend: common.VizLegendOptions; orientation?: common.VizOrientation; timeCompare?: common.TimeCompareOptions; diff --git a/public/app/plugins/panel/timeseries/suggestions.ts b/public/app/plugins/panel/timeseries/suggestions.ts index a86c43ae232..b3f6abd74ba 100644 --- a/public/app/plugins/panel/timeseries/suggestions.ts +++ b/public/app/plugins/panel/timeseries/suggestions.ts @@ -45,6 +45,7 @@ const withDefaults = ( }, cardOptions: { previewModifier: (s) => { + s.options!.disableKeyboardEvents = true; if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); } From f8027e4d75064778864e5fa47d723cdf8eb5907f Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Thu, 11 Dec 2025 21:17:23 +0200 Subject: [PATCH 082/139] Dashboard: Implement modal to confirm layout change (#111093) --- .../dashboard-group-panels.spec.ts | 6 ++ .../dashboards-panel-layouts.spec.ts | 23 ++++-- .../dashboards-repeats-auto-grid.spec.ts | 26 ++++--- e2e-playwright/dashboard-new-layouts/utils.ts | 10 +++ .../DashboardLayoutSelector.test.tsx | 72 +++++++++++++++++++ .../DashboardLayoutSelector.tsx | 58 ++++++++++----- public/locales/en-US/grafana.json | 8 +++ 7 files changed, 163 insertions(+), 40 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts index c35eb03bf84..ad954267c85 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts @@ -419,6 +419,9 @@ test.describe( // Select tabs layout await page.getByLabel('layout-selection-option-Tabs').click(); + // confirm layout change + await dashboardPage.getByGrafanaSelector(selectors.pages.ConfirmModal.delete).click(); + await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New row'))).toBeVisible(); await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New row 1'))).toBeVisible(); await expect( @@ -757,6 +760,9 @@ test.describe( // Select rows layout await page.getByLabel('layout-selection-option-Rows').click(); + // confirm layout change + await dashboardPage.getByGrafanaSelector(selectors.pages.ConfirmModal.delete).click(); + await dashboardPage .getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New tab 1')) .scrollIntoViewIfNeeded(); diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts index ffacaa13912..803f9d18d46 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts @@ -4,6 +4,8 @@ import { test, expect, E2ESelectorGroups, DashboardPage } from '@grafana/plugin- import testV2Dashboard from '../dashboards/TestV2Dashboard.json'; +import { switchToAutoGrid } from './utils'; + test.use({ featureToggles: { kubernetesDashboards: true, @@ -33,7 +35,8 @@ test.describe( ).toHaveCount(3); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await page.getByLabel('layout-selection-option-Auto grid').click(); + + await switchToAutoGrid(page, dashboardPage); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) @@ -64,7 +67,8 @@ test.describe( ).toHaveCount(3); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await page.getByLabel('layout-selection-option-Auto grid').click(); + + await switchToAutoGrid(page, dashboardPage); // Get initial positions - standard width should have panels on different rows const firstPanelTop = await getPanelTop(dashboardPage, selectors); @@ -124,7 +128,8 @@ test.describe( ).toHaveCount(3); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await page.getByLabel('layout-selection-option-Auto grid').click(); + + await switchToAutoGrid(page, dashboardPage); await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth) @@ -181,7 +186,8 @@ test.describe( ).toHaveCount(3); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await page.getByLabel('layout-selection-option-Auto grid').click(); + + await switchToAutoGrid(page, dashboardPage); await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns) @@ -216,7 +222,8 @@ test.describe( ).toHaveCount(3); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await page.getByLabel('layout-selection-option-Auto grid').click(); + + await switchToAutoGrid(page, dashboardPage); const regularRowHeight = await getPanelHeight(dashboardPage, selectors); @@ -271,7 +278,8 @@ test.describe( ).toHaveCount(3); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await page.getByLabel('layout-selection-option-Auto grid').click(); + + await switchToAutoGrid(page, dashboardPage); const regularRowHeight = await getPanelHeight(dashboardPage, selectors); @@ -328,7 +336,8 @@ test.describe( ).toHaveCount(3); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await page.getByLabel('layout-selection-option-Auto grid').click(); + + await switchToAutoGrid(page, dashboardPage); // Set narrow column width first to ensure panels fit horizontally await dashboardPage diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-repeats-auto-grid.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-repeats-auto-grid.spec.ts index dbba5c583c1..3e25adc6c41 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-repeats-auto-grid.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-repeats-auto-grid.spec.ts @@ -1,6 +1,6 @@ import { Page } from 'playwright-core'; -import { test, expect } from '@grafana/plugin-e2e'; +import { test, expect, DashboardPage } from '@grafana/plugin-e2e'; import testV2DashWithRepeats from '../dashboards/V2DashWithRepeats.json'; @@ -12,6 +12,7 @@ import { getPanelPosition, importTestDashboard, goToEmbeddedPanel, + switchToAutoGrid, } from './utils'; const repeatTitleBase = 'repeat - '; @@ -42,7 +43,7 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await switchToAutoGrid(page); + await switchToAutoGrid(page, dashboardPage); await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')).first().click(); @@ -78,7 +79,8 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await switchToAutoGrid(page); + await switchToAutoGrid(page, dashboardPage); + await saveDashboard(dashboardPage, page, selectors); await page.reload(); @@ -117,7 +119,7 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await switchToAutoGrid(page); + await switchToAutoGrid(page, dashboardPage); // select first/original repeat panel to activate edit pane await dashboardPage @@ -148,7 +150,7 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await switchToAutoGrid(page); + await switchToAutoGrid(page, dashboardPage); await saveDashboard(dashboardPage, page, selectors); await page.reload(); @@ -214,7 +216,7 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await switchToAutoGrid(page); + await switchToAutoGrid(page, dashboardPage); await saveDashboard(dashboardPage, page, selectors); // loading directly into panel editor @@ -271,7 +273,7 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await switchToAutoGrid(page); + await switchToAutoGrid(page, dashboardPage); // this moving repeated panel between two normal panels await movePanel(dashboardPage, selectors, `${repeatTitleBase}${repeatOptions.at(0)}`, 'New panel'); @@ -319,7 +321,7 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await switchToAutoGrid(page); + await switchToAutoGrid(page, dashboardPage); await saveDashboard(dashboardPage, page, selectors); await page.reload(); @@ -382,7 +384,7 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await switchToAutoGrid(page); + await switchToAutoGrid(page, dashboardPage); await saveDashboard(dashboardPage, page, selectors); await page.reload(); @@ -410,7 +412,7 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click(); - await switchToAutoGrid(page); + await switchToAutoGrid(page, dashboardPage); await saveDashboard(dashboardPage, page, selectors); await page.reload(); @@ -462,7 +464,3 @@ test.describe( }); } ); - -async function switchToAutoGrid(page: Page) { - await page.getByLabel('layout-selection-option-Auto grid').click(); -} diff --git a/e2e-playwright/dashboard-new-layouts/utils.ts b/e2e-playwright/dashboard-new-layouts/utils.ts index 83508063ef5..69851994812 100644 --- a/e2e-playwright/dashboard-new-layouts/utils.ts +++ b/e2e-playwright/dashboard-new-layouts/utils.ts @@ -1,5 +1,6 @@ import { Page } from '@playwright/test'; +import { selectors } from '@grafana/e2e-selectors'; import { DashboardPage, E2ESelectorGroups, expect } from '@grafana/plugin-e2e'; import testV2Dashboard from '../dashboards/TestV2Dashboard.json'; @@ -239,3 +240,12 @@ export async function getTabPosition(dashboardPage: DashboardPage, selectors: E2 const boundingBox = await tab.boundingBox(); return boundingBox; } + +export async function switchToAutoGrid(page: Page, dashboardPage: DashboardPage) { + await page.getByLabel('layout-selection-option-Auto grid').click(); + // confirm layout change if applicable + const confirmModal = dashboardPage.getByGrafanaSelector(selectors.pages.ConfirmModal.delete); + if (confirmModal) { + await confirmModal.click(); + } +} diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx new file mode 100644 index 00000000000..d363a95ddf0 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { getPanelPlugin } from '@grafana/data/test'; +import { setPluginImportUtils } from '@grafana/runtime'; +import { SceneGridLayout, VizPanel, SceneVariableSet } from '@grafana/scenes'; + +import { activateFullSceneTree } from '../../utils/test-utils'; +import { DashboardScene } from '../DashboardScene'; +import { DashboardGridItem } from '../layout-default/DashboardGridItem'; +import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; +import { RowItem } from '../layout-rows/RowItem'; +import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; +import { LayoutParent } from '../types/LayoutParent'; + +import { DashboardLayoutSelector } from './DashboardLayoutSelector'; + +const switchLayoutMock = jest.fn(); + +setPluginImportUtils({ + importPanelPlugin: (_) => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: (_) => undefined, +}); + +describe('DashboardLayoutSelector', () => { + it('should show confirmation modal when switching layouts', async () => { + const user = userEvent.setup(); + const scene = buildTestScene(); + const layoutManager = scene.state.body; + (layoutManager.parent as LayoutParent).switchLayout = switchLayoutMock; + + render(); + + await user.click(screen.getByLabelText('layout-selection-option-Tabs')); + + const confirmButton = screen.getByRole('button', { name: 'Change layout' }); + + expect(confirmButton).toBeInTheDocument(); + + await user.click(confirmButton); + expect(switchLayoutMock).toHaveBeenCalled(); + }); +}); + +const buildTestScene = () => { + const scene = new DashboardScene({ + title: 'testScene', + editable: true, + $variables: new SceneVariableSet({ + variables: [], + }), + body: new RowsLayoutManager({ + rows: [ + new RowItem({ + title: 'Row 1', + layout: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [ + new DashboardGridItem({ + body: new VizPanel({ key: 'panel-1', pluginId: 'text' }), + }), + ], + }), + }), + }), + ], + }), + }); + + activateFullSceneTree(scene); + return scene; +}; diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx index e959352986a..b32555b32f8 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx @@ -1,8 +1,8 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; -import { RadioButtonGroup, Box } from '@grafana/ui'; +import { RadioButtonGroup, Box, ConfirmModal } from '@grafana/ui'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; @@ -20,6 +20,7 @@ export interface Props { export function DashboardLayoutSelector({ layoutManager }: Props) { const isGridLayout = layoutManager.descriptor.isGridLayout; const options = layoutRegistry.list().filter((layout) => layout.isGridLayout === isGridLayout); + const [newLayout, setNewLayout] = useState(); const disableTabs = useMemo(() => { if (config.featureToggles.unlimitedLayoutsNesting) { @@ -36,16 +37,23 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { return false; }, [layoutManager]); - const onChangeLayout = useCallback( - (newLayout: LayoutRegistryItem) => { - const layoutParent = layoutManager.parent; + const onChangeLayout = useCallback((newLayout: LayoutRegistryItem) => setNewLayout(newLayout), []); - if (layoutParent && isLayoutParent(layoutParent)) { - layoutParent.switchLayout(newLayout.createFromLayout(layoutManager)); - } - }, - [layoutManager] - ); + const onConfirmNewLayout = useCallback(() => { + if (!newLayout) { + return; + } + + const layoutParent = layoutManager.parent; + + if (layoutParent && isLayoutParent(layoutParent)) { + layoutParent.switchLayout(newLayout.createFromLayout(layoutManager)); + } + + setNewLayout(undefined); + }, [newLayout, layoutManager]); + + const onDismissNewLayout = useCallback(() => setNewLayout(undefined), []); const disabledOptions: LayoutRegistryItem[] = []; @@ -66,15 +74,27 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { }); return ( - - + + + + - + ); } export function useLayoutCategory(layoutManager: DashboardLayoutManager) { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 761ed8f51ec..90b047262a3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5093,6 +5093,14 @@ "layout": "Layout" }, "continue": "Continue", + "panel": { + "modal": { + "body": "Changing the layout will reset all panel positions and sizes.", + "confirm": "Change layout", + "dismiss": "Cancel", + "title": "Change layout" + } + }, "ungroup-nested-text": "This will ungroup all nested groups.", "ungroup-nested-title": "Ungroup nested groups?" }, From 91a72f2572e81a8489b496855dddcff61a43c696 Mon Sep 17 00:00:00 2001 From: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:21:33 -0600 Subject: [PATCH 083/139] DOCS: Updates to Elasticsearch data source docs (#115021) * created new configure folder, rewrote intro page * updated configure doc * updated query editor * updates to template variables * added troubleshooting doc, fixed heading issues * fix linter issues * added alerting doc * corrected title * final edits * fixed linter issue * added deprecation comment per feedback * ran prettier --- .../datasources/elasticsearch/_index.md | 130 ++---- .../elasticsearch/alerting/index.md | 144 +++++++ .../elasticsearch/annotations/index.md | 124 ++++++ .../configure-elasticsearch-data-source.md | 209 ---------- .../elasticsearch/configure/index.md | 377 ++++++++++++++++++ .../elasticsearch/query-editor/index.md | 73 ++-- .../elasticsearch/template-variables/index.md | 99 +++-- .../elasticsearch/troubleshooting/index.md | 266 ++++++++++++ 8 files changed, 1063 insertions(+), 359 deletions(-) create mode 100644 docs/sources/datasources/elasticsearch/alerting/index.md create mode 100644 docs/sources/datasources/elasticsearch/annotations/index.md delete mode 100644 docs/sources/datasources/elasticsearch/configure-elasticsearch-data-source.md create mode 100644 docs/sources/datasources/elasticsearch/configure/index.md create mode 100644 docs/sources/datasources/elasticsearch/troubleshooting/index.md diff --git a/docs/sources/datasources/elasticsearch/_index.md b/docs/sources/datasources/elasticsearch/_index.md index 1143dbdc68b..ebee19b2dcb 100644 --- a/docs/sources/datasources/elasticsearch/_index.md +++ b/docs/sources/datasources/elasticsearch/_index.md @@ -17,16 +17,6 @@ menuTitle: Elasticsearch title: Elasticsearch data source weight: 325 refs: - configuration: - - pattern: /docs/grafana/ - destination: /docs/grafana//setup-grafana/configure-grafana/#sigv4_auth_enabled - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//setup-grafana/configure-grafana/#sigv4_auth_enabled - provisioning-grafana: - - pattern: /docs/grafana/ - destination: /docs/grafana//administration/provisioning/#data-sources - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//administration/provisioning/#data-sources explore: - pattern: /docs/grafana/ destination: /docs/grafana//explore/ @@ -44,12 +34,36 @@ refs: Elasticsearch is a search and analytics engine used for a variety of use cases. You can create many types of queries to visualize logs or metrics stored in Elasticsearch, and annotate graphs with log events stored in Elasticsearch. -The following will help you get started working with Elasticsearch and Grafana: +The following resources will help you get started with Elasticsearch and Grafana: - [What is Elasticsearch?](https://www.elastic.co/guide/en/elasticsearch/reference/current/elasticsearch-intro.html) -- [Configure the Elasticsearch data source](/docs/grafana/latest/datasources/elasticsearch/configure-elasticsearch-data-source/) -- [Elasticsearch query editor](query-editor/) -- [Elasticsearch template variables](template-variables/) +- [Configure the Elasticsearch data source](https://grafana.com/docs/grafana//datasources/elasticsearch/configure/) +- [Elasticsearch query editor](https://grafana.com/docs/grafana//datasources/elasticsearch/query-editor/) +- [Elasticsearch template variables](https://grafana.com/docs/grafana//datasources/elasticsearch/template-variables/) +- [Elasticsearch annotations](https://grafana.com/docs/grafana//datasources/elasticsearch/annotations/) +- [Elasticsearch alerting](https://grafana.com/docs/grafana//datasources/elasticsearch/alerting/) +- [Troubleshooting issues with the Elasticsearch data source](https://grafana.com/docs/grafana//datasources/elasticsearch/troubleshooting/) + +## Key capabilities + +The Elasticsearch data source supports: + +- **Metrics queries:** Aggregate and visualize numeric data using bucket and metric aggregations. +- **Log queries:** Search, filter, and explore log data with Lucene query syntax. +- **Annotations:** Overlay Elasticsearch events on your dashboard graphs. +- **Alerting:** Create alerts based on Elasticsearch query results. + +## Before you begin + +Before you configure the Elasticsearch data source, you need: + +- An Elasticsearch instance (v7.17+, v8.x, or v9.x) +- Network access from Grafana to your Elasticsearch server +- Appropriate user credentials or API keys with read access + +{{< admonition type="note" >}} +If you use Amazon OpenSearch Service (the successor to Amazon Elasticsearch Service), use the [OpenSearch data source](https://grafana.com/docs/grafana//datasources/opensearch/) instead. +{{< /admonition >}} ## Supported Elasticsearch versions @@ -63,86 +77,18 @@ This data source supports these versions of Elasticsearch: - v8.x - v9.x -Our maintenance policy for Elasticsearch data source is aligned with the [Elastic Product End of Life Dates](https://www.elastic.co/support/eol) and we ensure proper functionality for supported versions. If you are using an Elasticsearch with version that is past its end-of-life (EOL), you can still execute queries, but you will receive a notification in the query builder indicating that the version of Elasticsearch you are using is no longer supported. It's important to note that in such cases, we do not guarantee the correctness of the functionality, and we will not be addressing any related issues. +The Grafana maintenance policy for the Elasticsearch data source aligns with [Elastic Product End of Life Dates](https://www.elastic.co/support/eol). Grafana ensures proper functionality for supported versions only. If you use an EOL version of Elasticsearch, you can still run queries, but the query builder displays a warning. Grafana doesn't guarantee functionality or provide fixes for EOL versions. -## Provision the data source +## Additional resources -You can define and configure the data source in YAML files as part of Grafana's provisioning system. -For more information about provisioning, and for available configuration options, refer to [Provisioning Grafana](ref:provisioning-grafana). +Once you have configured the Elasticsearch data source, you can: -{{< admonition type="note" >}} -The previously used `database` field has now been [deprecated](https://github.com/grafana/grafana/pull/58647). -You should now use the `index` field in `jsonData` to store the index name. -Please see the examples below. -{{< /admonition >}} +- Use [Explore](ref:explore) to run ad-hoc queries against your Elasticsearch data. +- Configure and use [template variables](https://grafana.com/docs/grafana//datasources/elasticsearch/template-variables/) for dynamic dashboards. +- Add [Transformations](https://grafana.com/docs/grafana//panels-visualizations/query-transform-data/transform-data/) to process query results. +- [Build dashboards](ref:build-dashboards) to visualize your Elasticsearch data. -### Provisioning examples +## Related data sources -**Basic provisioning** - -```yaml -apiVersion: 1 - -datasources: - - name: Elastic - type: elasticsearch - access: proxy - url: http://localhost:9200 - jsonData: - index: '[metrics-]YYYY.MM.DD' - interval: Daily - timeField: '@timestamp' -``` - -**Provision for logs** - -```yaml -apiVersion: 1 - -datasources: - - name: elasticsearch-v7-filebeat - type: elasticsearch - access: proxy - url: http://localhost:9200 - jsonData: - index: '[filebeat-]YYYY.MM.DD' - interval: Daily - timeField: '@timestamp' - logMessageField: message - logLevelField: fields.level - dataLinks: - - datasourceUid: my_jaeger_uid # Target UID needs to be known - field: traceID - url: '$${__value.raw}' # Careful about the double "$$" because of env var expansion -``` - -## Configure Amazon Elasticsearch Service - -If you use Amazon Elasticsearch Service, you can use Grafana's Elasticsearch data source to visualize data from it. - -If you use an AWS Identity and Access Management (IAM) policy to control access to your Amazon Elasticsearch Service domain, you must use AWS Signature Version 4 (AWS SigV4) to sign all requests to that domain. - -For details on AWS SigV4, refer to the [AWS documentation](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html). - -### AWS Signature Version 4 authentication - -To sign requests to your Amazon Elasticsearch Service domain, you can enable SigV4 in Grafana's [configuration](ref:configuration). - -Once AWS SigV4 is enabled, you can configure it on the Elasticsearch data source configuration page. -For more information about AWS authentication options, refer to [AWS authentication](../aws-cloudwatch/aws-authentication/). - -{{< figure src="/static/img/docs/v73/elasticsearch-sigv4-config-editor.png" max-width="500px" class="docs-image--no-shadow" caption="SigV4 configuration for AWS Elasticsearch Service" >}} - -## Query the data source - -You can select multiple metrics and group by multiple terms or filters when using the Elasticsearch query editor. - -For details, see the [query editor documentation](query-editor/). - -## Use template variables - -Instead of hard-coding details such as server, application, and sensor names in metric queries, you can use variables. -Grafana lists these variables in dropdown select boxes at the top of the dashboard to help you change the data displayed in your dashboard. -Grafana refers to such variables as template variables. - -For details, see the [template variables documentation](template-variables/). +- [OpenSearch](https://grafana.com/docs/grafana//datasources/opensearch/) - For Amazon OpenSearch Service. +- [Loki](https://grafana.com/docs/grafana//datasources/loki/) - Grafana's log aggregation system. diff --git a/docs/sources/datasources/elasticsearch/alerting/index.md b/docs/sources/datasources/elasticsearch/alerting/index.md new file mode 100644 index 00000000000..ef002764bda --- /dev/null +++ b/docs/sources/datasources/elasticsearch/alerting/index.md @@ -0,0 +1,144 @@ +--- +aliases: + - ../../data-sources/elasticsearch/alerting/ +description: Using Grafana Alerting with the Elasticsearch data source +keywords: + - grafana + - elasticsearch + - alerting + - alerts +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Alerting +title: Elasticsearch alerting +weight: 550 +refs: + alerting: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/ + create-alert-rule: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/alerting-rules/create-grafana-managed-rule/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-grafana-managed-rule/ +--- + +# Elasticsearch alerting + +You can use Grafana Alerting with Elasticsearch to create alerts based on your Elasticsearch data. This allows you to monitor metrics, detect anomalies, and receive notifications when specific conditions are met. + +For general information about Grafana Alerting, refer to [Grafana Alerting](ref:alerting). + +## Before you begin + +Before creating alerts with Elasticsearch, ensure you have: + +- An Elasticsearch data source configured in Grafana +- Appropriate permissions to create alert rules +- Understanding of the metrics you want to monitor + +## Supported query types + +Elasticsearch alerting works best with **metrics queries** that return time series data. To create a valid alert query: + +- Use a **Date histogram** as the last bucket aggregation (under **Group by**) +- Select appropriate metric aggregations (Count, Average, Sum, Min, Max, etc.) + +Queries that return time series data allow Grafana to evaluate values over time and trigger alerts when thresholds are crossed. + +### Query types and alerting compatibility + +| Query type | Alerting support | Notes | +| ------------------------------ | ---------------- | ----------------------------------------------------------- | +| Metrics with Date histogram | ✅ Full support | Recommended for alerting | +| Metrics without Date histogram | ⚠️ Limited | May not evaluate correctly over time | +| Logs | ❌ Not supported | Use metrics queries instead | +| Raw data | ❌ Not supported | Use metrics queries instead | +| Raw document (deprecated) | ❌ Not supported | Deprecated since Grafana v10.1. Use metrics queries instead | + +## Create an alert rule + +To create an alert rule using Elasticsearch: + +1. Navigate to **Alerting** > **Alert rules**. +1. Click **New alert rule**. +1. Enter a name for the alert rule. +1. Select your **Elasticsearch** data source. +1. Build your query using the query editor: + - Add metric aggregations (for example, Average, Count, Sum) + - Add a Date histogram under **Group by** + - Optionally add filters using Lucene query syntax +1. Configure the alert condition (for example, when the average is above a threshold). +1. Set the evaluation interval and pending period. +1. Configure notifications and labels. +1. Click **Save rule**. + +For detailed instructions, refer to [Create a Grafana-managed alert rule](ref:create-alert-rule). + +## Example alert queries + +The following examples show common alerting scenarios with Elasticsearch. + +### Alert on high error count + +Monitor the number of error-level log entries: + +1. **Query:** `level:error` +1. **Metric:** Count +1. **Group by:** Date histogram (interval: 1m) +1. **Condition:** When count is above 100 + +### Alert on average response time + +Monitor API response times: + +1. **Query:** `type:api_request` +1. **Metric:** Average on field `response_time` +1. **Group by:** Date histogram (interval: 5m) +1. **Condition:** When average is above 500 (milliseconds) + +### Alert on unique user count drop + +Detect drops in active users: + +1. **Query:** `*` (all documents) +1. **Metric:** Unique count on field `user_id` +1. **Group by:** Date histogram (interval: 1h) +1. **Condition:** When unique count is below 100 + +## Limitations + +When using Elasticsearch with Grafana Alerting, be aware of the following limitations: + +### Template variables not supported + +Alert queries cannot contain template variables. Grafana evaluates alert rules on the backend without dashboard context, so variables like `$hostname` or `$environment` won't be resolved. + +If your dashboard query uses template variables, create a separate query for alerting with hard coded values. + +### Logs queries not supported + +Queries using the **Logs** metric type cannot be used for alerting. Convert your query to use metric aggregations with a Date histogram instead. + +### Query complexity + +Complex queries with many nested aggregations may timeout or fail to evaluate. Simplify queries for alerting by: + +- Reducing the number of bucket aggregations +- Using appropriate time intervals +- Adding filters to limit the data scanned + +## Best practices + +Follow these best practices when creating Elasticsearch alerts: + +- **Use specific filters:** Add Lucene query filters to focus on relevant data and improve query performance. +- **Choose appropriate intervals:** Match the Date histogram interval to your evaluation frequency. +- **Test queries first:** Verify your query returns expected results in Explore before creating an alert. +- **Set realistic thresholds:** Base alert thresholds on historical data patterns. +- **Use meaningful names:** Give alert rules descriptive names that indicate what they monitor. diff --git a/docs/sources/datasources/elasticsearch/annotations/index.md b/docs/sources/datasources/elasticsearch/annotations/index.md new file mode 100644 index 00000000000..788cfe15ff6 --- /dev/null +++ b/docs/sources/datasources/elasticsearch/annotations/index.md @@ -0,0 +1,124 @@ +--- +aliases: + - ../../data-sources/elasticsearch/annotations/ +description: Using annotations with Elasticsearch in Grafana +keywords: + - grafana + - elasticsearch + - annotations + - events +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Annotations +title: Elasticsearch annotations +weight: 500 +refs: + annotate-visualizations: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/build-dashboards/annotate-visualizations/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//dashboards/build-dashboards/annotate-visualizations/ +--- + +# Elasticsearch annotations + +Annotations overlay event data on your dashboard graphs, helping you correlate log events with metrics. +You can use Elasticsearch as a data source for annotations to display events such as deployments, alerts, or other significant occurrences on your visualizations. + +For general information about annotations, refer to [Annotate visualizations](ref:annotate-visualizations). + +## Before you begin + +Before creating Elasticsearch annotations, ensure you have: + +- An Elasticsearch data source configured in Grafana +- Documents in Elasticsearch containing event data with timestamp fields +- Read access to the Elasticsearch index containing your events + +## Create an annotation query + +To add an Elasticsearch annotation to your dashboard: + +1. Navigate to your dashboard and click **Dashboard settings** (gear icon). +1. Select **Annotations** in the left menu. +1. Click **Add annotation query**. +1. Enter a **Name** for the annotation. +1. Select your **Elasticsearch** data source from the **Data source** drop-down. +1. Configure the annotation query and field mappings. +1. Click **Save dashboard**. + +## Query + +Use the query field to filter which Elasticsearch documents appear as annotations. The query uses [Lucene query syntax](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax). + +**Examples:** + +| Query | Description | +| ---------------------------------------- | ---------------------------------------------------- | +| `*` | Matches all documents. | +| `type:deployment` | Shows only deployment events. | +| `level:error OR level:critical` | Shows error and critical events. | +| `service:api AND environment:production` | Shows events for a specific service and environment. | +| `tags:release` | Shows events tagged as releases. | + +You can use template variables in your annotation queries. For example, `service:$service` filters annotations based on the selected service variable. + +## Field mappings + +Field mappings tell Grafana which Elasticsearch fields contain the annotation data. + +### Time + +The **Time** field specifies which field contains the annotation timestamp. + +- **Default:** `@timestamp` +- **Format:** The field must contain a date value that Elasticsearch recognizes. + +### Time End + +The **Time End** field specifies a field containing the end time for range annotations. Range annotations display as a shaded region on the graph instead of a single vertical line. + +- **Default:** Empty (single-point annotations) +- **Use case:** Display maintenance windows, incidents, or any event with a duration. + +### Text + +The **Text** field specifies which field contains the annotation description displayed when you hover over the annotation. + +- **Default:** `tags` +- **Tip:** Use a descriptive field like `message`, `description`, or `summary`. + +### Tags + +The **Tags** field specifies which field contains tags for the annotation. Tags help categorize and filter annotations. + +- **Default:** Empty +- **Format:** The field can contain either a comma-separated string or an array of strings. + +## Example: Deployment annotations + +To display deployment events as annotations: + +1. Create an annotation query with the following settings: + - **Query:** `type:deployment` + - **Time:** `@timestamp` + - **Text:** `message` + - **Tags:** `environment` + +This configuration displays deployment events with their messages as the annotation text and environments as tags. + +## Example: Range annotations for incidents + +To display incidents with duration: + +1. Create an annotation query with the following settings: + - **Query:** `type:incident` + - **Time:** `start_time` + - **Time End:** `end_time` + - **Text:** `description` + - **Tags:** `severity` + +This configuration displays incidents as shaded regions from their start time to end time. diff --git a/docs/sources/datasources/elasticsearch/configure-elasticsearch-data-source.md b/docs/sources/datasources/elasticsearch/configure-elasticsearch-data-source.md deleted file mode 100644 index d43e0ec2978..00000000000 --- a/docs/sources/datasources/elasticsearch/configure-elasticsearch-data-source.md +++ /dev/null @@ -1,209 +0,0 @@ ---- -aliases: - - ../data-sources/elasticsearch/ - - ../features/datasources/elasticsearch/ -description: Guide for configuring the Elasticsearch data source in Grafana -keywords: - - grafana - - elasticsearch - - guide - - data source -labels: - products: - - cloud - - enterprise - - oss -menuTitle: Configure Elasticsearch -title: Configure the Elasticsearch data source -weight: 200 -refs: - administration-documentation: - - pattern: /docs/grafana/ - destination: /docs/grafana//administration/data-source-management/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//administration/data-source-management/ - supported-expressions: - - pattern: /docs/grafana/ - destination: /docs/grafana//explore/logs-integration/#log-level - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//explore/logs-integration/#log-level - query-and-transform-data: - - pattern: /docs/grafana/ - destination: /docs/grafana//panels-visualizations/query-transform-data/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/ - provisioning-data-source: - - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/elasticsearch/#provision-the-data-source - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/elasticsearch/#provision-the-data-source ---- - -# Configure the Elasticsearch data source - -Grafana ships with built-in support for Elasticsearch. -You can create a variety of queries to visualize logs or metrics stored in Elasticsearch, and annotate graphs with log events stored in Elasticsearch. - -For instructions on how to add a data source to Grafana, refer to the [administration documentation](ref:administration-documentation). - -Only users with the organization `administrator` role can add data sources. -Administrators can also [configure the data source via YAML](ref:provisioning-data-source) with Grafana's provisioning system. - -## Configuring permissions - -When Elasticsearch security features are enabled, it is essential to configure the necessary cluster privileges to ensure seamless operation. Below is a list of the required privileges along with their purposes: - -- **monitor** - Necessary to retrieve the version information of the connected Elasticsearch instance. -- **view_index_metadata** - Required for accessing mapping definitions of indices. -- **read** - Grants the ability to perform search and retrieval operations on indices. This is essential for querying and extracting data from the cluster. - -## Add the data source - -To add the Elasticsearch data source, complete the following steps: - -1. Click **Connections** in the left-side menu. -1. Under **Connections**, click **Add new connection**. -1. Enter `Elasticsearch` in the search bar. -1. Click **Elasticsearch** under the **Data source** section. -1. Click **Add new data source** in the upper right. - -You will be taken to the **Settings** tab where you will set up your Elasticsearch configuration. - -## Configuration options - -The following is a list of configuration options for Elasticsearch. - -The first option to configure is the name of your connection: - -- **Name** - The data source name. This is how you refer to the data source in panels and queries. Examples: elastic-1, elasticsearch_metrics. - -- **Default** - Toggle to select as the default data source option. When you go to a dashboard panel or Explore, this will be the default selected data source. - -## Connection - -Connect the Elasticsearch data source by specifying a URL. - -- **URL** - The URL of your Elasticsearch server. If your Elasticsearch server is local, use `http://localhost:9200`. If it is on a server within a network, this is the URL with the port where you are running Elasticsearch. Example: `http://elasticsearch.example.orgname:9200`. - -## Authentication - -There are several authentication methods you can choose in the Authentication section. -Select one of the following authentication methods from the dropdown menu. - -- **Basic authentication** - The most common authentication method. Use your `data source` user name and `data source` password to connect. - -- **Forward OAuth identity** - Forward the OAuth access token (and the OIDC ID token if available) of the user querying the data source. - -- **No authentication** - Make the data source available without authentication. Grafana recommends using some type of authentication method. - - - -### TLS settings - -{{< admonition type="note" >}} -Use TLS (Transport Layer Security) for an additional layer of security when working with Elasticsearch. For information on setting up TLS encryption with Elasticsearch see [Configure TLS](https://www.elastic.co/guide/en/elasticsearch/reference/8.8/configuring-tls.html#configuring-tls). You must add TLS settings to your Elasticsearch configuration file **prior** to setting these options in Grafana. -{{< /admonition >}} - -- **Add self-signed certificate** - Check the box to authenticate with a CA certificate. Follow the instructions of the CA (Certificate Authority) to download the certificate file. Required for verifying self-signed TLS certificates. - -- **TLS client authentication** - Check the box to authenticate with the TLS client, where the server authenticates the client. Add the `Server name`, `Client certificate` and `Client key`. The **ServerName** is used to verify the hostname on the returned certificate. The **Client certificate** can be generated from a Certificate Authority (CA) or be self-signed. The **Client key** can also be generated from a Certificate Authority (CA) or be self-signed. The client key encrypts the data between client and server. - -- **Skip TLS certificate validation** - Check the box to bypass TLS certificate validation. Skipping TLS certificate validation is not recommended unless absolutely necessary or for testing purposes. - -### HTTP headers - -Click **+ Add header** to add one or more HTTP headers. HTTP headers pass additional context and metadata about the request/response. - -- **Header** - Add a custom header. This allows custom headers to be passed based on the needs of your Elasticsearch instance. - -- **Value** - The value of the header. - -## Additional settings - -Additional settings are optional settings that can be configured for more control over your data source. - -### Advanced HTTP settings - -- **Allowed cookies** - Specify cookies by name that should be forwarded to the data source. The Grafana proxy deletes all forwarded cookies by default. - -- **Timeout** - The HTTP request timeout. This must be in seconds. There is no default, so this setting is up to you. - -### Elasticsearch details - -The following settings are specific to the Elasticsearch data source. - -- **Index name** - Use the index settings to specify a default for the `time field` and your Elasticsearch index's name. You can use a time pattern, for example `[logstash-]YYYY.MM.DD`, or a wildcard for the index name. When specifying a time pattern, the fixed part(s) of the pattern should be wrapped in square brackets. - -- **Pattern** - Select the matching pattern if using one in your index name. Options include: - - no pattern - - hourly - - daily - - weekly - - monthly - - yearly - -Only select a pattern option if you have specified a time pattern in the Index name field. - -- **Time field name** - Name of the time field. The default value is @timestamp. You can enter a different name. - -- **Max concurrent shard requests** - Sets the number of shards being queried at the same time. The default is `5`. For more information on shards see [Elasticsearch's documentation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/scalability.html#scalability). - -- **Min time interval** - Defines a lower limit for the auto group-by time interval. This value **must** be formatted as a number followed by a valid time identifier: - - | Identifier | Description | - | ---------- | ----------- | - | `y` | year | - | `M` | month | - | `w` | week | - | `d` | day | - | `h` | hour | - | `m` | minute | - | `s` | second | - | `ms` | millisecond | - -We recommend setting this value to match your Elasticsearch write frequency. -For example, set this to `1m` if Elasticsearch writes data every minute. - -You can also override this setting in a dashboard panel under its data source options. The default is `10s`. - -- **X-Pack enabled** - Toggle to enable `X-Pack`-specific features and options, which provide the [query editor](../query-editor/) with additional aggregations, such as `Rate` and `Top Metrics`. - -- **Include frozen indices** - Toggle on when the `X-Pack enabled` setting is active. Includes frozen indices in searches. You can configure Grafana to include [frozen indices](https://www.elastic.co/guide/en/elasticsearch/reference/7.13/frozen-indices.html) when performing search requests. - -{{< admonition type="note" >}} -Frozen indices are [deprecated in Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/frozen-indices.html) since v7.14. -{{< /admonition >}} - -- **Default query mode** - Specifies which query mode the data source uses by default. Options are `Metrics`, `Logs`, `Raw data`, and `Raw document`. The default is `Metrics`. - -### Logs - -In this section you can configure which fields the data source uses for log messages and log levels. - -- **Message field name:** - Grabs the actual log message from the default source. - -- **Level field name:** - Name of the field with log level/severity information. When a level label is specified, the value of this label is used to determine the log level and update the color of each log line accordingly. If the log doesn’t have a specified level label, we try to determine if its content matches any of the [supported expressions](ref:supported-expressions). The first match always determines the log level. If Grafana cannot infer a log-level field, it will be visualized with an unknown log level. - -### Data links - -Data links create a link from a specified field that can be accessed in Explore's logs view. You can add multiple data links by clicking **+ Add**. - -Each data link configuration consists of: - -- **Field** - Sets the name of the field used by the data link. - -- **URL/query** - Sets the full link URL if the link is external. If the link is internal, this input serves as a query for the target data source.
In both cases, you can interpolate the value from the field with the `${__value.raw }` macro. - -- **URL Label** (Optional) - Sets a custom display label for the link. The link label defaults to the full external URL or name of the linked internal data source and is overridden by this setting. - -- **Internal link** - Toggle on to set an internal link. For an internal link, you can select the target data source with a data source selector. This supports only tracing data sources. - -## Private data source connect (PDC) and Elasticsearch - -Use private data source connect (PDC) to connect to and query data within a secure network without opening that network to inbound traffic from Grafana Cloud. See [Private data source connect](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) for more information on how PDC works and [Configure Grafana private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc) for steps on setting up a PDC connection. - -If you use PDC with SIGv4 (AWS Signature Version 4 Authentication), the PDC agent must allow internet egress to`sts..amazonaws.com:443`. - -- **Private data source connect** - Click in the box to set the default PDC connection from the dropdown menu or create a new connection. - -Once you have configured your Elasticsearch data source options, click **Save & test** at the bottom to test out your data source connection. You can also remove a connection by clicking **Delete**. diff --git a/docs/sources/datasources/elasticsearch/configure/index.md b/docs/sources/datasources/elasticsearch/configure/index.md new file mode 100644 index 00000000000..d76a90855ea --- /dev/null +++ b/docs/sources/datasources/elasticsearch/configure/index.md @@ -0,0 +1,377 @@ +--- +aliases: + - ../configure-elasticsearch-data-source/ +description: Guide for configuring the Elasticsearch data source in Grafana +keywords: + - grafana + - elasticsearch + - guide + - data source +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Configure +title: Configure the Elasticsearch data source +weight: 200 +refs: + administration-documentation: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/data-source-management/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/data-source-management/ + supported-expressions: + - pattern: /docs/grafana/ + destination: /docs/grafana//explore/logs-integration/#log-level + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//explore/logs-integration/#log-level + query-and-transform-data: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/query-transform-data/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/ + provisioning-data-source: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/elasticsearch/configure/#provision-the-data-source + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/elasticsearch/configure/#provision-the-data-source + configuration: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#sigv4_auth_enabled + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#sigv4_auth_enabled + provisioning-grafana: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/provisioning/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/provisioning/ +--- + +# Configure the Elasticsearch data source + +Grafana ships with built-in support for Elasticsearch. +You can create a variety of queries to visualize logs or metrics stored in Elasticsearch, and annotate graphs with log events stored in Elasticsearch. + +For instructions on how to add a data source to Grafana, refer to the [administration documentation](ref:administration-documentation). +Administrators can also [configure the data source via YAML](ref:provisioning-data-source) with Grafana's provisioning system. + +## Before you begin + +To configure the Elasticsearch data source, you need: + +- **Grafana administrator permissions:** Only users with the organization `administrator` role can add data sources. +- **A supported Elasticsearch version:** v7.17 or later, v8.x, or v9.x. Elastic Cloud Serverless isn't supported. +- **Elasticsearch server URL:** The HTTP or HTTPS endpoint for your Elasticsearch instance, including the port (default: `9200`). +- **Authentication credentials:** Depending on your Elasticsearch security configuration, you need one of the following: + - Username and password for basic authentication + - API key + - No credentials (if Elasticsearch security is disabled) +- **Network access:** Grafana must be able to reach your Elasticsearch server. For Grafana Cloud, consider using [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) if your Elasticsearch instance is in a private network. + +## Elasticsearch permissions + +When Elasticsearch security features are enabled, you must configure the following cluster privileges for the user or API key that Grafana uses to connect: + +- **monitor** - Necessary to retrieve the version information of the connected Elasticsearch instance. +- **view_index_metadata** - Required for accessing mapping definitions of indices. +- **read** - Grants the ability to perform search and retrieval operations on indices. This is essential for querying and extracting data from the cluster. + +## Add the data source + +To add the Elasticsearch data source, complete the following steps: + +1. Click **Connections** in the left-side menu. +1. Under **Connections**, click **Add new connection**. +1. Enter `Elasticsearch` in the search bar. +1. Click **Elasticsearch** under the **Data source** section. +1. Click **Add new data source** in the upper right. + +You will be taken to the **Settings** tab where you will set up your Elasticsearch configuration. + +## Configuration options + +Configure the following basic settings for the Elasticsearch data source: + +- **Name** - The data source name. This is how you refer to the data source in panels and queries. Examples: `elastic-1`, `elasticsearch_metrics`. + +- **Default** - Toggle on to make this the default data source. New panels and Explore queries use the default data source. + +## Connection + +- **URL** - The URL of your Elasticsearch server, including the port. Examples: `http://localhost:9200`, `http://elasticsearch.example.com:9200`. + +## Authentication + +Select an authentication method from the drop-down menu: + +- **Basic authentication** - Enter the username and password for your Elasticsearch user. + +- **Forward OAuth identity** - Forward the OAuth access token (and the OIDC ID token if available) of the user querying the data source. + +- **No authentication** - Connect without credentials. Only use this option if your Elasticsearch instance doesn't require authentication. + +### API key authentication + +To authenticate using an Elasticsearch API key, select **No authentication** and configure the API key using HTTP headers: + +1. In the **HTTP headers** section, click **+ Add header**. +1. Set **Header** to `Authorization`. +1. Set **Value** to `ApiKey `, replacing `` with your base64-encoded Elasticsearch API key. + +For information about creating API keys, refer to the [Elasticsearch API keys documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html). + +### Amazon Elasticsearch Service + +If you use Amazon Elasticsearch Service, you can use Grafana's Elasticsearch data source to visualize data from it. + +If you use an AWS Identity and Access Management (IAM) policy to control access to your Amazon Elasticsearch Service domain, you must use AWS Signature Version 4 (AWS SigV4) to sign all requests to that domain. + +For details on AWS SigV4, refer to the [AWS documentation](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html). + +To sign requests to your Amazon Elasticsearch Service domain, you can enable SigV4 in Grafana's [configuration](ref:configuration). + +Once AWS SigV4 is enabled, you can configure it on the Elasticsearch data source configuration page. +For more information about AWS authentication options, refer to [AWS authentication](https://grafana.com/docs/grafana//datasources/aws-cloudwatch/aws-authentication/). + +{{< figure src="/static/img/docs/v73/elasticsearch-sigv4-config-editor.png" max-width="500px" class="docs-image--no-shadow" caption="SigV4 configuration for AWS Elasticsearch Service" >}} + +### TLS settings + +{{< admonition type="note" >}} +Use TLS (Transport Layer Security) for an additional layer of security when working with Elasticsearch. For information on setting up TLS encryption with Elasticsearch, refer to [Configure TLS](https://www.elastic.co/guide/en/elasticsearch/reference/8.8/configuring-tls.html#configuring-tls). You must add TLS settings to your Elasticsearch configuration file **prior** to setting these options in Grafana. +{{< /admonition >}} + +- **Add self-signed certificate** - Check the box to authenticate with a CA certificate. Follow the instructions of the CA (Certificate Authority) to download the certificate file. Required for verifying self-signed TLS certificates. + +- **TLS client authentication** - Check the box to authenticate with the TLS client, where the server authenticates the client. Add the `Server name`, `Client certificate` and `Client key`. The **ServerName** is used to verify the hostname on the returned certificate. The **Client certificate** can be generated from a Certificate Authority (CA) or be self-signed. The **Client key** can also be generated from a Certificate Authority (CA) or be self-signed. The client key encrypts the data between client and server. + +- **Skip TLS certificate validation** - Check the box to bypass TLS certificate validation. Skipping TLS certificate validation is not recommended unless absolutely necessary or for testing purposes. + +### HTTP headers + +Click **+ Add header** to add one or more HTTP headers. HTTP headers pass additional context and metadata about the request/response. + +- **Header** - Add a custom header. This allows custom headers to be passed based on the needs of your Elasticsearch instance. + +- **Value** - The value of the header. + +## Additional settings + +Additional settings are optional settings that can be configured for more control over your data source. + +### Advanced HTTP settings + +- **Allowed cookies** - Specify cookies by name that should be forwarded to the data source. The Grafana proxy deletes all forwarded cookies by default. + +- **Timeout** - The HTTP request timeout. This must be in seconds. There is no default, so this setting is up to you. + +### Elasticsearch details + +The following settings are specific to the Elasticsearch data source. + +- **Index name** - The name of your Elasticsearch index. You can use the following formats: + - **Wildcard patterns** - Use `*` to match multiple indices. Examples: `logs-*`, `metrics-*`, `filebeat-*`. + - **Time patterns** - Use date placeholders for time-based indices. Wrap the fixed portion in square brackets. Examples: `[logstash-]YYYY.MM.DD`, `[metrics-]YYYY.MM`. + - **Specific index** - Enter the exact index name. Example: `application-logs`. + +- **Pattern** - Select the matching pattern if you use a time pattern in your index name. Options include: + - no pattern + - hourly + - daily + - weekly + - monthly + - yearly + +Only select a pattern option if you have specified a time pattern in the Index name field. + +- **Time field name** - Name of the time field. The default value is `@timestamp`. You can enter a different name. + +- **Max concurrent shard requests** - Sets the number of shards being queried at the same time. The default is `5`. For more information on shards, refer to the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/scalability.html#scalability). + +- **Min time interval** - Defines a lower limit for the auto group-by time interval. This value **must** be formatted as a number followed by a valid time identifier: + + | Identifier | Description | + | ---------- | ----------- | + | `y` | year | + | `M` | month | + | `w` | week | + | `d` | day | + | `h` | hour | + | `m` | minute | + | `s` | second | + | `ms` | millisecond | + +We recommend setting this value to match your Elasticsearch write frequency. +For example, set this to `1m` if Elasticsearch writes data every minute. + +You can also override this setting in a dashboard panel under its data source options. The default is `10s`. + +- **X-Pack enabled** - Toggle to enable `X-Pack`-specific features and options, which provide the [query editor](https://grafana.com/docs/grafana//datasources/elasticsearch/query-editor/) with additional aggregations, such as `Rate` and `Top Metrics`. + +- **Include frozen indices** - Toggle on when the `X-Pack enabled` setting is active. Includes frozen indices in searches. You can configure Grafana to include [frozen indices](https://www.elastic.co/guide/en/elasticsearch/reference/7.13/frozen-indices.html) when performing search requests. + +{{< admonition type="note" >}} +Frozen indices are [deprecated in Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/frozen-indices.html) since v7.14. +{{< /admonition >}} + +### Logs + +Configure which fields the data source uses for log messages and log levels. + +- **Message field name** - The field that contains the log message content. + +- **Level field name** - The field that contains log level or severity information. When specified, Grafana uses this field to determine the log level and color-code each log line. If the log doesn't have a level field, Grafana tries to match the content against [supported expressions](ref:supported-expressions). If Grafana can't determine the log level, it displays as unknown. + +### Data links + +Data links create a link from a specified field that can be accessed in Explore's logs view. You can add multiple data links by clicking **+ Add**. + +Each data link configuration consists of: + +- **Field** - Sets the name of the field used by the data link. + +- **URL/query** - Sets the full link URL if the link is external. If the link is internal, this input serves as a query for the target data source.
In both cases, you can interpolate the value from the field with the `${__value.raw }` macro. + +- **URL Label** (Optional) - Sets a custom display label for the link. The link label defaults to the full external URL or name of the linked internal data source and is overridden by this setting. + +- **Internal link** - Toggle on to set an internal link. For an internal link, you can select the target data source with a data source selector. This supports only tracing data sources. + +## Private data source connect (PDC) and Elasticsearch + +Use private data source connect (PDC) to connect to and query data within a secure network without opening that network to inbound traffic from Grafana Cloud. Refer to [Private data source connect](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) for more information on how PDC works and [Configure Grafana private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc) for steps on setting up a PDC connection. + +If you use PDC with SigV4 (AWS Signature Version 4 Authentication), the PDC agent must allow internet egress to `sts..amazonaws.com:443`. + +- **Private data source connect** - Click in the box to set the default PDC connection from the drop-down menu or create a new connection. + +Once you have configured your Elasticsearch data source options, click **Save & test** to test the connection. A successful connection displays the following message: + +`Elasticsearch data source is healthy.` + +## Provision the data source + +You can define and configure the data source in YAML files as part of Grafana's provisioning system. +For more information about provisioning, and for available configuration options, refer to [Provisioning Grafana](ref:provisioning-grafana). + +{{< admonition type="note" >}} +The previously used `database` field has now been [deprecated](https://github.com/grafana/grafana/pull/58647). +Use the `index` field in `jsonData` to store the index name. +Refer to the examples below. +{{< /admonition >}} + +### Basic provisioning + +```yaml +apiVersion: 1 + +datasources: + - name: Elastic + type: elasticsearch + access: proxy + url: http://localhost:9200 + jsonData: + index: '[metrics-]YYYY.MM.DD' + interval: Daily + timeField: '@timestamp' +``` + +### Provision for logs + +```yaml +apiVersion: 1 + +datasources: + - name: elasticsearch-v7-filebeat + type: elasticsearch + access: proxy + url: http://localhost:9200 + jsonData: + index: '[filebeat-]YYYY.MM.DD' + interval: Daily + timeField: '@timestamp' + logMessageField: message + logLevelField: fields.level + dataLinks: + - datasourceUid: my_jaeger_uid # Target UID needs to be known + field: traceID + url: '$${__value.raw}' # Careful about the double "$$" because of env var expansion +``` + +## Provision the data source using Terraform + +You can provision the Elasticsearch data source using [Terraform](https://www.terraform.io/) with the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs). + +For more information about provisioning resources with Terraform, refer to the [Grafana as code using Terraform](https://grafana.com/docs/grafana-cloud/developer-resources/infrastructure-as-code/terraform/) documentation. + +### Basic Terraform example + +The following example creates a basic Elasticsearch data source for metrics: + +```hcl +resource "grafana_data_source" "elasticsearch" { + name = "Elasticsearch" + type = "elasticsearch" + url = "http://localhost:9200" + + json_data_encoded = jsonencode({ + index = "[metrics-]YYYY.MM.DD" + interval = "Daily" + timeField = "@timestamp" + }) +} +``` + +### Terraform example for logs + +The following example creates an Elasticsearch data source configured for logs with a data link to Jaeger: + +```hcl +resource "grafana_data_source" "elasticsearch_logs" { + name = "Elasticsearch Logs" + type = "elasticsearch" + url = "http://localhost:9200" + + json_data_encoded = jsonencode({ + index = "[filebeat-]YYYY.MM.DD" + interval = "Daily" + timeField = "@timestamp" + logMessageField = "message" + logLevelField = "fields.level" + dataLinks = [ + { + datasourceUid = grafana_data_source.jaeger.uid + field = "traceID" + url = "$${__value.raw}" + } + ] + }) +} +``` + +### Terraform example with basic authentication + +The following example includes basic authentication: + +```hcl +resource "grafana_data_source" "elasticsearch_auth" { + name = "Elasticsearch" + type = "elasticsearch" + url = "http://localhost:9200" + + basic_auth_enabled = true + basic_auth_username = "elastic_user" + + secure_json_data_encoded = jsonencode({ + basicAuthPassword = var.elasticsearch_password + }) + + json_data_encoded = jsonencode({ + index = "[metrics-]YYYY.MM.DD" + interval = "Daily" + timeField = "@timestamp" + }) +} +``` + +For all available configuration options, refer to the [Grafana provider data source resource documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source). diff --git a/docs/sources/datasources/elasticsearch/query-editor/index.md b/docs/sources/datasources/elasticsearch/query-editor/index.md index fa20353a395..c5c7bf91abf 100644 --- a/docs/sources/datasources/elasticsearch/query-editor/index.md +++ b/docs/sources/datasources/elasticsearch/query-editor/index.md @@ -30,7 +30,7 @@ refs: # Elasticsearch query editor Grafana provides a query editor for Elasticsearch. Elasticsearch queries are in Lucene format. -See [Lucene query syntax](https://www.elastic.co/guide/en/kibana/current/lucene-query.html) and [Query string syntax](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/query-dsl-query-string-query.html#query-string-syntax) if you are new to working with Lucene queries in Elasticsearch. +For more information about query syntax, refer to [Lucene query syntax](https://www.elastic.co/guide/en/kibana/current/lucene-query.html) and [Query string syntax](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax). {{< admonition type="note" >}} When composing Lucene queries, ensure that you use uppercase boolean operators: `AND`, `OR`, and `NOT`. Lowercase versions of these operators are not supported by the Lucene query syntax. @@ -38,17 +38,17 @@ When composing Lucene queries, ensure that you use uppercase boolean operators: {{< figure src="/static/img/docs/elasticsearch/elastic-query-editor-10.1.png" max-width="800px" class="docs-image--no-shadow" caption="Elasticsearch query editor" >}} -For general documentation on querying data sources in Grafana, including options and functions common to all query editors, see [Query and transform data](ref:query-and-transform-data). +For general documentation on querying data sources in Grafana, including options and functions common to all query editors, refer to [Query and transform data](ref:query-and-transform-data). ## Aggregation types Elasticsearch groups aggregations into three categories: -- **Bucket** - Bucket aggregations don't calculate metrics, they create buckets of documents based on field values, ranges and a variety of other criteria. See [Bucket aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket.html) for additional information. Use bucket aggregations under `Group by` when creating a metrics query in the query builder. +- **Bucket** - Bucket aggregations don't calculate metrics, they create buckets of documents based on field values, ranges and a variety of other criteria. Refer to [Bucket aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket.html) for additional information. Use bucket aggregations under `Group by` when creating a metrics query in the query builder. -- **Metrics** - Metrics aggregations perform calculations such as sum, average, min, etc. They can be single-value or multi-value. See [Metrics aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics.html) for additional information. Use metrics aggregations in the metrics query type in the query builder. +- **Metrics** - Metrics aggregations perform calculations such as sum, average, min, etc. They can be single-value or multi-value. Refer to [Metrics aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics.html) for additional information. Use metrics aggregations in the metrics query type in the query builder. -- **Pipeline** - Elasticsearch pipeline aggregations work with inputs or metrics created from other aggregations (not documents or fields). There are parent and sibling and sibling pipeline aggregations. See [Pipeline aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-pipeline.html) for additional information. +- **Pipeline** - Pipeline aggregations work on the output of other aggregations rather than on documents or fields. Refer to [Pipeline aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline.html) for additional information. ## Select a query type @@ -56,44 +56,51 @@ There are three types of queries you can create with the Elasticsearch query bui ### Metrics query type -Metrics queries aggregate data and produce a variety of calculations such as count, min, max, etc. Click on the metric box to view a list of options in the dropdown menu. The default is `count`. +Metrics queries aggregate data and produce calculations such as count, min, max, and more. Click the metric box to view options in the drop-down menu. The default is `count`. - **Alias** - Aliasing only applies to **time series queries**, where the last group is `date histogram`. This is ignored for any other type of query. - **Metric** - Metrics aggregations include: - - count - see [Value count aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-valuecount-aggregation.html) - - average - see [Avg aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-rate-aggregation.html) - - sum - see [Sum aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-sum-aggregation.html) - - max - see [Max aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-max-aggregation.html) - - min - see [Min aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-min-aggregation.html) - - extended stats - see [Extended stats aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-extendedstats-aggregation.html) - - percentiles - see [Percentiles aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-percentile-aggregation.html) - - unique count - see [Cardinality aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-cardinality-aggregation.html) - - top metrics - see [Top metrics aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-top-metrics.html) - - rate - see [Rate aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-rate-aggregation.html) + - count - refer to [Value count aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-valuecount-aggregation.html) + - average - refer to [Avg aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-avg-aggregation.html) + - sum - refer to [Sum aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-sum-aggregation.html) + - max - refer to [Max aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-max-aggregation.html) + - min - refer to [Min aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-min-aggregation.html) + - extended stats - refer to [Extended stats aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-extendedstats-aggregation.html) + - percentiles - refer to [Percentiles aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-percentile-aggregation.html) + - unique count - refer to [Cardinality aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-cardinality-aggregation.html) + - top metrics - refer to [Top metrics aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-top-metrics.html) + - rate - refer to [Rate aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-rate-aggregation.html) + +- **Pipeline aggregations** - Pipeline aggregations work on the output of other aggregations rather than on documents. The following pipeline aggregations are available: + - moving function - Calculates a value based on a sliding window of aggregated values. Refer to [Moving function aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-movfn-aggregation.html). + - derivative - Calculates the derivative of a metric. Refer to [Derivative aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-derivative-aggregation.html). + - cumulative sum - Calculates the cumulative sum of a metric. Refer to [Cumulative sum aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-cumulative-sum-aggregation.html). + - serial difference - Calculates the difference between values in a time series. Refer to [Serial differencing aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-serialdiff-aggregation.html). + - bucket script - Executes a script on metric values from other aggregations. Refer to [Bucket script aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-bucket-script-aggregation.html). You can select multiple metrics and group by multiple terms or filters when using the Elasticsearch query editor. Use the **+ sign** to the right to add multiple metrics to your query. Click on the **eye icon** next to **Metric** to hide metrics, and the **garbage can icon** to remove metrics. -- **Group by options** - Create multiple group by options when constructing your Elasticsearch query. Date histogram is the default option. Below is a list of options in the dropdown menu. - - terms - see [Terms aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html). - - filter - see [Filter aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-filter-aggregation.html). - - geo hash grid - see [Geohash grid aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-geohashgrid-aggregation.html). - - date histogram - for time series queries. See [Date histogram aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-datehistogram-aggregation.html). - - histogram - Depicts frequency distributions. See [Histogram aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-histogram-aggregation.html). - - nested (experimental) - See [Nested aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-nested-aggregation.html). +- **Group by options** - Create multiple group by options when constructing your Elasticsearch query. Date histogram is the default option. The following options are available in the drop-down menu: + - terms - refer to [Terms aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html). + - filter - refer to [Filter aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-filter-aggregation.html). + - geo hash grid - refer to [Geohash grid aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-geohashgrid-aggregation.html). + - date histogram - for time series queries. Refer to [Date histogram aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-datehistogram-aggregation.html). + - histogram - Depicts frequency distributions. Refer to [Histogram aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-histogram-aggregation.html). + - nested (experimental) - Refer to [Nested aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-nested-aggregation.html). Each group by option will have a different subset of options to further narrow your query. The following options are specific to the **date histogram** bucket aggregation option. -- **Time field** - Depicts date data options. The default option can be specified when configuring the Elasticsearch data source in the **Time field name** under the [**Elasticsearch details**](/docs/grafana/latest/datasources/elasticsearch/configure-elasticsearch-data-source/#elasticsearch-details) section. Otherwise **@timestamp** field will be used as a default option. -- **Interval** - Group by a type of interval. There are option to choose from the dropdown menu to select seconds, minutes, hours or day. You can also add a custom interval such as `30d` (30 days). `Auto` is the default option. -- **Min doc count** - The minimum amount of data to include in your query. The default is `0`. -- **Thin edges** - Select to trim edges on the time series data points. The default is `0`. -- **Offset** - Changes the start value of each bucket by the specified positive(+) or negative (-) offset duration. Examples include `1h` for 1 hour, `5s` for 5 seconds or `1d` for 1 day. -- **Timezone** - Select a timezone from the dropdown menu. The default is `Coordinated universal time`. +- **Time field** - The field used for time-based queries. The default can be set when configuring the data source in the **Time field name** setting under [Elasticsearch details](https://grafana.com/docs/grafana//datasources/elasticsearch/configure/#elasticsearch-details). The default is `@timestamp`. +- **Interval** - The time interval for grouping data. Select from the drop-down menu or enter a custom interval such as `30d` (30 days). The default is `Auto`. +- **Min doc count** - The minimum number of documents required to include a bucket. The default is `0`. +- **Trim edges** - Removes partial buckets at the edges of the time range. The default is `0`. +- **Offset** - Shifts the start of each bucket by the specified duration. Use positive (`+`) or negative (`-`) values. Examples: `1h`, `5s`, `1d`. +- **Timezone** - The timezone for date calculations. The default is `Coordinated Universal Time`. Configure the following options for the **terms** bucket aggregation option: @@ -101,7 +108,7 @@ Configure the following options for the **terms** bucket aggregation option: - **Size** - Limits the number of documents, or size of the data set. You can set a custom number or `no limit`. - **Min doc count** - The minimum amount of data to include in your query. The default is `0`. - **Order by** - Order terms by `term value`, `doc count` or `count`. -- **Missing** - Defines how documents missing a value should be treated. Missing values are ignored by default, but they can be treated as if they had a value. See [Missing value](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#_missing_value_5) in Elasticsearch's documentation for more information. +- **Missing** - Defines how documents missing a value should be treated. Missing values are ignored by default, but they can be treated as if they had a value. Refer to [Missing value](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#_missing_value_5) in the Elasticsearch documentation for more information. Configure the following options for the **filters** bucket aggregation option: @@ -114,8 +121,8 @@ Configure the following options for the **geo hash grid** bucket aggregation opt Configure the following options for the **histogram** bucket aggregation option: -- **Interval** - Group by a type of interval. There are option to choose from the dropdown menu to select seconds, minutes, hours or day. You can also add a custom interval such as `30d` (30 days). `Auto` is the default option. -- **Min doc count** - The minimum amount of data to include in your query. The default is `0` +- **Interval** - The numeric interval for grouping values into buckets. +- **Min doc count** - The minimum number of documents required to include a bucket. The default is `0`. The **nested** group by option is currently experimental, you can select a field and then settings specific to that field. @@ -141,7 +148,7 @@ The option to run a **raw document query** is deprecated as of Grafana v10.1. ## Use template variables -You can also augment queries by using [template variables](../template-variables/). +You can also augment queries by using [template variables](https://grafana.com/docs/grafana//datasources/elasticsearch/template-variables/). Queries of `terms` have a 500-result limit by default. To set a custom limit, set the `size` property in your query. diff --git a/docs/sources/datasources/elasticsearch/template-variables/index.md b/docs/sources/datasources/elasticsearch/template-variables/index.md index 66ca17a93bd..ed2cb95f3af 100644 --- a/docs/sources/datasources/elasticsearch/template-variables/index.md +++ b/docs/sources/datasources/elasticsearch/template-variables/index.md @@ -22,6 +22,11 @@ refs: destination: /docs/grafana//dashboards/variables/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana//dashboards/variables/ + add-template-variables-add-ad-hoc-filters: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/variables/add-template-variables/#add-ad-hoc-filters + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//dashboards/variables/add-template-variables/#add-ad-hoc-filters add-template-variables-multi-value-variables: - pattern: /docs/grafana/ destination: /docs/grafana//dashboards/variables/add-template-variables/#multi-value-variables @@ -37,11 +42,29 @@ refs: # Elasticsearch template variables Instead of hard-coding details such as server, application, and sensor names in metric queries, you can use variables. -Grafana lists these variables in dropdown select boxes at the top of the dashboard to help you change the data displayed in your dashboard. +Grafana lists these variables in drop-down select boxes at the top of the dashboard to help you change the data displayed in your dashboard. Grafana refers to such variables as template variables. For an introduction to templating and template variables, refer to the [Templating](ref:variables) and [Add and manage variables](ref:add-template-variables) documentation. +## Use ad hoc filters + +Elasticsearch supports the **Ad hoc filters** variable type. +You can use this variable type to specify any number of key/value filters, and Grafana applies them automatically to all of your Elasticsearch queries. + +Ad hoc filters support the following operators: + +| Operator | Description | +| -------- | ------------------------------------------------------------- | +| `=` | Equals. Adds `AND field:"value"` to the query. | +| `!=` | Not equals. Adds `AND -field:"value"` to the query. | +| `=~` | Matches regex. Adds `AND field:/value/` to the query. | +| `!~` | Does not match regex. Adds `AND -field:/value/` to the query. | +| `>` | Greater than. Adds `AND field:>value` to the query. | +| `<` | Less than. Adds `AND field:}} -To use an ascending sort (`asc`) with doc_count (a bottom-N list), set `order: "asc"`. However, Elasticsearch [discourages this](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-order) because sorting by ascending doc count can return inaccurate results. -{{< /admonition >}} - -To keep terms in the doc count order, set the variable's Sort dropdown to **Disabled**. -You can alternatively use other sorting criteria, such as **Alphabetical**, to re-sort them. +This example defines a variable named `$host` that only shows hosts matching the selected `$environment`: +```json +{ "find": "terms", "field": "hostname", "query": "environment:$environment" } ``` -{"find": "terms", "field": "hostname", "orderBy": "doc_count"} -``` + +Whenever you change the value of the `$environment` variable via the drop-down, Grafana triggers an update of the `$host` variable to contain only hostnames filtered by the selected environment. + +### Variables in aggregations + +You can use variables in bucket aggregation fields to dynamically change how data is grouped. For example, use a variable in the **Terms** group by field to let users switch between grouping by `hostname`, `service`, or `datacenter`. ## Template variable examples @@ -92,11 +116,36 @@ Write the query using a custom JSON string, with the field mapped as a [keyword] If the query is [multi-field](https://www.elastic.co/guide/en/elasticsearch/reference/current/multi-fields.html) with both a `text` and `keyword` type, use `"field":"fieldname.keyword"` (sometimes `fieldname.raw`) to specify the keyword field in your query. -| Query | Description | -| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `{"find": "fields", "type": "keyword"}` | Returns a list of field names with the index type `keyword`. | -| `{"find": "terms", "field": "hostname.keyword", "size": 1000}` | Returns a list of values for a keyword using term aggregation. Query will use current dashboard time range as time range query. | -| `{"find": "terms", "field": "hostname", "query": ''}` | Returns a list of values for a keyword field using term aggregation and a specified Lucene query filter. Query will use current dashboard time range as time range for query. | +| Query | Description | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| `{"find": "fields", "type": "keyword"}` | Returns a list of field names with the index type `keyword`. | +| `{"find": "fields", "type": "number"}` | Returns a list of numeric field names (includes `float`, `double`, `integer`, `long`, `scaled_float`). | +| `{"find": "fields", "type": "date"}` | Returns a list of date field names. | +| `{"find": "terms", "field": "hostname.keyword", "size": 1000}` | Returns a list of values for a keyword field. Uses the current dashboard time range. | +| `{"find": "terms", "field": "hostname", "query": ""}` | Returns a list of values filtered by a Lucene query. Uses the current dashboard time range. | +| `{"find": "terms", "field": "status", "orderBy": "doc_count"}` | Returns values sorted by document count (descending by default). | +| `{"find": "terms", "field": "status", "orderBy": "doc_count", "order": "asc"}` | Returns values sorted by document count in ascending order. | -Queries of `terms` have a 500-result limit by default. -To set a custom limit, set the `size` property in your query. +Queries of `terms` have a 500-result limit by default. To set a custom limit, set the `size` property in your query. + +### Sort query results + +By default, queries return results in term order (which can then be sorted alphabetically or numerically using the variable's Sort setting). + +To produce a list of terms sorted by document count (a top-N values list), add an `orderBy` property of `doc_count`. This automatically selects a descending sort: + +```json +{ "find": "terms", "field": "status", "orderBy": "doc_count" } +``` + +You can also use the `order` property to explicitly set ascending or descending sort: + +```json +{ "find": "terms", "field": "hostname", "orderBy": "doc_count", "order": "asc" } +``` + +{{< admonition type="note" >}} +Elasticsearch [discourages](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-order) sorting by ascending doc count because it can return inaccurate results. +{{< /admonition >}} + +To keep terms in the document count order, set the variable's Sort drop-down to **Disabled**. You can alternatively use other sorting criteria, such as **Alphabetical**, to re-sort them. diff --git a/docs/sources/datasources/elasticsearch/troubleshooting/index.md b/docs/sources/datasources/elasticsearch/troubleshooting/index.md new file mode 100644 index 00000000000..ff0f73c6093 --- /dev/null +++ b/docs/sources/datasources/elasticsearch/troubleshooting/index.md @@ -0,0 +1,266 @@ +--- +aliases: + - ../../data-sources/elasticsearch/troubleshooting/ +description: Troubleshooting the Elasticsearch data source in Grafana +keywords: + - grafana + - elasticsearch + - troubleshooting + - errors +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Troubleshooting +title: Troubleshoot issues with the Elasticsearch data source +weight: 600 +--- + +# Troubleshoot issues with the Elasticsearch data source + +This document provides troubleshooting information for common errors you may encounter when using the Elasticsearch data source in Grafana. + +## Connection errors + +The following errors occur when Grafana cannot establish or maintain a connection to Elasticsearch. + +### Failed to connect to Elasticsearch + +**Error message:** "Health check failed: Failed to connect to Elasticsearch" + +**Cause:** Grafana cannot establish a network connection to the Elasticsearch server. + +**Solution:** + +1. Verify that the Elasticsearch URL is correct in the data source configuration. +1. Check that Elasticsearch is running and accessible from the Grafana server. +1. Ensure there are no firewall rules blocking the connection. +1. If using a proxy, verify the proxy settings are correct. +1. For Grafana Cloud, ensure you have configured [Private data source connect](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) if your Elasticsearch instance is not publicly accessible. + +### Request timed out + +**Error message:** "Health check failed: Elasticsearch data source is not healthy. Request timed out" + +**Cause:** The connection to Elasticsearch timed out before receiving a response. + +**Solution:** + +1. Check the network latency between Grafana and Elasticsearch. +1. Verify that Elasticsearch is not overloaded or experiencing performance issues. +1. Increase the timeout setting in the data source configuration if needed. +1. Check if any network devices (load balancers, proxies) are timing out the connection. + +### Failed to parse data source URL + +**Error message:** "Failed to parse data source URL" + +**Cause:** The URL entered in the data source configuration is not valid. + +**Solution:** + +1. Verify the URL format is correct (for example, `http://localhost:9200` or `https://elasticsearch.example.com:9200`). +1. Ensure the URL includes the protocol (`http://` or `https://`). +1. Remove any trailing slashes or invalid characters from the URL. + +## Authentication errors + +The following errors occur when there are issues with authentication credentials or permissions. + +### Unauthorized (401) + +**Error message:** "Health check failed: Elasticsearch data source is not healthy. Status: 401 Unauthorized" + +**Cause:** The authentication credentials are invalid or missing. + +**Solution:** + +1. Verify that the username and password are correct. +1. If using an API key, ensure the key is valid and has not expired. +1. Check that the authentication method selected matches your Elasticsearch configuration. +1. Verify the user has the required permissions to access the Elasticsearch cluster. + +### Forbidden (403) + +**Error message:** "Health check failed: Elasticsearch data source is not healthy. Status: 403 Forbidden" + +**Cause:** The authenticated user does not have permission to access the requested resource. + +**Solution:** + +1. Verify the user has read access to the specified index. +1. Check Elasticsearch security settings and role mappings. +1. Ensure the user has permission to access the `_cluster/health` endpoint. +1. If using AWS Elasticsearch Service with SigV4 authentication, verify the IAM policy grants the required permissions. + +## Cluster health errors + +The following errors occur when the Elasticsearch cluster is unhealthy or unavailable. + +### Cluster status is red + +**Error message:** "Health check failed: Elasticsearch data source is not healthy" + +**Cause:** The Elasticsearch cluster health status is red, indicating one or more primary shards are not allocated. + +**Solution:** + +1. Check the Elasticsearch cluster health using `GET /_cluster/health`. +1. Review Elasticsearch logs for errors. +1. Verify all nodes in the cluster are running and connected. +1. Check for unassigned shards using `GET /_cat/shards?v&h=index,shard,prirep,state,unassigned.reason`. +1. Consider increasing the cluster's resources or reducing the number of shards. + +### Bad Gateway (502) + +**Error message:** "Health check failed: Elasticsearch data source is not healthy. Status: 502 Bad Gateway" + +**Cause:** A proxy or load balancer between Grafana and Elasticsearch returned an error. + +**Solution:** + +1. Check the health of any proxies or load balancers in the connection path. +1. Verify Elasticsearch is running and accepting connections. +1. Review proxy/load balancer logs for more details. +1. Ensure the proxy timeout is configured appropriately for Elasticsearch requests. + +## Index errors + +The following errors occur when there are issues with the configured index or index pattern. + +### Index not found + +**Error message:** "Error validating index: index_not_found" + +**Cause:** The specified index or index pattern does not match any existing indices. + +**Solution:** + +1. Verify the index name or pattern in the data source configuration. +1. Check that the index exists using `GET /_cat/indices`. +1. If using a time-based index pattern (for example, `[logs-]YYYY.MM.DD`), ensure indices exist for the selected time range. +1. Verify the user has permission to access the index. + +### Time field not found + +**Error message:** "Could not find time field '@timestamp' with type date in index" + +**Cause:** The specified time field does not exist in the index or is not of type `date`. + +**Solution:** + +1. Verify the time field name in the data source configuration matches the field in your index. +1. Check the field mapping using `GET //_mapping`. +1. Ensure the time field is mapped as a `date` type, not `text` or `keyword`. +1. If the field name is different (for example, `timestamp` instead of `@timestamp`), update the data source configuration. + +## Query errors + +The following errors occur when there are issues with query syntax or configuration. + +### Too many buckets + +**Error message:** "Trying to create too many buckets. Must be less than or equal to: [65536]." + +**Cause:** The query is generating more aggregation buckets than Elasticsearch allows. + +**Solution:** + +1. Reduce the time range of your query. +1. Increase the date histogram interval (for example, change from `10s` to `1m`). +1. Add filters to reduce the number of documents being aggregated. +1. Increase the `search.max_buckets` setting in Elasticsearch (requires cluster admin access). + +### Required field missing + +**Error message:** "Required one of fields [field, script], but none were specified." + +**Cause:** A metric aggregation (such as Average, Sum, or Min) was added without specifying a field. + +**Solution:** + +1. Select a field for the metric aggregation in the query editor. +1. Ensure the selected field exists in your index and contains numeric data. + +### Unsupported interval + +**Error message:** "unsupported interval '<interval>'" + +**Cause:** The interval specified for the index pattern is not valid. + +**Solution:** + +1. Use a supported interval: `Hourly`, `Daily`, `Weekly`, `Monthly`, or `Yearly`. +1. If you don't need a time-based index pattern, use `No pattern` and specify the exact index name. + +## Version errors + +The following errors occur when there are Elasticsearch version compatibility issues. + +### Unsupported Elasticsearch version + +**Error message:** "Support for Elasticsearch versions after their end-of-life (currently versions < 7.16) was removed. Using unsupported version of Elasticsearch may lead to unexpected and incorrect results." + +**Cause:** The Elasticsearch version is no longer supported by the Grafana data source. + +**Solution:** + +1. Upgrade Elasticsearch to a supported version (7.17+, 8.x, or 9.x). +1. Refer to [Elastic Product End of Life Dates](https://www.elastic.co/support/eol) for version support information. +1. Note that queries may still work, but Grafana does not guarantee functionality for unsupported versions. + +## Other common issues + +The following issues don't produce specific error messages but are commonly encountered. + +### Empty query results + +**Cause:** The query returns no data. + +**Solution:** + +1. Verify the time range includes data in your index. +1. Check the Lucene query syntax for errors. +1. Test the query directly in Elasticsearch using the `_search` API. +1. Ensure the index contains documents matching your query filters. + +### Slow query performance + +**Cause:** Queries take a long time to execute. + +**Solution:** + +1. Reduce the time range of your query. +1. Add more specific filters to limit the data scanned. +1. Increase the date histogram interval. +1. Check Elasticsearch cluster performance and resource utilization. +1. Consider using index aliases or data streams for better query routing. + +### CORS errors in browser console + +**Cause:** Cross-Origin Resource Sharing (CORS) is blocking requests from the browser to Elasticsearch. + +**Solution:** + +1. Use Server (proxy) access mode instead of Browser access mode in the data source configuration. +1. If Browser access is required, configure CORS settings in Elasticsearch: + +```yaml +http.cors.enabled: true +http.cors.allow-origin: '' +http.cors.allow-headers: 'Authorization, Content-Type' +http.cors.allow-credentials: true +``` + +{{< admonition type="note" >}} +Server (proxy) access mode is recommended for security and reliability. +{{< /admonition >}} + +## Get additional help + +If you continue to experience issues after following this troubleshooting guide: + +1. Check the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) for API-specific guidance. +1. Review the [Grafana community forums](https://community.grafana.com/) for similar issues. +1. Contact Grafana Support if you have an Enterprise license. From c35642b04dd589925ffe68c2d66ca086871dd0cb Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Thu, 11 Dec 2025 16:40:23 -0300 Subject: [PATCH 084/139] Chore: Bump nodemailer with forced resolution (#115172) --- package.json | 3 ++- yarn.lock | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 8be20ab399f..8b410b35085 100644 --- a/package.json +++ b/package.json @@ -459,7 +459,8 @@ "gitconfiglocal": "2.1.0", "tmp@npm:^0.0.33": "~0.2.1", "js-yaml@npm:4.1.0": "^4.1.0", - "js-yaml@npm:=4.1.0": "^4.1.0" + "js-yaml@npm:=4.1.0": "^4.1.0", + "nodemailer": "7.0.7" }, "workspaces": { "packages": [ diff --git a/yarn.lock b/yarn.lock index 91cbadc61e4..710279ad3d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25168,10 +25168,10 @@ __metadata: languageName: node linkType: hard -"nodemailer@npm:6.9.13": - version: 6.9.13 - resolution: "nodemailer@npm:6.9.13" - checksum: 10/efbc6fc415ec1e1dc1b91530920b0bcfc648183003c3d79718cd54fc2efef4b7dd1917ddd3853ab127e4b5ebd7353903b5859f0ac3ccea487374b076d41ac8b8 +"nodemailer@npm:7.0.7": + version: 7.0.7 + resolution: "nodemailer@npm:7.0.7" + checksum: 10/903d4e0a8320c0e4a2bede6737a9b4996048ddc2e010befc406c8953dcec96ef0e2c17e8b7639654e8bf46844cf7d26f017d8bf9fd629588637b699e09547222 languageName: node linkType: hard From 652b4f2fab422288314bb7595d114ff38415ae19 Mon Sep 17 00:00:00 2001 From: Andres Torres Date: Thu, 11 Dec 2025 15:12:25 -0500 Subject: [PATCH 085/139] fix(setting): Add default scheme to handle k8s api errors (#115177) --- pkg/services/setting/service.go | 6 ++- pkg/services/setting/service_test.go | 76 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/pkg/services/setting/service.go b/pkg/services/setting/service.go index 6b49abcb24f..0a249ef65ac 100644 --- a/pkg/services/setting/service.go +++ b/pkg/services/setting/service.go @@ -13,6 +13,7 @@ import ( "go.opentelemetry.io/otel/trace" "gopkg.in/ini.v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apiserver/pkg/endpoints/request" @@ -397,6 +398,9 @@ func getRestClient(config Config, log logging.Logger) (*rest.RESTClient, error) burst = config.Burst } + // Add a default scheme to handle K8s API error responses + scheme := runtime.NewScheme() + restConfig := &rest.Config{ Host: config.URL, TLSClientConfig: config.TLSClientConfig, @@ -407,7 +411,7 @@ func getRestClient(config Config, log logging.Logger) (*rest.RESTClient, error) APIPath: "/apis", ContentConfig: rest.ContentConfig{ GroupVersion: &settingGroupVersion, - NegotiatedSerializer: serializer.NewCodecFactory(nil).WithoutConversion(), + NegotiatedSerializer: serializer.NewCodecFactory(scheme).WithoutConversion(), }, } diff --git a/pkg/services/setting/service_test.go b/pkg/services/setting/service_test.go index 5d9a2565551..9b007fb1dc9 100644 --- a/pkg/services/setting/service_test.go +++ b/pkg/services/setting/service_test.go @@ -148,6 +148,82 @@ func TestRemoteSettingService_List(t *testing.T) { require.Error(t, err) assert.Nil(t, result) }) + + t.Run("should handle API errors", func(t *testing.T) { + statusResponse := `{ + "apiVersion": "v1", + "kind": "Status", + "metadata": {}, + "status": "Failure", + "message": "settings.setting.grafana.app \"test\" not found", + "reason": "NotFound", + "details": { + "name": "test", + "group": "setting.grafana.app", + "kind": "settings" + }, + "code": 404 + }` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(statusResponse)) + })) + defer server.Close() + + client := newTestClient(t, server.URL, 500) + ctx := request.WithNamespace(context.Background(), "test-namespace") + + result, err := client.List(ctx, metav1.LabelSelector{}) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "could not find the requested resource") + }) + + t.Run("should handle 500 internal server error", func(t *testing.T) { + statusResponse := `{ + "apiVersion": "v1", + "kind": "Status", + "metadata": {}, + "status": "Failure", + "message": "Internal error occurred: database connection failed", + "reason": "InternalError", + "code": 500 + }` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(statusResponse)) + })) + defer server.Close() + + client := newTestClient(t, server.URL, 500) + ctx := request.WithNamespace(context.Background(), "test-namespace") + + result, err := client.List(ctx, metav1.LabelSelector{}) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "error on the server") + }) + + t.Run("should handle connection errors", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + serverURL := server.URL + server.Close() + + client := newTestClient(t, serverURL, 500) + ctx := request.WithNamespace(context.Background(), "test-namespace") + + result, err := client.List(ctx, metav1.LabelSelector{}) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "connection refused") + }) } func TestParseSettingList(t *testing.T) { From e8039d1c3dd2eaa6568e6a589e4617b0cc22c8e2 Mon Sep 17 00:00:00 2001 From: Eric Hilse Date: Thu, 11 Dec 2025 13:28:30 -0700 Subject: [PATCH 086/139] fix(topbar): remove minWidth property for better layout handling (#115166) --- public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx index 6542e457f2c..d5179570ef3 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -93,7 +93,6 @@ export const SingleTopBar = memo(function SingleTopBar({ justifyContent={'flex-end'} flex={1} data-testid={!showToolbarLevel ? Components.NavToolbar.container : undefined} - minWidth={{ xs: 'unset', lg: 0 }} > From 1611489b84412e3ef575bcd6889031e57b977f7d Mon Sep 17 00:00:00 2001 From: Jack Baldry Date: Thu, 11 Dec 2025 21:40:35 +0000 Subject: [PATCH 087/139] Fix path to generation and source content (#115095) Signed-off-by: Jack Baldry --- docs/Makefile | 4 +-- .../transform-data/index.md | 3 +- scripts/docs/generate-transformations.ts | 29 ++++++++++--------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 7bdbe026293..8202b11f826 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -7,8 +7,8 @@ MAKEFLAGS += --no-builtin-rule include docs.mk -.PHONY: sources/panels-visualizations/query-transform-data/transform-data/index.md -sources/panels-visualizations/query-transform-data/transform-data/index.md: ## Generate the Transform Data page source. +.PHONY: sources/visualizations/panels-visualizations/query-transform-data/transform-data/index.md +sources/visualizations/panels-visualizations/query-transform-data/transform-data/index.md: ## Generate the Transform Data page source. cd $(CURDIR)/.. && \ npx tsx ./scripts/docs/generate-transformations.ts && \ npx prettier -w $(CURDIR)/$@ diff --git a/docs/sources/visualizations/panels-visualizations/query-transform-data/transform-data/index.md b/docs/sources/visualizations/panels-visualizations/query-transform-data/transform-data/index.md index b6b3499b4d7..2a13836d246 100644 --- a/docs/sources/visualizations/panels-visualizations/query-transform-data/transform-data/index.md +++ b/docs/sources/visualizations/panels-visualizations/query-transform-data/transform-data/index.md @@ -12,12 +12,13 @@ comments: | To build this Markdown, do the following: $ cd /docs (from the root of the repository) - $ make sources/panels-visualizations/query-transform-data/transform-data/index.md + $ make sources/visualizations/panels-visualizations/query-transform-data/transform-data/index.md $ make docs Browse to http://localhost:3003/docs/grafana/latest/panels-visualizations/query-transform-data/transform-data/ Refer to ./docs/README.md "Content guidelines" for more information about editing and building these docs. + aliases: - ../../../panels/transform-data/ # /docs/grafana/next/panels/transform-data/ - ../../../panels/transform-data/about-transformation/ # /docs/grafana/next/panels/transform-data/about-transformation/ diff --git a/scripts/docs/generate-transformations.ts b/scripts/docs/generate-transformations.ts index f6912c9a660..68eec4fa716 100644 --- a/scripts/docs/generate-transformations.ts +++ b/scripts/docs/generate-transformations.ts @@ -20,7 +20,7 @@ export const readMeContent = ` To build this Markdown, do the following: $ cd /docs (from the root of the repository) - $ make sources/panels-visualizations/query-transform-data/transform-data/index.md + $ make sources/visualizations/panels-visualizations/query-transform-data/transform-data/index.md $ make docs Browse to http://localhost:3003/docs/grafana/latest/panels-visualizations/query-transform-data/transform-data/ @@ -34,19 +34,20 @@ comments: | ${readMeContent} aliases: - - ../../panels/reference-transformation-functions/ - - ../../panels/transform-data/ - - ../../panels/transform-data/about-transformation/ - - ../../panels/transform-data/add-transformation-to-data/ - - ../../panels/transform-data/apply-transformation-to-data/ - - ../../panels/transform-data/debug-transformation/ - - ../../panels/transform-data/delete-transformation/ - - ../../panels/transform-data/transformation-functions/ - - ../../panels/transformations/ - - ../../panels/transformations/apply-transformations/ - - ../../panels/transformations/config-from-query/ - - ../../panels/transformations/rows-to-fields/ - - ../../panels/transformations/types-options/ + - ../../../panels/transform-data/ # /docs/grafana/next/panels/transform-data/ + - ../../../panels/transform-data/about-transformation/ # /docs/grafana/next/panels/transform-data/about-transformation/ + - ../../../panels/transform-data/add-transformation-to-data/ # /docs/grafana/next/panels/transform-data/add-transformation-to-data/ + - ../../../panels/transform-data/apply-transformation-to-data/ # /docs/grafana/next/panels/transform-data/apply-transformation-to-data/ + - ../../../panels/transform-data/debug-transformation/ # /docs/grafana/next/panels/transform-data/debug-transformation/ + - ../../../panels/transform-data/delete-transformation/ # /docs/grafana/next/panels/transform-data/delete-transformation/ + - ../../../panels/transform-data/transformation-functions/ # /docs/grafana/next/panels/transform-data/transformation-functions/ + - ../../../panels/transformations/ # /docs/grafana/next/panels/transformations/ + - ../../../panels/transformations/apply-transformations/ # /docs/grafana/next/panels/transformations/apply-transformations/ + - ../../../panels/transformations/config-from-query/ # /docs/grafana/next/panels/transformations/config-from-query/ + - ../../../panels/transformations/rows-to-fields/ # /docs/grafana/next/panels/transformations/rows-to-fields/ + - ../../../panels/transformations/types-options/ # /docs/grafana/next/panels/transformations/types-options/ + - ../../../panels/reference-transformation-functions/ # /docs/grafana/next/panels/reference-transformation-functions/ + - ../../../panels-visualizations/query-transform-data/transform-data/ # /docs/grafana/next/panels-visualizations/query-transform-data/transform-data/ labels: products: - cloud From 0385a7a4a495f276cde1ab42e9654c431cdf65aa Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:54:06 -0700 Subject: [PATCH 088/139] Dashboard Import: disable importing V2 dashboards when dashboardNewLayouts is disabled (#114188) * Disable importing v2 dashboards when dynamic dashboards are disabled * clean up * Update error messaging --- .../manage-dashboards/DashboardImportPage.tsx | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/public/app/features/manage-dashboards/DashboardImportPage.tsx b/public/app/features/manage-dashboards/DashboardImportPage.tsx index fd286a33f2f..b640035c2de 100644 --- a/public/app/features/manage-dashboards/DashboardImportPage.tsx +++ b/public/app/features/manage-dashboards/DashboardImportPage.tsx @@ -94,17 +94,14 @@ class UnthemedDashboardImport extends PureComponent { const json = JSON.parse(String(result)); if (json.spec?.elements) { - dispatch(importDashboardV2Json(json.spec)); - return; + return dispatch(importDashboardV2Json(json.spec)); } else if (json.elements) { - dispatch(importDashboardV2Json(json)); - return; + return dispatch(importDashboardV2Json(json)); } // check if it's a v1 resource format if (json.spec) { - this.props.importDashboardJson(json.spec); - return; + return this.props.importDashboardJson(json.spec); } this.props.importDashboardJson(json); @@ -123,20 +120,26 @@ class UnthemedDashboardImport extends PureComponent { const dashboard = JSON.parse(formData.dashboardJson); + if ((dashboard.spec?.elements || dashboard.elements) && !config.featureToggles.dashboardNewLayouts) { + return appEvents.emit(AppEvents.alertError, [ + 'Import failed', + 'Dashboard using new layout cannot be imported because the feature is not enabled', + ]); + } + // check if it's a v2 resource format if (dashboard.spec?.elements) { - dispatch(importDashboardV2Json(dashboard.spec)); - return; - // check if it's just a v2 spec - } else if (dashboard.elements) { - dispatch(importDashboardV2Json(dashboard)); - return; + return dispatch(importDashboardV2Json(dashboard.spec)); + } + + // check if it's just a v2 spec + if (dashboard.elements) { + return dispatch(importDashboardV2Json(dashboard)); } // check if it's a v1 resource format if (dashboard.spec) { - this.props.importDashboardJson(dashboard.spec); - return; + return this.props.importDashboardJson(dashboard.spec); } this.props.importDashboardJson(dashboard); From b407f0062d07ba7436d5bacb52ecc6d88612861f Mon Sep 17 00:00:00 2001 From: Steve Simpson Date: Thu, 11 Dec 2025 23:34:37 +0000 Subject: [PATCH 089/139] Alerting: Add an authorizer to the historian app (#115188) historian: add an authorizer Co-authored-by: Charandas Batra --- pkg/registry/apps/alerting/historian/register.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/registry/apps/alerting/historian/register.go b/pkg/registry/apps/alerting/historian/register.go index 68830dcd0ef..725cb2fae8d 100644 --- a/pkg/registry/apps/alerting/historian/register.go +++ b/pkg/registry/apps/alerting/historian/register.go @@ -1,9 +1,12 @@ package historian import ( + "context" + "github.com/grafana/grafana-app-sdk/app" appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" "github.com/grafana/grafana-app-sdk/simple" + "k8s.io/apiserver/pkg/authorization/authorizer" restclient "k8s.io/client-go/rest" "github.com/grafana/grafana/apps/alerting/historian/pkg/apis" @@ -23,6 +26,14 @@ type AlertingHistorianAppInstaller struct { appsdkapiserver.AppInstaller } +func (a *AlertingHistorianAppInstaller) GetAuthorizer() authorizer.Authorizer { + return authorizer.AuthorizerFunc( + func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + return authorizer.DecisionAllow, "", nil + }, + ) +} + func RegisterAppInstaller( cfg *setting.Cfg, ng *ngalert.AlertNG, From c3224411c0c04f8d6965aa33b63dc3ff4b548c25 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 12 Dec 2025 07:45:04 +0100 Subject: [PATCH 090/139] NPM: Use env var for OIDC token auth instead of direct npmrc (#115153) * use env var * ignore spellcheck --- scripts/publish-npm-packages.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/publish-npm-packages.sh b/scripts/publish-npm-packages.sh index 6ee770ee840..3131c791d36 100755 --- a/scripts/publish-npm-packages.sh +++ b/scripts/publish-npm-packages.sh @@ -100,8 +100,11 @@ if (( CHANGES_COUNT > 0 )); then if [ -n "$NPM_AUTH_TOKEN" ]; then # Mask the token so it won't appear in logs echo "::add-mask::$NPM_AUTH_TOKEN" - echo "Configuring npm auth token in ~/.npmrc" - echo "//registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN}" >> ~/.npmrc + echo "Configuring npm auth via NPM_TOKEN env var" + export NPM_TOKEN="$NPM_AUTH_TOKEN" + # Reference the env var in npmrc (single quotes intentional - npm expands it at runtime) + # shellcheck disable=SC2016 + echo '//registry.npmjs.org/:_authToken=${NPM_TOKEN}' >> ~/.npmrc else echo "Warning: No token in response, dist-tag operation may fail" fi From 35c214249f11cad9425c03a7a68615977b9d1205 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 12 Dec 2025 08:59:10 +0100 Subject: [PATCH 091/139] E2E Selectors: Fix comment typo (#115197) fix typo --- packages/grafana-e2e-selectors/src/selectors/components.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index d6e34db61ca..f1f2ce08642 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -3,7 +3,7 @@ // (a
)} - {!hideLinksControls && !editPanel && }
{!hideVariableControls && ( <> @@ -179,6 +178,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps )} + {!hideLinksControls && !editPanel && } {!hideDashboardControls && hasDashboardControls && } {editPanel && } {showDebugger && } diff --git a/public/app/features/dashboard-scene/scene/DashboardLinkRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardLinkRenderer.tsx index e7bc346642b..d2571314782 100644 --- a/public/app/features/dashboard-scene/scene/DashboardLinkRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardLinkRenderer.tsx @@ -64,7 +64,6 @@ function getStyles(theme: GrafanaTheme2) { alignItems: 'center', verticalAlign: 'middle', marginBottom: theme.spacing(1), - marginRight: theme.spacing(1), }), }; } diff --git a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx index 11639554e5d..46fcf64d121 100644 --- a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx @@ -36,13 +36,9 @@ export function DashboardLinksControls({ links, dashboard }: Props) { function getStyles(theme: GrafanaTheme2) { return { linksContainer: css({ - display: 'flex', - flexWrap: 'wrap', + display: 'inline-flex', gap: theme.spacing(1), - maxWidth: '100%', - minWidth: 0, - order: 1, - flex: '1 1 0%', + marginRight: theme.spacing(1), }), }; } diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index 1061938e5e6..03d23712c7b 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -19,7 +19,6 @@ import { AddVariableButton } from './VariableControlsAddButton'; export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { const { variables } = sceneGraph.getVariables(dashboard)!.useState(); - const styles = useStyles2(getStyles); return ( <> @@ -28,11 +27,7 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { .map((variable) => ( ))} - {config.featureToggles.dashboardNewLayouts ? ( -
- -
- ) : null} + {config.featureToggles.dashboardNewLayouts ? : null} ); } @@ -211,11 +206,4 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: 'flex', alignItems: 'center', }), - addButton: css({ - display: 'inline-flex', - alignItems: 'center', - verticalAlign: 'middle', - marginBottom: theme.spacing(1), - marginRight: theme.spacing(1), - }), }); diff --git a/public/app/features/dashboard-scene/scene/VariableControlsAddButton.tsx b/public/app/features/dashboard-scene/scene/VariableControlsAddButton.tsx index 8bdabd397d1..75d48144146 100644 --- a/public/app/features/dashboard-scene/scene/VariableControlsAddButton.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControlsAddButton.tsx @@ -1,7 +1,9 @@ +import { css } from '@emotion/css'; import { PointerEventHandler, useCallback } from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; -import { Button } from '@grafana/ui'; +import { Button, useStyles2 } from '@grafana/ui'; import { openAddVariablePane } from '../settings/variables/VariableAddEditableElement'; import { DashboardInteractions } from '../utils/interactions'; @@ -9,6 +11,7 @@ import { DashboardInteractions } from '../utils/interactions'; import { DashboardScene } from './DashboardScene'; export function AddVariableButton({ dashboard }: { dashboard: DashboardScene }) { + const styles = useStyles2(getStyles); const { editview, editPanel, isEditing, viewPanel } = dashboard.useState(); const handlePointerDown: PointerEventHandler = useCallback( @@ -30,10 +33,22 @@ export function AddVariableButton({ dashboard }: { dashboard: DashboardScene }) } return ( -
- +
+
+ +
); } + +const getStyles = (theme: GrafanaTheme2) => ({ + addButton: css({ + display: 'inline-flex', + alignItems: 'center', + verticalAlign: 'middle', + marginBottom: theme.spacing(1), + marginRight: theme.spacing(1), + }), +}); diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx index 43d1dda185f..ae5cffb9b31 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx @@ -182,7 +182,6 @@ function getStyles(theme: GrafanaTheme2) { alignItems: 'center', verticalAlign: 'middle', marginBottom: theme.spacing(1), - marginRight: theme.spacing(1), }), }; } From 7805e18368799443543b9008affd21fec8831c1a Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 12 Dec 2025 07:56:31 -0500 Subject: [PATCH 108/139] Sparkline: Export a class component for now (#115189) --- .../src/components/Sparkline/Sparkline.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index 68b1a5a832f..c18b235e757 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -16,7 +16,7 @@ export interface SparklineProps extends Themeable2 { sparkline: FieldSparkline; } -export const Sparkline: React.FC = memo((props) => { +const SparklineFn: React.FC = memo((props) => { const { sparkline, config: fieldConfig, theme, width, height } = props; const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig); @@ -30,4 +30,14 @@ export const Sparkline: React.FC = memo((props) => { return ; }); -Sparkline.displayName = 'Sparkline'; +SparklineFn.displayName = 'Sparkline'; + +// we converted to function component above, but some apps extend Sparkline, so we need +// to keep exporting a class component until those apps are all rolled out. +// see https://github.com/grafana/app-observability-plugin/pull/2079 +// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component +export class Sparkline extends React.PureComponent { + render() { + return ; + } +} From e525b529a861614aa5f4870272f8a297bbce8b2c Mon Sep 17 00:00:00 2001 From: Charandas <542168+charandas@users.noreply.github.com> Date: Fri, 12 Dec 2025 05:01:03 -0800 Subject: [PATCH 109/139] fix: Add panic for nil authorizer in installer (#115186) --- pkg/registry/apis/service/register.go | 1 + pkg/registry/apps/correlations/register.go | 7 ++++ pkg/registry/apps/playlist/register.go | 8 +++++ pkg/registry/apps/quotas/register.go | 7 ++++ .../apiserver/appinstaller/installer.go | 2 ++ .../apiserver/appinstaller/installer_test.go | 33 ++++++++++++++----- .../apiserver/auth/authorizer/authorizer.go | 8 ++--- .../apiserver/auth/authorizer/role.go | 1 + 8 files changed, 55 insertions(+), 12 deletions(-) diff --git a/pkg/registry/apis/service/register.go b/pkg/registry/apis/service/register.go index 1002bff5bf6..db909b08777 100644 --- a/pkg/registry/apis/service/register.go +++ b/pkg/registry/apis/service/register.go @@ -38,6 +38,7 @@ func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration bui } func (b *ServiceAPIBuilder) GetAuthorizer() authorizer.Authorizer { + //nolint:staticcheck // not yet migrated to Resource Authorizer return roleauthorizer.NewRoleAuthorizer() } diff --git a/pkg/registry/apps/correlations/register.go b/pkg/registry/apps/correlations/register.go index 757af68a8ce..2a3b1f0bde5 100644 --- a/pkg/registry/apps/correlations/register.go +++ b/pkg/registry/apps/correlations/register.go @@ -5,6 +5,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authorization/authorizer" restclient "k8s.io/client-go/rest" "github.com/grafana/grafana-app-sdk/app" @@ -16,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/appinstaller" + roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/correlations" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -60,6 +62,11 @@ func RegisterAppInstaller( return installer, nil } +func (a *AppInstaller) GetAuthorizer() authorizer.Authorizer { + //nolint:staticcheck // not yet migrated to Resource Authorizer + return roleauthorizer.NewRoleAuthorizer() +} + func (a *AppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) rest.Storage { kind := correlationsV0.CorrelationKind() gvr := schema.GroupVersionResource{ diff --git a/pkg/registry/apps/playlist/register.go b/pkg/registry/apps/playlist/register.go index 52bbc0210f9..336270098e4 100644 --- a/pkg/registry/apps/playlist/register.go +++ b/pkg/registry/apps/playlist/register.go @@ -6,17 +6,20 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authorization/authorizer" restclient "k8s.io/client-go/rest" "github.com/grafana/grafana-app-sdk/app" appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" "github.com/grafana/grafana-app-sdk/simple" + "github.com/grafana/grafana/apps/playlist/pkg/apis" playlistv0alpha1 "github.com/grafana/grafana/apps/playlist/pkg/apis/playlist/v0alpha1" playlistapp "github.com/grafana/grafana/apps/playlist/pkg/app" "github.com/grafana/grafana/pkg/apimachinery/utils" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/appinstaller" + roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/featuremgmt" playlistsvc "github.com/grafana/grafana/pkg/services/playlist" @@ -63,6 +66,11 @@ func RegisterAppInstaller( return installer, nil } +func (p *PlaylistAppInstaller) GetAuthorizer() authorizer.Authorizer { + //nolint:staticcheck // not yet migrated to Resource Authorizer + return roleauthorizer.NewRoleAuthorizer() +} + // GetLegacyStorage returns the legacy storage for the playlist app. func (p *PlaylistAppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) grafanarest.Storage { gvr := playlistv0alpha1.PlaylistKind().GroupVersionResource() diff --git a/pkg/registry/apps/quotas/register.go b/pkg/registry/apps/quotas/register.go index b81d4fef5cf..c6b33bc4d00 100644 --- a/pkg/registry/apps/quotas/register.go +++ b/pkg/registry/apps/quotas/register.go @@ -3,12 +3,14 @@ package quotas import ( "github.com/grafana/grafana/apps/quotas/pkg/apis" "github.com/grafana/grafana/pkg/storage/unified/resource" + "k8s.io/apiserver/pkg/authorization/authorizer" restclient "k8s.io/client-go/rest" "github.com/grafana/grafana-app-sdk/app" appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" "github.com/grafana/grafana-app-sdk/simple" quotasapp "github.com/grafana/grafana/apps/quotas/pkg/app" + roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) @@ -22,6 +24,11 @@ type QuotasAppInstaller struct { cfg *setting.Cfg } +func (a *QuotasAppInstaller) GetAuthorizer() authorizer.Authorizer { + //nolint:staticcheck // not yet migrated to Resource Authorizer + return roleauthorizer.NewRoleAuthorizer() +} + func RegisterAppInstaller( cfg *setting.Cfg, features featuremgmt.FeatureToggles, diff --git a/pkg/services/apiserver/appinstaller/installer.go b/pkg/services/apiserver/appinstaller/installer.go index c7b926bd5f3..d282b42f547 100644 --- a/pkg/services/apiserver/appinstaller/installer.go +++ b/pkg/services/apiserver/appinstaller/installer.go @@ -114,6 +114,8 @@ func RegisterAuthorizers( registrar.Register(gv, authorizer) logger.Debug("Registered authorizer", "group", gv.Group, "version", gv.Version, "app") } + } else { + panic("authorizer cannot be nil for api group: " + installer.GroupVersions()[0].Group) } } } diff --git a/pkg/services/apiserver/appinstaller/installer_test.go b/pkg/services/apiserver/appinstaller/installer_test.go index 89c4bfa0164..53c2d2dbedf 100644 --- a/pkg/services/apiserver/appinstaller/installer_test.go +++ b/pkg/services/apiserver/appinstaller/installer_test.go @@ -15,6 +15,7 @@ func TestRegisterAuthorizers(t *testing.T) { name string appInstallers []appsdkapiserver.AppInstaller expectedRegisters int + expectedPanic bool }{ { name: "empty installers list", @@ -30,7 +31,7 @@ func TestRegisterAuthorizers(t *testing.T) { }, }, }, - expectedRegisters: 0, + expectedPanic: true, }, { name: "single installer with authorizer provider", @@ -46,6 +47,20 @@ func TestRegisterAuthorizers(t *testing.T) { }, expectedRegisters: 1, }, + { + name: "single installer with invalid authorizer provider", + appInstallers: []appsdkapiserver.AppInstaller{ + &mockAppInstallerWithAuth{ + mockAppInstaller: &mockAppInstaller{ + groupVersions: []schema.GroupVersion{ + {Group: "test.example.com", Version: "v1"}, + }, + }, + mockAuthorizer: nil, + }, + }, + expectedPanic: true, + }, { name: "installer with multiple group versions", appInstallers: []appsdkapiserver.AppInstaller{ @@ -63,7 +78,7 @@ func TestRegisterAuthorizers(t *testing.T) { expectedRegisters: 3, }, { - name: "multiple installers with mixed authorizer support", + name: "multiple installers with authorizer support", appInstallers: []appsdkapiserver.AppInstaller{ &mockAppInstallerWithAuth{ mockAppInstaller: &mockAppInstaller{ @@ -73,11 +88,6 @@ func TestRegisterAuthorizers(t *testing.T) { }, mockAuthorizer: &mockAuthorizer{}, }, - &mockAppInstaller{ - groupVersions: []schema.GroupVersion{ - {Group: "other.example.com", Version: "v1"}, - }, - }, &mockAppInstallerWithAuth{ mockAppInstaller: &mockAppInstaller{ groupVersions: []schema.GroupVersion{ @@ -88,7 +98,7 @@ func TestRegisterAuthorizers(t *testing.T) { mockAuthorizer: &mockAuthorizer{}, }, }, - expectedRegisters: 3, // 1 from first installer + 2 from third installer + expectedRegisters: 3, // 1 from first installer + 2 from second installer }, } @@ -96,6 +106,13 @@ func TestRegisterAuthorizers(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() registrar := &mockAuthorizerRegistrar{} + if tt.expectedPanic { + defer func() { + if r := recover(); r == nil { + t.Errorf("%s case did not panic as expected", t.Name()) + } + }() + } RegisterAuthorizers(ctx, tt.appInstallers, registrar) require.Equal(t, tt.expectedRegisters, len(registrar.registrations)) }) diff --git a/pkg/services/apiserver/auth/authorizer/authorizer.go b/pkg/services/apiserver/auth/authorizer/authorizer.go index f58c1d14bf8..dab8167deb0 100644 --- a/pkg/services/apiserver/auth/authorizer/authorizer.go +++ b/pkg/services/apiserver/auth/authorizer/authorizer.go @@ -38,12 +38,12 @@ func NewGrafanaBuiltInSTAuthorizer(cfg *setting.Cfg) *GrafanaAuthorizer { // Individual services may have explicit implementations apis := make(map[string]authorizer.Authorizer) + // The apiVersion flavors will run first and can return early when FGAC has appropriate rules authorizers = append(authorizers, &authorizerForAPI{apis}) - // org role is last -- and will return allow for verbs that match expectations - // The apiVersion flavors will run first and can return early when FGAC has appropriate rules - // NOTE: role authorizer is now used by some api groups as their specific authorizer - // but there are still some apis not directly registered in the embedded delegate that benefit from including it here + // org role authorizer is last -- and will return allow for verbs that match expectations + // it is only helpful here for remote APIs in some cloud use-cases. + //nolint:staticcheck // remove once build handler chains are untangled between local and remote APIs handling authorizers = append(authorizers, NewRoleAuthorizer()) return &GrafanaAuthorizer{ apis: apis, diff --git a/pkg/services/apiserver/auth/authorizer/role.go b/pkg/services/apiserver/auth/authorizer/role.go index 23164dbf556..e8e70dd01c8 100644 --- a/pkg/services/apiserver/auth/authorizer/role.go +++ b/pkg/services/apiserver/auth/authorizer/role.go @@ -19,6 +19,7 @@ var orgRoleNoneAsViewerAPIGroups = []string{ type roleAuthorizer struct{} +// Deprecated: NewRoleAuthorizer exists for apps that were launched with simplistic authorization requirements. Consider using NewResourceAuthorizer instead. func NewRoleAuthorizer() *roleAuthorizer { return &roleAuthorizer{} } From b2dd095bd860cfbc9187e0fdf91cae18206044e4 Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Fri, 12 Dec 2025 08:09:51 -0500 Subject: [PATCH 110/139] Unified-storage: sql backend key path backfill (#115033) * unified-storage: add migration to backfill key_path in resource_history --- .../unified/sql/db/migrations/resource_mig.go | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/pkg/storage/unified/sql/db/migrations/resource_mig.go b/pkg/storage/unified/sql/db/migrations/resource_mig.go index fbbfe32d4a6..170b418a22a 100644 --- a/pkg/storage/unified/sql/db/migrations/resource_mig.go +++ b/pkg/storage/unified/sql/db/migrations/resource_mig.go @@ -2,8 +2,11 @@ package migrations import ( "fmt" + "strings" + "github.com/bwmarrin/snowflake" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/util/xorm" ) func initResourceTables(mg *migrator.Migrator) string { @@ -204,5 +207,142 @@ func initResourceTables(mg *migrator.Migrator) string { Name: "IDX_resource_history_key_path", })) + mg.AddMigration("resource_history key_path backfill", &ResourceHistoryKeyPathBackfillMigration{}) + return marker } + +type ResourceHistoryKeyPathBackfillMigration struct { + migrator.MigrationBase +} + +func (m *ResourceHistoryKeyPathBackfillMigration) SQL(_ migrator.Dialect) string { + return "resource_history key_path backfill code migration" +} + +func (m *ResourceHistoryKeyPathBackfillMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error { + rows, err := getResourceHistoryRows(sess, mg, resourceHistoryRow{}) + if err != nil { + return err + } + + for len(rows) > 0 { + if err := updateResourceHistoryKeyPath(sess, rows); err != nil { + return err + } + + rows, err = getResourceHistoryRows(sess, mg, rows[len(rows)-1]) + if err != nil { + return err + } + } + + return nil +} + +func updateResourceHistoryKeyPath(sess *xorm.Session, rows []resourceHistoryRow) error { + if len(rows) == 0 { + return nil + } + + updates := []resourceHistoryRow{} + + for _, row := range rows { + if row.KeyPath == "" { + row.KeyPath = parseKeyPath(row) + updates = append(updates, row) + } + } + + if len(updates) == 0 { + return nil + } + + guids := "" + setCases := "CASE" + for _, row := range updates { + guids += fmt.Sprintf("'%s',", row.GUID) + setCases += fmt.Sprintf(" WHEN guid = '%s' THEN '%s'", row.GUID, row.KeyPath) + } + + guids = strings.TrimRight(guids, ",") + setCases += " ELSE key_path END " + + // the query will look like this + // UPDATE resource_history + // SET key_path = CASE + // WHEN guid = '1402de51-669b-4206-8a6c-005a00eee6e3' then 'unified/data/folder.grafana.app/folders/default/cf6lylpvls000c/1998492888241012800~created~' + // WHEN guid = '8842cc56-f22b-45e1-82b1-99759cd443b3' then 'unified/data/dashboard.grafana.app/dashboards/default/adzvfhp/1998492902577144677~created~cf6lylpvls000c' + // ELSE key_path END + // WHERE guid IN ('1402de51-669b-4206-8a6c-005a00eee6e3', '8842cc56-f22b-45e1-82b1-99759cd443b3') + // AND key_path = ''; + sql := fmt.Sprintf(` + UPDATE resource_history + SET key_path = %s + WHERE guid IN (%s) + AND key_path = ''; + `, setCases, guids) + + if _, err := sess.Exec(sql); err != nil { + return err + } + + return nil +} + +func parseKeyPath(row resourceHistoryRow) string { + var action string + switch row.Action { + case 1: + action = "created" + case 2: + action = "updated" + case 3: + action = "deleted" + } + return fmt.Sprintf("unified/data/%s/%s/%s/%s/%d~%s~%s", row.Group, row.Resource, row.Namespace, row.Name, snowflakeFromRv(row.ResourceVersion), action, row.Folder) +} + +func snowflakeFromRv(rv int64) int64 { + return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000) +} + +type resourceHistoryRow struct { + GUID string `xorm:"guid"` + Group string `xorm:"group"` + Resource string `xorm:"resource"` + Namespace string `xorm:"namespace"` + Name string `xorm:"name"` + ResourceVersion int64 `xorm:"resource_version"` + Action int64 `xorm:"action"` + Folder string `xorm:"folder"` + KeyPath string `xorm:"key_path"` +} + +func getResourceHistoryRows(sess *xorm.Session, mg *migrator.Migrator, continueRow resourceHistoryRow) ([]resourceHistoryRow, error) { + var rows []resourceHistoryRow + cols := fmt.Sprintf( + "%s, %s, %s, %s, %s, %s, %s, %s, %s", + mg.Dialect.Quote("guid"), + mg.Dialect.Quote("group"), + mg.Dialect.Quote("resource"), + mg.Dialect.Quote("namespace"), + mg.Dialect.Quote("name"), + mg.Dialect.Quote("resource_version"), + mg.Dialect.Quote("action"), + mg.Dialect.Quote("folder"), + mg.Dialect.Quote("key_path")) + sql := fmt.Sprintf(` + SELECT %s + FROM resource_history + WHERE (resource_version > %d OR (resource_version = %d AND guid > '%s')) + AND key_path = '' + ORDER BY resource_version ASC, guid ASC + LIMIT 1000; + `, cols, continueRow.ResourceVersion, continueRow.ResourceVersion, continueRow.GUID) + if err := sess.SQL(sql).Find(&rows); err != nil { + return nil, err + } + + return rows, nil +} From 6512259acc2d56d8f9b2d17b2c2f4cc31b041044 Mon Sep 17 00:00:00 2001 From: Juan Cabanas Date: Fri, 12 Dec 2025 10:10:05 -0300 Subject: [PATCH 111/139] DashboardLibrary: Restore New dashboard naming (#115184) --- public/app/core/utils/navBarItem-translations.ts | 5 +---- public/app/features/search/tempI18nPhrases.ts | 5 +---- public/locales/en-US/grafana.json | 2 -- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index 0ac5619e893..0fab359de4e 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -1,5 +1,4 @@ import { t } from '@grafana/i18n'; -import { config } from '@grafana/runtime'; // Maps the ID of the nav item to a translated phrase to later pass to // Because the navigation content is dynamic (defined in the backend), we can not use // the normal inline message definition method. @@ -49,9 +48,7 @@ export function getNavTitle(navId: string | undefined) { case 'dashboards/recently-deleted': return t('nav.recently-deleted.title', 'Recently deleted'); case 'dashboards/new': - return config.featureToggles.dashboardTemplates - ? t('nav.new-dashboard.empty-title', 'Empty dashboard') - : t('nav.new-dashboard.title', 'New dashboard'); + return t('nav.new-dashboard.title', 'New dashboard'); case 'dashboards/folder/new': return t('nav.new-folder.title', 'New folder'); case 'dashboards/import': diff --git a/public/app/features/search/tempI18nPhrases.ts b/public/app/features/search/tempI18nPhrases.ts index 73766e4bff6..926bf24ea73 100644 --- a/public/app/features/search/tempI18nPhrases.ts +++ b/public/app/features/search/tempI18nPhrases.ts @@ -2,7 +2,6 @@ // TODO: remove this when new Browse Dashboards UI is no longer feature flagged import { t } from '@grafana/i18n'; -import { config } from '@grafana/runtime'; export function getSearchPlaceholder(includePanels = false) { return includePanels @@ -11,9 +10,7 @@ export function getSearchPlaceholder(includePanels = false) { } export function getNewDashboardPhrase() { - return config.featureToggles.dashboardTemplates - ? t('search.dashboard-actions.empty-dashboard', 'Empty dashboard') - : t('search.dashboard-actions.new-dashboard', 'New dashboard'); + return t('search.dashboard-actions.new-dashboard', 'New dashboard'); } export function getNewTemplateDashboardPhrase() { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index f0848b1fe3d..6c760e94814 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -10724,7 +10724,6 @@ "title": "New" }, "new-dashboard": { - "empty-title": "Empty dashboard", "title": "New dashboard" }, "new-folder": { @@ -12634,7 +12633,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "Empty dashboard", "import": "Import", "new": "New", "new-dashboard": "New dashboard", From 403f4d41deadd54c5af51b60a28ef3af868771ab Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Fri, 12 Dec 2025 15:17:44 +0100 Subject: [PATCH 112/139] APIServer: Add wiring for audit backend and policy rule evaluator (#115212) --- pkg/apiserver/auditing/noop.go | 29 +++++++++++++++++++++++++++++ pkg/registry/apis/wireset.go | 5 +++++ pkg/server/wire_gen.go | 9 +++++++-- pkg/services/apiserver/service.go | 12 ++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 pkg/apiserver/auditing/noop.go diff --git a/pkg/apiserver/auditing/noop.go b/pkg/apiserver/auditing/noop.go new file mode 100644 index 00000000000..5ab8f902c19 --- /dev/null +++ b/pkg/apiserver/auditing/noop.go @@ -0,0 +1,29 @@ +package auditing + +import ( + auditinternal "k8s.io/apiserver/pkg/apis/audit" + "k8s.io/apiserver/pkg/audit" + "k8s.io/apiserver/pkg/authorization/authorizer" +) + +// NoopBackend is a no-op implementation of audit.Backend +type NoopBackend struct{} + +func ProvideNoopBackend() audit.Backend { return &NoopBackend{} } + +func (b *NoopBackend) ProcessEvents(k8sEvents ...*auditinternal.Event) bool { return false } + +func (NoopBackend) Run(stopCh <-chan struct{}) error { return nil } + +func (NoopBackend) Shutdown() {} + +func (NoopBackend) String() string { return "" } + +// NoopPolicyRuleEvaluator is a no-op implementation of audit.PolicyRuleEvaluator +type NoopPolicyRuleEvaluator struct{} + +func ProvideNoopPolicyRuleEvaluator() audit.PolicyRuleEvaluator { return &NoopPolicyRuleEvaluator{} } + +func (NoopPolicyRuleEvaluator) EvaluatePolicyRule(authorizer.Attributes) audit.RequestAuditConfig { + return audit.RequestAuditConfig{Level: auditinternal.LevelNone} +} diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index 12153f812a1..740f2a46cef 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -3,6 +3,7 @@ package apiregistry import ( "github.com/google/wire" + "github.com/grafana/grafana/pkg/apiserver/auditing" "github.com/grafana/grafana/pkg/registry/apis/collections" dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/datasource" @@ -33,6 +34,10 @@ var WireSetExts = wire.NewSet( externalgroupmapping.ProvideNoopTeamGroupsREST, wire.Bind(new(externalgroupmapping.TeamGroupsHandler), new(*externalgroupmapping.NoopTeamGroupsREST)), + + // Auditing Options + auditing.ProvideNoopBackend, + auditing.ProvideNoopPolicyRuleEvaluator, ) var provisioningExtras = wire.NewSet( diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 5782e7d7018..5abda77524a 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/avatar" "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/apiserver/auditing" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/expr" @@ -831,7 +832,9 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller, quotasAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) - apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) + backend := auditing.ProvideNoopBackend() + policyRuleEvaluator := auditing.ProvideNoopPolicyRuleEvaluator() + apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend, policyRuleEvaluator) if err != nil { return nil, err } @@ -1489,7 +1492,9 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller, quotasAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) - apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) + backend := auditing.ProvideNoopBackend() + policyRuleEvaluator := auditing.ProvideNoopPolicyRuleEvaluator() + apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend, policyRuleEvaluator) if err != nil { return nil, err } diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index ac38dee7e3f..5d9e37e649e 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -12,6 +12,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/audit" genericapifilters "k8s.io/apiserver/pkg/endpoints/filters" "k8s.io/apiserver/pkg/endpoints/responsewriter" genericapiserver "k8s.io/apiserver/pkg/server" @@ -113,6 +114,9 @@ type service struct { appInstallers []appsdkapiserver.AppInstaller builderMetrics *builder.BuilderMetrics dualWriterMetrics *grafanarest.DualWriterMetrics + + auditBackend audit.Backend + auditPolicyRuleEvaluator audit.PolicyRuleEvaluator } func ProvideService( @@ -137,6 +141,8 @@ func ProvideService( aggregatorRunner aggregatorrunner.AggregatorRunner, appInstallers []appsdkapiserver.AppInstaller, builderMetrics *builder.BuilderMetrics, + auditBackend audit.Backend, + auditPolicyRuleEvaluator audit.PolicyRuleEvaluator, ) (*service, error) { scheme := builder.ProvideScheme() codecs := builder.ProvideCodecFactory(scheme) @@ -167,6 +173,8 @@ func ProvideService( appInstallers: appInstallers, builderMetrics: builderMetrics, dualWriterMetrics: grafanarest.NewDualWriterMetrics(reg), + auditBackend: auditBackend, + auditPolicyRuleEvaluator: auditPolicyRuleEvaluator, } // This will be used when running as a dskit service s.NamedService = services.NewBasicService(s.start, s.running, nil).WithName(modules.GrafanaAPIServer) @@ -355,6 +363,10 @@ func (s *service) start(ctx context.Context) error { appinstaller.BuildOpenAPIDefGetter(s.appInstallers), } + // Auditing Options + serverConfig.AuditBackend = s.auditBackend + serverConfig.AuditPolicyRuleEvaluator = s.auditPolicyRuleEvaluator + // Add OpenAPI specs for each group+version (existing builders) err = builder.SetupConfig( s.scheme, From 6ce672dd00feca5e12b576e9a0b2f045b7d75351 Mon Sep 17 00:00:00 2001 From: Serge Zaitsev Date: Fri, 12 Dec 2025 22:37:43 +0800 Subject: [PATCH 113/139] Chore: Fix mysql query for annotation migration (#115222) fix mysql query for annotation migration --- .../sqlstore/migrations/annotation_mig.go | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg/services/sqlstore/migrations/annotation_mig.go b/pkg/services/sqlstore/migrations/annotation_mig.go index 914f73819fc..d1cd33b108f 100644 --- a/pkg/services/sqlstore/migrations/annotation_mig.go +++ b/pkg/services/sqlstore/migrations/annotation_mig.go @@ -261,8 +261,8 @@ func RunDashboardUIDMigrations(sess *xorm.Session, driverName string, logger log logger.Info("Starting batched dashboard_uid migration for annotations (newest first)", "batchSize", batchSize) updateSQL := `UPDATE annotation SET dashboard_uid = (SELECT uid FROM dashboard WHERE dashboard.id = annotation.dashboard_id) - WHERE dashboard_uid IS NULL - AND dashboard_id != 0 + WHERE dashboard_uid IS NULL + AND dashboard_id != 0 AND EXISTS (SELECT 1 FROM dashboard WHERE dashboard.id = annotation.dashboard_id) AND annotation.id IN ( SELECT id FROM annotation @@ -285,19 +285,19 @@ func RunDashboardUIDMigrations(sess *xorm.Session, driverName string, logger log LIMIT $1 )` case MySQL: - updateSQL = `UPDATE annotation - INNER JOIN dashboard ON annotation.dashboard_id = dashboard.id - SET annotation.dashboard_uid = dashboard.uid - WHERE annotation.dashboard_uid IS NULL - AND annotation.dashboard_id != 0 - AND annotation.id IN ( - SELECT id FROM ( - SELECT id FROM annotation - WHERE dashboard_uid IS NULL AND dashboard_id != 0 - ORDER BY id DESC - LIMIT ? - ) AS batch - )` + updateSQL = `UPDATE annotation AS a + JOIN dashboard AS d ON a.dashboard_id = d.id + JOIN ( + SELECT id + FROM annotation + WHERE dashboard_uid IS NULL + AND dashboard_id != 0 + ORDER BY id DESC + LIMIT ? + ) AS batch ON batch.id = a.id + SET a.dashboard_uid = d.uid + WHERE a.dashboard_uid IS NULL + AND a.dashboard_id != 0` } updatedTotal := int64(0) From 46ef9aaa0aad79bdebb8cc9d48037826a2c1a543 Mon Sep 17 00:00:00 2001 From: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com> Date: Fri, 12 Dec 2025 08:58:10 -0600 Subject: [PATCH 114/139] Alerting docs: Links fix (#115044) * alerting docs: links fix fixes 404 errors * Alerting docs: Fix Slack integration links Fixes Slack links and clarifies the first two steps. * prettier --- .../integrations/configure-alertmanager.md | 6 +++--- .../integrations/configure-jira.md | 16 ++++++++-------- .../integrations/configure-mqtt.md | 8 ++++---- .../integrations/configure-slack.md | 4 ++-- .../integrations/webhook-notifier.md | 6 +++--- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-alertmanager.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-alertmanager.md index 4cdc84ec366..f34a9eeb654 100644 --- a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-alertmanager.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-alertmanager.md @@ -59,9 +59,9 @@ For more details on contact points, including how to test them and enable notifi ## Alertmanager settings -| Option | Description | -| ------ | ---------------------------------------------------------------------------------------------------------------------------------- | -| URL | The Alertmanager URL. This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. | +| Option | Description | +| ------ | ----------------------------------------------------------------------------------------------------------------- | +| URL | The Alertmanager URL. This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. | #### Optional settings diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-jira.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-jira.md index 659341405f1..9f68f70ae08 100644 --- a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-jira.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-jira.md @@ -49,14 +49,14 @@ For more details on contact points, including how to test them and enable notifi ### Required Settings -| Key | Description | -| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| URL | The URL of the REST API of your Jira instance. Supported versions: `2` and `3` (e.g., `https://your-domain.atlassian.net/rest/api/3`). This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. | -| Basic Auth User | Username for authentication. For Jira Cloud, use your email address. | -| Basic Auth Password | Password or personal token. For Jira Cloud, you need to obtain a personal token [here](https://id.atlassian.com/manage-profile/security/api-tokens) and use it as the password. | -| API Token | An alternative to basic authentication, a bearer token is used to authorize the API requests. See [Jira documentation](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) for more information. | -| Project Key | The project key identifying the project where issues will be created. Project keys are unique identifiers for a project. | -| Issue Type | The type of issue to create (e.g., `Task`, `Bug`, `Incident`). Make sure that you specify a type that is available in your project. | +| Key | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| URL | The URL of the REST API of your Jira instance. Supported versions: `2` and `3` (e.g., `https://your-domain.atlassian.net/rest/api/3`). This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. | +| Basic Auth User | Username for authentication. For Jira Cloud, use your email address. | +| Basic Auth Password | Password or personal token. For Jira Cloud, you need to obtain a personal token [here](https://id.atlassian.com/manage-profile/security/api-tokens) and use it as the password. | +| API Token | An alternative to basic authentication, a bearer token is used to authorize the API requests. See [Jira documentation](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) for more information. | +| Project Key | The project key identifying the project where issues will be created. Project keys are unique identifiers for a project. | +| Issue Type | The type of issue to create (e.g., `Task`, `Bug`, `Incident`). Make sure that you specify a type that is available in your project. | ### Optional Settings diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-mqtt.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-mqtt.md index 6f76a574619..3f6a403d27a 100644 --- a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-mqtt.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-mqtt.md @@ -54,10 +54,10 @@ For more details on contact points, including how to test them and enable notifi ### Required Settings -| Option | Description | -| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| Broker URL | The URL of the MQTT broker. This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. | -| Topic | The topic to which the message will be sent. | +| Option | Description | +| ---------- | ----------------------------------------------------------------------------------------------------------------------- | +| Broker URL | The URL of the MQTT broker. This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. | +| Topic | The topic to which the message will be sent. | ### Optional Settings diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md index 8f56174ed60..3bda324ec23 100644 --- a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/configure-slack.md @@ -51,8 +51,8 @@ You can customize the `title` and `body` of the Slack message using [notificatio If you are using a Slack API Token, complete the following steps. -1. Follow steps 1 and 2 of the [Slack API Quickstart](https://api.slack.com/start/quickstart). -1. Add the [chat:write.public](https://api.slack.com/scopes/chat:write.public) scope to give your app the ability to post in all public channels without joining. +1. Follow step 1 of the [Slack API Quickstart](https://docs.slack.dev/app-management/quickstart-app-settings/#creating) to create the app. +1. Continue onto the second step of the [Slack API Quickstart](https://docs.slack.dev/app-management/quickstart-app-settings/#scopes) and add the [chat:write.public](https://api.slack.com/scopes/chat:write.public) scope as described to give your app the ability to post in all public channels without joining. 1. In OAuth Tokens for Your Workspace, copy the Bot User OAuth Token. 1. Open your Slack workplace. 1. Right click the channel you want to receive notifications in. diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md index 120a45be2a2..d24b54fc568 100644 --- a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md @@ -62,9 +62,9 @@ For more details on contact points, including how to test them and enable notifi ## Webhook settings -| Option | Description | -| ------ | ----------------------------------------------------------------------------------------------------------------------------- | -| URL | The Webhook URL. This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. | +| Option | Description | +| ------ | ------------------------------------------------------------------------------------------------------------ | +| URL | The Webhook URL. This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. | #### Optional settings From d4a627c5fc9c5e646675673f65db57b7f4857d83 Mon Sep 17 00:00:00 2001 From: Gonzalo Trigueros Manzanas <242162051+gttrigger@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:59:45 +0100 Subject: [PATCH 115/139] Provisioning: Add resource-level warning support. (#115023) --- .../pkg/apis/provisioning/v0alpha1/jobs.go | 17 +- .../v0alpha1/zz_generated.deepcopy.go | 10 ++ .../v0alpha1/zz_generated.openapi.go | 37 +++- ...enerated.openapi_violation_exceptions.list | 2 + .../v0alpha1/jobresourcesummary.go | 40 +++-- .../provisioning/v0alpha1/jobstatus.go | 11 ++ .../provisioning/v0alpha1/endpoints.gen.ts | 6 +- .../apis/provisioning/jobs/progress.go | 31 +++- .../apis/provisioning/jobs/progress_test.go | 166 ++++++++++++++++++ .../provisioning.grafana.app-v0alpha1.json | 21 ++- 10 files changed, 314 insertions(+), 27 deletions(-) diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/jobs.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/jobs.go index b96fb1a6d27..2e7700ac4a2 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/jobs.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/jobs.go @@ -198,6 +198,7 @@ type JobStatus struct { Finished int64 `json:"finished,omitempty"` Message string `json:"message,omitempty"` Errors []string `json:"errors,omitempty"` + Warnings []string `json:"warnings,omitempty"` // Optional value 0-100 that can be set while running Progress float64 `json:"progress,omitempty"` @@ -225,18 +226,20 @@ type JobResourceSummary struct { Kind string `json:"kind,omitempty"` Total int64 `json:"total,omitempty"` // the count (if known) - Create int64 `json:"create,omitempty"` - Update int64 `json:"update,omitempty"` - Delete int64 `json:"delete,omitempty"` - Write int64 `json:"write,omitempty"` // Create or update (export) - Error int64 `json:"error,omitempty"` // The error count + Create int64 `json:"create,omitempty"` + Update int64 `json:"update,omitempty"` + Delete int64 `json:"delete,omitempty"` + Write int64 `json:"write,omitempty"` // Create or update (export) + Error int64 `json:"error,omitempty"` // The error count + Warning int64 `json:"warning,omitempty"` // The warning count // No action required (useful for sync) Noop int64 `json:"noop,omitempty"` - // Report errors for this resource type + // Report errors/warnings for this resource type // This may not be an exhaustive list and recommend looking at the logs for more info - Errors []string `json:"errors,omitempty"` + Errors []string `json:"errors,omitempty"` + Warnings []string `json:"warnings,omitempty"` } // HistoricJob is an append only log, saving all jobs that have been processed. diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index 8a3def39e8d..4bf4f7674ff 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -401,6 +401,11 @@ func (in *JobResourceSummary) DeepCopyInto(out *JobResourceSummary) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.Warnings != nil { + in, out := &in.Warnings, &out.Warnings + *out = make([]string, len(*in)) + copy(*out, *in) + } return } @@ -468,6 +473,11 @@ func (in *JobStatus) DeepCopyInto(out *JobStatus) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.Warnings != nil { + in, out := &in.Warnings, &out.Warnings + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.Summary != nil { in, out := &in.Summary, &out.Summary *out = make([]*JobResourceSummary, len(*in)) diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 9a4a99d703a..933525eca0b 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -889,6 +889,13 @@ func schema_pkg_apis_provisioning_v0alpha1_JobResourceSummary(ref common.Referen Format: "int64", }, }, + "warning": { + SchemaProps: spec.SchemaProps{ + Description: "The error count", + Type: []string{"integer"}, + Format: "int64", + }, + }, "noop": { SchemaProps: spec.SchemaProps{ Description: "No action required (useful for sync)", @@ -898,7 +905,7 @@ func schema_pkg_apis_provisioning_v0alpha1_JobResourceSummary(ref common.Referen }, "errors": { SchemaProps: spec.SchemaProps{ - Description: "Report errors for this resource type This may not be an exhaustive list and recommend looking at the logs for more info", + Description: "Report errors/warnings for this resource type This may not be an exhaustive list and recommend looking at the logs for more info", Type: []string{"array"}, Items: &spec.SchemaOrArray{ Schema: &spec.Schema{ @@ -911,6 +918,20 @@ func schema_pkg_apis_provisioning_v0alpha1_JobResourceSummary(ref common.Referen }, }, }, + "warnings": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, }, }, }, @@ -1029,6 +1050,20 @@ func schema_pkg_apis_provisioning_v0alpha1_JobStatus(ref common.ReferenceCallbac }, }, }, + "warnings": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, "progress": { SchemaProps: spec.SchemaProps{ Description: "Optional value 0-100 that can be set while running", diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list index c67b1462a9e..53071a05ec8 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -3,8 +3,10 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioni API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,FileList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,HistoryList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Errors +API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Warnings API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,Errors API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,Summary +API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,Warnings API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ManagerStats,Stats API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,MoveJobOptions,Paths API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,MoveJobOptions,Resources diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/jobresourcesummary.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/jobresourcesummary.go index ed6a62f651a..8986d2f85a6 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/jobresourcesummary.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/jobresourcesummary.go @@ -7,16 +7,18 @@ package v0alpha1 // JobResourceSummaryApplyConfiguration represents a declarative configuration of the JobResourceSummary type for use // with apply. type JobResourceSummaryApplyConfiguration struct { - Group *string `json:"group,omitempty"` - Kind *string `json:"kind,omitempty"` - Total *int64 `json:"total,omitempty"` - Create *int64 `json:"create,omitempty"` - Update *int64 `json:"update,omitempty"` - Delete *int64 `json:"delete,omitempty"` - Write *int64 `json:"write,omitempty"` - Error *int64 `json:"error,omitempty"` - Noop *int64 `json:"noop,omitempty"` - Errors []string `json:"errors,omitempty"` + Group *string `json:"group,omitempty"` + Kind *string `json:"kind,omitempty"` + Total *int64 `json:"total,omitempty"` + Create *int64 `json:"create,omitempty"` + Update *int64 `json:"update,omitempty"` + Delete *int64 `json:"delete,omitempty"` + Write *int64 `json:"write,omitempty"` + Error *int64 `json:"error,omitempty"` + Warning *int64 `json:"warning,omitempty"` + Noop *int64 `json:"noop,omitempty"` + Errors []string `json:"errors,omitempty"` + Warnings []string `json:"warnings,omitempty"` } // JobResourceSummaryApplyConfiguration constructs a declarative configuration of the JobResourceSummary type for use with @@ -89,6 +91,14 @@ func (b *JobResourceSummaryApplyConfiguration) WithError(value int64) *JobResour return b } +// WithWarning sets the Warning field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Warning field is set to the value of the last call. +func (b *JobResourceSummaryApplyConfiguration) WithWarning(value int64) *JobResourceSummaryApplyConfiguration { + b.Warning = &value + return b +} + // WithNoop sets the Noop field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Noop field is set to the value of the last call. @@ -106,3 +116,13 @@ func (b *JobResourceSummaryApplyConfiguration) WithErrors(values ...string) *Job } return b } + +// WithWarnings adds the given value to the Warnings field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Warnings field. +func (b *JobResourceSummaryApplyConfiguration) WithWarnings(values ...string) *JobResourceSummaryApplyConfiguration { + for i := range values { + b.Warnings = append(b.Warnings, values[i]) + } + return b +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/jobstatus.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/jobstatus.go index ea9228473a5..0ad62090c62 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/jobstatus.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/jobstatus.go @@ -16,6 +16,7 @@ type JobStatusApplyConfiguration struct { Finished *int64 `json:"finished,omitempty"` Message *string `json:"message,omitempty"` Errors []string `json:"errors,omitempty"` + Warnings []string `json:"warnings,omitempty"` Progress *float64 `json:"progress,omitempty"` Summary []*provisioningv0alpha1.JobResourceSummary `json:"summary,omitempty"` URLs *RepositoryURLsApplyConfiguration `json:"url,omitempty"` @@ -69,6 +70,16 @@ func (b *JobStatusApplyConfiguration) WithErrors(values ...string) *JobStatusApp return b } +// WithWarnings adds the given value to the Warnings field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Warnings field. +func (b *JobStatusApplyConfiguration) WithWarnings(values ...string) *JobStatusApplyConfiguration { + for i := range values { + b.Warnings = append(b.Warnings, values[i]) + } + return b +} + // WithProgress sets the Progress field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Progress field is set to the value of the last call. diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index 7ef1fc4fc91..3c6e905683c 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -1138,7 +1138,7 @@ export type JobResourceSummary = { delete?: number; /** Create or update (export) */ error?: number; - /** Report errors for this resource type This may not be an exhaustive list and recommend looking at the logs for more info */ + /** Report errors/warnings for this resource type This may not be an exhaustive list and recommend looking at the logs for more info */ errors?: string[]; group?: string; kind?: string; @@ -1146,6 +1146,9 @@ export type JobResourceSummary = { noop?: number; total?: number; update?: number; + /** The error count */ + warning?: number; + warnings?: string[]; write?: number; }; export type RepositoryUrLs = { @@ -1176,6 +1179,7 @@ export type JobStatus = { summary?: JobResourceSummary[]; /** URLs contains URLs for the reference branch or commit if applicable. */ url?: RepositoryUrLs; + warnings?: string[]; }; export type Job = { /** 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 */ diff --git a/pkg/registry/apis/provisioning/jobs/progress.go b/pkg/registry/apis/provisioning/jobs/progress.go index 97a293a5d8c..2cb9dc9ddcf 100644 --- a/pkg/registry/apis/provisioning/jobs/progress.go +++ b/pkg/registry/apis/provisioning/jobs/progress.go @@ -35,12 +35,13 @@ func maybeNotifyProgress(threshold time.Duration, fn ProgressFn) ProgressFn { // FIXME: ProgressRecorder should be initialized in the queue type JobResourceResult struct { - Name string - Group string - Kind string - Path string - Action repository.FileAction - Error error + Name string + Group string + Kind string + Path string + Action repository.FileAction + Error error + Warning error } type jobProgressRecorder struct { @@ -193,6 +194,10 @@ func (r *jobProgressRecorder) updateSummary(result JobResourceResult) { errorMsg := fmt.Sprintf("%s (file: %s, name: %s, action: %s)", result.Error.Error(), result.Path, result.Name, result.Action) summary.Errors = append(summary.Errors, errorMsg) summary.Error++ + } else if result.Warning != nil { + warningMsg := fmt.Sprintf("%s (file: %s, name: %s, action: %s)", result.Warning.Error(), result.Path, result.Name, result.Action) + summary.Warnings = append(summary.Warnings, warningMsg) + summary.Warning++ } else { switch result.Action { case repository.FileActionDeleted: @@ -266,8 +271,17 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision jobStatus.Message = err.Error() } - jobStatus.Summary = r.summary() + summaries := r.summary() + jobStatus.Summary = summaries jobStatus.Errors = r.errors + + // Extract warnings from summaries + warnings := make([]string, 0) + for _, summary := range summaries { + warnings = append(warnings, summary.Warnings...) + } + jobStatus.Warnings = warnings + jobStatus.URLs = r.refURLs tooManyErrors := r.maxErrors > 0 && r.errorCount >= r.maxErrors @@ -283,6 +297,9 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision jobStatus.Message = "completed with errors" jobStatus.State = provisioning.JobStateWarning } + } else if len(jobStatus.Warnings) > 0 { + jobStatus.State = provisioning.JobStateWarning + jobStatus.Message = "completed with warnings" } // Override message if progress have a more explicit message diff --git a/pkg/registry/apis/provisioning/jobs/progress_test.go b/pkg/registry/apis/provisioning/jobs/progress_test.go index caf44c767ff..611058d2c74 100644 --- a/pkg/registry/apis/provisioning/jobs/progress_test.go +++ b/pkg/registry/apis/provisioning/jobs/progress_test.go @@ -2,9 +2,11 @@ package jobs import ( "context" + "errors" "testing" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -83,3 +85,167 @@ func TestJobProgressRecorderCompleteIncludesRefURLs(t *testing.T) { assert.Equal(t, provisioning.JobStateSuccess, finalStatus.State) assert.Equal(t, "completed successfully", finalStatus.Message) } + +func TestJobProgressRecorderWarningStatus(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 result with a warning + warningErr := errors.New("deprecated API used") + result := JobResourceResult{ + Name: "test-resource", + Group: "test.grafana.app", + Kind: "Dashboard", + Path: "dashboards/test.json", + Action: repository.FileActionUpdated, + Warning: warningErr, + } + recorder.Record(ctx, result) + + // Record another result with a different warning + warningErr2 := errors.New("missing optional field") + result2 := JobResourceResult{ + Name: "test-resource-2", + Group: "test.grafana.app", + Kind: "Dashboard", + Path: "dashboards/test2.json", + Action: repository.FileActionCreated, + Warning: warningErr2, + } + recorder.Record(ctx, result2) + + // Record a result with a warning from a different resource type + warningErr3 := errors.New("validation warning") + result3 := JobResourceResult{ + Name: "test-resource-3", + Group: "test.grafana.app", + Kind: "DataSource", + Path: "datasources/test.yaml", + Action: repository.FileActionCreated, + Warning: warningErr3, + } + recorder.Record(ctx, result3) + + // Verify warnings are stored in summaries + recorder.mu.RLock() + require.Len(t, recorder.summaries, 2) // Dashboard and DataSource + dashboardSummary := recorder.summaries["test.grafana.app:Dashboard"] + require.NotNil(t, dashboardSummary) + assert.Equal(t, int64(2), dashboardSummary.Warning) + assert.Len(t, dashboardSummary.Warnings, 2) + assert.Contains(t, dashboardSummary.Warnings[0], "deprecated API used") + assert.Contains(t, dashboardSummary.Warnings[1], "missing optional field") + + datasourceSummary := recorder.summaries["test.grafana.app:DataSource"] + require.NotNil(t, datasourceSummary) + assert.Equal(t, int64(1), datasourceSummary.Warning) + assert.Len(t, datasourceSummary.Warnings, 1) + assert.Contains(t, datasourceSummary.Warnings[0], "validation warning") + recorder.mu.RUnlock() + + // Complete the job + finalStatus := recorder.Complete(ctx, nil) + + // Verify the final status includes warnings + require.NotNil(t, finalStatus.Warnings) + assert.Len(t, finalStatus.Warnings, 3) + assert.Contains(t, finalStatus.Warnings[0], "deprecated API used") + assert.Contains(t, finalStatus.Warnings[1], "missing optional field") + assert.Contains(t, finalStatus.Warnings[2], "validation warning") + + // Verify the state is set to Warning + assert.Equal(t, provisioning.JobStateWarning, finalStatus.State) + assert.Equal(t, "completed with warnings", finalStatus.Message) + + // Verify summaries are included + require.Len(t, finalStatus.Summary, 2) + + // Verify no errors were recorded + assert.Empty(t, finalStatus.Errors) +} + +func TestJobProgressRecorderWarningWithErrors(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 result with an error (errors take precedence) + errorErr := errors.New("failed to process") + result := JobResourceResult{ + Name: "test-resource", + Group: "test.grafana.app", + Kind: "Dashboard", + Path: "dashboards/test.json", + Action: repository.FileActionUpdated, + Error: errorErr, + } + recorder.Record(ctx, result) + + // Record a result with only a warning + warningErr := errors.New("deprecated API used") + result2 := JobResourceResult{ + Name: "test-resource-2", + Group: "test.grafana.app", + Kind: "Dashboard", + Path: "dashboards/test2.json", + Action: repository.FileActionCreated, + Warning: warningErr, + } + recorder.Record(ctx, result2) + + // Complete the job + finalStatus := recorder.Complete(ctx, nil) + + // When there are errors, the state should be Warning (not Error unless too many) + // and warnings should still be included + assert.Equal(t, provisioning.JobStateWarning, finalStatus.State) + assert.Equal(t, "completed with errors", finalStatus.Message) + assert.Len(t, finalStatus.Errors, 1) + assert.Contains(t, finalStatus.Errors[0], "failed to process") + + // Warnings should still be extracted from summaries + require.NotNil(t, finalStatus.Warnings) + assert.Len(t, finalStatus.Warnings, 1) + assert.Contains(t, finalStatus.Warnings[0], "deprecated API used") +} + +func TestJobProgressRecorderWarningOnlyNoErrors(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 only warnings, no errors + warningErr := errors.New("deprecated API used") + result := JobResourceResult{ + Name: "test-resource", + Group: "test.grafana.app", + Kind: "Dashboard", + Path: "dashboards/test.json", + Action: repository.FileActionUpdated, + Warning: warningErr, + } + recorder.Record(ctx, result) + + // Complete the job + finalStatus := recorder.Complete(ctx, nil) + + // Verify the state is Warning (not Error) when only warnings exist + assert.Equal(t, provisioning.JobStateWarning, finalStatus.State) + assert.Equal(t, "completed with warnings", finalStatus.Message) + assert.Empty(t, finalStatus.Errors) + require.NotNil(t, finalStatus.Warnings) + assert.Len(t, finalStatus.Warnings, 1) +} diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index f99b8f60738..ab232f8674c 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -3696,7 +3696,7 @@ "format": "int64" }, "errors": { - "description": "Report errors for this resource type This may not be an exhaustive list and recommend looking at the logs for more info", + "description": "Report errors/warnings for this resource type This may not be an exhaustive list and recommend looking at the logs for more info", "type": "array", "items": { "type": "string", @@ -3722,6 +3722,18 @@ "type": "integer", "format": "int64" }, + "warning": { + "description": "The error count", + "type": "integer", + "format": "int64" + }, + "warnings": { + "type": "array", + "items": { + "type": "string", + "default": "" + } + }, "write": { "type": "integer", "format": "int64" @@ -3849,6 +3861,13 @@ "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.RepositoryURLs" } ] + }, + "warnings": { + "type": "array", + "items": { + "type": "string", + "default": "" + } } } }, From 1addfd69b45afc3ca1a0f7a37bf9df33327f9fd4 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Fri, 12 Dec 2025 17:00:40 +0200 Subject: [PATCH 116/139] Provisioning: Fix duplicated breadcrumb (#115234) * Provisioning: Fix duplicated breadcrumb * translations --- .../GettingStarted/GettingStartedPage.tsx | 11 ++++------- public/locales/en-US/grafana.json | 1 - 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/public/app/features/provisioning/GettingStarted/GettingStartedPage.tsx b/public/app/features/provisioning/GettingStarted/GettingStartedPage.tsx index adaf68a80ee..0235ae86e92 100644 --- a/public/app/features/provisioning/GettingStarted/GettingStartedPage.tsx +++ b/public/app/features/provisioning/GettingStarted/GettingStartedPage.tsx @@ -15,13 +15,10 @@ export default function GettingStartedPage({ items }: Props) { return ( diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 6c760e94814..9d9ec201d18 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11867,7 +11867,6 @@ "title-setting-connection-could-cause-temporary-outage": "Setting up this connection could cause a temporary outage" }, "getting-started-page": { - "header": "Provisioning", "subtitle-provisioning-feature": "View and manage your provisioning connections" }, "git": { From f3421b97189769c41b8ccd39b75fbce5aa8331bb Mon Sep 17 00:00:00 2001 From: Anna Urbiztondo Date: Fri, 12 Dec 2025 16:08:28 +0100 Subject: [PATCH 117/139] Docs: Git Sync scenarios (#115199) * WIP * Review * Move scenarions * Structure fix * Edits, fix * Vale, x-refs * Feedback, tweaks * Consolidate HA, titles * Prettier * Prettier * Adding missing content * Minor edits * Links * Prettier --- .../manage-dashboards-argocd.md | 2 +- .../git-sync-deployment-scenarios/_index.md | 147 ++++++++++++ .../git-sync-deployment-scenarios/dev-prod.md | 147 ++++++++++++ .../high-availability.md | 217 ++++++++++++++++++ .../multi-region.md | 93 ++++++++ .../multi-team.md | 169 ++++++++++++++ .../single-instance.md | 86 +++++++ .../provision-resources/git-sync-setup.md | 1 + .../provision-resources/intro-git-sync.md | 8 +- .../provision-resources/use-git-sync.md | 2 +- 10 files changed, 869 insertions(+), 3 deletions(-) create mode 100644 docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/_index.md create mode 100644 docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/dev-prod.md create mode 100644 docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/high-availability.md create mode 100644 docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-region.md create mode 100644 docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-team.md create mode 100644 docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/single-instance.md diff --git a/docs/sources/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd.md b/docs/sources/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd.md index 0531a0c803f..bfc369222c8 100644 --- a/docs/sources/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd.md +++ b/docs/sources/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd.md @@ -81,7 +81,7 @@ Replace the placeholders with your values: In your `grafana` directory, create a sub-folder called `dashboards`. -This guide shows you how to creates three separate dashboards. For all dashboard configurations, replace the placeholders with your values: +This guide shows you how to create three separate dashboards. For all dashboard configurations, replace the placeholders with your values: - _``_: Name of your Grafana Cloud Stack - _``_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster diff --git a/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/_index.md b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/_index.md new file mode 100644 index 00000000000..4e4e523bf35 --- /dev/null +++ b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/_index.md @@ -0,0 +1,147 @@ +--- +title: Git Sync deployment scenarios +menuTitle: Deployment scenarios +description: Learn about common Git Sync deployment patterns and configurations for different organizational needs +weight: 450 +keywords: + - git sync + - deployment patterns + - scenarios + - multi-environment + - teams +--- + +# Git Sync deployment scenarios + +This guide shows practical deployment scenarios for Grafana’s Git Sync. Learn how to configure bidirectional synchronization between Grafana and Git repositories for teams, environments, and regions. + +{{< admonition type="caution" >}} +Git Sync is an experimental feature. It reflects Grafana’s approach to Observability as Code and might include limitations or breaking changes. For current status and known limitations, refer to the [Git Sync introduction](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/intro-git-sync/). +{{< /admonition >}} + +## Understand the relationship between key Git Sync components + +Before you explore the scenarios, understand how the key Git Sync components relate: + +- [Grafana instance](#grafana-instance) +- [Git repository structure](#git-repository-structure) +- [Git Sync repository resource](#git-sync-repository-resource) + +### Grafana instance + +A Grafana instance is a running Grafana server. Multiple instances can: + +- Connect to the same Git repository using different Repository configurations. +- Sync from different branches of the same repository. +- Sync from different paths within the same repository. +- Sync from different repositories. + +### Git repository structure + +You can organize your Git repository in several ways: + +- Single branch, multiple paths: Use different directories for different purposes (for example, `dev/`, `prod/`, `team-a/`). +- Multiple branches: Use different branches for different environments or teams (for example, `main`, `develop`, `team-a`). +- Multiple repositories: Use separate repositories for different teams or environments. + +### Git Sync repository resource + +A repository resource is a Grafana configuration object that defines: + +- Which Git repository to sync with. +- Which branch to use. +- Which directory path to synchronize. +- Sync behavior and workflows. + +Each repository resource creates bidirectional synchronization between a Grafana instance and a specific location in Git. + +## How does repository sync behave? + +With Git Sync you configure a repository resource to sync with your Grafana instance: + +1. Grafana monitors the specified Git location (repository, branch, and path). +2. Grafana creates a folder in Dashboards (typically named after the repository). +3. Grafana creates dashboards from dashboard JSON files in Git within this folder. +4. Grafana commits dashboard changes made in the UI back to Git. +5. Grafana pulls dashboard changes made in Git and updates dashboards in the UI. +6. Synchronization occurs at regular intervals (configurable), or instantly if you use webhooks. + +You can find the provisioned dashboards organized in folders under **Dashboards**. + +## Example: Relationship between repository, branch, and path + +Here's a concrete example showing how the three parameters work together: + +**Configuration:** + +- **Repository**: `your-org/grafana-manifests` +- **Branch**: `main` +- **Path**: `team-platform/grafana/` + +**In Git (on branch `main`):** + +``` +your-org/grafana-manifests/ +├── .git/ +├── README.md +├── team-platform/ +│ └── grafana/ +│ ├── cpu-metrics.json ← Synced +│ ├── memory-usage.json ← Synced +│ └── disk-io.json ← Synced +├── team-data/ +│ └── grafana/ +│ └── pipeline-stats.json ← Not synced (different path) +└── other-files.txt ← Not synced (outside path) +``` + +**In Grafana Dashboards view:** + +``` +Dashboards +└── 📁 grafana-manifests/ + ├── CPU Metrics Dashboard + ├── Memory Usage Dashboard + └── Disk I/O Dashboard +``` + +**Key points:** + +- Grafana only synchronizes files within the specified path (`team-platform/grafana/`). +- Grafana ignores files in other paths or at the repository root. +- The folder name in Grafana comes from the repository name. +- Dashboard titles come from the JSON file content, not the filename. + +## Repository configuration flexibility + +Git Sync repositories support different combinations of repository URL, branch, and path: + +- Different Git repositories: Each environment or team can use its own repository. + - Instance A: `repository: your-org/grafana-prod`. + - Instance B: `repository: your-org/grafana-dev`. +- Different branches: Use separate branches within the same repository. + - Instance A: `repository: your-org/grafana-manifests, branch: main`. + - Instance B: `repository: your-org/grafana-manifests, branch: develop`. +- Different paths: Use different directory paths within the same repository. + - Instance A: `repository: your-org/grafana-manifests, branch: main, path: production/`. + - Instance B: `repository: your-org/grafana-manifests, branch: main, path: development/`. +- Any combination: Mix and match based on your workflow requirements. + +## Scenarios + +Use these deployment scenarios to plan your Git Sync setup: + +- [Single instance](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/single-instance/) +- [Git Sync for development and production environments](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/dev-prod/) +- [Git Sync with regional replication](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-region/) +- [High availability](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/high-availability/) +- [Git Sync in a shared Grafana instance](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-team/) + +## Learn more + +Refer to the following documents to learn more: + +- [Git Sync introduction](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/intro-git-sync/) +- [Git Sync setup guide](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/git-sync-setup/) +- [Dashboard provisioning](https://grafana.com/docs/grafana//administration/provisioning/) +- [Observability as Code](https://grafana.com/docs/grafana//as-code/observability-as-code/) diff --git a/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/dev-prod.md b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/dev-prod.md new file mode 100644 index 00000000000..3433c713024 --- /dev/null +++ b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/dev-prod.md @@ -0,0 +1,147 @@ +--- +title: Git Sync for development and production environments +menuTitle: Across environments +description: Use separate Grafana instances for development and production with Git-controlled promotion +weight: 20 +--- + +# Git Sync for development and production environments + +Use separate Grafana instances for development and production. Each syncs with different Git locations to test dashboards before production. + +## Use it for + +- **Staged deployments**: You need to test dashboard changes before production deployment. +- **Change control**: You require approvals before dashboards reach production. +- **Quality assurance**: You verify dashboard functionality in a non-production environment. +- **Risk mitigation**: You minimize the risk of breaking production dashboards. + +## Architecture + +``` +┌────────────────────────────────────────────────────────────┐ +│ GitHub Repository │ +│ Repository: your-org/grafana-manifests │ +│ Branch: main │ +│ │ +│ grafana-manifests/ │ +│ ├── dev/ │ +│ │ ├── dashboard-new.json ← Development dashboards │ +│ │ └── dashboard-test.json │ +│ │ │ +│ └── prod/ │ +│ ├── dashboard-stable.json ← Production dashboards │ +│ └── dashboard-approved.json │ +└────────────────────────────────────────────────────────────┘ + ↕ ↕ + Git Sync (dev/) Git Sync (prod/) + ↕ ↕ +┌─────────────────────┐ ┌─────────────────────┐ +│ Dev Grafana │ │ Prod Grafana │ +│ │ │ │ +│ Repository: │ │ Repository: │ +│ - path: dev/ │ │ - path: prod/ │ +│ │ │ │ +│ Creates folder: │ │ Creates folder: │ +│ "grafana-manifests"│ │ "grafana-manifests"│ +└─────────────────────┘ └─────────────────────┘ +``` + +## Repository structure + +**In Git:** + +``` +your-org/grafana-manifests +├── dev/ +│ ├── dashboard-new.json +│ └── dashboard-test.json +└── prod/ + ├── dashboard-stable.json + └── dashboard-approved.json +``` + +**In Grafana Dashboards view:** + +**Dev instance:** + +``` +Dashboards +└── 📁 grafana-manifests/ + ├── New Dashboard + └── Test Dashboard +``` + +**Prod instance:** + +``` +Dashboards +└── 📁 grafana-manifests/ + ├── Stable Dashboard + └── Approved Dashboard +``` + +- Both instances create a folder named "grafana-manifests" (from repository name) +- Each instance only shows dashboards from its configured path (`dev/` or `prod/`) +- Dashboards appear with their titles from the JSON files + +## Configuration parameters + +Development: + +- Repository: `your-org/grafana-manifests` +- Branch: `main` +- Path: `dev/` + +Production: + +- Repository: `your-org/grafana-manifests` +- Branch: `main` +- Path: `prod/` + +## How it works + +1. Developers create and modify dashboards in development. +2. Git Sync commits changes to `dev/`. +3. You review changes in Git. +4. You promote approved dashboards from `dev/` to `prod/`. +5. Production syncs from `prod/`. +6. Production dashboards update. + +## Alternative: Use branches + +Instead of using different paths, you can configure instances to use different branches: + +**Development instance:** + +- **Repository**: `your-org/grafana-manifests` +- **Branch**: `develop` +- **Path**: `grafana/` + +**Production instance:** + +- **Repository**: `your-org/grafana-manifests` +- **Branch**: `main` +- **Path**: `grafana/` + +With this approach: + +- Development changes go to the `develop` branch +- Use Git merge or pull request workflows to promote changes from `develop` to `main` +- Production automatically syncs from the `main` branch + +## Alternative: Use separate repositories for stricter isolation + +For stricter isolation, use completely separate repositories: + +**Development instance:** + +- **Repository**: `your-org/grafana-manifests-dev` +- **Branch**: `main` +- **Path**: `grafana/` + +**Production instance:** + +- **Repository**: `your-org/grafana-manifests-prod` +- **Branch**: `main` +- **Path**: `grafana/` diff --git a/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/high-availability.md b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/high-availability.md new file mode 100644 index 00000000000..04575574ed4 --- /dev/null +++ b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/high-availability.md @@ -0,0 +1,217 @@ +--- +title: Git Sync for high availability environments +menuTitle: High availability +description: Run multiple Grafana instances serving traffic simultaneously, synchronized via Git Sync +weight: 50 +--- + +# Git Sync for high availability environments + +## Primary–replica scenario + +Use a primary Grafana instance and one or more replicas synchronized with the same Git location to enable failover. + +### Use it for + +- **Automatic failover**: You need service continuity when the primary instance fails. +- **High availability**: Your organization requires guaranteed dashboard availability. +- **Simple HA setup**: You want high availability without the complexity of active–active. +- **Maintenance windows**: You perform updates while another instance serves traffic. +- **Business continuity**: Dashboard access can't tolerate downtime. + +### Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ GitHub Repository │ +│ Repository: your-org/grafana-manifests │ +│ Branch: main │ +│ │ +│ grafana-manifests/ │ +│ └── shared/ │ +│ ├── dashboard-metrics.json │ +│ ├── dashboard-alerts.json │ +│ └── dashboard-logs.json │ +└─────────────────────────────────────────────────────┘ + ↕ ↕ + Git Sync (shared/) Git Sync (shared/) + ↕ ↕ +┌────────────────────┐ ┌────────────────────┐ +│ Master Grafana │ │ Replica Grafana │ +│ (Active) │ │ (Standby) │ +│ │ │ │ +│ Repository: │ │ Repository: │ +│ - path: shared/ │ │ - path: shared/ │ +└────────────────────┘ └────────────────────┘ + │ │ + └───────────┬───────────────────┘ + ↓ + ┌──────────────────────┐ + │ Reverse Proxy │ + │ (Failover) │ + └──────────────────────┘ +``` + +### Repository structure + +**In Git:** + +``` +your-org/grafana-manifests +└── shared/ + ├── dashboard-metrics.json + ├── dashboard-alerts.json + └── dashboard-logs.json +``` + +**In Grafana Dashboards view (both instances):** + +``` +Dashboards +└── 📁 grafana-manifests/ + ├── Metrics Dashboard + ├── Alerts Dashboard + └── Logs Dashboard +``` + +- Master and replica instances show identical folder structure. +- Both sync from the same `shared/` path. +- Reverse proxy routes traffic to master (active) instance. +- If master fails, proxy automatically fails over to replica (standby). +- Users see the same dashboards regardless of which instance is serving traffic. + +### Configuration parameters + +Both master and replica instances use identical parameters: + +**Master instance:** + +- **Repository**: `your-org/grafana-manifests` +- **Branch**: `main` +- **Path**: `shared/` + +**Replica instance:** + +- **Repository**: `your-org/grafana-manifests` +- **Branch**: `main` +- **Path**: `shared/` + +### How it works + +1. Both instances stay synchronized through Git. +2. Reverse proxy routes traffic to primary. +3. Users edit on primary. Git Sync commits changes. +4. Both instances pull latest changes to keep replica in sync. +5. On primary failure, proxy fails over to replica. + +### Failover considerations + +- Health checks and monitoring. +- Continuous syncing to minimize data loss. +- Plan failback (automatic or manual). + +## Load balancer scenario + +Run multiple active Grafana instances behind a load balancer. All instances sync from the same Git location. + +### Use it for + +- **High traffic**: Your deployment needs to handle significant user load. +- **Load distribution**: You want to distribute user requests across instances. +- **Maximum availability**: You need service continuity during maintenance or failures. +- **Scalability**: You want to add instances as load increases. +- **Performance**: Users need fast response times under heavy load. + +### Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ GitHub Repository │ +│ Repository: your-org/grafana-manifests │ +│ Branch: main │ +│ │ +│ grafana-manifests/ │ +│ └── shared/ │ +│ ├── dashboard-metrics.json │ +│ ├── dashboard-alerts.json │ +│ └── dashboard-logs.json │ +└─────────────────────────────────────────────────────┘ + ↕ ↕ + Git Sync (shared/) Git Sync (shared/) + ↕ ↕ +┌────────────────────┐ ┌────────────────────┐ +│ Grafana Instance 1│ │ Grafana Instance 2│ +│ (Active) │ │ (Active) │ +│ │ │ │ +│ Repository: │ │ Repository: │ +│ - path: shared/ │ │ - path: shared/ │ +└────────────────────┘ └────────────────────┘ + │ │ + └───────────┬───────────────────┘ + ↓ + ┌──────────────────────┐ + │ Load Balancer │ + │ (Round Robin) │ + └──────────────────────┘ +``` + +### Repository structure + +**In Git:** + +``` +your-org/grafana-manifests +└── shared/ + ├── dashboard-metrics.json + ├── dashboard-alerts.json + └── dashboard-logs.json +``` + +**In Grafana Dashboards view (all instances):** + +``` +Dashboards +└── 📁 grafana-manifests/ + ├── Metrics Dashboard + ├── Alerts Dashboard + └── Logs Dashboard +``` + +- All instances show identical folder structure. +- All instances sync from the same `shared/` path. +- Load balancer distributes requests across all active instances. +- Any instance can serve read requests. +- Any instance can accept dashboard modifications. +- Changes propagate to all instances through Git. + +### Configuration parameters + +All instances use identical parameters: + +**Instance 1:** + +- **Repository**: `your-org/grafana-manifests` +- **Branch**: `main` +- **Path**: `shared/` + +**Instance 2:** + +- **Repository**: `your-org/grafana-manifests` +- **Branch**: `main` +- **Path**: `shared/` + +### How it works + +1. All instances stay synchronized through Git. +2. Load balancer distributes incoming traffic across all active instances. +3. Users can view dashboards from any instance. +4. When a user modifies a dashboard on any instance, Git Sync commits the change. +5. All other instances pull the updated dashboard during their next sync cycle, or instantly if webhooks are configured. +6. If one instance fails, load balancer stops routing traffic to it and remaining instances continue serving. + +### Important considerations + +- **Eventually consistent**: Due to sync intervals, instances may briefly have different dashboard versions. +- **Concurrent edits**: Multiple users editing the same dashboard on different instances can cause conflicts. +- **Database sharing**: Instances should share the same backend database for user sessions, preferences, and annotations. +- **Stateless design**: Design for stateless operation where possible to maximize load balancing effectiveness. diff --git a/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-region.md b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-region.md new file mode 100644 index 00000000000..ee699719560 --- /dev/null +++ b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-region.md @@ -0,0 +1,93 @@ +--- +title: Git Sync with regional replication +menuTitle: Regional replication +description: Synchronize multiple regional Grafana instances from a shared Git location +weight: 30 +--- + +# Git Sync with regional replication + +Deploy multiple Grafana instances across regions. Synchronize them with the same Git location to ensure consistent dashboards everywhere. + +## Use it for + +- **Geographic distribution**: You deploy Grafana close to users in different regions. +- **Latency reduction**: Users need fast dashboard access from their location. +- **Data sovereignty**: You keep dashboard data in specific regions. +- **High availability**: You need dashboard availability across regions. +- **Consistent experience**: All users see the same dashboards regardless of region. + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ GitHub Repository │ +│ Repository: your-org/grafana-manifests │ +│ Branch: main │ +│ │ +│ grafana-manifests/ │ +│ └── shared/ │ +│ ├── dashboard-global.json │ +│ ├── dashboard-metrics.json │ +│ └── dashboard-logs.json │ +└─────────────────────────────────────────────────────┘ + ↕ ↕ + Git Sync (shared/) Git Sync (shared/) + ↕ ↕ +┌────────────────────┐ ┌────────────────────┐ +│ US Region │ │ EU Region │ +│ Grafana │ │ Grafana │ +│ │ │ │ +│ Repository: │ │ Repository: │ +│ - path: shared/ │ │ - path: shared/ │ +└────────────────────┘ └────────────────────┘ +``` + +## Repository structure + +**In Git:** + +``` +your-org/grafana-manifests +└── shared/ + ├── dashboard-global.json + ├── dashboard-metrics.json + └── dashboard-logs.json +``` + +**In Grafana Dashboards view (all regions):** + +``` +Dashboards +└── 📁 grafana-manifests/ + ├── Global Dashboard + ├── Metrics Dashboard + └── Logs Dashboard +``` + +- All regional instances (US, EU, etc.) show identical folder structure +- Same folder name "grafana-manifests" in every region +- Same dashboards synced from the `shared/` path appear everywhere +- Users in any region see the exact same dashboards with the same titles + +## Configuration parameters + +All regions: + +- Repository: `your-org/grafana-manifests` +- Branch: `main` +- Path: `shared/` + +## How it works + +1. All regional instances pull dashboards from `shared/`. +2. Any region’s change commits to Git. +3. Other regions pull updates during the next sync (or via webhooks). +4. Changes propagate across regions per sync interval. + +## Considerations + +- **Write conflicts**: If users in different regions modify the same dashboard simultaneously, Git uses last-write-wins. +- **Primary region**: Consider designating one region as the primary location for making dashboard changes. +- **Propagation time**: Changes propagate to all regions within the configured sync interval, or instantly if webhooks are configured. +- **Network reliability**: Ensure all regions have reliable connectivity to the Git repository. diff --git a/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-team.md b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-team.md new file mode 100644 index 00000000000..4f5ecc85fd2 --- /dev/null +++ b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-team.md @@ -0,0 +1,169 @@ +--- +title: Multiple team Git Sync +menuTitle: Shared instance +description: Use multiple Git repositories with one Grafana instance, one repository per team +weight: 60 +--- + +# Git Sync in a Grafana instance shared by multiple teams + +Use a single Grafana instance with multiple Repository resources, one per team. Each team manages its own dashboards while sharing Grafana. + +## Use it for + +- **Team autonomy**: Different teams manage their own dashboards independently. +- **Organizational structure**: Dashboard organization aligns with team structure. +- **Resource efficiency**: Multiple teams share Grafana infrastructure. +- **Cost optimization**: You reduce infrastructure costs while maintaining team separation. +- **Collaboration**: Teams can view each other’s dashboards while managing their own. + +## Architecture + +``` +┌─────────────────────────┐ ┌─────────────────────────┐ +│ Platform Team Repo │ │ Data Team Repo │ +│ platform-dashboards │ │ data-dashboards │ +│ │ │ │ +│ platform-dashboards/ │ │ data-dashboards/ │ +│ └── grafana/ │ │ └── grafana/ │ +│ ├── k8s.json │ │ ├── pipeline.json │ +│ └── infra.json │ │ └── analytics.json │ +└─────────────────────────┘ └─────────────────────────┘ + ↕ ↕ + Git Sync (grafana/) Git Sync (grafana/) + ↕ ↕ + ┌──────────────────────────────────────┐ + │ Grafana Instance │ + │ │ + │ Repository 1: │ + │ - repo: platform-dashboards │ + │ → Creates "platform-dashboards" │ + │ │ + │ Repository 2: │ + │ - repo: data-dashboards │ + │ → Creates "data-dashboards" │ + └──────────────────────────────────────┘ +``` + +## Repository structure + +**In Git (separate repositories):** + +**Platform team repository:** + +``` +your-org/platform-dashboards +└── grafana/ + ├── dashboard-k8s.json + └── dashboard-infra.json +``` + +**Data team repository:** + +``` +your-org/data-dashboards +└── grafana/ + ├── dashboard-pipeline.json + └── dashboard-analytics.json +``` + +**In Grafana Dashboards view:** + +``` +Dashboards +├── 📁 platform-dashboards/ +│ ├── Kubernetes Dashboard +│ └── Infrastructure Dashboard +└── 📁 data-dashboards/ + ├── Pipeline Dashboard + └── Analytics Dashboard +``` + +- Two separate folders created (one per Repository resource). +- Folder names derived from repository names. +- Each team has complete control over their own repository. +- Teams can independently manage permissions, branches, and workflows in their repos. +- All teams can view each other's dashboards in Grafana but manage only their own. + +## Configuration parameters + +**Platform team repository:** + +- **Repository**: `your-org/platform-dashboards` +- **Branch**: `main` +- **Path**: `grafana/` + +**Data team repository:** + +- **Repository**: `your-org/data-dashboards` +- **Branch**: `main` +- **Path**: `grafana/` + +## How it works + +1. Each team has their own Git repository for complete autonomy. +2. Each repository resource in Grafana creates a separate folder. +3. Platform team dashboards sync from `your-org/platform-dashboards` repository. +4. Data team dashboards sync from `your-org/data-dashboards` repository. +5. Teams can independently manage their repository settings, access controls, and workflows. +6. All teams can view each other's dashboards in Grafana but edit only their own. + +## Scale to more teams + +Adding additional teams is straightforward. For a third team, create a new repository and configure: + +- **Repository**: `your-org/security-dashboards` +- **Branch**: `main` +- **Path**: `grafana/` + +This creates a new "security-dashboards" folder in the same Grafana instance. + +## Alternative: Shared repository with different paths + +For teams that prefer sharing a single repository, use different paths to separate team dashboards: + +**In Git:** + +``` +your-org/grafana-manifests +├── team-platform/ +│ ├── dashboard-k8s.json +│ └── dashboard-infra.json +└── team-data/ + ├── dashboard-pipeline.json + └── dashboard-analytics.json +``` + +**Configuration:** + +**Platform team:** + +- **Repository**: `your-org/grafana-manifests` +- **Branch**: `main` +- **Path**: `team-platform/` + +**Data team:** + +- **Repository**: `your-org/grafana-manifests` +- **Branch**: `main` +- **Path**: `team-data/` + +This approach provides simpler repository management but less isolation between teams. + +## Alternative: Different branches per team + +For teams wanting their own branch in a shared repository: + +**Platform team:** + +- **Repository**: `your-org/grafana-manifests` +- **Branch**: `team-platform` +- **Path**: `grafana/` + +**Data team:** + +- **Repository**: `your-org/grafana-manifests` +- **Branch**: `team-data` +- **Path**: `grafana/` + +This allows teams to use Git branch workflows for collaboration while sharing the same repository. diff --git a/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/single-instance.md b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/single-instance.md new file mode 100644 index 00000000000..39371f7e7d6 --- /dev/null +++ b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/single-instance.md @@ -0,0 +1,86 @@ +--- +title: Single instance Git Sync +menuTitle: Single instance +description: Synchronize a single Grafana instance with a Git repository +weight: 10 +--- + +# Single instance Git Sync + +Use a single Grafana instance synchronized with a Git repository. This is the foundation for Git Sync and helps you understand bidirectional synchronization. + +## Use it for + +- **Getting started**: You want to learn how Git Sync works before implementing complex scenarios. +- **Personal projects**: Individual developers manage their own dashboards. +- **Small teams**: You have a simple setup without multiple environments or complex workflows. +- **Development environments**: You need quick prototyping and testing. + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ GitHub Repository │ +│ Repository: your-org/grafana-manifests │ +│ Branch: main │ +│ │ +│ grafana-manifests/ │ +│ └── grafana/ │ +│ ├── dashboard-1.json │ +│ ├── dashboard-2.json │ +│ └── dashboard-3.json │ +└─────────────────────────────────────────────────────┘ + ↕ + Git Sync (bidirectional) + ↕ + ┌─────────────────────────────┐ + │ Grafana Instance │ + │ │ + │ Repository Resource: │ + │ - url: grafana-manifests │ + │ - branch: main │ + │ - path: grafana/ │ + │ │ + │ Creates folder: │ + │ "grafana-manifests" │ + └─────────────────────────────┘ +``` + +## Repository structure + +**In Git:** + +``` +your-org/grafana-manifests +└── grafana/ + ├── dashboard-1.json + ├── dashboard-2.json + └── dashboard-3.json +``` + +**In Grafana Dashboards view:** + +``` +Dashboards +└── 📁 grafana-manifests/ + ├── Dashboard 1 + ├── Dashboard 2 + └── Dashboard 3 +``` + +- A folder named "grafana-manifests" (from repository name) contains all synced dashboards. +- Each JSON file becomes a dashboard with its title displayed in the folder. +- Users browse dashboards organized under this folder structure. + +## Configuration parameters + +Configure your Grafana instance to synchronize with: + +- **Repository**: `your-org/grafana-manifests` +- **Branch**: `main` +- **Path**: `grafana/` + +## How it works + +1. **From Grafana to Git**: When users create or modify dashboards in Grafana, Git Sync commits changes to the `grafana/` directory on the `main` branch. +2. **From Git to Grafana**: When dashboard JSON files are added or modified in the `grafana/` directory, Git Sync pulls these changes into Grafana. diff --git a/docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md index 553027f4132..cd07c532dae 100644 --- a/docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md @@ -367,5 +367,6 @@ To learn more about using Git Sync: - [Work with provisioned dashboards](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/provisioned-dashboards/) - [Manage provisioned repositories with Git Sync](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/use-git-sync/) +- [Git Sync deployment scenarios](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios) - [Export resources](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/export-resources/) - [grafanactl documentation](https://grafana.github.io/grafanactl/) diff --git a/docs/sources/as-code/observability-as-code/provision-resources/intro-git-sync.md b/docs/sources/as-code/observability-as-code/provision-resources/intro-git-sync.md index 4d9bda529a3..9e32ce32e8a 100644 --- a/docs/sources/as-code/observability-as-code/provision-resources/intro-git-sync.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/intro-git-sync.md @@ -127,7 +127,13 @@ An instance can be in one of the following Git Sync states: ## Common use cases -You can use Git Sync in the following scenarios. +{{< admonition type="note" >}} + +Refer to [Git Sync deployment scenarios](https://grafana.com/docs/grafana//as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios) for sample scenarios, including architecture and configuration details. + +{{< /admonition >}} + +You can use Git Sync for the following use cases: ### Version control and auditing diff --git a/docs/sources/as-code/observability-as-code/provision-resources/use-git-sync.md b/docs/sources/as-code/observability-as-code/provision-resources/use-git-sync.md index f7d37d4b94e..11ce2aee0aa 100644 --- a/docs/sources/as-code/observability-as-code/provision-resources/use-git-sync.md +++ b/docs/sources/as-code/observability-as-code/provision-resources/use-git-sync.md @@ -14,7 +14,7 @@ labels: - cloud title: Manage provisioned repositories with Git Sync menuTitle: Manage repositories with Git Sync -weight: 120 +weight: 400 canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/provision-resources/use-git-sync/ aliases: - ../../../observability-as-code/provision-resources/use-git-sync/ # /docs/grafana/next/observability-as-code/provision-resources/use-git-sync/ From eab5d2b30ef443870167a7ec34ba3d2b603a8aef Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Fri, 12 Dec 2025 17:17:34 +0200 Subject: [PATCH 118/139] Dashboard: Fix rogue modal when exiting edit mode (#115240) * Dashboard: Fix rogue modal when exiting edit mode * Remove unnecessary change --- .../dashboard-scene/saving/DashboardSceneChangeTracker.ts | 4 ++++ public/app/features/dashboard-scene/scene/DashboardScene.tsx | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts index 6bae04e7248..3884106df39 100644 --- a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts +++ b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts @@ -206,6 +206,10 @@ export class DashboardSceneChangeTracker { } this._changesWorker!.onmessage = (e: MessageEvent) => { + if (!this._dashboard.state.isEditing) { + return; + } + this.updateIsDirty(!!e.data.hasChanges); }; diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 2312fdec7ed..4e3fa954a51 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -271,7 +271,7 @@ export class DashboardScene extends SceneObjectBase impleme public onEnterEditMode = () => { // Save this state - this._initialState = sceneUtils.cloneSceneObjectState(this.state); + this._initialState = sceneUtils.cloneSceneObjectState(this.state, { isDirty: false }); this._initialUrlState = locationService.getLocation(); // Switch to edit mode From 5f80a29a289bf7251010496e1b22192d79185319 Mon Sep 17 00:00:00 2001 From: William Wernert Date: Fri, 12 Dec 2025 10:25:08 -0500 Subject: [PATCH 119/139] Alerting: Prevent users from saving rules to git-synced folders (#114944) --------- Co-authored-by: Yuri Tseretyan --- pkg/services/ngalert/api/api_ruler.go | 13 ++- pkg/services/ngalert/api/api_ruler_test.go | 61 +++++++++++ .../ngalert/api/prometheus/api_prometheus.go | 2 +- pkg/services/ngalert/eval/eval.go | 2 +- pkg/services/ngalert/models/alert_rule.go | 15 +++ .../ngalert/provisioning/alert_rules.go | 19 +++- .../ngalert/provisioning/alert_rules_test.go | 103 ++++++++++++++++++ pkg/services/ngalert/state/historian/loki.go | 2 +- 8 files changed, 209 insertions(+), 8 deletions(-) diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index d81f448f600..9fde7a2ec6f 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -412,11 +412,16 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGro deletePermanently = true } - namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser) + f, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser) if err != nil { return toNamespaceErrorResponse(err) } + namespace := ngmodels.NewNamespace(f) + if err := namespace.ValidateForRuleStorage(); err != nil { + return ErrResp(http.StatusBadRequest, fmt.Errorf("%w: %s", ngmodels.ErrAlertRuleFailedValidation, err), "") + } + if err := srv.checkGroupLimits(ruleGroupConfig); err != nil { return ErrResp(http.StatusBadRequest, err, "") } @@ -841,10 +846,14 @@ func (srv RulerSrv) RouteUpdateNamespaceRules(c *contextmodel.ReqContext, body a return ErrResp(http.StatusBadRequest, errors.New("missing request body"), "") } - namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser) + f, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser) if err != nil { return toNamespaceErrorResponse(err) } + namespace := ngmodels.NewNamespace(f) + if err := namespace.ValidateForRuleStorage(); err != nil { + return ErrResp(http.StatusBadRequest, fmt.Errorf("%w: %s", ngmodels.ErrAlertRuleFailedValidation, err), "") + } ruleGroups, _, err := srv.searchAuthorizedAlertRules(c.Req.Context(), authorizedRuleGroupQuery{ User: c.SignedInUser, diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index c6ddeee2a6c..e09088e4669 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/log" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" @@ -1288,4 +1289,64 @@ func TestRouteUpdateNamespaceRules(t *testing.T) { updatedRules := getRecordedUpdatedRules(ruleStore) require.Empty(t, updatedRules) }) + + t.Run("should reject update when folder is managed by ManagerKindRepo", func(t *testing.T) { + ruleStore := fakes.NewRuleStore(t) + provisioningStore := fakes.NewFakeProvisioningStore() + + // Create a managed folder + managedFolder := randFolder() + managedFolder.ManagedBy = utils.ManagerKindRepo + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], managedFolder) + + // Create some rules in the managed folder + ruleGen := models.RuleGen.With( + models.RuleGen.WithOrgID(orgID), + models.RuleGen.WithNamespaceUID(managedFolder.UID), + ) + rules := ruleGen.GenerateManyRef(2) + ruleStore.PutRule(context.Background(), rules...) + + permissions := createPermissionsForRules(rules, orgID) + requestCtx := createRequestContextWithPerms(orgID, permissions, nil) + + svc := createServiceWithProvenanceStore(ruleStore, provisioningStore) + response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{ + IsPaused: util.Pointer(true), + }, managedFolder.UID) + + require.Equal(t, http.StatusBadRequest, response.Status()) + require.Contains(t, string(response.Body()), "cannot store rules in folder managed by Git Sync") + + // Verify no rules were updated + updatedRules := getRecordedUpdatedRules(ruleStore) + require.Empty(t, updatedRules) + }) +} + +func TestRoutePostNameRulesConfig(t *testing.T) { + t.Run("should reject creation when folder is managed by ManagerKindRepo", func(t *testing.T) { + orgID := rand.Int63() + ruleStore := fakes.NewRuleStore(t) + + // Create a managed folder + managedFolder := randFolder() + managedFolder.ManagedBy = utils.ManagerKindRepo + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], managedFolder) + + permissions := map[int64]map[string][]string{ + orgID: { + dashboards.ScopeFoldersProvider.GetResourceScopeUID(managedFolder.UID): {dashboards.ActionFoldersRead}, + }, + } + requestCtx := createRequestContextWithPerms(orgID, permissions, nil) + + svc := createService(ruleStore, nil) + response := svc.RoutePostNameRulesConfig(requestCtx, apimodels.PostableRuleGroupConfig{ + Name: "test-group", + }, managedFolder.UID) + + require.Equal(t, http.StatusBadRequest, response.Status()) + require.Contains(t, string(response.Body()), "cannot store rules in folder managed by Git Sync") + }) } diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index 412a0795469..36457cf7a30 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -296,7 +296,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon allowedNamespaces := map[string]string{} for namespaceUID, folder := range namespaceMap { // only add namespaces that the user has access to rules in - hasAccess, err := srv.authz.HasAccessInFolder(c.Req.Context(), c.SignedInUser, ngmodels.Namespace(*folder.ToFolderReference())) + hasAccess, err := srv.authz.HasAccessInFolder(c.Req.Context(), c.SignedInUser, ngmodels.NewNamespace(folder)) if err != nil { ruleResponse.Status = "error" ruleResponse.Error = fmt.Sprintf("failed to get namespaces visible to the user: %s", err.Error()) diff --git a/pkg/services/ngalert/eval/eval.go b/pkg/services/ngalert/eval/eval.go index 687e9b2dd03..40e2a257e60 100644 --- a/pkg/services/ngalert/eval/eval.go +++ b/pkg/services/ngalert/eval/eval.go @@ -204,7 +204,7 @@ func IsNonRetryableError(err error) bool { return false } -// HasErrors returns true when Results contains at least one element and all elements are errors +// IsError returns true when Results contains at least one element and all elements are errors func (evalResults Results) IsError() bool { for _, r := range evalResults { if r.State != Error { diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 7ee223e8165..bc21e31cb45 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -24,6 +24,7 @@ import ( alertingModels "github.com/grafana/alerting/models" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" @@ -397,6 +398,20 @@ type Namespaced interface { type Namespace folder.FolderReference +func NewNamespace(f *folder.Folder) Namespace { + return Namespace(*f.ToFolderReference()) +} + +func (n Namespace) ValidateForRuleStorage() error { + if n.UID == "" { + return fmt.Errorf("cannot store rules in folder without UID") + } + if n.ManagedBy == utils.ManagerKindRepo { + return fmt.Errorf("cannot store rules in folder managed by Git Sync") + } + return nil +} + func (n Namespace) GetNamespaceUID() string { return n.UID } diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index aa6c4c55b34..b5ff7506650 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -114,7 +114,7 @@ func (service *AlertRuleService) ListAlertRules(ctx context.Context, user identi } folderUIDs := make([]string, 0, len(folders)) for _, f := range folders { - access, err := service.authz.HasAccessInFolder(ctx, user, models.Namespace(*f.ToFolderReference())) + access, err := service.authz.HasAccessInFolder(ctx, user, models.NewNamespace(f)) if err != nil { return nil, nil, "", err } @@ -407,6 +407,9 @@ func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, user ident if err := models.ValidateRuleGroupInterval(intervalSeconds, service.baseIntervalSeconds); err != nil { return err } + if err := service.ensureNamespace(ctx, user, user.GetOrgID(), namespaceUID); err != nil { + return err + } return service.xact.InTransaction(ctx, func(ctx context.Context) error { query := &models.ListAlertRulesQuery{ OrgID: user.GetOrgID(), @@ -471,6 +474,10 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, user iden return err } + if err := service.ensureNamespace(ctx, user, user.GetOrgID(), group.FolderUID); err != nil { + return err + } + // If the rule group is reserved for no-group rules, we cannot have multiple rules in it. if models.IsNoGroupRuleGroup(group.Title) && len(group.Rules) > 1 { return fmt.Errorf("rule group %s is reserved for no-group rules and cannot be used for rule groups with multiple rules", group.Title) @@ -1025,6 +1032,7 @@ func (service *AlertRuleService) checkGroupLimits(group models.AlertRuleGroup) e // ensureNamespace ensures that the rule has a valid namespace UID. // If the rule does not have a namespace UID or the namespace (folder) does not exist it will return an error. +// If the folder is managed by a manager, it will also return an error. func (service *AlertRuleService) ensureNamespace(ctx context.Context, user identity.Requester, orgID int64, namespaceUID string) error { if namespaceUID == "" { return fmt.Errorf("%w: folderUID must be set", models.ErrAlertRuleFailedValidation) @@ -1037,18 +1045,23 @@ func (service *AlertRuleService) ensureNamespace(ctx context.Context, user ident } // ensure the namespace exists - _, err := service.folderService.Get(ctx, &folder.GetFolderQuery{ + f, err := service.folderService.Get(ctx, &folder.GetFolderQuery{ OrgID: orgID, UID: &namespaceUID, SignedInUser: user, }) - if err != nil { + if err != nil || f == nil { if errors.Is(err, dashboards.ErrFolderNotFound) { return fmt.Errorf("%w: folder does not exist", models.ErrAlertRuleFailedValidation) } return err } + // check if the folder is managed by a manager + if err := models.NewNamespace(f).ValidateForRuleStorage(); err != nil { + return fmt.Errorf("%w: %s", models.ErrAlertRuleFailedValidation, err) + } + return nil } diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index c552f797429..ed001a527c4 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/db" @@ -867,6 +868,27 @@ func TestIntegrationAlertRuleService(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(120), rule.IntervalSeconds) }) + + t.Run("UpdateRuleGroup should reject when folder is managed by a manager", func(t *testing.T) { + service, _, _, ac := initService(t) + ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { + return true, nil + } + + managedFolderUID := "managed-folder-update-group" + fs := foldertest.NewFakeService() + fs.AddFolder(&folder.Folder{ + OrgID: orgID, + UID: managedFolderUID, + Title: "Managed Folder", + ManagedBy: utils.ManagerKindRepo, + }) + service.folderService = fs + + err := service.UpdateRuleGroup(context.Background(), u, managedFolderUID, "some-group", 120) + require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation) + require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync") + }) } func TestIntegrationCreateAlertRule(t *testing.T) { @@ -1166,6 +1188,30 @@ func TestIntegrationCreateAlertRule(t *testing.T) { require.NoError(t, err) require.True(t, models.IsNoGroupRuleGroup(retrievedRule.RuleGroup), "Rule should be considered NoGroup rule") }) + + t.Run("should reject creation when folder is managed by a manager", func(t *testing.T) { + service, _, _, ac := initService(t) + ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { + return true, nil + } + + managedFolderUID := "managed-folder" + fs := foldertest.NewFakeService() + fs.AddFolder(&folder.Folder{ + OrgID: orgID, + UID: managedFolderUID, + Title: "Managed Folder", + ManagedBy: utils.ManagerKindRepo, + }) + service.folderService = fs + + rule := dummyRule("test-managed-folder", orgID) + rule.NamespaceUID = managedFolderUID + + _, err := service.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone) + require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation) + require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync") + }) } func TestUpdateAlertRule(t *testing.T) { @@ -1316,6 +1362,36 @@ func TestUpdateAlertRule(t *testing.T) { require.Equal(t, "nogroup-update-new", updated.Title) require.Equal(t, originalInterval, updated.IntervalSeconds) }) + + t.Run("should reject update when folder is managed by a manager", func(t *testing.T) { + service, ruleStore, provenanceStore, ac := initService(t) + ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { + return true, nil + } + + managedFolderUID := "managed-folder-update" + fs := foldertest.NewFakeService() + fs.AddFolder(&folder.Folder{ + OrgID: orgID, + UID: managedFolderUID, + Title: "Managed Folder", + ManagedBy: utils.ManagerKindRepo, + }) + service.folderService = fs + + // Create an existing rule + existingRule := dummyRule("test-managed-folder-update", orgID) + existingRule.NamespaceUID = managedFolderUID + _, err := ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.InsertRule{{AlertRule: existingRule}}) + require.NoError(t, err) + require.NoError(t, provenanceStore.SetProvenance(context.Background(), &existingRule, orgID, models.ProvenanceNone)) + + // Try to update the rule + existingRule.Title = "Updated Title" + _, err = service.UpdateAlertRule(context.Background(), u, existingRule, models.ProvenanceNone) + require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation) + require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync") + }) } func TestDeleteAlertRule(t *testing.T) { @@ -2054,6 +2130,33 @@ func TestReplaceGroup(t *testing.T) { require.Error(t, err) require.ErrorContains(t, err, "cannot move rule out of this group") }) + + t.Run("should reject replace when folder is managed by a manager", func(t *testing.T) { + service, _, _, ac := initService(t) + ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { + return true, nil + } + + managedFolderUID := "managed-folder-replace" + fs := foldertest.NewFakeService() + fs.AddFolder(&folder.Folder{ + OrgID: orgID, + UID: managedFolderUID, + Title: "Managed Folder", + ManagedBy: utils.ManagerKindRepo, + }) + service.folderService = fs + + group := models.AlertRuleGroup{ + Title: "test-group", + FolderUID: managedFolderUID, + Interval: 60, + } + + err := service.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceNone, "") + require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation) + require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync") + }) } func TestDeleteRuleGroup(t *testing.T) { diff --git a/pkg/services/ngalert/state/historian/loki.go b/pkg/services/ngalert/state/historian/loki.go index 137c18f031c..e07461f27bf 100644 --- a/pkg/services/ngalert/state/historian/loki.go +++ b/pkg/services/ngalert/state/historian/loki.go @@ -530,7 +530,7 @@ func (h *RemoteLokiBackend) getFolderUIDsForFilter(ctx context.Context, query mo uids := make([]string, 0, len(folders)) // now keep only UIDs of folder in which user can read rules. for _, f := range folders { - hasAccess, err := h.ac.HasAccessInFolder(ctx, query.SignedInUser, models.Namespace(*f.ToFolderReference())) + hasAccess, err := h.ac.HasAccessInFolder(ctx, query.SignedInUser, models.NewNamespace(f)) if err != nil { return nil, err } From 584615cf3fd36b6b4b05c28f2c477486c2b903f6 Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Fri, 12 Dec 2025 10:32:05 -0500 Subject: [PATCH 120/139] RecentlyViewedDashboards: Set up container on browsing dashboards page (#115164) * RecentlyViewedDashboards: Set up container on browsing dashboards page --- .../BrowseDashboardsPage.tsx | 3 + .../components/RecentlyViewedDashboards.tsx | 77 +++++++++++++++++++ .../browse-dashboards/components/utils.ts | 36 +++++++++ .../actions/dashboardActions.ts | 17 +--- public/locales/en-US/grafana.json | 4 + 5 files changed, 122 insertions(+), 15 deletions(-) create mode 100644 public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index 921c67ba9cb..e177fcf9fa2 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -27,6 +27,7 @@ import { BrowseFilters } from './components/BrowseFilters'; import { BrowseView } from './components/BrowseView'; import CreateNewButton from './components/CreateNewButton'; import { FolderActionsButton } from './components/FolderActionsButton'; +import { RecentlyViewedDashboards } from './components/RecentlyViewedDashboards'; import { SearchView } from './components/SearchView'; import { getFolderPermissions } from './permissions'; import { useHasSelection } from './state/hooks'; @@ -178,6 +179,8 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record + {/* only show recently viewed dashboards when in root */} + {!folderUID && }
{ + if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) { + return []; + } + return getRecentlyViewedDashboards(MAX_RECENT); + }, []); + + if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) { + return null; + } + + return ( + + Recently viewed + + } + isOpen={true} + className={styles.title} + contentClassName={styles.content} + > + {/* placeholder */} + {loading && } + {/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */} + {!loading && recentDashboards.length === 0 && ( + {t('browse-dashboards.recently-viewed.empty', 'Nothing viewed yet')} + )} + + {/* TODO: implement actual card content */} + {!loading && recentDashboards.length > 0 && ( + <> + {recentDashboards.map((dash) => ( +
+ {dash.name} +
+ ))} + + )} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + const accent = theme.visualization.getColorByName('purple'); // or your own hex + + return { + title: css({ + background: `linear-gradient(90deg, ${accent} 0%, #e478eaff 100%)`, + WebkitTextFillColor: 'transparent', + backgroundClip: 'text', + color: 'transparent', + '& button svg': { + color: accent, + }, + }), + content: css({ + paddingTop: theme.spacing(0), + }), + }; +}; diff --git a/public/app/features/browse-dashboards/components/utils.ts b/public/app/features/browse-dashboards/components/utils.ts index a6c97e4788f..e1a3d5b5a02 100644 --- a/public/app/features/browse-dashboards/components/utils.ts +++ b/public/app/features/browse-dashboards/components/utils.ts @@ -1,6 +1,9 @@ import { config } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; +import impressionSrv from 'app/core/services/impression_srv'; import { ResourceRef } from 'app/features/provisioning/components/BulkActions/useBulkActionJob'; +import { getGrafanaSearcher } from 'app/features/search/service/searcher'; +import { DashboardQueryResult } from 'app/features/search/service/types'; import { DashboardTreeSelection, DashboardViewItemWithUIItems, BrowseDashboardsPermissions } from '../types'; @@ -60,3 +63,36 @@ export function canSelectItems(permissions: BrowseDashboardsPermissions) { const canSelectDashboards = canEditDashboards || canDeleteDashboards; return Boolean(canSelectFolders || canSelectDashboards); } + +/** + * Returns dashboard search results ordered the same way the user opened them. + */ +export async function getRecentlyViewedDashboards(maxItems = 5): Promise { + try { + const recentlyOpened = (await impressionSrv.getDashboardOpened()).slice(0, maxItems); + if (!recentlyOpened.length) { + return []; + } + + const searchResults = await getGrafanaSearcher().search({ + kind: ['dashboard'], + limit: recentlyOpened.length, + uid: recentlyOpened, + }); + + const dashboards = searchResults.view.toArray(); + // Keep dashboards in the same order the user opened them. + // When a UID is missing from the search response + // push it to the end instead of letting indexOf return -1 + const order = (uid: string) => { + const idx = recentlyOpened.indexOf(uid); + return idx === -1 ? recentlyOpened.length : idx; + }; + + dashboards.sort((a, b) => order(a.uid) - order(b.uid)); + return dashboards; + } catch (error) { + console.error('Failed to load recently viewed dashboards', error); + return []; + } +} diff --git a/public/app/features/commandPalette/actions/dashboardActions.ts b/public/app/features/commandPalette/actions/dashboardActions.ts index 70a62e57295..ac3e8ce83dd 100644 --- a/public/app/features/commandPalette/actions/dashboardActions.ts +++ b/public/app/features/commandPalette/actions/dashboardActions.ts @@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react'; import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; -import impressionSrv from 'app/core/services/impression_srv'; +import { getRecentlyViewedDashboards } from 'app/features/browse-dashboards/components/utils'; import { getGrafanaSearcher } from 'app/features/search/service/searcher'; import { CommandPaletteAction } from '../types'; @@ -20,20 +20,7 @@ export async function getRecentDashboardActions(): Promise { - const orderA = recentUids.indexOf(resultA.uid); - const orderB = recentUids.indexOf(resultB.uid); - return orderA - orderB; - }); + const recentResults = await getRecentlyViewedDashboards(MAX_RECENT_DASHBOARDS); const recentDashboardActions: CommandPaletteAction[] = recentResults.map((item) => { const { url, name } = item; // items are backed by DataFrameView, so must hold the url in a closure diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 9d9ec201d18..167f3714495 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3707,6 +3707,10 @@ "clear": "Clear search and filters", "text": "No results found for your query" }, + "recently-viewed": { + "empty": "Nothing viewed yet", + "title": "Recently viewed" + }, "restore": { "all-failed_one": "Failed to restore {{count}} dashboard", "all-failed_other": "Failed to restore {{count}} dashboards", From b40d0e6ff4c3b02a9c8be9b75282f2c2e2b1fe35 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Fri, 12 Dec 2025 10:36:31 -0500 Subject: [PATCH 121/139] Dashboards: Fix accessible color palettes not being saved in v2 schema (#115244) * Fix palette color v2 conversion * v2->v1 conversion --- .../serialization/transformToV2TypesUtils.ts | 10 ++++++++++ .../app/features/dashboard/api/ResponseTransformers.ts | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.ts b/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.ts index fe1d85e6525..8b17ab8ff89 100644 --- a/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.ts +++ b/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.ts @@ -119,6 +119,16 @@ export function colorIdEnumToColorIdV2(colorId: FieldColorModeIdV1 | string): Fi return 'continuous-greens'; case FieldColorModeIdV1.ContinuousPurples: return 'continuous-purples'; + case FieldColorModeIdV1.ContinuousViridis: + return 'continuous-viridis'; + case FieldColorModeIdV1.ContinuousMagma: + return 'continuous-magma'; + case FieldColorModeIdV1.ContinuousPlasma: + return 'continuous-plasma'; + case FieldColorModeIdV1.ContinuousInferno: + return 'continuous-inferno'; + case FieldColorModeIdV1.ContinuousCividis: + return 'continuous-cividis'; case FieldColorModeIdV1.Fixed: return 'fixed'; case FieldColorModeIdV1.Shades: diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index b6fcba03de3..d2640991b1e 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -1268,6 +1268,16 @@ function colorIdToEnumv1(colorId: FieldColorModeId): FieldColorModeIdV1 { return FieldColorModeIdV1.ContinuousGreens; case 'continuous-purples': return FieldColorModeIdV1.ContinuousPurples; + case 'continuous-viridis': + return FieldColorModeIdV1.ContinuousViridis; + case 'continuous-magma': + return FieldColorModeIdV1.ContinuousMagma; + case 'continuous-plasma': + return FieldColorModeIdV1.ContinuousPlasma; + case 'continuous-inferno': + return FieldColorModeIdV1.ContinuousInferno; + case 'continuous-cividis': + return FieldColorModeIdV1.ContinuousCividis; case 'fixed': return FieldColorModeIdV1.Fixed; case 'shades': From 7114b9cd3bafa4d108081c8d95ac0ed6e31928f6 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Fri, 12 Dec 2025 16:56:23 +0100 Subject: [PATCH 122/139] Log Line Details: Fix width calculation in dashboards (#115248) * FieldSelector: rename functions to be more explicit * LogDetailsContext: calculate width based on field selector visibility * LogLineDetails: Fix sidebar max width calculation * Update functions usage * Add regression and fix context calculation --- .../features/explore/Logs/LogsTableWrap.tsx | 4 +-- .../fieldSelector/FieldSelector.tsx | 10 +++--- .../components/panel/LogDetailsContext.tsx | 36 +++++++++++++------ .../components/panel/LogLineDetails.test.tsx | 26 ++++++++++++++ .../logs/components/panel/LogLineDetails.tsx | 10 ++++-- .../logs/components/panel/LogList.tsx | 2 ++ .../logs/components/panel/LogListContext.tsx | 4 +-- 7 files changed, 70 insertions(+), 22 deletions(-) diff --git a/public/app/features/explore/Logs/LogsTableWrap.tsx b/public/app/features/explore/Logs/LogsTableWrap.tsx index ff8cceaadcf..cde546d92ba 100644 --- a/public/app/features/explore/Logs/LogsTableWrap.tsx +++ b/public/app/features/explore/Logs/LogsTableWrap.tsx @@ -19,7 +19,7 @@ import { t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { getDragStyles, InlineField, Select, useStyles2 } from '@grafana/ui'; import { - getSidebarWidth, + getFieldSelectorWidth, LogsTableFieldSelector, MIN_WIDTH, } from 'app/features/logs/components/fieldSelector/FieldSelector'; @@ -279,7 +279,7 @@ export function LogsTableWrap(props: Props) { // The panel state is updated when the user interacts with the multi-select sidebar }, [currentDataFrame, getColumnsFromProps]); - const [sidebarWidth, setSidebarWidth] = useState(getSidebarWidth(SETTING_KEY_ROOT)); + const [sidebarWidth, setSidebarWidth] = useState(getFieldSelectorWidth(SETTING_KEY_ROOT)); const tableWidth = props.width - sidebarWidth; const styles = useStyles2(getStyles, height, sidebarWidth); diff --git a/public/app/features/logs/components/fieldSelector/FieldSelector.tsx b/public/app/features/logs/components/fieldSelector/FieldSelector.tsx index 44b1ff0f85c..d68cf5340c9 100644 --- a/public/app/features/logs/components/fieldSelector/FieldSelector.tsx +++ b/public/app/features/logs/components/fieldSelector/FieldSelector.tsx @@ -35,7 +35,7 @@ export const LogListFieldSelector = ({ containerElement, dataFrames, logs }: Log const { displayedFields, onClickShowField, onClickHideField, setDisplayedFields, logOptionsStorageKey } = useLogListContext(); const [sidebarHeight, setSidebarHeight] = useState(220); - const [sidebarWidth, setSidebarWidth] = useState(getSidebarWidth(logOptionsStorageKey)); + const [sidebarWidth, setSidebarWidth] = useState(getFieldSelectorWidth(logOptionsStorageKey)); const dragStyles = useStyles2(getDragStyles); useLayoutEffect(() => { @@ -74,7 +74,7 @@ export const LogListFieldSelector = ({ containerElement, dataFrames, logs }: Log }, [setSidebarWidthWrapper]); const expand = useCallback(() => { - const width = getSidebarWidth(logOptionsStorageKey); + const width = getFieldSelectorWidth(logOptionsStorageKey); setSidebarWidthWrapper(width < 2 * MIN_WIDTH ? DEFAULT_WIDTH : width); reportInteraction('logs_field_selector_expand_clicked', { mode: 'logs', @@ -205,7 +205,7 @@ export const LogsTableFieldSelector = ({ }, [setSidebarWidthWrapper]); const expand = useCallback(() => { - const width = getSidebarWidth(SETTING_KEY_ROOT); + const width = getFieldSelectorWidth(SETTING_KEY_ROOT); setSidebarWidthWrapper(width < 2 * MIN_WIDTH ? DEFAULT_WIDTH : width); reportInteraction('logs_field_selector_expand_clicked', { mode: 'table', @@ -436,7 +436,7 @@ function getSuggestedFields(logs: LogListModel[], displayedFields: string[], def return suggestedFields; } -export function getSidebarWidth(logOptionsStorageKey?: string): number { +export function getFieldSelectorWidth(logOptionsStorageKey?: string): number { const width = (logOptionsStorageKey ? parseInt(store.get(`${logOptionsStorageKey}.fieldSelector.width`) ?? DEFAULT_WIDTH, 10) @@ -445,7 +445,7 @@ export function getSidebarWidth(logOptionsStorageKey?: string): number { return width < MIN_WIDTH ? MIN_WIDTH : width; } -export function getSidebarState(logOptionsStorageKey?: string): boolean | undefined { +export function getFieldSelectorState(logOptionsStorageKey?: string): boolean | undefined { if (!logOptionsStorageKey) { return undefined; } diff --git a/public/app/features/logs/components/panel/LogDetailsContext.tsx b/public/app/features/logs/components/panel/LogDetailsContext.tsx index de0c044911e..d3526e2bcb4 100644 --- a/public/app/features/logs/components/panel/LogDetailsContext.tsx +++ b/public/app/features/logs/components/panel/LogDetailsContext.tsx @@ -3,7 +3,7 @@ import { createContext, ReactNode, useCallback, useContext, useEffect, useState import { LogRowModel, store } from '@grafana/data'; -import { getSidebarWidth } from '../fieldSelector/FieldSelector'; +import { getFieldSelectorWidth } from '../fieldSelector/FieldSelector'; import { LogLineDetailsMode } from './LogLineDetails'; import { LogListModel } from './processing'; @@ -56,6 +56,7 @@ export interface Props { logs: LogRowModel[]; logOptionsStorageKey?: string; showControls: boolean; + showFieldSelector?: boolean; } export const LogDetailsContextProvider = ({ @@ -68,12 +69,13 @@ export const LogDetailsContextProvider = ({ : getDefaultDetailsMode(containerElement), logs, showControls, + showFieldSelector, }: Props) => { const [showDetails, setShowDetails] = useState([]); const [currentLog, setCurrentLog] = useState(undefined); const [detailsWidth, setDetailsWidthState] = useState( - getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsModeProp, showControls) + getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsModeProp, showControls, showFieldSelector) ); const [detailsMode, setDetailsMode] = useState( detailsModeProp ?? getDefaultDetailsMode(containerElement) @@ -101,8 +103,10 @@ export const LogDetailsContextProvider = ({ // Sync log details inline and sidebar width useEffect(() => { - setDetailsWidthState(getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsMode, showControls)); - }, [containerElement, detailsMode, logOptionsStorageKey, showControls]); + setDetailsWidthState( + getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsMode, showControls, showFieldSelector) + ); + }, [containerElement, detailsMode, logOptionsStorageKey, showControls, showFieldSelector]); // Sync log details width useEffect(() => { @@ -111,13 +115,20 @@ export const LogDetailsContextProvider = ({ } const handleResize = debounce(() => { setDetailsWidthState((detailsWidth) => - getDetailsWidth(containerElement, logOptionsStorageKey, detailsWidth, detailsMode, showControls) + getDetailsWidth( + containerElement, + logOptionsStorageKey, + detailsWidth, + detailsMode, + showControls, + showFieldSelector + ) ); }, 50); const observer = new ResizeObserver(() => handleResize()); observer.observe(containerElement); return () => observer.disconnect(); - }, [containerElement, detailsMode, logOptionsStorageKey, showControls, showDetails]); + }, [containerElement, detailsMode, logOptionsStorageKey, showControls, showDetails, showFieldSelector]); const closeDetails = useCallback(() => { showDetails.forEach((log) => removeDetailsScrollPosition(log)); @@ -158,7 +169,10 @@ export const LogDetailsContextProvider = ({ return; } - const maxWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey) - LOG_LIST_MIN_WIDTH; + const maxWidth = + containerElement.clientWidth - + (showFieldSelector ? getFieldSelectorWidth(logOptionsStorageKey) : 0) - + LOG_LIST_MIN_WIDTH; if (width > maxWidth) { return; } @@ -166,7 +180,7 @@ export const LogDetailsContextProvider = ({ store.set(`${logOptionsStorageKey}.detailsWidth`, width); setDetailsWidthState(width); }, - [containerElement, logOptionsStorageKey] + [containerElement, logOptionsStorageKey, showFieldSelector] ); return ( @@ -196,12 +210,14 @@ export function getDetailsWidth( logOptionsStorageKey?: string, currentWidth?: number, detailsMode: LogLineDetailsMode = 'sidebar', - showControls?: boolean + showControls?: boolean, + showFieldSelector?: boolean ) { if (!containerElement) { return 0; } - const availableWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey); + const availableWidth = + containerElement.clientWidth - (showFieldSelector ? getFieldSelectorWidth(logOptionsStorageKey) : 0); if (detailsMode === 'inline') { return availableWidth - getScrollbarWidth() - (showControls ? LOG_LIST_CONTROLS_WIDTH : 0); } diff --git a/public/app/features/logs/components/panel/LogLineDetails.test.tsx b/public/app/features/logs/components/panel/LogLineDetails.test.tsx index dafc055ce0d..cee12e10d8b 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.test.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.test.tsx @@ -20,6 +20,7 @@ import { setPluginLinksHook } from '@grafana/runtime'; import { createTempoDatasource } from 'app/plugins/datasource/tempo/test/mocks'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; +import { getFieldSelectorWidth } from '../fieldSelector/FieldSelector'; import { createLogLine } from '../mocks/logRow'; import { emptyContextData, LogDetailsContext, LogDetailsContextData } from './LogDetailsContext'; @@ -27,6 +28,10 @@ import { LogLineDetails, Props } from './LogLineDetails'; import { LogListContext, LogListContextData } from './LogListContext'; import { defaultValue } from './__mocks__/LogListContext'; +jest.mock('../fieldSelector/FieldSelector'); + +jest.mocked(getFieldSelectorWidth).mockReturnValue(220); + jest.mock('@grafana/assistant', () => { return { ...jest.requireActual('@grafana/assistant'), @@ -79,6 +84,7 @@ const setup = ( }, timeZone: 'browser', showControls: true, + showFieldSelector: true, ...(propOverrides || {}), }; @@ -775,4 +781,24 @@ describe('LogLineDetails', () => { expect(screen.getByText('value')).toBeInTheDocument(); expect(screen.getByText('Open service overview for label')).toBeInTheDocument(); }); + + describe('Width regressions', () => { + test('should consider Fields Selector width when enabled', () => { + jest.mocked(getFieldSelectorWidth).mockClear(); + + setup({ showFieldSelector: true }, { labels: { key1: 'label1', key2: 'label2' } }); + expect(screen.getByText('Log line')).toBeInTheDocument(); + expect(screen.getByText('Fields')).toBeInTheDocument(); + expect(getFieldSelectorWidth).toHaveBeenCalled(); + }); + + test('should not consider Fields Selector width when disabled', () => { + jest.mocked(getFieldSelectorWidth).mockClear(); + + setup({ showFieldSelector: false }, { labels: { key1: 'label1', key2: 'label2' } }); + expect(screen.getByText('Log line')).toBeInTheDocument(); + expect(screen.getByText('Fields')).toBeInTheDocument(); + expect(getFieldSelectorWidth).not.toHaveBeenCalled(); + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index 19639e61f8c..bc5a961cbd7 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -7,7 +7,7 @@ import { t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { getDragStyles, Icon, Tab, TabsBar, useStyles2 } from '@grafana/ui'; -import { getSidebarWidth } from '../fieldSelector/FieldSelector'; +import { getFieldSelectorWidth } from '../fieldSelector/FieldSelector'; import { getDetailsScrollPosition, saveDetailsScrollPosition, useLogDetailsContext } from './LogDetailsContext'; import { LogLineDetailsComponent } from './LogLineDetailsComponent'; @@ -22,12 +22,13 @@ export interface Props { timeRange: TimeRange; timeZone: string; showControls: boolean; + showFieldSelector: boolean | undefined; } export type LogLineDetailsMode = 'inline' | 'sidebar'; export const LogLineDetails = memo( - ({ containerElement, focusLogLine, logs, timeRange, timeZone, showControls }: Props) => { + ({ containerElement, focusLogLine, logs, timeRange, timeZone, showControls, showFieldSelector }: Props) => { const { noInteractions, logOptionsStorageKey } = useLogListContext(); const { detailsWidth, setDetailsWidth } = useLogDetailsContext(); const styles = useStyles2(getStyles, 'sidebar', showControls); @@ -48,7 +49,10 @@ export const LogLineDetails = memo( } }, [noInteractions]); - const maxWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey) - LOG_LIST_MIN_WIDTH; + const maxWidth = + containerElement.clientWidth - + (showFieldSelector ? getFieldSelectorWidth(logOptionsStorageKey) : 0) - + LOG_LIST_MIN_WIDTH; return ( )}
diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index a917b0eac56..139f40ea98e 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -27,7 +27,7 @@ import { config, getDataSourceSrv } from '@grafana/runtime'; import { PopoverContent } from '@grafana/ui'; import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFormat } from '../../utils'; -import { getSidebarState } from '../fieldSelector/FieldSelector'; +import { getFieldSelectorState } from '../fieldSelector/FieldSelector'; import { getDisplayedFieldsForLogs } from '../otel/formats'; import { getDefaultDetailsMode, getDetailsWidth } from './LogDetailsContext'; @@ -245,7 +245,7 @@ export const LogListContextProvider = ({ dedupStrategy, fontSize, forceEscape: logListState.forceEscape, - fieldSelectorOpen: getSidebarState(logOptionsStorageKey), + fieldSelectorOpen: getFieldSelectorState(logOptionsStorageKey), showTime, showUniqueLabels, syntaxHighlighting, From c0dc92e8cd23dbdbc5fba6bc6554b9c416882a9d Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 12 Dec 2025 11:10:56 -0500 Subject: [PATCH 123/139] Gauge: Fit-and-finish tweaks to glows, text position, and sparkline size (#115173) * Gauge: Fit-and-finish tweaks to glows, text position, and sparkline size * adjust text height and positions a little more * cohesive no data handling * more tweaks * fix migration test * Fix JSON formatting by adding missing newline * remove new line --- .../panel-gauge/gauge_tests_new.v42.json | 153 ++++++++++- .../panel-gauge/gauge_tests_new.json | 255 +++++++++++++----- .../components/RadialGauge/RadialGauge.tsx | 12 +- .../RadialGauge/RadialSparkline.tsx | 29 +- .../src/components/RadialGauge/RadialText.tsx | 49 ++-- .../src/components/RadialGauge/effects.tsx | 9 +- .../panel/radialbar/RadialBarPanel.tsx | 6 + 7 files changed, 389 insertions(+), 124 deletions(-) diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json index c589f8b7400..9f8cf76c9f7 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json @@ -1603,7 +1603,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "description": "", "fieldConfig": { "defaults": { "color": { @@ -1671,7 +1670,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "hide": false, "max": 98, "min": 5, "noise": 22, @@ -1689,7 +1687,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "description": "", "fieldConfig": { "defaults": { "color": { @@ -1757,7 +1754,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "hide": false, "max": 98, "min": 5, "noise": 22, @@ -1788,7 +1784,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "description": "", "fieldConfig": { "defaults": { "color": { @@ -1857,7 +1852,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "hide": false, "max": 8, "min": 1, "noise": 2, @@ -1875,7 +1869,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "description": "", "fieldConfig": { "defaults": { "color": { @@ -1944,7 +1937,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "hide": false, "max": 12, "min": 1, "noise": 2, @@ -1962,7 +1954,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "description": "", "fieldConfig": { "defaults": { "color": { @@ -2030,7 +2021,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "hide": false, "max": 100, "min": 10, "noise": 22, @@ -2048,7 +2038,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "description": "", "fieldConfig": { "defaults": { "color": { @@ -2116,7 +2105,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "hide": false, "max": 100, "min": 10, "noise": 22, @@ -2129,6 +2117,147 @@ ], "title": "Backend", "type": "radialbar" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 66 + }, + "id": 35, + "panels": [], + "title": "Empty data", + "type": "row" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 67 + }, + "id": 36, + "options": { + "barWidthFactor": 0.5, + "effects": { + "barGlow": false, + "centerGlow": false, + "gradient": true, + "rounded": false, + "spotlight": false + }, + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "segmentCount": 1, + "segmentSpacing": 0.3, + "shape": "gauge", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sparkline": true + }, + "pluginVersion": "13.0.0-pre", + "targets": [ + { + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 0 + } + ], + "title": "Numeric, no series", + "type": "gauge" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 67 + }, + "id": 37, + "options": { + "barWidthFactor": 0.5, + "effects": { + "barGlow": false, + "centerGlow": false, + "gradient": true, + "rounded": false, + "spotlight": false + }, + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "segmentCount": 1, + "segmentSpacing": 0.3, + "shape": "gauge", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sparkline": true + }, + "pluginVersion": "13.0.0-pre", + "targets": [ + { + "refId": "A", + "scenarioId": "logs" + } + ], + "title": "Non-numeric", + "type": "gauge" } ], "preload": false, diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json index f9ee5a8c4e3..b3c47c9aa7a 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json @@ -75,9 +75,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": false, "rounded": true, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -152,9 +152,9 @@ "effects": { "barGlow": false, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -229,9 +229,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -306,9 +306,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -383,9 +383,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -460,9 +460,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": false, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -537,9 +537,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": false, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -627,9 +627,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -704,9 +704,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -781,9 +781,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -858,9 +858,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -952,9 +952,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": false, "rounded": false, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1029,9 +1029,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": false, "rounded": false, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1106,9 +1106,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": true, "rounded": false, - "spotlight": false, - "gradient": true + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1183,9 +1183,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": false, "rounded": false, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1260,9 +1260,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": false, "rounded": false, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1354,9 +1354,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": true, "rounded": false, - "spotlight": false, - "gradient": true + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1435,9 +1435,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": true, "rounded": false, - "spotlight": false, - "gradient": true + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1516,9 +1516,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": true, "rounded": false, - "spotlight": false, - "gradient": true + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1565,7 +1565,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "description": "", "fieldConfig": { "defaults": { "color": { @@ -1606,9 +1605,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": true, "rounded": true, - "spotlight": true, - "gradient": true + "spotlight": true }, "glow": "both", "orientation": "auto", @@ -1631,7 +1630,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "hide": false, "max": 98, "min": 5, "noise": 22, @@ -1649,7 +1647,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "description": "", "fieldConfig": { "defaults": { "color": { @@ -1690,9 +1687,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": true, "rounded": true, - "spotlight": true, - "gradient": true + "spotlight": true }, "glow": "both", "orientation": "auto", @@ -1715,7 +1712,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "hide": false, "max": 98, "min": 5, "noise": 22, @@ -1746,7 +1742,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "description": "", "fieldConfig": { "defaults": { "color": { @@ -1788,9 +1783,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": true, "rounded": true, - "spotlight": true, - "gradient": true + "spotlight": true }, "glow": "both", "orientation": "auto", @@ -1813,7 +1808,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "hide": false, "max": 8, "min": 1, "noise": 2, @@ -1831,7 +1825,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "description": "", "fieldConfig": { "defaults": { "color": { @@ -1873,9 +1866,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": true, "rounded": true, - "spotlight": true, - "gradient": true + "spotlight": true }, "glow": "both", "orientation": "auto", @@ -1898,7 +1891,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "hide": false, "max": 12, "min": 1, "noise": 2, @@ -1916,7 +1908,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "description": "", "fieldConfig": { "defaults": { "color": { @@ -1957,9 +1948,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": true, "rounded": true, - "spotlight": true, - "gradient": true + "spotlight": true }, "glow": "both", "orientation": "auto", @@ -1982,7 +1973,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "hide": false, "max": 100, "min": 10, "noise": 22, @@ -2000,7 +1990,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "description": "", "fieldConfig": { "defaults": { "color": { @@ -2041,9 +2030,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": true, "rounded": true, - "spotlight": true, - "gradient": true + "spotlight": true }, "glow": "both", "orientation": "auto", @@ -2066,7 +2055,6 @@ "datasource": { "type": "grafana-testdata-datasource" }, - "hide": false, "max": 100, "min": 10, "noise": 22, @@ -2079,6 +2067,147 @@ ], "title": "Backend", "type": "radialbar" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 66 + }, + "id": 35, + "panels": [], + "title": "Empty data", + "type": "row" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 67 + }, + "id": 36, + "options": { + "barWidthFactor": 0.5, + "effects": { + "barGlow": false, + "centerGlow": false, + "gradient": true, + "rounded": false, + "spotlight": false + }, + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "segmentCount": 1, + "segmentSpacing": 0.3, + "shape": "gauge", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sparkline": true + }, + "pluginVersion": "13.0.0-pre", + "targets": [ + { + "refId": "A", + "scenarioId": "random_walk", + "seriesCount": 0 + } + ], + "title": "Numeric, no series", + "type": "gauge" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 67 + }, + "id": 37, + "options": { + "barWidthFactor": 0.5, + "effects": { + "barGlow": false, + "centerGlow": false, + "gradient": true, + "rounded": false, + "spotlight": false + }, + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "segmentCount": 1, + "segmentSpacing": 0.3, + "shape": "gauge", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sparkline": true + }, + "pluginVersion": "13.0.0-pre", + "targets": [ + { + "refId": "A", + "scenarioId": "logs" + } + ], + "title": "Non-numeric", + "type": "gauge" } ], "preload": false, @@ -2095,5 +2224,5 @@ "timezone": "browser", "title": "Panel tests - Gauge (new)", "uid": "panel-tests-gauge-new", - "version": 6 + "version": 9 } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index 18147e0cac5..fadabf8ec72 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -106,6 +106,11 @@ export function RadialGauge(props: RadialGaugeProps) { const gaugeId = useId(); const styles = useStyles2(getStyles); + let effectiveTextMode = textMode; + if (effectiveTextMode === 'auto') { + effectiveTextMode = vizCount === 1 ? 'value' : 'value_and_name'; + } + const startAngle = shape === 'gauge' ? 250 : 0; const endAngle = shape === 'gauge' ? 110 : 360; @@ -188,7 +193,7 @@ export function RadialGauge(props: RadialGaugeProps) { // These elements are only added for first value / bar if (barIndex === 0) { if (glowBar) { - defs.push(); + defs.push(); } if (glowCenter) { @@ -198,14 +203,14 @@ export function RadialGauge(props: RadialGaugeProps) { graphics.push( ); @@ -254,6 +259,7 @@ export function RadialGauge(props: RadialGaugeProps) { theme={theme} color={color} shape={shape} + textMode={effectiveTextMode} /> ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx index 7a25fe3201a..acb255a3f3e 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx @@ -1,11 +1,9 @@ -import { css } from '@emotion/css'; - import { FieldDisplay, GrafanaTheme2, FieldConfig } from '@grafana/data'; import { GraphFieldConfig, GraphGradientMode, LineInterpolation } from '@grafana/schema'; import { Sparkline } from '../Sparkline/Sparkline'; -import { RadialShape } from './RadialGauge'; +import { RadialShape, RadialTextMode } from './RadialGauge'; import { GaugeDimensions } from './utils'; interface RadialSparklineProps { @@ -14,23 +12,22 @@ interface RadialSparklineProps { theme: GrafanaTheme2; color?: string; shape?: RadialShape; + textMode: Exclude; } -export function RadialSparkline({ sparkline, dimensions, theme, color, shape }: RadialSparklineProps) { +export function RadialSparkline({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) { + const { radius, barWidth } = dimensions; + if (!sparkline) { return null; } - const { radius, barWidth } = dimensions; - - const height = radius / 4; - const widthFactor = shape === 'gauge' ? 1.6 : 1.4; - const width = radius * widthFactor - barWidth; - const topPos = shape === 'gauge' ? `${dimensions.gaugeBottomY - height}px` : `calc(50% + ${radius / 2.8}px)`; - - const styles = css({ - position: 'absolute', - top: topPos, - }); + const showNameAndValue = textMode === 'value_and_name'; + const height = radius / (showNameAndValue ? 4 : 3); + const width = radius * (shape === 'gauge' ? 1.6 : 1.4) - barWidth; + const topPos = + shape === 'gauge' + ? `${dimensions.gaugeBottomY - height}px` + : `calc(50% + ${radius / (showNameAndValue ? 3.3 : 4)}px)`; const config: FieldConfig = { color: { @@ -45,7 +42,7 @@ export function RadialSparkline({ sparkline, dimensions, theme, color, shape }: }; return ( -
+
); diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx index d01a2d99570..51a1c64c842 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx @@ -1,6 +1,12 @@ import { css } from '@emotion/css'; -import { DisplayValue, DisplayValueAlignmentFactors, formattedValueToString, GrafanaTheme2 } from '@grafana/data'; +import { + DisplayValue, + DisplayValueAlignmentFactors, + FieldSparkline, + formattedValueToString, + GrafanaTheme2, +} from '@grafana/data'; import { useStyles2 } from '../../themes/ThemeContext'; import { calculateFontSize } from '../../utils/measureText'; @@ -8,21 +14,13 @@ import { calculateFontSize } from '../../utils/measureText'; import { RadialShape, RadialTextMode } from './RadialGauge'; import { GaugeDimensions } from './utils'; -// function toCartesian(centerX: number, centerY: number, radius: number, angleInDegrees: number) { -// let radian = ((angleInDegrees - 90) * Math.PI) / 180.0; -// return { -// x: centerX + radius * Math.cos(radian), -// y: centerY + radius * Math.sin(radian), -// }; -// } - interface RadialTextProps { displayValue: DisplayValue; theme: GrafanaTheme2; dimensions: GaugeDimensions; - textMode: RadialTextMode; - vizCount: number; + textMode: Exclude; shape: RadialShape; + sparkline?: FieldSparkline; alignmentFactors?: DisplayValueAlignmentFactors; valueManualFontSize?: number; nameManualFontSize?: number; @@ -33,8 +31,8 @@ export function RadialText({ theme, dimensions, textMode, - vizCount, shape, + sparkline, alignmentFactors, valueManualFontSize, nameManualFontSize, @@ -46,10 +44,6 @@ export function RadialText({ return null; } - if (textMode === 'auto') { - textMode = vizCount === 1 ? 'value' : 'value_and_name'; - } - const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? ''; const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue); @@ -59,7 +53,7 @@ export function RadialText({ // Not sure where this comes from but svg text is not using body line-height const lineHeight = 1.21; - const valueWidthToRadiusFactor = 0.85; + const valueWidthToRadiusFactor = 0.82; const nameToHeightFactor = 0.45; const largeRadiusScalingDecay = 0.86; @@ -98,18 +92,23 @@ export function RadialText({ const valueHeight = valueFontSize * lineHeight; const nameHeight = nameFontSize * lineHeight; - const valueY = showName ? centerY - nameHeight / 2 : centerY; - const valueNameSpacing = valueHeight / 3.5; - const nameY = showValue ? valueY + valueHeight / 2 + valueNameSpacing : centerY; + const valueY = showName ? centerY - nameHeight * 0.3 : centerY; + const nameY = showValue ? valueY + valueHeight * 0.7 : centerY; const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary; const suffixShift = (valueFontSize - unitFontSize * 1.2) / 2; - // For gauge shape we shift text up a bit - const valueDy = shape === 'gauge' ? -valueFontSize * 0.3 : 0; - const nameDy = shape === 'gauge' ? -nameFontSize * 0.7 : 0; + // adjust the text up on gauges and when sparklines are present + let yOffset = 0; + if (shape === 'gauge') { + // we render from the center of the gauge, so move up by half of half of the total height + yOffset -= (valueHeight + nameHeight) / 4; + } + if (sparkline) { + yOffset -= 8; + } return ( - + {showValue && ( {displayValue.prefix ?? ''} {displayValue.text} @@ -133,7 +131,6 @@ export function RadialText({ fontSize={nameFontSize} x={centerX} y={nameY} - dy={nameDy} textAnchor="middle" dominantBaseline="middle" fill={nameColor} diff --git a/packages/grafana-ui/src/components/RadialGauge/effects.tsx b/packages/grafana-ui/src/components/RadialGauge/effects.tsx index 551d9d91186..354a68a25ba 100644 --- a/packages/grafana-ui/src/components/RadialGauge/effects.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/effects.tsx @@ -4,11 +4,12 @@ import { GaugeDimensions } from './utils'; export interface GlowGradientProps { id: string; - radius: number; + barWidth: number; } -export function GlowGradient({ id, radius }: GlowGradientProps) { - const glowSize = 0.02 * radius; +export function GlowGradient({ id, barWidth }: GlowGradientProps) { + // 0.75 is the minimum glow size, and it scales with bar width + const glowSize = 0.75 + barWidth * 0.08; return ( @@ -82,7 +83,7 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps <> - + diff --git a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx index fa064037157..86235a3bf68 100644 --- a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx +++ b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx @@ -7,6 +7,7 @@ import { getFieldDisplayValues, PanelProps, } from '@grafana/data'; +import { PanelDataErrorView } from '@grafana/runtime'; import { DataLinksContextMenu, Stack, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui'; import { DataLinksContextMenuApi, RadialGauge } from '@grafana/ui/internal'; import { config } from 'app/core/config'; @@ -14,6 +15,7 @@ import { config } from 'app/core/config'; import { Options } from './panelcfg.gen'; export function RadialBarPanel({ + id, height, width, data, @@ -88,6 +90,10 @@ export function RadialBarPanel({ const minVizHeight = 60; const minVizWidth = 60; + if (getValues()[0]?.display?.text === 'No data') { + return ; + } + return ( Date: Fri, 12 Dec 2025 17:24:36 +0100 Subject: [PATCH 124/139] LogLineContext: remove broken permalink prop (#115252) --- public/app/features/explore/Logs/Logs.tsx | 1 - public/app/plugins/panel/logs/LogsPanel.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 84470d73a61..1ff012f3fee 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -789,7 +789,6 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { logOptionsStorageKey={SETTING_KEY_ROOT} timeZone={timeZone} displayedFields={displayedFields} - onPermalinkClick={onPermalinkClick} onClickShowField={showField} onClickHideField={hideField} /> diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 3cf47744762..3d89ba1d105 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -566,7 +566,6 @@ export const LogsPanel = ({ logLineMenuCustomItems={isLogLineMenuCustomItems(logLineMenuCustomItems) ? logLineMenuCustomItems : undefined} timeZone={timeZone} displayedFields={displayedFields} - onPermalinkClick={showPermaLink() ? onPermalinkClick : undefined} onClickShowField={showField} onClickHideField={hideField} /> From 1b59c82b747d002399983221a44cad42816e18d1 Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:00:08 -0500 Subject: [PATCH 125/139] Revert "Unified-storage: sql backend key path backfill (#115033)" (#115257) This reverts commit b2dd095bd860cfbc9187e0fdf91cae18206044e4. --- .../unified/sql/db/migrations/resource_mig.go | 140 ------------------ 1 file changed, 140 deletions(-) diff --git a/pkg/storage/unified/sql/db/migrations/resource_mig.go b/pkg/storage/unified/sql/db/migrations/resource_mig.go index 170b418a22a..fbbfe32d4a6 100644 --- a/pkg/storage/unified/sql/db/migrations/resource_mig.go +++ b/pkg/storage/unified/sql/db/migrations/resource_mig.go @@ -2,11 +2,8 @@ package migrations import ( "fmt" - "strings" - "github.com/bwmarrin/snowflake" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - "github.com/grafana/grafana/pkg/util/xorm" ) func initResourceTables(mg *migrator.Migrator) string { @@ -207,142 +204,5 @@ func initResourceTables(mg *migrator.Migrator) string { Name: "IDX_resource_history_key_path", })) - mg.AddMigration("resource_history key_path backfill", &ResourceHistoryKeyPathBackfillMigration{}) - return marker } - -type ResourceHistoryKeyPathBackfillMigration struct { - migrator.MigrationBase -} - -func (m *ResourceHistoryKeyPathBackfillMigration) SQL(_ migrator.Dialect) string { - return "resource_history key_path backfill code migration" -} - -func (m *ResourceHistoryKeyPathBackfillMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error { - rows, err := getResourceHistoryRows(sess, mg, resourceHistoryRow{}) - if err != nil { - return err - } - - for len(rows) > 0 { - if err := updateResourceHistoryKeyPath(sess, rows); err != nil { - return err - } - - rows, err = getResourceHistoryRows(sess, mg, rows[len(rows)-1]) - if err != nil { - return err - } - } - - return nil -} - -func updateResourceHistoryKeyPath(sess *xorm.Session, rows []resourceHistoryRow) error { - if len(rows) == 0 { - return nil - } - - updates := []resourceHistoryRow{} - - for _, row := range rows { - if row.KeyPath == "" { - row.KeyPath = parseKeyPath(row) - updates = append(updates, row) - } - } - - if len(updates) == 0 { - return nil - } - - guids := "" - setCases := "CASE" - for _, row := range updates { - guids += fmt.Sprintf("'%s',", row.GUID) - setCases += fmt.Sprintf(" WHEN guid = '%s' THEN '%s'", row.GUID, row.KeyPath) - } - - guids = strings.TrimRight(guids, ",") - setCases += " ELSE key_path END " - - // the query will look like this - // UPDATE resource_history - // SET key_path = CASE - // WHEN guid = '1402de51-669b-4206-8a6c-005a00eee6e3' then 'unified/data/folder.grafana.app/folders/default/cf6lylpvls000c/1998492888241012800~created~' - // WHEN guid = '8842cc56-f22b-45e1-82b1-99759cd443b3' then 'unified/data/dashboard.grafana.app/dashboards/default/adzvfhp/1998492902577144677~created~cf6lylpvls000c' - // ELSE key_path END - // WHERE guid IN ('1402de51-669b-4206-8a6c-005a00eee6e3', '8842cc56-f22b-45e1-82b1-99759cd443b3') - // AND key_path = ''; - sql := fmt.Sprintf(` - UPDATE resource_history - SET key_path = %s - WHERE guid IN (%s) - AND key_path = ''; - `, setCases, guids) - - if _, err := sess.Exec(sql); err != nil { - return err - } - - return nil -} - -func parseKeyPath(row resourceHistoryRow) string { - var action string - switch row.Action { - case 1: - action = "created" - case 2: - action = "updated" - case 3: - action = "deleted" - } - return fmt.Sprintf("unified/data/%s/%s/%s/%s/%d~%s~%s", row.Group, row.Resource, row.Namespace, row.Name, snowflakeFromRv(row.ResourceVersion), action, row.Folder) -} - -func snowflakeFromRv(rv int64) int64 { - return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000) -} - -type resourceHistoryRow struct { - GUID string `xorm:"guid"` - Group string `xorm:"group"` - Resource string `xorm:"resource"` - Namespace string `xorm:"namespace"` - Name string `xorm:"name"` - ResourceVersion int64 `xorm:"resource_version"` - Action int64 `xorm:"action"` - Folder string `xorm:"folder"` - KeyPath string `xorm:"key_path"` -} - -func getResourceHistoryRows(sess *xorm.Session, mg *migrator.Migrator, continueRow resourceHistoryRow) ([]resourceHistoryRow, error) { - var rows []resourceHistoryRow - cols := fmt.Sprintf( - "%s, %s, %s, %s, %s, %s, %s, %s, %s", - mg.Dialect.Quote("guid"), - mg.Dialect.Quote("group"), - mg.Dialect.Quote("resource"), - mg.Dialect.Quote("namespace"), - mg.Dialect.Quote("name"), - mg.Dialect.Quote("resource_version"), - mg.Dialect.Quote("action"), - mg.Dialect.Quote("folder"), - mg.Dialect.Quote("key_path")) - sql := fmt.Sprintf(` - SELECT %s - FROM resource_history - WHERE (resource_version > %d OR (resource_version = %d AND guid > '%s')) - AND key_path = '' - ORDER BY resource_version ASC, guid ASC - LIMIT 1000; - `, cols, continueRow.ResourceVersion, continueRow.ResourceVersion, continueRow.GUID) - if err := sess.SQL(sql).Find(&rows); err != nil { - return nil, err - } - - return rows, nil -} From 629570926d8846f68715ed6240413452055c5c97 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Fri, 12 Dec 2025 18:05:10 +0100 Subject: [PATCH 126/139] Zanzana: Fix resource translation for dashboards (#115077) --- pkg/services/authz/zanzana/common/info.go | 32 ++++++- pkg/services/authz/zanzana/common/tuple.go | 3 + .../authz/zanzana/common/tuple_test.go | 89 +++++++++++++++++++ .../authz/zanzana/server/server_check_test.go | 12 +++ .../authz/zanzana/server/server_test.go | 2 + 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 pkg/services/authz/zanzana/common/tuple_test.go diff --git a/pkg/services/authz/zanzana/common/info.go b/pkg/services/authz/zanzana/common/info.go index c17970ca1b3..4e4bbedc0b7 100644 --- a/pkg/services/authz/zanzana/common/info.go +++ b/pkg/services/authz/zanzana/common/info.go @@ -4,8 +4,12 @@ import ( "google.golang.org/protobuf/types/known/structpb" authzv1 "github.com/grafana/authlib/authz/proto/v1" + + dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/services/accesscontrol" authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" ) @@ -44,7 +48,8 @@ func getTypeInfo(group, resource string) (typeInfo, bool) { func NewResourceInfoFromCheck(r *authzv1.CheckRequest) ResourceInfo { typ, relations := getTypeAndRelations(r.GetGroup(), r.GetResource()) - return newResource( + + resource := newResource( typ, r.GetGroup(), r.GetResource(), @@ -53,6 +58,19 @@ func NewResourceInfoFromCheck(r *authzv1.CheckRequest) ResourceInfo { r.GetSubresource(), relations, ) + + // Special case for creating folders and resources in the root folder + if r.GetVerb() == utils.VerbCreate { + if resource.IsFolderResource() && resource.name == "" { + resource.name = accesscontrol.GeneralFolderUID + } else if resource.HasFolderSupport() && resource.folder == "" { + resource.folder = accesscontrol.GeneralFolderUID + } + + return resource + } + + return resource } func NewResourceInfoFromBatchItem(i *authzextv1.BatchCheckItem) ResourceInfo { @@ -164,3 +182,15 @@ func (r ResourceInfo) IsValidRelation(relation string) bool { func (r ResourceInfo) HasSubresource() bool { return r.subresource != "" } + +var resourcesWithFolderSupport = map[string]bool{ + dashboardV1.DashboardResourceInfo.GroupResource().Group: true, +} + +func (r ResourceInfo) HasFolderSupport() bool { + return resourcesWithFolderSupport[r.group] +} + +func (r ResourceInfo) IsFolderResource() bool { + return r.group == folders.FolderResourceInfo.GroupResource().Group +} diff --git a/pkg/services/authz/zanzana/common/tuple.go b/pkg/services/authz/zanzana/common/tuple.go index b1b6499dcd2..7f0faff1f18 100644 --- a/pkg/services/authz/zanzana/common/tuple.go +++ b/pkg/services/authz/zanzana/common/tuple.go @@ -228,6 +228,9 @@ func TranslateToResourceTuple(subject string, action, kind, name string) (*openf } if name == "*" { + if m.group != "" && m.resource != "" { + return NewGroupResourceTuple(subject, m.relation, m.group, m.resource, m.subresource), true + } return NewGroupResourceTuple(subject, m.relation, translation.group, translation.resource, m.subresource), true } diff --git a/pkg/services/authz/zanzana/common/tuple_test.go b/pkg/services/authz/zanzana/common/tuple_test.go new file mode 100644 index 00000000000..ecb4d6e9dd1 --- /dev/null +++ b/pkg/services/authz/zanzana/common/tuple_test.go @@ -0,0 +1,89 @@ +package common + +import ( + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +type translationTestCase struct { + testName string + subject string + action string + kind string + name string + expected *openfgav1.TupleKey +} + +func TestTranslateToResourceTuple(t *testing.T) { + tests := []translationTestCase{ + { + testName: "dashboards:read in folders", + subject: "user:1", + action: "dashboards:read", + kind: "folders", + name: "*", + expected: &openfgav1.TupleKey{ + User: "user:1", + Relation: "get", + Object: "group_resource:dashboard.grafana.app/dashboards", + }, + }, + { + testName: "dashboards:read for all dashboards", + subject: "user:1", + action: "dashboards:read", + kind: "dashboards", + name: "*", + expected: &openfgav1.TupleKey{ + User: "user:1", + Relation: "get", + Object: "group_resource:dashboard.grafana.app/dashboards", + }, + }, + { + testName: "dashboards:read for general folder", + subject: "user:1", + action: "dashboards:read", + kind: "folders", + name: "general", + expected: &openfgav1.TupleKey{ + User: "user:1", + Relation: "resource_get", + Object: "folder:general", + Condition: &openfgav1.RelationshipCondition{ + Name: "subresource_filter", + Context: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "subresources": structpb.NewListValue(&structpb.ListValue{ + Values: []*structpb.Value{structpb.NewStringValue("dashboard.grafana.app/dashboards")}, + }), + }, + }, + }, + }, + }, + { + testName: "folders:read", + subject: "user:1", + action: "folders:read", + kind: "folders", + name: "*", + expected: &openfgav1.TupleKey{ + User: "user:1", + Relation: "get", + Object: "group_resource:folder.grafana.app/folders", + }, + }, + } + + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + tuple, ok := TranslateToResourceTuple(test.subject, test.action, test.kind, test.name) + require.True(t, ok) + require.EqualExportedValues(t, test.expected, tuple) + }) + } +} diff --git a/pkg/services/authz/zanzana/server/server_check_test.go b/pkg/services/authz/zanzana/server/server_check_test.go index 59a192fe6a0..d8e8fa01526 100644 --- a/pkg/services/authz/zanzana/server/server_check_test.go +++ b/pkg/services/authz/zanzana/server/server_check_test.go @@ -212,4 +212,16 @@ func testCheck(t *testing.T, server *Server) { require.NoError(t, err) assert.True(t, res.GetAllowed(), "user should be able to view dashboards in folder 6") }) + + t.Run("user:18 should be able to create folder in root folder", func(t *testing.T) { + res, err := server.Check(newContextWithNamespace(), newReq("user:18", utils.VerbCreate, folderGroup, folderResource, "", "", "")) + require.NoError(t, err) + assert.Equal(t, true, res.GetAllowed()) + }) + + t.Run("user:18 should be able to create dashboard in root folder", func(t *testing.T) { + res, err := server.Check(newContextWithNamespace(), newReq("user:18", utils.VerbCreate, dashboardGroup, dashboardResource, "", "", "")) + require.NoError(t, err) + assert.Equal(t, true, res.GetAllowed()) + }) } diff --git a/pkg/services/authz/zanzana/server/server_test.go b/pkg/services/authz/zanzana/server/server_test.go index 63cf8ee2a50..3f3a7e2cad6 100644 --- a/pkg/services/authz/zanzana/server/server_test.go +++ b/pkg/services/authz/zanzana/server/server_test.go @@ -71,6 +71,8 @@ func setup(t *testing.T, srv *Server) *Server { common.NewTypedResourceTuple("user:15", common.RelationGet, common.TypeUser, userGroup, userResource, statusSubresource, "1"), common.NewTypedResourceTuple("user:16", common.RelationGet, common.TypeServiceAccount, serviceAccountGroup, serviceAccountResource, statusSubresource, "1"), common.NewFolderTuple("user:17", common.RelationSetView, "4"), + common.NewFolderTuple("user:18", common.RelationCreate, "general"), + common.NewFolderResourceTuple("user:18", common.RelationCreate, dashboardGroup, dashboardResource, "", "general"), } return setupOpenFGADatabase(t, srv, tuples) From 644f7b7001fb30f06a998cc0e2a16778a74a717b Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Fri, 12 Dec 2025 18:59:49 +0100 Subject: [PATCH 127/139] Infinite scroll: Fix interaction with client-side filter (#115243) Infinite scroll: fix interaction with client-side filter --- .../app/features/logs/components/panel/InfiniteScroll.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx index fd5fb15a014..4508682df57 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -256,7 +256,11 @@ export const InfiniteScroll = ({ if (props.visibleStartIndex === 0) { noScrollRef.current = scrollElement.scrollHeight <= scrollElement.clientHeight; } - if (noScrollRef.current || infiniteLoaderState === 'loading' || infiniteLoaderState === 'out-of-bounds') { + if (noScrollRef.current) { + setInfiniteLoaderState('idle'); + return; + } + if (infiniteLoaderState === 'loading' || infiniteLoaderState === 'out-of-bounds') { return; } const lastLogIndex = logs.length - 1; @@ -267,7 +271,7 @@ export const InfiniteScroll = ({ setInfiniteLoaderState('idle'); } }, - [infiniteLoaderState, logs.length, scrollElement] + [infiniteLoaderState, logs, scrollElement] ); const getItemKey = useCallback((index: number) => (logs[index] ? logs[index].uid : index.toString()), [logs]); From b29e8ccb457f53dc920132f432d7b8c2a30db5e4 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Fri, 12 Dec 2025 13:14:56 -0500 Subject: [PATCH 128/139] Dashboards: Generate default tab title when converting rows with empty titles to tabs (#115256) Generate default title for empty row titles --- .../layout-tabs/TabsLayoutManager.test.tsx | 53 +++++++++++++++++++ .../scene/layout-tabs/TabsLayoutManager.tsx | 10 +++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.test.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.test.tsx index 3d1fd828042..0502f0d62e8 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.test.tsx @@ -262,4 +262,57 @@ describe('TabsLayoutManager', () => { expect(manager.getVizPanels().length).toBe(1); }); }); + + describe('createFromLayout', () => { + it('should convert rows with titles to tabs', () => { + const rowsLayout = new RowsLayoutManager({ + rows: [new RowItem({ title: 'Row 1' }), new RowItem({ title: 'Row 2' })], + }); + + const tabsManager = TabsLayoutManager.createFromLayout(rowsLayout); + + expect(tabsManager.state.tabs).toHaveLength(2); + expect(tabsManager.state.tabs[0].state.title).toBe('Row 1'); + expect(tabsManager.state.tabs[1].state.title).toBe('Row 2'); + }); + + it('should use default title when row has empty title', () => { + const rowsLayout = new RowsLayoutManager({ + rows: [new RowItem({ title: '' })], + }); + + const tabsManager = TabsLayoutManager.createFromLayout(rowsLayout); + + expect(tabsManager.state.tabs).toHaveLength(1); + expect(tabsManager.state.tabs[0].state.title).toBe('New tab'); + }); + + it('should generate unique titles for multiple rows with empty titles', () => { + const rowsLayout = new RowsLayoutManager({ + rows: [new RowItem({ title: '' }), new RowItem({ title: '' }), new RowItem({ title: '' })], + }); + + const tabsManager = TabsLayoutManager.createFromLayout(rowsLayout); + + expect(tabsManager.state.tabs).toHaveLength(3); + expect(tabsManager.state.tabs[0].state.title).toBe('New tab'); + expect(tabsManager.state.tabs[1].state.title).toBe('New tab 1'); + expect(tabsManager.state.tabs[2].state.title).toBe('New tab 2'); + }); + + it('should generate unique titles when mixing empty and existing titles', () => { + const rowsLayout = new RowsLayoutManager({ + rows: [ + new RowItem({ title: 'New row' }), // existing title that matches default + new RowItem({ title: '' }), // empty, should get unique title + ], + }); + + const tabsManager = TabsLayoutManager.createFromLayout(rowsLayout); + + expect(tabsManager.state.tabs).toHaveLength(2); + expect(tabsManager.state.tabs[0].state.title).toBe('New row'); + expect(tabsManager.state.tabs[1].state.title).toBe('New tab'); + }); + }); }); diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx index 79d027b1a02..4e08b56e54f 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx @@ -410,6 +410,10 @@ export class TabsLayoutManager extends SceneObjectBase i let tabs: TabItem[] = []; if (layout instanceof RowsLayoutManager) { + const existingNames = new Set( + layout.state.rows.map((row) => row.state.title).filter((title): title is string => !!title) + ); + for (const row of layout.state.rows) { if (row.state.repeatSourceKey) { continue; @@ -420,10 +424,14 @@ export class TabsLayoutManager extends SceneObjectBase i // We need to clear the target since we don't want to point the original row anymore (if it was set) conditionalRendering?.setTarget(undefined); + const newTitle = + row.state.title || generateUniqueTitle(t('dashboard.tabs-layout.tab.new', 'New tab'), existingNames); + existingNames.add(newTitle); + tabs.push( new TabItem({ layout: row.state.layout.clone(), - title: row.state.title, + title: newTitle, conditionalRendering, repeatByVariable: row.state.repeatByVariable, }) From a37ebf609e31ff3ac8852415dc2b659af564c7e4 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:25:03 -0600 Subject: [PATCH 129/139] VizSuggestions: Fix unique key warning (#115112) --- .github/CODEOWNERS | 3 ++- .../VizTypePicker/VisualizationSuggestions.tsx | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d8a1e4104bf..47688554cce 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -520,7 +520,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /e2e-playwright/various-suite/solo-route.spec.ts @grafana/dashboards-squad /e2e-playwright/various-suite/trace-view-scrolling.spec.ts @grafana/observability-traces-and-profiling /e2e-playwright/various-suite/verify-i18n.spec.ts @grafana/grafana-frontend-platform -/e2e-playwright/various-suite/visualization-suggestions.spec.ts @grafana/dashboards-squad +/e2e-playwright/various-suite/visualization-suggestions.spec.ts @grafana/dataviz-squad /e2e-playwright/various-suite/perf-test.spec.ts @grafana/grafana-frontend-platform # Packages @@ -956,6 +956,7 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform /public/app/features/notifications/ @grafana/grafana-search-navigate-organise /public/app/features/org/ @grafana/grafana-search-navigate-organise /public/app/features/panel/ @grafana/dashboards-squad +/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @grafana/dataviz-squad /public/app/features/panel/suggestions/ @grafana/dataviz-squad /public/app/features/playlist/ @grafana/dashboards-squad /public/app/features/plugins/ @grafana/plugins-platform-frontend diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index d7eed4ed2e7..e93e74f358d 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; import { useAsync, useMeasure } from 'react-use'; import { @@ -133,9 +133,9 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) { return (
{isNewVizSuggestionsEnabled - ? suggestionsByVizType.map(([vizType, vizTypeSuggestions]) => ( - <> -
+ ? suggestionsByVizType.map(([vizType, vizTypeSuggestions], groupIndex) => ( + +
{vizType?.info && } {vizType?.name || t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')} @@ -190,7 +190,7 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
); })} - +
)) : suggestions?.map((suggestion, index) => (
From c73cab8eef57195679072285d2942529ea5e75ee Mon Sep 17 00:00:00 2001 From: Renato Costa <103441181+renatolabs@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:56:47 -0500 Subject: [PATCH 130/139] chore: add cleanup task for duplicated provisioned dashboards (#115103) * chore: add cleanup task for duplicated provisioned dashboards --- pkg/services/dashboards/models.go | 11 +- .../dashboards/service/dashboard_service.go | 148 +++++++++- .../service/dashboard_service_test.go | 6 +- .../service/provisioning_cleanup.go | 107 +++++++ .../service/provisioning_cleanup_test.go | 279 ++++++++++++++++++ .../folderimpl/folder_unifiedstorage.go | 5 + pkg/services/folder/model.go | 13 +- .../provisioning/dashboards/dashboard.go | 13 +- 8 files changed, 561 insertions(+), 21 deletions(-) create mode 100644 pkg/services/dashboards/service/provisioning_cleanup.go create mode 100644 pkg/services/dashboards/service/provisioning_cleanup_test.go diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 3c661ee6b9c..c68263db693 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -304,8 +304,15 @@ type DeleteDashboardCommand struct { RemovePermissions bool } +type ProvisioningConfig struct { + Name string + OrgID int64 + Folder string + AllowUIUpdates bool +} + type DeleteOrphanedProvisionedDashboardsCommand struct { - ReaderNames []string + Config []ProvisioningConfig } type DashboardProvisioningSearchResults struct { @@ -405,6 +412,8 @@ type DashboardSearchProjection struct { FolderTitle string SortMeta int64 Tags []string + ManagedBy utils.ManagerKind + ManagerId string Deleted *time.Time } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 4c3bf3fea2f..03dd021a480 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -877,24 +877,32 @@ func (dr *DashboardServiceImpl) waitForSearchQuery(ctx context.Context, query *d } func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *dashboards.DeleteOrphanedProvisionedDashboardsCommand) error { - // cleanup duplicate provisioned dashboards first (this will have the same name and external_id) - // note: only works in modes 1-3 - if err := dr.DeleteDuplicateProvisionedDashboards(ctx); err != nil { - dr.log.Error("Failed to delete duplicate provisioned dashboards", "error", err) - } - // check each org for orphaned provisioned dashboards orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{}) if err != nil { return err } + orgIDs := make([]int64, 0, len(orgs)) + for _, org := range orgs { + orgIDs = append(orgIDs, org.ID) + } + + if err := dr.DeleteDuplicateProvisionedDashboards(ctx, orgIDs, cmd.Config); err != nil { + dr.log.Error("Failed to delete duplicate provisioned dashboards", "error", err) + } + + currentNames := make([]string, 0, len(cmd.Config)) + for _, cfg := range cmd.Config { + currentNames = append(currentNames, cfg.Name) + } + for _, org := range orgs { ctx, _ := identity.WithServiceIdentity(ctx, org.ID) // find all dashboards in the org that have a file repo set that is not in the given readers list foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ ManagedBy: utils.ManagerKindClassicFP, //nolint:staticcheck - ManagerIdentityNotIn: cmd.ReaderNames, + ManagerIdentityNotIn: currentNames, OrgId: org.ID, }) if err != nil { @@ -921,7 +929,129 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context. return nil } -func (dr *DashboardServiceImpl) DeleteDuplicateProvisionedDashboards(ctx context.Context) error { +// searchExistingProvisionedData fetches provisioned data for the purposes of +// duplication cleanup. Returns the set of folder UIDs for folders with the +// given title, and the set of resources contained in those folders. +func (dr *DashboardServiceImpl) searchExistingProvisionedData( + ctx context.Context, orgID int64, folderTitle string, +) ([]string, []dashboards.DashboardSearchProjection, error) { + ctx, user := identity.WithServiceIdentity(ctx, orgID) + cmd := folder.SearchFoldersQuery{ + OrgID: orgID, + SignedInUser: user, + Title: folderTitle, + TitleExactMatch: true, + } + + searchResults, err := dr.folderService.SearchFolders(ctx, cmd) + if err != nil { + return nil, nil, fmt.Errorf("checking if provisioning reset is required: %w", err) + } + + var matchingFolders []string //nolint:prealloc + for _, result := range searchResults { + f, err := dr.folderService.Get(ctx, &folder.GetFolderQuery{ + OrgID: orgID, + UID: &result.UID, + SignedInUser: user, + }) + if err != nil { + return nil, nil, err + } + + // We are only interested in folders at the top-level of the folder hierarchy. + // Cleanup is not performed for provisioned folders that were moved to + // a different location. + if f.ParentUID != "" { + continue + } + + matchingFolders = append(matchingFolders, f.UID) + } + + if len(matchingFolders) == 0 { + // If there are no folders with the same title as the provisioned folder we + // are looking for, there is nothing to be cleaned up. + return nil, nil, nil + } + + resources, err := dr.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{ + OrgId: orgID, + SignedInUser: user, + FolderUIDs: matchingFolders, + }) + if err != nil { + return nil, nil, err + } + + return matchingFolders, resources, nil +} + +// maybeResetProvisioning will check for duplicated provisioned dashboards in the database. These duplications +// happen when multiple provisioned dashboards of the same title are found, or multiple provisioned +// folders are found. In this case, provisioned resources are deleted, allowing the provisioning +// process to start from scratch after this function returns. +func (dr *DashboardServiceImpl) maybeResetProvisioning(ctx context.Context, orgs []int64, configs []dashboards.ProvisioningConfig) { + if skipReason := canBeAutomaticallyCleanedUp(configs); skipReason != "" { + dr.log.Info("not eligible for automated cleanup", "reason", skipReason) + return + } + + folderTitle := configs[0].Folder + provisionedNames := map[string]bool{} + for _, c := range configs { + provisionedNames[c.Name] = true + } + + for _, orgID := range orgs { + ctx, user := identity.WithServiceIdentity(ctx, orgID) + provFolders, resources, err := dr.searchExistingProvisionedData(ctx, orgID, folderTitle) + if err != nil { + dr.log.Error("failed to search for provisioned data for cleanup", "org", orgID, "error", err) + continue + } + + steps, err := cleanupSteps(provFolders, resources, provisionedNames) + if err != nil { + dr.log.Warn("not possible to perform automated duplicate cleanup", "org", orgID, "error", err) + continue + } + + for _, step := range steps { + var err error + + switch step.Type { + case searchstore.TypeDashboard: + err = dr.deleteDashboard(ctx, 0, step.UID, orgID, false) + case searchstore.TypeFolder: + err = dr.folderService.Delete(ctx, &folder.DeleteFolderCommand{ + OrgID: orgID, + SignedInUser: user, + UID: step.UID, + }) + } + + if err == nil { + dr.log.Info("deleted duplicated provisioned resource", + "type", step.Type, "uid", step.UID, + ) + } else { + dr.log.Error("failed to delete duplicated provisioned resource", + "type", step.Type, "uid", step.UID, "error", err, + ) + } + } + } +} + +func (dr *DashboardServiceImpl) DeleteDuplicateProvisionedDashboards(ctx context.Context, orgs []int64, configs []dashboards.ProvisioningConfig) error { + // Start from scratch if duplications that cannot be fixed by the logic + // below are found in the database. + dr.maybeResetProvisioning(ctx, orgs, configs) + + // cleanup duplicate provisioned dashboards (i.e., with the same name and external_id). + // Note: only works in modes 1-3. This logic can be removed once mode5 is + // enabled everywhere. duplicates, err := dr.dashboardStore.GetDuplicateProvisionedDashboards(ctx) if err != nil { return err @@ -1511,6 +1641,8 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb FolderTitle: folderTitle, FolderID: folderID, FolderSlug: slugify.Slugify(folderTitle), + ManagedBy: hit.ManagedBy.Kind, + ManagerId: hit.ManagedBy.ID, Tags: hit.Tags, } diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index dafb925adb5..a3745f864c2 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -779,7 +779,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { }, nil).Twice() err := service.DeleteOrphanedProvisionedDashboards(context.Background(), &dashboards.DeleteOrphanedProvisionedDashboardsCommand{ - ReaderNames: []string{"test"}, + Config: []dashboards.ProvisioningConfig{{Name: "test"}}, }) require.NoError(t, err) k8sCliMock.AssertExpectations(t) @@ -874,7 +874,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { }, nil).Once() err := singleOrgService.DeleteOrphanedProvisionedDashboards(ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{ - ReaderNames: []string{"test"}, + Config: []dashboards.ProvisioningConfig{{Name: "test"}}, }) require.NoError(t, err) k8sCliMock.AssertExpectations(t) @@ -906,7 +906,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { }, nil) err := singleOrgService.DeleteOrphanedProvisionedDashboards(ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{ - ReaderNames: []string{"test"}, + Config: []dashboards.ProvisioningConfig{{Name: "test"}}, }) require.NoError(t, err) k8sCliMock.AssertExpectations(t) diff --git a/pkg/services/dashboards/service/provisioning_cleanup.go b/pkg/services/dashboards/service/provisioning_cleanup.go new file mode 100644 index 00000000000..ca5fe75921a --- /dev/null +++ b/pkg/services/dashboards/service/provisioning_cleanup.go @@ -0,0 +1,107 @@ +package service + +import ( + "errors" + "fmt" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" +) + +// canBeAutomaticallyCleanedUp determines whether this instance can be automatically cleaned up +// if duplicated provisioned resources are found. To ensure the process does not delete +// resources it shouldn't, automatic cleanups only happen if all provisioned dashboards +// are stored in the same folder (by title), and no dashboards allow UI updates. +func canBeAutomaticallyCleanedUp(configs []dashboards.ProvisioningConfig) string { + if len(configs) == 0 { + return "no provisioned dashboards" + } + + folderTitle := configs[0].Folder + if len(folderTitle) == 0 { + return fmt.Sprintf("dashboard has no folder: %s", configs[0].Name) + } + + for _, cfg := range configs { + if cfg.AllowUIUpdates { + return "contains dashboards with allowUiUpdates" + } + + if cfg.Folder != folderTitle { + return "dashboards provisioned across multiple folders" + } + } + + return "" +} + +type deleteProvisionedResource struct { + Type string + UID string +} + +// cleanupSteps computes the sequence of steps to be performed in order to cleanup the +// provisioning resources and allow the process to start from scratch when duplication +// is detected. The sequence of steps will dictate the order in which dashboards and folders +// are to be deleted. +func cleanupSteps(provFolders []string, resources []dashboards.DashboardSearchProjection, configDashboards map[string]bool) ([]deleteProvisionedResource, error) { + var hasDuplicatedProvisionedDashboard bool + var hasUserCreatedResource bool + var uniqueNames = map[string]struct{}{} + var deleteProvisionedDashboards []deleteProvisionedResource //nolint:prealloc + + for _, r := range resources { + // nolint:staticcheck + if r.IsFolder || r.ManagedBy != utils.ManagerKindClassicFP { + hasUserCreatedResource = true + continue + } + + // Only delete dashboards if they are included in the provisioning configuration + // for this instance. + if !configDashboards[r.ManagerId] { + continue + } + + if _, exists := uniqueNames[r.ManagerId]; exists { + hasDuplicatedProvisionedDashboard = true + } + + uniqueNames[r.ManagerId] = struct{}{} + deleteProvisionedDashboards = append(deleteProvisionedDashboards, deleteProvisionedResource{ + Type: searchstore.TypeDashboard, + UID: r.UID, + }) + } + + if len(provFolders) == 0 { + // When there are no provisioned folders, there is nothing to do. + return nil, nil + } else if len(provFolders) == 1 { + // If only one folder was found, keep it and delete the provisioned dashboards if + // duplication was found. + if hasDuplicatedProvisionedDashboard { + return deleteProvisionedDashboards, nil + } + } else { + // If multiple folders were found *and* a user-created resource exists in + // one of them, bail, as we wouldn't be able to delete one of the duplicated folders. + if hasUserCreatedResource { + return nil, errors.New("multiple provisioning folders exist with at least one user-created resource") + } + + // Delete provisioned dashboards first, and then the folders. + steps := deleteProvisionedDashboards + for _, uid := range provFolders { + steps = append(steps, deleteProvisionedResource{ + Type: searchstore.TypeFolder, + UID: uid, + }) + } + + return steps, nil + } + + return nil, nil +} diff --git a/pkg/services/dashboards/service/provisioning_cleanup_test.go b/pkg/services/dashboards/service/provisioning_cleanup_test.go new file mode 100644 index 00000000000..049dc420f2d --- /dev/null +++ b/pkg/services/dashboards/service/provisioning_cleanup_test.go @@ -0,0 +1,279 @@ +package service + +import ( + "testing" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" + "github.com/stretchr/testify/require" +) + +func Test_canBeAutomaticallyCleanedUp(t *testing.T) { + testCases := []struct { + name string + configs []dashboards.ProvisioningConfig + expectedSkip string + }{ + { + name: "no dashboards defined in the configuration", + configs: []dashboards.ProvisioningConfig{}, + expectedSkip: "no provisioned dashboards", + }, + { + name: "first defined dashboard has no folder defined", + configs: []dashboards.ProvisioningConfig{ + {Name: "1", Folder: ""}, + {Folder: "f1"}, + }, + expectedSkip: "dashboard has no folder: 1", + }, + { + name: "one of the provisioned dashboards has no folder defined", + configs: []dashboards.ProvisioningConfig{ + {Name: "1", Folder: "f1"}, + {Name: "2", Folder: "f1"}, + {Name: "3", Folder: ""}, + {Name: "4", Folder: "f1"}, + }, + expectedSkip: "dashboards provisioned across multiple folders", + }, + { + name: "one of the provisioned dashboards allows UI updates", + configs: []dashboards.ProvisioningConfig{ + {Name: "1", Folder: "f1"}, + {Name: "2", Folder: "f1", AllowUIUpdates: true}, + {Name: "3", Folder: "f1"}, + {Name: "4", Folder: "f1"}, + }, + expectedSkip: "contains dashboards with allowUiUpdates", + }, + { + name: "one of the provisioned dashboards is in a different folder", + configs: []dashboards.ProvisioningConfig{ + {Name: "1", Folder: "f1"}, + {Name: "2", Folder: "f1"}, + {Name: "3", Folder: "f1"}, + {Name: "4", Folder: "different"}, + }, + expectedSkip: "dashboards provisioned across multiple folders", + }, + { + name: "can be skipped when all conditions are met", + configs: []dashboards.ProvisioningConfig{ + {Name: "1", Folder: "f1"}, + {Name: "2", Folder: "f1"}, + {Name: "3", Folder: "f1"}, + {Name: "4", Folder: "f1"}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.expectedSkip, canBeAutomaticallyCleanedUp(tc.configs)) + }) + } +} + +func Test_cleanupSteps(t *testing.T) { + isDashboard, isFolder := false, true + + fromUser := func(uid, name string, isFolder bool) dashboards.DashboardSearchProjection { + return dashboards.DashboardSearchProjection{ + UID: uid, + ManagerId: name, + IsFolder: isFolder, + } + } + + provisioned := func(uid, name string, isFolder bool) dashboards.DashboardSearchProjection { + dashboard := fromUser(uid, name, isFolder) + dashboard.ManagedBy = utils.ManagerKindClassicFP //nolint:staticcheck + return dashboard + } + + testCases := []struct { + name string + provisionedFolders []string + provisionedResources []dashboards.DashboardSearchProjection + configDashboards []string + expectedSteps []deleteProvisionedResource + expectedErr string + }{ + { + name: "no provisioned folders, nothing to do", + provisionedFolders: []string{}, + configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3"}, + provisionedResources: []dashboards.DashboardSearchProjection{ + provisioned("d1", "Provisioned1", isDashboard), + }, + }, + { + name: "multiple folders, a user-created dashboard in one of them", + provisionedFolders: []string{"folder1", "folder2"}, + configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3"}, + provisionedResources: []dashboards.DashboardSearchProjection{ + provisioned("d1", "Provisioned1", isDashboard), + provisioned("d2", "Provisioned2", isDashboard), + fromUser("d3", "User1", isDashboard), + provisioned("d4", "Provisioned3", isDashboard), + }, + expectedErr: "multiple provisioning folders exist with at least one user-created resource", + }, + { + name: "multiple folders, a user-created folder in one of them", + provisionedFolders: []string{"folder1", "folder2"}, + configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"}, + provisionedResources: []dashboards.DashboardSearchProjection{ + provisioned("d1", "Provisioned1", isDashboard), + provisioned("d2", "Provisioned2", isDashboard), + provisioned("d3", "Provisioned3", isDashboard), + fromUser("f1", "UserFolder1", isFolder), + }, + expectedErr: "multiple provisioning folders exist with at least one user-created resource", + }, + { + name: "single folder, some dashboards duplicated", + provisionedFolders: []string{"folder1"}, + configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"}, + provisionedResources: []dashboards.DashboardSearchProjection{ + // Provisioned1 is duplicated. + provisioned("d1", "Provisioned1", isDashboard), + provisioned("d2", "Provisioned2", isDashboard), + provisioned("d3", "Provisioned1", isDashboard), + provisioned("d4", "Provisioned3", isDashboard), + }, + expectedSteps: []deleteProvisionedResource{ + {Type: searchstore.TypeDashboard, UID: "d1"}, + {Type: searchstore.TypeDashboard, UID: "d2"}, + {Type: searchstore.TypeDashboard, UID: "d3"}, + {Type: searchstore.TypeDashboard, UID: "d4"}, + }, + }, + { + name: "single folder, duplicated dashboards, user-created dashboards are ignored", + provisionedFolders: []string{"folder1"}, + configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"}, + provisionedResources: []dashboards.DashboardSearchProjection{ + // Provisioned1 is duplicated. + provisioned("d1", "Provisioned1", isDashboard), + provisioned("d2", "Provisioned2", isDashboard), + fromUser("d3", "User1", isDashboard), + provisioned("d4", "Provisioned3", isDashboard), + provisioned("d5", "Provisioned1", isDashboard), + }, + // User dashboard (d3) is not deleted. + expectedSteps: []deleteProvisionedResource{ + {Type: searchstore.TypeDashboard, UID: "d1"}, + {Type: searchstore.TypeDashboard, UID: "d2"}, + {Type: searchstore.TypeDashboard, UID: "d4"}, + {Type: searchstore.TypeDashboard, UID: "d5"}, + }, + }, + { + name: "single folder, duplicated dashboards, user-created folders are ignored", + provisionedFolders: []string{"folder1"}, + configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3"}, + provisionedResources: []dashboards.DashboardSearchProjection{ + // Provisioned1 is duplicated. + provisioned("d1", "Provisioned1", isDashboard), + provisioned("d2", "Provisioned2", isDashboard), + provisioned("d3", "Provisioned3", isDashboard), + provisioned("d4", "Provisioned1", isDashboard), + fromUser("f1", "UserFolder1", isFolder), + }, + // User folder (f1) is not deleted. + expectedSteps: []deleteProvisionedResource{ + {Type: searchstore.TypeDashboard, UID: "d1"}, + {Type: searchstore.TypeDashboard, UID: "d2"}, + {Type: searchstore.TypeDashboard, UID: "d3"}, + {Type: searchstore.TypeDashboard, UID: "d4"}, + }, + }, + { + name: "multiple folders, only provisioned dashboards", + provisionedFolders: []string{"folder1", "folder2"}, + configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"}, + provisionedResources: []dashboards.DashboardSearchProjection{ + provisioned("d1", "Provisioned1", isDashboard), + provisioned("d2", "Provisioned2", isDashboard), + provisioned("d3", "Provisioned3", isDashboard), + provisioned("d4", "Provisioned4", isDashboard), + }, + // Delete all dashboards, then all folders. + expectedSteps: []deleteProvisionedResource{ + {Type: searchstore.TypeDashboard, UID: "d1"}, + {Type: searchstore.TypeDashboard, UID: "d2"}, + {Type: searchstore.TypeDashboard, UID: "d3"}, + {Type: searchstore.TypeDashboard, UID: "d4"}, + {Type: searchstore.TypeFolder, UID: "folder1"}, + {Type: searchstore.TypeFolder, UID: "folder2"}, + }, + }, + { + name: "single folder, only deletes dashboards defined in the config file", + provisionedFolders: []string{"folder1"}, + configDashboards: []string{"Provisioned1", "Provisioned2"}, + provisionedResources: []dashboards.DashboardSearchProjection{ + provisioned("d1", "Provisioned1", isDashboard), + provisioned("d2", "Provisioned2", isDashboard), + provisioned("d3", "Provisioned1", isDashboard), + provisioned("d4", "Provisioned4", isDashboard), + provisioned("d5", "Provisioned4", isDashboard), + }, + // Delete duplicated dashboards, but keep Provisioned4, since it's not in the config file. + expectedSteps: []deleteProvisionedResource{ + {Type: searchstore.TypeDashboard, UID: "d1"}, + {Type: searchstore.TypeDashboard, UID: "d2"}, + {Type: searchstore.TypeDashboard, UID: "d3"}, + }, + }, + { + name: "single folder, no duplicated dashboards", + provisionedFolders: []string{"folder1"}, + configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"}, + provisionedResources: []dashboards.DashboardSearchProjection{ + provisioned("d1", "Provisioned1", isDashboard), + provisioned("d2", "Provisioned2", isDashboard), + provisioned("d3", "Provisioned3", isDashboard), + provisioned("d4", "Provisioned4", isDashboard), + }, + expectedSteps: nil, // no duplicates, nothing to do + }, + { + name: "single folder, no duplicated dashboards, multiple user-created resources", + provisionedFolders: []string{"folder1"}, + configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"}, + provisionedResources: []dashboards.DashboardSearchProjection{ + provisioned("d1", "Provisioned1", isDashboard), + provisioned("d2", "Provisioned2", isDashboard), + fromUser("f1", "UserFolder1", isFolder), + provisioned("d3", "Provisioned3", isDashboard), + fromUser("d4", "User1", isDashboard), + provisioned("d5", "Provisioned4", isDashboard), + fromUser("d6", "User2", isDashboard), + fromUser("f2", "UserFolder2", isFolder), + }, + expectedSteps: nil, // no duplicates in the provisioned set, nothing to do + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + provisionedSet := make(map[string]bool) + for _, name := range tc.configDashboards { + provisionedSet[name] = true + } + + steps, err := cleanupSteps(tc.provisionedFolders, tc.provisionedResources, provisionedSet) + if tc.expectedErr == "" { + require.NoError(t, err) + require.Equal(t, tc.expectedSteps, steps) + } else { + require.Error(t, err) + require.Equal(t, tc.expectedErr, err.Error()) + } + }) + } +} diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage.go b/pkg/services/folder/folderimpl/folder_unifiedstorage.go index 1551d858efe..9b238b769fe 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage.go @@ -202,6 +202,11 @@ func (s *Service) searchFoldersFromApiServer(ctx context.Context, query folder.S if query.Title != "" { // allow wildcard search request.Query = "*" + strings.ToLower(query.Title) + "*" + // or perform exact match if requested + if query.TitleExactMatch { + request.Query = query.Title + } + // if using query, you need to specify the fields you want request.Fields = dashboardsearch.IncludeFields } diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index 3e59f5c1b6f..e0061ca8dd7 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -224,12 +224,13 @@ type GetFoldersQuery struct { } type SearchFoldersQuery struct { - OrgID int64 - UIDs []string - IDs []int64 - Title string - Limit int64 - SignedInUser identity.Requester `json:"-"` + OrgID int64 + UIDs []string + IDs []int64 + Title string + TitleExactMatch bool + Limit int64 + SignedInUser identity.Requester `json:"-"` } // GetParentsQuery captures the information required by the folder service to diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go index 72b980e4198..36cacdaf12a 100644 --- a/pkg/services/provisioning/dashboards/dashboard.go +++ b/pkg/services/provisioning/dashboards/dashboard.go @@ -153,13 +153,20 @@ func (provider *Provisioner) Provision(ctx context.Context) error { // CleanUpOrphanedDashboards deletes provisioned dashboards missing a linked reader. func (provider *Provisioner) CleanUpOrphanedDashboards(ctx context.Context) { - currentReaders := make([]string, len(provider.fileReaders)) + configs := make([]dashboards.ProvisioningConfig, len(provider.fileReaders)) for index, reader := range provider.fileReaders { - currentReaders[index] = reader.Cfg.Name + configs[index] = dashboards.ProvisioningConfig{ + Name: reader.Cfg.Name, + OrgID: reader.Cfg.OrgID, + Folder: reader.Cfg.Folder, + AllowUIUpdates: reader.Cfg.AllowUIUpdates, + } } - if err := provider.provisioner.DeleteOrphanedProvisionedDashboards(ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{ReaderNames: currentReaders}); err != nil { + if err := provider.provisioner.DeleteOrphanedProvisionedDashboards( + ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{Config: configs}, + ); err != nil { provider.log.Warn("Failed to delete orphaned provisioned dashboards", "err", err) } } From 4817ecf6a30cc86b49deadeec5482a054a5045ae Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 12 Dec 2025 13:59:54 -0500 Subject: [PATCH 131/139] Sparkline: Guess decimals rather than going with 0 (#115246) * Sparkline: Guess decimals rather than going with 0 * Update packages/grafana-ui/src/components/Sparkline/utils.test.ts --- .../src/components/Sparkline/utils.test.ts | 14 +++++++++++++- .../grafana-ui/src/components/Sparkline/utils.ts | 11 +++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/grafana-ui/src/components/Sparkline/utils.test.ts b/packages/grafana-ui/src/components/Sparkline/utils.test.ts index 2a77677d998..ca49f6da512 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.test.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.test.ts @@ -119,7 +119,14 @@ describe('Get y range', () => { values: [2, 1.999999999999999, 2.000000000000001, 2, 2], type: FieldType.number, config: {}, - state: { range: { min: 1.999999999999999, max: 2.000000000000001, delta: 0 } }, + state: { range: { min: 1.9999999999999999999, max: 2.000000000000000001, delta: 0 } }, + }; + const decimalsNotCloseYField: Field = { + name: 'y', + values: [2, 0.0094, 0.0053, 0.0078, 0.0061], + type: FieldType.number, + config: {}, + state: { range: { min: 0.0053, max: 0.0094, delta: 0.0041 } }, }; const xField: Field = { name: 'x', @@ -183,6 +190,11 @@ describe('Get y range', () => { field: decimalsCloseYField, expected: [2, 4], }, + { + description: 'decimal values which are not close to equal should not be rounded out', + field: decimalsNotCloseYField, + expected: [0.0053, 0.0094], + }, ])(`should return correct range for $description`, ({ field, expected }) => { const actual = getYRange(getAlignedFrame(field)); expect(actual).toEqual(expected); diff --git a/packages/grafana-ui/src/components/Sparkline/utils.ts b/packages/grafana-ui/src/components/Sparkline/utils.ts index e4d17a85c15..be24eb6c4e8 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.ts @@ -8,6 +8,7 @@ import { FieldType, getFieldColorModeForField, GrafanaTheme2, + guessDecimals, isLikelyAscendingVector, nullToValue, roundDecimals, @@ -76,8 +77,6 @@ export function getYRange(alignedFrame: DataFrame): Range.MinMax { min = Math.min(min!, field.config.min ?? Infinity); max = Math.max(max!, field.config.max ?? -Infinity); - // console.log({ min, max }); - // if noValue is set, ensure that it is included in the range as well const noValue = +field.config?.noValue!; if (!Number.isNaN(noValue)) { @@ -85,9 +84,11 @@ export function getYRange(alignedFrame: DataFrame): Range.MinMax { max = Math.max(max, noValue); } + const decimals = field.config.decimals ?? Math.max(guessDecimals(min), guessDecimals(max)); + // call roundDecimals to mirror what is going to eventually happen in uplot - let roundedMin = roundDecimals(min, field.config.decimals ?? 0); - let roundedMax = roundDecimals(max, field.config.decimals ?? 0); + let roundedMin = roundDecimals(min, decimals); + let roundedMax = roundDecimals(max, decimals); // if the rounded min and max are different, // we can return the real min and max. @@ -102,11 +103,9 @@ export function getYRange(alignedFrame: DataFrame): Range.MinMax { roundedMax = 1; } else if (roundedMin < 0) { // both are negative - // max = 0; roundedMin *= 2; } else { // both are positive - // min = 0; roundedMax *= 2; } From 5156177079f9f539f7ff453197e14c5734cda33e Mon Sep 17 00:00:00 2001 From: colin-stuart Date: Fri, 12 Dec 2025 13:51:37 -0600 Subject: [PATCH 132/139] SCIM: show error if SCIM-provisioned user attempts login with non-SAML auth module (#115271) --- .../authn/authnimpl/sync/user_sync.go | 19 ++++ .../authn/authnimpl/sync/user_sync_test.go | 97 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/pkg/services/authn/authnimpl/sync/user_sync.go b/pkg/services/authn/authnimpl/sync/user_sync.go index cb8099ba1d1..ada03081add 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync.go +++ b/pkg/services/authn/authnimpl/sync/user_sync.go @@ -77,6 +77,10 @@ var ( "user.sync.user-externalUID-mismatch", errutil.WithPublicMessage("User externalUID mismatch"), ) + errSCIMAuthModuleMismatch = errutil.Unauthorized( + "user.sync.scim-auth-module-mismatch", + errutil.WithPublicMessage("User was provisioned via SCIM and must login via SAML"), + ) ) var ( @@ -308,6 +312,21 @@ func (s *UserSync) SyncUserHook(ctx context.Context, id *authn.Identity, _ *auth // just try to fetch the user one more to make the other request work. if errors.Is(err, user.ErrUserAlreadyExists) { usr, _, err = s.getUser(ctx, id) + + // Check if this is a SCIM-provisioned user trying to login via an auth module that is not SAML or GCOM + if err == nil && usr != nil && usr.IsProvisioned && id.AuthenticatedBy != login.GrafanaComAuthModule { + _, authErr := s.authInfoService.GetAuthInfo(ctx, &login.GetAuthInfoQuery{ + UserId: usr.ID, + AuthModule: id.AuthenticatedBy, + }) + if errors.Is(authErr, user.ErrUserNotFound) { + s.log.FromContext(ctx).Error("SCIM-provisioned user attempted login via non-SAML auth module", + "user_id", usr.ID, + "attempted_module", id.AuthenticatedBy, + ) + return errSCIMAuthModuleMismatch.Errorf("user was provisioned via SCIM but attempted login via %s", id.AuthenticatedBy) + } + } } if err != nil { diff --git a/pkg/services/authn/authnimpl/sync/user_sync_test.go b/pkg/services/authn/authnimpl/sync/user_sync_test.go index dd19836b0a5..ad863164aee 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/user_sync_test.go @@ -1926,3 +1926,100 @@ func TestUserSync_SCIMLoginUsageStatSet(t *testing.T) { finalCount := finalStats["stats.features.scim.has_successful_login.count"].(int) require.Equal(t, int(1), finalCount) } + +func TestUserSync_SyncUserHook_SCIMAuthModuleMismatch(t *testing.T) { + userSrv := usertest.NewMockService(t) + authInfoSrv := authinfotest.NewMockAuthInfoService(t) + + userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(nil, user.ErrUserNotFound).Once() + + userSrv.On("Create", mock.Anything, mock.Anything).Return(nil, user.ErrUserAlreadyExists).Once() + + userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(&user.User{ + ID: 1, + Email: "test@test.com", + IsProvisioned: true, + }, nil).Once() + + authInfoSrv.On("GetAuthInfo", mock.Anything, mock.MatchedBy(func(q *login.GetAuthInfoQuery) bool { + return q.AuthModule == "oauth_azuread" + })).Return(nil, user.ErrUserNotFound).Once() + + s := ProvideUserSync( + userSrv, + authinfoimpl.ProvideOSSUserProtectionService(), + authInfoSrv, + "atest.FakeQuotaService{}, + tracing.NewNoopTracerService(), + featuremgmt.WithFeatures(), + setting.NewCfg(), + nil, + ) + + email := "test@test.com" + + err := s.SyncUserHook(context.Background(), &authn.Identity{ + AuthenticatedBy: "oauth_azuread", + ClientParams: authn.ClientParams{ + SyncUser: true, + AllowSignUp: true, + LookUpParams: login.UserLookupParams{ + Email: &email, + }, + }, + }, nil) + + require.Error(t, err) + assert.ErrorIs(t, err, errSCIMAuthModuleMismatch) + assert.Contains(t, err.Error(), "SCIM") + assert.Contains(t, err.Error(), "oauth_azuread") +} + +func TestUserSync_SyncUserHook_SCIMUserAllowsGCOMLogin(t *testing.T) { + userSrv := usertest.NewMockService(t) + authInfoSrv := authinfotest.NewMockAuthInfoService(t) + + authInfoSrv.On("GetAuthInfo", mock.Anything, mock.MatchedBy(func(q *login.GetAuthInfoQuery) bool { + return q.AuthModule == login.GrafanaComAuthModule && q.AuthId == "gcom-user-123" + })).Return(nil, user.ErrUserNotFound).Once() + + userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(nil, user.ErrUserNotFound).Once() + userSrv.On("Create", mock.Anything, mock.Anything).Return(nil, user.ErrUserAlreadyExists).Once() + + authInfoSrv.On("GetAuthInfo", mock.Anything, mock.MatchedBy(func(q *login.GetAuthInfoQuery) bool { + return q.AuthModule == login.GrafanaComAuthModule && q.AuthId == "gcom-user-123" + })).Return(nil, user.ErrUserNotFound).Once() + + userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(&user.User{ + ID: 1, + Email: "test@test.com", + IsProvisioned: true, + }, nil).Once() + + s := ProvideUserSync( + userSrv, + authinfoimpl.ProvideOSSUserProtectionService(), + authInfoSrv, + "atest.FakeQuotaService{}, + tracing.NewNoopTracerService(), + featuremgmt.WithFeatures(), + setting.NewCfg(), + nil, + ) + + email := "test@test.com" + + err := s.SyncUserHook(context.Background(), &authn.Identity{ + AuthenticatedBy: login.GrafanaComAuthModule, + AuthID: "gcom-user-123", + ClientParams: authn.ClientParams{ + SyncUser: true, + AllowSignUp: true, + LookUpParams: login.UserLookupParams{ + Email: &email, + }, + }, + }, nil) + + require.NoError(t, err) +} From 37ccd8bc3dbd245098d7f62b72ff8d0ad6ebce09 Mon Sep 17 00:00:00 2001 From: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:21:50 -0600 Subject: [PATCH 133/139] Docs: Added troubleshooting guide for the InfluxDB data source (#115191) * Docs: Add troubleshooting guide for InfluxDB data source * linter fixes, updates based on feedback --- docs/sources/datasources/influxdb/_index.md | 1 + .../influxdb/troubleshooting/index.md | 291 ++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 docs/sources/datasources/influxdb/troubleshooting/index.md diff --git a/docs/sources/datasources/influxdb/_index.md b/docs/sources/datasources/influxdb/_index.md index c3f722d848f..4ede6c02c52 100644 --- a/docs/sources/datasources/influxdb/_index.md +++ b/docs/sources/datasources/influxdb/_index.md @@ -52,6 +52,7 @@ The following documents will help you get started with the InfluxDB data source - [Configure the InfluxDB data source](./configure-influxdb-data-source/) - [InfluxDB query editor](./query-editor/) - [InfluxDB templates and variables](./template-variables/) +- [Troubleshoot issues with the InfluxDB data source](./troubleshooting/) Once you have configured the data source you can: diff --git a/docs/sources/datasources/influxdb/troubleshooting/index.md b/docs/sources/datasources/influxdb/troubleshooting/index.md new file mode 100644 index 00000000000..33fe67ecadf --- /dev/null +++ b/docs/sources/datasources/influxdb/troubleshooting/index.md @@ -0,0 +1,291 @@ +--- +aliases: + - ../../data-sources/influxdb/troubleshooting/ +description: Troubleshooting the InfluxDB data source in Grafana +keywords: + - grafana + - influxdb + - troubleshooting + - errors + - flux + - influxql + - sql +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Troubleshooting +title: Troubleshoot issues with the InfluxDB data source +weight: 600 +--- + +# Troubleshoot issues with the InfluxDB data source + +This document provides troubleshooting information for common errors you may encounter when using the InfluxDB data source in Grafana. + +## Connection errors + +The following errors occur when Grafana cannot establish or maintain a connection to InfluxDB. + +### Failed to connect to InfluxDB + +**Error message:** "error performing influxQL query" or "error performing flux query" or "error performing sql query" + +**Cause:** Grafana cannot establish a network connection to the InfluxDB server. + +**Solution:** + +1. Verify that the InfluxDB URL is correct in the data source configuration. +1. Check that InfluxDB is running and accessible from the Grafana server. +1. Ensure the URL includes the protocol (`http://` or `https://`). +1. Verify the port is correct (the InfluxDB default API port is `8086`). +1. Ensure there are no firewall rules blocking the connection. +1. For Grafana Cloud, ensure you have configured [Private data source connect](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) if your InfluxDB instance is not publicly accessible. + +### Request timed out + +**Error message:** "context deadline exceeded" or "request timeout" + +**Cause:** The connection to InfluxDB timed out before receiving a response. + +**Solution:** + +1. Check the network latency between Grafana and InfluxDB. +1. Verify that InfluxDB is not overloaded or experiencing performance issues. +1. Increase the timeout setting in the data source configuration under **Advanced HTTP Settings**. +1. Reduce the time range or complexity of your query. + +## Authentication errors + +The following errors occur when there are issues with authentication credentials or permissions. + +### Unauthorized (401) + +**Error message:** "401 Unauthorized" or "authorization failed" + +**Cause:** The authentication credentials are invalid or missing. + +**Solution:** + +1. Verify that the token or password is correct in the data source configuration. +1. For Flux and SQL, ensure the token has not expired. +1. For InfluxQL with InfluxDB 2.x, verify the token is set as an `Authorization` header with the value `Token `. +1. For InfluxDB 1.x, verify the username and password are correct. +1. Check that the token has the required permissions to access the specified bucket or database. + +### Forbidden (403) + +**Error message:** "403 Forbidden" or "access denied" + +**Cause:** The authenticated user or token does not have permission to access the requested resource. + +**Solution:** + +1. Verify the token has read access to the specified bucket or database. +1. Check the token's permissions in the InfluxDB UI under **API Tokens**. +1. Ensure the organization ID is correct for Flux queries. +1. For InfluxQL with InfluxDB 2.x, verify the DBRP mapping is configured correctly. + +## Configuration errors + +The following errors occur when the data source is not configured correctly. + +### Unknown influx version + +**Error message:** "unknown influx version" + +**Cause:** The query language is not properly configured in the data source settings. + +**Solution:** + +1. Open the data source configuration in Grafana. +1. Verify that a valid query language is selected: **Flux**, **InfluxQL**, or **SQL**. +1. Ensure the selected query language matches your InfluxDB version: + - Flux: InfluxDB 1.8+ and 2.x + - InfluxQL: InfluxDB 1.x and 2.x (with DBRP mapping) + - SQL: InfluxDB 3.x only + +### Invalid data source info received + +**Error message:** "invalid data source info received" + +**Cause:** The data source configuration is incomplete or corrupted. + +**Solution:** + +1. Delete and recreate the data source. +1. Ensure all required fields are populated based on your query language: + - **Flux:** URL, Organization, Token, Default Bucket + - **InfluxQL:** URL, Database, User, Password + - **SQL:** URL, Database, Token + +### DBRP mapping required + +**Error message:** "database not found" or queries return no data with InfluxQL on InfluxDB 2.x + +**Cause:** InfluxQL queries on InfluxDB 2.x require a Database and Retention Policy (DBRP) mapping. + +**Solution:** + +1. Create a DBRP mapping in InfluxDB using the CLI or API. +1. Refer to [Manage DBRP Mappings](https://docs.influxdata.com/influxdb/cloud/query-data/influxql/dbrp/) for guidance. +1. Verify the database name in Grafana matches the DBRP mapping. + +## Query errors + +The following errors occur when there are issues with query syntax or execution. + +### Query syntax error + +**Error message:** "error parsing query: found THING" or "failed to parse query: found WERE, expected ; at line 1, char 38" + +**Cause:** The query contains invalid syntax. + +**Solution:** + +1. Check your query syntax for typos or invalid keywords. +1. For InfluxQL, verify the query follows the correct syntax: + + ```sql + SELECT FROM WHERE + ``` + +1. For Flux, ensure proper pipe-forward syntax and function calls. +1. Use the InfluxDB UI or CLI to test your query directly. + +### Query timeout limit exceeded + +**Error message:** "query-timeout limit exceeded" + +**Cause:** The query took longer than the configured timeout limit in InfluxDB. + +**Solution:** + +1. Reduce the time range of your query. +1. Add more specific filters to limit the data scanned. +1. Increase the query timeout setting in InfluxDB if you have admin access. +1. Optimize your query to reduce complexity. + +### Too many series or data points + +**Error message:** "max-series-per-database limit exceeded" or "A query returned too many data points and the results have been truncated" + +**Cause:** The query is returning more data than the configured limits allow. + +**Solution:** + +1. Reduce the time range of your query. +1. Add filters to limit the number of series returned. +1. Increase the **Max series** setting in the data source configuration under **Advanced Database Settings**. +1. Use aggregation functions to reduce the number of data points. +1. For Flux, use `aggregateWindow()` to downsample data. + +### No time column found + +**Error message:** "no time column found" + +**Cause:** The query result does not include a time column, which is required for time series visualization. + +**Solution:** + +1. Ensure your query includes a time field. +1. For Flux, verify the query includes `_time` in the output. +1. For SQL, ensure the query returns a timestamp column. +1. Check that the time field is not being filtered out or excluded. + +## Health check errors + +The following errors occur when testing the data source connection. + +### Error getting flux query buckets + +**Error message:** "error getting flux query buckets" + +**Cause:** The health check query `buckets()` failed to return results. + +**Solution:** + +1. Verify the token has permission to list buckets. +1. Check that the organization ID is correct. +1. Ensure InfluxDB is running and accessible. + +### Error connecting InfluxDB influxQL + +**Error message:** "error connecting InfluxDB influxQL" + +**Cause:** The health check query `SHOW MEASUREMENTS` failed. + +**Solution:** + +1. Verify the database name is correct. +1. Check that the user has permission to run `SHOW MEASUREMENTS`. +1. Ensure the database exists and contains measurements. +1. For InfluxDB 2.x, verify DBRP mapping is configured. + +### 0 measurements found + +**Error message:** "data source is working. 0 measurements found" + +**Cause:** The connection is successful, but the database contains no measurements. + +**Solution:** + +1. Verify you are connecting to the correct database. +1. Check that data has been written to the database. +1. If the database is new, add some test data to verify the connection. + +## Other common issues + +The following issues don't produce specific error messages but are commonly encountered. + +### Empty query results + +**Cause:** The query returns no data. + +**Solution:** + +1. Verify the time range includes data in your database. +1. Check that the measurement and field names are correct. +1. Test the query directly in the InfluxDB UI or CLI. +1. Ensure filters are not excluding all data. +1. For InfluxQL, verify the retention policy contains data for the selected time range. + +### Slow query performance + +**Cause:** Queries take a long time to execute. + +**Solution:** + +1. Reduce the time range of your query. +1. Add more specific filters to limit the data scanned. +1. Increase the **Min time interval** setting to reduce the number of data points. +1. Check InfluxDB server performance and resource utilization. +1. For Flux, use `aggregateWindow()` to downsample data before visualization. +1. Consider using continuous queries or tasks to pre-aggregate data. + +### Data appears delayed or missing recent points + +**Cause:** The visualization doesn't show the most recent data. + +**Solution:** + +1. Check the dashboard time range and refresh settings. +1. Verify the **Min time interval** is not set too high. +1. Ensure InfluxDB has finished writing the data. +1. Check for clock synchronization issues between Grafana and InfluxDB. + +## Get additional help + +If you continue to experience issues after following this troubleshooting guide: + +1. Check the [InfluxDB documentation](https://docs.influxdata.com/) for API-specific guidance. +1. Review the [Grafana community forums](https://community.grafana.com/) for similar issues. +1. Contact Grafana Support if you're an Enterprise, Cloud Pro or Cloud Contracted user. +1. When reporting issues, include: + - Grafana version + - InfluxDB version and product (OSS, Cloud, Enterprise) + - Query language (Flux, InfluxQL, or SQL) + - Error messages (redact sensitive information) + - Steps to reproduce + - Relevant configuration such as data source settings, HTTP method, and TLS settings (redact tokens, passwords, and other credentials) From 3459c67bfbfc69a6b2f621b2f43147f565803d8e Mon Sep 17 00:00:00 2001 From: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:10:03 -0600 Subject: [PATCH 134/139] DOCS: Overhaul Azure Monitor data source docs (#115121) * continued edits * authentication updates * added more info to configure doc * started work on query editor * reviewed the configure doc, consolidated sections * fixed issue with headings * fixed errors * updates to the template variables doc * created initial troubleshooting doc * removed gerunds and fixed heading issues * new annotations doc added * more updates to query editor * fixed spelling * fixed some linter issues * fixed flow for the intro doc * updates to the intro doc * fixed transformation links * added review date to front matter * ran prettier * added a new alerting doc * linter updates * some final edits * ran prettier again * Update docs/sources/datasources/azure-monitor/configure/index.md Co-authored-by: Andreas Christou * Update docs/sources/datasources/azure-monitor/configure/index.md Co-authored-by: Andreas Christou * Update docs/sources/datasources/azure-monitor/troubleshooting/index.md Co-authored-by: Andreas Christou * edits based on feedback * removed all relative reference links * ran prettier --------- Co-authored-by: Andreas Christou --- .../datasources/azure-monitor/_index.md | 348 +++------- .../azure-monitor/alerting/index.md | 262 ++++++++ .../azure-monitor/annotations/index.md | 218 +++++++ .../azure-monitor/configure/index.md | 605 ++++++++++++++++++ .../azure-monitor/query-editor/index.md | 160 ++++- .../azure-monitor/template-variables/index.md | 187 +++++- .../azure-monitor/troubleshooting/index.md | 320 +++++++++ 7 files changed, 1766 insertions(+), 334 deletions(-) create mode 100644 docs/sources/datasources/azure-monitor/alerting/index.md create mode 100644 docs/sources/datasources/azure-monitor/annotations/index.md create mode 100644 docs/sources/datasources/azure-monitor/configure/index.md create mode 100644 docs/sources/datasources/azure-monitor/troubleshooting/index.md diff --git a/docs/sources/datasources/azure-monitor/_index.md b/docs/sources/datasources/azure-monitor/_index.md index 90452f4cc52..d66b19efdff 100644 --- a/docs/sources/datasources/azure-monitor/_index.md +++ b/docs/sources/datasources/azure-monitor/_index.md @@ -3,7 +3,6 @@ aliases: - ../data-sources/azure-monitor/ - ../features/datasources/azuremonitor/ - azuremonitor/ - - azuremonitor/deprecated-application-insights/ description: Guide for using Azure Monitor in Grafana keywords: - grafana @@ -23,6 +22,7 @@ labels: menuTitle: Azure Monitor title: Azure Monitor data source weight: 300 +last_reviewed: 2025-12-04 refs: configure-grafana-feature-toggles: - pattern: /docs/grafana/ @@ -49,6 +49,11 @@ refs: destination: /docs/grafana//dashboards/build-dashboards/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana//dashboards/build-dashboards/ + transform-data: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/query-transform-data/transform-data/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//panels-visualizations/query-transform-data/transform-data/ configure-grafana-azure: - pattern: /docs/grafana/ destination: /docs/grafana//setup-grafana/configure-grafana/#azure @@ -63,295 +68,98 @@ refs: - pattern: /docs/grafana/ destination: /docs/grafana//setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana + destination: /docs/grafana//setup-grafana/configure-security/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana + configure-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/configure/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/configure/ + query-editor-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/query-editor/ + template-variables-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/template-variables/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/template-variables/ + alerting-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/alerting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/alerting/ + troubleshooting-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/troubleshooting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/troubleshooting/ + annotations-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/annotations/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/annotations/ --- # Azure Monitor data source -Grafana ships with built-in support for Azure Monitor, the Azure service to maximize the availability and performance of applications and services in the Azure Cloud. -This topic explains configuring and querying specific to the Azure Monitor data source. +The Azure Monitor data source plugin allows you to query and visualize data from Azure Monitor, the Azure service to maximize the availability and performance of applications and services in the Azure Cloud. -For instructions on how to add a data source to Grafana, refer to the [administration documentation](ref:data-source-management). -Only users with the organization administrator role can add data sources. +## Supported Azure clouds -Once you've added the Azure Monitor data source, you can [configure it](#configure-the-data-source) so that your Grafana instance's users can create queries in its [query editor](query-editor/) when they [build dashboards](ref:build-dashboards) and use [Explore](ref:explore). +The Azure Monitor data source supports the following Azure cloud environments: -The Azure Monitor data source supports visualizing data from four Azure services: +- **Azure** - Azure public cloud (default) +- **Azure US Government** - Azure Government cloud +- **Azure China** - Azure China cloud operated by 21Vianet -- **Azure Monitor Metrics:** Collect numeric data from resources in your Azure account. -- **Azure Monitor Logs:** Collect log and performance data from your Azure account, and query using the Kusto Query Language (KQL). -- **Azure Resource Graph:** Query your Azure resources across subscriptions. -- **Azure Monitor Application Insights:** Collect trace logging data and other application performance metrics. +## Supported Azure services -## Configure the data source +The Azure Monitor data source supports the following Azure services: -**To access the data source configuration page:** +| Service | Description | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| **Azure Monitor Metrics** | Collect numeric data from resources in your Azure account. Supports dimensions, aggregations, and time grain configuration. | +| **Azure Monitor Logs** | Collect log and performance data from your Azure account using the Kusto Query Language (KQL). | +| **Azure Resource Graph** | Query your Azure resources across subscriptions using KQL. Useful for inventory, compliance, and resource management. | +| **Application Insights Traces** | Collect distributed trace data and correlate requests across your application components. | -1. Click **Connections** in the left-side menu. -1. Under Your connections, click **Data sources**. -1. Enter `Azure Monitor` in the search bar. -1. Click **Azure Monitor**. +## Get started - The **Settings** tab of the data source is displayed. +The following documents will help you get started with the Azure Monitor data source: -### Configure Azure Active Directory (AD) authentication +- [Configure the Azure Monitor data source](ref:configure-azure-monitor) - Set up authentication and connect to Azure +- [Azure Monitor query editor](ref:query-editor-azure-monitor) - Create and edit queries for Metrics, Logs, Traces, and Resource Graph +- [Template variables](ref:template-variables-azure-monitor) - Create dynamic dashboards with Azure Monitor variables +- [Alerting](ref:alerting-azure-monitor) - Create alert rules using Azure Monitor data +- [Troubleshooting](ref:troubleshooting-azure-monitor) - Solve common configuration and query errors -You must create an app registration and service principal in Azure AD to authenticate the data source. -For configuration details, refer to the [Azure documentation for service principals](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal#get-tenant-and-app-id-values-for-signing-in). +## Additional features -The app registration you create must have the `Reader` role assigned on the subscription. -For more information, refer to [Azure documentation for role assignments](https://docs.microsoft.com/en-us/azure/role-based-access-control/role-assignments-portal?tabs=current). +After you have configured the Azure Monitor data source, you can: -If you host Grafana in Azure, such as in App Service or Azure Virtual Machines, you can configure the Azure Monitor data source to use Managed Identity for secure authentication without entering credentials into Grafana. -For details, refer to [Configuring using Managed Identity](#configuring-using-managed-identity). +- Add [Annotations](ref:annotations-azure-monitor) to overlay Azure log events on your graphs. +- Configure and use [Template variables](ref:template-variables-azure-monitor) for dynamic dashboards. +- Add [Transformations](ref:transform-data) to manipulate query results. +- Set up [Alerting](ref:alerting-azure-monitor) and recording rules using Metrics, Logs, Traces, and Resource Graph queries. +- Use [Explore](ref:explore) to investigate your Azure data without building a dashboard. -You can configure the Azure Monitor data source to use Workload Identity for secure authentication without entering credentials into Grafana if you host Grafana in a Kubernetes environment, such as AKS, and require access to Azure resources. -For details, refer to [Configuring using Workload Identity](#configuring-using-workload-identity). +## Pre-built dashboards -| Name | Description | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Authentication** | Enables Managed Identity. Selecting Managed Identity hides many of the other fields. For details, see [Configuring using Managed Identity](#configuring-using-managed-identity). | -| **Azure Cloud** | Sets the national cloud for your Azure account. For most users, this is the default "Azure". For details, see the [Azure documentation](https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud). | -| **Directory (tenant) ID** | Sets the directory/tenant ID for the Azure AD app registration to use for authentication. For details, see the [Azure tenant and app ID docs](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal#get-tenant-and-app-id-values-for-signing-in). | -| **Application (client) ID** | Sets the application/client ID for the Azure AD app registration to use for authentication. | -| **Client secret** | Sets the application client secret for the Azure AD app registration to use for authentication. For details, see the [Azure application secret docs](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal#option-2-create-a-new-application-secret). | -| **Default subscription** | _(Optional)_ Sets a default subscription for template variables to use. | -| **Enable Basic Logs** | Allows this data source to execute queries against [Basic Logs tables](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/basic-logs-query?tabs=portal-1) in supported Log Analytics Workspaces. These queries may incur additional costs. | +The Azure Monitor plugin includes the following pre-built dashboards: -### Provision the data source +- **Azure Monitor Overview** - Displays key metrics across your Azure subscriptions and resources. +- **Azure Storage Account** - Shows storage account metrics including availability, latency, and transactions. -You can define and configure the data source in YAML files as part of Grafana's provisioning system. -For more information about provisioning, and for available configuration options, refer to [Provisioning Grafana](ref:provisioning-data-sources). +To import a pre-built dashboard: -#### Provisioning examples +1. Go to **Connections** > **Data sources**. +1. Select your Azure Monitor data source. +1. Click the **Dashboards** tab. +1. Click **Import** next to the dashboard you want to use. -**Azure AD App Registration (client secret):** +## Related resources -```yaml -apiVersion: 1 # config file version - -datasources: - - name: Azure Monitor - type: grafana-azure-monitor-datasource - access: proxy - jsonData: - azureAuthType: clientsecret - cloudName: azuremonitor # See table below - tenantId: - clientId: - subscriptionId: # Optional, default subscription - secureJsonData: - clientSecret: - version: 1 -``` - -**Managed Identity:** - -```yaml -apiVersion: 1 # config file version - -datasources: - - name: Azure Monitor - type: grafana-azure-monitor-datasource - access: proxy - jsonData: - azureAuthType: msi - subscriptionId: # Optional, default subscription - version: 1 -``` - -**Workload Identity:** - -```yaml -apiVersion: 1 # config file version - -datasources: - - name: Azure Monitor - type: grafana-azure-monitor-datasource - access: proxy - jsonData: - azureAuthType: workloadidentity - subscriptionId: # Optional, default subscription - version: 1 -``` - -**Current User:** - -{{< admonition type="note" >}} -The `oauthPassThru` property is required for current user authentication to function. -Additionally, `disableGrafanaCache` is necessary to prevent the data source returning cached responses for resources users don't have access to. -{{< /admonition >}} - -```yaml -apiVersion: 1 # config file version - -datasources: - - name: Azure Monitor - type: grafana-azure-monitor-datasource - access: proxy - jsonData: - azureAuthType: currentuser - oauthPassThru: true - disableGrafanaCache: true - subscriptionId: # Optional, default subscription - version: 1 -``` - -#### Supported cloud names - -| Azure Cloud | `cloudName` Value | -| ------------------------------------ | -------------------------- | -| **Microsoft Azure public cloud** | `azuremonitor` (_Default_) | -| **Microsoft Chinese national cloud** | `chinaazuremonitor` | -| **US Government cloud** | `govazuremonitor` | - -{{< admonition type="note" >}} -Cloud names for current user authentication differ to the `cloudName` values in the preceding table. -The public cloud name is `AzureCloud`, the Chinese national cloud name is `AzureChinaCloud`, and the US Government cloud name is `AzureUSGovernment`. -{{< /admonition >}} - -### Configure Managed Identity - -{{< admonition type="note" >}} -Managed Identity is available only in [Azure Managed Grafana](https://azure.microsoft.com/en-us/products/managed-grafana) or Grafana OSS/Enterprise when deployed in Azure. It is not available in Grafana Cloud. -{{< /admonition >}} - -You can use managed identity to configure Azure Monitor in Grafana if you host Grafana in Azure (such as an App Service or with Azure Virtual Machines) and have managed identity enabled on your VM. -This lets you securely authenticate data sources without manually configuring credentials via Azure AD App Registrations. -For details on Azure managed identities, refer to the [Azure documentation](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview). - -**To enable managed identity for Grafana:** - -1. Set the `managed_identity_enabled` flag in the `[azure]` section of the [Grafana server configuration](ref:configure-grafana-azure). - - ```ini - [azure] - managed_identity_enabled = true - ``` - -2. In the Azure Monitor data source configuration, set **Authentication** to **Managed Identity**. - - This hides the directory ID, application ID, and client secret fields, and the data source uses managed identity to authenticate to Azure Monitor Metrics and Logs, and Azure Resource Graph. - - {{< figure src="/media/docs/grafana/data-sources/screenshot-managed-identity-2.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor screenshot showing Managed Identity authentication" >}} - -3. You can set the `managed_identity_client_id` field in the `[azure]` section of the [Grafana server configuration](ref:configure-grafana-azure) to allow a user-assigned managed identity to be used instead of the default system-assigned identity. - -```ini -[azure] -managed_identity_enabled = true -managed_identity_client_id = USER_ASSIGNED_IDENTITY_CLIENT_ID -``` - -### Configure Workload Identity - -You can use workload identity to configure Azure Monitor in Grafana if you host Grafana in a Kubernetes environment, such as AKS, in conjunction with managed identities. -This lets you securely authenticate data sources without manually configuring credentials via Azure AD App Registrations. -For details on workload identity, refer to the [Azure workload identity documentation](https://azure.github.io/azure-workload-identity/docs/). - -**To enable workload identity for Grafana:** - -1. Set the `workload_identity_enabled` flag in the `[azure]` section of the [Grafana server configuration](ref:configure-grafana-azure). - - ```ini - [azure] - workload_identity_enabled = true - ``` - -2. In the Azure Monitor data source configuration, set **Authentication** to **Workload Identity**. - - This hides the directory ID, application ID, and client secret fields, and the data source uses workload identity to authenticate to Azure Monitor Metrics and Logs, and Azure Resource Graph. - - {{< figure src="/media/docs/grafana/data-sources/screenshot-workload-identity.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor screenshot showing Workload Identity authentication" >}} - -3. There are additional configuration variables that can control the authentication method.`workload_identity_tenant_id` represents the Azure AD tenant that contains the managed identity, `workload_identity_client_id` represents the client ID of the managed identity if it differs from the default client ID, `workload_identity_token_file` represents the path to the token file. Refer to the [documentation](https://azure.github.io/azure-workload-identity/docs/) for more information on what values these variables should use, if any. - - ```ini - [azure] - workload_identity_enabled = true - workload_identity_tenant_id = IDENTITY_TENANT_ID - workload_identity_client_id = IDENTITY_CLIENT_ID - workload_identity_token_file = TOKEN_FILE_PATH - ``` - -### Configure Current User authentication - -{{< admonition type="note" >}} -Current user authentication is an [experimental feature](/docs/release-life-cycle). Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. Contact Grafana Support to enable this feature in Grafana Cloud. Aspects of Grafana may not work as expected when using this authentication method. -{{< /admonition >}} - -If your Grafana instance is configured with Azure Entra (formerly Active Directory) authentication for login, this authentication method can be used to forward the currently logged in user's credentials to the data source. The users credentials will then be used when requesting data from the data source. For details on how to configure your Grafana instance using Azure Entra refer to the [documentation](ref:configure-grafana-azure-auth). - -{{< admonition type="note" >}} -Additional configuration is required to ensure that the App Registration used to login a user via Azure provides an access token with the permissions required by the data source. - -The App Registration must be configured to issue both **Access Tokens** and **ID Tokens**. - -1. In the Azure Portal, open the App Registration that requires configuration. -2. Select **Authentication** in the side menu. -3. Under **Implicit grant and hybrid flows** check both the **Access tokens** and **ID tokens** boxes. -4. Save the changes to ensure the App Registration is updated. - -The App Registration must also be configured with additional **API Permissions** to provide authenticated users with access to the APIs utilised by the data source. - -1. In the Azure Portal, open the App Registration that requires configuration. -1. Select **API Permissions** in the side menu. -1. Ensure the `openid`, `profile`, `email`, and `offline_access` permissions are present under the **Microsoft Graph** section. If not, they must be added. -1. Select **Add a permission** and choose the following permissions. They must be added individually. Refer to the [Azure documentation](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-configure-app-access-web-apis) for more information. - - Select **Azure Service Management** > **Delegated permissions** > `user_impersonation` > **Add permissions** - - Select **APIs my organization uses** > Search for **Log Analytics API** and select it > **Delegated permissions** > `Date.Read` > **Add permissions** - -Once all permissions have been added, the Azure authentication section in Grafana must be updated. The `scopes` section must be updated to include the `.default` scope to ensure that a token with access to all APIs declared on the App Registration is requested by Grafana. Once updated the scopes value should equal: `.default openid email profile`. -{{< /admonition >}} - -This method of authentication doesn't inherently support all backend functionality as a user's credentials won't be in scope. -Affected functionality includes alerting, reporting, and recorded queries. -In order to support backend queries when using a data source configured with current user authentication, you can configure service credentials. -Also, note that query and resource caching is disabled by default for data sources using current user authentication. - -{{< admonition type="note" >}} -To configure fallback service credentials the [feature toggle](ref:configure-grafana-feature-toggles) `idForwarding` must be set to `true` and `user_identity_fallback_credentials_enabled` must be enabled in the [Azure configuration section](ref:configure-grafana-azure) (enabled by default when `user_identity_enabled` is set to `true`). -{{< /admonition >}} - -Permissions for fallback credentials may need to be broad to appropriately support backend functionality. -For example, an alerting query created by a user is dependent on their permissions. -If a user tries to create an alert for a resource that the fallback credentials can't access, the alert will fail. - -**To enable current user authentication for Grafana:** - -1. Set the `user_identity_enabled` flag in the `[azure]` section of the [Grafana server configuration](ref:configure-grafana-azure). - By default this will also enable fallback service credentials. - If you want to disable service credentials at the instance level set `user_identity_fallback_credentials_enabled` to false. - - ```ini - [azure] - user_identity_enabled = true - ``` - -1. In the Azure Monitor data source configuration, set **Authentication** to **Current User**. - If fallback service credentials are enabled at the instance level, an additional configuration section is visible that you can use to enable or disable using service credentials for this data source. - {{< figure src="/media/docs/grafana/data-sources/screenshot-current-user.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor screenshot showing Current User authentication" >}} - -1. If you want backend functionality to work with this data source, enable service credentials and configure the data source using the most applicable credentials for your circumstances. - -## Query the data source - -The Azure Monitor data source can query data from Azure Monitor Metrics and Logs, the Azure Resource Graph, and Application Insights Traces. Each source has its own specialized query editor. - -For details, see the [query editor documentation](query-editor/). - -## Use template variables - -Instead of hard-coding details such as server, application, and sensor names in metric queries, you can use variables. -Grafana lists these variables in dropdown select boxes at the top of the dashboard to help you change the data displayed in your dashboard. -Grafana refers to such variables as template variables. - -For details, see the [template variables documentation](template-variables/). - -## Application Insights and Insights Analytics (removed) - -Until Grafana v8.0, you could query the same Azure Application Insights data using Application Insights and Insights Analytics. - -These queries were deprecated in Grafana v7.5. In Grafana v8.0, Application Insights and Insights Analytics were made read-only in favor of querying this data through Metrics and Logs. These query methods were completely removed in Grafana v9.0. - -If you're upgrading from a Grafana version prior to v9.0 and relied on Application Insights and Analytics queries, refer to the [Grafana v9.0 documentation](/docs/grafana/v9.0/datasources/azuremonitor/deprecated-application-insights/) for help migrating these queries to Metrics and Logs queries. +- [Azure Monitor documentation](https://docs.microsoft.com/en-us/azure/azure-monitor/) +- [Kusto Query Language (KQL) reference](https://docs.microsoft.com/en-us/azure/data-explorer/kusto/query/) +- [Grafana community forum](https://community.grafana.com/) diff --git a/docs/sources/datasources/azure-monitor/alerting/index.md b/docs/sources/datasources/azure-monitor/alerting/index.md new file mode 100644 index 00000000000..860c1d343a4 --- /dev/null +++ b/docs/sources/datasources/azure-monitor/alerting/index.md @@ -0,0 +1,262 @@ +--- +aliases: + - ../../data-sources/azure-monitor/alerting/ +description: Set up alerts using Azure Monitor data in Grafana +keywords: + - grafana + - azure + - monitor + - alerting + - alerts + - metrics + - logs +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Alerting +title: Azure Monitor alerting +weight: 500 +refs: + alerting: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//alerting/ + alerting-fundamentals: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//alerting/fundamentals/ + create-alert-rule: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/alerting-rules/create-grafana-managed-rule/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//alerting/alerting-rules/create-grafana-managed-rule/ + grafana-managed-recording-rules: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/alerting-rules/create-recording-rules/create-grafana-managed-recording-rules/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//alerting/alerting-rules/create-recording-rules/create-grafana-managed-recording-rules/ + configure-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/configure/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/configure/ + query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/query-editor/ + troubleshoot: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/troubleshooting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/troubleshooting/ +--- + +# Azure Monitor alerting + +The Azure Monitor data source supports [Grafana Alerting](ref:alerting) and [Grafana-managed recording rules](ref:grafana-managed-recording-rules), allowing you to create alert rules based on Azure metrics, logs, traces, and resource data. You can monitor your Azure environment and receive notifications when specific conditions are met. + +## Before you begin + +- Ensure you have the appropriate permissions to create alert rules in Grafana. +- Verify your Azure Monitor data source is configured and working correctly. +- Familiarize yourself with [Grafana Alerting concepts](ref:alerting-fundamentals). +- **Important**: Verify your data source uses a supported authentication method. Refer to [Authentication requirements](#authentication-requirements). + +## Supported query types for alerting + +All Azure Monitor query types support alerting and recording rules: + +| Query type | Use case | Notes | +| -------------------- | -------------------------------------------------- | -------------------------------------------------------- | +| Metrics | Threshold-based alerts on Azure resource metrics | Best suited for alerting; returns time-series data | +| Logs | Alert on log patterns, error counts, or thresholds | Use KQL to aggregate data into numeric values | +| Azure Resource Graph | Alert on resource state or configuration changes | Use count aggregations to return numeric data | +| Traces | Alert on trace data and application performance | Use aggregations to return numeric values for evaluation | + +{{< admonition type="note" >}} +Alert queries must return numeric data that Grafana can evaluate against a threshold. Queries that return only text or non-numeric data cannot be used directly for alerting. +{{< /admonition >}} + +## Authentication requirements + +Alerting and recording rules run as background processes without a user context. This means they require service-level authentication and don't work with all authentication methods. + +| Authentication method | Supported | +| -------------------------------- | ------------------------------------- | +| App Registration (client secret) | ✓ | +| Managed Identity | ✓ | +| Workload Identity | ✓ | +| Current User | ✓ (with fallback service credentials) | + +{{< admonition type="note" >}} +If you use **Current User** authentication, you must configure **fallback service credentials** for alerting and recording rules to function. User credentials aren't available for background operations, so Grafana uses the fallback credentials instead. Refer to [configure the data source](ref:configure-azure-monitor) for details on setting up fallback credentials. +{{< /admonition >}} + +## Create an alert rule + +To create an alert rule using Azure Monitor data: + +1. Go to **Alerting** > **Alert rules**. +1. Click **New alert rule**. +1. Enter a name for your alert rule. +1. In the **Define query and alert condition** section: + - Select your Azure Monitor data source. + - Configure your query (for example, a Metrics query for CPU usage or a Logs query using KQL). + - Add a **Reduce** expression if your query returns multiple series. + - Add a **Threshold** expression to define the alert condition. +1. Configure the **Set evaluation behavior**: + - Select or create a folder and evaluation group. + - Set the evaluation interval (how often the alert is checked). + - Set the pending period (how long the condition must be true before firing). +1. Add labels and annotations to provide context for notifications. +1. Click **Save rule**. + +For detailed instructions, refer to [Create a Grafana-managed alert rule](ref:create-alert-rule). + +## Example: VM CPU usage alert + +This example creates an alert that fires when virtual machine CPU usage exceeds 80%: + +1. Create a new alert rule. +1. Configure the query: + - **Service**: Metrics + - **Resource**: Select your virtual machine + - **Metric namespace**: `Microsoft.Compute/virtualMachines` + - **Metric**: `Percentage CPU` + - **Aggregation**: `Average` +1. Add expressions: + - **Reduce**: Last (to get the most recent data point) + - **Threshold**: Is above 80 +1. Set evaluation to run every 1 minute with a 5-minute pending period. +1. Save the rule. + +## Example: Error log count alert + +This example alerts when error logs exceed a threshold using a KQL query: + +1. Create a new alert rule. +1. Configure the query: + - **Service**: Logs + - **Resource**: Select your Log Analytics workspace + - **Query**: + ```kusto + AppExceptions + | where TimeGenerated > ago(5m) + | summarize ErrorCount = count() by bin(TimeGenerated, 1m) + ``` +1. Add expressions: + - **Reduce**: Max (to get the highest count in the period) + - **Threshold**: Is above 10 +1. Set evaluation to run every 5 minutes. +1. Save the rule. + +## Example: Resource count alert + +This example alerts when the number of running virtual machines drops below a threshold using Azure Resource Graph: + +1. Create a new alert rule. +1. Configure the query: + - **Service**: Azure Resource Graph + - **Subscriptions**: Select your subscriptions + - **Query**: + + ```kusto + resources + | where type == "microsoft.compute/virtualmachines" + | where properties.extended.instanceView.powerState.displayStatus == "VM running" + | summarize RunningVMs = count() + ``` + +1. Add expressions: + - **Reduce**: Last + - **Threshold**: Is below 3 +1. Set evaluation to run every 5 minutes. +1. Save the rule. + +## Best practices + +Follow these recommendations to create reliable and efficient alerts with Azure Monitor data. + +### Use appropriate query intervals + +- Set the alert evaluation interval to be greater than or equal to the minimum data resolution from Azure Monitor. +- Azure Monitor Metrics typically have 1-minute granularity at minimum. +- Avoid very short intervals (less than 1 minute) as they may cause evaluation timeouts or miss data points. + +### Reduce multiple series + +When your Azure Monitor query returns multiple time series (for example, CPU usage across multiple VMs), use the **Reduce** expression to aggregate them: + +- **Last**: Use the most recent value +- **Mean**: Average across all series +- **Max/Min**: Use the highest or lowest value +- **Sum**: Total across all series + +### Optimize Log Analytics queries + +For Logs queries used in alerting: + +- Use `summarize` to aggregate data into numeric values. +- Include appropriate time filters using `ago()` or `TimeGenerated`. +- Avoid returning large result sets; aggregate data in the query. +- Test queries in Explore before using them in alert rules. + +### Handle no data conditions + +Configure what happens when no data is returned: + +1. In the alert rule, find **Configure no data and error handling**. +1. Choose an appropriate action: + - **No Data**: Keep the alert in its current state + - **Alerting**: Treat no data as an alert condition + - **OK**: Treat no data as a healthy state + +### Test queries before alerting + +Always verify your query returns expected data before creating an alert: + +1. Go to **Explore**. +1. Select your Azure Monitor data source. +1. Run the query you plan to use for alerting. +1. Confirm the data format and values are correct. +1. Verify the query returns numeric data suitable for threshold evaluation. + +## Troubleshooting + +If your Azure Monitor alerts aren't working as expected, use the following sections to diagnose and resolve common issues. + +### Alerts not firing + +- Verify the data source uses a supported authentication method. If using Current User authentication, ensure fallback service credentials are configured. +- Check that the query returns numeric data in Explore. +- Ensure the evaluation interval allows enough time for data to be available. +- Review the alert rule's health and any error messages in the Alerting UI. + +### Authentication errors in alert evaluation + +If you see authentication errors when alerts evaluate: + +- Confirm the data source is configured with App Registration, Managed Identity, Workload Identity, or Current User with fallback service credentials. +- If using App Registration, verify the client secret hasn't expired. +- If using Current User, verify that fallback service credentials are configured and valid. +- Check that the service principal has appropriate permissions on Azure resources. + +### Query timeout errors + +- Simplify complex KQL queries. +- Reduce the time range in Log Analytics queries. +- Add more specific filters to narrow result sets. + +For additional troubleshooting help, refer to [Troubleshoot Azure Monitor](ref:troubleshoot). + +## Additional resources + +- [Grafana Alerting documentation](ref:alerting) +- [Create alert rules](ref:create-alert-rule) +- [Azure Monitor query editor](ref:query-editor) +- [Grafana-managed recording rules](ref:grafana-managed-recording-rules) diff --git a/docs/sources/datasources/azure-monitor/annotations/index.md b/docs/sources/datasources/azure-monitor/annotations/index.md new file mode 100644 index 00000000000..43fbb914a9d --- /dev/null +++ b/docs/sources/datasources/azure-monitor/annotations/index.md @@ -0,0 +1,218 @@ +--- +aliases: + - ../../data-sources/azure-monitor/annotations/ +description: Use annotations with the Azure Monitor data source in Grafana +keywords: + - grafana + - azure + - monitor + - annotations + - events + - logs +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Annotations +title: Azure Monitor annotations +weight: 450 +refs: + annotate-visualizations: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/build-dashboards/annotate-visualizations/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//dashboards/build-dashboards/annotate-visualizations/ + query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/query-editor/ +--- + +# Azure Monitor annotations + +[Annotations](ref:annotate-visualizations) overlay rich event information on top of graphs. You can use Azure Monitor Log Analytics queries to create annotations that mark important events, deployments, alerts, or other significant occurrences on your dashboards. + +## Before you begin + +- Ensure you have configured the Azure Monitor data source. +- You need access to a Log Analytics workspace containing the data you want to use for annotations. +- Annotations use Log Analytics (KQL) queries only. Metrics, Traces, and Azure Resource Graph queries are not supported for annotations. + +## Create an annotation query + +To add an Azure Monitor annotation to a dashboard: + +1. Open the dashboard where you want to add annotations. +1. Click **Dashboard settings** (gear icon) in the top navigation. +1. Select **Annotations** in the left menu. +1. Click **Add annotation query**. +1. Enter a **Name** for the annotation (e.g., "Azure Activity", "Deployments"). +1. Select your **Azure Monitor** data source. +1. Choose the **Logs** service. +1. Select a **Resource** (Log Analytics workspace or Application Insights resource). +1. Write a KQL query that returns the annotation data. +1. Click **Apply** to save. + +## Query requirements + +Your KQL query should return columns that Grafana can use to create annotations: + +| Column | Required | Description | +| ------------------ | ----------- | ------------------------------------------------------------------------------------------------ | +| `TimeGenerated` | Yes | The timestamp for the annotation. Grafana uses this to position the annotation on the time axis. | +| `Text` | Recommended | The annotation text displayed when you hover over or click the annotation. | +| Additional columns | Optional | Any other columns returned become annotation tags. | + +{{< admonition type="note" >}} +Always include a time filter in your query to limit results to the dashboard's time range. Use the `$__timeFilter()` macro. +{{< /admonition >}} + +## Annotation query examples + +The following examples demonstrate common annotation use cases. + +### Azure Activity Log events + +Display Azure Activity Log events such as resource modifications, deployments, and administrative actions: + +```kusto +AzureActivity +| where $__timeFilter(TimeGenerated) +| where Level == "Error" or Level == "Warning" or CategoryValue == "Administrative" +| project TimeGenerated, Text=OperationNameValue, Level, ResourceGroup, Caller +| order by TimeGenerated desc +| take 100 +``` + +### Deployment events + +Show deployment-related activity: + +```kusto +AzureActivity +| where $__timeFilter(TimeGenerated) +| where OperationNameValue contains "deployments" +| project TimeGenerated, Text=strcat("Deployment: ", OperationNameValue), Status=ActivityStatusValue, ResourceGroup +| order by TimeGenerated desc +``` + +### Application Insights exceptions + +Mark application exceptions as annotations: + +```kusto +AppExceptions +| where $__timeFilter(TimeGenerated) +| project TimeGenerated, Text=strcat(ProblemId, ": ", OuterMessage), SeverityLevel, AppRoleName +| order by TimeGenerated desc +| take 50 +``` + +### Custom events from Application Insights + +Display custom events logged by your application: + +```kusto +AppEvents +| where $__timeFilter(TimeGenerated) +| where Name == "DeploymentStarted" or Name == "DeploymentCompleted" +| project TimeGenerated, Text=Name, AppRoleName +| order by TimeGenerated desc +``` + +### Security alerts + +Show security-related alerts: + +```kusto +SecurityAlert +| where $__timeFilter(TimeGenerated) +| project TimeGenerated, Text=AlertName, Severity=AlertSeverity, Description +| order by TimeGenerated desc +| take 50 +``` + +### Resource health events + +Display resource health status changes: + +```kusto +AzureActivity +| where $__timeFilter(TimeGenerated) +| where CategoryValue == "ResourceHealth" +| project TimeGenerated, Text=OperationNameValue, Status=ActivityStatusValue, ResourceId +| order by TimeGenerated desc +``` + +### VM start and stop events + +Mark virtual machine state changes: + +```kusto +AzureActivity +| where $__timeFilter(TimeGenerated) +| where OperationNameValue has_any ("start", "deallocate", "restart") +| where ResourceProviderValue == "MICROSOFT.COMPUTE" +| project TimeGenerated, Text=OperationNameValue, VM=Resource, Status=ActivityStatusValue +| order by TimeGenerated desc +``` + +### Autoscale events + +Show autoscale operations: + +```kusto +AzureActivity +| where $__timeFilter(TimeGenerated) +| where OperationNameValue contains "autoscale" +| project TimeGenerated, Text=strcat("Autoscale: ", OperationNameValue), Status=ActivityStatusValue, ResourceGroup +| order by TimeGenerated desc +``` + +## Customize annotation appearance + +After creating an annotation query, you can customize its appearance: + +| Setting | Description | +| ------------- | -------------------------------------------------------------------------------------------------------- | +| **Color** | Choose a color for the annotation markers. Use different colors to distinguish between annotation types. | +| **Show in** | Select which panels display the annotations. | +| **Filter by** | Add filters to limit when annotations appear. | + +## Best practices + +Follow these recommendations when creating annotations: + +1. **Limit results**: Always use `take` or `limit` to restrict the number of annotations. Too many annotations can clutter your dashboard and impact performance. + +2. **Use time filters**: Include `$__timeFilter()` to ensure queries only return data within the dashboard's time range. + +3. **Create meaningful text**: Use `strcat()` or `project` to create descriptive annotation text that provides context at a glance. + +4. **Add relevant tags**: Include columns like `ResourceGroup`, `Severity`, or `Status` that become clickable tags for filtering. + +5. **Use descriptive names**: Name your annotations clearly (e.g., "Production Deployments", "Critical Alerts") so dashboard users understand what they represent. + +## Troubleshoot annotations + +If annotations aren't appearing as expected, try the following solutions. + +### Annotations don't appear + +- Verify the query returns data in the selected time range. +- Check that the query includes a `TimeGenerated` column. +- Test the query in the Azure Portal Log Analytics query editor. +- Ensure the annotation is enabled (toggle is on). + +### Too many annotations + +- Add more specific filters to your query. +- Use `take` to limit results. +- Narrow the time range. + +### Annotations appear at wrong times + +- Verify the `TimeGenerated` column contains the correct timestamp. +- Check your dashboard's timezone settings. diff --git a/docs/sources/datasources/azure-monitor/configure/index.md b/docs/sources/datasources/azure-monitor/configure/index.md new file mode 100644 index 00000000000..cef21b08744 --- /dev/null +++ b/docs/sources/datasources/azure-monitor/configure/index.md @@ -0,0 +1,605 @@ +--- +aliases: + - ../../data-sources/azure-monitor/configure/ +description: Guide for configuring the Azure Monitor data source in Grafana. +keywords: + - grafana + - microsoft + - azure + - monitor + - application + - insights + - log + - analytics + - guide +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Configure +title: Configure the Azure Monitor data source +weight: 200 +last_reviewed: 2025-12-04 +refs: + configure-grafana-feature-toggles: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#feature_toggles + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#feature_toggles + provisioning-data-sources: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/provisioning/#data-sources + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/provisioning/#data-sources + explore: + - pattern: /docs/grafana/ + destination: /docs/grafana//explore/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//explore/ + configure-grafana-azure-auth: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-security/configure-authentication/azuread/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-security/configure-authentication/azuread/ + build-dashboards: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/build-dashboards/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//dashboards/build-dashboards/ + configure-grafana-azure: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#azure + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#azure + data-source-management: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/data-source-management/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/data-source-management/ + configure-grafana-azure-auth-scopes: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-security/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-security/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana + data-sources: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/ + private-data-source-connect: + - pattern: /docs/grafana/ + destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + - pattern: /docs/grafana-cloud/ + destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + configure-pdc: + - pattern: /docs/grafana/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc +--- + +# Configure the Azure Monitor data source + +This document explains how to configure the Azure Monitor data source and the available configuration options. +For general information about data sources, refer to [Grafana data sources](ref:data-sources) and [Data source management](ref:data-source-management). + +## Before you begin + +Before configuring the Azure Monitor data source, ensure you have the following: + +- **Grafana permissions:** You must have the `Organization administrator` role to configure data sources. + Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system or [using Terraform](#configure-with-terraform). + +- **Azure prerequisites:** Depending on your chosen authentication method, you may need: + - A Microsoft Entra ID (formerly Azure AD) app registration with a service principal (for App Registration authentication) + - A Managed Identity enabled on your Azure VM or App Service (for Managed Identity authentication) + - Workload identity configured in your Kubernetes cluster (for Workload Identity authentication) + - Microsoft Entra ID authentication configured for Grafana login (for Current User authentication) + +{{< admonition type="note" >}} +**Grafana Cloud users:** Managed Identity and Workload Identity authentication methods are not available in Grafana Cloud because they require Grafana to run on your Azure infrastructure. Use **App Registration** authentication instead. +{{< /admonition >}} + +- **Azure RBAC permissions:** The identity used to authenticate must have the `Reader` role on the Azure subscription containing the resources you want to monitor. + For Log Analytics queries, the identity also needs appropriate permissions on the Log Analytics workspaces to be queried. + Refer to the [Azure documentation for role assignments](https://docs.microsoft.com/en-us/azure/role-based-access-control/role-assignments-portal?tabs=current). + +{{< admonition type="note" >}} +The Azure Monitor data source plugin is built into Grafana. No additional installation is required. +{{< /admonition >}} + +## Add the data source + +To add the Azure Monitor data source: + +1. Click **Connections** in the left-side menu. +1. Click **Add new connection**. +1. Type `Azure Monitor` in the search bar. +1. Select **Azure Monitor**. +1. Click **Add new data source** in the upper right. + +You're taken to the **Settings** tab where you can configure the data source. + +## Choose an authentication method + +The Azure Monitor data source supports four authentication methods. Choose based on where Grafana is hosted and your security requirements: + +| Authentication method | Best for | Requirements | +| --------------------- | ------------------------------------------ | -------------------------------------------------------------- | +| **App Registration** | Any Grafana deployment | Microsoft Entra ID app registration with client secret | +| **Managed Identity** | Grafana hosted in Azure (VMs, App Service) | Managed identity enabled on the Azure resource | +| **Workload Identity** | Grafana in Kubernetes (AKS) | Workload identity federation configured | +| **Current User** | User-level access control | Microsoft Entra ID authentication configured for Grafana login | + +## Configure authentication + +Select one of the following authentication methods and complete the configuration. + +### App Registration + +Use a Microsoft Entra ID app registration (service principal) to authenticate. This method works with any Grafana deployment. + +#### App Registration prerequisites + +1. Create an app registration in Microsoft Entra ID. + Refer to the [Azure documentation for creating a service principal](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal#get-tenant-and-app-id-values-for-signing-in). + +1. Create a client secret for the app registration. + Refer to the [Azure documentation for creating a client secret](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal#option-2-create-a-new-application-secret). + +1. Assign the `Reader` role to the app registration on the subscription or resources you want to monitor. + Refer to the [Azure documentation for role assignments](https://docs.microsoft.com/en-us/azure/role-based-access-control/role-assignments-portal?tabs=current). + +#### App Registration UI configuration + +| Setting | Description | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| **Authentication** | Select **App Registration**. | +| **Azure Cloud** | The Azure environment to connect to. Select **Azure** for the public cloud, or choose Azure Government or Azure China for national clouds. | +| **Directory (tenant) ID** | The GUID that identifies your Microsoft Entra ID tenant. | +| **Application (client) ID** | The GUID for the app registration you created. | +| **Client secret** | The secret key for the app registration. Keep this secure and rotate periodically. | +| **Default Subscription** | Click **Load Subscriptions** to populate available subscriptions, then select your default. | + +#### Provision App Registration with YAML + +```yaml +apiVersion: 1 + +datasources: + - name: Azure Monitor + type: grafana-azure-monitor-datasource + access: proxy + jsonData: + azureAuthType: clientsecret + cloudName: azuremonitor # See supported cloud names below + tenantId: + clientId: + subscriptionId: # Optional, default subscription + secureJsonData: + clientSecret: + version: 1 +``` + +### Managed Identity + +Use Azure Managed Identity for secure, credential-free authentication when Grafana is hosted in Azure. + +{{< admonition type="note" >}} +Managed Identity is available in [Azure Managed Grafana](https://azure.microsoft.com/en-us/products/managed-grafana) or self-hosted Grafana deployed in Azure. It is not available in Grafana Cloud. +{{< /admonition >}} + +#### Managed Identity prerequisites + +- Grafana must be hosted in Azure (App Service, Azure VMs, or Azure Managed Grafana). +- Managed identity must be enabled on the Azure resource hosting Grafana. +- The managed identity must have the `Reader` role on the subscription or resources you want to monitor. + +For details on Azure managed identities, refer to the [Azure documentation](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview). + +#### Managed Identity Grafana server configuration + +Enable managed identity in the Grafana server configuration: + +```ini +[azure] +managed_identity_enabled = true +``` + +To use a user-assigned managed identity instead of the system-assigned identity, also set: + +```ini +[azure] +managed_identity_enabled = true +managed_identity_client_id = +``` + +Refer to [Grafana Azure configuration](ref:configure-grafana-azure) for more details. + +#### Managed Identity UI configuration + +| Setting | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------- | +| **Authentication** | Select **Managed Identity**. The directory ID, application ID, and client secret fields are hidden. | +| **Default Subscription** | Click **Load Subscriptions** to populate available subscriptions, then select your default. | + +{{< figure src="/media/docs/grafana/data-sources/screenshot-managed-identity-2.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor data source configured with Managed Identity" >}} + +#### Provision Managed Identity with YAML + +```yaml +apiVersion: 1 + +datasources: + - name: Azure Monitor + type: grafana-azure-monitor-datasource + access: proxy + jsonData: + azureAuthType: msi + subscriptionId: # Optional, default subscription + version: 1 +``` + +### Workload Identity + +Use Azure Workload Identity for secure authentication in Kubernetes environments like AKS. + +#### Workload Identity prerequisites + +- Grafana must be running in a Kubernetes environment with workload identity federation configured. +- The workload identity must have the `Reader` role on the subscription or resources you want to monitor. + +For details, refer to the [Azure workload identity documentation](https://azure.github.io/azure-workload-identity/docs/). + +#### Workload Identity Grafana server configuration + +Enable workload identity in the Grafana server configuration: + +```ini +[azure] +workload_identity_enabled = true +``` + +Optional configuration variables: + +```ini +[azure] +workload_identity_enabled = true +workload_identity_tenant_id = # Microsoft Entra ID tenant containing the managed identity +workload_identity_client_id = # Client ID if different from default +workload_identity_token_file = # Path to the token file +``` + +Refer to [Grafana Azure configuration](ref:configure-grafana-azure) and the [Azure workload identity documentation](https://azure.github.io/azure-workload-identity/docs/) for more details. + +#### Workload Identity UI configuration + +| Setting | Description | +| ------------------------ | ---------------------------------------------------------------------------------------------------- | +| **Authentication** | Select **Workload Identity**. The directory ID, application ID, and client secret fields are hidden. | +| **Default Subscription** | Click **Load Subscriptions** to populate available subscriptions, then select your default. | + +{{< figure src="/media/docs/grafana/data-sources/screenshot-workload-identity.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor data source configured with Workload Identity" >}} + +#### Provision Workload Identity with YAML + +```yaml +apiVersion: 1 + +datasources: + - name: Azure Monitor + type: grafana-azure-monitor-datasource + access: proxy + jsonData: + azureAuthType: workloadidentity + subscriptionId: # Optional, default subscription + version: 1 +``` + +### Current User + +Forward the logged-in Grafana user's Azure credentials to the data source for user-level access control. + +{{< admonition type="warning" >}} +Current User authentication is an [experimental feature](/docs/release-life-cycle/). Engineering and on-call support is not available. Documentation is limited. No SLA is provided. Contact Grafana Support to enable this feature in Grafana Cloud. +{{< /admonition >}} + +#### Current User prerequisites + +Your Grafana instance must be configured with Microsoft Entra ID authentication. Refer to the [Microsoft Entra ID authentication documentation](ref:configure-grafana-azure-auth). + +#### Configure your Azure App Registration + +The App Registration used for Grafana login requires additional configuration: + +**Enable token issuance:** + +1. In the Azure Portal, open your App Registration. +1. Select **Authentication** in the side menu. +1. Under **Implicit grant and hybrid flows**, check both **Access tokens** and **ID tokens**. +1. Save your changes. + +**Add API permissions:** + +1. In the Azure Portal, open your App Registration. +1. Select **API Permissions** in the side menu. +1. Ensure these permissions are present under **Microsoft Graph**: `openid`, `profile`, `email`, and `offline_access`. +1. Add the following permissions: + - **Azure Service Management** > **Delegated permissions** > `user_impersonation` + - **APIs my organization uses** > Search for **Log Analytics API** > **Delegated permissions** > `Data.Read` + +Refer to the [Azure documentation](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-configure-app-access-web-apis) for more information. + +**Update Grafana scopes:** + +Update the `scopes` section in your Grafana Azure authentication configuration to include the `.default` scope: + +``` +.default openid email profile +``` + +#### Current User Grafana server configuration + +Enable current user authentication in the Grafana server configuration: + +```ini +[azure] +user_identity_enabled = true +``` + +By default, this also enables fallback service credentials. To disable fallback credentials at the instance level: + +```ini +[azure] +user_identity_enabled = true +user_identity_fallback_credentials_enabled = false +``` + +{{< admonition type="note" >}} +To use fallback service credentials, the [feature toggle](ref:configure-grafana-feature-toggles) `idForwarding` must be set to `true`. +{{< /admonition >}} + +#### Limitations and fallback credentials + +Current User authentication doesn't support backend functionality like alerting, reporting, and recorded queries because user credentials aren't available for background operations. + +To support these features, configure **fallback service credentials**. When enabled, Grafana uses the fallback credentials for backend operations. Note that operations using fallback credentials are limited to the permissions of those credentials, not the user's permissions. + +{{< admonition type="note" >}} +Query and resource caching is disabled by default for data sources using Current User authentication. +{{< /admonition >}} + +#### Current User UI configuration + +| Setting | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------- | +| **Authentication** | Select **Current User**. | +| **Default Subscription** | Click **Load Subscriptions** to populate available subscriptions, then select your default. | +| **Fallback Service Credentials** | Enable and configure credentials for backend features like alerting. | + +{{< figure src="/media/docs/grafana/data-sources/screenshot-current-user.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor data source configured with Current User authentication" >}} + +#### Provision Current User with YAML + +{{< admonition type="note" >}} +The `oauthPassThru` property is required for Current User authentication. The `disableGrafanaCache` property prevents returning cached responses for resources users don't have access to. +{{< /admonition >}} + +```yaml +apiVersion: 1 + +datasources: + - name: Azure Monitor + type: grafana-azure-monitor-datasource + access: proxy + jsonData: + azureAuthType: currentuser + oauthPassThru: true + disableGrafanaCache: true + subscriptionId: # Optional, default subscription + version: 1 +``` + +## Additional configuration options + +These settings apply to all authentication methods. + +### General settings + +| Setting | Description | +| ----------- | ------------------------------------------------------------------------------- | +| **Name** | The data source name used in panels and queries. Example: `azure-monitor-prod`. | +| **Default** | Toggle to make this the default data source for new panels. | + +### Enable Basic Logs + +Toggle **Enable Basic Logs** to allow queries against [Basic Logs tables](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/basic-logs-query?tabs=portal-1) in supported Log Analytics Workspaces. + +{{< admonition type="note" >}} +Querying Basic Logs tables incurs additional costs on a per-query basis. +{{< /admonition >}} + +### Private data source connect (Grafana Cloud only) + +If you're using Grafana Cloud and need to connect to Azure resources in a private network, use Private Data Source Connect (PDC). + +1. Click the **Private data source connect** dropdown to select your PDC configuration. +1. Click **Manage private data source connect** to view your PDC connection details. + +For more information, refer to [Private data source connect](ref:private-data-source-connect) and [Configure PDC](ref:configure-pdc). + +## Supported cloud names + +When provisioning the data source, use the following `cloudName` values: + +| Azure Cloud | `cloudName` value | +| -------------------------------- | ------------------------ | +| Microsoft Azure public cloud | `azuremonitor` (default) | +| Microsoft Chinese national cloud | `chinaazuremonitor` | +| US Government cloud | `govazuremonitor` | + +{{< admonition type="note" >}} +For Current User authentication, the cloud names differ: use `AzureCloud` for public cloud, `AzureChinaCloud` for the Chinese national cloud, and `AzureUSGovernment` for the US Government cloud. +{{< /admonition >}} + +## Verify the connection + +After configuring the data source, click **Save & test**. A successful connection displays a message confirming that the credentials are valid and have access to the configured default subscription. + +If the test fails, verify: + +- Your credentials are correct (tenant ID, client ID, client secret) +- The identity has the required Azure RBAC permissions +- For Managed Identity or Workload Identity, that the Grafana server configuration is correct +- Network connectivity to Azure endpoints + +## Provision the data source + +You can define and configure the Azure Monitor data source in YAML files as part of the Grafana provisioning system. +For more information about provisioning, refer to [Provisioning Grafana](ref:provisioning-data-sources). + +### Provision quick reference + +| Authentication method | `azureAuthType` value | Required fields | +| --------------------- | --------------------- | -------------------------------------------------- | +| App Registration | `clientsecret` | `tenantId`, `clientId`, `clientSecret` | +| Managed Identity | `msi` | None (uses VM identity) | +| Workload Identity | `workloadidentity` | None (uses pod identity) | +| Current User | `currentuser` | `oauthPassThru: true`, `disableGrafanaCache: true` | + +All methods support the optional `subscriptionId` field to set a default subscription. + +For complete YAML examples, see the [authentication method sections](#configure-authentication) above. + +## Configure with Terraform + +You can configure the Azure Monitor data source using the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs). This approach enables infrastructure-as-code workflows and version control for your Grafana configuration. + +### Terraform prerequisites + +- [Terraform](https://www.terraform.io/downloads) installed +- Grafana Terraform provider configured with appropriate credentials +- For Grafana Cloud: A [Cloud Access Policy token](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) with data source permissions + +### Provider configuration + +Configure the Grafana provider to connect to your Grafana instance: + +```hcl +terraform { + required_providers { + grafana = { + source = "grafana/grafana" + version = ">= 2.0.0" + } + } +} + +# For Grafana Cloud +provider "grafana" { + url = "" + auth = "" +} + +# For self-hosted Grafana +# provider "grafana" { +# url = "http://localhost:3000" +# auth = "" +# } +``` + +### Terraform examples + +The following examples show how to configure the Azure Monitor data source for each authentication method. + +**App Registration (client secret):** + +```hcl +resource "grafana_data_source" "azure_monitor" { + type = "grafana-azure-monitor-datasource" + name = "Azure Monitor" + + json_data_encoded = jsonencode({ + azureAuthType = "clientsecret" + cloudName = "azuremonitor" + tenantId = "" + clientId = "" + subscriptionId = "" + }) + + secure_json_data_encoded = jsonencode({ + clientSecret = "" + }) +} +``` + +**Managed Identity:** + +```hcl +resource "grafana_data_source" "azure_monitor" { + type = "grafana-azure-monitor-datasource" + name = "Azure Monitor" + + json_data_encoded = jsonencode({ + azureAuthType = "msi" + subscriptionId = "" + }) +} +``` + +**Workload Identity:** + +```hcl +resource "grafana_data_source" "azure_monitor" { + type = "grafana-azure-monitor-datasource" + name = "Azure Monitor" + + json_data_encoded = jsonencode({ + azureAuthType = "workloadidentity" + subscriptionId = "" + }) +} +``` + +**Current User:** + +```hcl +resource "grafana_data_source" "azure_monitor" { + type = "grafana-azure-monitor-datasource" + name = "Azure Monitor" + + json_data_encoded = jsonencode({ + azureAuthType = "currentuser" + oauthPassThru = true + disableGrafanaCache = true + subscriptionId = "" + }) +} +``` + +**With Basic Logs enabled:** + +Add `enableBasicLogs = true` to any of the above configurations: + +```hcl +resource "grafana_data_source" "azure_monitor" { + type = "grafana-azure-monitor-datasource" + name = "Azure Monitor" + + json_data_encoded = jsonencode({ + azureAuthType = "clientsecret" + cloudName = "azuremonitor" + tenantId = "" + clientId = "" + subscriptionId = "" + enableBasicLogs = true + }) + + secure_json_data_encoded = jsonencode({ + clientSecret = "" + }) +} +``` + +For more information about the Grafana Terraform provider, refer to the [provider documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs) and the [grafana_data_source resource](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source). diff --git a/docs/sources/datasources/azure-monitor/query-editor/index.md b/docs/sources/datasources/azure-monitor/query-editor/index.md index 6415be1281c..a8c763d9280 100644 --- a/docs/sources/datasources/azure-monitor/query-editor/index.md +++ b/docs/sources/datasources/azure-monitor/query-editor/index.md @@ -21,6 +21,7 @@ labels: menuTitle: Query editor title: Azure Monitor query editor weight: 300 +last_reviewed: 2025-12-04 refs: query-transform-data-query-options: - pattern: /docs/grafana/ @@ -32,30 +33,85 @@ refs: destination: /docs/grafana//panels-visualizations/query-transform-data/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana//panels-visualizations/query-transform-data/ + configure-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/configure/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/configure/ + explore: + - pattern: /docs/grafana/ + destination: /docs/grafana//explore/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//explore/ + troubleshoot-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/troubleshooting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/troubleshooting/ + configure-grafana-feature-toggles: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/feature-toggles/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/feature-toggles/ + template-variables: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/template-variables/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/template-variables/ + alerting-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/alerting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/alerting/ + annotations-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/annotations/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/annotations/ --- # Azure Monitor query editor -This topic explains querying specific to the Azure Monitor data source. -For general documentation on querying data sources in Grafana, see [Query and transform data](ref:query-transform-data). +Grafana provides a query editor for the Azure Monitor data source, which is located on the [Explore page](ref:explore). You can also access the Azure Monitor query editor from a dashboard panel. Click the menu in the upper right of the panel and select **Edit**. -## Choose a query editing mode +This document explains querying specific to the Azure Monitor data source. +For general documentation on querying data sources in Grafana, refer to [Query and transform data](ref:query-transform-data). -The Azure Monitor data source's query editor has three modes depending on which Azure service you want to query: +The Azure Monitor data source can query data from Azure Monitor Metrics and Logs, the Azure Resource Graph, and Application Insights Traces. Each source has its own specialized query editor. + +## Before you begin + +- Ensure you have [configured the Azure Monitor data source](ref:configure-azure-monitor). +- Verify your credentials have appropriate permissions for the resources you want to query. + +## Key concepts + +If you're new to Azure Monitor, here are some key terms used throughout this documentation: + +| Term | Description | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **KQL (Kusto Query Language)** | The query language used for Azure Monitor Logs and Azure Resource Graph. KQL uses a pipe-based syntax similar to Unix commands and is optimized for read-only data exploration. If you know SQL, the [SQL to Kusto cheat sheet](https://docs.microsoft.com/en-us/azure/data-explorer/kusto/query/sqlcheatsheet) can help you get started. | +| **Log Analytics workspace** | An Azure resource that collects and stores log data from your Azure resources, applications, and services. You query this data using KQL. | +| **Application Insights** | Azure's application performance monitoring (APM) service. It collects telemetry data like requests, exceptions, and traces from your applications. | +| **Metrics vs. Logs** | **Metrics** are lightweight numeric values collected at regular intervals (e.g., CPU percentage). **Logs** are detailed records of events with varying schemas (e.g., request logs, error messages). Metrics use a visual query builder; Logs require KQL. | + +## Choose a query editor mode + +The Azure Monitor data source's query editor has four modes depending on which Azure service you want to query: - **Metrics** for [Azure Monitor Metrics](#query-azure-monitor-metrics) - **Logs** for [Azure Monitor Logs](#query-azure-monitor-logs) -- [**Azure Resource Graph**](#query-azure-resource-graph) - **Traces** for [Application Insights Traces](#query-application-insights-traces) +- **Azure Resource Graph** for [Azure Resource Graph](#query-azure-resource-graph) ## Query Azure Monitor Metrics -Azure Monitor Metrics collects numeric data from [supported resources](https://docs.microsoft.com/en-us/azure/azure-monitor/monitor-reference), and you can query them to investigate your resources' health and usage and maximise availability and performance. +Azure Monitor Metrics collects numeric data from [supported resources](https://docs.microsoft.com/en-us/azure/azure-monitor/monitor-reference), and you can query them to investigate your resources' health and usage and maximize availability and performance. Monitor Metrics use a lightweight format that stores only numeric data in a specific structure and supports near real-time scenarios, making it useful for fast detection of issues. In contrast, Azure Monitor Logs can store a variety of data types, each with their own structure. -{{< figure src="/static/img/docs/azure-monitor/query-editor-metrics.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Logs Metrics sample query visualizing CPU percentage over time" >}} +{{< figure src="/static/img/docs/azure-monitor/query-editor-metrics.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor Metrics sample query visualizing CPU percentage over time" >}} ### Create a Metrics query @@ -85,7 +141,7 @@ Optionally, you can apply further aggregations or filter by dimensions. The available options change depending on what is relevant to the selected metric. -You can also augment queries by using [template variables](../template-variables/). +You can also augment queries by using [template variables](ref:template-variables). ### Format legend aliases @@ -109,7 +165,7 @@ For example: | `{{ dimensionname }}` | _(Legacy for backward compatibility)_ Replaced with the name of the first dimension. | | `{{ dimensionvalue }}` | _(Legacy for backward compatibility)_ Replaced with the value of the first dimension. | -### Filter using dimensions +### Filter with dimensions Some metrics also have dimensions, which associate additional metadata. Dimensions are represented as key-value pairs assigned to each value of a metric. @@ -121,7 +177,7 @@ For more information on multi-dimensional metrics, refer to the [Azure Monitor d ## Query Azure Monitor Logs -Azure Monitor Logs collects and organises log and performance data from [supported resources](https://docs.microsoft.com/en-us/azure/azure-monitor/monitor-reference), and makes many sources of data available to query together with the [Kusto Query Language (KQL)](https://docs.microsoft.com/en-us/azure/data-explorer/kusto/query/). +Azure Monitor Logs collects and organizes log and performance data from [supported resources](https://docs.microsoft.com/en-us/azure/azure-monitor/monitor-reference), and makes many sources of data available to query together with the [Kusto Query Language (KQL)](https://docs.microsoft.com/en-us/azure/data-explorer/kusto/query/). While Azure Monitor Metrics stores only simplified numerical data, Logs can store different data types, each with their own structure. You can also perform complex analysis of Logs data by using KQL. @@ -130,6 +186,32 @@ The Azure Monitor data source also supports querying of [Basic Logs](https://lea {{< figure src="/static/img/docs/azure-monitor/query-editor-logs.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor Logs sample query comparing successful requests to failed requests" >}} +### Logs query builder (public preview) + +{{< admonition type="note" >}} +The Logs query builder is a [public preview feature](/docs/release-life-cycle/). It may not be enabled in all Grafana environments. +{{< /admonition >}} + +The Logs query builder provides a visual interface for building Azure Monitor Logs queries without writing KQL. This is helpful if you're new to KQL or want to quickly build simple queries. + +**To enable the Logs query builder:** + +1. Enable the `azureMonitorLogsBuilderEditor` [feature toggle](ref:configure-grafana-feature-toggles) in your Grafana configuration. +1. Restart Grafana for the change to take effect. + +**To switch between Builder and Code modes:** + +When the feature is enabled, a **Builder / Code** toggle appears in the Logs query editor: + +- **Builder**: Use the visual interface to select tables, columns, filters, and aggregations. The builder generates the KQL query for you. +- **Code**: Write KQL queries directly. Use this mode for complex queries that require full KQL capabilities. + +New queries default to Builder mode. Existing queries that were created with raw KQL remain in Code mode. + +{{< admonition type="note" >}} +You can switch from Builder to Code mode at any time to view or edit the generated KQL. However, switching from Code to Builder mode may not preserve complex queries that can't be represented in the builder interface. +{{< /admonition >}} + ### Create a Logs query **To create a Logs query:** @@ -140,13 +222,13 @@ The Azure Monitor data source also supports querying of [Basic Logs](https://lea Alternatively, you can dynamically query all resources under a single resource group or subscription. {{< admonition type="note" >}} - If a timespan is specified in the query, the overlap of the timespan between the query and the dashboard will be used as the query timespan. See the [API documentation for + If a time span is specified in the query, the overlap between the query time span and the dashboard time range will be used. See the [API documentation for details.](https://learn.microsoft.com/en-us/rest/api/loganalytics/dataaccess/query/get?tabs=HTTP#uri-parameters) {{< /admonition >}} 1. Enter your KQL query. -You can also augment queries by using [template variables](../template-variables/). +You can also augment queries by using [template variables](ref:template-variables). **To create a Basic Logs query:** @@ -161,7 +243,7 @@ You can also augment queries by using [template variables](../template-variables {{< /admonition >}} 1. Enter your KQL query. -You can also augment queries by using [template variables](https://grafana.com/docs/grafana//datasources/azure-monitor/template-variables/). +You can also augment queries by using [template variables](ref:template-variables). ### Logs query examples @@ -174,24 +256,28 @@ The Azure documentation includes resources to help you learn KQL: - [Tutorial: Use Kusto queries in Azure Monitor](https://docs.microsoft.com/en-us/azure/data-explorer/kusto/query/tutorial?pivots=azuremonitor) - [SQL to Kusto cheat sheet](https://docs.microsoft.com/en-us/azure/data-explorer/kusto/query/sqlcheatsheet) -> **Time-range:** The time-range that will be used for the query can be modified via the time-range switch. Selecting `Query` will only make use of time-ranges specified within the query. -> Specifying `Dashboard` will only make use of the Grafana time-range. -> If there are no time-ranges specified within the query, the default Log Analytics time-range will apply. -> For more details on this change, refer to the [Azure Monitor Logs API documentation](https://learn.microsoft.com/en-us/rest/api/loganalytics/dataaccess/query/get?tabs=HTTP#uri-parameters). -> If the `Intersection` option was previously chosen it will be migrated by default to `Dashboard`. +{{< admonition type="note" >}} +**Time-range:** The time-range used for the query can be modified via the time-range switch: -This example query returns a virtual machine's CPU performance, averaged over 5ms time grains: +- Selecting **Query** uses only time-ranges specified within the query. +- Selecting **Dashboard** uses only the Grafana dashboard time-range. +- If no time-range is specified in the query, the default Log Analytics time-range applies. + +For more details, refer to the [Azure Monitor Logs API documentation](https://learn.microsoft.com/en-us/rest/api/loganalytics/dataaccess/query/get?tabs=HTTP#uri-parameters). If you previously used the `Intersection` option, it has been migrated to `Dashboard`. +{{< /admonition >}} + +This example query returns a virtual machine's CPU performance, averaged over 5-minute time grains: ```kusto Perf -# $__timeFilter is a special Grafana macro that filters the results to the time span of the dashboard +// $__timeFilter is a special Grafana macro that filters the results to the time span of the dashboard | where $__timeFilter(TimeGenerated) | where CounterName == "% Processor Time" | summarize avg(CounterValue) by bin(TimeGenerated, 5m), Computer | order by TimeGenerated asc ``` -Use time series queries for values that change over time, usually for graph visualisations such as the Time series panel. +Use time series queries for values that change over time, usually for graph visualizations such as the Time series panel. Each query should return at least a datetime column and numeric value column. The result must also be sorted in ascending order by the datetime column. @@ -357,21 +443,33 @@ Application Insights stores trace data in an underlying Log Analytics workspace This query type only supports Application Insights resources. {{< /admonition >}} -Running a query of this kind will return all trace data within the timespan specified by the panel/dashboard. +1. (Optional) Specify an **Operation ID** value to filter traces. +1. (Optional) Specify **event types** to filter by. +1. (Optional) Specify **event properties** to filter by. +1. (Optional) Change the **Result format** to switch between tabular format and trace format. -Optionally, you can apply further filtering or select a specific Operation ID to query. The result format can also be switched between a tabular format or the trace format which will return the data in a format that can be used with the Trace visualization. + {{< admonition type="note" >}} + Selecting the trace format filters events to only the `trace` type. Use this format with the Trace visualization. + {{< /admonition >}} -{{< admonition type="note" >}} -Selecting the trace format will filter events with the `trace` type. -{{< /admonition >}} +Running a query returns all trace data within the time span specified by the panel or dashboard time range. -1. Specify an Operation ID value. -1. Specify event types to filter by. -1. Specify event properties to filter by. +You can also augment queries by using [template variables](ref:template-variables). -You can also augment queries by using [template variables](../template-variables/). +## Use queries for alerting and recording rules -## Working with large Azure resource data sets +All Azure Monitor query types (Metrics, Logs, Azure Resource Graph, and Traces) can be used with Grafana Alerting and recording rules. + +For detailed information about creating alert rules, supported query types, authentication requirements, and examples, refer to [Azure Monitor alerting](ref:alerting-azure-monitor). + +## Work with large Azure resource datasets If a request exceeds the [maximum allowed value of records](https://docs.microsoft.com/en-us/azure/governance/resource-graph/concepts/work-with-data#paging-results), the result is paginated and only the first page of results are returned. You can use filters to reduce the amount of records returned under that value. + +## Next steps + +- [Use template variables](../template-variables/) to create dynamic, reusable dashboards +- [Add annotations](ref:annotations-azure-monitor) to overlay events on your graphs +- [Set up alerting](ref:alerting-azure-monitor) to create alert rules based on Azure Monitor data +- [Troubleshoot](ref:troubleshoot-azure-monitor) common query and configuration issues diff --git a/docs/sources/datasources/azure-monitor/template-variables/index.md b/docs/sources/datasources/azure-monitor/template-variables/index.md index 1db472a4251..3cedadef9b5 100644 --- a/docs/sources/datasources/azure-monitor/template-variables/index.md +++ b/docs/sources/datasources/azure-monitor/template-variables/index.md @@ -23,6 +23,7 @@ labels: menuTitle: Template variables title: Azure Monitor template variables weight: 400 +last_reviewed: 2025-12-04 refs: variables: - pattern: /docs/grafana/ @@ -34,6 +35,11 @@ refs: destination: /docs/grafana//dashboards/variables/add-template-variables/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana//dashboards/variables/add-template-variables/ + configure-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/configure/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/configure/ --- # Azure Monitor template variables @@ -42,58 +48,173 @@ Instead of hard-coding details such as resource group or resource name values in This helps you create more interactive, dynamic, and reusable dashboards. Grafana refers to such variables as template variables. -For an introduction to templating and template variables, refer to the [Templating](ref:variables) and [Add and manage variables](ref:add-template-variables) documentation. +For an introduction to templating and template variables, refer to the [Templating](ref:variables) and [Add and manage variables](ref:add-template-variables). -## Use query variables +## Before you begin -You can specify these Azure Monitor data source queries in the Variable edit view's **Query Type** field. +- Ensure you have [configured the Azure Monitor data source](ref:configure-azure-monitor). +- If you want template variables to auto-populate subscriptions, set a **Default Subscription** in the data source configuration. -| Name | Description | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| **Subscriptions** | Returns subscriptions. | -| **Resource Groups** | Returns resource groups for a specified subscription. Supports multi-value. | -| **Namespaces** | Returns metric namespaces for the specified subscription. If a resource group is provided, only the namespaces within that group are returned. | -| **Regions** | Returns regions for the specified subscription | -| **Resource Names** | Returns a list of resource names for a specified subscription, resource group and namespace. Supports multi-value. | -| **Metric Names** | Returns a list of metric names for a resource. | -| **Workspaces** | Returns a list of workspaces for the specified subscription. | -| **Logs** | Use a KQL query to return values. | -| **Custom Namespaces** | Returns metric namespaces for the specified resource. | -| **Custom Metric Names** | Returns a list of custom metric names for the specified resource. | +## Create a template variable + +To create a template variable for Azure Monitor: + +1. Open the dashboard where you want to add the variable. +1. Click **Dashboard settings** (gear icon) in the top navigation. +1. Select **Variables** in the left menu. +1. Click **Add variable**. +1. Enter a **Name** for your variable (e.g., `subscription`, `resourceGroup`, `resource`). +1. In the **Type** dropdown, select **Query**. +1. In the **Data source** dropdown, select your Azure Monitor data source. +1. In the **Query Type** dropdown, select the appropriate query type (see [Available query types](#available-query-types)). +1. Configure any additional fields required by the selected query type. +1. Click **Run query** to preview the variable values. +1. Configure display options such as **Multi-value** or **Include All option** as needed. +1. Click **Apply** to save the variable. + +## Available query types + +The Azure Monitor data source provides the following query types for template variables: + +| Query type | Description | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| **Subscriptions** | Returns a list of Azure subscriptions accessible to the configured credentials. | +| **Resource Groups** | Returns resource groups for a specified subscription. Supports multi-value selection. | +| **Namespaces** | Returns metric namespaces for the specified subscription. If a resource group is specified, returns only namespaces within that group. | +| **Regions** | Returns Azure regions available for the specified subscription. | +| **Resource Names** | Returns resource names for a specified subscription, resource group, and namespace. Supports multi-value selection. | +| **Metric Names** | Returns available metric names for a specified resource. | +| **Workspaces** | Returns Log Analytics workspaces for the specified subscription. | +| **Logs** | Executes a KQL query and returns the results as variable values. See [Create a Logs variable](#create-a-logs-variable). | +| **Custom Namespaces** | Returns custom metric namespaces for a specified resource. | +| **Custom Metric Names** | Returns custom metric names for a specified resource. | {{< admonition type="note" >}} -Custom metrics cannot be emitted against a subscription or resource group. Select resources only when you need to retrieve custom metric namespaces or custom metric names associated with a specific resource. +Custom metrics cannot be emitted against a subscription or resource group. Select specific resources when retrieving custom metric namespaces or custom metric names. {{< /admonition >}} -You can use any Log Analytics Kusto Query Language (KQL) query that returns a single list of values in the `Query` field. -For example: +## Create cascading variables -| Query | List of values returned | -| ----------------------------------------------------------------------------------------- | --------------------------------------- | -| `workspace("myWorkspace").Heartbeat \| distinct Computer` | Virtual machines | -| `workspace("$workspace").Heartbeat \| distinct Computer` | Virtual machines with template variable | -| `workspace("$workspace").Perf \| distinct ObjectName` | Objects from the Perf table | -| `workspace("$workspace").Perf \| where ObjectName == "$object"` `\| distinct CounterName` | Metric names from the Perf table | +Cascading variables (also called dependent or chained variables) allow you to create dropdown menus that filter based on previous selections. This is useful for drilling down from subscription to resource group to specific resource. -### Query variable example +### Example: Subscription → Resource Group → Resource Name -This time series query uses query variables: +**Step 1: Create a Subscription variable** + +1. Create a variable named `subscription`. +1. Set **Query Type** to **Subscriptions**. + +**Step 2: Create a Resource Group variable** + +1. Create a variable named `resourceGroup`. +1. Set **Query Type** to **Resource Groups**. +1. In the **Subscription** field, select `$subscription`. + +**Step 3: Create a Resource Name variable** + +1. Create a variable named `resource`. +1. Set **Query Type** to **Resource Names**. +1. In the **Subscription** field, select `$subscription`. +1. In the **Resource Group** field, select `$resourceGroup`. +1. Select the appropriate **Namespace** for your resources (e.g., `Microsoft.Compute/virtualMachines`). + +Now when you change the subscription, the resource group dropdown updates automatically, and when you change the resource group, the resource name dropdown updates. + +## Create a Logs variable + +The **Logs** query type lets you use a KQL query to populate variable values. The query must return a single column of values. + +**To create a Logs variable:** + +1. Create a new variable with **Query Type** set to **Logs**. +1. Select a **Resource** (Log Analytics workspace or Application Insights resource). +1. Enter a KQL query that returns a single column. + +### Logs variable query examples + +| Query | Returns | +| ----------------------------------------- | ------------------------------------- | +| `Heartbeat \| distinct Computer` | List of virtual machine names | +| `Perf \| distinct ObjectName` | List of performance object names | +| `AzureActivity \| distinct ResourceGroup` | List of resource groups with activity | +| `AppRequests \| distinct Name` | List of application request names | + +You can reference other variables in your Logs query: + +```kusto +workspace("$workspace").Heartbeat | distinct Computer +``` + +```kusto +workspace("$workspace").Perf +| where ObjectName == "$object" +| distinct CounterName +``` + +## Variable refresh options + +Control when your variables refresh by setting the **Refresh** option: + +| Option | Behavior | +| ------------------------ | ----------------------------------------------------------------------------------------- | +| **On dashboard load** | Variables refresh each time the dashboard loads. Best for data that changes infrequently. | +| **On time range change** | Variables refresh when the dashboard time range changes. Use for time-sensitive queries. | + +For dashboards with many variables or complex queries, use **On dashboard load** to improve performance. + +## Use variables in queries + +After you create template variables, you can use them in your Azure Monitor queries by referencing them with the `$` prefix. + +### Metrics query example + +In a Metrics query, select your variables in the resource picker fields: + +- **Subscription**: `$subscription` +- **Resource Group**: `$resourceGroup` +- **Resource Name**: `$resource` + +### Logs query example + +Reference variables directly in your KQL queries: ```kusto Perf | where ObjectName == "$object" and CounterName == "$metric" | where TimeGenerated >= $__timeFrom() and TimeGenerated <= $__timeTo() -| where $__contains(Computer, $computer) +| where $__contains(Computer, $computer) | summarize avg(CounterValue) by bin(TimeGenerated, $__interval), Computer | order by TimeGenerated asc ``` -### Multi-value variables +## Multi-value variables -It is possible to select multiple values for **Resource Groups** and **Resource Names** and use a single metrics query pointing to those values as long as they: +You can enable **Multi-value** selection for **Resource Groups** and **Resource Names** variables. When using multi-value variables in a Metrics query, all selected resources must: -- Belong to the same subscription. -- Are in the same region. -- Are of the same type (namespace). +- Belong to the same subscription +- Be in the same Azure region +- Be of the same resource type (namespace) -Also, note that if a template variable pointing to multiple resource groups or names is used in another template variable as a parameter (e.g. to retrieve metric names), only the first value will be used. This means that the combination of the first resource group and name selected should be valid. +{{< admonition type="note" >}} +When a multi-value variable is used as a parameter in another variable query (for example, to retrieve metric names), only the first selected value is used. Ensure the first resource group and resource name combination is valid. +{{< /admonition >}} + +## Troubleshoot template variables + +If you encounter issues with template variables, try the following solutions. + +### Variable returns no values + +- Verify the Azure Monitor data source is configured correctly and can connect to Azure. +- Check that the credentials have appropriate permissions to list the requested resources. +- For cascading variables, ensure parent variables have valid selections. + +### Variable values are outdated + +- Check the **Refresh** setting and adjust if needed. +- Click the refresh icon next to the variable dropdown to manually refresh. + +### Multi-value selection not working in queries + +- Ensure the resources meet the requirements (same subscription, region, and type). +- For Logs queries, use the `$__contains()` macro to handle multi-value variables properly. diff --git a/docs/sources/datasources/azure-monitor/troubleshooting/index.md b/docs/sources/datasources/azure-monitor/troubleshooting/index.md new file mode 100644 index 00000000000..b2d5a9efc32 --- /dev/null +++ b/docs/sources/datasources/azure-monitor/troubleshooting/index.md @@ -0,0 +1,320 @@ +--- +aliases: + - ../../data-sources/azure-monitor/troubleshooting/ +description: Troubleshooting guide for the Azure Monitor data source in Grafana +keywords: + - grafana + - azure + - monitor + - troubleshooting + - errors + - authentication + - query +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Troubleshoot +title: Troubleshoot Azure Monitor data source issues +weight: 500 +last_reviewed: 2025-12-04 +refs: + configure-azure-monitor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/configure/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/configure/ + template-variables: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/template-variables/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/template-variables/ + query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/azure-monitor/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/azure-monitor/query-editor/ +--- + +# Troubleshoot Azure Monitor data source issues + +This document provides solutions to common issues you may encounter when configuring or using the Azure Monitor data source. + +## Configuration and authentication errors + +These errors typically occur when setting up the data source or when authentication credentials are invalid. + +### "Authorization failed" or "Access denied" + +**Symptoms:** + +- Save & test fails with "Authorization failed" +- Queries return "Access denied" errors +- Subscriptions don't load when clicking **Load Subscriptions** + +**Possible causes and solutions:** + +| Cause | Solution | +| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| App registration doesn't have required permissions | Assign the `Reader` role to the app registration on the subscription or resource group you want to monitor. Refer to the [Azure documentation for role assignments](https://docs.microsoft.com/en-us/azure/role-based-access-control/role-assignments-portal?tabs=current). | +| Incorrect tenant ID, client ID, or client secret | Verify the credentials in the Azure Portal under **App registrations** > your app > **Overview** (for IDs) and **Certificates & secrets** (for secret). | +| Client secret has expired | Create a new client secret in Azure and update the data source configuration. | +| Managed Identity not enabled on the Azure resource | For VMs, enable managed identity in the Azure Portal under **Identity**. For App Service, enable it under **Identity** in the app settings. | +| Managed Identity not assigned the Reader role | Assign the `Reader` role to the managed identity on the target subscription or resources. | + +### "Invalid client secret" or "Client secret not found" + +**Symptoms:** + +- Authentication fails immediately after configuration +- Error message references invalid credentials + +**Solutions:** + +1. Ensure you copied the client secret **value**, not the secret ID. In Azure Portal under **Certificates & secrets**, the secret value is only shown once when created. The secret ID is a different identifier and won't work for authentication. +2. Verify the client secret was copied correctly (no extra spaces or truncation). +3. Check if the secret has expired in Azure Portal under **App registrations** > your app > **Certificates & secrets**. +4. Create a new secret and update the data source configuration. + +### "Tenant not found" or "Invalid tenant ID" + +**Symptoms:** + +- Data source test fails with tenant-related errors +- Unable to authenticate + +**Solutions:** + +1. Verify the Directory (tenant) ID in Azure Portal under **Microsoft Entra ID** > **Overview**. +2. Ensure you're using the correct Azure cloud setting (Azure, Azure Government, or Azure China). +3. Check that the tenant ID is a valid GUID format. + +### Managed Identity not working + +**Symptoms:** + +- Managed Identity option is available but authentication fails +- Error: "Managed identity authentication is not available" + +**Solutions:** + +1. Verify `managed_identity_enabled = true` is set in the Grafana server configuration under `[azure]`. +2. Confirm the Azure resource hosting Grafana has managed identity enabled. +3. For user-assigned managed identity, ensure `managed_identity_client_id` is set correctly. +4. Verify the managed identity has the `Reader` role on the target resources. +5. Restart Grafana after changing server configuration. + +### Workload Identity not working + +**Symptoms:** + +- Workload Identity authentication fails in Kubernetes/AKS environment +- Token file errors + +**Solutions:** + +1. Verify `workload_identity_enabled = true` is set in the Grafana server configuration. +2. Check that the service account is correctly annotated for workload identity. +3. Verify the federated credential is configured in Azure. +4. Ensure the token path is accessible to the Grafana pod. +5. Check the workload identity webhook is running in the cluster. + +## Query errors + +These errors occur when executing queries against Azure Monitor services. + +### "No data" or empty results + +**Symptoms:** + +- Query executes without error but returns no data +- Charts show "No data" message + +**Possible causes and solutions:** + +| Cause | Solution | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| Time range doesn't contain data | Expand the dashboard time range or verify data exists in Azure Portal. | +| Wrong resource selected | Verify you've selected the correct subscription, resource group, and resource. | +| Metric not available for resource | Not all metrics are available for all resources. Check available metrics in Azure Portal under the resource's **Metrics** blade. | +| Metric has no values | Some metrics only populate under certain conditions (e.g., error counts when errors occur). | +| Permissions issue | Verify the identity has read access to the specific resource. | + +### "Bad request" or "Invalid query" + +**Symptoms:** + +- Query fails with 400 error +- Error message indicates query syntax issues + +**Solutions for Logs queries:** + +1. Validate your KQL syntax in the Azure Portal Log Analytics query editor. +2. Check for typos in table names or column names. +3. Ensure referenced tables exist in the selected workspace. +4. Verify the time range is valid (not in the future, not too far in the past for data retention). + +**Solutions for Metrics queries:** + +1. Verify the metric name is valid for the selected resource type. +2. Check that dimension filters use valid dimension names and values. +3. Ensure the aggregation type is supported for the selected metric. + +### "Resource not found" + +**Symptoms:** + +- Query fails with 404 error +- Resource picker shows resources that can't be queried + +**Solutions:** + +1. Verify the resource still exists in Azure (it may have been deleted or moved). +2. Check that the subscription is correct. +3. Refresh the resource picker by re-selecting the subscription. +4. Verify the identity has access to the resource's resource group. + +### Logs query timeout + +**Symptoms:** + +- Query runs for a long time then fails +- Error mentions timeout or query limits + +**Solutions:** + +1. Narrow the time range to reduce data volume. +2. Add filters to reduce the result set. +3. Use `summarize` to aggregate data instead of returning raw rows. +4. Consider using Basic Logs for large datasets (if enabled). +5. Break complex queries into smaller parts. + +### "Metrics not available" for a resource + +**Symptoms:** + +- Resource appears in picker but no metrics are listed +- Metric dropdown is empty + +**Solutions:** + +1. Verify the resource type supports Azure Monitor metrics. +2. Check if the resource is in a region that supports metrics. +3. Some resources require diagnostic settings to emit metrics—configure these in Azure Portal. +4. Try selecting a different namespace for the resource. + +## Azure Resource Graph errors + +These errors are specific to Azure Resource Graph (ARG) queries. + +### "Query execution failed" + +**Symptoms:** + +- ARG query fails with execution errors +- Results don't match expected resources + +**Solutions:** + +1. Validate query syntax in Azure Portal Resource Graph Explorer. +2. Check that you have access to the subscriptions being queried. +3. Verify table names are correct (e.g., `Resources`, `ResourceContainers`). +4. Some ARG features require specific permissions, check [ARG documentation](https://docs.microsoft.com/en-us/azure/governance/resource-graph/). + +### Query returns incomplete results + +**Symptoms:** + +- Not all expected resources appear in results +- Results seem truncated + +**Solutions:** + +1. ARG queries are paginated. The data source handles pagination automatically, but very large result sets may be limited. +2. Add filters to reduce result set size. +3. Verify you have access to all subscriptions containing the resources. + +## Application Insights Traces errors + +These errors are specific to the Traces query type. + +### "No traces found" + +**Symptoms:** + +- Trace query returns empty results +- Operation ID search finds nothing + +**Solutions:** + +1. Verify the Application Insights resource is collecting trace data. +2. Check that the time range includes when the traces were generated. +3. Ensure the Operation ID is correct (copy directly from another trace or log). +4. Verify the identity has access to the Application Insights resource. + +## Template variable errors + +For detailed troubleshooting of template variables, refer to the [template variables troubleshooting section](ref:template-variables). + +### Variables return no values + +**Solutions:** + +1. Verify the data source connection is working (test it in the data source settings). +2. Check that parent variables (for cascading variables) have valid selections. +3. Verify the identity has permissions to list the requested resources. +4. For Logs variables, ensure the KQL query returns a single column. + +### Variables are slow to load + +**Solutions:** + +1. Set variable refresh to **On dashboard load** instead of **On time range change**. +2. Reduce the scope of variable queries (e.g., filter by resource group instead of entire subscription). +3. For Logs variables, optimize the KQL query to return results faster. + +## Connection and network errors + +These errors indicate problems with network connectivity between Grafana and Azure services. + +### "Connection refused" or timeout errors + +**Symptoms:** + +- Data source test fails with network errors +- Queries timeout without returning results + +**Solutions:** + +1. Verify network connectivity from Grafana to Azure endpoints. +2. Check firewall rules allow outbound HTTPS (port 443) to Azure services. +3. For private networks, ensure Private Link or VPN is configured correctly. +4. For Grafana Cloud, configure [Private Data Source Connect](ref:configure-azure-monitor) if accessing private resources. + +### SSL/TLS certificate errors + +**Symptoms:** + +- Certificate validation failures +- SSL handshake errors + +**Solutions:** + +1. Ensure the system time is correct (certificate validation fails with incorrect time). +2. Verify corporate proxy isn't intercepting HTTPS traffic. +3. Check that required CA certificates are installed on the Grafana server. + +## Get additional help + +If you've tried the solutions above and still encounter issues: + +1. Check the [Grafana community forums](https://community.grafana.com/) for similar issues. +1. Review the [Azure Monitor data source GitHub issues](https://github.com/grafana/grafana/issues) for known bugs. +1. Enable debug logging in Grafana to capture detailed error information. +1. Contact Grafana Support if you're an Enterprise, Cloud Pro or Cloud Contracted user. +1. When reporting issues, include: + - Grafana version + - Error messages (redact sensitive information) + - Steps to reproduce + - Relevant configuration (redact credentials) From cfc5d96c34c08b66b5c381dc6de6820a8a82d7be Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:39:43 -0700 Subject: [PATCH 135/139] Dashboard Schema V2: Fix panel query tab (#115276) fix panel query tab for v2 schema --- .../PanelDataQueriesTab.test.tsx | 102 +++++++- .../PanelDataPane/PanelDataQueriesTab.tsx | 11 + .../panel-edit/testfiles/testDashboard.ts | 223 ++++++++++++++++++ 3 files changed, 335 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx index 40da4a158a8..27b924b5936 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx @@ -25,10 +25,17 @@ import { DashboardDataDTO } from 'app/types/dashboard'; import { PanelInspectDrawer } from '../../inspect/PanelInspectDrawer'; import { PanelTimeRange, PanelTimeRangeState } from '../../scene/panel-timerange/PanelTimeRange'; +import { DashboardLayoutManager } from '../../scene/types/DashboardLayoutManager'; +import { transformSaveModelSchemaV2ToScene } from '../../serialization/transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene'; import { findVizPanelByKey } from '../../utils/utils'; import { buildPanelEditScene } from '../PanelEditor'; -import { testDashboard, panelWithTransformations, panelWithQueriesOnly } from '../testfiles/testDashboard'; +import { + testDashboard, + panelWithTransformations, + panelWithQueriesOnly, + testDashboardV2, +} from '../testfiles/testDashboard'; import { PanelDataQueriesTab, PanelDataQueriesTabRendered } from './PanelDataQueriesTab'; @@ -824,6 +831,78 @@ describe('PanelDataQueriesTab', () => { expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata'); }); }); + + describe('V2 schema behavior - panel datasource undefined but queries have datasource', () => { + it('should load datasource from first query for V2 panel with prometheus datasource', async () => { + // panel-1 has a query with prometheus datasource + const { queriesTab } = await setupV2Scene('panel-1'); + + // V2 panels have undefined panel-level datasource for non-mixed panels + expect(queriesTab.queryRunner.state.datasource).toBeUndefined(); + + // But the query has its own datasource + expect(queriesTab.queryRunner.state.queries[0].datasource).toEqual({ + type: 'grafana-prometheus-datasource', + uid: 'gdev-prometheus', + }); + + // Should load the datasource from the first query + expect(queriesTab.state.datasource?.uid).toBe('gdev-prometheus'); + expect(queriesTab.state.dsSettings?.uid).toBe('gdev-prometheus'); + }); + + it('should load datasource from first query for V2 panel with testdata datasource', async () => { + // panel-2 has a query with testdata datasource + const { queriesTab } = await setupV2Scene('panel-2'); + + // V2 panels have undefined panel-level datasource for non-mixed panels + expect(queriesTab.queryRunner.state.datasource).toBeUndefined(); + + // But the query has its own datasource + expect(queriesTab.queryRunner.state.queries[0].datasource).toEqual({ + type: 'grafana-testdata-datasource', + uid: 'gdev-testdata', + }); + + // Should load the datasource from the first query + expect(queriesTab.state.datasource?.uid).toBe('gdev-testdata'); + expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata'); + }); + + it('should fall back to last used datasource when V2 query has no explicit datasource', async () => { + store.exists.mockReturnValue(true); + store.getObject.mockImplementation((key: string, def: unknown) => { + if (key === PANEL_EDIT_LAST_USED_DATASOURCE) { + return { + dashboardUid: 'v2-dashboard-uid', + datasourceUid: 'gdev-testdata', + }; + } + return def; + }); + + // panel-3 has a query with NO explicit datasource (datasource.name is undefined) + const { queriesTab } = await setupV2Scene('panel-3'); + + // V2 panel with no explicit datasource on query should fall back to last used + expect(queriesTab.state.datasource?.uid).toBe('gdev-testdata'); + expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata'); + }); + + it('should use panel-level datasource when available (V1 behavior preserved)', async () => { + const { queriesTab } = await setupScene('panel-1'); + + // V1 panels have panel-level datasource set + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-testdata', + type: 'grafana-testdata-datasource', + }); + + // Should use the panel-level datasource + expect(queriesTab.state.datasource?.uid).toBe('gdev-testdata'); + expect(queriesTab.state.dsSettings?.uid).toBe('gdev-testdata'); + }); + }); }); }); @@ -844,3 +923,24 @@ async function setupScene(panelId: string) { return { panel, scene: dashboard, queriesTab }; } + +// Setup V2 scene - uses transformSaveModelSchemaV2ToScene +async function setupV2Scene(panelKey: string) { + const dashboard = transformSaveModelSchemaV2ToScene(testDashboardV2); + + const vizPanels = (dashboard.state.body as DashboardLayoutManager).getVizPanels(); + const panel = vizPanels.find((p) => p.state.key === panelKey)!; + + const panelEditor = buildPanelEditScene(panel); + dashboard.setState({ editPanel: panelEditor }); + + deactivators.push(dashboard.activate()); + deactivators.push(panelEditor.activate()); + + const queriesTab = panelEditor.state.dataPane!.state.tabs[0] as PanelDataQueriesTab; + deactivators.push(queriesTab.activate()); + + await Promise.resolve(); + + return { panel, scene: dashboard, queriesTab }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx index d70fbf9c9c4..8bfa97461d3 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx @@ -86,6 +86,17 @@ export class PanelDataQueriesTab extends SceneObjectBase = { + kind: 'DashboardWithAccessInfo', + metadata: { + name: 'v2-dashboard-uid', + namespace: 'default', + labels: {}, + generation: 1, + resourceVersion: '1', + creationTimestamp: new Date().toISOString(), + }, + spec: { + title: 'V2 Test Dashboard', + description: 'Test dashboard for V2 schema', + tags: [], + cursorSync: 'Off', + liveNow: false, + editable: true, + preload: false, + links: [], + variables: [], + annotations: [], + timeSettings: { + from: 'now-6h', + to: 'now', + autoRefresh: '', + autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], + fiscalYearStartMonth: 0, + hideTimepicker: false, + timezone: '', + weekStart: undefined, + quickRanges: [], + }, + elements: { + 'panel-1': { + kind: 'Panel', + spec: { + id: 1, + title: 'Panel with Prometheus datasource', + description: '', + links: [], + data: { + kind: 'QueryGroup', + spec: { + queries: [ + { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + query: { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'grafana-prometheus-datasource', + datasource: { + name: 'gdev-prometheus', + }, + spec: { + expr: 'up', + }, + }, + }, + }, + ], + transformations: [], + queryOptions: {}, + }, + }, + vizConfig: { + kind: 'VizConfig', + group: 'timeseries', + version: '1.0.0', + spec: { + options: {}, + fieldConfig: { + defaults: {}, + overrides: [], + }, + }, + }, + }, + }, + 'panel-2': { + kind: 'Panel', + spec: { + id: 2, + title: 'Panel with TestData datasource', + description: '', + links: [], + data: { + kind: 'QueryGroup', + spec: { + queries: [ + { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + query: { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'grafana-testdata-datasource', + datasource: { + name: 'gdev-testdata', + }, + spec: { + scenarioId: 'random_walk', + }, + }, + }, + }, + ], + transformations: [], + queryOptions: {}, + }, + }, + vizConfig: { + kind: 'VizConfig', + group: 'timeseries', + version: '1.0.0', + spec: { + options: {}, + fieldConfig: { + defaults: {}, + overrides: [], + }, + }, + }, + }, + }, + 'panel-3': { + kind: 'Panel', + spec: { + id: 3, + title: 'Panel with no datasource on query', + description: '', + links: [], + data: { + kind: 'QueryGroup', + spec: { + queries: [ + { + kind: 'PanelQuery', + spec: { + refId: 'A', + hidden: false, + query: { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'grafana-testdata-datasource', + // No datasource.name - simulates panel with no explicit datasource + spec: {}, + }, + }, + }, + ], + transformations: [], + queryOptions: {}, + }, + }, + vizConfig: { + kind: 'VizConfig', + group: 'timeseries', + version: '1.0.0', + spec: { + options: {}, + fieldConfig: { + defaults: {}, + overrides: [], + }, + }, + }, + }, + }, + }, + layout: { + kind: 'GridLayout', + spec: { + items: [ + { + kind: 'GridLayoutItem', + spec: { + x: 0, + y: 0, + width: 12, + height: 8, + element: { kind: 'ElementReference', name: 'panel-1' }, + }, + }, + { + kind: 'GridLayoutItem', + spec: { + x: 12, + y: 0, + width: 12, + height: 8, + element: { kind: 'ElementReference', name: 'panel-2' }, + }, + }, + { + kind: 'GridLayoutItem', + spec: { + x: 0, + y: 8, + width: 12, + height: 8, + element: { kind: 'ElementReference', name: 'panel-3' }, + }, + }, + ], + }, + }, + }, + access: { + url: '/d/v2-dashboard-uid', + slug: 'v2-test-dashboard', + }, + apiVersion: 'v2', +}; From 359d097154a4334e01680247772a6cb072e56e7d Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 12 Dec 2025 16:57:47 -0500 Subject: [PATCH 136/139] Table: Remove hardcoded assumption of __nestedFrames field name (#115117) * Table: Remove hardcoded assumption of __nestedFrames field name * E2E for nested tables * Apply suggestion from @fastfrwrd --- .../panels-suite/table-kitchenSink.spec.ts | 27 +++++++++++++++++++ eslint-suppressions.json | 5 ---- .../src/selectors/components.ts | 3 +++ .../src/components/Table/TableNG/TableNG.tsx | 20 +++++++++++--- .../Table/TableNG/components/RowExpander.tsx | 11 +++++++- .../src/components/Table/TableNG/types.ts | 3 +-- .../src/components/Table/TableNG/utils.ts | 22 +++++++++------ .../datasource/tempo/resultTransformer.ts | 2 +- 8 files changed, 73 insertions(+), 20 deletions(-) diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts index a14085aa753..6dddba81820 100644 --- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts +++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @@ -343,6 +343,33 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] // TODO -- saving for another day. }); + test('Tests nested table expansion', async ({ gotoDashboardPage, selectors, page }) => { + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '4' }), + }); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Nested tables')) + ).toBeVisible(); + + await waitForTableLoad(page); + + await expect(page.locator('[role="row"]')).toHaveCount(3); // header + 2 rows + + const firstRowExpander = dashboardPage + .getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.RowExpander) + .first(); + + await firstRowExpander.click(); + await expect(page.locator('[role="row"]')).not.toHaveCount(3); // more rows are present now, it is dynamic tho. + + // TODO: test sorting + + await firstRowExpander.click(); + await expect(page.locator('[role="row"]')).toHaveCount(3); // back to original state + }); + test('Tests tooltip interactions', async ({ gotoDashboardPage, selectors }) => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID, diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 94a0b4c6b3c..2fcb6da5ee3 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -804,11 +804,6 @@ "count": 2 } }, - "packages/grafana-ui/src/components/Table/TableNG/utils.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "packages/grafana-ui/src/components/Table/TableRT/Filter.tsx": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index f1f2ce08642..0755477f93b 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -499,6 +499,9 @@ export const versionedComponents = { }, }, TableNG: { + RowExpander: { + '12.4.0': 'data-testid tableng row expander', + }, Filters: { HeaderButton: { '12.1.0': 'data-testid tableng header filter', diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 0a7c69edbf3..f17a62b92cf 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -154,8 +154,18 @@ export function TableNG(props: TableNGProps) { const resizeHandler = useColumnResize(onColumnResize); - const rows = useMemo(() => frameToRecords(data), [data]); const hasNestedFrames = useMemo(() => getIsNestedTable(data.fields), [data]); + const nestedFramesFieldName = useMemo(() => { + if (!hasNestedFrames) { + return; + } + const firstNestedField = data.fields.find((f) => f.type === FieldType.nestedFrames); + if (!firstNestedField) { + return; + } + return getDisplayName(firstNestedField); + }, [data, hasNestedFrames]); + const rows = useMemo(() => frameToRecords(data, nestedFramesFieldName), [data, nestedFramesFieldName]); const getTextColorForBackground = useMemo(() => memoize(_getTextColorForBackground, { maxSize: 1000 }), []); const { @@ -374,7 +384,11 @@ export function TableNG(props: TableNGProps) { return null; } - const expandedRecords = applySort(frameToRecords(nestedData), nestedData.fields, sortColumns); + const expandedRecords = applySort( + frameToRecords(nestedData, nestedFramesFieldName), + nestedData.fields, + sortColumns + ); if (!expandedRecords.length) { return (
@@ -398,7 +412,7 @@ export function TableNG(props: TableNGProps) { width: COLUMN.EXPANDER_WIDTH, minWidth: COLUMN.EXPANDER_WIDTH, }), - [commonDataGridProps, data.fields.length, expandedRows, sortColumns, styles] + [commonDataGridProps, data.fields.length, expandedRows, sortColumns, styles, nestedFramesFieldName] ); const fromFields = useCallback( diff --git a/packages/grafana-ui/src/components/Table/TableNG/components/RowExpander.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/RowExpander.tsx index d1f64824ef3..ab2ee41538d 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/components/RowExpander.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/components/RowExpander.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { useStyles2 } from '../../../../themes/ThemeContext'; @@ -16,13 +17,21 @@ export function RowExpander({ onCellExpand, isExpanded }: RowExpanderNGProps) { } } return ( -
+
diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 3a641de5ac6..ddfaf189f34 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -79,7 +79,6 @@ export interface TableRow { // Nested table properties data?: DataFrame; - __nestedFrames?: DataFrame[]; __expanded?: boolean; // For row expansion state // Generic typing for column values @@ -262,7 +261,7 @@ export type TableCellStyles = (theme: GrafanaTheme2, options: TableCellStyleOpti export type Comparator = (a: TableCellValue, b: TableCellValue) => number; // Type for converting a DataFrame into an array of TableRows -export type FrameToRowsConverter = (frame: DataFrame) => TableRow[]; +export type FrameToRowsConverter = (frame: DataFrame, nestedFramesFieldName?: string) => TableRow[]; // Type for mapping column names to their field types export type ColumnTypes = Record; diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 0226f8b6463..b960d8c08c5 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -675,10 +675,12 @@ export function applySort( /** * @internal */ -export const frameToRecords = (frame: DataFrame): TableRow[] => { +export const frameToRecords = (frame: DataFrame, nestedFramesFieldName?: string): TableRow[] => { const fnBody = ` const rows = Array(frame.length); const values = frame.fields.map(f => f.values); + const hasNestedFrames = '${nestedFramesFieldName ?? ''}'.length > 0; + let rowCount = 0; for (let i = 0; i < frame.length; i++) { rows[rowCount] = { @@ -686,11 +688,14 @@ export const frameToRecords = (frame: DataFrame): TableRow[] => { __index: i, ${frame.fields.map((field, fieldIdx) => `${JSON.stringify(getDisplayName(field))}: values[${fieldIdx}][i]`).join(',')} }; - rowCount += 1; - if (rows[rowCount-1]['__nestedFrames']){ - const childFrame = rows[rowCount-1]['__nestedFrames']; - rows[rowCount] = {__depth: 1, __index: i, data: childFrame[0]} - rowCount += 1; + rowCount++; + + if (hasNestedFrames) { + const childFrame = rows[rowCount-1][${JSON.stringify(nestedFramesFieldName)}]; + if (childFrame){ + rows[rowCount] = {__depth: 1, __index: i, data: childFrame[0]} + rowCount++; + } } } return rows; @@ -698,8 +703,9 @@ export const frameToRecords = (frame: DataFrame): TableRow[] => { // Creates a function that converts a DataFrame into an array of TableRows // Uses new Function() for performance as it's faster than creating rows using loops - const convert = new Function('frame', fnBody) as FrameToRowsConverter; - return convert(frame); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const convert = new Function('frame', 'nestedFramesFieldName', fnBody) as FrameToRowsConverter; + return convert(frame, nestedFramesFieldName); }; /* ----------------------------- Data grid comparator ---------------------------- */ diff --git a/public/app/plugins/datasource/tempo/resultTransformer.ts b/public/app/plugins/datasource/tempo/resultTransformer.ts index 01934cc97bf..87000026769 100644 --- a/public/app/plugins/datasource/tempo/resultTransformer.ts +++ b/public/app/plugins/datasource/tempo/resultTransformer.ts @@ -911,7 +911,7 @@ const traceSubFrame = ( subFrame.add(transformSpanToTraceData(span, spanSet, trace)); }); - return subFrame; + return toDataFrame(subFrame); }; interface TraceTableData { From 12b38d1b7a38336dab26164d0ec5736069c3fd08 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Fri, 12 Dec 2025 23:14:48 +0100 Subject: [PATCH 137/139] Dashboards: Never allow rows with hidden header to be collapsed (#115284) Never allow rows with hidden header to be collapsed --- .../dashboard-scene/scene/layout-rows/RowItemRenderer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx index 80c16b2f2a1..8d74ff46e3c 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx @@ -18,7 +18,8 @@ import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid'; import { RowItem } from './RowItem'; export function RowItemRenderer({ model }: SceneComponentProps) { - const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState(); + const { layout, collapse, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState(); + const isCollapsed = collapse && !isHeaderHidden; // never allow a row without a header to be collapsed const isClone = isRepeatCloneOrChildOf(model); const { isEditing } = useDashboardState(model); const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden( From 1bcccd5e6167b8914765d37d81ed8645d9c9ee9a Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:26:28 -0500 Subject: [PATCH 138/139] Docs: Update export as JSON task (#115288) --- .../dashboards/share-dashboards-panels/_index.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md b/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md index a2867759119..5fcd2344fe2 100644 --- a/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md +++ b/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md @@ -223,17 +223,25 @@ To export a dashboard in its current state as a PDF, follow these steps: 1. Click the **X** at the top-right corner to close the share drawer. -### Export a dashboard as JSON +### Export a dashboard as code Export a Grafana JSON file that contains everything you need, including layout, variables, styles, data sources, queries, and so on, so that you can later import the dashboard. To export a JSON file, follow these steps: 1. Click **Dashboards** in the main menu. 1. Open the dashboard you want to export. -1. Click the **Export** drop-down list in the top-right corner and select **Export as JSON**. +1. Click the **Export** drop-down list in the top-right corner and select **Export as code**. - The **Export dashboard JSON** drawer opens. + The **Export dashboard** drawer opens. + +1. Select the dashboard JSON model that you to export: + - **Classic** - Export dashboards created using the [current dashboard schema](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/view-dashboard-json-model/). + - **V1 Resource** - Export dashboards created using the [current dashboard schema](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/view-dashboard-json-model/) wrapped in the `spec` property of the [V1 Kubernetes-style resource](https://play.grafana.org/swagger?api=dashboard.grafana.app-v2alpha1). Choose between **JSON** and **YAML** format. + - **V2 Resource** - Export dashboards created using the [V2 Resource schema](https://play.grafana.org/swagger?api=dashboard.grafana.app-v2beta1). Choose between **JSON** and **YAML** format. + +1. Do one of the following: + - Toggle the **Export for sharing externally** switch to generate the JSON with a different data source UID. + - Toggle the **Remove deployment details** switch to make the dashboard externally shareable. -1. Toggle the **Export the dashboard to use in another instance** switch to generate the JSON with a different data source UID. 1. Click **Download file** or **Copy to clipboard**. 1. Click the **X** at the top-right corner to close the share drawer. From c5345498b16c0082c3d78d71b6c6547998667d49 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Sat, 13 Dec 2025 00:42:48 +0000 Subject: [PATCH 139/139] I18n: Download translations from Crowdin (#115291) 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 | 21 +++++++++++++++++---- public/locales/de-DE/grafana.json | 21 +++++++++++++++++---- public/locales/es-ES/grafana.json | 21 +++++++++++++++++---- public/locales/fr-FR/grafana.json | 21 +++++++++++++++++---- public/locales/hu-HU/grafana.json | 21 +++++++++++++++++---- public/locales/id-ID/grafana.json | 21 +++++++++++++++++---- public/locales/it-IT/grafana.json | 21 +++++++++++++++++---- public/locales/ja-JP/grafana.json | 21 +++++++++++++++++---- public/locales/ko-KR/grafana.json | 21 +++++++++++++++++---- public/locales/nl-NL/grafana.json | 21 +++++++++++++++++---- public/locales/pl-PL/grafana.json | 21 +++++++++++++++++---- public/locales/pt-BR/grafana.json | 21 +++++++++++++++++---- public/locales/pt-PT/grafana.json | 21 +++++++++++++++++---- public/locales/ru-RU/grafana.json | 21 +++++++++++++++++---- public/locales/sv-SE/grafana.json | 21 +++++++++++++++++---- public/locales/tr-TR/grafana.json | 21 +++++++++++++++++---- public/locales/zh-Hans/grafana.json | 21 +++++++++++++++++---- public/locales/zh-Hant/grafana.json | 21 +++++++++++++++++---- 18 files changed, 306 insertions(+), 72 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index af86db55162..316215abf0c 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -3739,6 +3739,10 @@ "clear": "Vymazat vyhledávání a filtry", "text": "Nebyly nalezeny žádné výsledky pro váš dotaz" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5992,13 +5996,25 @@ "title-error-loading-dashboard": "Chyba při načítání nástěnky" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Upravit panel", "view-panel": "Zobrazit panel" }, "title": { "dashboard": "Nástěnka", - "discard-changes-to-dashboard": "Zahodit změny nástěnky?" + "discard-changes-to-dashboard": "Zahodit změny nástěnky?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10798,7 +10814,6 @@ "title": "Nové" }, "new-dashboard": { - "empty-title": "", "title": "Nová nástěnka" }, "new-folder": { @@ -11958,7 +11973,6 @@ "title-setting-connection-could-cause-temporary-outage": "Nastavení tohoto připojení může způsobit dočasný výpadek" }, "getting-started-page": { - "header": "Zajišťování", "subtitle-provisioning-feature": "Zobrazujte a spravujte vazby zajištění" }, "git": { @@ -12730,7 +12744,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Importovat", "new": "Nové", "new-dashboard": "Nová nástěnka", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 57d5bcc26a9..26c780b333a 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -3707,6 +3707,10 @@ "clear": "Suche und Filter löschen", "text": "Keine Ergebnisse für deine Abfrage gefunden" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5950,13 +5954,25 @@ "title-error-loading-dashboard": "Fehler beim Laden des Dashboards" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Panel bearbeiten", "view-panel": "Panel anzeigen" }, "title": { "dashboard": "Dashboard", - "discard-changes-to-dashboard": "Änderungen am Dashboard verwerfen?" + "discard-changes-to-dashboard": "Änderungen am Dashboard verwerfen?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10712,7 +10728,6 @@ "title": "Neu" }, "new-dashboard": { - "empty-title": "", "title": "Neues Dashboard" }, "new-folder": { @@ -11856,7 +11871,6 @@ "title-setting-connection-could-cause-temporary-outage": "Das Einrichten dieser Verbindung kann zu einem vorübergehenden Ausfall führen" }, "getting-started-page": { - "header": "Bereitstellung", "subtitle-provisioning-feature": "Sehen und verwalten Sie Ihre Bereitstellungsverbindungen" }, "git": { @@ -12622,7 +12636,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Importieren", "new": "Neu", "new-dashboard": "Neues Dashboard", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 9e141b42f4e..d0e83225d56 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -3707,6 +3707,10 @@ "clear": "Borrar la búsqueda y los filtros", "text": "No se han encontrado resultados para tu consulta" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5950,13 +5954,25 @@ "title-error-loading-dashboard": "Error al cargar el panel de control" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Editar panel", "view-panel": "Ver panel" }, "title": { "dashboard": "Panel de control", - "discard-changes-to-dashboard": "¿Descartar los cambios en el dashboard?" + "discard-changes-to-dashboard": "¿Descartar los cambios en el dashboard?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10712,7 +10728,6 @@ "title": "Nuevo" }, "new-dashboard": { - "empty-title": "", "title": "Nuevo panel de control" }, "new-folder": { @@ -11856,7 +11871,6 @@ "title-setting-connection-could-cause-temporary-outage": "Configurar esta conexión podría causar una interrupción temporal" }, "getting-started-page": { - "header": "Aprovisionamiento", "subtitle-provisioning-feature": "Ver y gestionar tus conexiones de aprovisionamiento" }, "git": { @@ -12622,7 +12636,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Importar", "new": "Nuevo", "new-dashboard": "Nuevo panel de control", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 6adbf1a98d2..1944e137bb7 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -3707,6 +3707,10 @@ "clear": "Effacer la recherche et les filtres", "text": "Aucun résultat n'a été trouvé pour votre requête" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5950,13 +5954,25 @@ "title-error-loading-dashboard": "Erreur lors du chargement du tableau de bord" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Modifier le panneau", "view-panel": "Afficher le panneau" }, "title": { "dashboard": "Tableau de bord", - "discard-changes-to-dashboard": "Abandonner les modifications apportées au tableau de bord ?" + "discard-changes-to-dashboard": "Abandonner les modifications apportées au tableau de bord ?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10712,7 +10728,6 @@ "title": "Nouveau" }, "new-dashboard": { - "empty-title": "", "title": "Nouveau tableau de bord" }, "new-folder": { @@ -11856,7 +11871,6 @@ "title-setting-connection-could-cause-temporary-outage": "La configuration de cette connexion peut entraîner une interruption temporaire" }, "getting-started-page": { - "header": "Mise en service", "subtitle-provisioning-feature": "Afficher et gérer vos connexions de mise en service" }, "git": { @@ -12622,7 +12636,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Importer", "new": "Nouveau", "new-dashboard": "Nouveau tableau de bord", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index c96862e730a..b94b8cd7fe7 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -3707,6 +3707,10 @@ "clear": "Keresés és szűrők törlése", "text": "Nincs találat a lekérdezésre" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5950,13 +5954,25 @@ "title-error-loading-dashboard": "Hiba történt az irányítópult betöltésekor" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Panel szerkesztése", "view-panel": "Panel megtekintése" }, "title": { "dashboard": "Irányítópult", - "discard-changes-to-dashboard": "Elveti az irányítópult módosításait?" + "discard-changes-to-dashboard": "Elveti az irányítópult módosításait?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10712,7 +10728,6 @@ "title": "Új" }, "new-dashboard": { - "empty-title": "", "title": "Új irányítópult" }, "new-folder": { @@ -11856,7 +11871,6 @@ "title-setting-connection-could-cause-temporary-outage": "A kapcsolat létrehozása ideiglenes üzemszünetet okozhat" }, "getting-started-page": { - "header": "Kiépítés", "subtitle-provisioning-feature": "Kiépítési kapcsolatok megtekintése és kezelése" }, "git": { @@ -12622,7 +12636,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Importálás", "new": "Új", "new-dashboard": "Új irányítópult", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 365e4e2f783..5f1dda49d0a 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -3691,6 +3691,10 @@ "clear": "Hapus pencarian dan filter", "text": "Hasil untuk kueri Anda tidak ditemukan" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_other": "", @@ -5929,13 +5933,25 @@ "title-error-loading-dashboard": "Kesalahan saat memuat dasbor" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Edit panel", "view-panel": "Lihat panel" }, "title": { "dashboard": "Dasbor", - "discard-changes-to-dashboard": "Batalkan perubahan ke dasbor?" + "discard-changes-to-dashboard": "Batalkan perubahan ke dasbor?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10669,7 +10685,6 @@ "title": "Baru" }, "new-dashboard": { - "empty-title": "", "title": "Dasbor baru" }, "new-folder": { @@ -11805,7 +11820,6 @@ "title-setting-connection-could-cause-temporary-outage": "Mengatur koneksi ini dapat menyebabkan pemadaman sementara" }, "getting-started-page": { - "header": "Penyediaan", "subtitle-provisioning-feature": "Lihat dan kelola koneksi penyediaan Anda" }, "git": { @@ -12568,7 +12582,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Impor", "new": "Baru", "new-dashboard": "Dasbor baru", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 0f1dbd3b5fb..c8b728faa0f 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -3707,6 +3707,10 @@ "clear": "Cancella ricerca e filtri", "text": "Nessun risultato trovato per la ricerca" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5950,13 +5954,25 @@ "title-error-loading-dashboard": "Errore durante il caricamento del dashboard" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Modifica pannello", "view-panel": "Visualizza pannello" }, "title": { "dashboard": "Dashboard", - "discard-changes-to-dashboard": "Annullare le modifiche alla dashboard?" + "discard-changes-to-dashboard": "Annullare le modifiche alla dashboard?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10712,7 +10728,6 @@ "title": "Nuovo" }, "new-dashboard": { - "empty-title": "", "title": "Nuovo dashboard" }, "new-folder": { @@ -11856,7 +11871,6 @@ "title-setting-connection-could-cause-temporary-outage": "La configurazione di questa connessione potrebbe causare un'interruzione temporanea" }, "getting-started-page": { - "header": "Provisioning", "subtitle-provisioning-feature": "Visualizza e gestisci le connessioni di provisioning" }, "git": { @@ -12622,7 +12636,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Importa", "new": "Nuovo", "new-dashboard": "Nuovo dashboard", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index e6e8b87e70d..d633cd80893 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -3691,6 +3691,10 @@ "clear": "検索とフィルタをクリア", "text": "クエリに一致する結果が見つかりませんでした。" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_other": "", @@ -5929,13 +5933,25 @@ "title-error-loading-dashboard": "ダッシュボードの読み込み中にエラーが発生しました" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "パネルを編集", "view-panel": "パネルを表示" }, "title": { "dashboard": "ダッシュボード", - "discard-changes-to-dashboard": "ダッシュボードへの変更を破棄しますか?" + "discard-changes-to-dashboard": "ダッシュボードへの変更を破棄しますか?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10669,7 +10685,6 @@ "title": "新規" }, "new-dashboard": { - "empty-title": "", "title": "新しいダッシュボード" }, "new-folder": { @@ -11805,7 +11820,6 @@ "title-setting-connection-could-cause-temporary-outage": "この接続設定を行うことで、一時的に停止する可能性があります" }, "getting-started-page": { - "header": "プロビジョニング", "subtitle-provisioning-feature": "プロビジョニング接続を表示・管理" }, "git": { @@ -12568,7 +12582,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "インポート", "new": "新規", "new-dashboard": "新しいダッシュボード", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 152cb7f1607..64bab4a6953 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -3691,6 +3691,10 @@ "clear": "검색 및 필터 초기화", "text": "쿼리에 대해 찾은 결과 없음" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_other": "", @@ -5929,13 +5933,25 @@ "title-error-loading-dashboard": "대시보드 로딩 중 오류 발생" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "패널 편집", "view-panel": "패널 보기" }, "title": { "dashboard": "대시보드", - "discard-changes-to-dashboard": "대시보드 변경 사항을 취소하시겠어요?" + "discard-changes-to-dashboard": "대시보드 변경 사항을 취소하시겠어요?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10669,7 +10685,6 @@ "title": "신규" }, "new-dashboard": { - "empty-title": "", "title": "새 대시보드" }, "new-folder": { @@ -11805,7 +11820,6 @@ "title-setting-connection-could-cause-temporary-outage": "이 연결을 설정하면 일시적인 중단이 발생할 수 있습니다" }, "getting-started-page": { - "header": "프로비저닝", "subtitle-provisioning-feature": "프로비저닝 연결 보기 및 관리" }, "git": { @@ -12568,7 +12582,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "가져오기", "new": "신규", "new-dashboard": "새 대시보드", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index a20a4e079af..cd16286eea8 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -3707,6 +3707,10 @@ "clear": "Zoekopdracht en filters wissen", "text": "Geen resultaten gevonden voor je zoekopdracht" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5950,13 +5954,25 @@ "title-error-loading-dashboard": "Er is een fout opgetreden bij het laden van het dashboard" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Paneel bewerken", "view-panel": "Paneel bekijken" }, "title": { "dashboard": "Dashboard", - "discard-changes-to-dashboard": "Wijzigingen in dashboard verwerpen?" + "discard-changes-to-dashboard": "Wijzigingen in dashboard verwerpen?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10712,7 +10728,6 @@ "title": "Nieuw" }, "new-dashboard": { - "empty-title": "", "title": "Nieuw dashboard" }, "new-folder": { @@ -11856,7 +11871,6 @@ "title-setting-connection-could-cause-temporary-outage": "Het opzetten van deze verbinding kan een tijdelijke storing veroorzaken" }, "getting-started-page": { - "header": "Provisioning", "subtitle-provisioning-feature": "Je provisioningverbindingen bekijken en beheren" }, "git": { @@ -12622,7 +12636,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Importeren", "new": "Nieuw", "new-dashboard": "Nieuw dashboard", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 06987de8041..989a1d9be78 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -3739,6 +3739,10 @@ "clear": "Wyczyść wyszukiwanie i filtry", "text": "Nie znaleziono wyników dla tego zapytania" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5992,13 +5996,25 @@ "title-error-loading-dashboard": "Błąd wczytywania pulpitu" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Edytuj panel", "view-panel": "Wyświetl panel" }, "title": { "dashboard": "Pulpit", - "discard-changes-to-dashboard": "Odrzucić zmiany dotyczące pulpitu?" + "discard-changes-to-dashboard": "Odrzucić zmiany dotyczące pulpitu?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10798,7 +10814,6 @@ "title": "Nowy" }, "new-dashboard": { - "empty-title": "", "title": "Nowy pulpit" }, "new-folder": { @@ -11958,7 +11973,6 @@ "title-setting-connection-could-cause-temporary-outage": "Skonfigurowanie tego połączenia może spowodować tymczasową niedostępność" }, "getting-started-page": { - "header": "Konfiguracja", "subtitle-provisioning-feature": "Wyświetlaj połączenia aprowizacyjne i nimi zarządzaj" }, "git": { @@ -12730,7 +12744,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Importuj", "new": "Nowy", "new-dashboard": "Nowy pulpit", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 0774fe6767e..5480b73ca27 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -3707,6 +3707,10 @@ "clear": "Limpar busca e filtros", "text": "Nenhum resultado encontrado para sua consulta" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5950,13 +5954,25 @@ "title-error-loading-dashboard": "Erro ao carregar o painel de controle" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Editar painel", "view-panel": "Visualizar painel" }, "title": { "dashboard": "Painel de controle", - "discard-changes-to-dashboard": "Deseja descartar as alterações no painel?" + "discard-changes-to-dashboard": "Deseja descartar as alterações no painel?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10712,7 +10728,6 @@ "title": "Novo" }, "new-dashboard": { - "empty-title": "", "title": "Novo painel de controle" }, "new-folder": { @@ -11856,7 +11871,6 @@ "title-setting-connection-could-cause-temporary-outage": "Estabelecer esta conexão pode causar uma interrupção temporária" }, "getting-started-page": { - "header": "Aprovisionamento", "subtitle-provisioning-feature": "Visualize e gerencie suas conexões de provisionamento" }, "git": { @@ -12622,7 +12636,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Importar", "new": "Novo", "new-dashboard": "Novo painel de controle", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index eabb3d9e99e..53cf4c8fb46 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -3707,6 +3707,10 @@ "clear": "Limpar a pesquisa e os filtros", "text": "Não foram encontrados resultados para a sua consulta" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5950,13 +5954,25 @@ "title-error-loading-dashboard": "Erro ao carregar o painel de controlo" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Editar painel", "view-panel": "Visualizar painel" }, "title": { "dashboard": "Painel de controlo", - "discard-changes-to-dashboard": "Rejeitar alterações no painel de controlo?" + "discard-changes-to-dashboard": "Rejeitar alterações no painel de controlo?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10712,7 +10728,6 @@ "title": "Novo" }, "new-dashboard": { - "empty-title": "", "title": "Novo painel de controlo" }, "new-folder": { @@ -11856,7 +11871,6 @@ "title-setting-connection-could-cause-temporary-outage": "Configurar esta ligação pode causar uma interrupção temporária" }, "getting-started-page": { - "header": "Provisionamento", "subtitle-provisioning-feature": "Ver e gerir as suas ligações de provisionamento" }, "git": { @@ -12622,7 +12636,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Importar", "new": "Novo", "new-dashboard": "Novo painel de controlo", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 1542c4a7a29..a4236136a35 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -3739,6 +3739,10 @@ "clear": "Очистить поиск и фильтры", "text": "По вашему запросу ничего не найдено" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5992,13 +5996,25 @@ "title-error-loading-dashboard": "Ошибка при загрузке дашборда" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Редактировать панель", "view-panel": "Просмотр панели" }, "title": { "dashboard": "Дашборд", - "discard-changes-to-dashboard": "Отменить изменения на дашборде?" + "discard-changes-to-dashboard": "Отменить изменения на дашборде?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10798,7 +10814,6 @@ "title": "Новые элементы" }, "new-dashboard": { - "empty-title": "", "title": "Новый дашборд" }, "new-folder": { @@ -11958,7 +11973,6 @@ "title-setting-connection-could-cause-temporary-outage": "Настройка этого подключения может привести к временному сбою" }, "getting-started-page": { - "header": "Подготовка к работе", "subtitle-provisioning-feature": "Просмотр подключений для подготовки и управлением ими" }, "git": { @@ -12730,7 +12744,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Импорт", "new": "Новые элементы", "new-dashboard": "Новый дашборд", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index c390b4175c2..13f96c68f3e 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -3707,6 +3707,10 @@ "clear": "Rensa sökning och filter", "text": "Inga resultat hittades för din fråga" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5950,13 +5954,25 @@ "title-error-loading-dashboard": "Fel vid laddning av instrumentpanel" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Redigera panel", "view-panel": "Visa panel" }, "title": { "dashboard": "Instrumentpanel", - "discard-changes-to-dashboard": "Kassera ändringar i instrumentpanelen?" + "discard-changes-to-dashboard": "Kassera ändringar i instrumentpanelen?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10712,7 +10728,6 @@ "title": "Nyhet" }, "new-dashboard": { - "empty-title": "", "title": "Ny instrumentpanel" }, "new-folder": { @@ -11856,7 +11871,6 @@ "title-setting-connection-could-cause-temporary-outage": "Konfiguration av den här anslutningen kan orsaka ett tillfälligt avbrott" }, "getting-started-page": { - "header": "Provisionering", "subtitle-provisioning-feature": "Visa och hantera dina provisioneringsanslutningar" }, "git": { @@ -12622,7 +12636,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "Importera", "new": "Nyhet", "new-dashboard": "Ny instrumentpanel", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 258d74c27f1..96b92518f07 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -3707,6 +3707,10 @@ "clear": "Aramayı ve filtreleri temizle", "text": "Sorgunuz için sonuç bulunamadı" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_one": "", @@ -5950,13 +5954,25 @@ "title-error-loading-dashboard": "Pano yüklenirken hata oluştu" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "Paneli düzenle", "view-panel": "Paneli görüntüle" }, "title": { "dashboard": "Pano", - "discard-changes-to-dashboard": "Panodaki değişiklikler silinsin mi?" + "discard-changes-to-dashboard": "Panodaki değişiklikler silinsin mi?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10712,7 +10728,6 @@ "title": "Yeni" }, "new-dashboard": { - "empty-title": "", "title": "Yeni pano" }, "new-folder": { @@ -11856,7 +11871,6 @@ "title-setting-connection-could-cause-temporary-outage": "Bu bağlantıyı kurmak geçici bir kesintiye neden olabilir" }, "getting-started-page": { - "header": "Sağlama", "subtitle-provisioning-feature": "Sağlama bağlantılarınızı görüntüleyin ve yönetin" }, "git": { @@ -12622,7 +12636,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "İçe aktar", "new": "Yeni", "new-dashboard": "Yeni pano", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index e2514cf36ed..87666d55d39 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -3691,6 +3691,10 @@ "clear": "清除搜索和筛选条件", "text": "未找到与您的查询相关的结果" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_other": "", @@ -5929,13 +5933,25 @@ "title-error-loading-dashboard": "加载数据面板时出错" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "编辑面板", "view-panel": "查看面板" }, "title": { "dashboard": "仪表板", - "discard-changes-to-dashboard": "放弃对数据面板的更改?" + "discard-changes-to-dashboard": "放弃对数据面板的更改?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10669,7 +10685,6 @@ "title": "新建" }, "new-dashboard": { - "empty-title": "", "title": "新建仪表板" }, "new-folder": { @@ -11805,7 +11820,6 @@ "title-setting-connection-could-cause-temporary-outage": "设置此连接可能会导致暂时中断" }, "getting-started-page": { - "header": "配置", "subtitle-provisioning-feature": "查看和管理您的预配连接" }, "git": { @@ -12568,7 +12582,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "导入", "new": "新建", "new-dashboard": "新建仪表板", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 7ff6bfff111..d752e8ef1c1 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -3691,6 +3691,10 @@ "clear": "清除搜尋和篩選條件", "text": "未找到您的查詢結果" }, + "recently-viewed": { + "empty": "", + "title": "" + }, "restore": { "success": "", "all-failed_other": "", @@ -5929,13 +5933,25 @@ "title-error-loading-dashboard": "載入控制面板發生錯誤" }, "dashboard-scene": { + "modal": { + "cancel": "", + "discard": "", + "save": "", + "text": { + "save-changes-question": "" + }, + "title": { + "unsaved-changes": "" + } + }, "text": { "edit-panel": "編輯面板", "view-panel": "檢視面板" }, "title": { "dashboard": "儀表板", - "discard-changes-to-dashboard": "要捨棄儀表板的變更嗎?" + "discard-changes-to-dashboard": "要捨棄儀表板的變更嗎?", + "unsaved-changes-question": "" } }, "dashboard-scene-page-state-manager": { @@ -10669,7 +10685,6 @@ "title": "新" }, "new-dashboard": { - "empty-title": "", "title": "新儀表板" }, "new-folder": { @@ -11805,7 +11820,6 @@ "title-setting-connection-could-cause-temporary-outage": "設定此連線可能會導致暫時中斷" }, "getting-started-page": { - "header": "佈建", "subtitle-provisioning-feature": "檢視及管理您的佈建連線" }, "git": { @@ -12568,7 +12582,6 @@ } }, "dashboard-actions": { - "empty-dashboard": "", "import": "匯入", "new": "新", "new-dashboard": "新儀表板",