mirror of
https://github.com/grafana/grafana.git
synced 2025-12-21 12:04:45 +08:00
Compare commits
35 Commits
docs/updat
...
hackathon/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b6a434383 | ||
|
|
6d6112b627 | ||
|
|
41f9162472 | ||
|
|
7e991886e0 | ||
|
|
85925d0765 | ||
|
|
7790698aaa | ||
|
|
5499ad8023 | ||
|
|
90c4ab9d96 | ||
|
|
fd31f087ee | ||
|
|
3ee834922b | ||
|
|
2e2ce8fddd | ||
|
|
8214dbc758 | ||
|
|
98d454401c | ||
|
|
fcf1a47222 | ||
|
|
8a5b6804dd | ||
|
|
f0028f692b | ||
|
|
d71474246c | ||
|
|
9447015e54 | ||
|
|
abe10b2bb6 | ||
|
|
009716a408 | ||
|
|
e0c28cfa4c | ||
|
|
18c4f5b875 | ||
|
|
400f3a91d0 | ||
|
|
d6b04d28b6 | ||
|
|
0400d536c7 | ||
|
|
694e88b95b | ||
|
|
ad73303328 | ||
|
|
3dcd809aaf | ||
|
|
6b7fac65b1 | ||
|
|
2d17de2395 | ||
|
|
5b685373aa | ||
|
|
4d29e5bf6a | ||
|
|
7a0e64196b | ||
|
|
f1e24f528e | ||
|
|
198f4dbf93 |
@@ -7,4 +7,4 @@ generate: install-app-sdk update-app-sdk
|
||||
--gogenpath=./pkg/apis \
|
||||
--grouping=group \
|
||||
--genoperatorstate=false \
|
||||
--defencoding=none
|
||||
--defencoding=none
|
||||
|
||||
35
apps/collections/kinds/datasourcestacks.cue
Normal file
35
apps/collections/kinds/datasourcestacks.cue
Normal 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
|
||||
}
|
||||
@@ -6,12 +6,13 @@ manifest: {
|
||||
versions: {
|
||||
"v1alpha1": {
|
||||
codegen: {
|
||||
ts: {enabled: false}
|
||||
ts: {enabled: true}
|
||||
go: {enabled: true}
|
||||
}
|
||||
kinds: [
|
||||
starsV1alpha1,
|
||||
datasourcestacksV1alpha1
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_client_gen.go
generated
Normal file
80
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_client_gen.go
generated
Normal 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)
|
||||
}
|
||||
28
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_codec_gen.go
generated
Normal file
28
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_codec_gen.go
generated
Normal 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{}
|
||||
31
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_metadata_gen.go
generated
Normal file
31
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_metadata_gen.go
generated
Normal 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{},
|
||||
}
|
||||
}
|
||||
293
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_object_gen.go
generated
Normal file
293
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_object_gen.go
generated
Normal 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)
|
||||
}
|
||||
34
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_schema_gen.go
generated
Normal file
34
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_schema_gen.go
generated
Normal 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
|
||||
58
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_spec_gen.go
generated
Normal file
58
apps/collections/pkg/apis/collections/v1alpha1/datasourcestack_spec_gen.go
generated
Normal 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{},
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,19 @@ var StarsResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion,
|
||||
},
|
||||
)
|
||||
|
||||
var DatasourceStacksResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion,
|
||||
"datasourcestacks", "datasourcestack", "DataSourceStack",
|
||||
func() runtime.Object { return &DataSourceStack{} },
|
||||
func() runtime.Object { return &DataSourceStackList{} },
|
||||
utils.TableColumns{
|
||||
Definition: []metav1.TableColumnDefinition{
|
||||
{Name: "Name", Type: "string", Format: "name"},
|
||||
{Name: "Created At", Type: "date"},
|
||||
},
|
||||
// TODO: Reader?
|
||||
},
|
||||
)
|
||||
|
||||
var (
|
||||
SchemeBuilder runtime.SchemeBuilder
|
||||
localSchemeBuilder = &SchemeBuilder
|
||||
@@ -48,6 +61,8 @@ func addKnownTypes(scheme *runtime.Scheme) error {
|
||||
scheme.AddKnownTypes(schemeGroupVersion,
|
||||
&Stars{},
|
||||
&StarsList{},
|
||||
&DataSourceStack{},
|
||||
&DataSourceStackList{},
|
||||
)
|
||||
metav1.AddToGroupVersion(scheme, schemeGroupVersion)
|
||||
return nil
|
||||
|
||||
@@ -14,10 +14,241 @@ import (
|
||||
|
||||
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
|
||||
return map[string]common.OpenAPIDefinition{
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars": schema_pkg_apis_collections_v1alpha1_Stars(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsList": schema_pkg_apis_collections_v1alpha1_StarsList(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource": schema_pkg_apis_collections_v1alpha1_StarsResource(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec": schema_pkg_apis_collections_v1alpha1_StarsSpec(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack": schema_pkg_apis_collections_v1alpha1_DataSourceStack(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem": schema_pkg_apis_collections_v1alpha1_DataSourceStackDataSourceStackTemplateItem(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackList": schema_pkg_apis_collections_v1alpha1_DataSourceStackList(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem": schema_pkg_apis_collections_v1alpha1_DataSourceStackModeItem(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec": schema_pkg_apis_collections_v1alpha1_DataSourceStackModeSpec(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec": schema_pkg_apis_collections_v1alpha1_DataSourceStackSpec(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars": schema_pkg_apis_collections_v1alpha1_Stars(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsList": schema_pkg_apis_collections_v1alpha1_StarsList(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource": schema_pkg_apis_collections_v1alpha1_StarsResource(ref),
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec": schema_pkg_apis_collections_v1alpha1_StarsSpec(ref),
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_collections_v1alpha1_DataSourceStack(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Spec is the spec of the DataSourceStack",
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"metadata", "spec"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_collections_v1alpha1_DataSourceStackDataSourceStackTemplateItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"group": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "type",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"name": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "variable name / display name",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"group", "name"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_collections_v1alpha1_DataSourceStackList(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"metadata", "items"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_collections_v1alpha1_DataSourceStackModeItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"dataSourceRef": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "grafana data source uid",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"dataSourceRef"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_collections_v1alpha1_DataSourceStackModeSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"name": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"uid": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"definition": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
AdditionalProperties: &spec.SchemaOrBool{
|
||||
Allows: true,
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "uid", "definition"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_collections_v1alpha1_DataSourceStackSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"template": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
AdditionalProperties: &spec.SchemaOrBool{
|
||||
Allows: true,
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"modes": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"template", "modes"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem", "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,DataSourceStackSpec,Modes
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsSpec,Resource
|
||||
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,DataSourceStackList,ListMeta
|
||||
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsList,ListMeta
|
||||
|
||||
26
apps/collections/pkg/apis/collections_manifest.go
generated
26
apps/collections/pkg/apis/collections_manifest.go
generated
@@ -10,19 +10,22 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
v1alpha1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
)
|
||||
|
||||
var (
|
||||
rawSchemaStarsv1alpha1 = []byte(`{"Resource":{"additionalProperties":false,"properties":{"group":{"type":"string"},"kind":{"type":"string"},"names":{"description":"The set of resources\n+listType=set","items":{"type":"string"},"type":"array"}},"required":["group","kind","names"],"type":"object"},"Stars":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"resource":{"items":{"$ref":"#/components/schemas/Resource"},"type":"array"}},"required":["resource"],"type":"object"}}`)
|
||||
versionSchemaStarsv1alpha1 app.VersionSchema
|
||||
_ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1)
|
||||
rawSchemaStarsv1alpha1 = []byte(`{"Resource":{"additionalProperties":false,"properties":{"group":{"type":"string"},"kind":{"type":"string"},"names":{"description":"The set of resources\n+listType=set","items":{"type":"string"},"type":"array"}},"required":["group","kind","names"],"type":"object"},"Stars":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"resource":{"items":{"$ref":"#/components/schemas/Resource"},"type":"array"}},"required":["resource"],"type":"object"}}`)
|
||||
versionSchemaStarsv1alpha1 app.VersionSchema
|
||||
_ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1)
|
||||
rawSchemaDataSourceStackv1alpha1 = []byte(`{"DataSourceStack":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"DataSourceStackTemplateItem":{"additionalProperties":false,"properties":{"group":{"description":"type","type":"string"},"name":{"description":"variable name / display name","type":"string"}},"required":["group","name"],"type":"object"},"Mode":{"additionalProperties":{"$ref":"#/components/schemas/ModeItem"},"type":"object"},"ModeItem":{"additionalProperties":false,"properties":{"dataSourceRef":{"description":"grafana data source uid","type":"string"}},"required":["dataSourceRef"],"type":"object"},"ModeSpec":{"additionalProperties":false,"properties":{"definition":{"$ref":"#/components/schemas/Mode"},"name":{"type":"string"},"uid":{"type":"string"}},"required":["name","uid","definition"],"type":"object"},"TemplateSpec":{"additionalProperties":{"$ref":"#/components/schemas/DataSourceStackTemplateItem"},"type":"object"},"spec":{"additionalProperties":false,"properties":{"modes":{"items":{"$ref":"#/components/schemas/ModeSpec"},"type":"array"},"template":{"$ref":"#/components/schemas/TemplateSpec"}},"required":["template","modes"],"type":"object"}}`)
|
||||
versionSchemaDataSourceStackv1alpha1 app.VersionSchema
|
||||
_ = json.Unmarshal(rawSchemaDataSourceStackv1alpha1, &versionSchemaDataSourceStackv1alpha1)
|
||||
)
|
||||
|
||||
var appManifestData = app.ManifestData{
|
||||
@@ -49,6 +52,14 @@ var appManifestData = app.ManifestData{
|
||||
},
|
||||
Schema: &versionSchemaStarsv1alpha1,
|
||||
},
|
||||
|
||||
{
|
||||
Kind: "DataSourceStack",
|
||||
Plural: "DataSourceStacks",
|
||||
Scope: "Namespaced",
|
||||
Conversion: false,
|
||||
Schema: &versionSchemaDataSourceStackv1alpha1,
|
||||
},
|
||||
},
|
||||
Routes: app.ManifestVersionRoutes{
|
||||
Namespaced: map[string]spec3.PathProps{},
|
||||
@@ -68,7 +79,8 @@ func RemoteManifest() app.Manifest {
|
||||
}
|
||||
|
||||
var kindVersionToGoType = map[string]resource.Kind{
|
||||
"Stars/v1alpha1": v1alpha1.StarsKind(),
|
||||
"Stars/v1alpha1": v1alpha1.StarsKind(),
|
||||
"DataSourceStack/v1alpha1": v1alpha1.DataSourceStackKind(),
|
||||
}
|
||||
|
||||
// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists.
|
||||
|
||||
47
apps/collections/plugin/src/generated/datasourcestack/v1alpha1/datasourcestack_object_gen.ts
generated
Normal file
47
apps/collections/plugin/src/generated/datasourcestack/v1alpha1/datasourcestack_object_gen.ts
generated
Normal 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;
|
||||
}
|
||||
30
apps/collections/plugin/src/generated/datasourcestack/v1alpha1/types.metadata.gen.ts
generated
Normal file
30
apps/collections/plugin/src/generated/datasourcestack/v1alpha1/types.metadata.gen.ts
generated
Normal 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: {},
|
||||
});
|
||||
|
||||
53
apps/collections/plugin/src/generated/datasourcestack/v1alpha1/types.spec.gen.ts
generated
Normal file
53
apps/collections/plugin/src/generated/datasourcestack/v1alpha1/types.spec.gen.ts
generated
Normal 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: [],
|
||||
});
|
||||
|
||||
47
apps/collections/plugin/src/generated/stars/v1alpha1/stars_object_gen.ts
generated
Normal file
47
apps/collections/plugin/src/generated/stars/v1alpha1/stars_object_gen.ts
generated
Normal 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;
|
||||
}
|
||||
30
apps/collections/plugin/src/generated/stars/v1alpha1/types.metadata.gen.ts
generated
Normal file
30
apps/collections/plugin/src/generated/stars/v1alpha1/types.metadata.gen.ts
generated
Normal 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: {},
|
||||
});
|
||||
|
||||
24
apps/collections/plugin/src/generated/stars/v1alpha1/types.spec.gen.ts
generated
Normal file
24
apps/collections/plugin/src/generated/stars/v1alpha1/types.spec.gen.ts
generated
Normal 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: [],
|
||||
});
|
||||
|
||||
@@ -911,6 +911,7 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
@@ -915,6 +915,7 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
@@ -1675,18 +1675,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind {
|
||||
// Custom variable specification
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardCustomVariableSpec struct {
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Current DashboardVariableOption `json:"current"`
|
||||
Options []DashboardVariableOption `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
IncludeAll bool `json:"includeAll"`
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Hide DashboardVariableHide `json:"hide"`
|
||||
SkipUrlSync bool `json:"skipUrlSync"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowCustomValue bool `json:"allowCustomValue"`
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Current DashboardVariableOption `json:"current"`
|
||||
Options []DashboardVariableOption `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
IncludeAll bool `json:"includeAll"`
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Hide DashboardVariableHide `json:"hide"`
|
||||
SkipUrlSync bool `json:"skipUrlSync"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowCustomValue bool `json:"allowCustomValue"`
|
||||
ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"`
|
||||
}
|
||||
|
||||
// NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object.
|
||||
@@ -2101,6 +2102,14 @@ const (
|
||||
DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardCustomVariableSpecValuesFormat string
|
||||
|
||||
const (
|
||||
DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv"
|
||||
DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardPanelKindOrLibraryPanelKind struct {
|
||||
PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"`
|
||||
|
||||
@@ -1510,6 +1510,12 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableSpec(ref common.Re
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"valuesFormat": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"},
|
||||
},
|
||||
|
||||
@@ -181,6 +181,8 @@ import (
|
||||
//go:generate mockery --name InterfaceName --structname MockImplementationName --inpackage --filename my_implementation_mock.go
|
||||
```
|
||||
|
||||
The current `go:generate` command format used in this repository is only compatible with mockery v2.
|
||||
|
||||
## Globals
|
||||
|
||||
As a general rule of thumb, avoid using global variables, since they make the code difficult to maintain and reason
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"@grafana/plugin-ui": "^0.11.1",
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/scenes": "6.47.1",
|
||||
"@grafana/scenes-react": "6.47.1",
|
||||
"@grafana/scenes": "^6.48.0",
|
||||
"@grafana/scenes-react": "^6.48.0",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
||||
@@ -101,6 +101,7 @@ export interface IntervalVariableModel extends VariableWithOptions {
|
||||
|
||||
export interface CustomVariableModel extends VariableWithMultiSupport {
|
||||
type: 'custom';
|
||||
valuesFormat?: 'csv' | 'json';
|
||||
}
|
||||
|
||||
export interface DataSourceVariableModel extends VariableWithMultiSupport {
|
||||
|
||||
@@ -316,6 +316,7 @@ export const handyTestingSchema: Spec = {
|
||||
query: 'option1, option2',
|
||||
skipUrlSync: false,
|
||||
allowCustomValue: true,
|
||||
valuesFormat: 'csv',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1335,6 +1335,7 @@ export interface CustomVariableSpec {
|
||||
skipUrlSync: boolean;
|
||||
description?: string;
|
||||
allowCustomValue: boolean;
|
||||
valuesFormat?: "csv" | "json";
|
||||
}
|
||||
|
||||
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
|
||||
|
||||
@@ -150,6 +150,10 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
r.Get("/connections/datasources/edit/*", authorize(datasources.EditPageAccess), hs.Index)
|
||||
r.Get("/connections", authorize(datasources.ConfigurationPageAccess), hs.Index)
|
||||
r.Get("/connections/add-new-connection", authorize(datasources.ConfigurationPageAccess), hs.Index)
|
||||
r.Get("/connections/stacks", authorize(datasources.ConfigurationPageAccess), hs.Index)
|
||||
r.Get("/connections/stacks/new", authorize(datasources.ConfigurationPageAccess), hs.Index)
|
||||
r.Get("/connections/stacks/edit/*", authorize(datasources.ConfigurationPageAccess), hs.Index)
|
||||
|
||||
// Plugin details pages
|
||||
r.Get("/connections/datasources/:id", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
|
||||
r.Get("/connections/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
|
||||
|
||||
88
pkg/registry/apis/collections/datasources_validator.go
Normal file
88
pkg/registry/apis/collections/datasources_validator.go
Normal 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
|
||||
|
||||
}
|
||||
212
pkg/registry/apis/collections/datasources_validator_test.go
Normal file
212
pkg/registry/apis/collections/datasources_validator_test.go
Normal 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
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/admission"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
@@ -14,13 +16,16 @@ import (
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/collections/legacy"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
datasourcesClient "github.com/grafana/grafana/pkg/services/datasources/service/client"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/star"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@@ -29,13 +34,15 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupMutation = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupMutation = (*APIBuilder)(nil)
|
||||
_ builder.APIGroupValidation = (*APIBuilder)(nil)
|
||||
)
|
||||
|
||||
type APIBuilder struct {
|
||||
authorizer authorizer.Authorizer
|
||||
legacyStars *legacy.DashboardStarsStorage
|
||||
authorizer authorizer.Authorizer
|
||||
legacyStars *legacy.DashboardStarsStorage
|
||||
datasourceStacksValidator builder.APIGroupValidation
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
@@ -45,6 +52,8 @@ func RegisterAPIService(
|
||||
stars star.Service,
|
||||
users user.Service,
|
||||
apiregistration builder.APIRegistrar,
|
||||
dsConnClientFactory datasourcesClient.DataSourceConnectionClientFactory,
|
||||
restConfigProvider apiserver.RestConfigProvider,
|
||||
) *APIBuilder {
|
||||
// Requires development settings and clearly experimental
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
@@ -52,11 +61,15 @@ func RegisterAPIService(
|
||||
return nil
|
||||
}
|
||||
|
||||
dsConnClient := dsConnClientFactory(restConfigProvider)
|
||||
|
||||
sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db))
|
||||
builder := &APIBuilder{
|
||||
datasourceStacksValidator: GetDatasourceStacksValidator(dsConnClient),
|
||||
authorizer: &utils.AuthorizeFromName{
|
||||
Resource: map[string][]utils.ResourceOwner{
|
||||
"stars": {utils.UserResourceOwner},
|
||||
"stars": {utils.UserResourceOwner},
|
||||
"datasources": {utils.UserResourceOwner},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -94,28 +107,60 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
|
||||
storage := map[string]rest.Storage{}
|
||||
|
||||
// Configure Stars Dual writer
|
||||
resource := collections.StarsResourceInfo
|
||||
starsResource := collections.StarsResourceInfo
|
||||
var stars grafanarest.Storage
|
||||
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter)
|
||||
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, starsResource, opts.OptsGetter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stars = &starStorage{Storage: stars} // wrap List so we only return one value
|
||||
if b.legacyStars != nil && opts.DualWriteBuilder != nil {
|
||||
stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars)
|
||||
stars, err = opts.DualWriteBuilder(starsResource.GroupResource(), b.legacyStars, stars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
storage[resource.StoragePath()] = stars
|
||||
storage[resource.StoragePath("update")] = &starsREST{store: stars}
|
||||
storage[starsResource.StoragePath()] = stars
|
||||
storage[starsResource.StoragePath("update")] = &starsREST{store: stars}
|
||||
|
||||
// no need for dual writer for a kind that does not exist in the legacy database
|
||||
resourceInfo := collections.DatasourceStacksResourceInfo
|
||||
datasourcesStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, resourceInfo, opts.OptsGetter)
|
||||
storage[resourceInfo.StoragePath()] = datasourcesStorage
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[collections.APIVersion] = storage
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
|
||||
if a.GetKind().Group == collections.DatasourceStacksResourceInfo.GroupResource().Group {
|
||||
return b.datasourceStacksValidator.Validate(ctx, a, o)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
return b.authorizer
|
||||
|
||||
return authorizer.AuthorizerFunc(
|
||||
func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
|
||||
if attr.GetResource() == "stars" {
|
||||
return b.authorizer.Authorize(ctx, attr)
|
||||
}
|
||||
|
||||
// datasources auth branch starts
|
||||
if !attr.IsResourceRequest() {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
// require a user
|
||||
_, err = identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
// TODO make the auth more restrictive
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||
|
||||
@@ -85,7 +85,7 @@ func RegisterAPIService(
|
||||
accessControl,
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
|
||||
false,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
||||
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,11 +29,11 @@ var (
|
||||
// Get all datasource connections -- this will be backed by search or duplicated resource in unified storage
|
||||
type DataSourceConnectionProvider interface {
|
||||
// Get gets a specific datasource (that the user in context can see)
|
||||
// The name is {group}:{name}, see /pkg/apis/query/v0alpha1/connection.go#L34
|
||||
// The name is the legacy datasource UID.
|
||||
GetConnection(ctx context.Context, namespace string, name string) (*queryV0.DataSourceConnection, error)
|
||||
|
||||
// List lists all data sources the user in context can see
|
||||
ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error)
|
||||
// List lists all data sources the user in context can see. Optional field selectors can filter the results.
|
||||
ListConnections(ctx context.Context, namespace string, fieldSelector fields.Selector) (*queryV0.DataSourceConnectionList, error)
|
||||
}
|
||||
|
||||
type connectionAccess struct {
|
||||
@@ -74,7 +75,11 @@ func (s *connectionAccess) Get(ctx context.Context, name string, options *metav1
|
||||
}
|
||||
|
||||
func (s *connectionAccess) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
return s.connections.ListConnections(ctx, request.NamespaceValue(ctx))
|
||||
var fs fields.Selector
|
||||
if options != nil && options.FieldSelector != nil {
|
||||
fs = options.FieldSelector
|
||||
}
|
||||
return s.connections.ListConnections(ctx, request.NamespaceValue(ctx), fs)
|
||||
}
|
||||
|
||||
type connectionsProvider struct {
|
||||
@@ -103,19 +108,47 @@ func (q *connectionsProvider) GetConnection(ctx context.Context, namespace strin
|
||||
return q.asConnection(ds, namespace)
|
||||
}
|
||||
|
||||
func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error) {
|
||||
func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string, fieldSelector fields.Selector) (*queryV0.DataSourceConnectionList, error) {
|
||||
ns, err := authlib.ParseNamespace(namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dss, err := q.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{
|
||||
OrgID: ns.OrgID,
|
||||
DataSourceLimit: 10000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var dss []*datasources.DataSource
|
||||
// if fieldSelector is not nil, find any uids in the metadata.name field and
|
||||
// use them in the query
|
||||
if fieldSelector != nil && !fieldSelector.Empty() {
|
||||
uids := []string{}
|
||||
for _, req := range fieldSelector.Requirements() {
|
||||
if req.Field == "metadata.name" {
|
||||
uids = append(uids, req.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// We don't have a way to fetch a subset of datasources by UID in the legacy
|
||||
// datasource service, so fetch them one by one.
|
||||
if len(uids) > 0 {
|
||||
for _, uid := range uids {
|
||||
ds, err := q.dsService.GetDataSource(ctx, &datasources.GetDataSourceQuery{
|
||||
UID: uid,
|
||||
OrgID: ns.OrgID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dss = append(dss, ds)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dss, err = q.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{
|
||||
OrgID: ns.OrgID,
|
||||
DataSourceLimit: 10000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result := &queryV0.DataSourceConnectionList{
|
||||
Items: []queryV0.DataSourceConnection{},
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/datasourceproxy"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service"
|
||||
datasourcesclient "github.com/grafana/grafana/pkg/services/datasources/service/client"
|
||||
"github.com/grafana/grafana/pkg/services/dsquerierclient"
|
||||
"github.com/grafana/grafana/pkg/services/encryption"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
@@ -476,6 +477,7 @@ var wireBasicSet = wire.NewSet(
|
||||
appregistry.WireSet,
|
||||
// Dashboard Kubernetes helpers
|
||||
dashboardclient.ProvideK8sClientWithFallback,
|
||||
datasourcesclient.ProvideDataSourceConnectionClientFactory,
|
||||
)
|
||||
|
||||
var wireSet = wire.NewSet(
|
||||
|
||||
9
pkg/server/wire_gen.go
generated
9
pkg/server/wire_gen.go
generated
File diff suppressed because one or more lines are too long
147
pkg/services/datasources/service/client/client.go
Normal file
147
pkg/services/datasources/service/client/client.go
Normal 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
|
||||
}
|
||||
96
pkg/services/datasources/service/client/client_mock.go
Normal file
96
pkg/services/datasources/service/client/client_mock.go
Normal 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
|
||||
}
|
||||
@@ -574,6 +574,15 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *n
|
||||
Url: baseUrl + "/datasources",
|
||||
Children: []*navtree.NavLink{},
|
||||
})
|
||||
|
||||
// Stacks
|
||||
children = append(children, &navtree.NavLink{
|
||||
Id: "connections-stacks",
|
||||
Text: "Stacks",
|
||||
SubTitle: "Manage data source stacks for different environments",
|
||||
Url: baseUrl + "/stacks",
|
||||
Children: []*navtree.NavLink{},
|
||||
})
|
||||
}
|
||||
|
||||
if len(children) > 0 {
|
||||
|
||||
@@ -182,6 +182,8 @@ export function getNavTitle(navId: string | undefined) {
|
||||
return t('nav.connections.title', 'Connections');
|
||||
case 'connections-add-new-connection':
|
||||
return t('nav.add-new-connections.title', 'Add new connection');
|
||||
case 'connections-stacks':
|
||||
return t('nav.stacks.title', 'Stacks');
|
||||
case 'standalone-plugin-page-/connections/collector':
|
||||
return t('nav.collector.title', 'Collector');
|
||||
case 'connections-datasources':
|
||||
|
||||
@@ -10,10 +10,13 @@ import { CacheFeatureHighlightPage } from './pages/CacheFeatureHighlightPage';
|
||||
import ConnectionsHomePage from './pages/ConnectionsHomePage';
|
||||
import { DataSourceDashboardsPage } from './pages/DataSourceDashboardsPage';
|
||||
import { DataSourceDetailsPage } from './pages/DataSourceDetailsPage';
|
||||
import { DataSourceStacksPage } from './pages/DataSourceStacksPage';
|
||||
import { DataSourcesListPage } from './pages/DataSourcesListPage';
|
||||
import { EditDataSourcePage } from './pages/EditDataSourcePage';
|
||||
import { EditStackPage } from './pages/EditStackPage';
|
||||
import { InsightsFeatureHighlightPage } from './pages/InsightsFeatureHighlightPage';
|
||||
import { NewDataSourcePage } from './pages/NewDataSourcePage';
|
||||
import { NewStackPage } from './pages/NewStackPage';
|
||||
import { PermissionsFeatureHighlightPage } from './pages/PermissionsFeatureHighlightPage';
|
||||
|
||||
function RedirectToAddNewConnection() {
|
||||
@@ -41,6 +44,9 @@ export default function Connections() {
|
||||
{/* The route paths need to be relative to the parent path (ROUTES.Base), so we need to remove that part */}
|
||||
<Route caseSensitive path={ROUTES.DataSources.replace(ROUTES.Base, '')} element={<DataSourcesListPage />} />
|
||||
<Route caseSensitive path={ROUTES.DataSourcesNew.replace(ROUTES.Base, '')} element={<NewDataSourcePage />} />
|
||||
<Route caseSensitive path={ROUTES.Stacks.replace(ROUTES.Base, '')} element={<DataSourceStacksPage />} />
|
||||
<Route caseSensitive path={ROUTES.StacksNew.replace(ROUTES.Base, '')} element={<NewStackPage />} />
|
||||
<Route caseSensitive path={ROUTES.StacksEdit.replace(ROUTES.Base, '')} element={<EditStackPage />} />
|
||||
<Route
|
||||
caseSensitive
|
||||
path={ROUTES.DataSourcesDetails.replace(ROUTES.Base, '')}
|
||||
|
||||
@@ -75,5 +75,11 @@ export function getOssCardData(): CardData[] {
|
||||
url: '/connections/datasources',
|
||||
icon: 'database',
|
||||
},
|
||||
{
|
||||
text: 'Stacks',
|
||||
subTitle: 'Manage your data source stacks',
|
||||
url: '/connections/stacks',
|
||||
icon: 'layers',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ export const ROUTES = {
|
||||
DataSourcesNew: `/${ROUTE_BASE_ID}/datasources/new`,
|
||||
DataSourcesEdit: `/${ROUTE_BASE_ID}/datasources/edit/:uid`,
|
||||
DataSourcesDashboards: `/${ROUTE_BASE_ID}/datasources/edit/:uid/dashboards`,
|
||||
// Stacks
|
||||
Stacks: `/${ROUTE_BASE_ID}/stacks`,
|
||||
StacksNew: `/${ROUTE_BASE_ID}/stacks/new`,
|
||||
StacksEdit: `/${ROUTE_BASE_ID}/stacks/edit/:uid`,
|
||||
|
||||
// Add new connection
|
||||
AddNewConnection: `/${ROUTE_BASE_ID}/add-new-connection`,
|
||||
|
||||
252
public/app/features/connections/pages/DataSourceStacksPage.tsx
Normal file
252
public/app/features/connections/pages/DataSourceStacksPage.tsx
Normal 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);
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
106
public/app/features/connections/pages/EditStackPage.tsx
Normal file
106
public/app/features/connections/pages/EditStackPage.tsx
Normal 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} />;
|
||||
}
|
||||
19
public/app/features/connections/pages/NewStackPage.tsx
Normal file
19
public/app/features/connections/pages/NewStackPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -849,6 +849,7 @@ describe('DashboardSceneSerializer', () => {
|
||||
query: 'app1',
|
||||
skipUrlSync: false,
|
||||
allowCustomValue: true,
|
||||
valuesFormat: 'csv',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -294,6 +294,7 @@ exports[`Given a scene with custom quick ranges should save quick ranges to save
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
@@ -679,6 +680,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
|
||||
"options": [],
|
||||
"query": "A,B,C,D,E,F,E,G,H,I,J,K,L",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
@@ -697,6 +699,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
|
||||
"options": [],
|
||||
"query": "Bob : 1, Rob : 2,Sod : 3, Hod : 4, Cod : 5",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1019,6 +1022,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
@@ -1378,6 +1382,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
|
||||
@@ -197,6 +197,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
|
||||
"options": [],
|
||||
"query": "option1, option2",
|
||||
"skipUrlSync": false,
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -374,6 +374,7 @@ describe('sceneVariablesSetToVariables', () => {
|
||||
"options": [],
|
||||
"query": "test,test1,test2",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
}
|
||||
`);
|
||||
});
|
||||
@@ -1148,6 +1149,7 @@ describe('sceneVariablesSetToVariables', () => {
|
||||
"options": [],
|
||||
"query": "test,test1,test2",
|
||||
"skipUrlSync": false,
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -110,6 +110,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
|
||||
allValue: variable.state.allValue,
|
||||
includeAll: variable.state.includeAll,
|
||||
allowCustomValue: variable.state.allowCustomValue,
|
||||
valuesFormat: variable.state.valuesFormat,
|
||||
});
|
||||
} else if (sceneUtils.isDataSourceVariable(variable)) {
|
||||
variables.push({
|
||||
@@ -392,6 +393,7 @@ export function sceneVariablesSetToSchemaV2Variables(
|
||||
allValue: variable.state.allValue,
|
||||
includeAll: variable.state.includeAll ?? false,
|
||||
allowCustomValue: variable.state.allowCustomValue ?? true,
|
||||
valuesFormat: variable.state.valuesFormat,
|
||||
},
|
||||
};
|
||||
variables.push(customVariable);
|
||||
|
||||
@@ -335,12 +335,12 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (variable.kind === defaultCustomVariableKind().kind) {
|
||||
return new CustomVariable({
|
||||
...commonProperties,
|
||||
value: variable.spec.current?.value ?? '',
|
||||
text: variable.spec.current?.text ?? '',
|
||||
|
||||
query: variable.spec.query,
|
||||
isMulti: variable.spec.multi,
|
||||
allValue: variable.spec.allValue || undefined,
|
||||
@@ -348,6 +348,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
|
||||
defaultToAll: Boolean(variable.spec.includeAll),
|
||||
skipUrlSync: variable.spec.skipUrlSync,
|
||||
hide: transformVariableHideToEnumV1(variable.spec.hide),
|
||||
valuesFormat: variable.spec.valuesFormat || 'csv',
|
||||
});
|
||||
} else if (variable.kind === defaultQueryVariableKind().kind) {
|
||||
return new QueryVariable({
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { SceneVariable } from '@grafana/scenes';
|
||||
import { VariableHide, defaultVariableModel } from '@grafana/schema';
|
||||
import { Button, LoadingPlaceholder, ConfirmModal, ModalsController, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { Button, ConfirmModal, LoadingPlaceholder, ModalsController, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { VariableHideSelect } from 'app/features/dashboard-scene/settings/variables/components/VariableHideSelect';
|
||||
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
|
||||
import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField';
|
||||
@@ -68,6 +68,9 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
|
||||
const onHideChange = (hide: VariableHide) => variable.setState({ hide });
|
||||
|
||||
const isHasVariableOptions = hasVariableOptions(variable);
|
||||
const optionsForSelect = isHasVariableOptions ? variable.getOptionsForSelect(false) : [];
|
||||
const hasJsonValuesFormat = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json';
|
||||
const hasMultiProps = hasJsonValuesFormat || optionsForSelect.every((o) => Boolean(o.properties));
|
||||
|
||||
const onDeleteVariable = (hideModal: () => void) => () => {
|
||||
reportInteraction('Delete variable');
|
||||
@@ -123,7 +126,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
|
||||
|
||||
{EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />}
|
||||
|
||||
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
|
||||
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<Stack gap={2}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
@@ -130,4 +131,71 @@ describe('CustomVariableForm', () => {
|
||||
expect(onMultiChange).not.toHaveBeenCalled();
|
||||
expect(onIncludeAllChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('JSON values format', () => {
|
||||
test('should render the form fields correctly', async () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<CustomVariableForm
|
||||
query="query"
|
||||
valuesFormat="json"
|
||||
multi={true}
|
||||
allowCustomValue={true}
|
||||
includeAll={true}
|
||||
allValue="custom value"
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Object values in a JSON array'));
|
||||
|
||||
const multiCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
);
|
||||
const allowCustomValueCheckbox = queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
|
||||
);
|
||||
const includeAllCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
);
|
||||
const allValueInput = queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
);
|
||||
|
||||
expect(multiCheckbox).toBeInTheDocument();
|
||||
expect(multiCheckbox).toBeChecked();
|
||||
expect(includeAllCheckbox).toBeInTheDocument();
|
||||
expect(includeAllCheckbox).toBeChecked();
|
||||
|
||||
expect(allowCustomValueCheckbox).not.toBeInTheDocument();
|
||||
expect(allValueInput).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display validation error', async () => {
|
||||
const validationError = new Error('Ooops! Validation error.');
|
||||
|
||||
const { findByText } = render(
|
||||
<CustomVariableForm
|
||||
query="query"
|
||||
valuesFormat="json"
|
||||
queryValidationError={validationError}
|
||||
multi={false}
|
||||
includeAll={false}
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Object values in a JSON array'));
|
||||
|
||||
const errorEl = await findByText(validationError.message);
|
||||
expect(errorEl).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
import { CustomVariableModel } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { FieldValidationMessage, Icon, RadioButtonGroup, Stack, TextLink, Tooltip } from '@grafana/ui';
|
||||
|
||||
import { SelectionOptionsForm } from './SelectionOptionsForm';
|
||||
import { VariableLegend } from './VariableLegend';
|
||||
@@ -9,10 +11,12 @@ import { VariableTextAreaField } from './VariableTextAreaField';
|
||||
|
||||
interface CustomVariableFormProps {
|
||||
query: string;
|
||||
valuesFormat?: CustomVariableModel['valuesFormat'];
|
||||
multi: boolean;
|
||||
allValue?: string | null;
|
||||
includeAll: boolean;
|
||||
allowCustomValue?: boolean;
|
||||
queryValidationError?: Error;
|
||||
onQueryChange: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
onMultiChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
@@ -20,19 +24,23 @@ interface CustomVariableFormProps {
|
||||
onQueryBlur?: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
onAllValueBlur?: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
|
||||
}
|
||||
|
||||
export function CustomVariableForm({
|
||||
query,
|
||||
valuesFormat,
|
||||
multi,
|
||||
allValue,
|
||||
includeAll,
|
||||
allowCustomValue,
|
||||
queryValidationError,
|
||||
onQueryChange,
|
||||
onMultiChange,
|
||||
onIncludeAllChange,
|
||||
onAllValueChange,
|
||||
onAllowCustomValueChange,
|
||||
onValuesFormatChange,
|
||||
}: CustomVariableFormProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -40,16 +48,27 @@ export function CustomVariableForm({
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.custom-options">Custom options</Trans>
|
||||
</VariableLegend>
|
||||
|
||||
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
|
||||
|
||||
<VariableTextAreaField
|
||||
name={t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma')}
|
||||
// we don't use a controlled component so we make sure the textarea content is cleared when changing format by providing a key
|
||||
key={valuesFormat}
|
||||
name=""
|
||||
placeholder={
|
||||
valuesFormat === 'json'
|
||||
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
|
||||
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'1, 10, mykey : myvalue, myvalue, escaped\,value'
|
||||
}
|
||||
defaultValue={query}
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="1, 10, mykey : myvalue, myvalue, escaped\,value"
|
||||
onBlur={onQueryChange}
|
||||
required
|
||||
width={52}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
|
||||
/>
|
||||
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
|
||||
|
||||
<VariableLegend>
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.selection-options">Selection options</Trans>
|
||||
</VariableLegend>
|
||||
@@ -58,6 +77,8 @@ export function CustomVariableForm({
|
||||
includeAll={includeAll}
|
||||
allValue={allValue}
|
||||
allowCustomValue={allowCustomValue}
|
||||
disableAllowCustomValue={valuesFormat === 'json'}
|
||||
disableCustomAllValue={valuesFormat === 'json'}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
@@ -66,3 +87,48 @@ export function CustomVariableForm({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValuesFormatSelectorProps {
|
||||
valuesFormat?: CustomVariableModel['valuesFormat'];
|
||||
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
|
||||
}
|
||||
|
||||
export function ValuesFormatSelector({ valuesFormat, onValuesFormatChange }: ValuesFormatSelectorProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={1}>
|
||||
<RadioButtonGroup
|
||||
value={valuesFormat}
|
||||
onChange={onValuesFormatChange}
|
||||
options={[
|
||||
{
|
||||
value: 'csv',
|
||||
label: t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma'),
|
||||
},
|
||||
{
|
||||
value: 'json',
|
||||
label: t('dashboard-scene.custom-variable-form.name-json-values', 'Object values in a JSON array'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{valuesFormat === 'json' && (
|
||||
<Tooltip
|
||||
content={
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.json-values-tooltip">
|
||||
Provide a JSON representing an array of objects, where each object can have any number of properties.
|
||||
<br />
|
||||
Check{' '}
|
||||
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
|
||||
our docs
|
||||
</TextLink>{' '}
|
||||
for more information.
|
||||
</Trans>
|
||||
}
|
||||
placement="top"
|
||||
interactive
|
||||
>
|
||||
<Icon name="info-circle" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { QueryVariable } from '@grafana/scenes';
|
||||
import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema';
|
||||
import { Field, TextLink } from '@grafana/ui';
|
||||
import { Box, Field, TextLink } from '@grafana/ui';
|
||||
import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
|
||||
import { SelectionOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/SelectionOptionsForm';
|
||||
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
|
||||
@@ -15,9 +15,9 @@ import { getVariableQueryEditor } from 'app/features/variables/editor/getVariabl
|
||||
import { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect';
|
||||
import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect';
|
||||
import {
|
||||
QueryVariableStaticOptions,
|
||||
StaticOptionsOrderType,
|
||||
StaticOptionsType,
|
||||
QueryVariableStaticOptions,
|
||||
} from 'app/features/variables/query/QueryVariableStaticOptions';
|
||||
|
||||
import { VariableLegend } from './VariableLegend';
|
||||
@@ -34,6 +34,7 @@ interface QueryVariableEditorFormProps {
|
||||
timeRange: TimeRange;
|
||||
regex: string | null;
|
||||
onRegExChange: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
disableRegexEdition?: boolean;
|
||||
sort: VariableSort;
|
||||
onSortChange: (option: SelectableValue<VariableSort>) => void;
|
||||
refresh: VariableRefresh;
|
||||
@@ -42,14 +43,17 @@ interface QueryVariableEditorFormProps {
|
||||
onMultiChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
allowCustomValue?: boolean;
|
||||
onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void;
|
||||
disableAllowCustomValue?: boolean;
|
||||
includeAll: boolean;
|
||||
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
allValue: string;
|
||||
onAllValueChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
disableCustomAllValue?: boolean;
|
||||
staticOptions?: StaticOptionsType;
|
||||
staticOptionsOrder?: StaticOptionsOrderType;
|
||||
onStaticOptionsChange?: (staticOptions: StaticOptionsType) => void;
|
||||
onStaticOptionsOrderChange?: (staticOptionsOrder: StaticOptionsOrderType) => void;
|
||||
disableStaticOptions?: boolean;
|
||||
}
|
||||
|
||||
export function QueryVariableEditorForm({
|
||||
@@ -61,6 +65,7 @@ export function QueryVariableEditorForm({
|
||||
timeRange,
|
||||
regex,
|
||||
onRegExChange,
|
||||
disableRegexEdition,
|
||||
sort,
|
||||
onSortChange,
|
||||
refresh,
|
||||
@@ -69,14 +74,17 @@ export function QueryVariableEditorForm({
|
||||
onMultiChange,
|
||||
allowCustomValue,
|
||||
onAllowCustomValueChange,
|
||||
disableAllowCustomValue,
|
||||
includeAll,
|
||||
onIncludeAllChange,
|
||||
allValue,
|
||||
onAllValueChange,
|
||||
disableCustomAllValue,
|
||||
staticOptions,
|
||||
staticOptionsOrder,
|
||||
onStaticOptionsChange,
|
||||
onStaticOptionsOrderChange,
|
||||
disableStaticOptions,
|
||||
}: QueryVariableEditorFormProps) {
|
||||
const { value: dsConfig } = useAsync(async () => {
|
||||
const datasource = await getDataSourceSrv().get(datasourceRef ?? '');
|
||||
@@ -116,48 +124,53 @@ export function QueryVariableEditorForm({
|
||||
<Field
|
||||
label={t('dashboard-scene.query-variable-editor-form.label-data-source', 'Data source')}
|
||||
htmlFor="data-source-picker"
|
||||
noMargin
|
||||
>
|
||||
<DataSourcePicker current={datasourceRef} onChange={datasourceChangeHandler} variables={true} width={30} />
|
||||
</Field>
|
||||
|
||||
{datasource && VariableQueryEditor && (
|
||||
<QueryEditor
|
||||
onQueryChange={onQueryChange}
|
||||
onLegacyQueryChange={onLegacyQueryChange}
|
||||
datasource={datasource}
|
||||
query={query}
|
||||
VariableQueryEditor={VariableQueryEditor}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
<Box marginBottom={2}>
|
||||
<QueryEditor
|
||||
onQueryChange={onQueryChange}
|
||||
onLegacyQueryChange={onLegacyQueryChange}
|
||||
datasource={datasource}
|
||||
query={query}
|
||||
VariableQueryEditor={VariableQueryEditor}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<VariableTextAreaField
|
||||
defaultValue={regex ?? ''}
|
||||
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
|
||||
description={
|
||||
<div>
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
|
||||
Optional, if you want to extract part of a series name or metric node segment.
|
||||
</Trans>
|
||||
<br />
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
|
||||
Named capture groups can be used to separate the display text and value (
|
||||
<TextLink
|
||||
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
|
||||
external
|
||||
>
|
||||
see examples
|
||||
</TextLink>
|
||||
).
|
||||
</Trans>
|
||||
</div>
|
||||
}
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
|
||||
onBlur={onRegExChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
|
||||
width={52}
|
||||
/>
|
||||
{!disableRegexEdition && (
|
||||
<VariableTextAreaField
|
||||
defaultValue={regex ?? ''}
|
||||
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
|
||||
description={
|
||||
<div>
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
|
||||
Optional, if you want to extract part of a series name or metric node segment.
|
||||
</Trans>
|
||||
<br />
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
|
||||
Named capture groups can be used to separate the display text and value (
|
||||
<TextLink
|
||||
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
|
||||
external
|
||||
>
|
||||
see examples
|
||||
</TextLink>
|
||||
).
|
||||
</Trans>
|
||||
</div>
|
||||
}
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
|
||||
onBlur={onRegExChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
|
||||
width={52}
|
||||
/>
|
||||
)}
|
||||
|
||||
<QueryVariableSortSelect
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2}
|
||||
@@ -171,7 +184,7 @@ export function QueryVariableEditorForm({
|
||||
refresh={refresh}
|
||||
/>
|
||||
|
||||
{onStaticOptionsChange && onStaticOptionsOrderChange && (
|
||||
{!disableStaticOptions && onStaticOptionsChange && onStaticOptionsOrderChange && (
|
||||
<QueryVariableStaticOptions
|
||||
staticOptions={staticOptions}
|
||||
staticOptionsOrder={staticOptionsOrder}
|
||||
@@ -187,6 +200,8 @@ export function QueryVariableEditorForm({
|
||||
multi={!!isMulti}
|
||||
includeAll={!!includeAll}
|
||||
allowCustomValue={allowCustomValue}
|
||||
disableAllowCustomValue={disableAllowCustomValue}
|
||||
disableCustomAllValue={disableCustomAllValue}
|
||||
allValue={allValue}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
|
||||
@@ -10,7 +10,9 @@ interface SelectionOptionsFormProps {
|
||||
multi: boolean;
|
||||
includeAll: boolean;
|
||||
allowCustomValue?: boolean;
|
||||
disableAllowCustomValue?: boolean;
|
||||
allValue?: string | null;
|
||||
disableCustomAllValue?: boolean;
|
||||
onMultiChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onAllowCustomValueChange?: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onIncludeAllChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
@@ -20,8 +22,10 @@ interface SelectionOptionsFormProps {
|
||||
export function SelectionOptionsForm({
|
||||
multi,
|
||||
allowCustomValue,
|
||||
disableAllowCustomValue,
|
||||
includeAll,
|
||||
allValue,
|
||||
disableCustomAllValue,
|
||||
onMultiChange,
|
||||
onAllowCustomValueChange,
|
||||
onIncludeAllChange,
|
||||
@@ -39,18 +43,19 @@ export function SelectionOptionsForm({
|
||||
onChange={onMultiChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch}
|
||||
/>
|
||||
{onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup
|
||||
<VariableCheckboxField
|
||||
value={allowCustomValue ?? true}
|
||||
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
|
||||
description={t(
|
||||
'dashboard-scene.selection-options-form.description-enables-users-custom-values',
|
||||
'Enables users to add custom values to the list'
|
||||
)}
|
||||
onChange={onAllowCustomValueChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
|
||||
/>
|
||||
)}
|
||||
{!disableAllowCustomValue &&
|
||||
onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup
|
||||
<VariableCheckboxField
|
||||
value={allowCustomValue ?? true}
|
||||
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
|
||||
description={t(
|
||||
'dashboard-scene.selection-options-form.description-enables-users-custom-values',
|
||||
'Enables users to add custom values to the list'
|
||||
)}
|
||||
onChange={onAllowCustomValueChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
|
||||
/>
|
||||
)}
|
||||
<VariableCheckboxField
|
||||
value={includeAll}
|
||||
name={t('dashboard-scene.selection-options-form.name-include-all-option', 'Include All option')}
|
||||
@@ -61,7 +66,7 @@ export function SelectionOptionsForm({
|
||||
onChange={onIncludeAllChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch}
|
||||
/>
|
||||
{includeAll && (
|
||||
{!disableCustomAllValue && includeAll && (
|
||||
<VariableTextField
|
||||
defaultValue={allValue ?? ''}
|
||||
onBlur={onAllValueChange}
|
||||
|
||||
@@ -5,13 +5,50 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { VariableValueOption } from '@grafana/scenes';
|
||||
import { Button, InlineFieldRow, InlineLabel, useStyles2, Text } from '@grafana/ui';
|
||||
import { Button, InlineFieldRow, InlineLabel, InteractiveTable, Text, useStyles2 } from '@grafana/ui';
|
||||
|
||||
export interface VariableValuesPreviewProps {
|
||||
export interface Props {
|
||||
options: VariableValueOption[];
|
||||
hasMultiProps?: boolean;
|
||||
}
|
||||
|
||||
export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) => {
|
||||
export const VariableValuesPreview = ({ options, hasMultiProps }: Props) => {
|
||||
if (!options.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasMultiProps) {
|
||||
return <VariableValuesWithPropsPreview options={options} />;
|
||||
}
|
||||
|
||||
return <VariableValuesWithoutPropsPreview options={options} />;
|
||||
};
|
||||
VariableValuesPreview.displayName = 'VariableValuesPreview';
|
||||
|
||||
function VariableValuesWithPropsPreview({ options }: { options: VariableValueOption[] }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const data = options.map((o) => ({ label: String(o.label), value: String(o.value), ...o.properties }));
|
||||
// the first item in data may be the "All" option, which does not have any extra properties, so we try the 2nd item to determine the column names
|
||||
const columns = Object.keys(data[1] || data[0]).map((id) => ({ id, header: id, sortType: 'alphanumeric' as const }));
|
||||
|
||||
return (
|
||||
<div className={styles.previewContainer} style={{ gap: '8px' }}>
|
||||
<Text variant="bodySmall" weight="medium">
|
||||
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
|
||||
</Text>
|
||||
<InteractiveTable
|
||||
className={styles.table}
|
||||
columns={columns}
|
||||
data={data}
|
||||
getRowId={(r) => String(r.value)}
|
||||
pageSize={8}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VariableValuesWithoutPropsPreview({ options }: { options: VariableValueOption[] }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [previewLimit, setPreviewLimit] = useState(20);
|
||||
const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]);
|
||||
const showMoreOptions = useCallback(
|
||||
@@ -21,15 +58,10 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
|
||||
},
|
||||
[previewLimit, setPreviewLimit]
|
||||
);
|
||||
const styles = useStyles2(getStyles);
|
||||
useEffect(() => setPreviewOptions(options.slice(0, previewLimit)), [previewLimit, options]);
|
||||
|
||||
if (!previewOptions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', marginTop: '16px' }}>
|
||||
<div className={styles.previewContainer}>
|
||||
<Text variant="bodySmall" weight="medium">
|
||||
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
|
||||
</Text>
|
||||
@@ -51,12 +83,12 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
VariableValuesPreview.displayName = 'VariableValuesPreview';
|
||||
}
|
||||
VariableValuesWithoutPropsPreview.displayName = 'VariableValuesWithoutPropsPreview';
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrapper: css({
|
||||
previewContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: theme.spacing(2),
|
||||
@@ -71,5 +103,10 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '50vw',
|
||||
}),
|
||||
table: css({
|
||||
td: css({
|
||||
padding: theme.spacing(0.5, 1),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,117 +5,225 @@ import { CustomVariable } from '@grafana/scenes';
|
||||
|
||||
import { CustomVariableEditor } from './CustomVariableEditor';
|
||||
|
||||
function setup(options: Partial<ConstructorParameters<typeof CustomVariable>[0]> = {}) {
|
||||
return {
|
||||
variable: new CustomVariable({
|
||||
name: 'customVar',
|
||||
...options,
|
||||
}),
|
||||
onRunQuery: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderEditor(ui: React.ReactNode) {
|
||||
const renderResult = render(ui);
|
||||
|
||||
const elements = {
|
||||
formatButton: (label: string) => renderResult.queryByLabelText(label) as HTMLElement,
|
||||
queryInput: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
|
||||
) as HTMLTextAreaElement,
|
||||
multiValueCheckbox: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
) as HTMLInputElement,
|
||||
allowCustomValueCheckbox: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
|
||||
) as HTMLInputElement,
|
||||
includeAllCheckbox: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
) as HTMLInputElement,
|
||||
customAllValueInput: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
) as HTMLInputElement,
|
||||
};
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
elements,
|
||||
actions: {
|
||||
updateValuesInput(newQuery: string) {
|
||||
fireEvent.change(elements.queryInput(), { target: { value: newQuery } });
|
||||
fireEvent.blur(elements.queryInput());
|
||||
},
|
||||
changeValuesFormat(newFormat: 'csv' | 'json') {
|
||||
const targetLabel = newFormat === 'json' ? 'Object values in a JSON array' : 'Values separated by comma';
|
||||
|
||||
const formatButton = elements.formatButton(targetLabel);
|
||||
if (formatButton === null) {
|
||||
throw new Error(`Unable to fire a "click" event - button with label "${targetLabel}" not found in DOM`);
|
||||
}
|
||||
|
||||
fireEvent.click(formatButton);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('CustomVariableEditor', () => {
|
||||
it('should render the CustomVariableForm with correct initial values', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
allValue: 'test',
|
||||
describe('CSV values format', () => {
|
||||
it('should render CustomVariableForm with the correct initial values', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
allowCustomValue: true,
|
||||
allValue: 'all',
|
||||
});
|
||||
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(elements.queryInput().value).toBe('test, test2');
|
||||
expect(elements.multiValueCheckbox().checked).toBe(true);
|
||||
expect(elements.allowCustomValueCheckbox().checked).toBe(true);
|
||||
expect(elements.includeAllCheckbox().checked).toBe(true);
|
||||
expect(elements.customAllValueInput().value).toBe('all');
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
it('should update the variable state when some input values change ("Multi-value", "Allow custom values" & "Include All option")', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: false,
|
||||
allowCustomValue: false,
|
||||
includeAll: false,
|
||||
});
|
||||
|
||||
const queryInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
|
||||
) as HTMLInputElement;
|
||||
const allValueInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
) as HTMLInputElement;
|
||||
const multiCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
) as HTMLInputElement;
|
||||
const includeAllCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
) as HTMLInputElement;
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(queryInput.value).toBe('test, test2');
|
||||
expect(allValueInput.value).toBe('test');
|
||||
expect(multiCheckbox.checked).toBe(true);
|
||||
expect(includeAllCheckbox.checked).toBe(true);
|
||||
expect(elements.multiValueCheckbox().checked).toBe(false);
|
||||
expect(elements.allowCustomValueCheckbox().checked).toBe(false);
|
||||
expect(elements.includeAllCheckbox().checked).toBe(false);
|
||||
// include-all-custom input appears after include-all checkbox is checked only
|
||||
expect(elements.customAllValueInput()).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(elements.multiValueCheckbox());
|
||||
fireEvent.click(elements.allowCustomValueCheckbox());
|
||||
fireEvent.click(elements.includeAllCheckbox());
|
||||
|
||||
expect(variable.state.isMulti).toBe(true);
|
||||
expect(variable.state.allowCustomValue).toBe(true);
|
||||
expect(variable.state.includeAll).toBe(true);
|
||||
expect(elements.customAllValueInput()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('when the values textarea loses focus after its value has changed', () => {
|
||||
it('should update the query in the variable state and call the onRunQuery callback', async () => {
|
||||
const { variable, onRunQuery } = setup({ query: 'test, test2', value: 'test' });
|
||||
|
||||
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
actions.updateValuesInput('test3, test4');
|
||||
|
||||
expect(variable.state.query).toBe('test3, test4');
|
||||
expect(onRunQuery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the "Custom all value" input loses focus after its value has changed', () => {
|
||||
it('should update the variable state', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
});
|
||||
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
fireEvent.change(elements.customAllValueInput(), { target: { value: 'new custom all' } });
|
||||
fireEvent.blur(elements.customAllValueInput());
|
||||
|
||||
expect(variable.state.allValue).toBe('new custom all');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the variable state when input values change', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
describe('JSON values format', () => {
|
||||
const initialJsonQuery = `[
|
||||
{"value":1,"text":"Development","aws":"dev","azure":"development"},
|
||||
{"value":2,"text":"Production","aws":"prod","azure":"production"}
|
||||
]`;
|
||||
|
||||
it('should render CustomVariableForm with the correct initial values', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
valuesFormat: 'json',
|
||||
query: initialJsonQuery,
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
});
|
||||
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(elements.queryInput().value).toBe(initialJsonQuery);
|
||||
expect(elements.multiValueCheckbox().checked).toBe(true);
|
||||
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
|
||||
expect(elements.includeAllCheckbox().checked).toBe(true);
|
||||
expect(elements.customAllValueInput()).not.toBeInTheDocument();
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
describe('when the values textarea loses focus after its value has changed', () => {
|
||||
describe('if the value is valid JSON', () => {
|
||||
it('should update the query in the variable state and call the onRunQuery callback', async () => {
|
||||
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
|
||||
|
||||
const multiCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
);
|
||||
const includeAllCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
);
|
||||
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
const allowCustomValueCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
|
||||
);
|
||||
actions.updateValuesInput('[]');
|
||||
|
||||
// It include-all-custom input appears after include-all checkbox is checked only
|
||||
expect(() =>
|
||||
getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput)
|
||||
).toThrow('Unable to find an element');
|
||||
expect(variable.state.query).toBe('[]');
|
||||
expect(onRunQuery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(allowCustomValueCheckbox);
|
||||
describe('if the value is NOT valid JSON', () => {
|
||||
it('should display a validation error message and neither update the query in the variable state nor call the onRunQuery callback', async () => {
|
||||
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
|
||||
|
||||
fireEvent.click(multiCheckbox);
|
||||
const { actions, getByRole } = renderEditor(
|
||||
<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />
|
||||
);
|
||||
|
||||
fireEvent.click(includeAllCheckbox);
|
||||
const allValueInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
);
|
||||
actions.updateValuesInput('[x]');
|
||||
|
||||
expect(variable.state.isMulti).toBe(true);
|
||||
expect(variable.state.includeAll).toBe(true);
|
||||
expect(variable.state.allowCustomValue).toBe(false);
|
||||
expect(allValueInput).toBeInTheDocument();
|
||||
expect(getByRole('alert')).toHaveTextContent(`Unexpected token 'x', "[x]" is not valid JSON`);
|
||||
expect(variable.state.query).toBe(initialJsonQuery);
|
||||
expect(onRunQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call update query and re-run query when input loses focus', async () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
describe('when switching values format', () => {
|
||||
it('should switch the visibility of the proper form inputs ("Allow custom values" and "Custom all value")', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
valuesFormat: 'csv',
|
||||
query: '',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
allowCustomValue: true,
|
||||
allValue: '',
|
||||
});
|
||||
|
||||
const { elements, actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
|
||||
expect(elements.customAllValueInput()).toBeInTheDocument();
|
||||
|
||||
actions.changeValuesFormat('json');
|
||||
|
||||
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
|
||||
expect(elements.customAllValueInput()).not.toBeInTheDocument();
|
||||
|
||||
actions.changeValuesFormat('csv');
|
||||
|
||||
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
|
||||
expect(elements.customAllValueInput()).toBeInTheDocument();
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput);
|
||||
fireEvent.change(queryInput, { target: { value: 'test3, test4' } });
|
||||
fireEvent.blur(queryInput);
|
||||
|
||||
expect(onRunQuery).toHaveBeenCalled();
|
||||
expect(variable.state.query).toBe('test3, test4');
|
||||
});
|
||||
|
||||
it('should update the variable state when all-custom-value input loses focus', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
const allValueInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(allValueInput, { target: { value: 'new custom all' } });
|
||||
fireEvent.blur(allValueInput);
|
||||
|
||||
expect(variable.state.allValue).toBe('new custom all');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FormEvent, useCallback } from 'react';
|
||||
import { isObject } from 'lodash';
|
||||
import { FormEvent, useCallback, useState } from 'react';
|
||||
|
||||
import { CustomVariableModel, shallowCompare } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { CustomVariable, SceneVariable } from '@grafana/scenes';
|
||||
|
||||
@@ -14,7 +16,26 @@ interface CustomVariableEditorProps {
|
||||
}
|
||||
|
||||
export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) {
|
||||
const { query, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
|
||||
const { query, valuesFormat, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
|
||||
const [queryValidationError, setQueryValidationError] = useState<Error>();
|
||||
|
||||
const [prevQuery, setPrevQuery] = useState('');
|
||||
const onValuesFormatChange = useCallback(
|
||||
(format: CustomVariableModel['valuesFormat']) => {
|
||||
variable.setState({ query: prevQuery });
|
||||
variable.setState({ value: isMulti ? [] : undefined });
|
||||
variable.setState({ valuesFormat: format });
|
||||
variable.setState({ allowCustomValue: false });
|
||||
variable.setState({ allValue: undefined });
|
||||
onRunQuery();
|
||||
|
||||
setQueryValidationError(undefined);
|
||||
if (query !== prevQuery) {
|
||||
setPrevQuery(query);
|
||||
}
|
||||
},
|
||||
[isMulti, onRunQuery, prevQuery, query, variable]
|
||||
);
|
||||
|
||||
const onMultiChange = useCallback(
|
||||
(event: FormEvent<HTMLInputElement>) => {
|
||||
@@ -32,10 +53,20 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
(event: FormEvent<HTMLTextAreaElement>) => {
|
||||
setPrevQuery('');
|
||||
|
||||
if (valuesFormat === 'json') {
|
||||
const validationError = validateJsonQuery(event.currentTarget.value);
|
||||
setQueryValidationError(validationError);
|
||||
if (validationError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
variable.setState({ query: event.currentTarget.value });
|
||||
onRunQuery();
|
||||
},
|
||||
[variable, onRunQuery]
|
||||
[valuesFormat, variable, onRunQuery]
|
||||
);
|
||||
|
||||
const onAllValueChange = useCallback(
|
||||
@@ -55,15 +86,18 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
|
||||
return (
|
||||
<CustomVariableForm
|
||||
query={query ?? ''}
|
||||
valuesFormat={valuesFormat ?? 'csv'}
|
||||
multi={!!isMulti}
|
||||
allValue={allValue ?? ''}
|
||||
includeAll={!!includeAll}
|
||||
allowCustomValue={allowCustomValue}
|
||||
queryValidationError={queryValidationError}
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onQueryChange={onQueryChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
onValuesFormatChange={onValuesFormatChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -81,3 +115,47 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export const validateJsonQuery = (rawQuery: string): Error | undefined => {
|
||||
const query = rawQuery.trim();
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = JSON.parse(query);
|
||||
|
||||
if (!Array.isArray(options)) {
|
||||
throw new Error('Enter a valid JSON array of objects');
|
||||
}
|
||||
|
||||
if (!options.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let errorIndex = options.findIndex((item) => !isObject(item));
|
||||
if (errorIndex !== -1) {
|
||||
throw new Error(`All items must be objects. The item at index ${errorIndex} is not an object.`);
|
||||
}
|
||||
|
||||
const keys = Object.keys(options[0]);
|
||||
if (!keys.includes('value')) {
|
||||
throw new Error('Each object in the array must include at least a "value" property');
|
||||
}
|
||||
if (keys.includes('')) {
|
||||
throw new Error('Object property names cannot be empty strings');
|
||||
}
|
||||
|
||||
errorIndex = options.findIndex((o) => !shallowCompare(keys, Object.keys(o)));
|
||||
if (errorIndex !== -1) {
|
||||
throw new Error(
|
||||
`All objects must have the same set of properties. The object at index ${errorIndex} does not match the expected properties`
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return error as Error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { FormEvent, useCallback, useState } from 'react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { CustomVariableModel } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { CustomVariable } from '@grafana/scenes';
|
||||
import { Button, Modal, Stack } from '@grafana/ui';
|
||||
import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana/ui';
|
||||
|
||||
import { VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
|
||||
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
|
||||
import { ValuesFormatSelector } from '../../components/CustomVariableForm';
|
||||
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
|
||||
|
||||
import { ValuesBuilder } from './ValuesBuilder';
|
||||
import { ValuesPreview } from './ValuesPreview';
|
||||
import { validateJsonQuery } from './CustomVariableEditor';
|
||||
|
||||
interface ModalEditorProps {
|
||||
variable: CustomVariable;
|
||||
@@ -18,9 +19,49 @@ interface ModalEditorProps {
|
||||
}
|
||||
|
||||
export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
|
||||
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
|
||||
const { query, valuesFormat, isMulti } = variable.useState();
|
||||
const [prevQuery, setPrevQuery] = useState('');
|
||||
const [queryValidationError, setQueryValidationError] = useState<Error>();
|
||||
|
||||
const handleOnAdd = useCallback(() => formRef.current?.addItem(), []);
|
||||
const onValuesFormatChange = useCallback(
|
||||
async (format: CustomVariableModel['valuesFormat']) => {
|
||||
variable.setState({ query: prevQuery });
|
||||
variable.setState({ value: isMulti ? [] : undefined });
|
||||
variable.setState({ valuesFormat: format });
|
||||
variable.setState({ allowCustomValue: false });
|
||||
variable.setState({ allValue: undefined });
|
||||
|
||||
await lastValueFrom(variable.validateAndUpdate());
|
||||
|
||||
setQueryValidationError(undefined);
|
||||
if (query !== prevQuery) {
|
||||
setPrevQuery(query);
|
||||
}
|
||||
},
|
||||
[isMulti, prevQuery, query, variable]
|
||||
);
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
async (event: FormEvent<HTMLTextAreaElement>) => {
|
||||
setPrevQuery('');
|
||||
|
||||
if (valuesFormat === 'json') {
|
||||
const validationError = validateJsonQuery(event.currentTarget.value);
|
||||
setQueryValidationError(validationError);
|
||||
if (validationError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
variable.setState({ query: event.currentTarget.value });
|
||||
await lastValueFrom(variable.validateAndUpdate());
|
||||
},
|
||||
[valuesFormat, variable]
|
||||
);
|
||||
|
||||
const optionsForSelect = variable.getOptionsForSelect(false);
|
||||
const hasJsonValuesFormat = variable.state.valuesFormat === 'json';
|
||||
const hasMultiProps = hasJsonValuesFormat || optionsForSelect.every((o) => Boolean(o.properties));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -29,10 +70,31 @@ export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
|
||||
onDismiss={onClose}
|
||||
>
|
||||
<Stack direction="column" gap={2}>
|
||||
<ValuesBuilder variable={variable} ref={formRef} />
|
||||
<ValuesPreview variable={variable} />
|
||||
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
|
||||
<div>
|
||||
<TextArea
|
||||
id={valuesFormat}
|
||||
key={valuesFormat}
|
||||
rows={4}
|
||||
defaultValue={query}
|
||||
onBlur={onQueryChange}
|
||||
placeholder={
|
||||
valuesFormat === 'json'
|
||||
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
|
||||
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'1, 10, mykey : myvalue, myvalue, escaped\,value'
|
||||
}
|
||||
required
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
|
||||
/>
|
||||
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
|
||||
</div>
|
||||
<div>
|
||||
<VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />
|
||||
</div>
|
||||
</Stack>
|
||||
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={handleOnAdd} />}>
|
||||
<Modal.ButtonRow>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t } from '@grafana/i18n';
|
||||
import { CustomVariable, SceneVariable } from '@grafana/scenes';
|
||||
|
||||
import { OptionsPaneItemDescriptor } from '../../../../../dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
@@ -12,7 +11,6 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
|
||||
|
||||
return [
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
|
||||
id: 'custom-variable-values',
|
||||
render: ({ props }) => <PaneItem id={props.id} variable={variable} />,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useState, FormEvent, useMemo, useEffect } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { SelectableValue, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data';
|
||||
@@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { QueryVariable, sceneGraph, SceneVariable } from '@grafana/scenes';
|
||||
import { VariableRefresh, VariableSort } from '@grafana/schema';
|
||||
import { Box, Button, Field, Modal, TextLink } from '@grafana/ui';
|
||||
import { Box, Button, Field, Modal, Switch, TextLink } from '@grafana/ui';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
|
||||
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
|
||||
@@ -44,6 +44,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
|
||||
allowCustomValue,
|
||||
staticOptions,
|
||||
staticOptionsOrder,
|
||||
options,
|
||||
} = variable.useState();
|
||||
const { value: timeRange } = sceneGraph.getTimeRange(variable).useState();
|
||||
|
||||
@@ -93,6 +94,17 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
|
||||
variable.setState({ staticOptionsOrder });
|
||||
};
|
||||
|
||||
const hasMultiProps = useMemo(() => options.every((o) => Boolean(o.properties)), [options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMultiProps) {
|
||||
variable.setState({ allowCustomValue: false });
|
||||
variable.setState({ allValue: '' });
|
||||
variable.setState({ regex: '' });
|
||||
variable.setState({ staticOptions: [] });
|
||||
}
|
||||
}, [hasMultiProps, variable]);
|
||||
|
||||
return (
|
||||
<QueryVariableEditorForm
|
||||
datasource={datasource ?? undefined}
|
||||
@@ -103,6 +115,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
|
||||
timeRange={timeRange}
|
||||
regex={regex}
|
||||
onRegExChange={onRegExChange}
|
||||
disableRegexEdition={hasMultiProps}
|
||||
sort={sort}
|
||||
onSortChange={onSortChange}
|
||||
refresh={refresh}
|
||||
@@ -113,12 +126,15 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
allValue={allValue ?? ''}
|
||||
onAllValueChange={onAllValueChange}
|
||||
disableCustomAllValue={hasMultiProps}
|
||||
allowCustomValue={allowCustomValue}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
disableAllowCustomValue={hasMultiProps}
|
||||
staticOptions={staticOptions}
|
||||
staticOptionsOrder={staticOptionsOrder}
|
||||
onStaticOptionsChange={onStaticOptionsChange}
|
||||
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
|
||||
disableStaticOptions={hasMultiProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -250,6 +266,19 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||
|
||||
const isHasVariableOptions = hasVariableOptions(variable);
|
||||
|
||||
// TODO: remove me after finished testing - each DS can/should implement their own UI
|
||||
const [returnsMultiProps, setReturnsMultiProps] = useState(false);
|
||||
const onChangeReturnsMultipleProps = (e: FormEvent<HTMLInputElement>) => {
|
||||
setReturnsMultiProps(e.currentTarget.checked);
|
||||
variable.setState({ allowCustomValue: false });
|
||||
variable.setState({ allValue: '' });
|
||||
variable.setState({ regex: '' });
|
||||
onStaticOptionsChange?.([]);
|
||||
};
|
||||
|
||||
const optionsForSelect = variable.getOptionsForSelect(false);
|
||||
const hasMultiProps = returnsMultiProps || optionsForSelect.every((o) => Boolean(o.properties));
|
||||
|
||||
return (
|
||||
<div data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.editor}>
|
||||
<Field
|
||||
@@ -261,43 +290,64 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||
</Field>
|
||||
|
||||
{selectedDatasource && VariableQueryEditor && (
|
||||
<QueryEditor
|
||||
onQueryChange={onQueryChange}
|
||||
onLegacyQueryChange={onQueryChange}
|
||||
datasource={selectedDatasource}
|
||||
query={query}
|
||||
VariableQueryEditor={VariableQueryEditor}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
<Box marginBottom={2}>
|
||||
<QueryEditor
|
||||
onQueryChange={onQueryChange}
|
||||
onLegacyQueryChange={onQueryChange}
|
||||
datasource={selectedDatasource}
|
||||
query={query}
|
||||
VariableQueryEditor={VariableQueryEditor}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
{/* TODO: remove me after finished testing - each DS can/should implement their own UI */}
|
||||
<Field
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
label="Enable access to all the fields of the query results"
|
||||
description={
|
||||
<Trans i18nKey="">
|
||||
Check{' '}
|
||||
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
|
||||
our docs
|
||||
</TextLink>{' '}
|
||||
for more information.
|
||||
</Trans>
|
||||
}
|
||||
noMargin
|
||||
>
|
||||
<Switch onChange={onChangeReturnsMultipleProps} />
|
||||
</Field>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<VariableTextAreaField
|
||||
defaultValue={regex ?? ''}
|
||||
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
|
||||
description={
|
||||
<div>
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
|
||||
Optional, if you want to extract part of a series name or metric node segment.
|
||||
</Trans>
|
||||
<br />
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
|
||||
Named capture groups can be used to separate the display text and value (
|
||||
<TextLink
|
||||
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
|
||||
external
|
||||
>
|
||||
see examples
|
||||
</TextLink>
|
||||
).
|
||||
</Trans>
|
||||
</div>
|
||||
}
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
|
||||
onBlur={onRegExChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
|
||||
width={52}
|
||||
/>
|
||||
{!returnsMultiProps && (
|
||||
<VariableTextAreaField
|
||||
defaultValue={regex ?? ''}
|
||||
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
|
||||
description={
|
||||
<div>
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
|
||||
Optional, if you want to extract part of a series name or metric node segment.
|
||||
</Trans>
|
||||
<br />
|
||||
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
|
||||
Named capture groups can be used to separate the display text and value (
|
||||
<TextLink
|
||||
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
|
||||
external
|
||||
>
|
||||
see examples
|
||||
</TextLink>
|
||||
).
|
||||
</Trans>
|
||||
</div>
|
||||
}
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
|
||||
onBlur={onRegExChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
|
||||
width={52}
|
||||
/>
|
||||
)}
|
||||
|
||||
<QueryVariableSortSelect
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2}
|
||||
@@ -311,7 +361,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||
refresh={refresh}
|
||||
/>
|
||||
|
||||
{onStaticOptionsChange && onStaticOptionsOrderChange && (
|
||||
{!returnsMultiProps && onStaticOptionsChange && onStaticOptionsOrderChange && (
|
||||
<QueryVariableStaticOptions
|
||||
staticOptions={staticOptions}
|
||||
staticOptionsOrder={staticOptionsOrder}
|
||||
@@ -320,7 +370,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
|
||||
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,11 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
|
||||
'A wildcard regex or other value to represent All'
|
||||
),
|
||||
useShowIf: () => {
|
||||
return variable.useState().includeAll ?? false;
|
||||
const state = variable.useState();
|
||||
const hasMultiProps =
|
||||
('valuesFormat' in state && state.valuesFormat === 'json') ||
|
||||
state.options.every((o) => Boolean(o.properties));
|
||||
return hasMultiProps ? false : (state.includeAll ?? false);
|
||||
},
|
||||
render: (descriptor) => <CustomAllValueInput id={descriptor.props.id} variable={variable} />,
|
||||
})
|
||||
@@ -58,6 +62,13 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
|
||||
'dashboard.edit-pane.variable.selection-options.allow-custom-values-description',
|
||||
'Enables users to enter values'
|
||||
),
|
||||
useShowIf: () => {
|
||||
const state = variable.useState();
|
||||
const hasMultiProps =
|
||||
('valuesFormat' in state && state.valuesFormat === 'json') ||
|
||||
state.options.every((o) => Boolean(o.properties));
|
||||
return !hasMultiProps;
|
||||
},
|
||||
render: (descriptor) => <AllowCustomSwitch id={descriptor.props.id} variable={variable} />,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ export function getLocalVariableValueSet(
|
||||
name: variable.state.name,
|
||||
value,
|
||||
text,
|
||||
properties: variable.state.options.find((o) => o.value === value)?.properties,
|
||||
isMulti: variable.state.isMulti,
|
||||
includeAll: variable.state.includeAll,
|
||||
}),
|
||||
|
||||
@@ -103,6 +103,7 @@ describe('when creating variables objects', () => {
|
||||
text: 'a',
|
||||
type: 'custom',
|
||||
value: 'a',
|
||||
valuesFormat: 'csv',
|
||||
hide: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
import { FormProvider, SubmitErrorHandler, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { Button, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
||||
import { GroupVersionResource } from 'app/features/apiserver/types';
|
||||
import { ROUTES } from 'app/features/connections/constants';
|
||||
import { DataSourceStackSpec } from 'app/features/connections/pages/DataSourceStacksPage';
|
||||
|
||||
import { StackModes } from './StackModes';
|
||||
import { StackName } from './StackName';
|
||||
import { StackTemplate } from './StackTemplate';
|
||||
import { StackFormValues } from './types';
|
||||
|
||||
type Props = {
|
||||
existing?: StackFormValues;
|
||||
};
|
||||
|
||||
const defaultValues: StackFormValues = {
|
||||
name: '',
|
||||
templates: [],
|
||||
modes: [],
|
||||
};
|
||||
|
||||
// GroupVersionResource for datasourcestacks
|
||||
const datasourceStacksGVR: GroupVersionResource = {
|
||||
group: 'collections.grafana.app',
|
||||
version: 'v1alpha1',
|
||||
resource: 'datasourcestacks',
|
||||
};
|
||||
|
||||
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
|
||||
|
||||
export const StackForm = ({ existing }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const notifyApp = useAppNotification();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isEditing = !!existing;
|
||||
|
||||
const initialValues: StackFormValues = useMemo(() => {
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return defaultValues;
|
||||
}, [existing]);
|
||||
|
||||
const formAPI = useForm<StackFormValues>({
|
||||
mode: 'onSubmit',
|
||||
defaultValues: initialValues,
|
||||
shouldFocusError: true,
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = formAPI;
|
||||
|
||||
const submit = async (values: StackFormValues): Promise<void> => {
|
||||
const spec = prepareCreateStackPayload(values);
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
// Update existing stack
|
||||
const existingStack = await datasourceStacksClient.get(values.name);
|
||||
await datasourceStacksClient.update({
|
||||
...existingStack,
|
||||
spec,
|
||||
});
|
||||
notifyApp.success('Stack updated successfully!');
|
||||
} else {
|
||||
// Create new stack
|
||||
await datasourceStacksClient.create({
|
||||
metadata: { name: values.name },
|
||||
spec,
|
||||
});
|
||||
notifyApp.success('Stack created successfully!');
|
||||
}
|
||||
|
||||
// Navigate to stacks list page
|
||||
navigate(ROUTES.Stacks);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'An unknown error occurred';
|
||||
notifyApp.error(`Failed to save stack: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onInvalid: SubmitErrorHandler<StackFormValues> = () => {
|
||||
notifyApp.error('There are errors in the form. Please correct them and try again!');
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...formAPI}>
|
||||
<form onSubmit={(e) => e.preventDefault()} className={styles.form}>
|
||||
<div className={styles.contentOuter}>
|
||||
<Stack direction="column" gap={3}>
|
||||
{/* Step 1 - name */}
|
||||
<StackName />
|
||||
|
||||
{/* Step 2 - Templates */}
|
||||
<StackTemplate />
|
||||
|
||||
{/* Step 3 - Modes */}
|
||||
<StackModes />
|
||||
|
||||
{/* Actions */}
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Button
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={handleSubmit((values) => submit(values), onInvalid)}
|
||||
disabled={isSubmitting}
|
||||
icon={isSubmitting ? 'spinner' : undefined}
|
||||
>
|
||||
<Trans i18nKey="datasources.stack-form.save">Save</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
form: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
contentOuter: css({
|
||||
background: theme.colors.background.primary,
|
||||
overflow: 'hidden',
|
||||
maxWidth: theme.breakpoints.values.xl,
|
||||
flex: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
export const prepareCreateStackPayload = (formValues: StackFormValues): DataSourceStackSpec => {
|
||||
// creates a mapping from template name to UUID
|
||||
const templateNameToUuid: Record<string, string> = {};
|
||||
formValues.templates.forEach((template) => {
|
||||
templateNameToUuid[template.name] = uuidv4();
|
||||
});
|
||||
|
||||
// builds the template record with UUIDs as keys
|
||||
const template: DataSourceStackSpec['template'] = {};
|
||||
formValues.templates.forEach((t) => {
|
||||
const uuid = templateNameToUuid[t.name];
|
||||
template[uuid] = {
|
||||
group: t.type,
|
||||
name: t.name,
|
||||
};
|
||||
});
|
||||
// uses template ids to build modes
|
||||
const modes: DataSourceStackSpec['modes'] = formValues.modes.map((mode) => {
|
||||
const definition: Record<string, { dataSourceRef: string }> = {};
|
||||
|
||||
Object.entries(mode.datasources).forEach(([templateName, dataSourceUid]) => {
|
||||
const uuid = templateNameToUuid[templateName];
|
||||
if (uuid) {
|
||||
definition[uuid] = { dataSourceRef: dataSourceUid };
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
name: mode.name,
|
||||
uid: uuidv4(),
|
||||
definition,
|
||||
};
|
||||
});
|
||||
|
||||
return { template, modes };
|
||||
};
|
||||
|
||||
//used when loading an existing stack for editing.
|
||||
export const transformStackSpecToFormValues = (stackName: string, spec: DataSourceStackSpec): StackFormValues => {
|
||||
const uuidToTemplateName: Record<string, string> = {};
|
||||
Object.entries(spec.template).forEach(([uuid, templateItem]) => {
|
||||
uuidToTemplateName[uuid] = templateItem.name;
|
||||
});
|
||||
|
||||
const templates = Object.values(spec.template).map((templateItem) => ({
|
||||
name: templateItem.name,
|
||||
type: templateItem.group,
|
||||
}));
|
||||
|
||||
const modes = spec.modes.map((mode) => {
|
||||
const datasources: Record<string, string> = {};
|
||||
|
||||
Object.entries(mode.definition).forEach(([uuid, modeItem]) => {
|
||||
const templateName = uuidToTemplateName[uuid];
|
||||
if (templateName) {
|
||||
datasources[templateName] = modeItem.dataSourceRef;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
name: mode.name,
|
||||
datasources,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name: stackName,
|
||||
templates,
|
||||
modes,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { FieldSet, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
|
||||
export interface StackFormSectionProps {
|
||||
title: string;
|
||||
stepNo: number;
|
||||
description?: string | ReactElement;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const StackFormSection = ({
|
||||
title,
|
||||
stepNo,
|
||||
children,
|
||||
fullWidth = false,
|
||||
description,
|
||||
}: React.PropsWithChildren<StackFormSectionProps>) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.parent}>
|
||||
<FieldSet
|
||||
className={cx(fullWidth && styles.fullWidth)}
|
||||
label={
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Text variant="h3">
|
||||
{stepNo}. {title}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Stack direction="column">
|
||||
{description && <div className={styles.description}>{description}</div>}
|
||||
{children}
|
||||
</Stack>
|
||||
</FieldSet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
parent: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
border: `solid 1px ${theme.colors.border.weak}`,
|
||||
borderRadius: theme.shape.radius.lg,
|
||||
padding: `${theme.spacing(2)} ${theme.spacing(3)}`,
|
||||
}),
|
||||
description: css({
|
||||
marginTop: `-${theme.spacing(2)}`,
|
||||
}),
|
||||
fullWidth: css({
|
||||
width: '100%',
|
||||
}),
|
||||
reverse: css({
|
||||
flexDirection: 'row-reverse',
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, Field, IconButton, Input, Stack, Text } from '@grafana/ui';
|
||||
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
|
||||
|
||||
import { StackFormSection } from './StackFormSection';
|
||||
import { ModeSection, StackFormValues } from './types';
|
||||
|
||||
const createEmptyMode = (): ModeSection => ({
|
||||
name: '',
|
||||
datasources: {},
|
||||
});
|
||||
|
||||
export const StackModes = () => {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useFormContext<StackFormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'modes',
|
||||
});
|
||||
|
||||
const templates = watch('templates');
|
||||
|
||||
const hasTemplates = templates && templates.length > 0;
|
||||
|
||||
return (
|
||||
<StackFormSection
|
||||
stepNo={3}
|
||||
title={t('datasources.stack-modes.title', 'Add modes')}
|
||||
description={
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
<Trans i18nKey="datasources.stack-modes.description">
|
||||
Define modes (e.g., dev, staging, prod) and select the actual datasources for each template entry.
|
||||
</Trans>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack direction="column" gap={3}>
|
||||
{!hasTemplates && (
|
||||
<Text color="secondary" italic>
|
||||
<Trans i18nKey="datasources.stack-modes.no-templates">
|
||||
Add template sections first to define modes.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{hasTemplates &&
|
||||
fields.map((field, index) => (
|
||||
<ModeSectionRow
|
||||
key={field.id}
|
||||
index={index}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
templates={templates}
|
||||
onRemove={() => remove(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasTemplates && (
|
||||
<Button type="button" variant="secondary" icon="plus" onClick={() => append(createEmptyMode())}>
|
||||
<Trans i18nKey="datasources.stack-modes.add-mode">Add mode</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</StackFormSection>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModeSectionRowProps {
|
||||
index: number;
|
||||
register: ReturnType<typeof useFormContext<StackFormValues>>['register'];
|
||||
control: ReturnType<typeof useFormContext<StackFormValues>>['control'];
|
||||
errors: ReturnType<typeof useFormContext<StackFormValues>>['formState']['errors'];
|
||||
templates: StackFormValues['templates'];
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const ModeSectionRow = ({ index, register, control, errors, templates, onRemove }: ModeSectionRowProps) => {
|
||||
return (
|
||||
<Stack direction="column" gap={2}>
|
||||
<Stack direction="row" gap={2} alignItems="center">
|
||||
<Field
|
||||
noMargin
|
||||
label={t('datasources.stack-modes.mode-name-label', 'Mode name')}
|
||||
error={errors?.modes?.[index]?.name?.message}
|
||||
invalid={!!errors?.modes?.[index]?.name?.message}
|
||||
>
|
||||
<Input
|
||||
id={`modes.${index}.name`}
|
||||
width={30}
|
||||
{...register(`modes.${index}.name`, {
|
||||
required: {
|
||||
value: true,
|
||||
message: t('datasources.stack-modes.mode-name-required', 'Mode name is required'),
|
||||
},
|
||||
})}
|
||||
placeholder={t('datasources.stack-modes.mode-name-placeholder', 'e.g. production')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
variant="destructive"
|
||||
tooltip={t('datasources.stack-modes.remove-mode', 'Remove mode')}
|
||||
onClick={onRemove}
|
||||
aria-label={t('datasources.stack-modes.remove-mode', 'Remove mode')}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap={2} wrap="wrap">
|
||||
{templates.map((template) => (
|
||||
<Field
|
||||
noMargin
|
||||
key={template.name}
|
||||
label={template.name || t('datasources.stack-modes.unnamed-template', 'Unnamed template')}
|
||||
>
|
||||
<Controller
|
||||
name={`modes.${index}.datasources.${template.name}`}
|
||||
control={control}
|
||||
render={({ field: { ref, onChange, value, ...field } }) => (
|
||||
<DataSourcePicker
|
||||
{...field}
|
||||
current={value}
|
||||
onChange={(ds) => onChange(ds.uid)}
|
||||
noDefault={true}
|
||||
pluginId={template.type}
|
||||
placeholder={t('datasources.stack-modes.select-datasource', 'Select datasource')}
|
||||
width={30}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Field, Input, Stack, Text } from '@grafana/ui';
|
||||
|
||||
import { StackFormSection } from './StackFormSection';
|
||||
import { StackFormValues } from './types';
|
||||
|
||||
export const StackName = () => {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<StackFormValues>();
|
||||
|
||||
return (
|
||||
<StackFormSection
|
||||
stepNo={1}
|
||||
title={t('datasources.stack-name.title', 'Enter stack name')}
|
||||
description={
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
<Trans i18nKey="datasources.stack-name.description">Enter a name to identify your stack.</Trans>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack direction="column">
|
||||
<Field
|
||||
label={t('datasources.stack-name.label', 'Name')}
|
||||
error={errors?.name?.message}
|
||||
invalid={!!errors.name?.message}
|
||||
>
|
||||
<Input
|
||||
data-testid={selectors.components.AlertRules.ruleNameField}
|
||||
id="name"
|
||||
width={38}
|
||||
{...register('name', {
|
||||
required: {
|
||||
value: true,
|
||||
message: t('datasources.stack-name.required', 'Must enter a name'),
|
||||
},
|
||||
})}
|
||||
aria-label={t('datasources.stack-name.aria-label', 'name')}
|
||||
placeholder="example: LGTM"
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
</StackFormSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, Combobox, Field, IconButton, Input, Stack, Text } from '@grafana/ui';
|
||||
import { getOptionDataSourceTypes } from 'app/features/dashboard-scene/settings/variables/utils';
|
||||
|
||||
import { StackFormSection } from './StackFormSection';
|
||||
import { StackFormValues, TemplateSection } from './types';
|
||||
|
||||
const emptyTemplateSection: TemplateSection = {
|
||||
name: '',
|
||||
type: '',
|
||||
};
|
||||
|
||||
export const StackTemplate = () => {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<StackFormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'templates',
|
||||
});
|
||||
|
||||
return (
|
||||
<StackFormSection
|
||||
stepNo={2}
|
||||
title={t('datasources.stack-template.title', 'Add template sections')}
|
||||
description={
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
<Trans i18nKey="datasources.stack-template.description">
|
||||
Add which datasource types comprise your stack and add names to reference them in the query editor.
|
||||
</Trans>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack direction="column" gap={2}>
|
||||
{fields.map((field, index) => (
|
||||
<TemplateSectionRow
|
||||
key={field.id}
|
||||
index={index}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
onRemove={() => remove(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button type="button" variant="secondary" icon="plus" onClick={() => append(emptyTemplateSection)}>
|
||||
<Trans i18nKey="datasources.stack-template.add-section">Add datasource</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</StackFormSection>
|
||||
);
|
||||
};
|
||||
|
||||
interface TemplateSectionRowProps {
|
||||
index: number;
|
||||
register: ReturnType<typeof useFormContext<StackFormValues>>['register'];
|
||||
control: ReturnType<typeof useFormContext<StackFormValues>>['control'];
|
||||
errors: ReturnType<typeof useFormContext<StackFormValues>>['formState']['errors'];
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const TemplateSectionRow = ({ index, register, control, errors, onRemove }: TemplateSectionRowProps) => {
|
||||
const dataSourceOptions = getOptionDataSourceTypes();
|
||||
|
||||
return (
|
||||
<Stack direction="row" gap={2} alignItems="flex-start">
|
||||
<Field
|
||||
noMargin
|
||||
label={t('datasources.stack-template.name-label', 'Name')}
|
||||
error={errors?.templates?.[index]?.name?.message}
|
||||
invalid={!!errors?.templates?.[index]?.name?.message}
|
||||
>
|
||||
<Input
|
||||
id={`templates.${index}.name`}
|
||||
width={30}
|
||||
{...register(`templates.${index}.name`, {
|
||||
required: {
|
||||
value: true,
|
||||
message: t('datasources.stack-template.name-required', 'Name is required'),
|
||||
},
|
||||
})}
|
||||
placeholder={t('datasources.stack-template.name-placeholder', 'e.g. logs-datasource')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
noMargin
|
||||
label={t('datasources.stack-template.type-label', 'Data source type')}
|
||||
error={errors?.templates?.[index]?.type?.message}
|
||||
invalid={!!errors?.templates?.[index]?.type?.message}
|
||||
>
|
||||
<Controller
|
||||
name={`templates.${index}.type`}
|
||||
control={control}
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t('datasources.stack-template.type-required', 'Type is required'),
|
||||
},
|
||||
}}
|
||||
render={({ field: { ref, onChange, ...field } }) => (
|
||||
<Combobox
|
||||
id={`templates.${index}.type`}
|
||||
width={30}
|
||||
options={dataSourceOptions}
|
||||
onChange={(option) => onChange(option?.value || '')}
|
||||
placeholder={t('datasources.stack-template.type-placeholder', 'Select type')}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
variant="destructive"
|
||||
tooltip={t('datasources.stack-template.remove-section', 'Remove section')}
|
||||
onClick={onRemove}
|
||||
aria-label={t('datasources.stack-template.remove-section', 'Remove section')}
|
||||
style={{ marginTop: '28px' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface TemplateSection {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ModeSection {
|
||||
name: string;
|
||||
/** template name to selected datasource UID */
|
||||
datasources: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface StackFormValues {
|
||||
name: string;
|
||||
templates: TemplateSection[];
|
||||
modes: ModeSection[];
|
||||
}
|
||||
@@ -5880,6 +5880,8 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Custom options",
|
||||
"json-values-tooltip": "Provide a JSON representing an array of objects, where each object can have any number of properties.<br/>Check <4>our docs</4> for more information.",
|
||||
"name-json-values": "Object values in a JSON array",
|
||||
"name-values-separated-comma": "Values separated by comma",
|
||||
"selection-options": "Selection options"
|
||||
},
|
||||
|
||||
22
yarn.lock
22
yarn.lock
@@ -3603,11 +3603,11 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@grafana/scenes-react@npm:6.47.1":
|
||||
version: 6.47.1
|
||||
resolution: "@grafana/scenes-react@npm:6.47.1"
|
||||
"@grafana/scenes-react@npm:^6.48.0":
|
||||
version: 6.48.0
|
||||
resolution: "@grafana/scenes-react@npm:6.48.0"
|
||||
dependencies:
|
||||
"@grafana/scenes": "npm:6.47.1"
|
||||
"@grafana/scenes": "npm:6.48.0"
|
||||
lru-cache: "npm:^10.2.2"
|
||||
react-use: "npm:^17.4.0"
|
||||
peerDependencies:
|
||||
@@ -3619,7 +3619,7 @@ __metadata:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
react-router-dom: ^6.28.0
|
||||
checksum: 10/dc20f9ee80eaf648665f7449e3ccb3b640a931f8f4a1be89599dce17eb0f52e763e3a603a4d491d9886b3e6cdf2ad3634124252c223315917206200f7cd6da16
|
||||
checksum: 10/5afb2aa79271dd824cc35f0a59ec193ddcbd4e1f14e756551228ce218a19faad54923d3a83f8bbb10d38c1d23c49846df29e7fce8feba8ec9aec2d32d9c1cf8d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -3649,9 +3649,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/scenes@npm:6.47.1":
|
||||
version: 6.47.1
|
||||
resolution: "@grafana/scenes@npm:6.47.1"
|
||||
"@grafana/scenes@npm:6.48.0, @grafana/scenes@npm:^6.48.0":
|
||||
version: 6.48.0
|
||||
resolution: "@grafana/scenes@npm:6.48.0"
|
||||
dependencies:
|
||||
"@floating-ui/react": "npm:^0.26.16"
|
||||
"@leeoniya/ufuzzy": "npm:^1.0.16"
|
||||
@@ -3671,7 +3671,7 @@ __metadata:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
react-router-dom: ^6.28.0
|
||||
checksum: 10/bc0c76258955058e7493b04e7cdd5d59dcc4159adf06da0837e992716ea15700b54f8403614df04326350363dc3344fb2602a2e8f7807724571659b4bd95aded
|
||||
checksum: 10/28cd64ea3c4faf87173ea71ffc136a7a525c33ec2e263ab2a98df718e3968ed7b7a12ecf0f309400af78e3c3269ae6048da8113412645a361a3e4925f9a2a810
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -19234,8 +19234,8 @@ __metadata:
|
||||
"@grafana/plugin-ui": "npm:^0.11.1"
|
||||
"@grafana/prometheus": "workspace:*"
|
||||
"@grafana/runtime": "workspace:*"
|
||||
"@grafana/scenes": "npm:6.47.1"
|
||||
"@grafana/scenes-react": "npm:6.47.1"
|
||||
"@grafana/scenes": "npm:^6.48.0"
|
||||
"@grafana/scenes-react": "npm:^6.48.0"
|
||||
"@grafana/schema": "workspace:*"
|
||||
"@grafana/sql": "workspace:*"
|
||||
"@grafana/test-utils": "workspace:*"
|
||||
|
||||
Reference in New Issue
Block a user