Compare commits

..

35 Commits

Author SHA1 Message Date
idastambuk 6b6a434383 update navigation and apply save form changes 2025-12-08 13:05:59 +01:00
idastambuk 6d6112b627 Merge branch 'grafakus/multi-dimensional-vars-ui' into hackathon/stacks 2025-12-08 12:40:05 +01:00
idastambuk 41f9162472 Add stack list, new stack, edit stack 2025-12-08 12:27:41 +01:00
Dafydd 7e991886e0 use fieldSelector on the ListConnections endpoint to get datasources by UID, instead of relying on uniqueness in the Get endpoint 2025-12-05 15:49:40 +00:00
Dafydd 85925d0765 simplify the new datasource client interface to not require group 2025-12-05 11:24:59 +00:00
Dafydd 7790698aaa newline 2025-12-04 16:36:28 +00:00
Dafydd 5499ad8023 provide an interface for the datasourceConnection 2025-12-04 16:34:22 +00:00
Dafydd 90c4ab9d96 wip: basic logic to check that datasource exists in validation 2025-12-04 12:34:14 +00:00
Dafydd fd31f087ee add some tests for validating datasource stacks structure 2025-12-04 11:03:41 +00:00
Dafydd 3ee834922b wip: start to use validator in the builder instead of validating on the store hooks 2025-12-03 15:13:34 +00:00
Dafydd 2e2ce8fddd wip: exploring update validation 2025-12-02 15:53:35 +00:00
Dafydd 8214dbc758 start adding some validation to the store 2025-12-02 15:11:46 +00:00
Dafydd 98d454401c rm unused store implementation 2025-12-02 14:37:41 +00:00
Dafydd fcf1a47222 update kind names 2025-12-02 13:44:31 +00:00
Dafydd 8a5b6804dd wip: add separate authorization logic for datasources 2025-12-02 10:44:43 +00:00
Dafydd f0028f692b wip: add storage that ignores dual writer. Next step: why doesnt the attr.GetName() method work? When posting a new datasource 2025-12-01 16:58:57 +00:00
Dafydd d71474246c wip: add datasources collection resource kind definition, register it to the API 2025-12-01 15:02:00 +00:00
grafakus 9447015e54 Remove temp switch in QueryVariableEditor - rely on options instead to determine if the variable has multi props 2025-11-26 19:51:00 +01:00
grafakus abe10b2bb6 chore: Better naming and minor test improvement 2025-11-25 18:27:58 +01:00
grafakus 009716a408 test(CustomVariableEditor): Add unit tests 2025-11-25 18:21:29 +01:00
grafakus e0c28cfa4c Fix: hide options when multi properties exist on every var options 2025-11-25 12:11:37 +01:00
grafakus 18c4f5b875 feat: Update dynamic dashboards editors 2025-11-25 12:06:47 +01:00
grafakus 400f3a91d0 Fix conflicts with main 2025-11-25 10:09:43 +01:00
grafakus d6b04d28b6 chore: Update to new Scenes version 2025-11-24 10:46:10 +01:00
grafakus 0400d536c7 Fix K8s Codegen Check 2025-11-19 09:07:17 +01:00
grafakus 694e88b95b Add some unit tests 2025-11-19 08:48:55 +01:00
grafakus ad73303328 VariableEditorForm checks to display preview with multiple props 2025-11-19 08:48:25 +01:00
grafakus 3dcd809aaf Translate CustomVariableEditor + improve JSON validation 2025-11-18 18:35:49 +01:00
grafakus 6b7fac65b1 chore: Add comment 2025-11-18 15:04:44 +01:00
grafakus 2d17de2395 Small preview fix when "All" option is checked 2025-11-18 14:58:28 +01:00
grafakus 5b685373aa Strengthen valuesFormat type + cleanup generated files 2025-11-18 14:42:56 +01:00
grafakus 4d29e5bf6a chore: ... 2025-11-13 20:16:47 +01:00
grafakus 7a0e64196b Update v2 schema + gen types 2025-11-13 14:19:03 +01:00
grafakus f1e24f528e Merge branch 'main' into grafakus/multi-dimensional-vars-ui 2025-11-13 14:18:36 +01:00
grafakus 198f4dbf93 feat: WiP 2025-11-13 14:08:00 +01:00
78 changed files with 3558 additions and 647 deletions
+1 -1
View File
@@ -7,4 +7,4 @@ generate: install-app-sdk update-app-sdk
--gogenpath=./pkg/apis \
--grouping=group \
--genoperatorstate=false \
--defencoding=none
--defencoding=none
@@ -0,0 +1,35 @@
package preferences
datasourcestacksV1alpha1: {
kind: "DataSourceStack"
pluralName: "DataSourceStacks"
scope: "Namespaced"
schema: {
spec: {
template: TemplateSpec
modes: [...ModeSpec]
}
}
}
TemplateSpec: {
[string]: DataSourceStackTemplateItem
}
DataSourceStackTemplateItem: {
group: string // type
name: string // variable name / display name
}
ModeSpec: {
name: string
uid: string
definition: Mode
}
Mode: [string]: ModeItem
ModeItem: {
dataSourceRef: string // grafana data source uid
}
+4 -3
View File
@@ -6,12 +6,13 @@ manifest: {
versions: {
"v1alpha1": {
codegen: {
ts: {enabled: false}
ts: {enabled: true}
go: {enabled: true}
}
kinds: [
starsV1alpha1,
datasourcestacksV1alpha1
]
}
},
}
}
}
@@ -0,0 +1,80 @@
package v1alpha1
import (
"context"
"github.com/grafana/grafana-app-sdk/resource"
)
type DataSourceStackClient struct {
client *resource.TypedClient[*DataSourceStack, *DataSourceStackList]
}
func NewDataSourceStackClient(client resource.Client) *DataSourceStackClient {
return &DataSourceStackClient{
client: resource.NewTypedClient[*DataSourceStack, *DataSourceStackList](client, DataSourceStackKind()),
}
}
func NewDataSourceStackClientFromGenerator(generator resource.ClientGenerator) (*DataSourceStackClient, error) {
c, err := generator.ClientFor(DataSourceStackKind())
if err != nil {
return nil, err
}
return NewDataSourceStackClient(c), nil
}
func (c *DataSourceStackClient) Get(ctx context.Context, identifier resource.Identifier) (*DataSourceStack, error) {
return c.client.Get(ctx, identifier)
}
func (c *DataSourceStackClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*DataSourceStackList, error) {
return c.client.List(ctx, namespace, opts)
}
func (c *DataSourceStackClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*DataSourceStackList, 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 *DataSourceStackClient) Create(ctx context.Context, obj *DataSourceStack, opts resource.CreateOptions) (*DataSourceStack, error) {
// Make sure apiVersion and kind are set
obj.APIVersion = GroupVersion.Identifier()
obj.Kind = DataSourceStackKind().Kind()
return c.client.Create(ctx, obj, opts)
}
func (c *DataSourceStackClient) Update(ctx context.Context, obj *DataSourceStack, opts resource.UpdateOptions) (*DataSourceStack, error) {
return c.client.Update(ctx, obj, opts)
}
func (c *DataSourceStackClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*DataSourceStack, error) {
return c.client.Patch(ctx, identifier, req, opts)
}
func (c *DataSourceStackClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error {
return c.client.Delete(ctx, identifier, opts)
}
@@ -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"
)
// DataSourceStackJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding
type DataSourceStackJSONCodec struct{}
// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into`
func (*DataSourceStackJSONCodec) 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 (*DataSourceStackJSONCodec) Write(writer io.Writer, from resource.Object) error {
return json.NewEncoder(writer).Encode(from)
}
// Interface compliance checks
var _ resource.Codec = &DataSourceStackJSONCodec{}
@@ -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 DataSourceStackMetadata 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"`
}
// NewDataSourceStackMetadata creates a new DataSourceStackMetadata object.
func NewDataSourceStackMetadata() *DataSourceStackMetadata {
return &DataSourceStackMetadata{
Finalizers: []string{},
Labels: map[string]string{},
}
}
@@ -0,0 +1,293 @@
//
// 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 DataSourceStack struct {
metav1.TypeMeta `json:",inline" yaml:",inline"`
metav1.ObjectMeta `json:"metadata" yaml:"metadata"`
// Spec is the spec of the DataSourceStack
Spec DataSourceStackSpec `json:"spec" yaml:"spec"`
}
func (o *DataSourceStack) GetSpec() any {
return o.Spec
}
func (o *DataSourceStack) SetSpec(spec any) error {
cast, ok := spec.(DataSourceStackSpec)
if !ok {
return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec)
}
o.Spec = cast
return nil
}
func (o *DataSourceStack) GetSubresources() map[string]any {
return map[string]any{}
}
func (o *DataSourceStack) GetSubresource(name string) (any, bool) {
switch name {
default:
return nil, false
}
}
func (o *DataSourceStack) SetSubresource(name string, value any) error {
switch name {
default:
return fmt.Errorf("subresource '%s' does not exist", name)
}
}
func (o *DataSourceStack) 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 *DataSourceStack) 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 *DataSourceStack) 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 *DataSourceStack) 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 *DataSourceStack) GetCreatedBy() string {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
return o.ObjectMeta.Annotations["grafana.com/createdBy"]
}
func (o *DataSourceStack) SetCreatedBy(createdBy string) {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy
}
func (o *DataSourceStack) 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 *DataSourceStack) 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 *DataSourceStack) GetUpdatedBy() string {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
return o.ObjectMeta.Annotations["grafana.com/updatedBy"]
}
func (o *DataSourceStack) SetUpdatedBy(updatedBy string) {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy
}
func (o *DataSourceStack) Copy() resource.Object {
return resource.CopyObject(o)
}
func (o *DataSourceStack) DeepCopyObject() runtime.Object {
return o.Copy()
}
func (o *DataSourceStack) DeepCopy() *DataSourceStack {
cpy := &DataSourceStack{}
o.DeepCopyInto(cpy)
return cpy
}
func (o *DataSourceStack) DeepCopyInto(dst *DataSourceStack) {
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
dst.TypeMeta.Kind = o.TypeMeta.Kind
o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta)
o.Spec.DeepCopyInto(&dst.Spec)
}
// Interface compliance compile-time check
var _ resource.Object = &DataSourceStack{}
// +k8s:openapi-gen=true
type DataSourceStackList struct {
metav1.TypeMeta `json:",inline" yaml:",inline"`
metav1.ListMeta `json:"metadata" yaml:"metadata"`
Items []DataSourceStack `json:"items" yaml:"items"`
}
func (o *DataSourceStackList) DeepCopyObject() runtime.Object {
return o.Copy()
}
func (o *DataSourceStackList) Copy() resource.ListObject {
cpy := &DataSourceStackList{
TypeMeta: o.TypeMeta,
Items: make([]DataSourceStack, len(o.Items)),
}
o.ListMeta.DeepCopyInto(&cpy.ListMeta)
for i := 0; i < len(o.Items); i++ {
if item, ok := o.Items[i].Copy().(*DataSourceStack); ok {
cpy.Items[i] = *item
}
}
return cpy
}
func (o *DataSourceStackList) 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 *DataSourceStackList) SetItems(items []resource.Object) {
o.Items = make([]DataSourceStack, len(items))
for i := 0; i < len(items); i++ {
o.Items[i] = *items[i].(*DataSourceStack)
}
}
func (o *DataSourceStackList) DeepCopy() *DataSourceStackList {
cpy := &DataSourceStackList{}
o.DeepCopyInto(cpy)
return cpy
}
func (o *DataSourceStackList) DeepCopyInto(dst *DataSourceStackList) {
resource.CopyObjectInto(dst, o)
}
// Interface compliance compile-time check
var _ resource.ListObject = &DataSourceStackList{}
// Copy methods for all subresource types
// DeepCopy creates a full deep copy of Spec
func (s *DataSourceStackSpec) DeepCopy() *DataSourceStackSpec {
cpy := &DataSourceStackSpec{}
s.DeepCopyInto(cpy)
return cpy
}
// DeepCopyInto deep copies Spec into another Spec object
func (s *DataSourceStackSpec) DeepCopyInto(dst *DataSourceStackSpec) {
resource.CopyObjectInto(dst, s)
}
@@ -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 (
schemaDataSourceStack = resource.NewSimpleSchema("collections.grafana.app", "v1alpha1", &DataSourceStack{}, &DataSourceStackList{}, resource.WithKind("DataSourceStack"),
resource.WithPlural("datasourcestacks"), resource.WithScope(resource.NamespacedScope))
kindDataSourceStack = resource.Kind{
Schema: schemaDataSourceStack,
Codecs: map[resource.KindEncoding]resource.Codec{
resource.KindEncodingJSON: &DataSourceStackJSONCodec{},
},
}
)
// Kind returns a resource.Kind for this Schema with a JSON codec
func DataSourceStackKind() resource.Kind {
return kindDataSourceStack
}
// Schema returns a resource.SimpleSchema representation of DataSourceStack
func DataSourceStackSchema() *resource.SimpleSchema {
return schemaDataSourceStack
}
// Interface compliance checks
var _ resource.Schema = kindDataSourceStack
@@ -0,0 +1,58 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v1alpha1
// +k8s:openapi-gen=true
type DataSourceStackTemplateSpec map[string]DataSourceStackDataSourceStackTemplateItem
// +k8s:openapi-gen=true
type DataSourceStackDataSourceStackTemplateItem struct {
// type
Group string `json:"group"`
// variable name / display name
Name string `json:"name"`
}
// NewDataSourceStackDataSourceStackTemplateItem creates a new DataSourceStackDataSourceStackTemplateItem object.
func NewDataSourceStackDataSourceStackTemplateItem() *DataSourceStackDataSourceStackTemplateItem {
return &DataSourceStackDataSourceStackTemplateItem{}
}
// +k8s:openapi-gen=true
type DataSourceStackModeSpec struct {
Name string `json:"name"`
Uid string `json:"uid"`
Definition DataSourceStackMode `json:"definition"`
}
// NewDataSourceStackModeSpec creates a new DataSourceStackModeSpec object.
func NewDataSourceStackModeSpec() *DataSourceStackModeSpec {
return &DataSourceStackModeSpec{}
}
// +k8s:openapi-gen=true
type DataSourceStackMode map[string]DataSourceStackModeItem
// +k8s:openapi-gen=true
type DataSourceStackModeItem struct {
// grafana data source uid
DataSourceRef string `json:"dataSourceRef"`
}
// NewDataSourceStackModeItem creates a new DataSourceStackModeItem object.
func NewDataSourceStackModeItem() *DataSourceStackModeItem {
return &DataSourceStackModeItem{}
}
// +k8s:openapi-gen=true
type DataSourceStackSpec struct {
Template DataSourceStackTemplateSpec `json:"template"`
Modes []DataSourceStackModeSpec `json:"modes"`
}
// NewDataSourceStackSpec creates a new DataSourceStackSpec object.
func NewDataSourceStackSpec() *DataSourceStackSpec {
return &DataSourceStackSpec{
Modes: []DataSourceStackModeSpec{},
}
}
@@ -32,6 +32,19 @@ var StarsResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion,
},
)
var DatasourceStacksResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion,
"datasourcestacks", "datasourcestack", "DataSourceStack",
func() runtime.Object { return &DataSourceStack{} },
func() runtime.Object { return &DataSourceStackList{} },
utils.TableColumns{
Definition: []metav1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name"},
{Name: "Created At", Type: "date"},
},
// TODO: Reader?
},
)
var (
SchemeBuilder runtime.SchemeBuilder
localSchemeBuilder = &SchemeBuilder
@@ -48,6 +61,8 @@ func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(schemeGroupVersion,
&Stars{},
&StarsList{},
&DataSourceStack{},
&DataSourceStackList{},
)
metav1.AddToGroupVersion(scheme, schemeGroupVersion)
return nil
@@ -14,10 +14,241 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars": schema_pkg_apis_collections_v1alpha1_Stars(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsList": schema_pkg_apis_collections_v1alpha1_StarsList(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource": schema_pkg_apis_collections_v1alpha1_StarsResource(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec": schema_pkg_apis_collections_v1alpha1_StarsSpec(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack": schema_pkg_apis_collections_v1alpha1_DataSourceStack(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem": schema_pkg_apis_collections_v1alpha1_DataSourceStackDataSourceStackTemplateItem(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackList": schema_pkg_apis_collections_v1alpha1_DataSourceStackList(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem": schema_pkg_apis_collections_v1alpha1_DataSourceStackModeItem(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec": schema_pkg_apis_collections_v1alpha1_DataSourceStackModeSpec(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec": schema_pkg_apis_collections_v1alpha1_DataSourceStackSpec(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars": schema_pkg_apis_collections_v1alpha1_Stars(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsList": schema_pkg_apis_collections_v1alpha1_StarsList(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource": schema_pkg_apis_collections_v1alpha1_StarsResource(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec": schema_pkg_apis_collections_v1alpha1_StarsSpec(ref),
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStack(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
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{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
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{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
},
},
"spec": {
SchemaProps: spec.SchemaProps{
Description: "Spec is the spec of the DataSourceStack",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec"),
},
},
},
Required: []string{"metadata", "spec"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackDataSourceStackTemplateItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"group": {
SchemaProps: spec.SchemaProps{
Description: "type",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"name": {
SchemaProps: spec.SchemaProps{
Description: "variable name / display name",
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"group", "name"},
},
},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackList(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
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{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
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{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
},
},
"items": {
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/collections/pkg/apis/collections/v1alpha1.DataSourceStack"),
},
},
},
},
},
},
Required: []string{"metadata", "items"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackModeItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"dataSourceRef": {
SchemaProps: spec.SchemaProps{
Description: "grafana data source uid",
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"dataSourceRef"},
},
},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackModeSpec(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: "",
},
},
"uid": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"definition": {
SchemaProps: spec.SchemaProps{
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/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem"),
},
},
},
},
},
},
Required: []string{"name", "uid", "definition"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem"},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"template": {
SchemaProps: spec.SchemaProps{
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/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem"),
},
},
},
},
},
"modes": {
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/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec"),
},
},
},
},
},
},
Required: []string{"template", "modes"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem", "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec"},
}
}
@@ -1,2 +1,4 @@
API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,DataSourceStackSpec,Modes
API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsSpec,Resource
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,DataSourceStackList,ListMeta
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsList,ListMeta
+19 -7
View File
@@ -10,19 +10,22 @@ import (
"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"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/resource"
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
v1alpha1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
)
var (
rawSchemaStarsv1alpha1 = []byte(`{"Resource":{"additionalProperties":false,"properties":{"group":{"type":"string"},"kind":{"type":"string"},"names":{"description":"The set of resources\n+listType=set","items":{"type":"string"},"type":"array"}},"required":["group","kind","names"],"type":"object"},"Stars":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"resource":{"items":{"$ref":"#/components/schemas/Resource"},"type":"array"}},"required":["resource"],"type":"object"}}`)
versionSchemaStarsv1alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1)
rawSchemaStarsv1alpha1 = []byte(`{"Resource":{"additionalProperties":false,"properties":{"group":{"type":"string"},"kind":{"type":"string"},"names":{"description":"The set of resources\n+listType=set","items":{"type":"string"},"type":"array"}},"required":["group","kind","names"],"type":"object"},"Stars":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"resource":{"items":{"$ref":"#/components/schemas/Resource"},"type":"array"}},"required":["resource"],"type":"object"}}`)
versionSchemaStarsv1alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1)
rawSchemaDataSourceStackv1alpha1 = []byte(`{"DataSourceStack":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"DataSourceStackTemplateItem":{"additionalProperties":false,"properties":{"group":{"description":"type","type":"string"},"name":{"description":"variable name / display name","type":"string"}},"required":["group","name"],"type":"object"},"Mode":{"additionalProperties":{"$ref":"#/components/schemas/ModeItem"},"type":"object"},"ModeItem":{"additionalProperties":false,"properties":{"dataSourceRef":{"description":"grafana data source uid","type":"string"}},"required":["dataSourceRef"],"type":"object"},"ModeSpec":{"additionalProperties":false,"properties":{"definition":{"$ref":"#/components/schemas/Mode"},"name":{"type":"string"},"uid":{"type":"string"}},"required":["name","uid","definition"],"type":"object"},"TemplateSpec":{"additionalProperties":{"$ref":"#/components/schemas/DataSourceStackTemplateItem"},"type":"object"},"spec":{"additionalProperties":false,"properties":{"modes":{"items":{"$ref":"#/components/schemas/ModeSpec"},"type":"array"},"template":{"$ref":"#/components/schemas/TemplateSpec"}},"required":["template","modes"],"type":"object"}}`)
versionSchemaDataSourceStackv1alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaDataSourceStackv1alpha1, &versionSchemaDataSourceStackv1alpha1)
)
var appManifestData = app.ManifestData{
@@ -49,6 +52,14 @@ var appManifestData = app.ManifestData{
},
Schema: &versionSchemaStarsv1alpha1,
},
{
Kind: "DataSourceStack",
Plural: "DataSourceStacks",
Scope: "Namespaced",
Conversion: false,
Schema: &versionSchemaDataSourceStackv1alpha1,
},
},
Routes: app.ManifestVersionRoutes{
Namespaced: map[string]spec3.PathProps{},
@@ -68,7 +79,8 @@ func RemoteManifest() app.Manifest {
}
var kindVersionToGoType = map[string]resource.Kind{
"Stars/v1alpha1": v1alpha1.StarsKind(),
"Stars/v1alpha1": v1alpha1.StarsKind(),
"DataSourceStack/v1alpha1": v1alpha1.DataSourceStackKind(),
}
// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists.
@@ -0,0 +1,47 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.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<string, string>;
annotations?: Record<string, string>;
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 DataSourceStack {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
}
@@ -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<string, string>;
}
export const defaultMetadata = (): Metadata => ({
updateTimestamp: "",
createdBy: "",
uid: "",
creationTimestamp: "",
finalizers: [],
resourceVersion: "",
generation: 0,
updatedBy: "",
labels: {},
});
@@ -0,0 +1,53 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export type TemplateSpec = Record<string, DataSourceStackTemplateItem>;
export const defaultTemplateSpec = (): TemplateSpec => ({});
export interface DataSourceStackTemplateItem {
// type
group: string;
// variable name / display name
name: string;
}
export const defaultDataSourceStackTemplateItem = (): DataSourceStackTemplateItem => ({
group: "",
name: "",
});
export interface ModeSpec {
name: string;
uid: string;
definition: Mode;
}
export const defaultModeSpec = (): ModeSpec => ({
name: "",
uid: "",
definition: defaultMode(),
});
export type Mode = Record<string, ModeItem>;
export const defaultMode = (): Mode => ({});
export interface ModeItem {
// grafana data source uid
dataSourceRef: string;
}
export const defaultModeItem = (): ModeItem => ({
dataSourceRef: "",
});
export interface Spec {
template: TemplateSpec;
modes: ModeSpec[];
}
export const defaultSpec = (): Spec => ({
template: defaultTemplateSpec(),
modes: [],
});
@@ -0,0 +1,47 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.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<string, string>;
annotations?: Record<string, string>;
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 Stars {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
}
@@ -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<string, string>;
}
export const defaultMetadata = (): Metadata => ({
updateTimestamp: "",
createdBy: "",
uid: "",
creationTimestamp: "",
finalizers: [],
resourceVersion: "",
generation: 0,
updatedBy: "",
labels: {},
});
@@ -0,0 +1,24 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export interface Resource {
group: string;
kind: string;
// The set of resources
// +listType=set
names: string[];
}
export const defaultResource = (): Resource => ({
group: "",
kind: "",
names: [],
});
export interface Spec {
resource: Resource[];
}
export const defaultSpec = (): Spec => ({
resource: [],
});
@@ -911,6 +911,7 @@ CustomVariableSpec: {
skipUrlSync: bool | *false
description?: string
allowCustomValue: bool | *true
valuesFormat?: "csv" | "json"
}
// Custom variable kind
@@ -915,6 +915,7 @@ CustomVariableSpec: {
skipUrlSync: bool | *false
description?: string
allowCustomValue: bool | *true
valuesFormat?: "csv" | "json"
}
// Custom variable kind
@@ -1675,18 +1675,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind {
// Custom variable specification
// +k8s:openapi-gen=true
type DashboardCustomVariableSpec struct {
Name string `json:"name"`
Query string `json:"query"`
Current DashboardVariableOption `json:"current"`
Options []DashboardVariableOption `json:"options"`
Multi bool `json:"multi"`
IncludeAll bool `json:"includeAll"`
AllValue *string `json:"allValue,omitempty"`
Label *string `json:"label,omitempty"`
Hide DashboardVariableHide `json:"hide"`
SkipUrlSync bool `json:"skipUrlSync"`
Description *string `json:"description,omitempty"`
AllowCustomValue bool `json:"allowCustomValue"`
Name string `json:"name"`
Query string `json:"query"`
Current DashboardVariableOption `json:"current"`
Options []DashboardVariableOption `json:"options"`
Multi bool `json:"multi"`
IncludeAll bool `json:"includeAll"`
AllValue *string `json:"allValue,omitempty"`
Label *string `json:"label,omitempty"`
Hide DashboardVariableHide `json:"hide"`
SkipUrlSync bool `json:"skipUrlSync"`
Description *string `json:"description,omitempty"`
AllowCustomValue bool `json:"allowCustomValue"`
ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"`
}
// NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object.
@@ -2101,6 +2102,14 @@ const (
DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted"
)
// +k8s:openapi-gen=true
type DashboardCustomVariableSpecValuesFormat string
const (
DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv"
DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json"
)
// +k8s:openapi-gen=true
type DashboardPanelKindOrLibraryPanelKind struct {
PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"`
@@ -1510,6 +1510,12 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableSpec(ref common.Re
Format: "",
},
},
"valuesFormat": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"},
},
+2
View File
@@ -181,6 +181,8 @@ import (
//go:generate mockery --name InterfaceName --structname MockImplementationName --inpackage --filename my_implementation_mock.go
```
The current `go:generate` command format used in this repository is only compatible with mockery v2.
## Globals
As a general rule of thumb, avoid using global variables, since they make the code difficult to maintain and reason
@@ -226,8 +226,8 @@ Dashboards are reloaded when the JSON files change.
#### `min_tls_version`
The TLS handshake requires a minimum TLS version. Available options are `TLS1.2` and `TLS1.3`.
If you don't specify a version, Grafana uses `TLS1.2`.
The TLS Handshake requires a minimum TLS version. The available options are TLS1.2 and TLS1.3.
If you do not specify a version, the system uses TLS1.2.
#### `http_addr`
@@ -286,9 +286,9 @@ Set to `true` for Grafana to log all HTTP requests (not just errors). These are
#### `static_root_path`
The path to the directory containing the frontend files (HTML, JS, and CSS).
Defaults to `public`, which is why you must run the Grafana binary with the
working directory set to the installation path.
The path to the directory where the frontend files (HTML, JS, and CSS
files). Defaults to `public` which is why the Grafana binary needs to be
executed with working directory set to the installation path.
#### `enable_gzip`
@@ -312,8 +312,8 @@ Optional. Password to decrypt encrypted certificates.
#### `certs_watch_interval`
Controls whether `cert_key` and `cert_file` are periodically watched for changes.
Disabled by default. When enabled, `cert_key` and `cert_file` are watched for
changes. If there is a change, Grafana loads the new certificates automatically.
Disabled, by default. When enabled, `cert_key` and `cert_file`
are watched for changes. If there is change, the new certificates are loaded automatically.
{{< admonition type="warning" >}}
After the new certificates are loaded, connections with old certificates don't work.
@@ -393,10 +393,6 @@ The database user's password (not applicable for `sqlite3`). If the password con
Use either URL or the previous fields to configure the database
Example: `type://user:password@host:port/name`
#### `high_availability`
Enable or disable high availability mode. When disabled, some functions run in-process instead of relying on the database. Default is `true`.
#### `max_idle_conn`
The maximum number of connections in the idle connection pool.
@@ -479,10 +475,6 @@ This setting applies to `sqlite` only and controls the number of times the syste
Set to `true` to add metrics and tracing for database queries. The default value is `false`.
#### `delete_auto_gen_ids`
Delete auto-generated primary keys during migrations. Useful if the database has auto-generated primary keys enabled. Default is `false`.
#### `skip_dashboard_uid_migration_on_startup`
Set to true to skip dashboard UID migrations on startup. Improves startup performance for instances with large numbers of annotations who do not plan to downgrade Grafana. The default value is `false`.
@@ -524,14 +516,6 @@ Example connection string: `addr=127.0.0.1:6379,pool_size=100,db=0,username=graf
Example connection string: `127.0.0.1:11211`
#### `prefix`
Prefix prepended to all keys in the remote cache.
#### `encryption`
Enable encryption of values stored in the remote cache.
<hr />
### `[dataproxy]`
@@ -546,10 +530,6 @@ How long the data proxy should wait before timing out. Default is 30 seconds.
This setting also applies to core backend HTTP data sources where query requests use an HTTP client with timeout set.
#### `dialTimeout`
How long the data proxy waits to establish a TCP connection before timing out. Default is `10` seconds.
#### `keep_alive_seconds`
Interval between keep-alive probes. Default is `30` seconds. For more details, refer to the [`Dialer.KeepAlive`](https://golang.org/pkg/net/#Dialer.KeepAlive) documentation.
@@ -838,22 +818,6 @@ Set to `true` to execute the CSRF check even if the login cookie is not in a req
Comma-separated list of plugins IDs to load inside the frontend sandbox.
### `[security.encryption]`
Configure encryption-related cache settings for data encryption keys used by Grafana.
#### `data_keys_cache_ttl`
Defines the time-to-live (TTL) for decrypted data encryption keys stored in memory. Default: `15m`.
#### `data_keys_cache_cleanup_interval`
Sets how often Grafana cleans up the encryption key cache, removing entries that reached the TTL. Default: `1m`.
{{< admonition type="note" >}}
Small TTL values can impact performance due to frequent decryption operations.
{{< /admonition >}}
### `[snapshots]`
#### `enabled`
@@ -1686,10 +1650,6 @@ Custom HTTP endpoint to send events captured by the Grafana Faro agent to. Defau
If `custom_endpoint` required authentication, you can set the API key here. Only relevant for Grafana JavaScript Agent provider.
#### `internal_logger_level`
Sets the internal logging level for the Grafana JavaScript agent. Allowed values are `0` (OFF), `1` (ERROR), `2` (WARN), `3` (INFO), and `4` (VERBOSE). Default is `0`.
#### `instrumentations_console_enabled`
Enables the [Console instrumentation](https://grafana.com/docs/grafana-cloud/monitor-applications/frontend-observability/instrument/console-instrumentation/) for Grafana Faro, defaults to `true`.
@@ -2010,29 +1970,7 @@ This setting has precedence over each individual rule frequency.
If a rule frequency is lower than this value, then this value is enforced.
{{< /admonition >}}
#### `state_periodic_save_interval`
If the `alertingSaveStatePeriodic` feature flag is enabled, sets the interval used to persist alerting instances to the database. Specify a duration (for example, `5m`). Default is `5m`.
#### `state_periodic_save_batch_size`
If the `alertingSaveStatePeriodic` feature flag is enabled, sets the size of the batch that is saved to the database at once. Default is `1`.
#### `state_periodic_save_jitter_enabled`
Enables jitter for periodic state saving to distribute database load over time. When enabled, batches are spread across the save interval to prevent load spikes. Default is `false`.
#### `disable_jitter`
Disables smoothing of alert evaluations across their evaluation window. When set to `true`, rules evaluate in sync. Default is `false`.
#### `notification_log_retention`
Retention period for Alertmanager notification log entries. Specify a duration (for example, `5d`). Default is `5d`.
#### `resolved_alert_retention`
Duration for which a resolved alert state transition continues to be sent to the Alertmanager. Specify a duration (for example, `15m`). Default is `15m`.
<hr>
#### `rule_version_record_limit`
@@ -2040,12 +1978,6 @@ Defines the limits for how many alert rule versions are stored in the database p
The default `0` value means there's no limit.
#### `deleted_rule_retention`
Retention period for deleted alerting rules before permanent removal. Specify a duration (for example, `30d`). Default is `30d`. Setting `0` deletes rules immediately.
<hr>
### `[unified_alerting.screenshots]`
For more information about screenshots, refer to [Images in notifications](../../alerting/configure-notifications/template-notifications/images-in-notifications/).
@@ -2085,86 +2017,6 @@ For example: `disabled_labels=grafana_folder`
<hr>
### `[unified_alerting.state_history]`
Configure state history for Unified Alerting. Previous alert rule states can be queried in panels and viewed in the UI.
#### `enabled`
Enable or disable the state history functionality. Default: `true`.
#### `backend`
Select the backend for state history. Options: `annotations`, `loki`, `prometheus`, `multiple`. Default: `annotations`.
The backends provide different storage and query characteristics:
- **annotations:** Stores alert state transitions as Grafana annotations in the local database.
- **loki:** Writes alert state history to an external Loki instance. Requires Loki connection configuration in this section.
- **prometheus:** Emits alert state as `GRAFANA_ALERTS` metrics to a Prometheus-compatible data source. Requires Prometheus target configuration in this section.
- **multiple:** Writes state history to more than one backend at the same time. Use `primary` to select which backend serves queries, and `secondaries` for additional write targets.
Backend-specific configuration requirements:
- When `backend = annotations`, no additional keys in this section are required.
- When a Loki backend is used in any capacity (for example, `backend = loki`, or `backend = multiple` with Loki as `primary` or present in `secondaries`) you must set either `loki_remote_url` or both `loki_remote_read_url` and `loki_remote_write_url`.
- When a Prometheus backend is used in any capacity (for example, `backend = prometheus`, or `backend = multiple` with Prometheus present in `secondaries`) you must set `prometheus_target_datasource_uid`.
- When `backend = multiple`, set `primary` and `secondaries`.
#### `primary`
For `multiple` backend only. Sets the primary backend used to serve queries. Options: `annotations`, `loki`.
#### `secondaries`
For `multiple` backend only. Comma-separated list of additional backends to write state history to.
#### `loki_remote_url`
For `loki` backend. URL of the external Loki instance.
#### `loki_remote_read_url`
For `loki` backend. Read URL when Loki read/write endpoints are separated.
#### `loki_remote_write_url`
For `loki` backend. Write URL when Loki read/write endpoints are separated.
#### `loki_tenant_id`
For `loki` backend. Optional tenant ID to attach to requests.
#### `loki_basic_auth_username`
For `loki` backend. Optional username for basic authentication.
#### `loki_basic_auth_password`
For `loki` backend. Optional password for basic authentication.
#### `loki_max_query_length`
For `loki` backend. Maximum query length duration. Default: `721h`.
#### `loki_max_query_size`
For `loki` backend. Maximum query size in bytes. Default: `65536`.
#### `prometheus_target_datasource_uid`
For `prometheus` backend. Target datasource UID for writing `GRAFANA_ALERTS` metrics.
#### `prometheus_metric_name`
For `prometheus` backend. Metric name for `GRAFANA_ALERTS`. Default: `GRAFANA_ALERTS`.
#### `prometheus_write_timeout`
For `prometheus` backend. Timeout for writing `GRAFANA_ALERTS` metrics. Default: `10s`.
<hr>
### `[unified_alerting.state_history.annotations]`
This section controls retention of annotations automatically created while evaluating alert rules when alerting state history backend is configured to be annotations (see setting [unified_alerting.state_history].backend)
@@ -2179,36 +2031,6 @@ Configures max number of alert annotations that Grafana stores. Default value is
<hr>
### `[unified_alerting.notification_history]`
Enable storage of Alertmanager notification logs in Loki.
#### `enabled`
Enable or disable the notification history functionality. Default: `false`.
#### `loki_remote_url`
URL of the Loki instance used to store logs.
#### `loki_tenant_id`
Optional tenant ID to attach to requests sent to Loki.
#### `loki_basic_auth_username`
Optional username for basic authentication to Loki.
#### `loki_basic_auth_password`
Optional password for basic authentication to Loki.
### `[unified_alerting.notification_history.external_labels]`
Optional extra labels to attach to outbound notification history records or log streams. Provide any number of label key-value pairs.
<hr>
### `[unified_alerting.prometheus_conversion]`
This section applies only to rules imported as Grafana-managed rules. For more information about the import process, refer to [Import data source-managed rules to Grafana-managed rules](/docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/alerting-migration/).
@@ -2219,52 +2041,6 @@ Set the query offset to imported Grafana-managed rules when `query_offset` is no
<hr>
### `[recording_rules]`
Configure recording rules.
#### `enabled`
Enable recording rules. Default: `true`.
#### `timeout`
Request timeout for recording rule writes. Default: `10s`.
#### `default_datasource_uid`
Default data source UID to write to if not specified in the rule definition.
### `[recording_rules.custom_headers]`
Optional custom headers to include in recording rule write requests.
### `[remote.alertmanager]`
Configure a remote Alertmanager to replace the internal one.
#### `url`
Root URL of the remote Alertmanager. Grafana automatically appends `/alertmanager` for certain HTTP calls.
#### `tenant`
Tenant ID used in requests. Also used as basic auth username if a password is configured.
#### `password`
Optional password for basic authentication. If not present, the tenant ID will be set in the X-Scope-OrgID header.
#### `sync_interval`
Interval for syncing with the Alertmanager. Default: `5m`.
#### `timeout`
Timeout for the HTTP client. Default: `30s`.
<hr>
### `[annotations]`
#### `cleanupjob_batchsize`
@@ -3175,8 +2951,6 @@ Move an app plugin (referenced by its id), including all its pages, to a specifi
Move an individual app plugin page (referenced by its `path` field) to a specific navigation section.
Format: `<pageUrl> = <sectionId> <sortWeight>`
<hr>
### `[public_dashboards]`
This section configures the [shared dashboards](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/dashboards/share-dashboards-panels/shared-dashboards/) feature.
@@ -3184,81 +2958,3 @@ This section configures the [shared dashboards](https://grafana.com/docs/grafana
#### `enabled`
Set this to `false` to disable the shared dashboards feature. This prevents users from creating new shared dashboards and disables existing ones.
<hr>
### `[cloud_migration]`
Configure the Grafana Cloud Migration Assistant.
#### `enabled`
Enable or disable the Cloud Migration Assistant. Default is `true`.
#### `is_target`
Enable the target-side migration UI. Default is `false`.
#### `gcom_api_token`
Token used to send requests to Grafana com. Default is empty.
#### `start_snapshot_timeout`
Timeout for requests to start a snapshot. Default is `5s`.
#### `validate_key_timeout`
Timeout for requests to validate a key. Default is `5s`.
#### `get_snapshot_status_timeout`
Timeout for requests to get snapshot status. Default is `5s`.
#### `create_upload_url_timeout`
Timeout for requests to create a presigned upload URL. Default is `5s`.
#### `report_event_timeout`
Timeout for requests to report an event. Default is `5s`.
#### `fetch_instance_timeout`
Timeout for requests to fetch an instance. Default is `5s`.
#### `create_access_policy_timeout`
Timeout for requests to create an access policy. Default is `5s`.
#### `fetch_access_policy_timeout`
Timeout for requests to fetch an access policy. Default is `5s`.
#### `delete_access_policy_timeout`
Timeout for requests to delete an access policy. Default is `5s`.
#### `domain`
Domain name used to access the cloud migration service. Default is `grafana.net`.
#### `snapshot_folder`
Folder used to store snapshot files. Default is empty (home dir).
#### `frontend_poll_interval`
Polling interval for the frontend UI while resources are migrating. Default is `2s`.
#### `alert_rules_state`
Controls how alert rules are migrated. Options are `paused` or `unchanged`. Default is `"paused"`.
{{< adomition type="note" >}}
For more information, refer to the [Prevent duplicated alert notifications](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/migration-guide/cloud-migration-assistant/#prevent-duplicated-alert-notifications) documentation.
{{< /admonition >}}
#### `resource_storage_type`
Resource snapshot storage type. Options are `db` (database) or `fs` (file system). Default is `"db"`.
@@ -22,7 +22,7 @@ weight: 100
# Node graph
Node graphs are useful when you need to visualize elements that are related to each other. This is done by displaying circles&mdash;or _nodes_&mdash;for each element you want to visualize, connected by lines&mdash;or _edges_. By default, the visualization uses a [layered layout](#layout-algorithm) that positions the nodes into a network of connected circles.
Node graphs are useful when you need to visualize elements that are related to each other. This is done by displaying circles&mdash;or _nodes_&mdash;for each element you want to visualize, connected by lines&mdash;or _edges_. The visualization uses a directed force layout that positions the nodes into a network of connected circles.
Node graphs display useful information about each node, as well as the relationships between them, allowing you to visualize complex infrastructure maps, hierarchies, or execution diagrams.
@@ -123,32 +123,26 @@ You can pan the view by clicking outside any node or edge and dragging your mous
Use the buttons in the lower right corner to zoom in or out. You can also use the mouse wheel or touchpad scroll, together with either Ctrl or Cmd key to do so.
### Switch layouts
Switch quickly between displaying the visualization in graph or grid [layout](#layout-algorithm).
Click a node and select either **Show in Grid layout** or **Show in Graph layout**, depending on the current layout of the visualization:
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid-menu.png" max-width="750px" alt="Node graph in grid layout with node menu open" >}}
In grid layout, you can sort nodes by clicking on the stats inside the legend.
The marker next to the stat name shows which stat is currently used for sorting and the sorting direction:
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-legend-sort.png" max-width="550px" alt="Node graph legend sorting" >}}
Switching between grid and other layouts this way only changes the layout temporarily.
The visualization maintains the layout algorithm selected in the panel editor, and reverts to it when the dashboard refreshes.
For more information about layouts, refer to [Layout algorithm](#layout-algorithm).
<!-- if you have the panel in grid layout and switch it to graph, is it switching to layered? -->
### Hidden nodes
The number of nodes shown at a given time is limited to maintain a reasonable visualization performance. Nodes that are not currently visible are hidden behind clickable markers that show an approximate number of hidden nodes that are connected by a particular edge. You can click on the marker to expand the graph around that node.
![Node graph exploration](/media/docs/grafana/panels-visualizations/node-graph-exploration-8.0-2.png 'Node graph exploration')
### Grid view
You can switch to the grid view to have a better overview of the most interesting nodes in the graph. Grid view shows nodes in a grid without edges and can be sorted by stats shown inside the node or by stats represented by the a colored border of the nodes.
![Node graph grid](/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid-v11.3.png 'Node graph grid')
To sort the nodes, click on the stats inside the legend. The marker next to the stat name shows which stat is currently used for sorting and sorting direction.
![Node graph legend](/media/docs/grafana/panels-visualizations/screenshot-node-graph-legend-v11.3.png 'Node graph legend')
Click on the node and select "Show in Graph layout" option to switch back to graph layout and focus on the selected node, to show it in context of the full graph.
![Node graph grid to default](/media/docs/grafana/panels-visualizations/screenshot-node-graph-view-v11.3.png 'Node graph grid to default')
## Configuration options
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
@@ -161,24 +155,7 @@ The number of nodes shown at a given time is limited to maintain a reasonable vi
Use the following options to refine your node graph visualization.
#### Zoom mode
Choose how the node graph should handle zoom and scroll events:
- **Cooperative** - Allows you to scroll the visualization normally.
- **Greedy** - Reacts to all zoom gestures.
#### Layout algorithm
Choose how the visualization layout is generated:
- **Layered** - Default. Creates a predictable and orderly layout, especially useful for service graphs.
- **Force** - Uses a physics-based force layout algorithm that's useful with a large number of nodes (500+).
- **Grid** - Arranges nodes into a grid format to provide a better overview of the most interesting nodes in the graph. This layout shows nodes in a grid without edges and can be sorted by the stats shown inside the node or by the ones represented by the a colored border of the nodes.
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid.png" max-width="650px" alt="Node graph in grid layout" >}}
For more information about using the graph in grid layout, refer to [Switch layouts](#switch-layouts).
- **Zoom mode** - Choose how the node graph should handle zoom and scroll events.
### Nodes options
@@ -262,6 +239,6 @@ Optional fields:
| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behavior depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. |
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/iconography-icon--icons-overview) are allowed. |
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/iconography-icon--icons-overview)) are allowed. |
| nodeRadius | number | Radius value in pixels. Used to manage node size. |
| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` |
+2 -2
View File
@@ -296,8 +296,8 @@
"@grafana/plugin-ui": "^0.11.1",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "6.47.1",
"@grafana/scenes-react": "6.47.1",
"@grafana/scenes": "^6.48.0",
"@grafana/scenes-react": "^6.48.0",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
@@ -101,6 +101,7 @@ export interface IntervalVariableModel extends VariableWithOptions {
export interface CustomVariableModel extends VariableWithMultiSupport {
type: 'custom';
valuesFormat?: 'csv' | 'json';
}
export interface DataSourceVariableModel extends VariableWithMultiSupport {
@@ -316,6 +316,7 @@ export const handyTestingSchema: Spec = {
query: 'option1, option2',
skipUrlSync: false,
allowCustomValue: true,
valuesFormat: 'csv',
},
},
{
@@ -1335,6 +1335,7 @@ export interface CustomVariableSpec {
skipUrlSync: boolean;
description?: string;
allowCustomValue: boolean;
valuesFormat?: "csv" | "json";
}
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
+4
View File
@@ -150,6 +150,10 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/connections/datasources/edit/*", authorize(datasources.EditPageAccess), hs.Index)
r.Get("/connections", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/add-new-connection", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/stacks", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/stacks/new", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/stacks/edit/*", authorize(datasources.ConfigurationPageAccess), hs.Index)
// Plugin details pages
r.Get("/connections/datasources/:id", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
r.Get("/connections/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
@@ -0,0 +1,88 @@
package collections
import (
"context"
"fmt"
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/datasources/service/client"
"k8s.io/apiserver/pkg/admission"
)
var _ builder.APIGroupValidation = (*DatasourceStacksValidator)(nil)
type DatasourceStacksValidator struct {
dsClient client.DataSourceConnectionClient
}
func GetDatasourceStacksValidator(dsClient client.DataSourceConnectionClient) builder.APIGroupValidation {
return &DatasourceStacksValidator{dsClient: dsClient}
}
func (v *DatasourceStacksValidator) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
obj := a.GetObject()
operation := a.GetOperation()
if operation == admission.Connect {
return fmt.Errorf("Connect operation is not allowed (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
}
if operation != admission.Create && operation != admission.Update {
return nil
}
cast, ok := obj.(*collections.DataSourceStack)
if !ok {
return fmt.Errorf("object is not of type *collections.DataSourceStack (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
}
// get the keys from the template
template := cast.Spec.Template
templateNames := map[string]bool{}
for _, item := range template {
// template items cannot be empty
if item.Group == "" || item.Name == "" {
return fmt.Errorf("template items cannot be empty (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
}
// template names must be unique
if _, exists := templateNames[item.Name]; exists {
return fmt.Errorf("template item names must be unique. name '%s' already exists (%s %s)", item.Name, a.GetName(), a.GetKind().GroupVersion().String())
}
templateNames[item.Name] = true
}
// for each mode, check that the keys are in the template
modes := cast.Spec.Modes
for _, mode := range modes {
for key, item := range mode.Definition {
// if a key is not in the template, return an error
if _, ok := template[key]; !ok {
return fmt.Errorf("key '%s' is not in the DataSourceStack template (%s %s)", key, a.GetName(), a.GetKind().GroupVersion().String())
}
exists, err := v.checkDatasourceExists(ctx, item.DataSourceRef)
if err != nil || !exists {
return fmt.Errorf("datasource '%s' in group '%s' does not exist (%s %s): %w", item.DataSourceRef, template[key].Group, a.GetName(), a.GetKind().GroupVersion().String(), err)
}
}
}
return nil
}
func (v *DatasourceStacksValidator) checkDatasourceExists(ctx context.Context, name string) (bool, error) {
dsConn, err := v.dsClient.GetByUID(ctx, name)
if err != nil {
return false, err
}
if dsConn == nil {
return false, nil
}
return true, nil
}
@@ -0,0 +1,212 @@
package collections_test
import (
"context"
"testing"
collectionsv1alpha1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
queryv0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/collections"
datasourcesclient "github.com/grafana/grafana/pkg/services/datasources/service/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
)
func TestDataSourceValidator_Validate(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
operation admission.Operation
object runtime.Object
needMockDSClient bool // only set to true if you expect to make a call to the datasource client
dsClientReturnValue *queryv0alpha1.DataSourceConnection
dsClientReturnError error
expectError bool
errorMsg string
}{
{
name: "should return no error for invalid kind",
operation: admission.Delete,
object: &collectionsv1alpha1.Stars{},
expectError: false,
},
{
name: "should return error for Connect operation",
operation: admission.Connect,
object: &collectionsv1alpha1.DataSourceStack{},
expectError: true,
},
{
name: "template items cannot be empty",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{},
},
},
},
expectError: true,
errorMsg: "template items cannot be empty (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "template item name keys must be unique",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
"key2": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
},
},
expectError: true,
errorMsg: "template item names must be unique. name 'foo' already exists (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "mode keys must exist in the template",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
{
Name: "prod",
Definition: collectionsv1alpha1.DataSourceStackMode{
"notintemplate": collectionsv1alpha1.DataSourceStackModeItem{
DataSourceRef: "foo",
},
},
},
},
},
},
expectError: true,
errorMsg: "key 'notintemplate' is not in the DataSourceStack template (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "error if data source does not exist",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
{
Name: "prod",
Definition: collectionsv1alpha1.DataSourceStackMode{
"key1": collectionsv1alpha1.DataSourceStackModeItem{
DataSourceRef: "ref",
},
},
},
},
},
},
needMockDSClient: true,
dsClientReturnValue: nil, // no result - this is the default anyway
expectError: true,
errorMsg: "datasource 'ref' in group 'foo.grafana' does not exist (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "valid request",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
{
Name: "prod",
Definition: collectionsv1alpha1.DataSourceStackMode{
"key1": collectionsv1alpha1.DataSourceStackModeItem{
DataSourceRef: "ref",
},
},
},
},
},
},
needMockDSClient: true,
dsClientReturnValue: &queryv0alpha1.DataSourceConnection{}, // returning any non-nil value will pass validation
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
attrs := &FakeAdmissionAttributes{
Operation: tt.operation,
Object: tt.object,
Name: "test-datasourcestack",
Kind: schema.GroupVersionKind{Group: "collections.grafana.app", Version: "v1alpha1", Kind: "DataSourceStack"},
}
var client *datasourcesclient.MockDataSourceConnectionClient
if tt.needMockDSClient {
client = datasourcesclient.NewMockDataSourceConnectionClient(t)
client.On("GetByUID", mock.Anything, mock.Anything).Return(tt.dsClientReturnValue, tt.dsClientReturnError)
}
validator := collections.GetDatasourceStacksValidator(client)
err := validator.Validate(ctx, attrs, nil)
if tt.expectError {
assert.Error(t, err)
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
type FakeAdmissionAttributes struct {
admission.Attributes
Operation admission.Operation
Object runtime.Object
Name string
Kind schema.GroupVersionKind
}
func (m *FakeAdmissionAttributes) GetOperation() admission.Operation {
return m.Operation
}
func (m *FakeAdmissionAttributes) GetObject() runtime.Object {
return m.Object
}
func (m *FakeAdmissionAttributes) GetName() string {
return m.Name
}
func (m *FakeAdmissionAttributes) GetKind() schema.GroupVersionKind {
return m.Kind
}
+56 -11
View File
@@ -1,11 +1,13 @@
package collections
import (
"context"
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
@@ -14,13 +16,16 @@ import (
"k8s.io/kube-openapi/pkg/validation/spec"
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/registry/apis/collections/legacy"
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
datasourcesClient "github.com/grafana/grafana/pkg/services/datasources/service/client"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/star"
"github.com/grafana/grafana/pkg/services/user"
@@ -29,13 +34,15 @@ import (
)
var (
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupMutation = (*APIBuilder)(nil)
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupMutation = (*APIBuilder)(nil)
_ builder.APIGroupValidation = (*APIBuilder)(nil)
)
type APIBuilder struct {
authorizer authorizer.Authorizer
legacyStars *legacy.DashboardStarsStorage
authorizer authorizer.Authorizer
legacyStars *legacy.DashboardStarsStorage
datasourceStacksValidator builder.APIGroupValidation
}
func RegisterAPIService(
@@ -45,6 +52,8 @@ func RegisterAPIService(
stars star.Service,
users user.Service,
apiregistration builder.APIRegistrar,
dsConnClientFactory datasourcesClient.DataSourceConnectionClientFactory,
restConfigProvider apiserver.RestConfigProvider,
) *APIBuilder {
// Requires development settings and clearly experimental
//nolint:staticcheck // not yet migrated to OpenFeature
@@ -52,11 +61,15 @@ func RegisterAPIService(
return nil
}
dsConnClient := dsConnClientFactory(restConfigProvider)
sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db))
builder := &APIBuilder{
datasourceStacksValidator: GetDatasourceStacksValidator(dsConnClient),
authorizer: &utils.AuthorizeFromName{
Resource: map[string][]utils.ResourceOwner{
"stars": {utils.UserResourceOwner},
"stars": {utils.UserResourceOwner},
"datasources": {utils.UserResourceOwner},
},
},
}
@@ -94,28 +107,60 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
storage := map[string]rest.Storage{}
// Configure Stars Dual writer
resource := collections.StarsResourceInfo
starsResource := collections.StarsResourceInfo
var stars grafanarest.Storage
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter)
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, starsResource, opts.OptsGetter)
if err != nil {
return err
}
stars = &starStorage{Storage: stars} // wrap List so we only return one value
if b.legacyStars != nil && opts.DualWriteBuilder != nil {
stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars)
stars, err = opts.DualWriteBuilder(starsResource.GroupResource(), b.legacyStars, stars)
if err != nil {
return err
}
}
storage[resource.StoragePath()] = stars
storage[resource.StoragePath("update")] = &starsREST{store: stars}
storage[starsResource.StoragePath()] = stars
storage[starsResource.StoragePath("update")] = &starsREST{store: stars}
// no need for dual writer for a kind that does not exist in the legacy database
resourceInfo := collections.DatasourceStacksResourceInfo
datasourcesStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, resourceInfo, opts.OptsGetter)
storage[resourceInfo.StoragePath()] = datasourcesStorage
apiGroupInfo.VersionedResourcesStorageMap[collections.APIVersion] = storage
return nil
}
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
if a.GetKind().Group == collections.DatasourceStacksResourceInfo.GroupResource().Group {
return b.datasourceStacksValidator.Validate(ctx, a, o)
}
return nil
}
func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
return b.authorizer
return authorizer.AuthorizerFunc(
func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
if attr.GetResource() == "stars" {
return b.authorizer.Authorize(ctx, attr)
}
// datasources auth branch starts
if !attr.IsResourceRequest() {
return authorizer.DecisionNoOpinion, "", nil
}
// require a user
_, err = identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "valid user is required", err
}
// TODO make the auth more restrictive
return authorizer.DecisionAllow, "", nil
})
}
func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
+1 -1
View File
@@ -85,7 +85,7 @@ func RegisterAPIService(
accessControl,
//nolint:staticcheck // not yet migrated to OpenFeature
features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
false,
true,
)
if err != nil {
return nil, err
+44 -11
View File
@@ -15,6 +15,7 @@ import (
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/datasources"
"k8s.io/apimachinery/pkg/fields"
)
var (
@@ -28,11 +29,11 @@ var (
// Get all datasource connections -- this will be backed by search or duplicated resource in unified storage
type DataSourceConnectionProvider interface {
// Get gets a specific datasource (that the user in context can see)
// The name is {group}:{name}, see /pkg/apis/query/v0alpha1/connection.go#L34
// The name is the legacy datasource UID.
GetConnection(ctx context.Context, namespace string, name string) (*queryV0.DataSourceConnection, error)
// List lists all data sources the user in context can see
ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error)
// List lists all data sources the user in context can see. Optional field selectors can filter the results.
ListConnections(ctx context.Context, namespace string, fieldSelector fields.Selector) (*queryV0.DataSourceConnectionList, error)
}
type connectionAccess struct {
@@ -74,7 +75,11 @@ func (s *connectionAccess) Get(ctx context.Context, name string, options *metav1
}
func (s *connectionAccess) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
return s.connections.ListConnections(ctx, request.NamespaceValue(ctx))
var fs fields.Selector
if options != nil && options.FieldSelector != nil {
fs = options.FieldSelector
}
return s.connections.ListConnections(ctx, request.NamespaceValue(ctx), fs)
}
type connectionsProvider struct {
@@ -103,19 +108,47 @@ func (q *connectionsProvider) GetConnection(ctx context.Context, namespace strin
return q.asConnection(ds, namespace)
}
func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error) {
func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string, fieldSelector fields.Selector) (*queryV0.DataSourceConnectionList, error) {
ns, err := authlib.ParseNamespace(namespace)
if err != nil {
return nil, err
}
dss, err := q.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{
OrgID: ns.OrgID,
DataSourceLimit: 10000,
})
if err != nil {
return nil, err
var dss []*datasources.DataSource
// if fieldSelector is not nil, find any uids in the metadata.name field and
// use them in the query
if fieldSelector != nil && !fieldSelector.Empty() {
uids := []string{}
for _, req := range fieldSelector.Requirements() {
if req.Field == "metadata.name" {
uids = append(uids, req.Value)
}
}
// We don't have a way to fetch a subset of datasources by UID in the legacy
// datasource service, so fetch them one by one.
if len(uids) > 0 {
for _, uid := range uids {
ds, err := q.dsService.GetDataSource(ctx, &datasources.GetDataSourceQuery{
UID: uid,
OrgID: ns.OrgID,
})
if err != nil {
return nil, err
}
dss = append(dss, ds)
}
}
} else {
dss, err = q.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{
OrgID: ns.OrgID,
DataSourceLimit: 10000,
})
if err != nil {
return nil, err
}
}
result := &queryV0.DataSourceConnectionList{
Items: []queryV0.DataSourceConnection{},
}
+2
View File
@@ -88,6 +88,7 @@ import (
"github.com/grafana/grafana/pkg/services/datasourceproxy"
"github.com/grafana/grafana/pkg/services/datasources"
datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service"
datasourcesclient "github.com/grafana/grafana/pkg/services/datasources/service/client"
"github.com/grafana/grafana/pkg/services/dsquerierclient"
"github.com/grafana/grafana/pkg/services/encryption"
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
@@ -476,6 +477,7 @@ var wireBasicSet = wire.NewSet(
appregistry.WireSet,
// Dashboard Kubernetes helpers
dashboardclient.ProvideK8sClientWithFallback,
datasourcesclient.ProvideDataSourceConnectionClientFactory,
)
var wireSet = wire.NewSet(
+6 -3
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,147 @@
package client
import (
"context"
"errors"
"net/http"
datasourcev0alpha1 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
queryv0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver"
"k8s.io/client-go/kubernetes"
)
// DataSourceConnectionClient can get information about data source connections.
//
//go:generate mockery --name DataSourceConnectionClient --structname MockDataSourceConnectionClient --inpackage --filename=client_mock.go --with-expecter
type DataSourceConnectionClient interface {
GetByUID(ctx context.Context, uid string) (*queryv0alpha1.DataSourceConnection, error)
}
func ProvideDataSourceConnectionClientFactory(
restConfigProvider apiserver.RestConfigProvider,
) DataSourceConnectionClientFactory {
return func(configProvider apiserver.RestConfigProvider) DataSourceConnectionClient {
return &dataSourceConnectionClient{
configProvider: configProvider,
}
}
}
type DataSourceConnectionClientFactory func(configProvider apiserver.RestConfigProvider) DataSourceConnectionClient
type dataSourceConnectionClient struct {
configProvider apiserver.RestConfigProvider
}
func (dc *dataSourceConnectionClient) Get(ctx context.Context, group, version, name string) (*queryv0alpha1.DataSourceConnection, error) {
cfg, err := dc.configProvider.GetRestConfig(ctx)
if err != nil {
return nil, err
}
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
if version == "" {
version = "v0alpha1"
}
result := client.RESTClient().Get().
Prefix("apis", group, version).
Namespace("default"). // TODO do something about namespace
Resource("datasources").
Name(name).
Do(ctx)
if err = result.Error(); err != nil {
return nil, err
}
var statusCode int
result = result.StatusCode(&statusCode)
if statusCode == http.StatusNotFound {
return nil, errors.New("not found")
}
fullDS := datasourcev0alpha1.DataSource{}
err = result.Into(&fullDS)
if err != nil {
return nil, err
}
dsConnection := &queryv0alpha1.DataSourceConnection{
Title: fullDS.Spec.Title(),
Datasource: queryv0alpha1.DataSourceConnectionRef{
Group: fullDS.GroupVersionKind().Group,
Name: fullDS.ObjectMeta.Name,
Version: fullDS.GroupVersionKind().Version,
},
}
return dsConnection, nil
}
func (dc *dataSourceConnectionClient) GetByUID(ctx context.Context, uid string) (*queryv0alpha1.DataSourceConnection, error) {
cfg, err := dc.configProvider.GetRestConfig(ctx)
if err != nil {
return nil, err
}
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
// use the list endpoint with a fieldSelector so that can get multiple results
// in the case of a non-unique "uid". This should not be possible when we are
// backed by the legacy database, but wont be guaranteed when we are using
// uniStore as the names will not be guaranteed unique across apiGroups. We
// error below if more than one result is returned.
result := client.RESTClient().Get().
Prefix("apis", "query.grafana.app", "v0alpha1").
Namespace("default"). // TODO do something about namespace
Resource("connections").
Param("fieldSelector", "metadata.name="+uid).
Do(ctx)
if err = result.Error(); err != nil {
return nil, err
}
var statusCode int
result = result.StatusCode(&statusCode)
if statusCode == http.StatusNotFound {
return nil, errors.New("not found")
}
dsList := datasourcev0alpha1.DataSourceList{}
err = result.Into(&dsList)
if err != nil {
return nil, err
}
if len(dsList.Items) == 0 {
return nil, errors.New("not found")
}
if len(dsList.Items) > 1 {
return nil, errors.New("multiple connections found")
}
fullDS := dsList.Items[0]
dsConnection := &queryv0alpha1.DataSourceConnection{
Title: fullDS.Spec.Title(),
Datasource: queryv0alpha1.DataSourceConnectionRef{
Group: fullDS.GroupVersionKind().Group,
Name: fullDS.ObjectMeta.Name,
Version: fullDS.GroupVersionKind().Version,
},
}
return dsConnection, nil
}
@@ -0,0 +1,96 @@
// Code generated by mockery v2.53.3. DO NOT EDIT.
package client
import (
context "context"
v0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockDataSourceConnectionClient is an autogenerated mock type for the DataSourceConnectionClient type
type MockDataSourceConnectionClient struct {
mock.Mock
}
type MockDataSourceConnectionClient_Expecter struct {
mock *mock.Mock
}
func (_m *MockDataSourceConnectionClient) EXPECT() *MockDataSourceConnectionClient_Expecter {
return &MockDataSourceConnectionClient_Expecter{mock: &_m.Mock}
}
// GetByUID provides a mock function with given fields: ctx, uid
func (_m *MockDataSourceConnectionClient) GetByUID(ctx context.Context, uid string) (*v0alpha1.DataSourceConnection, error) {
ret := _m.Called(ctx, uid)
if len(ret) == 0 {
panic("no return value specified for GetByUID")
}
var r0 *v0alpha1.DataSourceConnection
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*v0alpha1.DataSourceConnection, error)); ok {
return rf(ctx, uid)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *v0alpha1.DataSourceConnection); ok {
r0 = rf(ctx, uid)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v0alpha1.DataSourceConnection)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, uid)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockDataSourceConnectionClient_GetByUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByUID'
type MockDataSourceConnectionClient_GetByUID_Call struct {
*mock.Call
}
// GetByUID is a helper method to define mock.On call
// - ctx context.Context
// - uid string
func (_e *MockDataSourceConnectionClient_Expecter) GetByUID(ctx interface{}, uid interface{}) *MockDataSourceConnectionClient_GetByUID_Call {
return &MockDataSourceConnectionClient_GetByUID_Call{Call: _e.mock.On("GetByUID", ctx, uid)}
}
func (_c *MockDataSourceConnectionClient_GetByUID_Call) Run(run func(ctx context.Context, uid string)) *MockDataSourceConnectionClient_GetByUID_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *MockDataSourceConnectionClient_GetByUID_Call) Return(_a0 *v0alpha1.DataSourceConnection, _a1 error) *MockDataSourceConnectionClient_GetByUID_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockDataSourceConnectionClient_GetByUID_Call) RunAndReturn(run func(context.Context, string) (*v0alpha1.DataSourceConnection, error)) *MockDataSourceConnectionClient_GetByUID_Call {
_c.Call.Return(run)
return _c
}
// NewMockDataSourceConnectionClient creates a new instance of MockDataSourceConnectionClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockDataSourceConnectionClient(t interface {
mock.TestingT
Cleanup(func())
}) *MockDataSourceConnectionClient {
mock := &MockDataSourceConnectionClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
+1 -1
View File
@@ -221,7 +221,7 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode
// Add Service Center as a standalone nav item under Alerts & IRM
if alertsSection := treeRoot.FindById(navtree.NavIDAlertsAndIncidents); alertsSection != nil {
serviceLink := &navtree.NavLink{
Text: "Service center",
Text: "Service Center",
Id: "standalone-plugin-page-slo-services",
Url: s.cfg.AppSubURL + "/a/grafana-slo-app/services",
SortWeight: 1,
@@ -574,6 +574,15 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *n
Url: baseUrl + "/datasources",
Children: []*navtree.NavLink{},
})
// Stacks
children = append(children, &navtree.NavLink{
Id: "connections-stacks",
Text: "Stacks",
SubTitle: "Manage data source stacks for different environments",
Url: baseUrl + "/stacks",
Children: []*navtree.NavLink{},
})
}
if len(children) > 0 {
@@ -182,6 +182,8 @@ export function getNavTitle(navId: string | undefined) {
return t('nav.connections.title', 'Connections');
case 'connections-add-new-connection':
return t('nav.add-new-connections.title', 'Add new connection');
case 'connections-stacks':
return t('nav.stacks.title', 'Stacks');
case 'standalone-plugin-page-/connections/collector':
return t('nav.collector.title', 'Collector');
case 'connections-datasources':
@@ -10,10 +10,13 @@ import { CacheFeatureHighlightPage } from './pages/CacheFeatureHighlightPage';
import ConnectionsHomePage from './pages/ConnectionsHomePage';
import { DataSourceDashboardsPage } from './pages/DataSourceDashboardsPage';
import { DataSourceDetailsPage } from './pages/DataSourceDetailsPage';
import { DataSourceStacksPage } from './pages/DataSourceStacksPage';
import { DataSourcesListPage } from './pages/DataSourcesListPage';
import { EditDataSourcePage } from './pages/EditDataSourcePage';
import { EditStackPage } from './pages/EditStackPage';
import { InsightsFeatureHighlightPage } from './pages/InsightsFeatureHighlightPage';
import { NewDataSourcePage } from './pages/NewDataSourcePage';
import { NewStackPage } from './pages/NewStackPage';
import { PermissionsFeatureHighlightPage } from './pages/PermissionsFeatureHighlightPage';
function RedirectToAddNewConnection() {
@@ -41,6 +44,9 @@ export default function Connections() {
{/* The route paths need to be relative to the parent path (ROUTES.Base), so we need to remove that part */}
<Route caseSensitive path={ROUTES.DataSources.replace(ROUTES.Base, '')} element={<DataSourcesListPage />} />
<Route caseSensitive path={ROUTES.DataSourcesNew.replace(ROUTES.Base, '')} element={<NewDataSourcePage />} />
<Route caseSensitive path={ROUTES.Stacks.replace(ROUTES.Base, '')} element={<DataSourceStacksPage />} />
<Route caseSensitive path={ROUTES.StacksNew.replace(ROUTES.Base, '')} element={<NewStackPage />} />
<Route caseSensitive path={ROUTES.StacksEdit.replace(ROUTES.Base, '')} element={<EditStackPage />} />
<Route
caseSensitive
path={ROUTES.DataSourcesDetails.replace(ROUTES.Base, '')}
@@ -75,5 +75,11 @@ export function getOssCardData(): CardData[] {
url: '/connections/datasources',
icon: 'database',
},
{
text: 'Stacks',
subTitle: 'Manage your data source stacks',
url: '/connections/stacks',
icon: 'layers',
},
];
}
@@ -9,6 +9,10 @@ export const ROUTES = {
DataSourcesNew: `/${ROUTE_BASE_ID}/datasources/new`,
DataSourcesEdit: `/${ROUTE_BASE_ID}/datasources/edit/:uid`,
DataSourcesDashboards: `/${ROUTE_BASE_ID}/datasources/edit/:uid/dashboards`,
// Stacks
Stacks: `/${ROUTE_BASE_ID}/stacks`,
StacksNew: `/${ROUTE_BASE_ID}/stacks/new`,
StacksEdit: `/${ROUTE_BASE_ID}/stacks/edit/:uid`,
// Add new connection
AddNewConnection: `/${ROUTE_BASE_ID}/add-new-connection`,
@@ -0,0 +1,252 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import {
Card,
EmptyState,
FilterInput,
IconButton,
LinkButton,
Spinner,
Stack,
TagList,
useStyles2,
} from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { Resource, ResourceList, GroupVersionResource } from 'app/features/apiserver/types';
// Define the DataSourceStack spec type based on the backend Go types
export interface DataSourceStackTemplateItem {
group: string;
name: string;
}
export interface DataSourceStackModeItem {
dataSourceRef: string;
}
export interface DataSourceStackModeSpec {
name: string;
uid: string;
definition: Record<string, DataSourceStackModeItem>;
}
export interface DataSourceStackSpec {
template: Record<string, DataSourceStackTemplateItem>;
modes: DataSourceStackModeSpec[];
}
// GroupVersionResource for datasourcestacks
const datasourceStacksGVR: GroupVersionResource = {
group: 'collections.grafana.app',
version: 'v1alpha1',
resource: 'datasourcestacks',
};
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
export function DataSourceStacksPage() {
const [stacks, setStacks] = useState<Array<Resource<DataSourceStackSpec>>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const styles = useStyles2(getStyles);
const fetchStacks = useCallback(async () => {
try {
setLoading(true);
const response: ResourceList<DataSourceStackSpec> = await datasourceStacksClient.list();
setStacks(response.items);
} catch (err) {
console.error('Failed to fetch datasource stacks:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch datasource stacks');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStacks();
}, [fetchStacks]);
const onDeleteStack = (stackName: string) => async () => {
await datasourceStacksClient.delete(stackName, false);
fetchStacks();
};
// Filter stacks based on search query
const filteredStacks = useMemo(() => {
if (!searchQuery) {
return stacks;
}
const query = searchQuery.toLowerCase();
return stacks.filter((stack) => {
const nameMatch = stack.metadata.name?.toLowerCase().includes(query);
const templateMatch = Object.values(stack.spec.template).some(
(template) => template.name.toLowerCase().includes(query) || template.group.toLowerCase().includes(query)
);
return nameMatch || templateMatch;
});
}, [stacks, searchQuery]);
const actions =
stacks.length > 0 ? (
<LinkButton variant="primary" icon="plus" href="/connections/stacks/new">
<Trans i18nKey="connections.stacks-list-view.add-stack">Add stack</Trans>
</LinkButton>
) : undefined;
const pageNav = {
text: t('connections.stacks-list-view.title', 'Data source stacks'),
subTitle: t(
'connections.stacks-list-view.subtitle',
'Manage your data source stacks to group environments like dev, staging, and production'
),
};
return (
<Page navId="connections-stacks" pageNav={pageNav} actions={actions}>
<Page.Contents>
<DataSourceStacksListContent
stacks={filteredStacks}
loading={loading}
error={error}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
onDeleteStack={onDeleteStack}
styles={styles}
/>
</Page.Contents>
</Page>
);
}
interface DataSourceStacksListContentProps {
stacks: Array<Resource<DataSourceStackSpec>>;
loading: boolean;
error: string | null;
searchQuery: string;
setSearchQuery: (query: string) => void;
styles: ReturnType<typeof getStyles>;
onDeleteStack: (stackName: string) => () => Promise<void>;
}
function DataSourceStacksListContent({
stacks,
loading,
error,
searchQuery,
setSearchQuery,
styles,
onDeleteStack,
}: DataSourceStacksListContentProps) {
if (loading) {
return <Spinner />;
}
if (error) {
return (
<EmptyState
variant="not-found"
message={t('connections.stacks-list-view.error', 'Failed to load data source stacks')}
>
<div>{error}</div>
</EmptyState>
);
}
if (stacks.length === 0 && !searchQuery) {
return (
<EmptyState
message={t(
'connections.stacks-list-view.empty.no-rules-created',
"You haven't created any data source stacks yet"
)}
variant="call-to-action"
>
<div>
<Trans i18nKey="connections.stacks-list-view.empty.description">
Use data source stacks to group environments like dev, stg, and prod. Reference the stack in your query, and
Grafana automatically selects the right data source for that environment.
</Trans>
</div>
<LinkButton variant="primary" icon="plus" size="lg" href="/connections/stacks/new">
<Trans i18nKey="connections.stacks-list-view.empty.new-stack">New stack</Trans>
</LinkButton>
</EmptyState>
);
}
return (
<Stack direction="column" gap={2}>
<div className={styles.searchContainer}>
<FilterInput
value={searchQuery}
onChange={setSearchQuery}
placeholder={t('connections.stacks-list-view.search-placeholder', 'Search by name or type')}
/>
</div>
{stacks.length === 0 && searchQuery ? (
<EmptyState
variant="not-found"
message={t('connections.stacks-list-view.no-results', 'No data source stacks found')}
/>
) : (
<ul className={styles.list}>
{stacks.map((stack) => (
<li key={stack.metadata.name}>
<Card noMargin href={`/connections/stacks/edit/${stack.metadata.name}`}>
<Card.Heading>{stack.metadata.name}</Card.Heading>
<Card.Tags>
<Stack direction="row" gap={2} alignItems="center">
<TagList tags={getDatasourceList(stack.spec)} />
<IconButton
name="trash-alt"
variant="destructive"
aria-label={t('connections.stacks-list-view.delete-stack', 'Delete stack')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeleteStack(stack.metadata.name)();
}}
/>
</Stack>
</Card.Tags>
</Card>
</li>
))}
</ul>
)}
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
searchContainer: css({
marginBottom: theme.spacing(2),
maxWidth: '500px',
}),
list: css({
listStyle: 'none',
display: 'grid',
gap: theme.spacing(1),
}),
});
const getDatasourceList = (stack: DataSourceStackSpec): string[] => {
return Array.from(
// remove duplicates
new Set(
Object.values(stack.template).map((template) => {
const match = template.group.match(/^grafana-(.+)-datasource$/);
if (match && match[1]) {
return match[1].charAt(0).toUpperCase() + match[1].slice(1);
}
return template.name.charAt(0).toUpperCase() + template.name.slice(1);
})
)
);
};
@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { t } from '@grafana/i18n';
import { EmptyState, Spinner } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { Resource, GroupVersionResource } from 'app/features/apiserver/types';
import {
StackForm,
transformStackSpecToFormValues,
} from 'app/features/datasources/components/new-stack-form/StackForm';
import { StackFormValues } from 'app/features/datasources/components/new-stack-form/types';
import { DataSourceStackSpec } from './DataSourceStacksPage';
const datasourceStacksGVR: GroupVersionResource = {
group: 'collections.grafana.app',
version: 'v1alpha1',
resource: 'datasourcestacks',
};
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
export function EditStackPage() {
const { uid } = useParams<{ uid: string }>();
const [stack, setStack] = useState<Resource<DataSourceStackSpec> | null>(null);
const [formValues, setFormValues] = useState<StackFormValues | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchStack = async () => {
if (!uid) {
setError('No stack UID provided');
setLoading(false);
return;
}
try {
setLoading(true);
const response = await datasourceStacksClient.get(uid);
setStack(response);
const values = transformStackSpecToFormValues(response.metadata.name || '', response.spec);
setFormValues(values);
} catch (err) {
console.error('Failed to fetch datasource stack:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch datasource stack');
} finally {
setLoading(false);
}
};
fetchStack();
}, [uid]);
const pageNav = {
text: stack?.metadata.name
? t('connections.edit-stack-page.title-with-name', 'Edit {{name}}', { name: stack.metadata.name })
: t('connections.edit-stack-page.title', 'Edit Data Source Stack'),
subTitle: t('connections.edit-stack-page.subtitle', 'Modify your data source stack configuration'),
};
return (
<Page navId="connections-stacks" pageNav={pageNav}>
<Page.Contents>
<EditStackContent loading={loading} error={error} formValues={formValues} />
</Page.Contents>
</Page>
);
}
interface EditStackContentProps {
loading: boolean;
error: string | null;
formValues: StackFormValues | null;
}
function EditStackContent({ loading, error, formValues }: EditStackContentProps) {
if (loading) {
return <Spinner />;
}
if (error) {
return (
<EmptyState
variant="not-found"
message={t('connections.edit-stack-page.error', 'Failed to load data source stack')}
>
<div>{error}</div>
</EmptyState>
);
}
if (!formValues) {
return (
<EmptyState
variant="not-found"
message={t('connections.edit-stack-page.not-found', 'Data source stack not found')}
/>
);
}
return <StackForm existing={formValues} />;
}
@@ -0,0 +1,19 @@
import { Page } from 'app/core/components/Page/Page';
import { StackForm } from 'app/features/datasources/components/new-stack-form/StackForm';
export function NewStackPage() {
return (
<Page
navId="connections-stacks"
pageNav={{
text: 'New Data Source Stack',
subTitle: 'Add a new data source stack',
active: true,
}}
>
<Page.Contents>
<StackForm />
</Page.Contents>
</Page>
);
}
@@ -171,7 +171,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
>
<div className={cx(styles.rightControls, editPanel && styles.rightControlsWrap)}>
{!hideTimeControls && (
<div className={styles.fixedControls}>
<div className={styles.timeControls}>
<timePicker.Component model={timePicker} />
<refreshPicker.Component model={refreshPicker} />
</div>
@@ -181,11 +181,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
<DashboardControlsButton dashboard={dashboard} />
</div>
)}
{config.featureToggles.dashboardNewLayouts && (
<div className={styles.fixedControls}>
<DashboardControlActions dashboard={dashboard} />
</div>
)}
{config.featureToggles.dashboardNewLayouts && <DashboardControlActions dashboard={dashboard} />}
{!hideLinksControls && !editPanel && <DashboardLinksControls links={links} dashboard={dashboard} />}
</div>
{!hideVariableControls && (
@@ -278,12 +274,12 @@ function getStyles(theme: GrafanaTheme2) {
display: 'flex',
gap: theme.spacing(1),
float: 'right',
alignItems: 'flex-start',
alignItems: 'center',
flexWrap: 'wrap',
maxWidth: '100%',
minWidth: 0,
}),
fixedControls: css({
timeControls: css({
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing(1),
@@ -849,6 +849,7 @@ describe('DashboardSceneSerializer', () => {
query: 'app1',
skipUrlSync: false,
allowCustomValue: true,
valuesFormat: 'csv',
},
},
]);
@@ -294,6 +294,7 @@ exports[`Given a scene with custom quick ranges should save quick ranges to save
"options": [],
"query": "a, b, c",
"type": "custom",
"valuesFormat": "csv",
},
{
"current": {
@@ -679,6 +680,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
"options": [],
"query": "A,B,C,D,E,F,E,G,H,I,J,K,L",
"type": "custom",
"valuesFormat": "csv",
},
{
"current": {
@@ -697,6 +699,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
"options": [],
"query": "Bob : 1, Rob : 2,Sod : 3, Hod : 4, Cod : 5",
"type": "custom",
"valuesFormat": "csv",
},
],
},
@@ -1019,6 +1022,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
"options": [],
"query": "a, b, c",
"type": "custom",
"valuesFormat": "csv",
},
{
"current": {
@@ -1378,6 +1382,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
"options": [],
"query": "a, b, c",
"type": "custom",
"valuesFormat": "csv",
},
{
"current": {
@@ -197,6 +197,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
"options": [],
"query": "option1, option2",
"skipUrlSync": false,
"valuesFormat": "csv",
},
},
{
@@ -374,6 +374,7 @@ describe('sceneVariablesSetToVariables', () => {
"options": [],
"query": "test,test1,test2",
"type": "custom",
"valuesFormat": "csv",
}
`);
});
@@ -1148,6 +1149,7 @@ describe('sceneVariablesSetToVariables', () => {
"options": [],
"query": "test,test1,test2",
"skipUrlSync": false,
"valuesFormat": "csv",
},
}
`);
@@ -110,6 +110,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
allValue: variable.state.allValue,
includeAll: variable.state.includeAll,
allowCustomValue: variable.state.allowCustomValue,
valuesFormat: variable.state.valuesFormat,
});
} else if (sceneUtils.isDataSourceVariable(variable)) {
variables.push({
@@ -392,6 +393,7 @@ export function sceneVariablesSetToSchemaV2Variables(
allValue: variable.state.allValue,
includeAll: variable.state.includeAll ?? false,
allowCustomValue: variable.state.allowCustomValue ?? true,
valuesFormat: variable.state.valuesFormat,
},
};
variables.push(customVariable);
@@ -335,12 +335,12 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
),
});
}
if (variable.kind === defaultCustomVariableKind().kind) {
return new CustomVariable({
...commonProperties,
value: variable.spec.current?.value ?? '',
text: variable.spec.current?.text ?? '',
query: variable.spec.query,
isMulti: variable.spec.multi,
allValue: variable.spec.allValue || undefined,
@@ -348,6 +348,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
defaultToAll: Boolean(variable.spec.includeAll),
skipUrlSync: variable.spec.skipUrlSync,
hide: transformVariableHideToEnumV1(variable.spec.hide),
valuesFormat: variable.spec.valuesFormat || 'csv',
});
} else if (variable.kind === defaultQueryVariableKind().kind) {
return new QueryVariable({
@@ -9,7 +9,7 @@ import { Trans, t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { SceneVariable } from '@grafana/scenes';
import { VariableHide, defaultVariableModel } from '@grafana/schema';
import { Button, LoadingPlaceholder, ConfirmModal, ModalsController, Stack, useStyles2 } from '@grafana/ui';
import { Button, ConfirmModal, LoadingPlaceholder, ModalsController, Stack, useStyles2 } from '@grafana/ui';
import { VariableHideSelect } from 'app/features/dashboard-scene/settings/variables/components/VariableHideSelect';
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField';
@@ -68,6 +68,9 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
const onHideChange = (hide: VariableHide) => variable.setState({ hide });
const isHasVariableOptions = hasVariableOptions(variable);
const optionsForSelect = isHasVariableOptions ? variable.getOptionsForSelect(false) : [];
const hasJsonValuesFormat = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json';
const hasMultiProps = hasJsonValuesFormat || optionsForSelect.every((o) => Boolean(o.properties));
const onDeleteVariable = (hideModal: () => void) => () => {
reportInteraction('Delete variable');
@@ -123,7 +126,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
{EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />}
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
<div className={styles.buttonContainer}>
<Stack gap={2}>
@@ -1,4 +1,5 @@
import { render, fireEvent } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { selectors } from '@grafana/e2e-selectors';
@@ -130,4 +131,71 @@ describe('CustomVariableForm', () => {
expect(onMultiChange).not.toHaveBeenCalled();
expect(onIncludeAllChange).not.toHaveBeenCalled();
});
describe('JSON values format', () => {
test('should render the form fields correctly', async () => {
const { getByTestId, queryByTestId } = render(
<CustomVariableForm
query="query"
valuesFormat="json"
multi={true}
allowCustomValue={true}
includeAll={true}
allValue="custom value"
onQueryChange={onQueryChange}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onAllValueChange={onAllValueChange}
onAllowCustomValueChange={onAllowCustomValueChange}
/>
);
await userEvent.click(screen.getByText('Object values in a JSON array'));
const multiCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
);
const allowCustomValueCheckbox = queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
);
const includeAllCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
);
const allValueInput = queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
);
expect(multiCheckbox).toBeInTheDocument();
expect(multiCheckbox).toBeChecked();
expect(includeAllCheckbox).toBeInTheDocument();
expect(includeAllCheckbox).toBeChecked();
expect(allowCustomValueCheckbox).not.toBeInTheDocument();
expect(allValueInput).not.toBeInTheDocument();
});
test('should display validation error', async () => {
const validationError = new Error('Ooops! Validation error.');
const { findByText } = render(
<CustomVariableForm
query="query"
valuesFormat="json"
queryValidationError={validationError}
multi={false}
includeAll={false}
onQueryChange={onQueryChange}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onAllValueChange={onAllValueChange}
onAllowCustomValueChange={onAllowCustomValueChange}
/>
);
await userEvent.click(screen.getByText('Object values in a JSON array'));
const errorEl = await findByText(validationError.message);
expect(errorEl).toBeInTheDocument();
});
});
});
@@ -1,7 +1,9 @@
import { FormEvent } from 'react';
import { CustomVariableModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { FieldValidationMessage, Icon, RadioButtonGroup, Stack, TextLink, Tooltip } from '@grafana/ui';
import { SelectionOptionsForm } from './SelectionOptionsForm';
import { VariableLegend } from './VariableLegend';
@@ -9,10 +11,12 @@ import { VariableTextAreaField } from './VariableTextAreaField';
interface CustomVariableFormProps {
query: string;
valuesFormat?: CustomVariableModel['valuesFormat'];
multi: boolean;
allValue?: string | null;
includeAll: boolean;
allowCustomValue?: boolean;
queryValidationError?: Error;
onQueryChange: (event: FormEvent<HTMLTextAreaElement>) => void;
onMultiChange: (event: FormEvent<HTMLInputElement>) => void;
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
@@ -20,19 +24,23 @@ interface CustomVariableFormProps {
onQueryBlur?: (event: FormEvent<HTMLTextAreaElement>) => void;
onAllValueBlur?: (event: FormEvent<HTMLInputElement>) => void;
onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void;
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
}
export function CustomVariableForm({
query,
valuesFormat,
multi,
allValue,
includeAll,
allowCustomValue,
queryValidationError,
onQueryChange,
onMultiChange,
onIncludeAllChange,
onAllValueChange,
onAllowCustomValueChange,
onValuesFormatChange,
}: CustomVariableFormProps) {
return (
<>
@@ -40,16 +48,27 @@ export function CustomVariableForm({
<Trans i18nKey="dashboard-scene.custom-variable-form.custom-options">Custom options</Trans>
</VariableLegend>
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
<VariableTextAreaField
name={t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma')}
// we don't use a controlled component so we make sure the textarea content is cleared when changing format by providing a key
key={valuesFormat}
name=""
placeholder={
valuesFormat === 'json'
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'1, 10, mykey : myvalue, myvalue, escaped\,value'
}
defaultValue={query}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="1, 10, mykey : myvalue, myvalue, escaped\,value"
onBlur={onQueryChange}
required
width={52}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
/>
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
<VariableLegend>
<Trans i18nKey="dashboard-scene.custom-variable-form.selection-options">Selection options</Trans>
</VariableLegend>
@@ -58,6 +77,8 @@ export function CustomVariableForm({
includeAll={includeAll}
allValue={allValue}
allowCustomValue={allowCustomValue}
disableAllowCustomValue={valuesFormat === 'json'}
disableCustomAllValue={valuesFormat === 'json'}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onAllValueChange={onAllValueChange}
@@ -66,3 +87,48 @@ export function CustomVariableForm({
</>
);
}
interface ValuesFormatSelectorProps {
valuesFormat?: CustomVariableModel['valuesFormat'];
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
}
export function ValuesFormatSelector({ valuesFormat, onValuesFormatChange }: ValuesFormatSelectorProps) {
return (
<Stack direction="row" gap={1}>
<RadioButtonGroup
value={valuesFormat}
onChange={onValuesFormatChange}
options={[
{
value: 'csv',
label: t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma'),
},
{
value: 'json',
label: t('dashboard-scene.custom-variable-form.name-json-values', 'Object values in a JSON array'),
},
]}
/>
{valuesFormat === 'json' && (
<Tooltip
content={
<Trans i18nKey="dashboard-scene.custom-variable-form.json-values-tooltip">
Provide a JSON representing an array of objects, where each object can have any number of properties.
<br />
Check{' '}
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
our docs
</TextLink>{' '}
for more information.
</Trans>
}
placement="top"
interactive
>
<Icon name="info-circle" />
</Tooltip>
)}
</Stack>
);
}
@@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { QueryVariable } from '@grafana/scenes';
import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema';
import { Field, TextLink } from '@grafana/ui';
import { Box, Field, TextLink } from '@grafana/ui';
import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
import { SelectionOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
@@ -15,9 +15,9 @@ import { getVariableQueryEditor } from 'app/features/variables/editor/getVariabl
import { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect';
import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect';
import {
QueryVariableStaticOptions,
StaticOptionsOrderType,
StaticOptionsType,
QueryVariableStaticOptions,
} from 'app/features/variables/query/QueryVariableStaticOptions';
import { VariableLegend } from './VariableLegend';
@@ -34,6 +34,7 @@ interface QueryVariableEditorFormProps {
timeRange: TimeRange;
regex: string | null;
onRegExChange: (event: FormEvent<HTMLTextAreaElement>) => void;
disableRegexEdition?: boolean;
sort: VariableSort;
onSortChange: (option: SelectableValue<VariableSort>) => void;
refresh: VariableRefresh;
@@ -42,14 +43,17 @@ interface QueryVariableEditorFormProps {
onMultiChange: (event: FormEvent<HTMLInputElement>) => void;
allowCustomValue?: boolean;
onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void;
disableAllowCustomValue?: boolean;
includeAll: boolean;
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
allValue: string;
onAllValueChange: (event: FormEvent<HTMLInputElement>) => void;
disableCustomAllValue?: boolean;
staticOptions?: StaticOptionsType;
staticOptionsOrder?: StaticOptionsOrderType;
onStaticOptionsChange?: (staticOptions: StaticOptionsType) => void;
onStaticOptionsOrderChange?: (staticOptionsOrder: StaticOptionsOrderType) => void;
disableStaticOptions?: boolean;
}
export function QueryVariableEditorForm({
@@ -61,6 +65,7 @@ export function QueryVariableEditorForm({
timeRange,
regex,
onRegExChange,
disableRegexEdition,
sort,
onSortChange,
refresh,
@@ -69,14 +74,17 @@ export function QueryVariableEditorForm({
onMultiChange,
allowCustomValue,
onAllowCustomValueChange,
disableAllowCustomValue,
includeAll,
onIncludeAllChange,
allValue,
onAllValueChange,
disableCustomAllValue,
staticOptions,
staticOptionsOrder,
onStaticOptionsChange,
onStaticOptionsOrderChange,
disableStaticOptions,
}: QueryVariableEditorFormProps) {
const { value: dsConfig } = useAsync(async () => {
const datasource = await getDataSourceSrv().get(datasourceRef ?? '');
@@ -116,48 +124,53 @@ export function QueryVariableEditorForm({
<Field
label={t('dashboard-scene.query-variable-editor-form.label-data-source', 'Data source')}
htmlFor="data-source-picker"
noMargin
>
<DataSourcePicker current={datasourceRef} onChange={datasourceChangeHandler} variables={true} width={30} />
</Field>
{datasource && VariableQueryEditor && (
<QueryEditor
onQueryChange={onQueryChange}
onLegacyQueryChange={onLegacyQueryChange}
datasource={datasource}
query={query}
VariableQueryEditor={VariableQueryEditor}
timeRange={timeRange}
/>
<Box marginBottom={2}>
<QueryEditor
onQueryChange={onQueryChange}
onLegacyQueryChange={onLegacyQueryChange}
datasource={datasource}
query={query}
VariableQueryEditor={VariableQueryEditor}
timeRange={timeRange}
/>
</Box>
)}
<VariableTextAreaField
defaultValue={regex ?? ''}
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
description={
<div>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
Optional, if you want to extract part of a series name or metric node segment.
</Trans>
<br />
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
Named capture groups can be used to separate the display text and value (
<TextLink
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
external
>
see examples
</TextLink>
).
</Trans>
</div>
}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
onBlur={onRegExChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
width={52}
/>
{!disableRegexEdition && (
<VariableTextAreaField
defaultValue={regex ?? ''}
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
description={
<div>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
Optional, if you want to extract part of a series name or metric node segment.
</Trans>
<br />
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
Named capture groups can be used to separate the display text and value (
<TextLink
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
external
>
see examples
</TextLink>
).
</Trans>
</div>
}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
onBlur={onRegExChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
width={52}
/>
)}
<QueryVariableSortSelect
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2}
@@ -171,7 +184,7 @@ export function QueryVariableEditorForm({
refresh={refresh}
/>
{onStaticOptionsChange && onStaticOptionsOrderChange && (
{!disableStaticOptions && onStaticOptionsChange && onStaticOptionsOrderChange && (
<QueryVariableStaticOptions
staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder}
@@ -187,6 +200,8 @@ export function QueryVariableEditorForm({
multi={!!isMulti}
includeAll={!!includeAll}
allowCustomValue={allowCustomValue}
disableAllowCustomValue={disableAllowCustomValue}
disableCustomAllValue={disableCustomAllValue}
allValue={allValue}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
@@ -10,7 +10,9 @@ interface SelectionOptionsFormProps {
multi: boolean;
includeAll: boolean;
allowCustomValue?: boolean;
disableAllowCustomValue?: boolean;
allValue?: string | null;
disableCustomAllValue?: boolean;
onMultiChange: (event: ChangeEvent<HTMLInputElement>) => void;
onAllowCustomValueChange?: (event: ChangeEvent<HTMLInputElement>) => void;
onIncludeAllChange: (event: ChangeEvent<HTMLInputElement>) => void;
@@ -20,8 +22,10 @@ interface SelectionOptionsFormProps {
export function SelectionOptionsForm({
multi,
allowCustomValue,
disableAllowCustomValue,
includeAll,
allValue,
disableCustomAllValue,
onMultiChange,
onAllowCustomValueChange,
onIncludeAllChange,
@@ -39,18 +43,19 @@ export function SelectionOptionsForm({
onChange={onMultiChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch}
/>
{onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup
<VariableCheckboxField
value={allowCustomValue ?? true}
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
description={t(
'dashboard-scene.selection-options-form.description-enables-users-custom-values',
'Enables users to add custom values to the list'
)}
onChange={onAllowCustomValueChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
/>
)}
{!disableAllowCustomValue &&
onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup
<VariableCheckboxField
value={allowCustomValue ?? true}
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
description={t(
'dashboard-scene.selection-options-form.description-enables-users-custom-values',
'Enables users to add custom values to the list'
)}
onChange={onAllowCustomValueChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
/>
)}
<VariableCheckboxField
value={includeAll}
name={t('dashboard-scene.selection-options-form.name-include-all-option', 'Include All option')}
@@ -61,7 +66,7 @@ export function SelectionOptionsForm({
onChange={onIncludeAllChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch}
/>
{includeAll && (
{!disableCustomAllValue && includeAll && (
<VariableTextField
defaultValue={allValue ?? ''}
onBlur={onAllValueChange}
@@ -5,13 +5,50 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n';
import { VariableValueOption } from '@grafana/scenes';
import { Button, InlineFieldRow, InlineLabel, useStyles2, Text } from '@grafana/ui';
import { Button, InlineFieldRow, InlineLabel, InteractiveTable, Text, useStyles2 } from '@grafana/ui';
export interface VariableValuesPreviewProps {
export interface Props {
options: VariableValueOption[];
hasMultiProps?: boolean;
}
export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) => {
export const VariableValuesPreview = ({ options, hasMultiProps }: Props) => {
if (!options.length) {
return null;
}
if (hasMultiProps) {
return <VariableValuesWithPropsPreview options={options} />;
}
return <VariableValuesWithoutPropsPreview options={options} />;
};
VariableValuesPreview.displayName = 'VariableValuesPreview';
function VariableValuesWithPropsPreview({ options }: { options: VariableValueOption[] }) {
const styles = useStyles2(getStyles);
const data = options.map((o) => ({ label: String(o.label), value: String(o.value), ...o.properties }));
// the first item in data may be the "All" option, which does not have any extra properties, so we try the 2nd item to determine the column names
const columns = Object.keys(data[1] || data[0]).map((id) => ({ id, header: id, sortType: 'alphanumeric' as const }));
return (
<div className={styles.previewContainer} style={{ gap: '8px' }}>
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
</Text>
<InteractiveTable
className={styles.table}
columns={columns}
data={data}
getRowId={(r) => String(r.value)}
pageSize={8}
/>
</div>
);
}
function VariableValuesWithoutPropsPreview({ options }: { options: VariableValueOption[] }) {
const styles = useStyles2(getStyles);
const [previewLimit, setPreviewLimit] = useState(20);
const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]);
const showMoreOptions = useCallback(
@@ -21,15 +58,10 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
},
[previewLimit, setPreviewLimit]
);
const styles = useStyles2(getStyles);
useEffect(() => setPreviewOptions(options.slice(0, previewLimit)), [previewLimit, options]);
if (!previewOptions.length) {
return null;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', marginTop: '16px' }}>
<div className={styles.previewContainer}>
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
</Text>
@@ -51,12 +83,12 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
)}
</div>
);
};
VariableValuesPreview.displayName = 'VariableValuesPreview';
}
VariableValuesWithoutPropsPreview.displayName = 'VariableValuesWithoutPropsPreview';
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
previewContainer: css({
display: 'flex',
flexDirection: 'column',
marginTop: theme.spacing(2),
@@ -71,5 +103,10 @@ function getStyles(theme: GrafanaTheme2) {
textOverflow: 'ellipsis',
maxWidth: '50vw',
}),
table: css({
td: css({
padding: theme.spacing(0.5, 1),
}),
}),
};
}
@@ -5,117 +5,225 @@ import { CustomVariable } from '@grafana/scenes';
import { CustomVariableEditor } from './CustomVariableEditor';
function setup(options: Partial<ConstructorParameters<typeof CustomVariable>[0]> = {}) {
return {
variable: new CustomVariable({
name: 'customVar',
...options,
}),
onRunQuery: jest.fn(),
};
}
function renderEditor(ui: React.ReactNode) {
const renderResult = render(ui);
const elements = {
formatButton: (label: string) => renderResult.queryByLabelText(label) as HTMLElement,
queryInput: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
) as HTMLTextAreaElement,
multiValueCheckbox: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
) as HTMLInputElement,
allowCustomValueCheckbox: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
) as HTMLInputElement,
includeAllCheckbox: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
) as HTMLInputElement,
customAllValueInput: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
) as HTMLInputElement,
};
return {
...renderResult,
elements,
actions: {
updateValuesInput(newQuery: string) {
fireEvent.change(elements.queryInput(), { target: { value: newQuery } });
fireEvent.blur(elements.queryInput());
},
changeValuesFormat(newFormat: 'csv' | 'json') {
const targetLabel = newFormat === 'json' ? 'Object values in a JSON array' : 'Values separated by comma';
const formatButton = elements.formatButton(targetLabel);
if (formatButton === null) {
throw new Error(`Unable to fire a "click" event - button with label "${targetLabel}" not found in DOM`);
}
fireEvent.click(formatButton);
},
},
};
}
describe('CustomVariableEditor', () => {
it('should render the CustomVariableForm with correct initial values', () => {
const variable = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
isMulti: true,
includeAll: true,
allValue: 'test',
describe('CSV values format', () => {
it('should render CustomVariableForm with the correct initial values', () => {
const { variable, onRunQuery } = setup({
query: 'test, test2',
value: 'test',
isMulti: true,
includeAll: true,
allowCustomValue: true,
allValue: 'all',
});
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
expect(elements.queryInput().value).toBe('test, test2');
expect(elements.multiValueCheckbox().checked).toBe(true);
expect(elements.allowCustomValueCheckbox().checked).toBe(true);
expect(elements.includeAllCheckbox().checked).toBe(true);
expect(elements.customAllValueInput().value).toBe('all');
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
it('should update the variable state when some input values change ("Multi-value", "Allow custom values" & "Include All option")', () => {
const { variable, onRunQuery } = setup({
query: 'test, test2',
value: 'test',
isMulti: false,
allowCustomValue: false,
includeAll: false,
});
const queryInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
) as HTMLInputElement;
const allValueInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
) as HTMLInputElement;
const multiCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
) as HTMLInputElement;
const includeAllCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
) as HTMLInputElement;
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
expect(queryInput.value).toBe('test, test2');
expect(allValueInput.value).toBe('test');
expect(multiCheckbox.checked).toBe(true);
expect(includeAllCheckbox.checked).toBe(true);
expect(elements.multiValueCheckbox().checked).toBe(false);
expect(elements.allowCustomValueCheckbox().checked).toBe(false);
expect(elements.includeAllCheckbox().checked).toBe(false);
// include-all-custom input appears after include-all checkbox is checked only
expect(elements.customAllValueInput()).not.toBeInTheDocument();
fireEvent.click(elements.multiValueCheckbox());
fireEvent.click(elements.allowCustomValueCheckbox());
fireEvent.click(elements.includeAllCheckbox());
expect(variable.state.isMulti).toBe(true);
expect(variable.state.allowCustomValue).toBe(true);
expect(variable.state.includeAll).toBe(true);
expect(elements.customAllValueInput()).toBeInTheDocument();
});
describe('when the values textarea loses focus after its value has changed', () => {
it('should update the query in the variable state and call the onRunQuery callback', async () => {
const { variable, onRunQuery } = setup({ query: 'test, test2', value: 'test' });
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
actions.updateValuesInput('test3, test4');
expect(variable.state.query).toBe('test3, test4');
expect(onRunQuery).toHaveBeenCalled();
});
});
describe('when the "Custom all value" input loses focus after its value has changed', () => {
it('should update the variable state', () => {
const { variable, onRunQuery } = setup({
query: 'test, test2',
value: 'test',
isMulti: true,
includeAll: true,
});
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
fireEvent.change(elements.customAllValueInput(), { target: { value: 'new custom all' } });
fireEvent.blur(elements.customAllValueInput());
expect(variable.state.allValue).toBe('new custom all');
});
});
});
it('should update the variable state when input values change', () => {
const variable = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
describe('JSON values format', () => {
const initialJsonQuery = `[
{"value":1,"text":"Development","aws":"dev","azure":"development"},
{"value":2,"text":"Production","aws":"prod","azure":"production"}
]`;
it('should render CustomVariableForm with the correct initial values', () => {
const { variable, onRunQuery } = setup({
valuesFormat: 'json',
query: initialJsonQuery,
isMulti: true,
includeAll: true,
});
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
expect(elements.queryInput().value).toBe(initialJsonQuery);
expect(elements.multiValueCheckbox().checked).toBe(true);
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
expect(elements.includeAllCheckbox().checked).toBe(true);
expect(elements.customAllValueInput()).not.toBeInTheDocument();
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
describe('when the values textarea loses focus after its value has changed', () => {
describe('if the value is valid JSON', () => {
it('should update the query in the variable state and call the onRunQuery callback', async () => {
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
const multiCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
);
const includeAllCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
);
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
const allowCustomValueCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
);
actions.updateValuesInput('[]');
// It include-all-custom input appears after include-all checkbox is checked only
expect(() =>
getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput)
).toThrow('Unable to find an element');
expect(variable.state.query).toBe('[]');
expect(onRunQuery).toHaveBeenCalled();
});
});
fireEvent.click(allowCustomValueCheckbox);
describe('if the value is NOT valid JSON', () => {
it('should display a validation error message and neither update the query in the variable state nor call the onRunQuery callback', async () => {
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
fireEvent.click(multiCheckbox);
const { actions, getByRole } = renderEditor(
<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />
);
fireEvent.click(includeAllCheckbox);
const allValueInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
);
actions.updateValuesInput('[x]');
expect(variable.state.isMulti).toBe(true);
expect(variable.state.includeAll).toBe(true);
expect(variable.state.allowCustomValue).toBe(false);
expect(allValueInput).toBeInTheDocument();
expect(getByRole('alert')).toHaveTextContent(`Unexpected token 'x', "[x]" is not valid JSON`);
expect(variable.state.query).toBe(initialJsonQuery);
expect(onRunQuery).not.toHaveBeenCalled();
});
});
});
});
it('should call update query and re-run query when input loses focus', async () => {
const variable = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
describe('when switching values format', () => {
it('should switch the visibility of the proper form inputs ("Allow custom values" and "Custom all value")', () => {
const { variable, onRunQuery } = setup({
valuesFormat: 'csv',
query: '',
isMulti: true,
includeAll: true,
allowCustomValue: true,
allValue: '',
});
const { elements, actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
expect(elements.customAllValueInput()).toBeInTheDocument();
actions.changeValuesFormat('json');
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
expect(elements.customAllValueInput()).not.toBeInTheDocument();
actions.changeValuesFormat('csv');
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
expect(elements.customAllValueInput()).toBeInTheDocument();
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput);
fireEvent.change(queryInput, { target: { value: 'test3, test4' } });
fireEvent.blur(queryInput);
expect(onRunQuery).toHaveBeenCalled();
expect(variable.state.query).toBe('test3, test4');
});
it('should update the variable state when all-custom-value input loses focus', () => {
const variable = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
isMulti: true,
includeAll: true,
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
const allValueInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
) as HTMLInputElement;
fireEvent.change(allValueInput, { target: { value: 'new custom all' } });
fireEvent.blur(allValueInput);
expect(variable.state.allValue).toBe('new custom all');
});
});
@@ -1,5 +1,7 @@
import { FormEvent, useCallback } from 'react';
import { isObject } from 'lodash';
import { FormEvent, useCallback, useState } from 'react';
import { CustomVariableModel, shallowCompare } from '@grafana/data';
import { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
@@ -14,7 +16,26 @@ interface CustomVariableEditorProps {
}
export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) {
const { query, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
const { query, valuesFormat, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
const [queryValidationError, setQueryValidationError] = useState<Error>();
const [prevQuery, setPrevQuery] = useState('');
const onValuesFormatChange = useCallback(
(format: CustomVariableModel['valuesFormat']) => {
variable.setState({ query: prevQuery });
variable.setState({ value: isMulti ? [] : undefined });
variable.setState({ valuesFormat: format });
variable.setState({ allowCustomValue: false });
variable.setState({ allValue: undefined });
onRunQuery();
setQueryValidationError(undefined);
if (query !== prevQuery) {
setPrevQuery(query);
}
},
[isMulti, onRunQuery, prevQuery, query, variable]
);
const onMultiChange = useCallback(
(event: FormEvent<HTMLInputElement>) => {
@@ -32,10 +53,20 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
const onQueryChange = useCallback(
(event: FormEvent<HTMLTextAreaElement>) => {
setPrevQuery('');
if (valuesFormat === 'json') {
const validationError = validateJsonQuery(event.currentTarget.value);
setQueryValidationError(validationError);
if (validationError) {
return;
}
}
variable.setState({ query: event.currentTarget.value });
onRunQuery();
},
[variable, onRunQuery]
[valuesFormat, variable, onRunQuery]
);
const onAllValueChange = useCallback(
@@ -55,15 +86,18 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
return (
<CustomVariableForm
query={query ?? ''}
valuesFormat={valuesFormat ?? 'csv'}
multi={!!isMulti}
allValue={allValue ?? ''}
includeAll={!!includeAll}
allowCustomValue={allowCustomValue}
queryValidationError={queryValidationError}
onQueryChange={onQueryChange}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onQueryChange={onQueryChange}
onAllValueChange={onAllValueChange}
onAllowCustomValueChange={onAllowCustomValueChange}
onValuesFormatChange={onValuesFormatChange}
/>
);
}
@@ -81,3 +115,47 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
}),
];
}
export const validateJsonQuery = (rawQuery: string): Error | undefined => {
const query = rawQuery.trim();
if (!query) {
return;
}
try {
const options = JSON.parse(query);
if (!Array.isArray(options)) {
throw new Error('Enter a valid JSON array of objects');
}
if (!options.length) {
return;
}
let errorIndex = options.findIndex((item) => !isObject(item));
if (errorIndex !== -1) {
throw new Error(`All items must be objects. The item at index ${errorIndex} is not an object.`);
}
const keys = Object.keys(options[0]);
if (!keys.includes('value')) {
throw new Error('Each object in the array must include at least a "value" property');
}
if (keys.includes('')) {
throw new Error('Object property names cannot be empty strings');
}
errorIndex = options.findIndex((o) => !shallowCompare(keys, Object.keys(o)));
if (errorIndex !== -1) {
throw new Error(
`All objects must have the same set of properties. The object at index ${errorIndex} does not match the expected properties`
);
}
return;
} catch (error) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return error as Error;
}
};
@@ -1,15 +1,16 @@
import { useCallback, useRef } from 'react';
import { FormEvent, useCallback, useState } from 'react';
import { lastValueFrom } from 'rxjs';
import { CustomVariableModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { CustomVariable } from '@grafana/scenes';
import { Button, Modal, Stack } from '@grafana/ui';
import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana/ui';
import { VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
import { ValuesFormatSelector } from '../../components/CustomVariableForm';
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
import { ValuesBuilder } from './ValuesBuilder';
import { ValuesPreview } from './ValuesPreview';
import { validateJsonQuery } from './CustomVariableEditor';
interface ModalEditorProps {
variable: CustomVariable;
@@ -18,9 +19,49 @@ interface ModalEditorProps {
}
export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
const { query, valuesFormat, isMulti } = variable.useState();
const [prevQuery, setPrevQuery] = useState('');
const [queryValidationError, setQueryValidationError] = useState<Error>();
const handleOnAdd = useCallback(() => formRef.current?.addItem(), []);
const onValuesFormatChange = useCallback(
async (format: CustomVariableModel['valuesFormat']) => {
variable.setState({ query: prevQuery });
variable.setState({ value: isMulti ? [] : undefined });
variable.setState({ valuesFormat: format });
variable.setState({ allowCustomValue: false });
variable.setState({ allValue: undefined });
await lastValueFrom(variable.validateAndUpdate());
setQueryValidationError(undefined);
if (query !== prevQuery) {
setPrevQuery(query);
}
},
[isMulti, prevQuery, query, variable]
);
const onQueryChange = useCallback(
async (event: FormEvent<HTMLTextAreaElement>) => {
setPrevQuery('');
if (valuesFormat === 'json') {
const validationError = validateJsonQuery(event.currentTarget.value);
setQueryValidationError(validationError);
if (validationError) {
return;
}
}
variable.setState({ query: event.currentTarget.value });
await lastValueFrom(variable.validateAndUpdate());
},
[valuesFormat, variable]
);
const optionsForSelect = variable.getOptionsForSelect(false);
const hasJsonValuesFormat = variable.state.valuesFormat === 'json';
const hasMultiProps = hasJsonValuesFormat || optionsForSelect.every((o) => Boolean(o.properties));
return (
<Modal
@@ -29,10 +70,31 @@ export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
onDismiss={onClose}
>
<Stack direction="column" gap={2}>
<ValuesBuilder variable={variable} ref={formRef} />
<ValuesPreview variable={variable} />
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
<div>
<TextArea
id={valuesFormat}
key={valuesFormat}
rows={4}
defaultValue={query}
onBlur={onQueryChange}
placeholder={
valuesFormat === 'json'
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'1, 10, mykey : myvalue, myvalue, escaped\,value'
}
required
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
/>
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
</div>
<div>
<VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />
</div>
</Stack>
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={handleOnAdd} />}>
<Modal.ButtonRow>
<Button
variant="secondary"
fill="outline"
@@ -1,4 +1,3 @@
import { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { OptionsPaneItemDescriptor } from '../../../../../dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@@ -12,7 +11,6 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
return [
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
id: 'custom-variable-values',
render: ({ props }) => <PaneItem id={props.id} variable={variable} />,
}),
@@ -1,4 +1,4 @@
import { useState, FormEvent } from 'react';
import { useState, FormEvent, useMemo, useEffect } from 'react';
import { useAsync } from 'react-use';
import { SelectableValue, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data';
@@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { QueryVariable, sceneGraph, SceneVariable } from '@grafana/scenes';
import { VariableRefresh, VariableSort } from '@grafana/schema';
import { Box, Button, Field, Modal, TextLink } from '@grafana/ui';
import { Box, Button, Field, Modal, Switch, TextLink } from '@grafana/ui';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
@@ -44,6 +44,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
allowCustomValue,
staticOptions,
staticOptionsOrder,
options,
} = variable.useState();
const { value: timeRange } = sceneGraph.getTimeRange(variable).useState();
@@ -93,6 +94,17 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
variable.setState({ staticOptionsOrder });
};
const hasMultiProps = useMemo(() => options.every((o) => Boolean(o.properties)), [options]);
useEffect(() => {
if (hasMultiProps) {
variable.setState({ allowCustomValue: false });
variable.setState({ allValue: '' });
variable.setState({ regex: '' });
variable.setState({ staticOptions: [] });
}
}, [hasMultiProps, variable]);
return (
<QueryVariableEditorForm
datasource={datasource ?? undefined}
@@ -103,6 +115,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
timeRange={timeRange}
regex={regex}
onRegExChange={onRegExChange}
disableRegexEdition={hasMultiProps}
sort={sort}
onSortChange={onSortChange}
refresh={refresh}
@@ -113,12 +126,15 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
onIncludeAllChange={onIncludeAllChange}
allValue={allValue ?? ''}
onAllValueChange={onAllValueChange}
disableCustomAllValue={hasMultiProps}
allowCustomValue={allowCustomValue}
onAllowCustomValueChange={onAllowCustomValueChange}
disableAllowCustomValue={hasMultiProps}
staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder}
onStaticOptionsChange={onStaticOptionsChange}
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
disableStaticOptions={hasMultiProps}
/>
);
}
@@ -250,6 +266,19 @@ export function Editor({ variable }: { variable: QueryVariable }) {
const isHasVariableOptions = hasVariableOptions(variable);
// TODO: remove me after finished testing - each DS can/should implement their own UI
const [returnsMultiProps, setReturnsMultiProps] = useState(false);
const onChangeReturnsMultipleProps = (e: FormEvent<HTMLInputElement>) => {
setReturnsMultiProps(e.currentTarget.checked);
variable.setState({ allowCustomValue: false });
variable.setState({ allValue: '' });
variable.setState({ regex: '' });
onStaticOptionsChange?.([]);
};
const optionsForSelect = variable.getOptionsForSelect(false);
const hasMultiProps = returnsMultiProps || optionsForSelect.every((o) => Boolean(o.properties));
return (
<div data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.editor}>
<Field
@@ -261,43 +290,64 @@ export function Editor({ variable }: { variable: QueryVariable }) {
</Field>
{selectedDatasource && VariableQueryEditor && (
<QueryEditor
onQueryChange={onQueryChange}
onLegacyQueryChange={onQueryChange}
datasource={selectedDatasource}
query={query}
VariableQueryEditor={VariableQueryEditor}
timeRange={timeRange}
/>
<Box marginBottom={2}>
<QueryEditor
onQueryChange={onQueryChange}
onLegacyQueryChange={onQueryChange}
datasource={selectedDatasource}
query={query}
VariableQueryEditor={VariableQueryEditor}
timeRange={timeRange}
/>
{/* TODO: remove me after finished testing - each DS can/should implement their own UI */}
<Field
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
label="Enable access to all the fields of the query results"
description={
<Trans i18nKey="">
Check{' '}
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
our docs
</TextLink>{' '}
for more information.
</Trans>
}
noMargin
>
<Switch onChange={onChangeReturnsMultipleProps} />
</Field>
</Box>
)}
<VariableTextAreaField
defaultValue={regex ?? ''}
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
description={
<div>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
Optional, if you want to extract part of a series name or metric node segment.
</Trans>
<br />
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
Named capture groups can be used to separate the display text and value (
<TextLink
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
external
>
see examples
</TextLink>
).
</Trans>
</div>
}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
onBlur={onRegExChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
width={52}
/>
{!returnsMultiProps && (
<VariableTextAreaField
defaultValue={regex ?? ''}
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
description={
<div>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
Optional, if you want to extract part of a series name or metric node segment.
</Trans>
<br />
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
Named capture groups can be used to separate the display text and value (
<TextLink
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
external
>
see examples
</TextLink>
).
</Trans>
</div>
}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
onBlur={onRegExChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
width={52}
/>
)}
<QueryVariableSortSelect
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2}
@@ -311,7 +361,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
refresh={refresh}
/>
{onStaticOptionsChange && onStaticOptionsOrderChange && (
{!returnsMultiProps && onStaticOptionsChange && onStaticOptionsOrderChange && (
<QueryVariableStaticOptions
staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder}
@@ -320,7 +370,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
/>
)}
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
</div>
);
}
@@ -45,7 +45,11 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
'A wildcard regex or other value to represent All'
),
useShowIf: () => {
return variable.useState().includeAll ?? false;
const state = variable.useState();
const hasMultiProps =
('valuesFormat' in state && state.valuesFormat === 'json') ||
state.options.every((o) => Boolean(o.properties));
return hasMultiProps ? false : (state.includeAll ?? false);
},
render: (descriptor) => <CustomAllValueInput id={descriptor.props.id} variable={variable} />,
})
@@ -58,6 +62,13 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
'dashboard.edit-pane.variable.selection-options.allow-custom-values-description',
'Enables users to enter values'
),
useShowIf: () => {
const state = variable.useState();
const hasMultiProps =
('valuesFormat' in state && state.valuesFormat === 'json') ||
state.options.every((o) => Boolean(o.properties));
return !hasMultiProps;
},
render: (descriptor) => <AllowCustomSwitch id={descriptor.props.id} variable={variable} />,
})
);
@@ -43,6 +43,7 @@ export function getLocalVariableValueSet(
name: variable.state.name,
value,
text,
properties: variable.state.options.find((o) => o.value === value)?.properties,
isMulti: variable.state.isMulti,
includeAll: variable.state.includeAll,
}),
@@ -103,6 +103,7 @@ describe('when creating variables objects', () => {
text: 'a',
type: 'custom',
value: 'a',
valuesFormat: 'csv',
hide: 0,
});
});
@@ -0,0 +1,216 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { FormProvider, SubmitErrorHandler, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Button, Stack, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { GroupVersionResource } from 'app/features/apiserver/types';
import { ROUTES } from 'app/features/connections/constants';
import { DataSourceStackSpec } from 'app/features/connections/pages/DataSourceStacksPage';
import { StackModes } from './StackModes';
import { StackName } from './StackName';
import { StackTemplate } from './StackTemplate';
import { StackFormValues } from './types';
type Props = {
existing?: StackFormValues;
};
const defaultValues: StackFormValues = {
name: '',
templates: [],
modes: [],
};
// GroupVersionResource for datasourcestacks
const datasourceStacksGVR: GroupVersionResource = {
group: 'collections.grafana.app',
version: 'v1alpha1',
resource: 'datasourcestacks',
};
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
export const StackForm = ({ existing }: Props) => {
const styles = useStyles2(getStyles);
const notifyApp = useAppNotification();
const navigate = useNavigate();
const isEditing = !!existing;
const initialValues: StackFormValues = useMemo(() => {
if (existing) {
return existing;
}
return defaultValues;
}, [existing]);
const formAPI = useForm<StackFormValues>({
mode: 'onSubmit',
defaultValues: initialValues,
shouldFocusError: true,
});
const {
handleSubmit,
formState: { isSubmitting },
} = formAPI;
const submit = async (values: StackFormValues): Promise<void> => {
const spec = prepareCreateStackPayload(values);
try {
if (isEditing) {
// Update existing stack
const existingStack = await datasourceStacksClient.get(values.name);
await datasourceStacksClient.update({
...existingStack,
spec,
});
notifyApp.success('Stack updated successfully!');
} else {
// Create new stack
await datasourceStacksClient.create({
metadata: { name: values.name },
spec,
});
notifyApp.success('Stack created successfully!');
}
// Navigate to stacks list page
navigate(ROUTES.Stacks);
} catch (error) {
const message = error instanceof Error ? error.message : 'An unknown error occurred';
notifyApp.error(`Failed to save stack: ${message}`);
}
};
const onInvalid: SubmitErrorHandler<StackFormValues> = () => {
notifyApp.error('There are errors in the form. Please correct them and try again!');
};
return (
<FormProvider {...formAPI}>
<form onSubmit={(e) => e.preventDefault()} className={styles.form}>
<div className={styles.contentOuter}>
<Stack direction="column" gap={3}>
{/* Step 1 - name */}
<StackName />
{/* Step 2 - Templates */}
<StackTemplate />
{/* Step 3 - Modes */}
<StackModes />
{/* Actions */}
<Stack direction="row" alignItems="center">
<Button
variant="primary"
type="button"
onClick={handleSubmit((values) => submit(values), onInvalid)}
disabled={isSubmitting}
icon={isSubmitting ? 'spinner' : undefined}
>
<Trans i18nKey="datasources.stack-form.save">Save</Trans>
</Button>
</Stack>
</Stack>
</div>
</form>
</FormProvider>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
form: css({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
}),
contentOuter: css({
background: theme.colors.background.primary,
overflow: 'hidden',
maxWidth: theme.breakpoints.values.xl,
flex: 1,
}),
});
export const prepareCreateStackPayload = (formValues: StackFormValues): DataSourceStackSpec => {
// creates a mapping from template name to UUID
const templateNameToUuid: Record<string, string> = {};
formValues.templates.forEach((template) => {
templateNameToUuid[template.name] = uuidv4();
});
// builds the template record with UUIDs as keys
const template: DataSourceStackSpec['template'] = {};
formValues.templates.forEach((t) => {
const uuid = templateNameToUuid[t.name];
template[uuid] = {
group: t.type,
name: t.name,
};
});
// uses template ids to build modes
const modes: DataSourceStackSpec['modes'] = formValues.modes.map((mode) => {
const definition: Record<string, { dataSourceRef: string }> = {};
Object.entries(mode.datasources).forEach(([templateName, dataSourceUid]) => {
const uuid = templateNameToUuid[templateName];
if (uuid) {
definition[uuid] = { dataSourceRef: dataSourceUid };
}
});
return {
name: mode.name,
uid: uuidv4(),
definition,
};
});
return { template, modes };
};
//used when loading an existing stack for editing.
export const transformStackSpecToFormValues = (stackName: string, spec: DataSourceStackSpec): StackFormValues => {
const uuidToTemplateName: Record<string, string> = {};
Object.entries(spec.template).forEach(([uuid, templateItem]) => {
uuidToTemplateName[uuid] = templateItem.name;
});
const templates = Object.values(spec.template).map((templateItem) => ({
name: templateItem.name,
type: templateItem.group,
}));
const modes = spec.modes.map((mode) => {
const datasources: Record<string, string> = {};
Object.entries(mode.definition).forEach(([uuid, modeItem]) => {
const templateName = uuidToTemplateName[uuid];
if (templateName) {
datasources[templateName] = modeItem.dataSourceRef;
}
});
return {
name: mode.name,
datasources,
};
});
return {
name: stackName,
templates,
modes,
};
};
@@ -0,0 +1,63 @@
import { css, cx } from '@emotion/css';
import * as React from 'react';
import { ReactElement } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { FieldSet, Stack, Text, useStyles2 } from '@grafana/ui';
export interface StackFormSectionProps {
title: string;
stepNo: number;
description?: string | ReactElement;
fullWidth?: boolean;
}
export const StackFormSection = ({
title,
stepNo,
children,
fullWidth = false,
description,
}: React.PropsWithChildren<StackFormSectionProps>) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.parent}>
<FieldSet
className={cx(fullWidth && styles.fullWidth)}
label={
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Text variant="h3">
{stepNo}. {title}
</Text>
</Stack>
}
>
<Stack direction="column">
{description && <div className={styles.description}>{description}</div>}
{children}
</Stack>
</FieldSet>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
parent: css({
display: 'flex',
flexDirection: 'row',
border: `solid 1px ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.lg,
padding: `${theme.spacing(2)} ${theme.spacing(3)}`,
}),
description: css({
marginTop: `-${theme.spacing(2)}`,
}),
fullWidth: css({
width: '100%',
}),
reverse: css({
flexDirection: 'row-reverse',
gap: theme.spacing(1),
}),
});
@@ -0,0 +1,145 @@
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Trans, t } from '@grafana/i18n';
import { Button, Field, IconButton, Input, Stack, Text } from '@grafana/ui';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { StackFormSection } from './StackFormSection';
import { ModeSection, StackFormValues } from './types';
const createEmptyMode = (): ModeSection => ({
name: '',
datasources: {},
});
export const StackModes = () => {
const {
control,
register,
watch,
formState: { errors },
} = useFormContext<StackFormValues>();
const { fields, append, remove } = useFieldArray({
control,
name: 'modes',
});
const templates = watch('templates');
const hasTemplates = templates && templates.length > 0;
return (
<StackFormSection
stepNo={3}
title={t('datasources.stack-modes.title', 'Add modes')}
description={
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="datasources.stack-modes.description">
Define modes (e.g., dev, staging, prod) and select the actual datasources for each template entry.
</Trans>
</Text>
}
>
<Stack direction="column" gap={3}>
{!hasTemplates && (
<Text color="secondary" italic>
<Trans i18nKey="datasources.stack-modes.no-templates">
Add template sections first to define modes.
</Trans>
</Text>
)}
{hasTemplates &&
fields.map((field, index) => (
<ModeSectionRow
key={field.id}
index={index}
register={register}
control={control}
errors={errors}
templates={templates}
onRemove={() => remove(index)}
/>
))}
{hasTemplates && (
<Button type="button" variant="secondary" icon="plus" onClick={() => append(createEmptyMode())}>
<Trans i18nKey="datasources.stack-modes.add-mode">Add mode</Trans>
</Button>
)}
</Stack>
</StackFormSection>
);
};
interface ModeSectionRowProps {
index: number;
register: ReturnType<typeof useFormContext<StackFormValues>>['register'];
control: ReturnType<typeof useFormContext<StackFormValues>>['control'];
errors: ReturnType<typeof useFormContext<StackFormValues>>['formState']['errors'];
templates: StackFormValues['templates'];
onRemove: () => void;
}
const ModeSectionRow = ({ index, register, control, errors, templates, onRemove }: ModeSectionRowProps) => {
return (
<Stack direction="column" gap={2}>
<Stack direction="row" gap={2} alignItems="center">
<Field
noMargin
label={t('datasources.stack-modes.mode-name-label', 'Mode name')}
error={errors?.modes?.[index]?.name?.message}
invalid={!!errors?.modes?.[index]?.name?.message}
>
<Input
id={`modes.${index}.name`}
width={30}
{...register(`modes.${index}.name`, {
required: {
value: true,
message: t('datasources.stack-modes.mode-name-required', 'Mode name is required'),
},
})}
placeholder={t('datasources.stack-modes.mode-name-placeholder', 'e.g. production')}
/>
</Field>
<IconButton
name="trash-alt"
variant="destructive"
tooltip={t('datasources.stack-modes.remove-mode', 'Remove mode')}
onClick={onRemove}
aria-label={t('datasources.stack-modes.remove-mode', 'Remove mode')}
/>
</Stack>
<Stack direction="row" gap={2} wrap="wrap">
{templates.map((template) => (
<Field
noMargin
key={template.name}
label={template.name || t('datasources.stack-modes.unnamed-template', 'Unnamed template')}
>
<Controller
name={`modes.${index}.datasources.${template.name}`}
control={control}
render={({ field: { ref, onChange, value, ...field } }) => (
<DataSourcePicker
{...field}
current={value}
onChange={(ds) => onChange(ds.uid)}
noDefault={true}
pluginId={template.type}
placeholder={t('datasources.stack-modes.select-datasource', 'Select datasource')}
width={30}
/>
)}
/>
</Field>
))}
</Stack>
</Stack>
);
};
@@ -0,0 +1,49 @@
import { useFormContext } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { Field, Input, Stack, Text } from '@grafana/ui';
import { StackFormSection } from './StackFormSection';
import { StackFormValues } from './types';
export const StackName = () => {
const {
register,
formState: { errors },
} = useFormContext<StackFormValues>();
return (
<StackFormSection
stepNo={1}
title={t('datasources.stack-name.title', 'Enter stack name')}
description={
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="datasources.stack-name.description">Enter a name to identify your stack.</Trans>
</Text>
}
>
<Stack direction="column">
<Field
label={t('datasources.stack-name.label', 'Name')}
error={errors?.name?.message}
invalid={!!errors.name?.message}
>
<Input
data-testid={selectors.components.AlertRules.ruleNameField}
id="name"
width={38}
{...register('name', {
required: {
value: true,
message: t('datasources.stack-name.required', 'Must enter a name'),
},
})}
aria-label={t('datasources.stack-name.aria-label', 'name')}
placeholder="example: LGTM"
/>
</Field>
</Stack>
</StackFormSection>
);
};
@@ -0,0 +1,129 @@
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Trans, t } from '@grafana/i18n';
import { Button, Combobox, Field, IconButton, Input, Stack, Text } from '@grafana/ui';
import { getOptionDataSourceTypes } from 'app/features/dashboard-scene/settings/variables/utils';
import { StackFormSection } from './StackFormSection';
import { StackFormValues, TemplateSection } from './types';
const emptyTemplateSection: TemplateSection = {
name: '',
type: '',
};
export const StackTemplate = () => {
const {
control,
register,
formState: { errors },
} = useFormContext<StackFormValues>();
const { fields, append, remove } = useFieldArray({
control,
name: 'templates',
});
return (
<StackFormSection
stepNo={2}
title={t('datasources.stack-template.title', 'Add template sections')}
description={
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="datasources.stack-template.description">
Add which datasource types comprise your stack and add names to reference them in the query editor.
</Trans>
</Text>
}
>
<Stack direction="column" gap={2}>
{fields.map((field, index) => (
<TemplateSectionRow
key={field.id}
index={index}
register={register}
control={control}
errors={errors}
onRemove={() => remove(index)}
/>
))}
<Button type="button" variant="secondary" icon="plus" onClick={() => append(emptyTemplateSection)}>
<Trans i18nKey="datasources.stack-template.add-section">Add datasource</Trans>
</Button>
</Stack>
</StackFormSection>
);
};
interface TemplateSectionRowProps {
index: number;
register: ReturnType<typeof useFormContext<StackFormValues>>['register'];
control: ReturnType<typeof useFormContext<StackFormValues>>['control'];
errors: ReturnType<typeof useFormContext<StackFormValues>>['formState']['errors'];
onRemove: () => void;
}
const TemplateSectionRow = ({ index, register, control, errors, onRemove }: TemplateSectionRowProps) => {
const dataSourceOptions = getOptionDataSourceTypes();
return (
<Stack direction="row" gap={2} alignItems="flex-start">
<Field
noMargin
label={t('datasources.stack-template.name-label', 'Name')}
error={errors?.templates?.[index]?.name?.message}
invalid={!!errors?.templates?.[index]?.name?.message}
>
<Input
id={`templates.${index}.name`}
width={30}
{...register(`templates.${index}.name`, {
required: {
value: true,
message: t('datasources.stack-template.name-required', 'Name is required'),
},
})}
placeholder={t('datasources.stack-template.name-placeholder', 'e.g. logs-datasource')}
/>
</Field>
<Field
noMargin
label={t('datasources.stack-template.type-label', 'Data source type')}
error={errors?.templates?.[index]?.type?.message}
invalid={!!errors?.templates?.[index]?.type?.message}
>
<Controller
name={`templates.${index}.type`}
control={control}
rules={{
required: {
value: true,
message: t('datasources.stack-template.type-required', 'Type is required'),
},
}}
render={({ field: { ref, onChange, ...field } }) => (
<Combobox
id={`templates.${index}.type`}
width={30}
options={dataSourceOptions}
onChange={(option) => onChange(option?.value || '')}
placeholder={t('datasources.stack-template.type-placeholder', 'Select type')}
{...field}
/>
)}
/>
</Field>
<IconButton
name="trash-alt"
variant="destructive"
tooltip={t('datasources.stack-template.remove-section', 'Remove section')}
onClick={onRemove}
aria-label={t('datasources.stack-template.remove-section', 'Remove section')}
style={{ marginTop: '28px' }}
/>
</Stack>
);
};
@@ -0,0 +1,16 @@
export interface TemplateSection {
name: string;
type: string;
}
export interface ModeSection {
name: string;
/** template name to selected datasource UID */
datasources: Record<string, string>;
}
export interface StackFormValues {
name: string;
templates: TemplateSection[];
modes: ModeSection[];
}
+2
View File
@@ -5880,6 +5880,8 @@
},
"custom-variable-form": {
"custom-options": "Custom options",
"json-values-tooltip": "Provide a JSON representing an array of objects, where each object can have any number of properties.<br/>Check <4>our docs</4> for more information.",
"name-json-values": "Object values in a JSON array",
"name-values-separated-comma": "Values separated by comma",
"selection-options": "Selection options"
},
+11 -11
View File
@@ -3603,11 +3603,11 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:6.47.1":
version: 6.47.1
resolution: "@grafana/scenes-react@npm:6.47.1"
"@grafana/scenes-react@npm:^6.48.0":
version: 6.48.0
resolution: "@grafana/scenes-react@npm:6.48.0"
dependencies:
"@grafana/scenes": "npm:6.47.1"
"@grafana/scenes": "npm:6.48.0"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@@ -3619,7 +3619,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/dc20f9ee80eaf648665f7449e3ccb3b640a931f8f4a1be89599dce17eb0f52e763e3a603a4d491d9886b3e6cdf2ad3634124252c223315917206200f7cd6da16
checksum: 10/5afb2aa79271dd824cc35f0a59ec193ddcbd4e1f14e756551228ce218a19faad54923d3a83f8bbb10d38c1d23c49846df29e7fce8feba8ec9aec2d32d9c1cf8d
languageName: node
linkType: hard
@@ -3649,9 +3649,9 @@ __metadata:
languageName: node
linkType: hard
"@grafana/scenes@npm:6.47.1":
version: 6.47.1
resolution: "@grafana/scenes@npm:6.47.1"
"@grafana/scenes@npm:6.48.0, @grafana/scenes@npm:^6.48.0":
version: 6.48.0
resolution: "@grafana/scenes@npm:6.48.0"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@@ -3671,7 +3671,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/bc0c76258955058e7493b04e7cdd5d59dcc4159adf06da0837e992716ea15700b54f8403614df04326350363dc3344fb2602a2e8f7807724571659b4bd95aded
checksum: 10/28cd64ea3c4faf87173ea71ffc136a7a525c33ec2e263ab2a98df718e3968ed7b7a12ecf0f309400af78e3c3269ae6048da8113412645a361a3e4925f9a2a810
languageName: node
linkType: hard
@@ -19234,8 +19234,8 @@ __metadata:
"@grafana/plugin-ui": "npm:^0.11.1"
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/scenes": "npm:6.47.1"
"@grafana/scenes-react": "npm:6.47.1"
"@grafana/scenes": "npm:^6.48.0"
"@grafana/scenes-react": "npm:^6.48.0"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/test-utils": "workspace:*"