Compare commits

..

43 Commits

Author SHA1 Message Date
idastambuk 6b6a434383 update navigation and apply save form changes 2025-12-08 13:05:59 +01:00
idastambuk 6d6112b627 Merge branch 'grafakus/multi-dimensional-vars-ui' into hackathon/stacks 2025-12-08 12:40:05 +01:00
idastambuk 41f9162472 Add stack list, new stack, edit stack 2025-12-08 12:27:41 +01:00
Dafydd 7e991886e0 use fieldSelector on the ListConnections endpoint to get datasources by UID, instead of relying on uniqueness in the Get endpoint 2025-12-05 15:49:40 +00:00
Dafydd 85925d0765 simplify the new datasource client interface to not require group 2025-12-05 11:24:59 +00:00
Dafydd 7790698aaa newline 2025-12-04 16:36:28 +00:00
Dafydd 5499ad8023 provide an interface for the datasourceConnection 2025-12-04 16:34:22 +00:00
Dafydd 90c4ab9d96 wip: basic logic to check that datasource exists in validation 2025-12-04 12:34:14 +00:00
Dafydd fd31f087ee add some tests for validating datasource stacks structure 2025-12-04 11:03:41 +00:00
Dafydd 3ee834922b wip: start to use validator in the builder instead of validating on the store hooks 2025-12-03 15:13:34 +00:00
Dafydd 2e2ce8fddd wip: exploring update validation 2025-12-02 15:53:35 +00:00
Dafydd 8214dbc758 start adding some validation to the store 2025-12-02 15:11:46 +00:00
Dafydd 98d454401c rm unused store implementation 2025-12-02 14:37:41 +00:00
Dafydd fcf1a47222 update kind names 2025-12-02 13:44:31 +00:00
Dafydd 8a5b6804dd wip: add separate authorization logic for datasources 2025-12-02 10:44:43 +00:00
Dafydd f0028f692b wip: add storage that ignores dual writer. Next step: why doesnt the attr.GetName() method work? When posting a new datasource 2025-12-01 16:58:57 +00:00
Dafydd d71474246c wip: add datasources collection resource kind definition, register it to the API 2025-12-01 15:02:00 +00:00
Torkel Ödegaard 0275939762 Sidebar: Persist state to local storage (#114627)
* fix merge issues

* unsaved file
2025-12-01 12:54:18 +01:00
Serge Zaitsev c15b1b6f10 Chore: Annotation store interface (#114100)
* annotation legacy store with api server, read only

* annotations are not addressable by ID for read operations

* add ownership for an app

* typo, of course

* fix go workspace

* update workspace

* copy annotation app in dockerfile

* experimenting with store interface

* finalising interfaces

* add tags as custom handler

* implement tags handler

* add missing config file

* mute linter

* update generated files

* update workspace
2025-12-01 12:28:46 +01:00
Josh Hunt 32b9bebc75 Swagger: Load embedded icons from CDN (#114632) 2025-12-01 11:17:28 +00:00
Rafael Bortolon Paulovic 7fce2d9516 fix(unified): set SQLite path in cfg for reusing shared DB (#114580) 2025-12-01 11:30:22 +01:00
Ashley Harrison 2da171595a FieldColor: Add accessible color palettes (#114424)
* add viridis and others, allow passing interpolator directly to FieldColorSchemeMode

* fix bug

* be more defensive when getting the fieldcolor

* backend changes

* add d3-scale-chromatic to list of esModules
2025-12-01 09:51:11 +00:00
Tom Ratcliffe dd77107ed4 Folders: Add additional query param to legacy API for tracking purposes (#114505) 2025-12-01 09:25:57 +00:00
Stephanie Hingtgen aaa5d02a3e AuthZ: Set span errors (#114460) 2025-12-01 09:29:04 +01:00
Stephanie Hingtgen db9afe31e4 Provisioning: Fix panic on watcher when channel is closed (#114439) 2025-12-01 08:24:03 +03:00
grafakus 9447015e54 Remove temp switch in QueryVariableEditor - rely on options instead to determine if the variable has multi props 2025-11-26 19:51:00 +01:00
grafakus abe10b2bb6 chore: Better naming and minor test improvement 2025-11-25 18:27:58 +01:00
grafakus 009716a408 test(CustomVariableEditor): Add unit tests 2025-11-25 18:21:29 +01:00
grafakus e0c28cfa4c Fix: hide options when multi properties exist on every var options 2025-11-25 12:11:37 +01:00
grafakus 18c4f5b875 feat: Update dynamic dashboards editors 2025-11-25 12:06:47 +01:00
grafakus 400f3a91d0 Fix conflicts with main 2025-11-25 10:09:43 +01:00
grafakus d6b04d28b6 chore: Update to new Scenes version 2025-11-24 10:46:10 +01:00
grafakus 0400d536c7 Fix K8s Codegen Check 2025-11-19 09:07:17 +01:00
grafakus 694e88b95b Add some unit tests 2025-11-19 08:48:55 +01:00
grafakus ad73303328 VariableEditorForm checks to display preview with multiple props 2025-11-19 08:48:25 +01:00
grafakus 3dcd809aaf Translate CustomVariableEditor + improve JSON validation 2025-11-18 18:35:49 +01:00
grafakus 6b7fac65b1 chore: Add comment 2025-11-18 15:04:44 +01:00
grafakus 2d17de2395 Small preview fix when "All" option is checked 2025-11-18 14:58:28 +01:00
grafakus 5b685373aa Strengthen valuesFormat type + cleanup generated files 2025-11-18 14:42:56 +01:00
grafakus 4d29e5bf6a chore: ... 2025-11-13 20:16:47 +01:00
grafakus 7a0e64196b Update v2 schema + gen types 2025-11-13 14:19:03 +01:00
grafakus f1e24f528e Merge branch 'main' into grafakus/multi-dimensional-vars-ui 2025-11-13 14:18:36 +01:00
grafakus 198f4dbf93 feat: WiP 2025-11-13 14:08:00 +01:00
120 changed files with 4668 additions and 501 deletions
+14
View File
@@ -10,6 +10,20 @@ manifest: {
v0alpha1: {
kinds: [annotationv0alpha1]
routes: {
namespaced: {
"/tags": {
"GET": {
response: {
tags: [...{
tag: string
count: number
}]
}
}
}
}
}
codegen: {
ts: {
enabled: true
@@ -0,0 +1,26 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
// +k8s:openapi-gen=true
type GetTagsBody struct {
Tags []V0alpha1GetTagsBodyTags `json:"tags"`
}
// NewGetTagsBody creates a new GetTagsBody object.
func NewGetTagsBody() *GetTagsBody {
return &GetTagsBody{
Tags: []V0alpha1GetTagsBodyTags{},
}
}
// +k8s:openapi-gen=true
type V0alpha1GetTagsBodyTags struct {
Tag string `json:"tag"`
Count float64 `json:"count"`
}
// NewV0alpha1GetTagsBodyTags creates a new V0alpha1GetTagsBodyTags object.
func NewV0alpha1GetTagsBodyTags() *V0alpha1GetTagsBodyTags {
return &V0alpha1GetTagsBodyTags{}
}
@@ -0,0 +1,37 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
import (
"github.com/grafana/grafana-app-sdk/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
// +k8s:openapi-gen=true
type GetTags struct {
metav1.TypeMeta `json:",inline"`
GetTagsBody `json:",inline"`
}
func NewGetTags() *GetTags {
return &GetTags{}
}
func (t *GetTagsBody) DeepCopyInto(dst *GetTagsBody) {
_ = resource.CopyObjectInto(dst, t)
}
func (o *GetTags) DeepCopyObject() runtime.Object {
dst := NewGetTags()
o.DeepCopyInto(dst)
return dst
}
func (o *GetTags) DeepCopyInto(dst *GetTags) {
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
dst.TypeMeta.Kind = o.TypeMeta.Kind
o.GetTagsBody.DeepCopyInto(&dst.GetTagsBody)
}
var _ runtime.Object = NewGetTags()
+57 -4
View File
@@ -43,9 +43,60 @@ var appManifestData = app.ManifestData{
},
},
Routes: app.ManifestVersionRoutes{
Namespaced: map[string]spec3.PathProps{},
Cluster: map[string]spec3.PathProps{},
Schemas: map[string]spec.Schema{},
Namespaced: map[string]spec3.PathProps{
"/tags": {
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
OperationId: "getTags",
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
Default: &spec3.Response{
ResponseProps: spec3.ResponseProps{
Description: "Default OK response",
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"apiVersion": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
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",
},
},
"kind": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
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",
},
},
"tags": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
},
},
},
Required: []string{
"tags",
"apiVersion",
"kind",
},
}},
}},
},
},
},
}},
},
},
},
},
Cluster: map[string]spec3.PathProps{},
Schemas: map[string]spec.Schema{},
},
},
},
@@ -70,7 +121,9 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist
return goType, exists
}
var customRouteToGoResponseType = map[string]any{}
var customRouteToGoResponseType = map[string]any{
"v0alpha1||<namespace>/tags|GET": v0alpha1.GetTags{},
}
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths.
+17
View File
@@ -30,6 +30,23 @@ func New(cfg app.Config) (app.App, error) {
},
}
// Add custom route handlers if a TagHandler is provided in SpecificConfig.
// The handler is created/owned by the registry layer and passed in via
// SpecificConfig to avoid the apps package depending on the registry.
if cfg.SpecificConfig != nil {
if annotationConfig, ok := cfg.SpecificConfig.(*AnnotationConfig); ok && annotationConfig.TagHandler != nil {
simpleConfig.VersionedCustomRoutes = map[string]simple.AppVersionRouteHandlers{
"v0alpha1": {
{
Namespaced: true,
Path: "tags",
Method: "GET",
}: annotationConfig.TagHandler,
},
}
}
}
a, err := simple.NewApp(simpleConfig)
if err != nil {
return nil, err
+16
View File
@@ -0,0 +1,16 @@
package app
import (
"context"
"github.com/grafana/grafana-app-sdk/app"
)
// AnnotationConfig is the app-specific config for the annotation app. The
// registry can pass a TagHandler implementation here to wire the /tags
// resource route into the app without importing registry types.
type AnnotationConfig struct {
// TagHandler is the handler function for the GET /tags custom route.
// The function signature matches app.CustomRouteHandler from the app-sdk.
TagHandler func(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error
}
+1 -1
View File
@@ -7,4 +7,4 @@ generate: install-app-sdk update-app-sdk
--gogenpath=./pkg/apis \
--grouping=group \
--genoperatorstate=false \
--defencoding=none
--defencoding=none
@@ -0,0 +1,35 @@
package preferences
datasourcestacksV1alpha1: {
kind: "DataSourceStack"
pluralName: "DataSourceStacks"
scope: "Namespaced"
schema: {
spec: {
template: TemplateSpec
modes: [...ModeSpec]
}
}
}
TemplateSpec: {
[string]: DataSourceStackTemplateItem
}
DataSourceStackTemplateItem: {
group: string // type
name: string // variable name / display name
}
ModeSpec: {
name: string
uid: string
definition: Mode
}
Mode: [string]: ModeItem
ModeItem: {
dataSourceRef: string // grafana data source uid
}
+4 -3
View File
@@ -6,12 +6,13 @@ manifest: {
versions: {
"v1alpha1": {
codegen: {
ts: {enabled: false}
ts: {enabled: true}
go: {enabled: true}
}
kinds: [
starsV1alpha1,
datasourcestacksV1alpha1
]
}
},
}
}
}
@@ -0,0 +1,80 @@
package v1alpha1
import (
"context"
"github.com/grafana/grafana-app-sdk/resource"
)
type DataSourceStackClient struct {
client *resource.TypedClient[*DataSourceStack, *DataSourceStackList]
}
func NewDataSourceStackClient(client resource.Client) *DataSourceStackClient {
return &DataSourceStackClient{
client: resource.NewTypedClient[*DataSourceStack, *DataSourceStackList](client, DataSourceStackKind()),
}
}
func NewDataSourceStackClientFromGenerator(generator resource.ClientGenerator) (*DataSourceStackClient, error) {
c, err := generator.ClientFor(DataSourceStackKind())
if err != nil {
return nil, err
}
return NewDataSourceStackClient(c), nil
}
func (c *DataSourceStackClient) Get(ctx context.Context, identifier resource.Identifier) (*DataSourceStack, error) {
return c.client.Get(ctx, identifier)
}
func (c *DataSourceStackClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*DataSourceStackList, error) {
return c.client.List(ctx, namespace, opts)
}
func (c *DataSourceStackClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*DataSourceStackList, error) {
resp, err := c.client.List(ctx, namespace, resource.ListOptions{
ResourceVersion: opts.ResourceVersion,
Limit: opts.Limit,
LabelFilters: opts.LabelFilters,
FieldSelectors: opts.FieldSelectors,
})
if err != nil {
return nil, err
}
for resp.GetContinue() != "" {
page, err := c.client.List(ctx, namespace, resource.ListOptions{
Continue: resp.GetContinue(),
ResourceVersion: opts.ResourceVersion,
Limit: opts.Limit,
LabelFilters: opts.LabelFilters,
FieldSelectors: opts.FieldSelectors,
})
if err != nil {
return nil, err
}
resp.SetContinue(page.GetContinue())
resp.SetResourceVersion(page.GetResourceVersion())
resp.SetItems(append(resp.GetItems(), page.GetItems()...))
}
return resp, nil
}
func (c *DataSourceStackClient) Create(ctx context.Context, obj *DataSourceStack, opts resource.CreateOptions) (*DataSourceStack, error) {
// Make sure apiVersion and kind are set
obj.APIVersion = GroupVersion.Identifier()
obj.Kind = DataSourceStackKind().Kind()
return c.client.Create(ctx, obj, opts)
}
func (c *DataSourceStackClient) Update(ctx context.Context, obj *DataSourceStack, opts resource.UpdateOptions) (*DataSourceStack, error) {
return c.client.Update(ctx, obj, opts)
}
func (c *DataSourceStackClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*DataSourceStack, error) {
return c.client.Patch(ctx, identifier, req, opts)
}
func (c *DataSourceStackClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error {
return c.client.Delete(ctx, identifier, opts)
}
@@ -0,0 +1,28 @@
//
// Code generated by grafana-app-sdk. DO NOT EDIT.
//
package v1alpha1
import (
"encoding/json"
"io"
"github.com/grafana/grafana-app-sdk/resource"
)
// DataSourceStackJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding
type DataSourceStackJSONCodec struct{}
// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into`
func (*DataSourceStackJSONCodec) Read(reader io.Reader, into resource.Object) error {
return json.NewDecoder(reader).Decode(into)
}
// Write writes JSON-encoded bytes into `writer` marshaled from `from`
func (*DataSourceStackJSONCodec) Write(writer io.Writer, from resource.Object) error {
return json.NewEncoder(writer).Encode(from)
}
// Interface compliance checks
var _ resource.Codec = &DataSourceStackJSONCodec{}
@@ -0,0 +1,31 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v1alpha1
import (
time "time"
)
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
type DataSourceStackMetadata struct {
UpdateTimestamp time.Time `json:"updateTimestamp"`
CreatedBy string `json:"createdBy"`
Uid string `json:"uid"`
CreationTimestamp time.Time `json:"creationTimestamp"`
DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"`
Finalizers []string `json:"finalizers"`
ResourceVersion string `json:"resourceVersion"`
Generation int64 `json:"generation"`
UpdatedBy string `json:"updatedBy"`
Labels map[string]string `json:"labels"`
}
// NewDataSourceStackMetadata creates a new DataSourceStackMetadata object.
func NewDataSourceStackMetadata() *DataSourceStackMetadata {
return &DataSourceStackMetadata{
Finalizers: []string{},
Labels: map[string]string{},
}
}
@@ -0,0 +1,293 @@
//
// Code generated by grafana-app-sdk. DO NOT EDIT.
//
package v1alpha1
import (
"fmt"
"github.com/grafana/grafana-app-sdk/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"time"
)
// +k8s:openapi-gen=true
type DataSourceStack struct {
metav1.TypeMeta `json:",inline" yaml:",inline"`
metav1.ObjectMeta `json:"metadata" yaml:"metadata"`
// Spec is the spec of the DataSourceStack
Spec DataSourceStackSpec `json:"spec" yaml:"spec"`
}
func (o *DataSourceStack) GetSpec() any {
return o.Spec
}
func (o *DataSourceStack) SetSpec(spec any) error {
cast, ok := spec.(DataSourceStackSpec)
if !ok {
return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec)
}
o.Spec = cast
return nil
}
func (o *DataSourceStack) GetSubresources() map[string]any {
return map[string]any{}
}
func (o *DataSourceStack) GetSubresource(name string) (any, bool) {
switch name {
default:
return nil, false
}
}
func (o *DataSourceStack) SetSubresource(name string, value any) error {
switch name {
default:
return fmt.Errorf("subresource '%s' does not exist", name)
}
}
func (o *DataSourceStack) GetStaticMetadata() resource.StaticMetadata {
gvk := o.GroupVersionKind()
return resource.StaticMetadata{
Name: o.ObjectMeta.Name,
Namespace: o.ObjectMeta.Namespace,
Group: gvk.Group,
Version: gvk.Version,
Kind: gvk.Kind,
}
}
func (o *DataSourceStack) SetStaticMetadata(metadata resource.StaticMetadata) {
o.Name = metadata.Name
o.Namespace = metadata.Namespace
o.SetGroupVersionKind(schema.GroupVersionKind{
Group: metadata.Group,
Version: metadata.Version,
Kind: metadata.Kind,
})
}
func (o *DataSourceStack) GetCommonMetadata() resource.CommonMetadata {
dt := o.DeletionTimestamp
var deletionTimestamp *time.Time
if dt != nil {
deletionTimestamp = &dt.Time
}
// Legacy ExtraFields support
extraFields := make(map[string]any)
if o.Annotations != nil {
extraFields["annotations"] = o.Annotations
}
if o.ManagedFields != nil {
extraFields["managedFields"] = o.ManagedFields
}
if o.OwnerReferences != nil {
extraFields["ownerReferences"] = o.OwnerReferences
}
return resource.CommonMetadata{
UID: string(o.UID),
ResourceVersion: o.ResourceVersion,
Generation: o.Generation,
Labels: o.Labels,
CreationTimestamp: o.CreationTimestamp.Time,
DeletionTimestamp: deletionTimestamp,
Finalizers: o.Finalizers,
UpdateTimestamp: o.GetUpdateTimestamp(),
CreatedBy: o.GetCreatedBy(),
UpdatedBy: o.GetUpdatedBy(),
ExtraFields: extraFields,
}
}
func (o *DataSourceStack) SetCommonMetadata(metadata resource.CommonMetadata) {
o.UID = types.UID(metadata.UID)
o.ResourceVersion = metadata.ResourceVersion
o.Generation = metadata.Generation
o.Labels = metadata.Labels
o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp)
if metadata.DeletionTimestamp != nil {
dt := metav1.NewTime(*metadata.DeletionTimestamp)
o.DeletionTimestamp = &dt
} else {
o.DeletionTimestamp = nil
}
o.Finalizers = metadata.Finalizers
if o.Annotations == nil {
o.Annotations = make(map[string]string)
}
if !metadata.UpdateTimestamp.IsZero() {
o.SetUpdateTimestamp(metadata.UpdateTimestamp)
}
if metadata.CreatedBy != "" {
o.SetCreatedBy(metadata.CreatedBy)
}
if metadata.UpdatedBy != "" {
o.SetUpdatedBy(metadata.UpdatedBy)
}
// Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields
if metadata.ExtraFields != nil {
if annotations, ok := metadata.ExtraFields["annotations"]; ok {
if cast, ok := annotations.(map[string]string); ok {
o.Annotations = cast
}
}
if managedFields, ok := metadata.ExtraFields["managedFields"]; ok {
if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok {
o.ManagedFields = cast
}
}
if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok {
if cast, ok := ownerReferences.([]metav1.OwnerReference); ok {
o.OwnerReferences = cast
}
}
}
}
func (o *DataSourceStack) GetCreatedBy() string {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
return o.ObjectMeta.Annotations["grafana.com/createdBy"]
}
func (o *DataSourceStack) SetCreatedBy(createdBy string) {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy
}
func (o *DataSourceStack) GetUpdateTimestamp() time.Time {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"])
return parsed
}
func (o *DataSourceStack) SetUpdateTimestamp(updateTimestamp time.Time) {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339)
}
func (o *DataSourceStack) GetUpdatedBy() string {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
return o.ObjectMeta.Annotations["grafana.com/updatedBy"]
}
func (o *DataSourceStack) SetUpdatedBy(updatedBy string) {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy
}
func (o *DataSourceStack) Copy() resource.Object {
return resource.CopyObject(o)
}
func (o *DataSourceStack) DeepCopyObject() runtime.Object {
return o.Copy()
}
func (o *DataSourceStack) DeepCopy() *DataSourceStack {
cpy := &DataSourceStack{}
o.DeepCopyInto(cpy)
return cpy
}
func (o *DataSourceStack) DeepCopyInto(dst *DataSourceStack) {
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
dst.TypeMeta.Kind = o.TypeMeta.Kind
o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta)
o.Spec.DeepCopyInto(&dst.Spec)
}
// Interface compliance compile-time check
var _ resource.Object = &DataSourceStack{}
// +k8s:openapi-gen=true
type DataSourceStackList struct {
metav1.TypeMeta `json:",inline" yaml:",inline"`
metav1.ListMeta `json:"metadata" yaml:"metadata"`
Items []DataSourceStack `json:"items" yaml:"items"`
}
func (o *DataSourceStackList) DeepCopyObject() runtime.Object {
return o.Copy()
}
func (o *DataSourceStackList) Copy() resource.ListObject {
cpy := &DataSourceStackList{
TypeMeta: o.TypeMeta,
Items: make([]DataSourceStack, len(o.Items)),
}
o.ListMeta.DeepCopyInto(&cpy.ListMeta)
for i := 0; i < len(o.Items); i++ {
if item, ok := o.Items[i].Copy().(*DataSourceStack); ok {
cpy.Items[i] = *item
}
}
return cpy
}
func (o *DataSourceStackList) GetItems() []resource.Object {
items := make([]resource.Object, len(o.Items))
for i := 0; i < len(o.Items); i++ {
items[i] = &o.Items[i]
}
return items
}
func (o *DataSourceStackList) SetItems(items []resource.Object) {
o.Items = make([]DataSourceStack, len(items))
for i := 0; i < len(items); i++ {
o.Items[i] = *items[i].(*DataSourceStack)
}
}
func (o *DataSourceStackList) DeepCopy() *DataSourceStackList {
cpy := &DataSourceStackList{}
o.DeepCopyInto(cpy)
return cpy
}
func (o *DataSourceStackList) DeepCopyInto(dst *DataSourceStackList) {
resource.CopyObjectInto(dst, o)
}
// Interface compliance compile-time check
var _ resource.ListObject = &DataSourceStackList{}
// Copy methods for all subresource types
// DeepCopy creates a full deep copy of Spec
func (s *DataSourceStackSpec) DeepCopy() *DataSourceStackSpec {
cpy := &DataSourceStackSpec{}
s.DeepCopyInto(cpy)
return cpy
}
// DeepCopyInto deep copies Spec into another Spec object
func (s *DataSourceStackSpec) DeepCopyInto(dst *DataSourceStackSpec) {
resource.CopyObjectInto(dst, s)
}
@@ -0,0 +1,34 @@
//
// Code generated by grafana-app-sdk. DO NOT EDIT.
//
package v1alpha1
import (
"github.com/grafana/grafana-app-sdk/resource"
)
// schema is unexported to prevent accidental overwrites
var (
schemaDataSourceStack = resource.NewSimpleSchema("collections.grafana.app", "v1alpha1", &DataSourceStack{}, &DataSourceStackList{}, resource.WithKind("DataSourceStack"),
resource.WithPlural("datasourcestacks"), resource.WithScope(resource.NamespacedScope))
kindDataSourceStack = resource.Kind{
Schema: schemaDataSourceStack,
Codecs: map[resource.KindEncoding]resource.Codec{
resource.KindEncodingJSON: &DataSourceStackJSONCodec{},
},
}
)
// Kind returns a resource.Kind for this Schema with a JSON codec
func DataSourceStackKind() resource.Kind {
return kindDataSourceStack
}
// Schema returns a resource.SimpleSchema representation of DataSourceStack
func DataSourceStackSchema() *resource.SimpleSchema {
return schemaDataSourceStack
}
// Interface compliance checks
var _ resource.Schema = kindDataSourceStack
@@ -0,0 +1,58 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v1alpha1
// +k8s:openapi-gen=true
type DataSourceStackTemplateSpec map[string]DataSourceStackDataSourceStackTemplateItem
// +k8s:openapi-gen=true
type DataSourceStackDataSourceStackTemplateItem struct {
// type
Group string `json:"group"`
// variable name / display name
Name string `json:"name"`
}
// NewDataSourceStackDataSourceStackTemplateItem creates a new DataSourceStackDataSourceStackTemplateItem object.
func NewDataSourceStackDataSourceStackTemplateItem() *DataSourceStackDataSourceStackTemplateItem {
return &DataSourceStackDataSourceStackTemplateItem{}
}
// +k8s:openapi-gen=true
type DataSourceStackModeSpec struct {
Name string `json:"name"`
Uid string `json:"uid"`
Definition DataSourceStackMode `json:"definition"`
}
// NewDataSourceStackModeSpec creates a new DataSourceStackModeSpec object.
func NewDataSourceStackModeSpec() *DataSourceStackModeSpec {
return &DataSourceStackModeSpec{}
}
// +k8s:openapi-gen=true
type DataSourceStackMode map[string]DataSourceStackModeItem
// +k8s:openapi-gen=true
type DataSourceStackModeItem struct {
// grafana data source uid
DataSourceRef string `json:"dataSourceRef"`
}
// NewDataSourceStackModeItem creates a new DataSourceStackModeItem object.
func NewDataSourceStackModeItem() *DataSourceStackModeItem {
return &DataSourceStackModeItem{}
}
// +k8s:openapi-gen=true
type DataSourceStackSpec struct {
Template DataSourceStackTemplateSpec `json:"template"`
Modes []DataSourceStackModeSpec `json:"modes"`
}
// NewDataSourceStackSpec creates a new DataSourceStackSpec object.
func NewDataSourceStackSpec() *DataSourceStackSpec {
return &DataSourceStackSpec{
Modes: []DataSourceStackModeSpec{},
}
}
@@ -32,6 +32,19 @@ var StarsResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion,
},
)
var DatasourceStacksResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion,
"datasourcestacks", "datasourcestack", "DataSourceStack",
func() runtime.Object { return &DataSourceStack{} },
func() runtime.Object { return &DataSourceStackList{} },
utils.TableColumns{
Definition: []metav1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name"},
{Name: "Created At", Type: "date"},
},
// TODO: Reader?
},
)
var (
SchemeBuilder runtime.SchemeBuilder
localSchemeBuilder = &SchemeBuilder
@@ -48,6 +61,8 @@ func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(schemeGroupVersion,
&Stars{},
&StarsList{},
&DataSourceStack{},
&DataSourceStackList{},
)
metav1.AddToGroupVersion(scheme, schemeGroupVersion)
return nil
@@ -14,10 +14,241 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars": schema_pkg_apis_collections_v1alpha1_Stars(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsList": schema_pkg_apis_collections_v1alpha1_StarsList(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource": schema_pkg_apis_collections_v1alpha1_StarsResource(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec": schema_pkg_apis_collections_v1alpha1_StarsSpec(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack": schema_pkg_apis_collections_v1alpha1_DataSourceStack(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem": schema_pkg_apis_collections_v1alpha1_DataSourceStackDataSourceStackTemplateItem(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackList": schema_pkg_apis_collections_v1alpha1_DataSourceStackList(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem": schema_pkg_apis_collections_v1alpha1_DataSourceStackModeItem(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec": schema_pkg_apis_collections_v1alpha1_DataSourceStackModeSpec(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec": schema_pkg_apis_collections_v1alpha1_DataSourceStackSpec(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars": schema_pkg_apis_collections_v1alpha1_Stars(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsList": schema_pkg_apis_collections_v1alpha1_StarsList(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource": schema_pkg_apis_collections_v1alpha1_StarsResource(ref),
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec": schema_pkg_apis_collections_v1alpha1_StarsSpec(ref),
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStack(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
},
},
"spec": {
SchemaProps: spec.SchemaProps{
Description: "Spec is the spec of the DataSourceStack",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec"),
},
},
},
Required: []string{"metadata", "spec"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackDataSourceStackTemplateItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"group": {
SchemaProps: spec.SchemaProps{
Description: "type",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"name": {
SchemaProps: spec.SchemaProps{
Description: "variable name / display name",
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"group", "name"},
},
},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackList(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
},
},
"items": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack"),
},
},
},
},
},
},
Required: []string{"metadata", "items"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStack", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackModeItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"dataSourceRef": {
SchemaProps: spec.SchemaProps{
Description: "grafana data source uid",
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"dataSourceRef"},
},
},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackModeSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"name": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"uid": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"definition": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem"),
},
},
},
},
},
},
Required: []string{"name", "uid", "definition"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeItem"},
}
}
func schema_pkg_apis_collections_v1alpha1_DataSourceStackSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"template": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem"),
},
},
},
},
},
"modes": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec"),
},
},
},
},
},
},
Required: []string{"template", "modes"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackDataSourceStackTemplateItem", "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.DataSourceStackModeSpec"},
}
}
@@ -1,2 +1,4 @@
API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,DataSourceStackSpec,Modes
API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsSpec,Resource
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,DataSourceStackList,ListMeta
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsList,ListMeta
+19 -7
View File
@@ -10,19 +10,22 @@ import (
"fmt"
"strings"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/resource"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/resource"
"github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
v1alpha1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
)
var (
rawSchemaStarsv1alpha1 = []byte(`{"Resource":{"additionalProperties":false,"properties":{"group":{"type":"string"},"kind":{"type":"string"},"names":{"description":"The set of resources\n+listType=set","items":{"type":"string"},"type":"array"}},"required":["group","kind","names"],"type":"object"},"Stars":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"resource":{"items":{"$ref":"#/components/schemas/Resource"},"type":"array"}},"required":["resource"],"type":"object"}}`)
versionSchemaStarsv1alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1)
rawSchemaStarsv1alpha1 = []byte(`{"Resource":{"additionalProperties":false,"properties":{"group":{"type":"string"},"kind":{"type":"string"},"names":{"description":"The set of resources\n+listType=set","items":{"type":"string"},"type":"array"}},"required":["group","kind","names"],"type":"object"},"Stars":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"resource":{"items":{"$ref":"#/components/schemas/Resource"},"type":"array"}},"required":["resource"],"type":"object"}}`)
versionSchemaStarsv1alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1)
rawSchemaDataSourceStackv1alpha1 = []byte(`{"DataSourceStack":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"DataSourceStackTemplateItem":{"additionalProperties":false,"properties":{"group":{"description":"type","type":"string"},"name":{"description":"variable name / display name","type":"string"}},"required":["group","name"],"type":"object"},"Mode":{"additionalProperties":{"$ref":"#/components/schemas/ModeItem"},"type":"object"},"ModeItem":{"additionalProperties":false,"properties":{"dataSourceRef":{"description":"grafana data source uid","type":"string"}},"required":["dataSourceRef"],"type":"object"},"ModeSpec":{"additionalProperties":false,"properties":{"definition":{"$ref":"#/components/schemas/Mode"},"name":{"type":"string"},"uid":{"type":"string"}},"required":["name","uid","definition"],"type":"object"},"TemplateSpec":{"additionalProperties":{"$ref":"#/components/schemas/DataSourceStackTemplateItem"},"type":"object"},"spec":{"additionalProperties":false,"properties":{"modes":{"items":{"$ref":"#/components/schemas/ModeSpec"},"type":"array"},"template":{"$ref":"#/components/schemas/TemplateSpec"}},"required":["template","modes"],"type":"object"}}`)
versionSchemaDataSourceStackv1alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaDataSourceStackv1alpha1, &versionSchemaDataSourceStackv1alpha1)
)
var appManifestData = app.ManifestData{
@@ -49,6 +52,14 @@ var appManifestData = app.ManifestData{
},
Schema: &versionSchemaStarsv1alpha1,
},
{
Kind: "DataSourceStack",
Plural: "DataSourceStacks",
Scope: "Namespaced",
Conversion: false,
Schema: &versionSchemaDataSourceStackv1alpha1,
},
},
Routes: app.ManifestVersionRoutes{
Namespaced: map[string]spec3.PathProps{},
@@ -68,7 +79,8 @@ func RemoteManifest() app.Manifest {
}
var kindVersionToGoType = map[string]resource.Kind{
"Stars/v1alpha1": v1alpha1.StarsKind(),
"Stars/v1alpha1": v1alpha1.StarsKind(),
"DataSourceStack/v1alpha1": v1alpha1.DataSourceStackKind(),
}
// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists.
@@ -0,0 +1,47 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.gen';
export interface Metadata {
name: string;
namespace: string;
generateName?: string;
selfLink?: string;
uid?: string;
resourceVersion?: string;
generation?: number;
creationTimestamp?: string;
deletionTimestamp?: string;
deletionGracePeriodSeconds?: number;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: OwnerReference[];
finalizers?: string[];
managedFields?: ManagedFieldsEntry[];
}
export interface OwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller?: boolean;
blockOwnerDeletion?: boolean;
}
export interface ManagedFieldsEntry {
manager?: string;
operation?: string;
apiVersion?: string;
time?: string;
fieldsType?: string;
subresource?: string;
}
export interface DataSourceStack {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
}
@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
export interface Metadata {
updateTimestamp: string;
createdBy: string;
uid: string;
creationTimestamp: string;
deletionTimestamp?: string;
finalizers: string[];
resourceVersion: string;
generation: number;
updatedBy: string;
labels: Record<string, string>;
}
export const defaultMetadata = (): Metadata => ({
updateTimestamp: "",
createdBy: "",
uid: "",
creationTimestamp: "",
finalizers: [],
resourceVersion: "",
generation: 0,
updatedBy: "",
labels: {},
});
@@ -0,0 +1,53 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export type TemplateSpec = Record<string, DataSourceStackTemplateItem>;
export const defaultTemplateSpec = (): TemplateSpec => ({});
export interface DataSourceStackTemplateItem {
// type
group: string;
// variable name / display name
name: string;
}
export const defaultDataSourceStackTemplateItem = (): DataSourceStackTemplateItem => ({
group: "",
name: "",
});
export interface ModeSpec {
name: string;
uid: string;
definition: Mode;
}
export const defaultModeSpec = (): ModeSpec => ({
name: "",
uid: "",
definition: defaultMode(),
});
export type Mode = Record<string, ModeItem>;
export const defaultMode = (): Mode => ({});
export interface ModeItem {
// grafana data source uid
dataSourceRef: string;
}
export const defaultModeItem = (): ModeItem => ({
dataSourceRef: "",
});
export interface Spec {
template: TemplateSpec;
modes: ModeSpec[];
}
export const defaultSpec = (): Spec => ({
template: defaultTemplateSpec(),
modes: [],
});
@@ -0,0 +1,47 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.gen';
export interface Metadata {
name: string;
namespace: string;
generateName?: string;
selfLink?: string;
uid?: string;
resourceVersion?: string;
generation?: number;
creationTimestamp?: string;
deletionTimestamp?: string;
deletionGracePeriodSeconds?: number;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: OwnerReference[];
finalizers?: string[];
managedFields?: ManagedFieldsEntry[];
}
export interface OwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller?: boolean;
blockOwnerDeletion?: boolean;
}
export interface ManagedFieldsEntry {
manager?: string;
operation?: string;
apiVersion?: string;
time?: string;
fieldsType?: string;
subresource?: string;
}
export interface Stars {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
}
@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
export interface Metadata {
updateTimestamp: string;
createdBy: string;
uid: string;
creationTimestamp: string;
deletionTimestamp?: string;
finalizers: string[];
resourceVersion: string;
generation: number;
updatedBy: string;
labels: Record<string, string>;
}
export const defaultMetadata = (): Metadata => ({
updateTimestamp: "",
createdBy: "",
uid: "",
creationTimestamp: "",
finalizers: [],
resourceVersion: "",
generation: 0,
updatedBy: "",
labels: {},
});
@@ -0,0 +1,24 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export interface Resource {
group: string;
kind: string;
// The set of resources
// +listType=set
names: string[];
}
export const defaultResource = (): Resource => ({
group: "",
kind: "",
names: [],
});
export interface Spec {
resource: Resource[];
}
export const defaultSpec = (): Spec => ({
resource: [],
});
@@ -340,7 +340,12 @@ ValueMappingResult: {
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -352,7 +357,7 @@ ValueMappingResult: {
// `continuous-purples`: Continuous Purple palette mode
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades"
FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-viridis" | "continuous-magma" | "continuous-plasma" | "continuous-inferno" | "continuous-cividis" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades"
// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.
FieldColorSeriesByMode: "min" | "max" | "last"
@@ -377,7 +382,7 @@ FetchOptions: {
url: string
body?: string
// These are 2D arrays of strings, each representing a key-value pair
// We are defining them this way because we can't generate a go struct that
// We are defining them this way because we can't generate a go struct that
// that would have exactly two strings in each sub-array
queryParams?: [...[...string]]
headers?: [...[...string]]
@@ -387,7 +392,7 @@ InfinityOptions: FetchOptions & {
datasourceUid: string
}
HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH"
HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH"
// Action variable type
ActionVariableType: "string"
@@ -113,7 +113,7 @@ DashboardLink: {
placement?: DashboardLinkPlacement
}
// Dashboard Link placement. Defines where the link should be displayed.
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
DashboardLinkPlacement: "inControlsMenu"
@@ -342,7 +342,12 @@ ValueMappingResult: {
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -354,7 +359,7 @@ ValueMappingResult: {
// `continuous-purples`: Continuous Purple palette mode
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades"
FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-viridis" | "continuous-magma" | "continuous-plasma" | "continuous-inferno" | "continuous-cividis" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades"
// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.
FieldColorSeriesByMode: "min" | "max" | "last"
@@ -379,7 +384,7 @@ FetchOptions: {
url: string
body?: string
// These are 2D arrays of strings, each representing a key-value pair
// We are defining them this way because we can't generate a go struct that
// We are defining them this way because we can't generate a go struct that
// that would have exactly two strings in each sub-array
queryParams?: [...[...string]]
headers?: [...[...string]]
@@ -389,7 +394,7 @@ InfinityOptions: FetchOptions & {
datasourceUid: string
}
HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH"
HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH"
// Action variable type
ActionVariableType: "string"
@@ -906,6 +911,7 @@ CustomVariableSpec: {
skipUrlSync: bool | *false
description?: string
allowCustomValue: bool | *true
valuesFormat?: "csv" | "json"
}
// Custom variable kind
@@ -301,8 +301,8 @@ lineage: schemas: [{
// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
#DashboardLinkType: "link" | "dashboards" @cuetsy(kind="type")
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
#DashboardLinkPlacement: "inControlsMenu" @cuetsy(kind="type")
// Annotation Query placement. Defines where the annotation query should be displayed.
@@ -318,7 +318,7 @@ lineage: schemas: [{
url: string
body?: string
// These are 2D arrays of strings, each representing a key-value pair
// We are defining this way because we can't generate a go struct that
// We are defining this way because we can't generate a go struct that
// that would have exactly two strings in each sub-array
queryParams?: [...[...string]]
headers?: [...[...string]]
@@ -330,7 +330,7 @@ lineage: schemas: [{
url: string
body?: string
// These are 2D arrays of strings, each representing a key-value pair
// We are defining them this way because we can't generate a go struct that
// We are defining them this way because we can't generate a go struct that
// that would have exactly two strings in each sub-array
queryParams?: [...[...string]]
headers?: [...[...string]]
@@ -381,7 +381,12 @@ lineage: schemas: [{
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -393,7 +398,7 @@ lineage: schemas: [{
// `continuous-purples`: Continuous Purple palette mode
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
#FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" @cuetsy(kind="enum",memberNames="Thresholds|PaletteClassic|PaletteClassicByName|ContinuousGrYlRd|ContinuousRdYlGr|ContinuousBlYlRd|ContinuousYlRd|ContinuousBlPu|ContinuousYlBl|ContinuousBlues|ContinuousReds|ContinuousGreens|ContinuousPurples|Fixed|Shades") @grafanamaturity(NeedsExpertReview)
#FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-viridis" | "continuous-magma" | "continuous-plasma" | "continuous-inferno" | "continuous-cividis" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" @cuetsy(kind="enum",memberNames="Thresholds|PaletteClassic|PaletteClassicByName|ContinuousViridis|ContinuousMagma|ContinuousPlasma|ContinuousInferno|ContinuousCividis|ContinuousGrYlRd|ContinuousRdYlGr|ContinuousBlYlRd|ContinuousYlRd|ContinuousBlPu|ContinuousYlBl|ContinuousBlues|ContinuousReds|ContinuousGreens|ContinuousPurples|Fixed|Shades") @grafanamaturity(NeedsExpertReview)
// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.
#FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type")
@@ -301,8 +301,8 @@ lineage: schemas: [{
// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
#DashboardLinkType: "link" | "dashboards" @cuetsy(kind="type")
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
#DashboardLinkPlacement: "inControlsMenu" @cuetsy(kind="type")
// Annotation Query placement. Defines where the annotation query should be displayed.
@@ -318,7 +318,7 @@ lineage: schemas: [{
url: string
body?: string
// These are 2D arrays of strings, each representing a key-value pair
// We are defining this way because we can't generate a go struct that
// We are defining this way because we can't generate a go struct that
// that would have exactly two strings in each sub-array
queryParams?: [...[...string]]
headers?: [...[...string]]
@@ -330,7 +330,7 @@ lineage: schemas: [{
url: string
body?: string
// These are 2D arrays of strings, each representing a key-value pair
// We are defining them this way because we can't generate a go struct that
// We are defining them this way because we can't generate a go struct that
// that would have exactly two strings in each sub-array
queryParams?: [...[...string]]
headers?: [...[...string]]
@@ -381,7 +381,12 @@ lineage: schemas: [{
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -393,7 +398,7 @@ lineage: schemas: [{
// `continuous-purples`: Continuous Purple palette mode
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
#FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" @cuetsy(kind="enum",memberNames="Thresholds|PaletteClassic|PaletteClassicByName|ContinuousGrYlRd|ContinuousRdYlGr|ContinuousBlYlRd|ContinuousYlRd|ContinuousBlPu|ContinuousYlBl|ContinuousBlues|ContinuousReds|ContinuousGreens|ContinuousPurples|Fixed|Shades") @grafanamaturity(NeedsExpertReview)
#FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-viridis" | "continuous-magma" | "continuous-plasma" | "continuous-inferno" | "continuous-cividis" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" @cuetsy(kind="enum",memberNames="Thresholds|PaletteClassic|PaletteClassicByName|ContinuousViridis|ContinuousMagma|ContinuousPlasma|ContinuousInferno|ContinuousCividis|ContinuousGrYlRd|ContinuousRdYlGr|ContinuousBlYlRd|ContinuousYlRd|ContinuousBlPu|ContinuousYlBl|ContinuousBlues|ContinuousReds|ContinuousGreens|ContinuousPurples|Fixed|Shades") @grafanamaturity(NeedsExpertReview)
// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.
#FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type")
@@ -344,7 +344,12 @@ ValueMappingResult: {
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -356,7 +361,7 @@ ValueMappingResult: {
// `continuous-purples`: Continuous Purple palette mode
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades"
FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-viridis" | "continuous-magma" | "continuous-plasma" | "continuous-inferno" | "continuous-cividis" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades"
// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.
FieldColorSeriesByMode: "min" | "max" | "last"
@@ -381,7 +386,7 @@ FetchOptions: {
url: string
body?: string
// These are 2D arrays of strings, each representing a key-value pair
// We are defining them this way because we can't generate a go struct that
// We are defining them this way because we can't generate a go struct that
// that would have exactly two strings in each sub-array
queryParams?: [...[...string]]
headers?: [...[...string]]
@@ -391,7 +396,7 @@ InfinityOptions: FetchOptions & {
datasourceUid: string
}
HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH"
HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH"
// Action variable type
ActionVariableType: "string"
@@ -583,7 +583,12 @@ func NewDashboardFieldColor() *DashboardFieldColor {
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -602,6 +607,11 @@ const (
DashboardFieldColorModeIdThresholds DashboardFieldColorModeId = "thresholds"
DashboardFieldColorModeIdPaletteClassic DashboardFieldColorModeId = "palette-classic"
DashboardFieldColorModeIdPaletteClassicByName DashboardFieldColorModeId = "palette-classic-by-name"
DashboardFieldColorModeIdContinuousViridis DashboardFieldColorModeId = "continuous-viridis"
DashboardFieldColorModeIdContinuousMagma DashboardFieldColorModeId = "continuous-magma"
DashboardFieldColorModeIdContinuousPlasma DashboardFieldColorModeId = "continuous-plasma"
DashboardFieldColorModeIdContinuousInferno DashboardFieldColorModeId = "continuous-inferno"
DashboardFieldColorModeIdContinuousCividis DashboardFieldColorModeId = "continuous-cividis"
DashboardFieldColorModeIdContinuousGrYlRd DashboardFieldColorModeId = "continuous-GrYlRd"
DashboardFieldColorModeIdContinuousRdYlGr DashboardFieldColorModeId = "continuous-RdYlGr"
DashboardFieldColorModeIdContinuousBlYlRd DashboardFieldColorModeId = "continuous-BlYlRd"
@@ -117,7 +117,7 @@ DashboardLink: {
placement?: DashboardLinkPlacement
}
// Dashboard Link placement. Defines where the link should be displayed.
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
DashboardLinkPlacement: "inControlsMenu"
@@ -346,7 +346,12 @@ ValueMappingResult: {
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -358,7 +363,7 @@ ValueMappingResult: {
// `continuous-purples`: Continuous Purple palette mode
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades"
FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-viridis" | "continuous-magma" | "continuous-plasma" | "continuous-inferno" | "continuous-cividis" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades"
// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.
FieldColorSeriesByMode: "min" | "max" | "last"
@@ -383,7 +388,7 @@ FetchOptions: {
url: string
body?: string
// These are 2D arrays of strings, each representing a key-value pair
// We are defining them this way because we can't generate a go struct that
// We are defining them this way because we can't generate a go struct that
// that would have exactly two strings in each sub-array
queryParams?: [...[...string]]
headers?: [...[...string]]
@@ -393,7 +398,7 @@ InfinityOptions: FetchOptions & {
datasourceUid: string
}
HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH"
HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH"
// Action variable type
ActionVariableType: "string"
@@ -910,6 +915,7 @@ CustomVariableSpec: {
skipUrlSync: bool | *false
description?: string
allowCustomValue: bool | *true
valuesFormat?: "csv" | "json"
}
// Custom variable kind
@@ -587,7 +587,12 @@ func NewDashboardFieldColor() *DashboardFieldColor {
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -606,6 +611,11 @@ const (
DashboardFieldColorModeIdThresholds DashboardFieldColorModeId = "thresholds"
DashboardFieldColorModeIdPaletteClassic DashboardFieldColorModeId = "palette-classic"
DashboardFieldColorModeIdPaletteClassicByName DashboardFieldColorModeId = "palette-classic-by-name"
DashboardFieldColorModeIdContinuousViridis DashboardFieldColorModeId = "continuous-viridis"
DashboardFieldColorModeIdContinuousMagma DashboardFieldColorModeId = "continuous-magma"
DashboardFieldColorModeIdContinuousPlasma DashboardFieldColorModeId = "continuous-plasma"
DashboardFieldColorModeIdContinuousInferno DashboardFieldColorModeId = "continuous-inferno"
DashboardFieldColorModeIdContinuousCividis DashboardFieldColorModeId = "continuous-cividis"
DashboardFieldColorModeIdContinuousGrYlRd DashboardFieldColorModeId = "continuous-GrYlRd"
DashboardFieldColorModeIdContinuousRdYlGr DashboardFieldColorModeId = "continuous-RdYlGr"
DashboardFieldColorModeIdContinuousBlYlRd DashboardFieldColorModeId = "continuous-BlYlRd"
@@ -1665,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.
@@ -2091,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"},
},
@@ -28,6 +28,7 @@ type fileWatcher struct {
timers map[string]*time.Timer
watcher *fsnotify.Watcher
logger logging.Logger
closed bool
}
// File watcher that buffers events for 100ms before actually firing them
@@ -77,22 +78,21 @@ func NewFileWatcher(path string, accept func(string) bool) (FileWatcher, error)
// Keep watching for changes until the context is done
func (f *fileWatcher) Watch(ctx context.Context, events chan<- string) {
defer f.cleanup(events)
for {
select {
case <-ctx.Done():
close(events)
return
case _, ok := <-f.watcher.Errors:
if !ok { // Channel was closed (i.e. Watcher.Close() was called).
close(events)
return
}
// Read from Events.
case e, ok := <-f.watcher.Events:
if !ok { // Channel was closed (i.e. Watcher.Close() was called).
close(events)
return
}
name := filepath.Base(e.Name)
@@ -114,6 +114,11 @@ func (f *fileWatcher) Watch(ctx context.Context, events chan<- string) {
if !ok {
nameCopy := e.Name
t = time.AfterFunc(math.MaxInt64, func() {
// before sending the event, check if the watcher has been closed
if f.closed {
return
}
path, _ := strings.CutPrefix(nameCopy, f.prefix)
events <- path
@@ -128,3 +133,17 @@ func (f *fileWatcher) Watch(ctx context.Context, events chan<- string) {
}
}
}
// stop all pending timers and close the event channel
func (f *fileWatcher) cleanup(events chan<- string) {
f.timersMu.Lock()
defer f.timersMu.Unlock()
for _, timer := range f.timers {
timer.Stop()
}
f.timers = make(map[string]*time.Timer)
close(events)
f.closed = true
}
+2
View File
@@ -181,6 +181,8 @@ import (
//go:generate mockery --name InterfaceName --structname MockImplementationName --inpackage --filename my_implementation_mock.go
```
The current `go:generate` command format used in this repository is only compatible with mockery v2.
## Globals
As a general rule of thumb, avoid using global variables, since they make the code difficult to maintain and reason
+1 -1
View File
@@ -43,7 +43,7 @@ require (
github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend
github.com/blang/semver/v4 v4.0.0 // indirect; @grafana/grafana-developer-enablement-squad
github.com/blevesearch/bleve/v2 v2.5.0 // @grafana/grafana-search-and-storage
github.com/blevesearch/bleve_index_api v1.2.11 // @grafana/grafana-search-and-storage
github.com/blevesearch/bleve_index_api v1.2.7 // @grafana/grafana-search-and-storage
github.com/blugelabs/bluge v0.2.2 // @grafana/grafana-backend-group
github.com/blugelabs/bluge_segment_api v0.2.0 // @grafana/grafana-backend-group
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // @grafana/grafana-backend-group
+2 -2
View File
@@ -927,8 +927,8 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/blevesearch/bleve/v2 v2.5.0 h1:HzYqBy/5/M9Ul9ESEmXzN/3Jl7YpmWBdHM/+zzv/3k4=
github.com/blevesearch/bleve/v2 v2.5.0/go.mod h1:PcJzTPnEynO15dCf9isxOga7YFRa/cMSsbnRwnszXUk=
github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/bleve_index_api v1.2.7 h1:c8r9vmbaYQroAMSGag7zq5gEVPiuXrUQDqfnj7uYZSY=
github.com/blevesearch/bleve_index_api v1.2.7/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM=
github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w=
github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U=
+11
View File
@@ -870,6 +870,17 @@ github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVn
github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk=
github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU=
github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU=
github.com/grafana/gomemcache v0.0.0-20250228145437-da7b95fd2ac1/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw=
github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM=
github.com/grafana/grafana-app-sdk v0.41.0 h1:SYHN3U7B1myRKY3UZZDkFsue9TDmAOap0UrQVTqtYBU=
github.com/grafana/grafana-app-sdk v0.41.0/go.mod h1:Wg/3vEZfok1hhIWiHaaJm+FwkosfO98o8KbeLFEnZpY=
github.com/grafana/grafana-app-sdk v0.46.0/go.mod h1:LCTrqR1SwBS13XGVYveBmM7giJDDjzuXK+M9VzPuPWc=
github.com/grafana/grafana-app-sdk v0.47.0/go.mod h1:kywXmkppq0oReUMzkjTW8Fq2EBzyN7v914jttTWnWxA=
github.com/grafana/grafana-app-sdk/logging v0.38.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk=
github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+V54zWQk/Q2/JsJQB4=
github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk=
github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk=
github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU=
github.com/grafana/grafana-app-sdk/logging v0.43.0/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M=
github.com/grafana/grafana-app-sdk/logging v0.43.1/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M=
github.com/grafana/grafana-app-sdk/logging v0.43.2/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA=
+11 -6
View File
@@ -297,8 +297,8 @@ lineage: schemas: [{
// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
#DashboardLinkType: "link" | "dashboards" @cuetsy(kind="type")
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
// Dashboard Link placement. Defines where the link should be displayed.
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
#DashboardLinkPlacement: "inControlsMenu" @cuetsy(kind="type")
// Annotation Query placement. Defines where the annotation query should be displayed.
@@ -314,7 +314,7 @@ lineage: schemas: [{
url: string
body?: string
// These are 2D arrays of strings, each representing a key-value pair
// We are defining this way because we can't generate a go struct that
// We are defining this way because we can't generate a go struct that
// that would have exactly two strings in each sub-array
queryParams?: [...[...string]]
headers?: [...[...string]]
@@ -326,7 +326,7 @@ lineage: schemas: [{
url: string
body?: string
// These are 2D arrays of strings, each representing a key-value pair
// We are defining them this way because we can't generate a go struct that
// We are defining them this way because we can't generate a go struct that
// that would have exactly two strings in each sub-array
queryParams?: [...[...string]]
headers?: [...[...string]]
@@ -377,7 +377,12 @@ lineage: schemas: [{
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -389,7 +394,7 @@ lineage: schemas: [{
// `continuous-purples`: Continuous Purple palette mode
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
#FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" @cuetsy(kind="enum",memberNames="Thresholds|PaletteClassic|PaletteClassicByName|ContinuousGrYlRd|ContinuousRdYlGr|ContinuousBlYlRd|ContinuousYlRd|ContinuousBlPu|ContinuousYlBl|ContinuousBlues|ContinuousReds|ContinuousGreens|ContinuousPurples|Fixed|Shades") @grafanamaturity(NeedsExpertReview)
#FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-viridis" | "continuous-magma" | "continuous-plasma" | "continuous-inferno" | "continuous-cividis" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades" @cuetsy(kind="enum",memberNames="Thresholds|PaletteClassic|PaletteClassicByName|ContinuousViridis|ContinuousMagma|ContinuousPlasma|ContinuousInferno|ContinuousCividis|ContinuousGrYlRd|ContinuousRdYlGr|ContinuousBlYlRd|ContinuousYlRd|ContinuousBlPu|ContinuousYlBl|ContinuousBlues|ContinuousReds|ContinuousGreens|ContinuousPurples|Fixed|Shades") @grafanamaturity(NeedsExpertReview)
// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.
#FieldColorSeriesByMode: "min" | "max" | "last" @cuetsy(kind="type")
+2 -2
View File
@@ -296,8 +296,8 @@
"@grafana/plugin-ui": "^0.11.1",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "6.47.1",
"@grafana/scenes-react": "6.47.1",
"@grafana/scenes": "^6.48.0",
"@grafana/scenes-react": "^6.48.0",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
+1
View File
@@ -63,6 +63,7 @@
"@types/string-hash": "1.1.3",
"@types/systemjs": "6.15.3",
"d3-interpolate": "3.0.1",
"d3-scale-chromatic": "3.1.0",
"date-fns": "4.1.0",
"dompurify": "3.3.0",
"eventemitter3": "5.0.1",
+65 -5
View File
@@ -1,4 +1,11 @@
import { interpolateRgbBasis } from 'd3-interpolate';
import {
interpolateViridis,
interpolateMagma,
interpolatePlasma,
interpolateInferno,
interpolateCividis,
} from 'd3-scale-chromatic';
import stringHash from 'string-hash';
import tinycolor from 'tinycolor2';
@@ -75,6 +82,41 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
);
},
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousViridis,
name: 'Viridis',
isContinuous: true,
isByValue: true,
interpolator: interpolateViridis,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousMagma,
name: 'Magma',
isContinuous: true,
isByValue: true,
interpolator: interpolateMagma,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousPlasma,
name: 'Plasma',
isContinuous: true,
isByValue: true,
interpolator: interpolatePlasma,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousInferno,
name: 'Inferno',
isContinuous: true,
isByValue: true,
interpolator: interpolateInferno,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousCividis,
name: 'Cividis',
isContinuous: true,
isByValue: true,
interpolator: interpolateCividis,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousGrYlRd,
name: 'Green-Yellow-Red',
@@ -148,16 +190,27 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
];
});
interface FieldColorSchemeModeOptions {
interface BaseFieldColorSchemeModeOptions {
id: FieldColorModeId;
name: string;
description?: string;
getColors: (theme: GrafanaTheme2) => string[];
isContinuous: boolean;
isByValue: boolean;
useSeriesName?: boolean;
}
interface FieldColorSchemeModeInterpolator extends BaseFieldColorSchemeModeOptions {
interpolator: (value: number) => string;
getColors?: never;
}
interface FieldColorSchemeModeGetColors extends BaseFieldColorSchemeModeOptions {
getColors: (theme: GrafanaTheme2) => string[];
interpolator?: never;
}
type FieldColorSchemeModeOptions = FieldColorSchemeModeGetColors | FieldColorSchemeModeInterpolator;
export class FieldColorSchemeMode implements FieldColorMode {
id: FieldColorModeId;
name: string;
@@ -178,11 +231,15 @@ export class FieldColorSchemeMode implements FieldColorMode {
this.isContinuous = options.isContinuous;
this.isByValue = options.isByValue;
this.useSeriesName = options.useSeriesName;
this.interpolator = options.interpolator;
}
getColors(theme: GrafanaTheme2): string[] {
if (!this.getNamedColors) {
return [];
if (!this.interpolator) {
return [];
}
this.getNamedColors = () => new Array(9).fill(0).map((_, i) => this.getInterpolator()(i / 8));
}
if (this.colorCache && this.colorCacheTheme === theme) {
@@ -231,12 +288,15 @@ export class FieldColorSchemeMode implements FieldColorMode {
/** @beta */
export function getFieldColorModeForField(field: Field): FieldColorMode {
return fieldColorModeRegistry.get(field.config.color?.mode ?? FieldColorModeId.Thresholds);
return (
fieldColorModeRegistry.getIfExists(field.config.color?.mode) ??
fieldColorModeRegistry.get(FieldColorModeId.Thresholds)
);
}
/** @beta */
export function getFieldColorMode(mode?: FieldColorModeId | string): FieldColorMode {
return fieldColorModeRegistry.get(mode ?? FieldColorModeId.Thresholds);
return fieldColorModeRegistry.getIfExists(mode) ?? fieldColorModeRegistry.get(FieldColorModeId.Thresholds);
}
/**
@@ -16,6 +16,11 @@ export enum FieldColorModeId {
ContinuousReds = 'continuous-reds',
ContinuousGreens = 'continuous-greens',
ContinuousPurples = 'continuous-purples',
ContinuousViridis = 'continuous-viridis',
ContinuousMagma = 'continuous-magma',
ContinuousPlasma = 'continuous-plasma',
ContinuousInferno = 'continuous-inferno',
ContinuousCividis = 'continuous-cividis',
Fixed = 'fixed',
Shades = 'shades',
}
@@ -101,6 +101,7 @@ export interface IntervalVariableModel extends VariableWithOptions {
export interface CustomVariableModel extends VariableWithMultiSupport {
type: 'custom';
valuesFormat?: 'csv' | 'json';
}
export interface DataSourceVariableModel extends VariableWithMultiSupport {
@@ -8,6 +8,7 @@ export const grafanaESModules = [
'd3',
'd3-color',
'd3-interpolate',
'd3-scale-chromatic',
'delaunator',
'get-user-locale',
'internmap',
@@ -475,7 +475,12 @@ export type VariableType = ('query' | 'adhoc' | 'groupby' | 'constant' | 'dataso
* `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
* `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
* `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
* `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
* `continuous-viridis`: Continuous Viridis palette mode
* `continuous-magma`: Continuous Magma palette mode
* `continuous-plasma`: Continuous Plasma palette mode
* `continuous-inferno`: Continuous Inferno palette mode
* `continuous-cividis`: Continuous Cividis palette mode
* `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
* `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
* `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
* `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -492,11 +497,16 @@ export enum FieldColorModeId {
ContinuousBlPu = 'continuous-BlPu',
ContinuousBlYlRd = 'continuous-BlYlRd',
ContinuousBlues = 'continuous-blues',
ContinuousCividis = 'continuous-cividis',
ContinuousGrYlRd = 'continuous-GrYlRd',
ContinuousGreens = 'continuous-greens',
ContinuousInferno = 'continuous-inferno',
ContinuousMagma = 'continuous-magma',
ContinuousPlasma = 'continuous-plasma',
ContinuousPurples = 'continuous-purples',
ContinuousRdYlGr = 'continuous-RdYlGr',
ContinuousReds = 'continuous-reds',
ContinuousViridis = 'continuous-viridis',
ContinuousYlBl = 'continuous-YlBl',
ContinuousYlRd = 'continuous-YlRd',
Fixed = 'fixed',
@@ -316,6 +316,7 @@ export const handyTestingSchema: Spec = {
query: 'option1, option2',
skipUrlSync: false,
allowCustomValue: true,
valuesFormat: 'csv',
},
},
{
@@ -334,7 +334,12 @@ ValueMappingResult: {
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -346,7 +351,7 @@ ValueMappingResult: {
// `continuous-purples`: Continuous Purple palette mode
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades"
FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-viridis" | "continuous-magma" | "continuous-plasma" | "continuous-inferno" | "continuous-cividis" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades"
// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.
FieldColorSeriesByMode: "min" | "max" | "last"
@@ -327,7 +327,7 @@ export interface FieldConfig {
description?: string;
// An explicit path to the field in the datasource. When the frame meta includes a path,
// This will default to `${frame.meta.path}/${field.name}
//
//
// When defined, this value can be used as an identifier within the datasource scope, and
// may be used to update the results
path?: string;
@@ -529,7 +529,12 @@ export const defaultFieldColor = (): FieldColor => ({
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -541,7 +546,7 @@ export const defaultFieldColor = (): FieldColor => ({
// `continuous-purples`: Continuous Purple palette mode
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
export type FieldColorModeId = "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades";
export type FieldColorModeId = "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-viridis" | "continuous-magma" | "continuous-plasma" | "continuous-inferno" | "continuous-cividis" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades";
export const defaultFieldColorModeId = (): FieldColorModeId => ("thresholds");
@@ -489,7 +489,12 @@ export const defaultFieldColor = (): FieldColor => ({
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -501,7 +506,7 @@ export const defaultFieldColor = (): FieldColor => ({
// `continuous-purples`: Continuous Purple palette mode
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
export type FieldColorModeId = "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades";
export type FieldColorModeId = "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-viridis" | "continuous-magma" | "continuous-plasma" | "continuous-inferno" | "continuous-cividis" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades";
export const defaultFieldColorModeId = (): FieldColorModeId => ("thresholds");
@@ -496,7 +496,12 @@ export const defaultFieldColor = (): FieldColor => ({
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -508,7 +513,7 @@ export const defaultFieldColor = (): FieldColor => ({
// `continuous-purples`: Continuous Purple palette mode
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
export type FieldColorModeId = "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades";
export type FieldColorModeId = "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-viridis" | "continuous-magma" | "continuous-plasma" | "continuous-inferno" | "continuous-cividis" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades";
export const defaultFieldColorModeId = (): FieldColorModeId => ("thresholds");
@@ -1330,6 +1335,7 @@ export interface CustomVariableSpec {
skipUrlSync: boolean;
description?: string;
allowCustomValue: boolean;
valuesFormat?: "csv" | "json";
}
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
@@ -23,13 +23,32 @@ describe('Sidebar', () => {
// Verify pane is closed
expect(screen.queryByTestId('sidebar-pane-header-title')).not.toBeInTheDocument();
});
it('Can persist docked state', async () => {
const { unmount } = render(<TestSetup persistanceKey="test" />);
act(() => screen.getByLabelText('Settings').click());
act(() => screen.getByLabelText('Dock').click());
unmount();
render(<TestSetup persistanceKey="test" />);
act(() => screen.getByLabelText('Settings').click());
expect(screen.getByLabelText('Undock')).toBeInTheDocument();
});
});
function TestSetup() {
interface TestSetupProps {
persistanceKey?: string;
}
function TestSetup({ persistanceKey }: TestSetupProps) {
const [openPane, setOpenPane] = React.useState('');
const contextValue = useSidebar({
position: 'right',
hasOpenPane: openPane !== '',
persistanceKey,
onClosePane: () => setOpenPane(''),
});
@@ -1,6 +1,8 @@
import { clamp } from 'lodash';
import React, { useCallback } from 'react';
import { store } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
export type SidebarPosition = 'left' | 'right';
@@ -30,7 +32,10 @@ export interface UseSideBarOptions {
hasOpenPane?: boolean;
position?: SidebarPosition;
tabsMode?: boolean;
compactDefault?: boolean;
/** Initial state for compact mode */
defaultToCompact?: boolean;
/** Initial state for docked mode */
defaultToDocked?: boolean;
/** defaults to 2 grid units (16px) */
bottomMargin?: number;
/** defaults to 2 grid units (16px) */
@@ -39,6 +44,11 @@ export interface UseSideBarOptions {
contentMargin?: number;
/** Called when pane is closed or clicked outside of (in undocked mode) */
onClosePane?: () => void;
/**
* Optional key to use for persisting sidebar state (docked / compact / size)
* Can only be app name as the final local storag key will be `grafana.ui.sidebar.{persistanceKey}.{docked|compact|size}`
*/
persistanceKey?: string;
}
export const SIDE_BAR_WIDTH_ICON_ONLY = 5;
@@ -48,21 +58,30 @@ export function useSidebar({
hasOpenPane,
position = 'right',
tabsMode,
compactDefault = true,
defaultToCompact = true,
defaultToDocked = false,
bottomMargin = 2,
edgeMargin = 2,
contentMargin = 2,
persistanceKey,
onClosePane,
}: UseSideBarOptions): SidebarContextValue {
const theme = useTheme2();
const [isDocked, setIsDocked] = React.useState(false);
const [paneWidth, setPaneWidth] = React.useState(280);
const [compact, setCompact] = React.useState(compactDefault);
const [isDocked, setIsDocked] = useSidebarSavedState(persistanceKey, 'docked', defaultToDocked);
const [compact, setCompact] = useSidebarSavedState(persistanceKey, 'compact', defaultToCompact);
const [paneWidth, setPaneWidth] = useSidebarSavedState(persistanceKey, 'size', 280);
// Used to accumulate drag distance to know when to change compact mode
const [_, setCompactDrag] = React.useState(0);
const onToggleDock = useCallback(() => setIsDocked((prev) => !prev), []);
const onToggleDock = useCallback(() => {
setIsDocked((prev) => {
return !prev;
});
}, [setIsDocked]);
// Calculate how much space the outer wrapper needs to reserve for the sidebar toolbar + pane (if docked)
const prop = position === 'right' ? 'paddingRight' : 'paddingLeft';
const toolbarWidth =
((compact ? SIDE_BAR_WIDTH_ICON_ONLY : SIDE_BAR_WIDTH_WITH_TEXT) + edgeMargin + contentMargin) *
@@ -82,10 +101,10 @@ export function useSidebar({
setCompactDrag((prevDrag) => {
const newDrag = prevDrag + diff;
if (newDrag < -20 && !compact) {
setCompact(true);
setCompact(() => true);
return 0;
} else if (newDrag > 20 && compact) {
setCompact(false);
setCompact(() => false);
return 0;
}
@@ -98,7 +117,7 @@ export function useSidebar({
return clamp(prevWidth + diff, 100, 500);
});
},
[hasOpenPane, compact]
[hasOpenPane, setCompact, setPaneWidth, compact]
);
return {
@@ -117,3 +136,53 @@ export function useSidebar({
onClosePane,
};
}
function useSidebarSavedState<T = number | boolean>(
persistanceKey: string | undefined,
subKey: string,
defaultValue: T
) {
const [state, setState] = React.useState<T>(() => {
if (!persistanceKey) {
return defaultValue;
}
if (typeof defaultValue === 'boolean') {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return store.getBool(`grafana.ui.sidebar.${persistanceKey}.${subKey}`, defaultValue) as T;
}
if (typeof defaultValue === 'number') {
const value = Number.parseInt(store.get(`grafana.ui.sidebar.${persistanceKey}.${subKey}`), 10);
if (Number.isNaN(value)) {
return defaultValue;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return value as T;
}
return defaultValue;
});
const setPersisted = useCallback(
(cb: (prevState: T) => T) => {
setState((prevState) => {
const newState = cb(prevState);
if (!persistanceKey) {
return newState;
}
if (persistanceKey) {
store.set(`grafana.ui.sidebar.${persistanceKey}.${subKey}`, String(newState));
}
return newState;
});
},
[persistanceKey, subKey]
);
return [state, setPersisted] as const;
}
+4
View File
@@ -150,6 +150,10 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/connections/datasources/edit/*", authorize(datasources.EditPageAccess), hs.Index)
r.Get("/connections", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/add-new-connection", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/stacks", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/stacks/new", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/stacks/edit/*", authorize(datasources.ConfigurationPageAccess), hs.Index)
// Plugin details pages
r.Get("/connections/datasources/:id", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
r.Get("/connections/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
+2 -4
View File
@@ -30,10 +30,8 @@ func (hs *HTTPServer) registerSwaggerUI(r routing.RouteRegister) {
}
data := map[string]any{
"Nonce": c.RequestNonce,
"Assets": assets,
"FavIcon": "public/img/fav32.png",
"AppleTouchIcon": "public/img/apple-touch-icon.png",
"Nonce": c.RequestNonce,
"Assets": assets,
}
if hs.Cfg.CSPEnabled {
data["CSPEnabled"] = true
+11 -1
View File
@@ -619,7 +619,12 @@ func NewFieldColor() *FieldColor {
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-viridis`: Continuous Viridis palette mode
// `continuous-magma`: Continuous Magma palette mode
// `continuous-plasma`: Continuous Plasma palette mode
// `continuous-inferno`: Continuous Inferno palette mode
// `continuous-cividis`: Continuous Cividis palette mode
// `continuous-GrYlRd`: Continuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
@@ -637,6 +642,11 @@ const (
FieldColorModeIdThresholds FieldColorModeId = "thresholds"
FieldColorModeIdPaletteClassic FieldColorModeId = "palette-classic"
FieldColorModeIdPaletteClassicByName FieldColorModeId = "palette-classic-by-name"
FieldColorModeIdContinuousViridis FieldColorModeId = "continuous-viridis"
FieldColorModeIdContinuousMagma FieldColorModeId = "continuous-magma"
FieldColorModeIdContinuousPlasma FieldColorModeId = "continuous-plasma"
FieldColorModeIdContinuousInferno FieldColorModeId = "continuous-inferno"
FieldColorModeIdContinuousCividis FieldColorModeId = "continuous-cividis"
FieldColorModeIdContinuousGrYlRd FieldColorModeId = "continuous-GrYlRd"
FieldColorModeIdContinuousRdYlGr FieldColorModeId = "continuous-RdYlGr"
FieldColorModeIdContinuousBlYlRd FieldColorModeId = "continuous-BlYlRd"
@@ -0,0 +1,88 @@
package collections
import (
"context"
"fmt"
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/datasources/service/client"
"k8s.io/apiserver/pkg/admission"
)
var _ builder.APIGroupValidation = (*DatasourceStacksValidator)(nil)
type DatasourceStacksValidator struct {
dsClient client.DataSourceConnectionClient
}
func GetDatasourceStacksValidator(dsClient client.DataSourceConnectionClient) builder.APIGroupValidation {
return &DatasourceStacksValidator{dsClient: dsClient}
}
func (v *DatasourceStacksValidator) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
obj := a.GetObject()
operation := a.GetOperation()
if operation == admission.Connect {
return fmt.Errorf("Connect operation is not allowed (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
}
if operation != admission.Create && operation != admission.Update {
return nil
}
cast, ok := obj.(*collections.DataSourceStack)
if !ok {
return fmt.Errorf("object is not of type *collections.DataSourceStack (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
}
// get the keys from the template
template := cast.Spec.Template
templateNames := map[string]bool{}
for _, item := range template {
// template items cannot be empty
if item.Group == "" || item.Name == "" {
return fmt.Errorf("template items cannot be empty (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
}
// template names must be unique
if _, exists := templateNames[item.Name]; exists {
return fmt.Errorf("template item names must be unique. name '%s' already exists (%s %s)", item.Name, a.GetName(), a.GetKind().GroupVersion().String())
}
templateNames[item.Name] = true
}
// for each mode, check that the keys are in the template
modes := cast.Spec.Modes
for _, mode := range modes {
for key, item := range mode.Definition {
// if a key is not in the template, return an error
if _, ok := template[key]; !ok {
return fmt.Errorf("key '%s' is not in the DataSourceStack template (%s %s)", key, a.GetName(), a.GetKind().GroupVersion().String())
}
exists, err := v.checkDatasourceExists(ctx, item.DataSourceRef)
if err != nil || !exists {
return fmt.Errorf("datasource '%s' in group '%s' does not exist (%s %s): %w", item.DataSourceRef, template[key].Group, a.GetName(), a.GetKind().GroupVersion().String(), err)
}
}
}
return nil
}
func (v *DatasourceStacksValidator) checkDatasourceExists(ctx context.Context, name string) (bool, error) {
dsConn, err := v.dsClient.GetByUID(ctx, name)
if err != nil {
return false, err
}
if dsConn == nil {
return false, nil
}
return true, nil
}
@@ -0,0 +1,212 @@
package collections_test
import (
"context"
"testing"
collectionsv1alpha1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
queryv0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/collections"
datasourcesclient "github.com/grafana/grafana/pkg/services/datasources/service/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
)
func TestDataSourceValidator_Validate(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
operation admission.Operation
object runtime.Object
needMockDSClient bool // only set to true if you expect to make a call to the datasource client
dsClientReturnValue *queryv0alpha1.DataSourceConnection
dsClientReturnError error
expectError bool
errorMsg string
}{
{
name: "should return no error for invalid kind",
operation: admission.Delete,
object: &collectionsv1alpha1.Stars{},
expectError: false,
},
{
name: "should return error for Connect operation",
operation: admission.Connect,
object: &collectionsv1alpha1.DataSourceStack{},
expectError: true,
},
{
name: "template items cannot be empty",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{},
},
},
},
expectError: true,
errorMsg: "template items cannot be empty (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "template item name keys must be unique",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
"key2": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
},
},
expectError: true,
errorMsg: "template item names must be unique. name 'foo' already exists (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "mode keys must exist in the template",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
{
Name: "prod",
Definition: collectionsv1alpha1.DataSourceStackMode{
"notintemplate": collectionsv1alpha1.DataSourceStackModeItem{
DataSourceRef: "foo",
},
},
},
},
},
},
expectError: true,
errorMsg: "key 'notintemplate' is not in the DataSourceStack template (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "error if data source does not exist",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
{
Name: "prod",
Definition: collectionsv1alpha1.DataSourceStackMode{
"key1": collectionsv1alpha1.DataSourceStackModeItem{
DataSourceRef: "ref",
},
},
},
},
},
},
needMockDSClient: true,
dsClientReturnValue: nil, // no result - this is the default anyway
expectError: true,
errorMsg: "datasource 'ref' in group 'foo.grafana' does not exist (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "valid request",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
{
Name: "prod",
Definition: collectionsv1alpha1.DataSourceStackMode{
"key1": collectionsv1alpha1.DataSourceStackModeItem{
DataSourceRef: "ref",
},
},
},
},
},
},
needMockDSClient: true,
dsClientReturnValue: &queryv0alpha1.DataSourceConnection{}, // returning any non-nil value will pass validation
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
attrs := &FakeAdmissionAttributes{
Operation: tt.operation,
Object: tt.object,
Name: "test-datasourcestack",
Kind: schema.GroupVersionKind{Group: "collections.grafana.app", Version: "v1alpha1", Kind: "DataSourceStack"},
}
var client *datasourcesclient.MockDataSourceConnectionClient
if tt.needMockDSClient {
client = datasourcesclient.NewMockDataSourceConnectionClient(t)
client.On("GetByUID", mock.Anything, mock.Anything).Return(tt.dsClientReturnValue, tt.dsClientReturnError)
}
validator := collections.GetDatasourceStacksValidator(client)
err := validator.Validate(ctx, attrs, nil)
if tt.expectError {
assert.Error(t, err)
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
type FakeAdmissionAttributes struct {
admission.Attributes
Operation admission.Operation
Object runtime.Object
Name string
Kind schema.GroupVersionKind
}
func (m *FakeAdmissionAttributes) GetOperation() admission.Operation {
return m.Operation
}
func (m *FakeAdmissionAttributes) GetObject() runtime.Object {
return m.Object
}
func (m *FakeAdmissionAttributes) GetName() string {
return m.Name
}
func (m *FakeAdmissionAttributes) GetKind() schema.GroupVersionKind {
return m.Kind
}
+56 -11
View File
@@ -1,11 +1,13 @@
package collections
import (
"context"
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
@@ -14,13 +16,16 @@ import (
"k8s.io/kube-openapi/pkg/validation/spec"
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/registry/apis/collections/legacy"
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
datasourcesClient "github.com/grafana/grafana/pkg/services/datasources/service/client"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/star"
"github.com/grafana/grafana/pkg/services/user"
@@ -29,13 +34,15 @@ import (
)
var (
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupMutation = (*APIBuilder)(nil)
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupMutation = (*APIBuilder)(nil)
_ builder.APIGroupValidation = (*APIBuilder)(nil)
)
type APIBuilder struct {
authorizer authorizer.Authorizer
legacyStars *legacy.DashboardStarsStorage
authorizer authorizer.Authorizer
legacyStars *legacy.DashboardStarsStorage
datasourceStacksValidator builder.APIGroupValidation
}
func RegisterAPIService(
@@ -45,6 +52,8 @@ func RegisterAPIService(
stars star.Service,
users user.Service,
apiregistration builder.APIRegistrar,
dsConnClientFactory datasourcesClient.DataSourceConnectionClientFactory,
restConfigProvider apiserver.RestConfigProvider,
) *APIBuilder {
// Requires development settings and clearly experimental
//nolint:staticcheck // not yet migrated to OpenFeature
@@ -52,11 +61,15 @@ func RegisterAPIService(
return nil
}
dsConnClient := dsConnClientFactory(restConfigProvider)
sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db))
builder := &APIBuilder{
datasourceStacksValidator: GetDatasourceStacksValidator(dsConnClient),
authorizer: &utils.AuthorizeFromName{
Resource: map[string][]utils.ResourceOwner{
"stars": {utils.UserResourceOwner},
"stars": {utils.UserResourceOwner},
"datasources": {utils.UserResourceOwner},
},
},
}
@@ -94,28 +107,60 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
storage := map[string]rest.Storage{}
// Configure Stars Dual writer
resource := collections.StarsResourceInfo
starsResource := collections.StarsResourceInfo
var stars grafanarest.Storage
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter)
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, starsResource, opts.OptsGetter)
if err != nil {
return err
}
stars = &starStorage{Storage: stars} // wrap List so we only return one value
if b.legacyStars != nil && opts.DualWriteBuilder != nil {
stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars)
stars, err = opts.DualWriteBuilder(starsResource.GroupResource(), b.legacyStars, stars)
if err != nil {
return err
}
}
storage[resource.StoragePath()] = stars
storage[resource.StoragePath("update")] = &starsREST{store: stars}
storage[starsResource.StoragePath()] = stars
storage[starsResource.StoragePath("update")] = &starsREST{store: stars}
// no need for dual writer for a kind that does not exist in the legacy database
resourceInfo := collections.DatasourceStacksResourceInfo
datasourcesStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, resourceInfo, opts.OptsGetter)
storage[resourceInfo.StoragePath()] = datasourcesStorage
apiGroupInfo.VersionedResourcesStorageMap[collections.APIVersion] = storage
return nil
}
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
if a.GetKind().Group == collections.DatasourceStacksResourceInfo.GroupResource().Group {
return b.datasourceStacksValidator.Validate(ctx, a, o)
}
return nil
}
func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
return b.authorizer
return authorizer.AuthorizerFunc(
func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
if attr.GetResource() == "stars" {
return b.authorizer.Authorize(ctx, attr)
}
// datasources auth branch starts
if !attr.IsResourceRequest() {
return authorizer.DecisionNoOpinion, "", nil
}
// require a user
_, err = identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "valid user is required", err
}
// TODO make the auth more restrictive
return authorizer.DecisionAllow, "", nil
})
}
func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
+1 -1
View File
@@ -85,7 +85,7 @@ func RegisterAPIService(
accessControl,
//nolint:staticcheck // not yet migrated to OpenFeature
features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
false,
true,
)
if err != nil {
return nil, err
+44 -11
View File
@@ -15,6 +15,7 @@ import (
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/datasources"
"k8s.io/apimachinery/pkg/fields"
)
var (
@@ -28,11 +29,11 @@ var (
// Get all datasource connections -- this will be backed by search or duplicated resource in unified storage
type DataSourceConnectionProvider interface {
// Get gets a specific datasource (that the user in context can see)
// The name is {group}:{name}, see /pkg/apis/query/v0alpha1/connection.go#L34
// The name is the legacy datasource UID.
GetConnection(ctx context.Context, namespace string, name string) (*queryV0.DataSourceConnection, error)
// List lists all data sources the user in context can see
ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error)
// List lists all data sources the user in context can see. Optional field selectors can filter the results.
ListConnections(ctx context.Context, namespace string, fieldSelector fields.Selector) (*queryV0.DataSourceConnectionList, error)
}
type connectionAccess struct {
@@ -74,7 +75,11 @@ func (s *connectionAccess) Get(ctx context.Context, name string, options *metav1
}
func (s *connectionAccess) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
return s.connections.ListConnections(ctx, request.NamespaceValue(ctx))
var fs fields.Selector
if options != nil && options.FieldSelector != nil {
fs = options.FieldSelector
}
return s.connections.ListConnections(ctx, request.NamespaceValue(ctx), fs)
}
type connectionsProvider struct {
@@ -103,19 +108,47 @@ func (q *connectionsProvider) GetConnection(ctx context.Context, namespace strin
return q.asConnection(ds, namespace)
}
func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string) (*queryV0.DataSourceConnectionList, error) {
func (q *connectionsProvider) ListConnections(ctx context.Context, namespace string, fieldSelector fields.Selector) (*queryV0.DataSourceConnectionList, error) {
ns, err := authlib.ParseNamespace(namespace)
if err != nil {
return nil, err
}
dss, err := q.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{
OrgID: ns.OrgID,
DataSourceLimit: 10000,
})
if err != nil {
return nil, err
var dss []*datasources.DataSource
// if fieldSelector is not nil, find any uids in the metadata.name field and
// use them in the query
if fieldSelector != nil && !fieldSelector.Empty() {
uids := []string{}
for _, req := range fieldSelector.Requirements() {
if req.Field == "metadata.name" {
uids = append(uids, req.Value)
}
}
// We don't have a way to fetch a subset of datasources by UID in the legacy
// datasource service, so fetch them one by one.
if len(uids) > 0 {
for _, uid := range uids {
ds, err := q.dsService.GetDataSource(ctx, &datasources.GetDataSourceQuery{
UID: uid,
OrgID: ns.OrgID,
})
if err != nil {
return nil, err
}
dss = append(dss, ds)
}
}
} else {
dss, err = q.dsService.GetDataSources(ctx, &datasources.GetDataSourcesQuery{
OrgID: ns.OrgID,
DataSourceLimit: 10000,
})
if err != nil {
return nil, err
}
}
result := &queryV0.DataSourceConnectionList{
Items: []queryV0.DataSourceConnection{},
}
@@ -0,0 +1,158 @@
package annotation
import (
"context"
"fmt"
"strings"
"sync"
"github.com/google/uuid"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
annotationV0 "github.com/grafana/grafana/apps/annotation/pkg/apis/annotation/v0alpha1"
)
type memoryStore struct {
mu sync.RWMutex
data map[string]*annotationV0.Annotation
}
func NewMemoryStore() Store {
return &memoryStore{
data: make(map[string]*annotationV0.Annotation),
}
}
func (m *memoryStore) Get(ctx context.Context, namespace, name string) (*annotationV0.Annotation, error) {
m.mu.RLock()
defer m.mu.RUnlock()
key := namespace + "/" + name
anno, ok := m.data[key]
if !ok {
return nil, fmt.Errorf("annotation not found")
}
return anno.DeepCopy(), nil
}
func (m *memoryStore) List(ctx context.Context, namespace string, opts ListOptions) (*AnnotationList, error) {
m.mu.RLock()
defer m.mu.RUnlock()
//nolint:prealloc
var result []annotationV0.Annotation // no, we can't pre-alloc it, we don't know the size yet
for _, anno := range m.data {
if anno.Namespace != namespace {
continue
}
if opts.DashboardUID != "" && (anno.Spec.DashboardUID == nil || *anno.Spec.DashboardUID != opts.DashboardUID) {
continue
}
if opts.PanelID != 0 && (anno.Spec.PanelID == nil || *anno.Spec.PanelID != opts.PanelID) {
continue
}
if opts.From > 0 && anno.Spec.Time < opts.From {
continue
}
if opts.To > 0 && anno.Spec.Time > opts.To {
continue
}
result = append(result, *anno.DeepCopy())
if opts.Limit > 0 && int64(len(result)) >= opts.Limit {
break
}
}
return &AnnotationList{Items: result}, nil
}
func (m *memoryStore) Create(ctx context.Context, anno *annotationV0.Annotation) (*annotationV0.Annotation, error) {
m.mu.Lock()
defer m.mu.Unlock()
if anno.Name == "" {
anno.Name = uuid.New().String()
}
key := anno.Namespace + "/" + anno.Name
if _, exists := m.data[key]; exists {
return nil, fmt.Errorf("annotation already exists")
}
created := anno.DeepCopy()
if created.CreationTimestamp.IsZero() {
created.CreationTimestamp = metav1.Now()
}
m.data[key] = created
return created, nil
}
func (m *memoryStore) Update(ctx context.Context, anno *annotationV0.Annotation) (*annotationV0.Annotation, error) {
m.mu.Lock()
defer m.mu.Unlock()
key := anno.Namespace + "/" + anno.Name
if _, exists := m.data[key]; !exists {
return nil, fmt.Errorf("annotation not found")
}
updated := anno.DeepCopy()
m.data[key] = updated
return updated, nil
}
func (m *memoryStore) Delete(ctx context.Context, namespace, name string) error {
m.mu.Lock()
defer m.mu.Unlock()
key := namespace + "/" + name
if _, exists := m.data[key]; !exists {
return fmt.Errorf("annotation not found")
}
delete(m.data, key)
return nil
}
func (m *memoryStore) ListTags(ctx context.Context, namespace string, opts TagListOptions) ([]Tag, error) {
m.mu.RLock()
defer m.mu.RUnlock()
tagCounts := make(map[string]int64)
for _, anno := range m.data {
if anno.Namespace != namespace {
continue
}
for _, tag := range anno.Spec.Tags {
if opts.Prefix == "" || strings.HasPrefix(tag, opts.Prefix) {
tagCounts[tag]++
}
}
}
tags := make([]Tag, 0, len(tagCounts))
for name, count := range tagCounts {
tags = append(tags, Tag{Name: name, Count: count})
}
if opts.Limit > 0 && len(tags) > opts.Limit {
tags = tags[:opts.Limit]
}
return tags, nil
}
+49 -122
View File
@@ -12,6 +12,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/selection"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
restclient "k8s.io/client-go/rest"
@@ -21,12 +22,11 @@ import (
"github.com/grafana/grafana/apps/annotation/pkg/apis"
annotationV0 "github.com/grafana/grafana/apps/annotation/pkg/apis/annotation/v0alpha1"
annotationapp "github.com/grafana/grafana/apps/annotation/pkg/app"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
apiserverrest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
grafrequest "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
)
@@ -46,15 +46,32 @@ func RegisterAppInstaller(
cfg *setting.Cfg,
features featuremgmt.FeatureToggles,
service annotations.Repository,
cleaner annotations.Cleaner,
) (*AnnotationAppInstaller, error) {
installer := &AnnotationAppInstaller{
cfg: cfg,
}
var tagHandler func(context.Context, app.CustomRouteResponseWriter, *app.CustomRouteRequest) error
if service != nil {
mapper := grafrequest.GetNamespaceMapper(cfg)
sqlAdapter := NewSQLAdapter(service, cleaner, mapper, cfg)
installer.legacy = &legacyStorage{
store: sqlAdapter,
mapper: mapper,
}
// Create the tags handler using the sqlAdapter as TagProvider
tagHandler = newTagsHandler(sqlAdapter)
}
provider := simple.NewAppProvider(apis.LocalManifest(), nil, annotationapp.New)
appConfig := app.Config{
KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method
KubeConfig: restclient.Config{},
ManifestData: *apis.LocalManifest().ManifestData,
SpecificConfig: &annotationapp.AnnotationConfig{
TagHandler: tagHandler,
},
}
i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, apis.NewGoTypeAssociator())
if err != nil {
@@ -62,13 +79,6 @@ func RegisterAppInstaller(
}
installer.AppInstaller = i
if service != nil {
installer.legacy = &legacyStorage{
service: service,
namespacer: request.GetNamespaceMapper(cfg),
}
}
return installer, nil
}
@@ -79,9 +89,11 @@ func (a *AnnotationAppInstaller) GetLegacyStorage(requested schema.GroupVersionR
Version: kind.Version(),
Resource: kind.Plural(),
}
if requested.String() != gvr.String() {
return nil
}
a.legacy.tableConverter = utils.NewTableConverter(
gvr.GroupResource(),
utils.TableColumns{
@@ -114,8 +126,8 @@ var (
)
type legacyStorage struct {
service annotations.Repository
namespacer request.NamespaceMapper
store Store
mapper grafrequest.NamespaceMapper
tableConverter rest.TableConvertor
}
@@ -142,21 +154,15 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec
}
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
orgID, err := request.OrgIDForList(ctx)
if err != nil {
return nil, err
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
query := &annotations.ItemQuery{OrgID: orgID, SignedInUser: user, AlertID: -1}
namespace := request.NamespaceValue(ctx)
opts := ListOptions{}
if options.FieldSelector != nil {
for _, r := range options.FieldSelector.Requirements() {
switch r.Field {
case "spec.dashboardUID":
if r.Operator == selection.Equals || r.Operator == selection.DoubleEquals {
query.DashboardUID = r.Value
opts.DashboardUID = r.Value
} else {
return nil, fmt.Errorf("unsupported operator %s for spec.dashboardUID (only = supported)", r.Operator)
}
@@ -167,7 +173,7 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
if err != nil {
return nil, fmt.Errorf("invalid panelID value %q: %w", r.Value, err)
}
query.PanelID = panelID
opts.PanelID = panelID
} else {
return nil, fmt.Errorf("unsupported operator %s for spec.panelID (only = supported)", r.Operator)
}
@@ -178,13 +184,13 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
if err != nil {
return nil, fmt.Errorf("invalid time value %q: %w", r.Value, err)
}
query.From = from
opts.From = from
case selection.LessThan:
to, err := strconv.ParseInt(r.Value, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid time value %q: %w", r.Value, err)
}
query.To = to
opts.To = to
default:
return nil, fmt.Errorf("unsupported operator %s for spec.time (only >, < supported for ranges)", r.Operator)
}
@@ -196,13 +202,13 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
if err != nil {
return nil, fmt.Errorf("invalid timeEnd value %q: %w", r.Value, err)
}
query.From = from
opts.From = from
case selection.LessThan:
to, err := strconv.ParseInt(r.Value, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid timeEnd value %q: %w", r.Value, err)
}
query.To = to
opts.To = to
default:
return nil, fmt.Errorf("unsupported operator %s for spec.timeEnd (only >, < supported for ranges)", r.Operator)
}
@@ -213,31 +219,22 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
}
}
query.Limit = 100
opts.Limit = 100
if options.Limit > 0 {
query.Limit = options.Limit
opts.Limit = options.Limit
}
items, err := s.service.Find(ctx, query)
result, err := s.store.List(ctx, namespace, opts)
if err != nil {
return nil, err
}
list := &annotationV0.AnnotationList{
Items: make([]annotationV0.Annotation, len(items)),
}
for i, item := range items {
c, err := toK8sResource(orgID, item, s.namespacer)
if err != nil {
return nil, err
}
list.Items[i] = *c
}
// TODO: pagination?
return list, nil
return &annotationV0.AnnotationList{Items: result.Items}, nil
}
func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
return nil, errors.New("fetching single annotations not supported by legacy storage")
namespace := request.NamespaceValue(ctx)
return s.store.Get(ctx, namespace, name)
}
func (s *legacyStorage) Create(ctx context.Context,
@@ -245,22 +242,11 @@ func (s *legacyStorage) Create(ctx context.Context,
createValidation rest.ValidateObjectFunc,
options *metav1.CreateOptions,
) (runtime.Object, error) {
return nil, errors.New("not implemented")
// resource, ok := obj.(*correlationsV0.Correlation)
// if !ok {
// return nil, fmt.Errorf("expected correlation")
// }
//
// cmd, err := correlations.ToCreateCorrelationCommand(resource)
// if err != nil {
// return nil, err
// }
//
// out, err := s.service.CreateCorrelation(ctx, *cmd)
// if err != nil {
// return nil, err
// }
// return s.Get(ctx, out.UID, &metav1.GetOptions{})
resource, ok := obj.(*annotationV0.Annotation)
if !ok {
return nil, fmt.Errorf("expected annotation")
}
return s.store.Create(ctx, resource)
}
func (s *legacyStorage) Update(ctx context.Context,
@@ -272,73 +258,14 @@ func (s *legacyStorage) Update(ctx context.Context,
options *metav1.UpdateOptions,
) (runtime.Object, bool, error) {
return nil, false, errors.New("not implemented")
// before, err := s.Get(ctx, name, &metav1.GetOptions{})
// if err != nil {
// return nil, false, err
// }
// obj, err := objInfo.UpdatedObject(ctx, before)
// if err != nil {
// return nil, false, err
// }
//
// resource, ok := obj.(*correlationsV0.Correlation)
// if !ok {
// return nil, false, fmt.Errorf("expected correlation")
// }
//
// cmd, err := correlations.ToUpdateCorrelationCommand(resource)
// if err != nil {
// return nil, false, err
// }
//
// out, err := s.service.UpdateCorrelation(ctx, *cmd)
// if err != nil {
// return nil, false, err
// }
// obj, err = s.Get(ctx, out.UID, &metav1.GetOptions{})
// return obj, false, err
}
// GracefulDeleter
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
return nil, false, errors.New("not implemented")
// orgID, err := request.OrgIDForList(ctx)
// if err != nil {
// return nil, false, err
// }
// err = s.service.DeleteCorrelation(ctx, correlations.DeleteCorrelationCommand{
// OrgId: orgID,
// UID: name,
// })
// return nil, (err == nil), err
namespace := request.NamespaceValue(ctx)
err := s.store.Delete(ctx, namespace, name)
return nil, false, err
}
// CollectionDeleter
func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
return nil, fmt.Errorf("DeleteCollection for annotation not implemented")
}
func toK8sResource(orgID int64, item *annotations.ItemDTO, namespacer request.NamespaceMapper) (*annotationV0.Annotation, error) {
annotation := &annotationV0.Annotation{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("a-%d", item.ID), // FIXME
Namespace: namespacer(orgID),
},
Spec: annotationV0.AnnotationSpec{
Text: item.Text,
Time: item.Time,
Tags: item.Tags,
},
}
if item.DashboardUID != nil && *item.DashboardUID != "" {
annotation.Spec.DashboardUID = item.DashboardUID
}
if item.PanelID != 0 {
annotation.Spec.PanelID = &item.PanelID
}
if item.TimeEnd != 0 {
annotation.Spec.TimeEnd = &item.TimeEnd
}
return annotation, nil
return nil, fmt.Errorf("DeleteCollection for annotation is not available")
}
+255
View File
@@ -0,0 +1,255 @@
package annotation
import (
"context"
"fmt"
"strconv"
claims "github.com/grafana/authlib/types"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
annotationV0 "github.com/grafana/grafana/apps/annotation/pkg/apis/annotation/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/setting"
)
type sqlAdapter struct {
repo annotations.Repository
cleaner annotations.Cleaner
nsMapper request.NamespaceMapper
cfg *setting.Cfg
}
func NewSQLAdapter(repo annotations.Repository, cleaner annotations.Cleaner, nsMapper request.NamespaceMapper, cfg *setting.Cfg) *sqlAdapter {
return &sqlAdapter{
repo: repo,
cleaner: cleaner,
nsMapper: nsMapper,
cfg: cfg,
}
}
func (a *sqlAdapter) Get(ctx context.Context, namespace, name string) (*annotationV0.Annotation, error) {
id, err := parseAnnotationID(name)
if err != nil {
return nil, err
}
orgID, err := namespaceToOrgID(ctx, namespace)
if err != nil {
return nil, err
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
query := &annotations.ItemQuery{
SignedInUser: user,
OrgID: orgID,
Limit: 1000,
AlertID: -1,
}
items, err := a.repo.Find(ctx, query)
if err != nil {
return nil, err
}
for _, item := range items {
if item.ID == id {
return a.toK8sResource(item, namespace), nil
}
}
return nil, fmt.Errorf("annotation not found")
}
func (a *sqlAdapter) List(ctx context.Context, namespace string, opts ListOptions) (*AnnotationList, error) {
orgID, err := namespaceToOrgID(ctx, namespace)
if err != nil {
return nil, err
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
query := &annotations.ItemQuery{
SignedInUser: user,
OrgID: orgID,
DashboardUID: opts.DashboardUID,
PanelID: opts.PanelID,
From: opts.From,
To: opts.To,
Limit: opts.Limit,
AlertID: -1,
}
items, err := a.repo.Find(ctx, query)
if err != nil {
return nil, err
}
result := make([]annotationV0.Annotation, 0, len(items))
for _, item := range items {
result = append(result, *a.toK8sResource(item, namespace))
}
return &AnnotationList{
Items: result,
Continue: "",
}, nil
}
func (a *sqlAdapter) Create(ctx context.Context, anno *annotationV0.Annotation) (*annotationV0.Annotation, error) {
orgID, err := namespaceToOrgID(ctx, anno.Namespace)
if err != nil {
return nil, err
}
item := a.fromK8sResource(anno)
item.OrgID = orgID
if err := a.repo.Save(ctx, item); err != nil {
return nil, err
}
created := anno.DeepCopy()
created.Name = fmt.Sprintf("a-%d", item.ID)
return created, nil
}
func (a *sqlAdapter) Update(ctx context.Context, anno *annotationV0.Annotation) (*annotationV0.Annotation, error) {
orgID, err := namespaceToOrgID(ctx, anno.Namespace)
if err != nil {
return nil, err
}
item := a.fromK8sResource(anno)
item.OrgID = orgID
if err := a.repo.Update(ctx, item); err != nil {
return nil, err
}
return anno, nil
}
func (a *sqlAdapter) Delete(ctx context.Context, namespace, name string) error {
id, err := parseAnnotationID(name)
if err != nil {
return err
}
orgID, err := namespaceToOrgID(ctx, namespace)
if err != nil {
return err
}
return a.repo.Delete(ctx, &annotations.DeleteParams{
ID: id,
OrgID: orgID,
})
}
func (a *sqlAdapter) Cleanup(ctx context.Context) (int64, error) {
if a.cleaner == nil {
return 0, nil
}
deleted, _, err := a.cleaner.Run(ctx, a.cfg)
return deleted, err
}
func (a *sqlAdapter) ListTags(ctx context.Context, namespace string, opts TagListOptions) ([]Tag, error) {
orgID, err := namespaceToOrgID(ctx, namespace)
if err != nil {
return nil, err
}
query := &annotations.TagsQuery{
OrgID: orgID,
Limit: int64(opts.Limit),
Tag: opts.Prefix,
}
result, err := a.repo.FindTags(ctx, query)
if err != nil {
return nil, err
}
tags := make([]Tag, len(result.Tags))
for i, t := range result.Tags {
tags[i] = Tag{Name: t.Tag, Count: t.Count}
}
return tags, nil
}
func (a *sqlAdapter) toK8sResource(item *annotations.ItemDTO, namespace string) *annotationV0.Annotation {
anno := &annotationV0.Annotation{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("a-%d", item.ID),
Namespace: namespace,
},
Spec: annotationV0.AnnotationSpec{
Text: item.Text,
Time: item.Time,
Tags: item.Tags,
},
}
if item.DashboardUID != nil && *item.DashboardUID != "" {
anno.Spec.DashboardUID = item.DashboardUID
}
if item.PanelID != 0 {
anno.Spec.PanelID = &item.PanelID
}
if item.TimeEnd != 0 {
anno.Spec.TimeEnd = &item.TimeEnd
}
return anno
}
func (a *sqlAdapter) fromK8sResource(anno *annotationV0.Annotation) *annotations.Item {
item := &annotations.Item{
Text: anno.Spec.Text,
Epoch: anno.Spec.Time,
Tags: anno.Spec.Tags,
}
if anno.Name != "" {
if id, err := parseAnnotationID(anno.Name); err == nil {
item.ID = id
}
}
if anno.Spec.DashboardUID != nil {
item.DashboardUID = *anno.Spec.DashboardUID
}
if anno.Spec.PanelID != nil {
item.PanelID = *anno.Spec.PanelID
}
if anno.Spec.TimeEnd != nil {
item.EpochEnd = *anno.Spec.TimeEnd
}
return item
}
func parseAnnotationID(name string) (int64, error) {
if len(name) < 3 || name[:2] != "a-" {
return 0, fmt.Errorf("invalid annotation name format: %s", name)
}
return strconv.ParseInt(name[2:], 10, 64)
}
func namespaceToOrgID(ctx context.Context, namespace string) (int64, error) {
info, err := claims.ParseNamespace(namespace)
return info.OrgID, err
}
+47
View File
@@ -0,0 +1,47 @@
package annotation
import (
"context"
annotationV0 "github.com/grafana/grafana/apps/annotation/pkg/apis/annotation/v0alpha1"
)
type Store interface {
Get(ctx context.Context, namespace, name string) (*annotationV0.Annotation, error)
List(ctx context.Context, namespace string, opts ListOptions) (*AnnotationList, error)
Create(ctx context.Context, annotation *annotationV0.Annotation) (*annotationV0.Annotation, error)
Update(ctx context.Context, annotation *annotationV0.Annotation) (*annotationV0.Annotation, error)
Delete(ctx context.Context, namespace, name string) error
}
type ListOptions struct {
DashboardUID string
PanelID int64
From int64
To int64
Limit int64
Continue string
}
type AnnotationList struct {
Items []annotationV0.Annotation
Continue string
}
type LifecycleManager interface {
Cleanup(ctx context.Context) (int64, error)
}
type TagProvider interface {
ListTags(ctx context.Context, namespace string, opts TagListOptions) ([]Tag, error)
}
type TagListOptions struct {
Prefix string
Limit int
}
type Tag struct {
Name string
Count int64
}
@@ -0,0 +1,45 @@
package annotation
import (
"context"
"encoding/json"
"fmt"
"github.com/grafana/grafana-app-sdk/app"
)
type tagResponse struct {
Tags []tagItem `json:"tags"`
}
type tagItem struct {
Tag string `json:"tag"`
Count int64 `json:"count"`
}
func newTagsHandler(tagProvider TagProvider) func(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
return func(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
fmt.Println("Handling /tags request")
namespace := request.ResourceIdentifier.Namespace
if namespace == "" {
namespace = "default"
}
tags, err := tagProvider.ListTags(ctx, namespace, TagListOptions{})
if err != nil {
return err
}
items := make([]tagItem, len(tags))
for i, tag := range tags {
items[i] = tagItem{
Tag: tag.Name,
Count: tag.Count,
}
}
response := tagResponse{
Tags: items,
}
return json.NewEncoder(writer).Encode(response)
}
}
+2
View File
@@ -88,6 +88,7 @@ import (
"github.com/grafana/grafana/pkg/services/datasourceproxy"
"github.com/grafana/grafana/pkg/services/datasources"
datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service"
datasourcesclient "github.com/grafana/grafana/pkg/services/datasources/service/client"
"github.com/grafana/grafana/pkg/services/dsquerierclient"
"github.com/grafana/grafana/pkg/services/encryption"
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
@@ -476,6 +477,7 @@ var wireBasicSet = wire.NewSet(
appregistry.WireSet,
// Dashboard Kubernetes helpers
dashboardclient.ProvideK8sClientWithFallback,
datasourcesclient.ProvideDataSourceConnectionClientFactory,
)
var wireSet = wire.NewSet(
+8 -5
View File
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@ import (
authzv1 "github.com/grafana/authlib/authz/proto/v1"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"go.opentelemetry.io/otel/codes"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
@@ -15,6 +16,8 @@ func (s *Server) BatchCheck(ctx context.Context, r *authzextv1.BatchCheckRequest
defer span.End()
if err := authorize(ctx, r.GetNamespace(), s.cfg); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
@@ -24,11 +27,15 @@ func (s *Server) BatchCheck(ctx context.Context, r *authzextv1.BatchCheckRequest
store, err := s.getStoreInfo(ctx, r.GetNamespace())
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
contextuals, err := s.getContextuals(r.GetSubject())
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
@@ -37,6 +44,8 @@ func (s *Server) BatchCheck(ctx context.Context, r *authzextv1.BatchCheckRequest
for _, item := range r.GetItems() {
res, err := s.batchCheckItem(ctx, r, item, contextuals, store, groupResourceAccess)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
@@ -9,6 +9,7 @@ import (
authzv1 "github.com/grafana/authlib/authz/proto/v1"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"google.golang.org/protobuf/types/known/structpb"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
@@ -25,6 +26,8 @@ func (s *Server) Check(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.C
res, err := s.check(ctx, r)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.Error("failed to perform check request", "error", err, "namespace", r.GetNamespace())
return nil, errors.New("failed to perform check request")
}
@@ -13,6 +13,7 @@ import (
authzv1 "github.com/grafana/authlib/authz/proto/v1"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
@@ -28,6 +29,8 @@ func (s *Server) List(ctx context.Context, r *authzv1.ListRequest) (*authzv1.Lis
res, err := s.list(ctx, r)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.Error("failed to perform list request", "error", err, "namespace", r.GetNamespace())
return nil, errors.New("failed to perform list request")
}
@@ -7,6 +7,7 @@ import (
"time"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"go.opentelemetry.io/otel/codes"
)
type OperationGroup string
@@ -30,6 +31,8 @@ func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
res, err := s.mutate(ctx, req)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.Error("failed to perform mutate request", "error", err, "namespace", req.GetNamespace())
return nil, errors.New("failed to perform mutate request")
}
@@ -8,6 +8,7 @@ import (
"time"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"go.opentelemetry.io/otel/codes"
)
func (s *Server) Query(ctx context.Context, req *authzextv1.QueryRequest) (*authzextv1.QueryResponse, error) {
@@ -20,6 +21,8 @@ func (s *Server) Query(ctx context.Context, req *authzextv1.QueryRequest) (*auth
res, err := s.query(ctx, req)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.Error("failed to perform query request", "error", err, "namespace", req.GetNamespace())
return nil, errors.New("failed to perform query request")
}
@@ -7,6 +7,7 @@ import (
"time"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"go.opentelemetry.io/otel/codes"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
@@ -22,6 +23,8 @@ func (s *Server) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authze
res, err := s.read(ctx, req)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.Error("failed to perform read request", "error", err, "namespace", req.GetNamespace())
return nil, errors.New("failed to perform read request")
}
@@ -7,6 +7,7 @@ import (
"time"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"go.opentelemetry.io/otel/codes"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
@@ -22,6 +23,8 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth
res, err := s.write(ctx, req)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.Error("failed to perform write request", "error", err, "namespace", req.GetNamespace())
return nil, errors.New("failed to perform write request")
}
@@ -0,0 +1,147 @@
package client
import (
"context"
"errors"
"net/http"
datasourcev0alpha1 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
queryv0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver"
"k8s.io/client-go/kubernetes"
)
// DataSourceConnectionClient can get information about data source connections.
//
//go:generate mockery --name DataSourceConnectionClient --structname MockDataSourceConnectionClient --inpackage --filename=client_mock.go --with-expecter
type DataSourceConnectionClient interface {
GetByUID(ctx context.Context, uid string) (*queryv0alpha1.DataSourceConnection, error)
}
func ProvideDataSourceConnectionClientFactory(
restConfigProvider apiserver.RestConfigProvider,
) DataSourceConnectionClientFactory {
return func(configProvider apiserver.RestConfigProvider) DataSourceConnectionClient {
return &dataSourceConnectionClient{
configProvider: configProvider,
}
}
}
type DataSourceConnectionClientFactory func(configProvider apiserver.RestConfigProvider) DataSourceConnectionClient
type dataSourceConnectionClient struct {
configProvider apiserver.RestConfigProvider
}
func (dc *dataSourceConnectionClient) Get(ctx context.Context, group, version, name string) (*queryv0alpha1.DataSourceConnection, error) {
cfg, err := dc.configProvider.GetRestConfig(ctx)
if err != nil {
return nil, err
}
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
if version == "" {
version = "v0alpha1"
}
result := client.RESTClient().Get().
Prefix("apis", group, version).
Namespace("default"). // TODO do something about namespace
Resource("datasources").
Name(name).
Do(ctx)
if err = result.Error(); err != nil {
return nil, err
}
var statusCode int
result = result.StatusCode(&statusCode)
if statusCode == http.StatusNotFound {
return nil, errors.New("not found")
}
fullDS := datasourcev0alpha1.DataSource{}
err = result.Into(&fullDS)
if err != nil {
return nil, err
}
dsConnection := &queryv0alpha1.DataSourceConnection{
Title: fullDS.Spec.Title(),
Datasource: queryv0alpha1.DataSourceConnectionRef{
Group: fullDS.GroupVersionKind().Group,
Name: fullDS.ObjectMeta.Name,
Version: fullDS.GroupVersionKind().Version,
},
}
return dsConnection, nil
}
func (dc *dataSourceConnectionClient) GetByUID(ctx context.Context, uid string) (*queryv0alpha1.DataSourceConnection, error) {
cfg, err := dc.configProvider.GetRestConfig(ctx)
if err != nil {
return nil, err
}
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
// use the list endpoint with a fieldSelector so that can get multiple results
// in the case of a non-unique "uid". This should not be possible when we are
// backed by the legacy database, but wont be guaranteed when we are using
// uniStore as the names will not be guaranteed unique across apiGroups. We
// error below if more than one result is returned.
result := client.RESTClient().Get().
Prefix("apis", "query.grafana.app", "v0alpha1").
Namespace("default"). // TODO do something about namespace
Resource("connections").
Param("fieldSelector", "metadata.name="+uid).
Do(ctx)
if err = result.Error(); err != nil {
return nil, err
}
var statusCode int
result = result.StatusCode(&statusCode)
if statusCode == http.StatusNotFound {
return nil, errors.New("not found")
}
dsList := datasourcev0alpha1.DataSourceList{}
err = result.Into(&dsList)
if err != nil {
return nil, err
}
if len(dsList.Items) == 0 {
return nil, errors.New("not found")
}
if len(dsList.Items) > 1 {
return nil, errors.New("multiple connections found")
}
fullDS := dsList.Items[0]
dsConnection := &queryv0alpha1.DataSourceConnection{
Title: fullDS.Spec.Title(),
Datasource: queryv0alpha1.DataSourceConnectionRef{
Group: fullDS.GroupVersionKind().Group,
Name: fullDS.ObjectMeta.Name,
Version: fullDS.GroupVersionKind().Version,
},
}
return dsConnection, nil
}
@@ -0,0 +1,96 @@
// Code generated by mockery v2.53.3. DO NOT EDIT.
package client
import (
context "context"
v0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockDataSourceConnectionClient is an autogenerated mock type for the DataSourceConnectionClient type
type MockDataSourceConnectionClient struct {
mock.Mock
}
type MockDataSourceConnectionClient_Expecter struct {
mock *mock.Mock
}
func (_m *MockDataSourceConnectionClient) EXPECT() *MockDataSourceConnectionClient_Expecter {
return &MockDataSourceConnectionClient_Expecter{mock: &_m.Mock}
}
// GetByUID provides a mock function with given fields: ctx, uid
func (_m *MockDataSourceConnectionClient) GetByUID(ctx context.Context, uid string) (*v0alpha1.DataSourceConnection, error) {
ret := _m.Called(ctx, uid)
if len(ret) == 0 {
panic("no return value specified for GetByUID")
}
var r0 *v0alpha1.DataSourceConnection
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*v0alpha1.DataSourceConnection, error)); ok {
return rf(ctx, uid)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *v0alpha1.DataSourceConnection); ok {
r0 = rf(ctx, uid)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v0alpha1.DataSourceConnection)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, uid)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockDataSourceConnectionClient_GetByUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByUID'
type MockDataSourceConnectionClient_GetByUID_Call struct {
*mock.Call
}
// GetByUID is a helper method to define mock.On call
// - ctx context.Context
// - uid string
func (_e *MockDataSourceConnectionClient_Expecter) GetByUID(ctx interface{}, uid interface{}) *MockDataSourceConnectionClient_GetByUID_Call {
return &MockDataSourceConnectionClient_GetByUID_Call{Call: _e.mock.On("GetByUID", ctx, uid)}
}
func (_c *MockDataSourceConnectionClient_GetByUID_Call) Run(run func(ctx context.Context, uid string)) *MockDataSourceConnectionClient_GetByUID_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *MockDataSourceConnectionClient_GetByUID_Call) Return(_a0 *v0alpha1.DataSourceConnection, _a1 error) *MockDataSourceConnectionClient_GetByUID_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockDataSourceConnectionClient_GetByUID_Call) RunAndReturn(run func(context.Context, string) (*v0alpha1.DataSourceConnection, error)) *MockDataSourceConnectionClient_GetByUID_Call {
_c.Call.Return(run)
return _c
}
// NewMockDataSourceConnectionClient creates a new instance of MockDataSourceConnectionClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockDataSourceConnectionClient(t interface {
mock.TestingT
Cleanup(func())
}) *MockDataSourceConnectionClient {
mock := &MockDataSourceConnectionClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -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 {
+3
View File
@@ -107,6 +107,9 @@ func StartGrafanaEnvWithDB(t *testing.T, grafDir, cfgPath string) (string, *serv
dbCfg.Key("user").SetValue(testDB.User)
dbCfg.Key("password").SetValue(testDB.Password)
dbCfg.Key("name").SetValue(testDB.Database)
if testDB.Path != "" {
dbCfg.Key("path").SetValue(testDB.Path)
}
t.Log("Using test database", "type", testDB.DriverName, "host", testDB.Host, "port", testDB.Port, "user", testDB.User, "name", testDB.Database, "path", testDB.Path)
+13 -4
View File
@@ -103,10 +103,17 @@ const combineFolderResponses = (
export async function getFolderByUidFacade(uid: string): Promise<FolderDTO> {
const isVirtualFolder = uid && [GENERAL_FOLDER_UID, config.sharedWithMeFolderUID].includes(uid);
// We need the legacy API call regardless, for now
const legacyApiCall = dispatch(browseDashboardsAPI.endpoints.getFolder.initiate(uid));
const shouldUseAppPlatformAPI = Boolean(config.featureToggles.foldersAppPlatformAPI);
// We need the legacy API call regardless, for now
const legacyApiCall = dispatch(
browseDashboardsAPI.endpoints.getFolder.initiate({
folderUID: uid,
accesscontrol: true,
isLegacyCall: shouldUseAppPlatformAPI,
})
);
if (shouldUseAppPlatformAPI) {
let virtualFolderResponse;
if (isVirtualFolder) {
@@ -165,7 +172,9 @@ export function useGetFolderQueryFacade(uid?: string) {
// This may look weird that we call the legacy folder anyway all the time, but the issue is we don't have good API
// for the access control metadata yet, and so we still take it from the old api.
// see https://github.com/grafana/identity-access-team/issues/1103
const legacyFolderResult = useGetFolderQueryLegacy(uid || skipToken);
const legacyFolderResult = useGetFolderQueryLegacy(
uid ? { folderUID: uid, accesscontrol: true, isLegacyCall: true } : skipToken
);
let resultFolder = useGetFolderQuery(shouldUseAppPlatformAPI && !isVirtualFolder ? params : skipToken);
// We get parents and folders for virtual folders too. Parents should just return empty array but it's easier to
// stitch the responses this way and access can actually return different response based on the grafana setup.
@@ -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':
@@ -94,9 +94,17 @@ export const browseDashboardsAPI = createApi({
}),
// get folder info (e.g. title, parents) but *not* children
getFolder: builder.query<FolderDTO, string>({
providesTags: (_result, _error, folderUID) => [{ type: 'getFolder', id: folderUID }],
query: (folderUID) => ({ url: `/folders/${folderUID}`, params: { accesscontrol: true } }),
getFolder: builder.query<FolderDTO, { folderUID: string; accesscontrol: boolean; isLegacyCall: boolean }>({
providesTags: (_result, _error, { folderUID }) => [{ type: 'getFolder', id: folderUID }],
query: ({ folderUID, accesscontrol, isLegacyCall }) => ({
url: `/folders/${folderUID}`,
params: {
accesscontrol,
// Add additional query param so we can tell when
// this was called for app platform compatibility purposes vs. actually needing to use the legacy API
isLegacyCall,
},
}),
}),
// create a new folder
@@ -10,10 +10,13 @@ import { CacheFeatureHighlightPage } from './pages/CacheFeatureHighlightPage';
import ConnectionsHomePage from './pages/ConnectionsHomePage';
import { DataSourceDashboardsPage } from './pages/DataSourceDashboardsPage';
import { DataSourceDetailsPage } from './pages/DataSourceDetailsPage';
import { DataSourceStacksPage } from './pages/DataSourceStacksPage';
import { DataSourcesListPage } from './pages/DataSourcesListPage';
import { EditDataSourcePage } from './pages/EditDataSourcePage';
import { EditStackPage } from './pages/EditStackPage';
import { InsightsFeatureHighlightPage } from './pages/InsightsFeatureHighlightPage';
import { NewDataSourcePage } from './pages/NewDataSourcePage';
import { NewStackPage } from './pages/NewStackPage';
import { PermissionsFeatureHighlightPage } from './pages/PermissionsFeatureHighlightPage';
function RedirectToAddNewConnection() {
@@ -41,6 +44,9 @@ export default function Connections() {
{/* The route paths need to be relative to the parent path (ROUTES.Base), so we need to remove that part */}
<Route caseSensitive path={ROUTES.DataSources.replace(ROUTES.Base, '')} element={<DataSourcesListPage />} />
<Route caseSensitive path={ROUTES.DataSourcesNew.replace(ROUTES.Base, '')} element={<NewDataSourcePage />} />
<Route caseSensitive path={ROUTES.Stacks.replace(ROUTES.Base, '')} element={<DataSourceStacksPage />} />
<Route caseSensitive path={ROUTES.StacksNew.replace(ROUTES.Base, '')} element={<NewStackPage />} />
<Route caseSensitive path={ROUTES.StacksEdit.replace(ROUTES.Base, '')} element={<EditStackPage />} />
<Route
caseSensitive
path={ROUTES.DataSourcesDetails.replace(ROUTES.Base, '')}
@@ -75,5 +75,11 @@ export function getOssCardData(): CardData[] {
url: '/connections/datasources',
icon: 'database',
},
{
text: 'Stacks',
subTitle: 'Manage your data source stacks',
url: '/connections/stacks',
icon: 'layers',
},
];
}
@@ -9,6 +9,10 @@ export const ROUTES = {
DataSourcesNew: `/${ROUTE_BASE_ID}/datasources/new`,
DataSourcesEdit: `/${ROUTE_BASE_ID}/datasources/edit/:uid`,
DataSourcesDashboards: `/${ROUTE_BASE_ID}/datasources/edit/:uid/dashboards`,
// Stacks
Stacks: `/${ROUTE_BASE_ID}/stacks`,
StacksNew: `/${ROUTE_BASE_ID}/stacks/new`,
StacksEdit: `/${ROUTE_BASE_ID}/stacks/edit/:uid`,
// Add new connection
AddNewConnection: `/${ROUTE_BASE_ID}/add-new-connection`,
@@ -0,0 +1,252 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import {
Card,
EmptyState,
FilterInput,
IconButton,
LinkButton,
Spinner,
Stack,
TagList,
useStyles2,
} from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { Resource, ResourceList, GroupVersionResource } from 'app/features/apiserver/types';
// Define the DataSourceStack spec type based on the backend Go types
export interface DataSourceStackTemplateItem {
group: string;
name: string;
}
export interface DataSourceStackModeItem {
dataSourceRef: string;
}
export interface DataSourceStackModeSpec {
name: string;
uid: string;
definition: Record<string, DataSourceStackModeItem>;
}
export interface DataSourceStackSpec {
template: Record<string, DataSourceStackTemplateItem>;
modes: DataSourceStackModeSpec[];
}
// GroupVersionResource for datasourcestacks
const datasourceStacksGVR: GroupVersionResource = {
group: 'collections.grafana.app',
version: 'v1alpha1',
resource: 'datasourcestacks',
};
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
export function DataSourceStacksPage() {
const [stacks, setStacks] = useState<Array<Resource<DataSourceStackSpec>>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const styles = useStyles2(getStyles);
const fetchStacks = useCallback(async () => {
try {
setLoading(true);
const response: ResourceList<DataSourceStackSpec> = await datasourceStacksClient.list();
setStacks(response.items);
} catch (err) {
console.error('Failed to fetch datasource stacks:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch datasource stacks');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStacks();
}, [fetchStacks]);
const onDeleteStack = (stackName: string) => async () => {
await datasourceStacksClient.delete(stackName, false);
fetchStacks();
};
// Filter stacks based on search query
const filteredStacks = useMemo(() => {
if (!searchQuery) {
return stacks;
}
const query = searchQuery.toLowerCase();
return stacks.filter((stack) => {
const nameMatch = stack.metadata.name?.toLowerCase().includes(query);
const templateMatch = Object.values(stack.spec.template).some(
(template) => template.name.toLowerCase().includes(query) || template.group.toLowerCase().includes(query)
);
return nameMatch || templateMatch;
});
}, [stacks, searchQuery]);
const actions =
stacks.length > 0 ? (
<LinkButton variant="primary" icon="plus" href="/connections/stacks/new">
<Trans i18nKey="connections.stacks-list-view.add-stack">Add stack</Trans>
</LinkButton>
) : undefined;
const pageNav = {
text: t('connections.stacks-list-view.title', 'Data source stacks'),
subTitle: t(
'connections.stacks-list-view.subtitle',
'Manage your data source stacks to group environments like dev, staging, and production'
),
};
return (
<Page navId="connections-stacks" pageNav={pageNav} actions={actions}>
<Page.Contents>
<DataSourceStacksListContent
stacks={filteredStacks}
loading={loading}
error={error}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
onDeleteStack={onDeleteStack}
styles={styles}
/>
</Page.Contents>
</Page>
);
}
interface DataSourceStacksListContentProps {
stacks: Array<Resource<DataSourceStackSpec>>;
loading: boolean;
error: string | null;
searchQuery: string;
setSearchQuery: (query: string) => void;
styles: ReturnType<typeof getStyles>;
onDeleteStack: (stackName: string) => () => Promise<void>;
}
function DataSourceStacksListContent({
stacks,
loading,
error,
searchQuery,
setSearchQuery,
styles,
onDeleteStack,
}: DataSourceStacksListContentProps) {
if (loading) {
return <Spinner />;
}
if (error) {
return (
<EmptyState
variant="not-found"
message={t('connections.stacks-list-view.error', 'Failed to load data source stacks')}
>
<div>{error}</div>
</EmptyState>
);
}
if (stacks.length === 0 && !searchQuery) {
return (
<EmptyState
message={t(
'connections.stacks-list-view.empty.no-rules-created',
"You haven't created any data source stacks yet"
)}
variant="call-to-action"
>
<div>
<Trans i18nKey="connections.stacks-list-view.empty.description">
Use data source stacks to group environments like dev, stg, and prod. Reference the stack in your query, and
Grafana automatically selects the right data source for that environment.
</Trans>
</div>
<LinkButton variant="primary" icon="plus" size="lg" href="/connections/stacks/new">
<Trans i18nKey="connections.stacks-list-view.empty.new-stack">New stack</Trans>
</LinkButton>
</EmptyState>
);
}
return (
<Stack direction="column" gap={2}>
<div className={styles.searchContainer}>
<FilterInput
value={searchQuery}
onChange={setSearchQuery}
placeholder={t('connections.stacks-list-view.search-placeholder', 'Search by name or type')}
/>
</div>
{stacks.length === 0 && searchQuery ? (
<EmptyState
variant="not-found"
message={t('connections.stacks-list-view.no-results', 'No data source stacks found')}
/>
) : (
<ul className={styles.list}>
{stacks.map((stack) => (
<li key={stack.metadata.name}>
<Card noMargin href={`/connections/stacks/edit/${stack.metadata.name}`}>
<Card.Heading>{stack.metadata.name}</Card.Heading>
<Card.Tags>
<Stack direction="row" gap={2} alignItems="center">
<TagList tags={getDatasourceList(stack.spec)} />
<IconButton
name="trash-alt"
variant="destructive"
aria-label={t('connections.stacks-list-view.delete-stack', 'Delete stack')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeleteStack(stack.metadata.name)();
}}
/>
</Stack>
</Card.Tags>
</Card>
</li>
))}
</ul>
)}
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
searchContainer: css({
marginBottom: theme.spacing(2),
maxWidth: '500px',
}),
list: css({
listStyle: 'none',
display: 'grid',
gap: theme.spacing(1),
}),
});
const getDatasourceList = (stack: DataSourceStackSpec): string[] => {
return Array.from(
// remove duplicates
new Set(
Object.values(stack.template).map((template) => {
const match = template.group.match(/^grafana-(.+)-datasource$/);
if (match && match[1]) {
return match[1].charAt(0).toUpperCase() + match[1].slice(1);
}
return template.name.charAt(0).toUpperCase() + template.name.slice(1);
})
)
);
};
@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { t } from '@grafana/i18n';
import { EmptyState, Spinner } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { Resource, GroupVersionResource } from 'app/features/apiserver/types';
import {
StackForm,
transformStackSpecToFormValues,
} from 'app/features/datasources/components/new-stack-form/StackForm';
import { StackFormValues } from 'app/features/datasources/components/new-stack-form/types';
import { DataSourceStackSpec } from './DataSourceStacksPage';
const datasourceStacksGVR: GroupVersionResource = {
group: 'collections.grafana.app',
version: 'v1alpha1',
resource: 'datasourcestacks',
};
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
export function EditStackPage() {
const { uid } = useParams<{ uid: string }>();
const [stack, setStack] = useState<Resource<DataSourceStackSpec> | null>(null);
const [formValues, setFormValues] = useState<StackFormValues | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchStack = async () => {
if (!uid) {
setError('No stack UID provided');
setLoading(false);
return;
}
try {
setLoading(true);
const response = await datasourceStacksClient.get(uid);
setStack(response);
const values = transformStackSpecToFormValues(response.metadata.name || '', response.spec);
setFormValues(values);
} catch (err) {
console.error('Failed to fetch datasource stack:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch datasource stack');
} finally {
setLoading(false);
}
};
fetchStack();
}, [uid]);
const pageNav = {
text: stack?.metadata.name
? t('connections.edit-stack-page.title-with-name', 'Edit {{name}}', { name: stack.metadata.name })
: t('connections.edit-stack-page.title', 'Edit Data Source Stack'),
subTitle: t('connections.edit-stack-page.subtitle', 'Modify your data source stack configuration'),
};
return (
<Page navId="connections-stacks" pageNav={pageNav}>
<Page.Contents>
<EditStackContent loading={loading} error={error} formValues={formValues} />
</Page.Contents>
</Page>
);
}
interface EditStackContentProps {
loading: boolean;
error: string | null;
formValues: StackFormValues | null;
}
function EditStackContent({ loading, error, formValues }: EditStackContentProps) {
if (loading) {
return <Spinner />;
}
if (error) {
return (
<EmptyState
variant="not-found"
message={t('connections.edit-stack-page.error', 'Failed to load data source stack')}
>
<div>{error}</div>
</EmptyState>
);
}
if (!formValues) {
return (
<EmptyState
variant="not-found"
message={t('connections.edit-stack-page.not-found', 'Data source stack not found')}
/>
);
}
return <StackForm existing={formValues} />;
}
@@ -0,0 +1,19 @@
import { Page } from 'app/core/components/Page/Page';
import { StackForm } from 'app/features/datasources/components/new-stack-form/StackForm';
export function NewStackPage() {
return (
<Page
navId="connections-stacks"
pageNav={{
text: 'New Data Source Stack',
subTitle: 'Add a new data source stack',
active: true,
}}
>
<Page.Contents>
<StackForm />
</Page.Contents>
</Page>
);
}
@@ -69,6 +69,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
hasOpenPane: Boolean(openPane),
contentMargin: 1,
position: 'right',
persistanceKey: 'dashboard',
onClosePane: () => editPane.closePane(),
});
@@ -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}

Some files were not shown because too many files have changed in this diff Show More