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
Some checks failed
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
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
74 changed files with 3528 additions and 286 deletions

View File

@@ -7,4 +7,4 @@ generate: install-app-sdk update-app-sdk
--gogenpath=./pkg/apis \ --gogenpath=./pkg/apis \
--grouping=group \ --grouping=group \
--genoperatorstate=false \ --genoperatorstate=false \
--defencoding=none --defencoding=none

View File

@@ -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
}

View File

@@ -6,12 +6,13 @@ manifest: {
versions: { versions: {
"v1alpha1": { "v1alpha1": {
codegen: { codegen: {
ts: {enabled: false} ts: {enabled: true}
go: {enabled: true} go: {enabled: true}
} }
kinds: [ kinds: [
starsV1alpha1, starsV1alpha1,
datasourcestacksV1alpha1
] ]
} },
} }
} }

View File

@@ -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)
}

View File

@@ -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{}

View File

@@ -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{},
}
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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{},
}
}

View File

@@ -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 ( var (
SchemeBuilder runtime.SchemeBuilder SchemeBuilder runtime.SchemeBuilder
localSchemeBuilder = &SchemeBuilder localSchemeBuilder = &SchemeBuilder
@@ -48,6 +61,8 @@ func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(schemeGroupVersion, scheme.AddKnownTypes(schemeGroupVersion,
&Stars{}, &Stars{},
&StarsList{}, &StarsList{},
&DataSourceStack{},
&DataSourceStackList{},
) )
metav1.AddToGroupVersion(scheme, schemeGroupVersion) metav1.AddToGroupVersion(scheme, schemeGroupVersion)
return nil return nil

View File

@@ -14,10 +14,241 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return 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.DataSourceStack": schema_pkg_apis_collections_v1alpha1_DataSourceStack(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.DataSourceStackDataSourceStackTemplateItem": schema_pkg_apis_collections_v1alpha1_DataSourceStackDataSourceStackTemplateItem(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.DataSourceStackList": schema_pkg_apis_collections_v1alpha1_DataSourceStackList(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.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"},
} }
} }

View File

@@ -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: 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 API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsList,ListMeta

View File

@@ -10,19 +10,22 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/resource"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/kube-openapi/pkg/spec3" "k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec" "k8s.io/kube-openapi/pkg/validation/spec"
"github.com/grafana/grafana-app-sdk/app" v1alpha1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
"github.com/grafana/grafana-app-sdk/resource"
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
) )
var ( 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"}}`) 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 versionSchemaStarsv1alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1) _ = 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{ var appManifestData = app.ManifestData{
@@ -49,6 +52,14 @@ var appManifestData = app.ManifestData{
}, },
Schema: &versionSchemaStarsv1alpha1, Schema: &versionSchemaStarsv1alpha1,
}, },
{
Kind: "DataSourceStack",
Plural: "DataSourceStacks",
Scope: "Namespaced",
Conversion: false,
Schema: &versionSchemaDataSourceStackv1alpha1,
},
}, },
Routes: app.ManifestVersionRoutes{ Routes: app.ManifestVersionRoutes{
Namespaced: map[string]spec3.PathProps{}, Namespaced: map[string]spec3.PathProps{},
@@ -68,7 +79,8 @@ func RemoteManifest() app.Manifest {
} }
var kindVersionToGoType = map[string]resource.Kind{ 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. // ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists.

View File

@@ -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;
}

View File

@@ -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: {},
});

View File

@@ -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: [],
});

View File

@@ -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;
}

View File

@@ -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: {},
});

View File

@@ -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: [],
});

View File

@@ -911,6 +911,7 @@ CustomVariableSpec: {
skipUrlSync: bool | *false skipUrlSync: bool | *false
description?: string description?: string
allowCustomValue: bool | *true allowCustomValue: bool | *true
valuesFormat?: "csv" | "json"
} }
// Custom variable kind // Custom variable kind

View File

@@ -915,6 +915,7 @@ CustomVariableSpec: {
skipUrlSync: bool | *false skipUrlSync: bool | *false
description?: string description?: string
allowCustomValue: bool | *true allowCustomValue: bool | *true
valuesFormat?: "csv" | "json"
} }
// Custom variable kind // Custom variable kind

View File

@@ -1675,18 +1675,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind {
// Custom variable specification // Custom variable specification
// +k8s:openapi-gen=true // +k8s:openapi-gen=true
type DashboardCustomVariableSpec struct { type DashboardCustomVariableSpec struct {
Name string `json:"name"` Name string `json:"name"`
Query string `json:"query"` Query string `json:"query"`
Current DashboardVariableOption `json:"current"` Current DashboardVariableOption `json:"current"`
Options []DashboardVariableOption `json:"options"` Options []DashboardVariableOption `json:"options"`
Multi bool `json:"multi"` Multi bool `json:"multi"`
IncludeAll bool `json:"includeAll"` IncludeAll bool `json:"includeAll"`
AllValue *string `json:"allValue,omitempty"` AllValue *string `json:"allValue,omitempty"`
Label *string `json:"label,omitempty"` Label *string `json:"label,omitempty"`
Hide DashboardVariableHide `json:"hide"` Hide DashboardVariableHide `json:"hide"`
SkipUrlSync bool `json:"skipUrlSync"` SkipUrlSync bool `json:"skipUrlSync"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
AllowCustomValue bool `json:"allowCustomValue"` AllowCustomValue bool `json:"allowCustomValue"`
ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"`
} }
// NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object. // NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object.
@@ -2101,6 +2102,14 @@ const (
DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted" DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted"
) )
// +k8s:openapi-gen=true
type DashboardCustomVariableSpecValuesFormat string
const (
DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv"
DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json"
)
// +k8s:openapi-gen=true // +k8s:openapi-gen=true
type DashboardPanelKindOrLibraryPanelKind struct { type DashboardPanelKindOrLibraryPanelKind struct {
PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"` PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"`

View File

@@ -1510,6 +1510,12 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableSpec(ref common.Re
Format: "", Format: "",
}, },
}, },
"valuesFormat": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
}, },
Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"}, Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"},
}, },

View File

@@ -181,6 +181,8 @@ import (
//go:generate mockery --name InterfaceName --structname MockImplementationName --inpackage --filename my_implementation_mock.go //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 ## Globals
As a general rule of thumb, avoid using global variables, since they make the code difficult to maintain and reason As a general rule of thumb, avoid using global variables, since they make the code difficult to maintain and reason

View File

@@ -296,8 +296,8 @@
"@grafana/plugin-ui": "^0.11.1", "@grafana/plugin-ui": "^0.11.1",
"@grafana/prometheus": "workspace:*", "@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*", "@grafana/runtime": "workspace:*",
"@grafana/scenes": "6.47.1", "@grafana/scenes": "^6.48.0",
"@grafana/scenes-react": "6.47.1", "@grafana/scenes-react": "^6.48.0",
"@grafana/schema": "workspace:*", "@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*", "@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*", "@grafana/ui": "workspace:*",

View File

@@ -101,6 +101,7 @@ export interface IntervalVariableModel extends VariableWithOptions {
export interface CustomVariableModel extends VariableWithMultiSupport { export interface CustomVariableModel extends VariableWithMultiSupport {
type: 'custom'; type: 'custom';
valuesFormat?: 'csv' | 'json';
} }
export interface DataSourceVariableModel extends VariableWithMultiSupport { export interface DataSourceVariableModel extends VariableWithMultiSupport {

View File

@@ -316,6 +316,7 @@ export const handyTestingSchema: Spec = {
query: 'option1, option2', query: 'option1, option2',
skipUrlSync: false, skipUrlSync: false,
allowCustomValue: true, allowCustomValue: true,
valuesFormat: 'csv',
}, },
}, },
{ {

View File

@@ -1335,6 +1335,7 @@ export interface CustomVariableSpec {
skipUrlSync: boolean; skipUrlSync: boolean;
description?: string; description?: string;
allowCustomValue: boolean; allowCustomValue: boolean;
valuesFormat?: "csv" | "json";
} }
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ export const defaultCustomVariableSpec = (): CustomVariableSpec => ({

View File

@@ -150,6 +150,10 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/connections/datasources/edit/*", authorize(datasources.EditPageAccess), hs.Index) r.Get("/connections/datasources/edit/*", authorize(datasources.EditPageAccess), hs.Index)
r.Get("/connections", authorize(datasources.ConfigurationPageAccess), hs.Index) r.Get("/connections", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/add-new-connection", 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 // Plugin details pages
r.Get("/connections/datasources/:id", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index) 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) r.Get("/connections/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1,11 +1,13 @@
package collections package collections
import ( import (
"context"
"fmt" "fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server" genericapiserver "k8s.io/apiserver/pkg/server"
@@ -14,13 +16,16 @@ import (
"k8s.io/kube-openapi/pkg/validation/spec" "k8s.io/kube-openapi/pkg/validation/spec"
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1" 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" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/registry/apis/collections/legacy" "github.com/grafana/grafana/pkg/registry/apis/collections/legacy"
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils" "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/builder"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "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/featuremgmt"
"github.com/grafana/grafana/pkg/services/star" "github.com/grafana/grafana/pkg/services/star"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
@@ -29,13 +34,15 @@ import (
) )
var ( var (
_ builder.APIGroupBuilder = (*APIBuilder)(nil) _ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupMutation = (*APIBuilder)(nil) _ builder.APIGroupMutation = (*APIBuilder)(nil)
_ builder.APIGroupValidation = (*APIBuilder)(nil)
) )
type APIBuilder struct { type APIBuilder struct {
authorizer authorizer.Authorizer authorizer authorizer.Authorizer
legacyStars *legacy.DashboardStarsStorage legacyStars *legacy.DashboardStarsStorage
datasourceStacksValidator builder.APIGroupValidation
} }
func RegisterAPIService( func RegisterAPIService(
@@ -45,6 +52,8 @@ func RegisterAPIService(
stars star.Service, stars star.Service,
users user.Service, users user.Service,
apiregistration builder.APIRegistrar, apiregistration builder.APIRegistrar,
dsConnClientFactory datasourcesClient.DataSourceConnectionClientFactory,
restConfigProvider apiserver.RestConfigProvider,
) *APIBuilder { ) *APIBuilder {
// Requires development settings and clearly experimental // Requires development settings and clearly experimental
//nolint:staticcheck // not yet migrated to OpenFeature //nolint:staticcheck // not yet migrated to OpenFeature
@@ -52,11 +61,15 @@ func RegisterAPIService(
return nil return nil
} }
dsConnClient := dsConnClientFactory(restConfigProvider)
sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db)) sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db))
builder := &APIBuilder{ builder := &APIBuilder{
datasourceStacksValidator: GetDatasourceStacksValidator(dsConnClient),
authorizer: &utils.AuthorizeFromName{ authorizer: &utils.AuthorizeFromName{
Resource: map[string][]utils.ResourceOwner{ 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{} storage := map[string]rest.Storage{}
// Configure Stars Dual writer // Configure Stars Dual writer
resource := collections.StarsResourceInfo starsResource := collections.StarsResourceInfo
var stars grafanarest.Storage 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 { if err != nil {
return err return err
} }
stars = &starStorage{Storage: stars} // wrap List so we only return one value stars = &starStorage{Storage: stars} // wrap List so we only return one value
if b.legacyStars != nil && opts.DualWriteBuilder != nil { 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 { if err != nil {
return err return err
} }
} }
storage[resource.StoragePath()] = stars storage[starsResource.StoragePath()] = stars
storage[resource.StoragePath("update")] = &starsREST{store: 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 apiGroupInfo.VersionedResourcesStorageMap[collections.APIVersion] = storage
return nil 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 { 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 { func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {

View File

@@ -85,7 +85,7 @@ func RegisterAPIService(
accessControl, accessControl,
//nolint:staticcheck // not yet migrated to OpenFeature //nolint:staticcheck // not yet migrated to OpenFeature
features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes), features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
false, true,
) )
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -15,6 +15,7 @@ import (
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1" queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources"
"k8s.io/apimachinery/pkg/fields"
) )
var ( var (
@@ -28,11 +29,11 @@ var (
// Get all datasource connections -- this will be backed by search or duplicated resource in unified storage // Get all datasource connections -- this will be backed by search or duplicated resource in unified storage
type DataSourceConnectionProvider interface { type DataSourceConnectionProvider interface {
// Get gets a specific datasource (that the user in context can see) // 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) GetConnection(ctx context.Context, namespace string, name string) (*queryV0.DataSourceConnection, error)
// List lists all data sources the user in context can see // List lists all data sources the user in context can see. Optional field selectors can filter the results.
ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error) ListConnections(ctx context.Context, namespace string, fieldSelector fields.Selector) (*queryV0.DataSourceConnectionList, error)
} }
type connectionAccess struct { 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) { 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 { type connectionsProvider struct {
@@ -103,19 +108,47 @@ func (q *connectionsProvider) GetConnection(ctx context.Context, namespace strin
return q.asConnection(ds, namespace) 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) ns, err := authlib.ParseNamespace(namespace)
if err != nil { if err != nil {
return nil, err return nil, err
} }
dss, err := q.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{ var dss []*datasources.DataSource
OrgID: ns.OrgID, // if fieldSelector is not nil, find any uids in the metadata.name field and
DataSourceLimit: 10000, // use them in the query
}) if fieldSelector != nil && !fieldSelector.Empty() {
if err != nil { uids := []string{}
return nil, err 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{ result := &queryV0.DataSourceConnectionList{
Items: []queryV0.DataSourceConnection{}, Items: []queryV0.DataSourceConnection{},
} }

View File

@@ -88,6 +88,7 @@ import (
"github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasourceproxy"
"github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources"
datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" 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/dsquerierclient"
"github.com/grafana/grafana/pkg/services/encryption" "github.com/grafana/grafana/pkg/services/encryption"
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
@@ -476,6 +477,7 @@ var wireBasicSet = wire.NewSet(
appregistry.WireSet, appregistry.WireSet,
// Dashboard Kubernetes helpers // Dashboard Kubernetes helpers
dashboardclient.ProvideK8sClientWithFallback, dashboardclient.ProvideK8sClientWithFallback,
datasourcesclient.ProvideDataSourceConnectionClientFactory,
) )
var wireSet = wire.NewSet( var wireSet = wire.NewSet(

File diff suppressed because one or more lines are too long

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -574,6 +574,15 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *n
Url: baseUrl + "/datasources", Url: baseUrl + "/datasources",
Children: []*navtree.NavLink{}, 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 { if len(children) > 0 {

View File

@@ -182,6 +182,8 @@ export function getNavTitle(navId: string | undefined) {
return t('nav.connections.title', 'Connections'); return t('nav.connections.title', 'Connections');
case 'connections-add-new-connection': case 'connections-add-new-connection':
return t('nav.add-new-connections.title', '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': case 'standalone-plugin-page-/connections/collector':
return t('nav.collector.title', 'Collector'); return t('nav.collector.title', 'Collector');
case 'connections-datasources': case 'connections-datasources':

View File

@@ -10,10 +10,13 @@ import { CacheFeatureHighlightPage } from './pages/CacheFeatureHighlightPage';
import ConnectionsHomePage from './pages/ConnectionsHomePage'; import ConnectionsHomePage from './pages/ConnectionsHomePage';
import { DataSourceDashboardsPage } from './pages/DataSourceDashboardsPage'; import { DataSourceDashboardsPage } from './pages/DataSourceDashboardsPage';
import { DataSourceDetailsPage } from './pages/DataSourceDetailsPage'; import { DataSourceDetailsPage } from './pages/DataSourceDetailsPage';
import { DataSourceStacksPage } from './pages/DataSourceStacksPage';
import { DataSourcesListPage } from './pages/DataSourcesListPage'; import { DataSourcesListPage } from './pages/DataSourcesListPage';
import { EditDataSourcePage } from './pages/EditDataSourcePage'; import { EditDataSourcePage } from './pages/EditDataSourcePage';
import { EditStackPage } from './pages/EditStackPage';
import { InsightsFeatureHighlightPage } from './pages/InsightsFeatureHighlightPage'; import { InsightsFeatureHighlightPage } from './pages/InsightsFeatureHighlightPage';
import { NewDataSourcePage } from './pages/NewDataSourcePage'; import { NewDataSourcePage } from './pages/NewDataSourcePage';
import { NewStackPage } from './pages/NewStackPage';
import { PermissionsFeatureHighlightPage } from './pages/PermissionsFeatureHighlightPage'; import { PermissionsFeatureHighlightPage } from './pages/PermissionsFeatureHighlightPage';
function RedirectToAddNewConnection() { 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 */} {/* 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.DataSources.replace(ROUTES.Base, '')} element={<DataSourcesListPage />} />
<Route caseSensitive path={ROUTES.DataSourcesNew.replace(ROUTES.Base, '')} element={<NewDataSourcePage />} /> <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 <Route
caseSensitive caseSensitive
path={ROUTES.DataSourcesDetails.replace(ROUTES.Base, '')} path={ROUTES.DataSourcesDetails.replace(ROUTES.Base, '')}

View File

@@ -75,5 +75,11 @@ export function getOssCardData(): CardData[] {
url: '/connections/datasources', url: '/connections/datasources',
icon: 'database', icon: 'database',
}, },
{
text: 'Stacks',
subTitle: 'Manage your data source stacks',
url: '/connections/stacks',
icon: 'layers',
},
]; ];
} }

View File

@@ -9,6 +9,10 @@ export const ROUTES = {
DataSourcesNew: `/${ROUTE_BASE_ID}/datasources/new`, DataSourcesNew: `/${ROUTE_BASE_ID}/datasources/new`,
DataSourcesEdit: `/${ROUTE_BASE_ID}/datasources/edit/:uid`, DataSourcesEdit: `/${ROUTE_BASE_ID}/datasources/edit/:uid`,
DataSourcesDashboards: `/${ROUTE_BASE_ID}/datasources/edit/:uid/dashboards`, 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 // Add new connection
AddNewConnection: `/${ROUTE_BASE_ID}/add-new-connection`, AddNewConnection: `/${ROUTE_BASE_ID}/add-new-connection`,

View File

@@ -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);
})
)
);
};

View File

@@ -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} />;
}

View File

@@ -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>
);
}

View File

@@ -849,6 +849,7 @@ describe('DashboardSceneSerializer', () => {
query: 'app1', query: 'app1',
skipUrlSync: false, skipUrlSync: false,
allowCustomValue: true, allowCustomValue: true,
valuesFormat: 'csv',
}, },
}, },
]); ]);

View File

@@ -294,6 +294,7 @@ exports[`Given a scene with custom quick ranges should save quick ranges to save
"options": [], "options": [],
"query": "a, b, c", "query": "a, b, c",
"type": "custom", "type": "custom",
"valuesFormat": "csv",
}, },
{ {
"current": { "current": {
@@ -679,6 +680,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
"options": [], "options": [],
"query": "A,B,C,D,E,F,E,G,H,I,J,K,L", "query": "A,B,C,D,E,F,E,G,H,I,J,K,L",
"type": "custom", "type": "custom",
"valuesFormat": "csv",
}, },
{ {
"current": { "current": {
@@ -697,6 +699,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
"options": [], "options": [],
"query": "Bob : 1, Rob : 2,Sod : 3, Hod : 4, Cod : 5", "query": "Bob : 1, Rob : 2,Sod : 3, Hod : 4, Cod : 5",
"type": "custom", "type": "custom",
"valuesFormat": "csv",
}, },
], ],
}, },
@@ -1019,6 +1022,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
"options": [], "options": [],
"query": "a, b, c", "query": "a, b, c",
"type": "custom", "type": "custom",
"valuesFormat": "csv",
}, },
{ {
"current": { "current": {
@@ -1378,6 +1382,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
"options": [], "options": [],
"query": "a, b, c", "query": "a, b, c",
"type": "custom", "type": "custom",
"valuesFormat": "csv",
}, },
{ {
"current": { "current": {

View File

@@ -197,6 +197,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
"options": [], "options": [],
"query": "option1, option2", "query": "option1, option2",
"skipUrlSync": false, "skipUrlSync": false,
"valuesFormat": "csv",
}, },
}, },
{ {

View File

@@ -374,6 +374,7 @@ describe('sceneVariablesSetToVariables', () => {
"options": [], "options": [],
"query": "test,test1,test2", "query": "test,test1,test2",
"type": "custom", "type": "custom",
"valuesFormat": "csv",
} }
`); `);
}); });
@@ -1148,6 +1149,7 @@ describe('sceneVariablesSetToVariables', () => {
"options": [], "options": [],
"query": "test,test1,test2", "query": "test,test1,test2",
"skipUrlSync": false, "skipUrlSync": false,
"valuesFormat": "csv",
}, },
} }
`); `);

View File

@@ -110,6 +110,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
allValue: variable.state.allValue, allValue: variable.state.allValue,
includeAll: variable.state.includeAll, includeAll: variable.state.includeAll,
allowCustomValue: variable.state.allowCustomValue, allowCustomValue: variable.state.allowCustomValue,
valuesFormat: variable.state.valuesFormat,
}); });
} else if (sceneUtils.isDataSourceVariable(variable)) { } else if (sceneUtils.isDataSourceVariable(variable)) {
variables.push({ variables.push({
@@ -392,6 +393,7 @@ export function sceneVariablesSetToSchemaV2Variables(
allValue: variable.state.allValue, allValue: variable.state.allValue,
includeAll: variable.state.includeAll ?? false, includeAll: variable.state.includeAll ?? false,
allowCustomValue: variable.state.allowCustomValue ?? true, allowCustomValue: variable.state.allowCustomValue ?? true,
valuesFormat: variable.state.valuesFormat,
}, },
}; };
variables.push(customVariable); variables.push(customVariable);

View File

@@ -335,12 +335,12 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
), ),
}); });
} }
if (variable.kind === defaultCustomVariableKind().kind) { if (variable.kind === defaultCustomVariableKind().kind) {
return new CustomVariable({ return new CustomVariable({
...commonProperties, ...commonProperties,
value: variable.spec.current?.value ?? '', value: variable.spec.current?.value ?? '',
text: variable.spec.current?.text ?? '', text: variable.spec.current?.text ?? '',
query: variable.spec.query, query: variable.spec.query,
isMulti: variable.spec.multi, isMulti: variable.spec.multi,
allValue: variable.spec.allValue || undefined, allValue: variable.spec.allValue || undefined,
@@ -348,6 +348,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
defaultToAll: Boolean(variable.spec.includeAll), defaultToAll: Boolean(variable.spec.includeAll),
skipUrlSync: variable.spec.skipUrlSync, skipUrlSync: variable.spec.skipUrlSync,
hide: transformVariableHideToEnumV1(variable.spec.hide), hide: transformVariableHideToEnumV1(variable.spec.hide),
valuesFormat: variable.spec.valuesFormat || 'csv',
}); });
} else if (variable.kind === defaultQueryVariableKind().kind) { } else if (variable.kind === defaultQueryVariableKind().kind) {
return new QueryVariable({ return new QueryVariable({

View File

@@ -9,7 +9,7 @@ import { Trans, t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { SceneVariable } from '@grafana/scenes'; import { SceneVariable } from '@grafana/scenes';
import { VariableHide, defaultVariableModel } from '@grafana/schema'; 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 { VariableHideSelect } from 'app/features/dashboard-scene/settings/variables/components/VariableHideSelect';
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend'; import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField'; 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 onHideChange = (hide: VariableHide) => variable.setState({ hide });
const isHasVariableOptions = hasVariableOptions(variable); 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) => () => { const onDeleteVariable = (hideModal: () => void) => () => {
reportInteraction('Delete variable'); reportInteraction('Delete variable');
@@ -123,7 +126,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
{EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />} {EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />}
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />} {isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>
<Stack gap={2}> <Stack gap={2}>

View File

@@ -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'; import { selectors } from '@grafana/e2e-selectors';
@@ -130,4 +131,71 @@ describe('CustomVariableForm', () => {
expect(onMultiChange).not.toHaveBeenCalled(); expect(onMultiChange).not.toHaveBeenCalled();
expect(onIncludeAllChange).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();
});
});
}); });

View File

@@ -1,7 +1,9 @@
import { FormEvent } from 'react'; import { FormEvent } from 'react';
import { CustomVariableModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n'; import { Trans, t } from '@grafana/i18n';
import { FieldValidationMessage, Icon, RadioButtonGroup, Stack, TextLink, Tooltip } from '@grafana/ui';
import { SelectionOptionsForm } from './SelectionOptionsForm'; import { SelectionOptionsForm } from './SelectionOptionsForm';
import { VariableLegend } from './VariableLegend'; import { VariableLegend } from './VariableLegend';
@@ -9,10 +11,12 @@ import { VariableTextAreaField } from './VariableTextAreaField';
interface CustomVariableFormProps { interface CustomVariableFormProps {
query: string; query: string;
valuesFormat?: CustomVariableModel['valuesFormat'];
multi: boolean; multi: boolean;
allValue?: string | null; allValue?: string | null;
includeAll: boolean; includeAll: boolean;
allowCustomValue?: boolean; allowCustomValue?: boolean;
queryValidationError?: Error;
onQueryChange: (event: FormEvent<HTMLTextAreaElement>) => void; onQueryChange: (event: FormEvent<HTMLTextAreaElement>) => void;
onMultiChange: (event: FormEvent<HTMLInputElement>) => void; onMultiChange: (event: FormEvent<HTMLInputElement>) => void;
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void; onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
@@ -20,19 +24,23 @@ interface CustomVariableFormProps {
onQueryBlur?: (event: FormEvent<HTMLTextAreaElement>) => void; onQueryBlur?: (event: FormEvent<HTMLTextAreaElement>) => void;
onAllValueBlur?: (event: FormEvent<HTMLInputElement>) => void; onAllValueBlur?: (event: FormEvent<HTMLInputElement>) => void;
onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void; onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void;
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
} }
export function CustomVariableForm({ export function CustomVariableForm({
query, query,
valuesFormat,
multi, multi,
allValue, allValue,
includeAll, includeAll,
allowCustomValue, allowCustomValue,
queryValidationError,
onQueryChange, onQueryChange,
onMultiChange, onMultiChange,
onIncludeAllChange, onIncludeAllChange,
onAllValueChange, onAllValueChange,
onAllowCustomValueChange, onAllowCustomValueChange,
onValuesFormatChange,
}: CustomVariableFormProps) { }: CustomVariableFormProps) {
return ( return (
<> <>
@@ -40,16 +48,27 @@ export function CustomVariableForm({
<Trans i18nKey="dashboard-scene.custom-variable-form.custom-options">Custom options</Trans> <Trans i18nKey="dashboard-scene.custom-variable-form.custom-options">Custom options</Trans>
</VariableLegend> </VariableLegend>
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
<VariableTextAreaField <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} defaultValue={query}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="1, 10, mykey : myvalue, myvalue, escaped\,value"
onBlur={onQueryChange} onBlur={onQueryChange}
required required
width={52} width={52}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput} testId={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
/> />
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
<VariableLegend> <VariableLegend>
<Trans i18nKey="dashboard-scene.custom-variable-form.selection-options">Selection options</Trans> <Trans i18nKey="dashboard-scene.custom-variable-form.selection-options">Selection options</Trans>
</VariableLegend> </VariableLegend>
@@ -58,6 +77,8 @@ export function CustomVariableForm({
includeAll={includeAll} includeAll={includeAll}
allValue={allValue} allValue={allValue}
allowCustomValue={allowCustomValue} allowCustomValue={allowCustomValue}
disableAllowCustomValue={valuesFormat === 'json'}
disableCustomAllValue={valuesFormat === 'json'}
onMultiChange={onMultiChange} onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange} onIncludeAllChange={onIncludeAllChange}
onAllValueChange={onAllValueChange} 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>
);
}

View File

@@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime'; import { getDataSourceSrv } from '@grafana/runtime';
import { QueryVariable } from '@grafana/scenes'; import { QueryVariable } from '@grafana/scenes';
import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema'; 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 { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
import { SelectionOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm'; import { SelectionOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; 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 { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect';
import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect'; import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect';
import { import {
QueryVariableStaticOptions,
StaticOptionsOrderType, StaticOptionsOrderType,
StaticOptionsType, StaticOptionsType,
QueryVariableStaticOptions,
} from 'app/features/variables/query/QueryVariableStaticOptions'; } from 'app/features/variables/query/QueryVariableStaticOptions';
import { VariableLegend } from './VariableLegend'; import { VariableLegend } from './VariableLegend';
@@ -34,6 +34,7 @@ interface QueryVariableEditorFormProps {
timeRange: TimeRange; timeRange: TimeRange;
regex: string | null; regex: string | null;
onRegExChange: (event: FormEvent<HTMLTextAreaElement>) => void; onRegExChange: (event: FormEvent<HTMLTextAreaElement>) => void;
disableRegexEdition?: boolean;
sort: VariableSort; sort: VariableSort;
onSortChange: (option: SelectableValue<VariableSort>) => void; onSortChange: (option: SelectableValue<VariableSort>) => void;
refresh: VariableRefresh; refresh: VariableRefresh;
@@ -42,14 +43,17 @@ interface QueryVariableEditorFormProps {
onMultiChange: (event: FormEvent<HTMLInputElement>) => void; onMultiChange: (event: FormEvent<HTMLInputElement>) => void;
allowCustomValue?: boolean; allowCustomValue?: boolean;
onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void; onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void;
disableAllowCustomValue?: boolean;
includeAll: boolean; includeAll: boolean;
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void; onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
allValue: string; allValue: string;
onAllValueChange: (event: FormEvent<HTMLInputElement>) => void; onAllValueChange: (event: FormEvent<HTMLInputElement>) => void;
disableCustomAllValue?: boolean;
staticOptions?: StaticOptionsType; staticOptions?: StaticOptionsType;
staticOptionsOrder?: StaticOptionsOrderType; staticOptionsOrder?: StaticOptionsOrderType;
onStaticOptionsChange?: (staticOptions: StaticOptionsType) => void; onStaticOptionsChange?: (staticOptions: StaticOptionsType) => void;
onStaticOptionsOrderChange?: (staticOptionsOrder: StaticOptionsOrderType) => void; onStaticOptionsOrderChange?: (staticOptionsOrder: StaticOptionsOrderType) => void;
disableStaticOptions?: boolean;
} }
export function QueryVariableEditorForm({ export function QueryVariableEditorForm({
@@ -61,6 +65,7 @@ export function QueryVariableEditorForm({
timeRange, timeRange,
regex, regex,
onRegExChange, onRegExChange,
disableRegexEdition,
sort, sort,
onSortChange, onSortChange,
refresh, refresh,
@@ -69,14 +74,17 @@ export function QueryVariableEditorForm({
onMultiChange, onMultiChange,
allowCustomValue, allowCustomValue,
onAllowCustomValueChange, onAllowCustomValueChange,
disableAllowCustomValue,
includeAll, includeAll,
onIncludeAllChange, onIncludeAllChange,
allValue, allValue,
onAllValueChange, onAllValueChange,
disableCustomAllValue,
staticOptions, staticOptions,
staticOptionsOrder, staticOptionsOrder,
onStaticOptionsChange, onStaticOptionsChange,
onStaticOptionsOrderChange, onStaticOptionsOrderChange,
disableStaticOptions,
}: QueryVariableEditorFormProps) { }: QueryVariableEditorFormProps) {
const { value: dsConfig } = useAsync(async () => { const { value: dsConfig } = useAsync(async () => {
const datasource = await getDataSourceSrv().get(datasourceRef ?? ''); const datasource = await getDataSourceSrv().get(datasourceRef ?? '');
@@ -116,48 +124,53 @@ export function QueryVariableEditorForm({
<Field <Field
label={t('dashboard-scene.query-variable-editor-form.label-data-source', 'Data source')} label={t('dashboard-scene.query-variable-editor-form.label-data-source', 'Data source')}
htmlFor="data-source-picker" htmlFor="data-source-picker"
noMargin
> >
<DataSourcePicker current={datasourceRef} onChange={datasourceChangeHandler} variables={true} width={30} /> <DataSourcePicker current={datasourceRef} onChange={datasourceChangeHandler} variables={true} width={30} />
</Field> </Field>
{datasource && VariableQueryEditor && ( {datasource && VariableQueryEditor && (
<QueryEditor <Box marginBottom={2}>
onQueryChange={onQueryChange} <QueryEditor
onLegacyQueryChange={onLegacyQueryChange} onQueryChange={onQueryChange}
datasource={datasource} onLegacyQueryChange={onLegacyQueryChange}
query={query} datasource={datasource}
VariableQueryEditor={VariableQueryEditor} query={query}
timeRange={timeRange} VariableQueryEditor={VariableQueryEditor}
/> timeRange={timeRange}
/>
</Box>
)} )}
<VariableTextAreaField {!disableRegexEdition && (
defaultValue={regex ?? ''} <VariableTextAreaField
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')} defaultValue={regex ?? ''}
description={ name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
<div> description={
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional"> <div>
Optional, if you want to extract part of a series name or metric node segment. <Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
</Trans> Optional, if you want to extract part of a series name or metric node segment.
<br /> </Trans>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples"> <br />
Named capture groups can be used to separate the display text and value ( <Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
<TextLink Named capture groups can be used to separate the display text and value (
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups" <TextLink
external 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> see examples
). </TextLink>
</Trans> ).
</div> </Trans>
} </div>
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings }
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/" // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
onBlur={onRegExChange} placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2} onBlur={onRegExChange}
width={52} testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
/> width={52}
/>
)}
<QueryVariableSortSelect <QueryVariableSortSelect
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2} testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2}
@@ -171,7 +184,7 @@ export function QueryVariableEditorForm({
refresh={refresh} refresh={refresh}
/> />
{onStaticOptionsChange && onStaticOptionsOrderChange && ( {!disableStaticOptions && onStaticOptionsChange && onStaticOptionsOrderChange && (
<QueryVariableStaticOptions <QueryVariableStaticOptions
staticOptions={staticOptions} staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder} staticOptionsOrder={staticOptionsOrder}
@@ -187,6 +200,8 @@ export function QueryVariableEditorForm({
multi={!!isMulti} multi={!!isMulti}
includeAll={!!includeAll} includeAll={!!includeAll}
allowCustomValue={allowCustomValue} allowCustomValue={allowCustomValue}
disableAllowCustomValue={disableAllowCustomValue}
disableCustomAllValue={disableCustomAllValue}
allValue={allValue} allValue={allValue}
onMultiChange={onMultiChange} onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange} onIncludeAllChange={onIncludeAllChange}

View File

@@ -10,7 +10,9 @@ interface SelectionOptionsFormProps {
multi: boolean; multi: boolean;
includeAll: boolean; includeAll: boolean;
allowCustomValue?: boolean; allowCustomValue?: boolean;
disableAllowCustomValue?: boolean;
allValue?: string | null; allValue?: string | null;
disableCustomAllValue?: boolean;
onMultiChange: (event: ChangeEvent<HTMLInputElement>) => void; onMultiChange: (event: ChangeEvent<HTMLInputElement>) => void;
onAllowCustomValueChange?: (event: ChangeEvent<HTMLInputElement>) => void; onAllowCustomValueChange?: (event: ChangeEvent<HTMLInputElement>) => void;
onIncludeAllChange: (event: ChangeEvent<HTMLInputElement>) => void; onIncludeAllChange: (event: ChangeEvent<HTMLInputElement>) => void;
@@ -20,8 +22,10 @@ interface SelectionOptionsFormProps {
export function SelectionOptionsForm({ export function SelectionOptionsForm({
multi, multi,
allowCustomValue, allowCustomValue,
disableAllowCustomValue,
includeAll, includeAll,
allValue, allValue,
disableCustomAllValue,
onMultiChange, onMultiChange,
onAllowCustomValueChange, onAllowCustomValueChange,
onIncludeAllChange, onIncludeAllChange,
@@ -39,18 +43,19 @@ export function SelectionOptionsForm({
onChange={onMultiChange} onChange={onMultiChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch} testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch}
/> />
{onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup {!disableAllowCustomValue &&
<VariableCheckboxField onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup
value={allowCustomValue ?? true} <VariableCheckboxField
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')} value={allowCustomValue ?? true}
description={t( name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
'dashboard-scene.selection-options-form.description-enables-users-custom-values', description={t(
'Enables users to add custom values to the list' '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} onChange={onAllowCustomValueChange}
/> testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
)} />
)}
<VariableCheckboxField <VariableCheckboxField
value={includeAll} value={includeAll}
name={t('dashboard-scene.selection-options-form.name-include-all-option', 'Include All option')} name={t('dashboard-scene.selection-options-form.name-include-all-option', 'Include All option')}
@@ -61,7 +66,7 @@ export function SelectionOptionsForm({
onChange={onIncludeAllChange} onChange={onIncludeAllChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch} testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch}
/> />
{includeAll && ( {!disableCustomAllValue && includeAll && (
<VariableTextField <VariableTextField
defaultValue={allValue ?? ''} defaultValue={allValue ?? ''}
onBlur={onAllValueChange} onBlur={onAllValueChange}

View File

@@ -5,13 +5,50 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n'; import { Trans } from '@grafana/i18n';
import { VariableValueOption } from '@grafana/scenes'; 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[]; 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 [previewLimit, setPreviewLimit] = useState(20);
const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]); const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]);
const showMoreOptions = useCallback( const showMoreOptions = useCallback(
@@ -21,15 +58,10 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
}, },
[previewLimit, setPreviewLimit] [previewLimit, setPreviewLimit]
); );
const styles = useStyles2(getStyles);
useEffect(() => setPreviewOptions(options.slice(0, previewLimit)), [previewLimit, options]); useEffect(() => setPreviewOptions(options.slice(0, previewLimit)), [previewLimit, options]);
if (!previewOptions.length) {
return null;
}
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', marginTop: '16px' }}> <div className={styles.previewContainer}>
<Text variant="bodySmall" weight="medium"> <Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans> <Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
</Text> </Text>
@@ -51,12 +83,12 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
)} )}
</div> </div>
); );
}; }
VariableValuesPreview.displayName = 'VariableValuesPreview'; VariableValuesWithoutPropsPreview.displayName = 'VariableValuesWithoutPropsPreview';
function getStyles(theme: GrafanaTheme2) { function getStyles(theme: GrafanaTheme2) {
return { return {
wrapper: css({ previewContainer: css({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
marginTop: theme.spacing(2), marginTop: theme.spacing(2),
@@ -71,5 +103,10 @@ function getStyles(theme: GrafanaTheme2) {
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
maxWidth: '50vw', maxWidth: '50vw',
}), }),
table: css({
td: css({
padding: theme.spacing(0.5, 1),
}),
}),
}; };
} }

View File

@@ -5,117 +5,225 @@ import { CustomVariable } from '@grafana/scenes';
import { CustomVariableEditor } from './CustomVariableEditor'; 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', () => { describe('CustomVariableEditor', () => {
it('should render the CustomVariableForm with correct initial values', () => { describe('CSV values format', () => {
const variable = new CustomVariable({ it('should render CustomVariableForm with the correct initial values', () => {
name: 'customVar', const { variable, onRunQuery } = setup({
query: 'test, test2', query: 'test, test2',
value: 'test', value: 'test',
isMulti: true, isMulti: true,
includeAll: true, includeAll: true,
allValue: 'test', 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( const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
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;
expect(queryInput.value).toBe('test, test2'); expect(elements.multiValueCheckbox().checked).toBe(false);
expect(allValueInput.value).toBe('test'); expect(elements.allowCustomValueCheckbox().checked).toBe(false);
expect(multiCheckbox.checked).toBe(true); expect(elements.includeAllCheckbox().checked).toBe(false);
expect(includeAllCheckbox.checked).toBe(true); // 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', () => { describe('JSON values format', () => {
const variable = new CustomVariable({ const initialJsonQuery = `[
name: 'customVar', {"value":1,"text":"Development","aws":"dev","azure":"development"},
query: 'test, test2', {"value":2,"text":"Production","aws":"prod","azure":"production"}
value: 'test', ]`;
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( const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
);
const includeAllCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
);
const allowCustomValueCheckbox = getByTestId( actions.updateValuesInput('[]');
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
);
// It include-all-custom input appears after include-all checkbox is checked only expect(variable.state.query).toBe('[]');
expect(() => expect(onRunQuery).toHaveBeenCalled();
getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput) });
).toThrow('Unable to find an element'); });
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); actions.updateValuesInput('[x]');
const allValueInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
);
expect(variable.state.isMulti).toBe(true); expect(getByRole('alert')).toHaveTextContent(`Unexpected token 'x', "[x]" is not valid JSON`);
expect(variable.state.includeAll).toBe(true); expect(variable.state.query).toBe(initialJsonQuery);
expect(variable.state.allowCustomValue).toBe(false); expect(onRunQuery).not.toHaveBeenCalled();
expect(allValueInput).toBeInTheDocument(); });
});
});
}); });
it('should call update query and re-run query when input loses focus', async () => { describe('when switching values format', () => {
const variable = new CustomVariable({ it('should switch the visibility of the proper form inputs ("Allow custom values" and "Custom all value")', () => {
name: 'customVar', const { variable, onRunQuery } = setup({
query: 'test, test2', valuesFormat: 'csv',
value: 'test', 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');
}); });
}); });

View File

@@ -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 { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes'; import { CustomVariable, SceneVariable } from '@grafana/scenes';
@@ -14,7 +16,26 @@ interface CustomVariableEditorProps {
} }
export function CustomVariableEditor({ variable, onRunQuery }: 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( const onMultiChange = useCallback(
(event: FormEvent<HTMLInputElement>) => { (event: FormEvent<HTMLInputElement>) => {
@@ -32,10 +53,20 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
const onQueryChange = useCallback( const onQueryChange = useCallback(
(event: FormEvent<HTMLTextAreaElement>) => { (event: FormEvent<HTMLTextAreaElement>) => {
setPrevQuery('');
if (valuesFormat === 'json') {
const validationError = validateJsonQuery(event.currentTarget.value);
setQueryValidationError(validationError);
if (validationError) {
return;
}
}
variable.setState({ query: event.currentTarget.value }); variable.setState({ query: event.currentTarget.value });
onRunQuery(); onRunQuery();
}, },
[variable, onRunQuery] [valuesFormat, variable, onRunQuery]
); );
const onAllValueChange = useCallback( const onAllValueChange = useCallback(
@@ -55,15 +86,18 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
return ( return (
<CustomVariableForm <CustomVariableForm
query={query ?? ''} query={query ?? ''}
valuesFormat={valuesFormat ?? 'csv'}
multi={!!isMulti} multi={!!isMulti}
allValue={allValue ?? ''} allValue={allValue ?? ''}
includeAll={!!includeAll} includeAll={!!includeAll}
allowCustomValue={allowCustomValue} allowCustomValue={allowCustomValue}
queryValidationError={queryValidationError}
onQueryChange={onQueryChange}
onMultiChange={onMultiChange} onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange} onIncludeAllChange={onIncludeAllChange}
onQueryChange={onQueryChange}
onAllValueChange={onAllValueChange} onAllValueChange={onAllValueChange}
onAllowCustomValueChange={onAllowCustomValueChange} 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;
}
};

View File

@@ -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 { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n'; import { t, Trans } from '@grafana/i18n';
import { CustomVariable } from '@grafana/scenes'; 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 { ValuesFormatSelector } from '../../components/CustomVariableForm';
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton'; import { VariableValuesPreview } from '../../components/VariableValuesPreview';
import { ValuesBuilder } from './ValuesBuilder'; import { validateJsonQuery } from './CustomVariableEditor';
import { ValuesPreview } from './ValuesPreview';
interface ModalEditorProps { interface ModalEditorProps {
variable: CustomVariable; variable: CustomVariable;
@@ -18,9 +19,49 @@ interface ModalEditorProps {
} }
export function ModalEditor({ variable, isOpen, onClose }: 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 ( return (
<Modal <Modal
@@ -29,10 +70,31 @@ export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
onDismiss={onClose} onDismiss={onClose}
> >
<Stack direction="column" gap={2}> <Stack direction="column" gap={2}>
<ValuesBuilder variable={variable} ref={formRef} /> <ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
<ValuesPreview variable={variable} /> <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> </Stack>
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={handleOnAdd} />}> <Modal.ButtonRow>
<Button <Button
variant="secondary" variant="secondary"
fill="outline" fill="outline"

View File

@@ -1,4 +1,3 @@
import { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes'; import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { OptionsPaneItemDescriptor } from '../../../../../dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { OptionsPaneItemDescriptor } from '../../../../../dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@@ -12,7 +11,6 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
return [ return [
new OptionsPaneItemDescriptor({ new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
id: 'custom-variable-values', id: 'custom-variable-values',
render: ({ props }) => <PaneItem id={props.id} variable={variable} />, render: ({ props }) => <PaneItem id={props.id} variable={variable} />,
}), }),

View File

@@ -1,4 +1,4 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent, useMemo, useEffect } from 'react';
import { useAsync } from 'react-use'; import { useAsync } from 'react-use';
import { SelectableValue, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data'; import { SelectableValue, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data';
@@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime'; import { getDataSourceSrv } from '@grafana/runtime';
import { QueryVariable, sceneGraph, SceneVariable } from '@grafana/scenes'; import { QueryVariable, sceneGraph, SceneVariable } from '@grafana/scenes';
import { VariableRefresh, VariableSort } from '@grafana/schema'; 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 { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor'; import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
@@ -44,6 +44,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
allowCustomValue, allowCustomValue,
staticOptions, staticOptions,
staticOptionsOrder, staticOptionsOrder,
options,
} = variable.useState(); } = variable.useState();
const { value: timeRange } = sceneGraph.getTimeRange(variable).useState(); const { value: timeRange } = sceneGraph.getTimeRange(variable).useState();
@@ -93,6 +94,17 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
variable.setState({ staticOptionsOrder }); 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 ( return (
<QueryVariableEditorForm <QueryVariableEditorForm
datasource={datasource ?? undefined} datasource={datasource ?? undefined}
@@ -103,6 +115,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
timeRange={timeRange} timeRange={timeRange}
regex={regex} regex={regex}
onRegExChange={onRegExChange} onRegExChange={onRegExChange}
disableRegexEdition={hasMultiProps}
sort={sort} sort={sort}
onSortChange={onSortChange} onSortChange={onSortChange}
refresh={refresh} refresh={refresh}
@@ -113,12 +126,15 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
onIncludeAllChange={onIncludeAllChange} onIncludeAllChange={onIncludeAllChange}
allValue={allValue ?? ''} allValue={allValue ?? ''}
onAllValueChange={onAllValueChange} onAllValueChange={onAllValueChange}
disableCustomAllValue={hasMultiProps}
allowCustomValue={allowCustomValue} allowCustomValue={allowCustomValue}
onAllowCustomValueChange={onAllowCustomValueChange} onAllowCustomValueChange={onAllowCustomValueChange}
disableAllowCustomValue={hasMultiProps}
staticOptions={staticOptions} staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder} staticOptionsOrder={staticOptionsOrder}
onStaticOptionsChange={onStaticOptionsChange} onStaticOptionsChange={onStaticOptionsChange}
onStaticOptionsOrderChange={onStaticOptionsOrderChange} onStaticOptionsOrderChange={onStaticOptionsOrderChange}
disableStaticOptions={hasMultiProps}
/> />
); );
} }
@@ -250,6 +266,19 @@ export function Editor({ variable }: { variable: QueryVariable }) {
const isHasVariableOptions = hasVariableOptions(variable); 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 ( return (
<div data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.editor}> <div data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.editor}>
<Field <Field
@@ -261,43 +290,64 @@ export function Editor({ variable }: { variable: QueryVariable }) {
</Field> </Field>
{selectedDatasource && VariableQueryEditor && ( {selectedDatasource && VariableQueryEditor && (
<QueryEditor <Box marginBottom={2}>
onQueryChange={onQueryChange} <QueryEditor
onLegacyQueryChange={onQueryChange} onQueryChange={onQueryChange}
datasource={selectedDatasource} onLegacyQueryChange={onQueryChange}
query={query} datasource={selectedDatasource}
VariableQueryEditor={VariableQueryEditor} query={query}
timeRange={timeRange} 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 {!returnsMultiProps && (
defaultValue={regex ?? ''} <VariableTextAreaField
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')} defaultValue={regex ?? ''}
description={ name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
<div> description={
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional"> <div>
Optional, if you want to extract part of a series name or metric node segment. <Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
</Trans> Optional, if you want to extract part of a series name or metric node segment.
<br /> </Trans>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples"> <br />
Named capture groups can be used to separate the display text and value ( <Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
<TextLink Named capture groups can be used to separate the display text and value (
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups" <TextLink
external 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> see examples
). </TextLink>
</Trans> ).
</div> </Trans>
} </div>
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings }
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/" // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
onBlur={onRegExChange} placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2} onBlur={onRegExChange}
width={52} testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
/> width={52}
/>
)}
<QueryVariableSortSelect <QueryVariableSortSelect
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2} testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2}
@@ -311,7 +361,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
refresh={refresh} refresh={refresh}
/> />
{onStaticOptionsChange && onStaticOptionsOrderChange && ( {!returnsMultiProps && onStaticOptionsChange && onStaticOptionsOrderChange && (
<QueryVariableStaticOptions <QueryVariableStaticOptions
staticOptions={staticOptions} staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder} 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> </div>
); );
} }

View File

@@ -45,7 +45,11 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
'A wildcard regex or other value to represent All' 'A wildcard regex or other value to represent All'
), ),
useShowIf: () => { 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} />, 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', 'dashboard.edit-pane.variable.selection-options.allow-custom-values-description',
'Enables users to enter values' '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} />, render: (descriptor) => <AllowCustomSwitch id={descriptor.props.id} variable={variable} />,
}) })
); );

View File

@@ -43,6 +43,7 @@ export function getLocalVariableValueSet(
name: variable.state.name, name: variable.state.name,
value, value,
text, text,
properties: variable.state.options.find((o) => o.value === value)?.properties,
isMulti: variable.state.isMulti, isMulti: variable.state.isMulti,
includeAll: variable.state.includeAll, includeAll: variable.state.includeAll,
}), }),

View File

@@ -103,6 +103,7 @@ describe('when creating variables objects', () => {
text: 'a', text: 'a',
type: 'custom', type: 'custom',
value: 'a', value: 'a',
valuesFormat: 'csv',
hide: 0, hide: 0,
}); });
}); });

View File

@@ -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,
};
};

View File

@@ -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),
}),
});

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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[];
}

View File

@@ -5880,6 +5880,8 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Custom options", "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", "name-values-separated-comma": "Values separated by comma",
"selection-options": "Selection options" "selection-options": "Selection options"
}, },

View File

@@ -3603,11 +3603,11 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@grafana/scenes-react@npm:6.47.1": "@grafana/scenes-react@npm:^6.48.0":
version: 6.47.1 version: 6.48.0
resolution: "@grafana/scenes-react@npm:6.47.1" resolution: "@grafana/scenes-react@npm:6.48.0"
dependencies: dependencies:
"@grafana/scenes": "npm:6.47.1" "@grafana/scenes": "npm:6.48.0"
lru-cache: "npm:^10.2.2" lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0" react-use: "npm:^17.4.0"
peerDependencies: peerDependencies:
@@ -3619,7 +3619,7 @@ __metadata:
react: ^18.0.0 react: ^18.0.0
react-dom: ^18.0.0 react-dom: ^18.0.0
react-router-dom: ^6.28.0 react-router-dom: ^6.28.0
checksum: 10/dc20f9ee80eaf648665f7449e3ccb3b640a931f8f4a1be89599dce17eb0f52e763e3a603a4d491d9886b3e6cdf2ad3634124252c223315917206200f7cd6da16 checksum: 10/5afb2aa79271dd824cc35f0a59ec193ddcbd4e1f14e756551228ce218a19faad54923d3a83f8bbb10d38c1d23c49846df29e7fce8feba8ec9aec2d32d9c1cf8d
languageName: node languageName: node
linkType: hard linkType: hard
@@ -3649,9 +3649,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@grafana/scenes@npm:6.47.1": "@grafana/scenes@npm:6.48.0, @grafana/scenes@npm:^6.48.0":
version: 6.47.1 version: 6.48.0
resolution: "@grafana/scenes@npm:6.47.1" resolution: "@grafana/scenes@npm:6.48.0"
dependencies: dependencies:
"@floating-ui/react": "npm:^0.26.16" "@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16" "@leeoniya/ufuzzy": "npm:^1.0.16"
@@ -3671,7 +3671,7 @@ __metadata:
react: ^18.0.0 react: ^18.0.0
react-dom: ^18.0.0 react-dom: ^18.0.0
react-router-dom: ^6.28.0 react-router-dom: ^6.28.0
checksum: 10/bc0c76258955058e7493b04e7cdd5d59dcc4159adf06da0837e992716ea15700b54f8403614df04326350363dc3344fb2602a2e8f7807724571659b4bd95aded checksum: 10/28cd64ea3c4faf87173ea71ffc136a7a525c33ec2e263ab2a98df718e3968ed7b7a12ecf0f309400af78e3c3269ae6048da8113412645a361a3e4925f9a2a810
languageName: node languageName: node
linkType: hard linkType: hard
@@ -19234,8 +19234,8 @@ __metadata:
"@grafana/plugin-ui": "npm:^0.11.1" "@grafana/plugin-ui": "npm:^0.11.1"
"@grafana/prometheus": "workspace:*" "@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*" "@grafana/runtime": "workspace:*"
"@grafana/scenes": "npm:6.47.1" "@grafana/scenes": "npm:^6.48.0"
"@grafana/scenes-react": "npm:6.47.1" "@grafana/scenes-react": "npm:^6.48.0"
"@grafana/schema": "workspace:*" "@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*" "@grafana/sql": "workspace:*"
"@grafana/test-utils": "workspace:*" "@grafana/test-utils": "workspace:*"