Compare commits

..

35 Commits

Author SHA1 Message Date
idastambuk
6b6a434383 update navigation and apply save form changes 2025-12-08 13:05:59 +01:00
idastambuk
6d6112b627 Merge branch 'grafakus/multi-dimensional-vars-ui' into hackathon/stacks 2025-12-08 12:40:05 +01:00
idastambuk
41f9162472 Add stack list, new stack, edit stack 2025-12-08 12:27:41 +01:00
Dafydd
7e991886e0 use fieldSelector on the ListConnections endpoint to get datasources by UID, instead of relying on uniqueness in the Get endpoint 2025-12-05 15:49:40 +00:00
Dafydd
85925d0765 simplify the new datasource client interface to not require group 2025-12-05 11:24:59 +00:00
Dafydd
7790698aaa newline 2025-12-04 16:36:28 +00:00
Dafydd
5499ad8023 provide an interface for the datasourceConnection 2025-12-04 16:34:22 +00:00
Dafydd
90c4ab9d96 wip: basic logic to check that datasource exists in validation 2025-12-04 12:34:14 +00:00
Dafydd
fd31f087ee add some tests for validating datasource stacks structure 2025-12-04 11:03:41 +00:00
Dafydd
3ee834922b wip: start to use validator in the builder instead of validating on the store hooks 2025-12-03 15:13:34 +00:00
Dafydd
2e2ce8fddd wip: exploring update validation 2025-12-02 15:53:35 +00:00
Dafydd
8214dbc758 start adding some validation to the store 2025-12-02 15:11:46 +00:00
Dafydd
98d454401c rm unused store implementation 2025-12-02 14:37:41 +00:00
Dafydd
fcf1a47222 update kind names 2025-12-02 13:44:31 +00:00
Dafydd
8a5b6804dd wip: add separate authorization logic for datasources 2025-12-02 10:44:43 +00:00
Dafydd
f0028f692b wip: add storage that ignores dual writer. Next step: why doesnt the attr.GetName() method work? When posting a new datasource 2025-12-01 16:58:57 +00:00
Dafydd
d71474246c wip: add datasources collection resource kind definition, register it to the API 2025-12-01 15:02:00 +00:00
grafakus
9447015e54 Remove temp switch in QueryVariableEditor - rely on options instead to determine if the variable has multi props 2025-11-26 19:51:00 +01:00
grafakus
abe10b2bb6 chore: Better naming and minor test improvement 2025-11-25 18:27:58 +01:00
grafakus
009716a408 test(CustomVariableEditor): Add unit tests 2025-11-25 18:21:29 +01:00
grafakus
e0c28cfa4c Fix: hide options when multi properties exist on every var options 2025-11-25 12:11:37 +01:00
grafakus
18c4f5b875 feat: Update dynamic dashboards editors 2025-11-25 12:06:47 +01:00
grafakus
400f3a91d0 Fix conflicts with main 2025-11-25 10:09:43 +01:00
grafakus
d6b04d28b6 chore: Update to new Scenes version 2025-11-24 10:46:10 +01:00
grafakus
0400d536c7 Fix K8s Codegen Check 2025-11-19 09:07:17 +01:00
grafakus
694e88b95b Add some unit tests 2025-11-19 08:48:55 +01:00
grafakus
ad73303328 VariableEditorForm checks to display preview with multiple props 2025-11-19 08:48:25 +01:00
grafakus
3dcd809aaf Translate CustomVariableEditor + improve JSON validation 2025-11-18 18:35:49 +01:00
grafakus
6b7fac65b1 chore: Add comment 2025-11-18 15:04:44 +01:00
grafakus
2d17de2395 Small preview fix when "All" option is checked 2025-11-18 14:58:28 +01:00
grafakus
5b685373aa Strengthen valuesFormat type + cleanup generated files 2025-11-18 14:42:56 +01:00
grafakus
4d29e5bf6a chore: ... 2025-11-13 20:16:47 +01:00
grafakus
7a0e64196b Update v2 schema + gen types 2025-11-13 14:19:03 +01:00
grafakus
f1e24f528e Merge branch 'main' into grafakus/multi-dimensional-vars-ui 2025-11-13 14:18:36 +01:00
grafakus
198f4dbf93 feat: WiP 2025-11-13 14:08:00 +01:00
147 changed files with 3791 additions and 1866 deletions

View File

@@ -57,7 +57,7 @@ jobs:
lint-go:
needs: detect-changes
if: needs.detect-changes.outputs.changed == 'true'
runs-on: ubuntu-x64-large-io
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:

View File

@@ -1,20 +1,34 @@
package kinds
import (
"github.com/grafana/grafana/apps/alerting/historian/kinds/v0alpha1"
)
manifest: {
appName: "alerting-historian"
groupOverride: "historian.alerting.grafana.app"
versions: {
"v0alpha1": {
kinds: [dummyv0alpha1]
routes: v0alpha1.routes
}
"v0alpha1": v0alpha1
}
}
v0alpha1: {
kinds: [dummyv0alpha1]
routes: {
namespaced: {
// This endpoint is an exact copy of the existing /history endpoint,
// with the exception that error responses will be Kubernetes-style,
// not Grafana-style. It will be replaced in the future with a better
// more schema-friendly API.
"/alertstate/history": {
"GET": {
response: {
body: [string]: _
}
responseMetadata: typeMeta: false
}
}
}
}
}
dummyv0alpha1: {
kind: "Dummy"
schema: {
@@ -23,4 +37,4 @@ dummyv0alpha1: {
dummyField: int
}
}
}
}

View File

@@ -1,9 +0,0 @@
package v0alpha1
#Matcher: {
type: "=" | "!=" | "=~" | "!~" @cuetsy(kind="enum",memberNames="Equal|NotEqual|EqualRegex|NotEqualRegex")
label: string
value: string
}
#Matchers: [...#Matcher]

View File

@@ -1,65 +0,0 @@
package v0alpha1
import (
"time"
)
#NotificationStatus: "firing" | "resolved" @cog(kind="enum",memberNames="Firing|Resolved")
#NotificationOutcome: "success" | "error" @cog(kind="enum",memberNames="Success|Error")
#NotificationQuery: {
// From is the starting timestamp for the query.
from?: time.Time
// To is the starting timestamp for the query.
to?: time.Time
// Limit is the maximum number of entries to return.
limit?: int64
// Receiver optionally filters the entries by receiver title (contact point).
receiver?: string
// Status optionally filters the entries to only either firing or resolved.
status?: #NotificationStatus
// Outcome optionally filters the entries to only either successful or failed attempts.
outcome?: #NotificationOutcome
// RuleUID optionally filters the entries to a specific alert rule.
ruleUID?: string
// GroupLabels optionally filters the entries by matching group labels.
groupLabels?: #Matchers
}
#NotificationQueryResult: {
entries: [...#NotificationEntry]
}
#NotificationEntry: {
// Timestamp is the time at which the notification attempt completed.
timestamp: time.Time
// Receiver is the receiver (contact point) title.
receiver: string
// Status indicates if the notification contains one or more firing alerts.
status: #NotificationStatus
// Outcome indicaes if the notificaion attempt was successful or if it failed.
outcome: #NotificationOutcome
// GroupLabels are the labels uniquely identifying the alert group within a route.
groupLabels: [string]: string
// Alerts are the alerts grouped into the notification.
alerts: [...#NotificationEntryAlert]
// Retry indicates if the attempt was a retried attempt.
retry: bool
// Error is the message returned by the contact point if delivery failed.
error?: string
// Duration is the length of time the notification attempt took in nanoseconds.
duration: int
// PipelineTime is the time at which the flush began.
pipelineTime: time.Time
// GroupKey uniquely idenifies the dispatcher alert group.
groupKey: string
}
#NotificationEntryAlert: {
status: string
labels: [string]: string
annotations: [string]: string
startsAt: time.Time
endsAt: time.Time
}

View File

@@ -1,29 +0,0 @@
package v0alpha1
routes: {
namespaced: {
// This endpoint is an exact copy of the existing /history endpoint,
// with the exception that error responses will be Kubernetes-style,
// not Grafana-style. It will be replaced in the future with a better
// more schema-friendly API.
"/alertstate/history": {
"GET": {
response: {
body: [string]: _
}
responseMetadata: typeMeta: false
}
}
// Query notification history.
"/notification/query": {
"POST": {
request: {
body: #NotificationQuery
}
response: #NotificationQueryResult
responseMetadata: typeMeta: false
}
}
}
}

View File

@@ -1,67 +0,0 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
import (
time "time"
)
type CreateNotificationqueryRequestNotificationStatus string
const (
CreateNotificationqueryRequestNotificationStatusFiring CreateNotificationqueryRequestNotificationStatus = "firing"
CreateNotificationqueryRequestNotificationStatusResolved CreateNotificationqueryRequestNotificationStatus = "resolved"
)
type CreateNotificationqueryRequestNotificationOutcome string
const (
CreateNotificationqueryRequestNotificationOutcomeSuccess CreateNotificationqueryRequestNotificationOutcome = "success"
CreateNotificationqueryRequestNotificationOutcomeError CreateNotificationqueryRequestNotificationOutcome = "error"
)
type CreateNotificationqueryRequestMatchers []CreateNotificationqueryRequestMatcher
type CreateNotificationqueryRequestMatcher struct {
Type CreateNotificationqueryRequestMatcherType `json:"type"`
Label string `json:"label"`
Value string `json:"value"`
}
// NewCreateNotificationqueryRequestMatcher creates a new CreateNotificationqueryRequestMatcher object.
func NewCreateNotificationqueryRequestMatcher() *CreateNotificationqueryRequestMatcher {
return &CreateNotificationqueryRequestMatcher{}
}
type CreateNotificationqueryRequestBody struct {
// From is the starting timestamp for the query.
From *time.Time `json:"from,omitempty"`
// To is the starting timestamp for the query.
To *time.Time `json:"to,omitempty"`
// Limit is the maximum number of entries to return.
Limit *int64 `json:"limit,omitempty"`
// Receiver optionally filters the entries by receiver title (contact point).
Receiver *string `json:"receiver,omitempty"`
// Status optionally filters the entries to only either firing or resolved.
Status *CreateNotificationqueryRequestNotificationStatus `json:"status,omitempty"`
// Outcome optionally filters the entries to only either successful or failed attempts.
Outcome *CreateNotificationqueryRequestNotificationOutcome `json:"outcome,omitempty"`
// RuleUID optionally filters the entries to a specific alert rule.
RuleUID *string `json:"ruleUID,omitempty"`
// GroupLabels optionally filters the entries by matching group labels.
GroupLabels *CreateNotificationqueryRequestMatchers `json:"groupLabels,omitempty"`
}
// NewCreateNotificationqueryRequestBody creates a new CreateNotificationqueryRequestBody object.
func NewCreateNotificationqueryRequestBody() *CreateNotificationqueryRequestBody {
return &CreateNotificationqueryRequestBody{}
}
type CreateNotificationqueryRequestMatcherType string
const (
CreateNotificationqueryRequestMatcherTypeEqual CreateNotificationqueryRequestMatcherType = "="
CreateNotificationqueryRequestMatcherTypeNotEqual CreateNotificationqueryRequestMatcherType = "!="
CreateNotificationqueryRequestMatcherTypeEqualRegex CreateNotificationqueryRequestMatcherType = "=~"
CreateNotificationqueryRequestMatcherTypeNotEqualRegex CreateNotificationqueryRequestMatcherType = "!~"
)

View File

@@ -1,86 +0,0 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
import (
time "time"
)
// +k8s:openapi-gen=true
type NotificationEntry struct {
// Timestamp is the time at which the notification attempt completed.
Timestamp time.Time `json:"timestamp"`
// Receiver is the receiver (contact point) title.
Receiver string `json:"receiver"`
// Status indicates if the notification contains one or more firing alerts.
Status NotificationStatus `json:"status"`
// Outcome indicaes if the notificaion attempt was successful or if it failed.
Outcome NotificationOutcome `json:"outcome"`
// GroupLabels are the labels uniquely identifying the alert group within a route.
GroupLabels map[string]string `json:"groupLabels"`
// Alerts are the alerts grouped into the notification.
Alerts []NotificationEntryAlert `json:"alerts"`
// Retry indicates if the attempt was a retried attempt.
Retry bool `json:"retry"`
// Error is the message returned by the contact point if delivery failed.
Error *string `json:"error,omitempty"`
// Duration is the length of time the notification attempt took in nanoseconds.
Duration int64 `json:"duration"`
// PipelineTime is the time at which the flush began.
PipelineTime time.Time `json:"pipelineTime"`
// GroupKey uniquely idenifies the dispatcher alert group.
GroupKey string `json:"groupKey"`
}
// NewNotificationEntry creates a new NotificationEntry object.
func NewNotificationEntry() *NotificationEntry {
return &NotificationEntry{
GroupLabels: map[string]string{},
Alerts: []NotificationEntryAlert{},
}
}
// +k8s:openapi-gen=true
type NotificationStatus string
const (
NotificationStatusFiring NotificationStatus = "firing"
NotificationStatusResolved NotificationStatus = "resolved"
)
// +k8s:openapi-gen=true
type NotificationOutcome string
const (
NotificationOutcomeSuccess NotificationOutcome = "success"
NotificationOutcomeError NotificationOutcome = "error"
)
// +k8s:openapi-gen=true
type NotificationEntryAlert struct {
Status string `json:"status"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
}
// NewNotificationEntryAlert creates a new NotificationEntryAlert object.
func NewNotificationEntryAlert() *NotificationEntryAlert {
return &NotificationEntryAlert{
Labels: map[string]string{},
Annotations: map[string]string{},
}
}
// +k8s:openapi-gen=true
type CreateNotificationquery struct {
Entries []NotificationEntry `json:"entries"`
}
// NewCreateNotificationquery creates a new CreateNotificationquery object.
func NewCreateNotificationquery() *CreateNotificationquery {
return &CreateNotificationquery{
Entries: []NotificationEntry{},
}
}

View File

@@ -92,321 +92,9 @@ var appManifestData = app.ManifestData{
},
},
},
"/notification/query": {
Post: &spec3.Operation{
OperationProps: spec3.OperationProps{
OperationId: "createNotificationquery",
RequestBody: &spec3.RequestBody{
RequestBodyProps: spec3.RequestBodyProps{
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"from": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
Description: "From is the starting timestamp for the query.",
},
},
"groupLabels": {
SchemaProps: spec.SchemaProps{
Description: "GroupLabels optionally filters the entries by matching group labels.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryMatchers"),
},
},
"limit": {
SchemaProps: spec.SchemaProps{
Type: []string{"integer"},
Description: "Limit is the maximum number of entries to return.",
},
},
"outcome": {
SchemaProps: spec.SchemaProps{
Description: "Outcome optionally filters the entries to only either successful or failed attempts.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationOutcome"),
},
},
"receiver": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "Receiver optionally filters the entries by receiver title (contact point).",
},
},
"ruleUID": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "RuleUID optionally filters the entries to a specific alert rule.",
},
},
"status": {
SchemaProps: spec.SchemaProps{
Description: "Status optionally filters the entries to only either firing or resolved.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationStatus"),
},
},
"to": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
Description: "To is the starting timestamp for the query.",
},
},
},
}},
}},
},
}},
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{
"entries": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
},
},
},
Required: []string{
"entries",
},
}},
}},
},
},
},
}},
},
},
},
},
Cluster: map[string]spec3.PathProps{},
Schemas: map[string]spec.Schema{
"createNotificationqueryMatcher": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"label": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
"type": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Enum: []interface{}{
"=",
"!=",
"=~",
"!~",
},
},
},
"value": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
Required: []string{
"type",
"label",
"value",
},
},
},
"createNotificationqueryMatchers": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
},
},
"createNotificationqueryNotificationEntry": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"alerts": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Description: "Alerts are the alerts grouped into the notification.",
},
},
"duration": {
SchemaProps: spec.SchemaProps{
Type: []string{"integer"},
Description: "Duration is the length of time the notification attempt took in nanoseconds.",
},
},
"error": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "Error is the message returned by the contact point if delivery failed.",
},
},
"groupKey": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "GroupKey uniquely idenifies the dispatcher alert group.",
},
},
"groupLabels": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Description: "GroupLabels are the labels uniquely identifying the alert group within a route.",
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
},
},
"outcome": {
SchemaProps: spec.SchemaProps{
Description: "Outcome indicaes if the notificaion attempt was successful or if it failed.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationOutcome"),
},
},
"pipelineTime": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
Description: "PipelineTime is the time at which the flush began.",
},
},
"receiver": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "Receiver is the receiver (contact point) title.",
},
},
"retry": {
SchemaProps: spec.SchemaProps{
Type: []string{"boolean"},
Description: "Retry indicates if the attempt was a retried attempt.",
},
},
"status": {
SchemaProps: spec.SchemaProps{
Description: "Status indicates if the notification contains one or more firing alerts.",
Ref: spec.MustCreateRef("#/components/schemas/createNotificationqueryNotificationStatus"),
},
},
"timestamp": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
Description: "Timestamp is the time at which the notification attempt completed.",
},
},
},
Required: []string{
"timestamp",
"receiver",
"status",
"outcome",
"groupLabels",
"alerts",
"retry",
"duration",
"pipelineTime",
"groupKey",
},
},
},
"createNotificationqueryNotificationEntryAlert": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"annotations": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
},
},
"endsAt": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
},
},
"labels": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
},
},
"startsAt": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
},
},
"status": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
Required: []string{
"status",
"labels",
"annotations",
"startsAt",
"endsAt",
},
},
},
"createNotificationqueryNotificationOutcome": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Enum: []interface{}{
"success",
"error",
},
},
},
"createNotificationqueryNotificationStatus": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Enum: []interface{}{
"firing",
"resolved",
},
},
},
},
Schemas: map[string]spec.Schema{},
},
},
},
@@ -432,8 +120,7 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist
}
var customRouteToGoResponseType = map[string]any{
"v0alpha1||<namespace>/alertstate/history|GET": v0alpha1.GetAlertstatehistory{},
"v0alpha1||<namespace>/notification/query|POST": v0alpha1.CreateNotificationquery{},
"v0alpha1||<namespace>/alertstate/history|GET": v0alpha1.GetAlertstatehistory{},
}
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
@@ -458,9 +145,7 @@ func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goTyp
return goType, exists
}
var customRouteToGoRequestBodyType = map[string]any{
"v0alpha1||<namespace>/notification/query|POST": v0alpha1.CreateNotificationqueryRequestBody{},
}
var customRouteToGoRequestBodyType = map[string]any{}
func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) {
if len(path) > 0 && path[0] == '/' {

View File

@@ -1,13 +1,8 @@
package app
import (
"context"
"net/http"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/simple"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/apps/alerting/historian/pkg/apis/alertinghistorian/v0alpha1"
"github.com/grafana/grafana/apps/alerting/historian/pkg/app/config"
@@ -26,11 +21,6 @@ func New(cfg app.Config) (app.App, error) {
Path: "/alertstate/history",
Method: "GET",
}: runtimeConfig.GetAlertStateHistoryHandler,
{
Namespaced: true,
Path: "/notification/query",
Method: "POST",
}: UnimplementedHandler,
},
},
// TODO: Remove when SDK is fixed.
@@ -53,13 +43,3 @@ func New(cfg app.Config) (app.App, error) {
return a, nil
}
func UnimplementedHandler(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusUnprocessableEntity,
Message: "unimplemented",
},
}
}

View File

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

View File

@@ -0,0 +1,35 @@
package preferences
datasourcestacksV1alpha1: {
kind: "DataSourceStack"
pluralName: "DataSourceStacks"
scope: "Namespaced"
schema: {
spec: {
template: TemplateSpec
modes: [...ModeSpec]
}
}
}
TemplateSpec: {
[string]: DataSourceStackTemplateItem
}
DataSourceStackTemplateItem: {
group: string // type
name: string // variable name / display name
}
ModeSpec: {
name: string
uid: string
definition: Mode
}
Mode: [string]: ModeItem
ModeItem: {
dataSourceRef: string // grafana data source uid
}

View File

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

View File

@@ -0,0 +1,80 @@
package v1alpha1
import (
"context"
"github.com/grafana/grafana-app-sdk/resource"
)
type DataSourceStackClient struct {
client *resource.TypedClient[*DataSourceStack, *DataSourceStackList]
}
func NewDataSourceStackClient(client resource.Client) *DataSourceStackClient {
return &DataSourceStackClient{
client: resource.NewTypedClient[*DataSourceStack, *DataSourceStackList](client, DataSourceStackKind()),
}
}
func NewDataSourceStackClientFromGenerator(generator resource.ClientGenerator) (*DataSourceStackClient, error) {
c, err := generator.ClientFor(DataSourceStackKind())
if err != nil {
return nil, err
}
return NewDataSourceStackClient(c), nil
}
func (c *DataSourceStackClient) Get(ctx context.Context, identifier resource.Identifier) (*DataSourceStack, error) {
return c.client.Get(ctx, identifier)
}
func (c *DataSourceStackClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*DataSourceStackList, error) {
return c.client.List(ctx, namespace, opts)
}
func (c *DataSourceStackClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*DataSourceStackList, error) {
resp, err := c.client.List(ctx, namespace, resource.ListOptions{
ResourceVersion: opts.ResourceVersion,
Limit: opts.Limit,
LabelFilters: opts.LabelFilters,
FieldSelectors: opts.FieldSelectors,
})
if err != nil {
return nil, err
}
for resp.GetContinue() != "" {
page, err := c.client.List(ctx, namespace, resource.ListOptions{
Continue: resp.GetContinue(),
ResourceVersion: opts.ResourceVersion,
Limit: opts.Limit,
LabelFilters: opts.LabelFilters,
FieldSelectors: opts.FieldSelectors,
})
if err != nil {
return nil, err
}
resp.SetContinue(page.GetContinue())
resp.SetResourceVersion(page.GetResourceVersion())
resp.SetItems(append(resp.GetItems(), page.GetItems()...))
}
return resp, nil
}
func (c *DataSourceStackClient) Create(ctx context.Context, obj *DataSourceStack, opts resource.CreateOptions) (*DataSourceStack, error) {
// Make sure apiVersion and kind are set
obj.APIVersion = GroupVersion.Identifier()
obj.Kind = DataSourceStackKind().Kind()
return c.client.Create(ctx, obj, opts)
}
func (c *DataSourceStackClient) Update(ctx context.Context, obj *DataSourceStack, opts resource.UpdateOptions) (*DataSourceStack, error) {
return c.client.Update(ctx, obj, opts)
}
func (c *DataSourceStackClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*DataSourceStack, error) {
return c.client.Patch(ctx, identifier, req, opts)
}
func (c *DataSourceStackClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error {
return c.client.Delete(ctx, identifier, opts)
}

View File

@@ -0,0 +1,28 @@
//
// Code generated by grafana-app-sdk. DO NOT EDIT.
//
package v1alpha1
import (
"encoding/json"
"io"
"github.com/grafana/grafana-app-sdk/resource"
)
// DataSourceStackJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding
type DataSourceStackJSONCodec struct{}
// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into`
func (*DataSourceStackJSONCodec) Read(reader io.Reader, into resource.Object) error {
return json.NewDecoder(reader).Decode(into)
}
// Write writes JSON-encoded bytes into `writer` marshaled from `from`
func (*DataSourceStackJSONCodec) Write(writer io.Writer, from resource.Object) error {
return json.NewEncoder(writer).Encode(from)
}
// Interface compliance checks
var _ resource.Codec = &DataSourceStackJSONCodec{}

View File

@@ -0,0 +1,31 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v1alpha1
import (
time "time"
)
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
type DataSourceStackMetadata struct {
UpdateTimestamp time.Time `json:"updateTimestamp"`
CreatedBy string `json:"createdBy"`
Uid string `json:"uid"`
CreationTimestamp time.Time `json:"creationTimestamp"`
DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"`
Finalizers []string `json:"finalizers"`
ResourceVersion string `json:"resourceVersion"`
Generation int64 `json:"generation"`
UpdatedBy string `json:"updatedBy"`
Labels map[string]string `json:"labels"`
}
// NewDataSourceStackMetadata creates a new DataSourceStackMetadata object.
func NewDataSourceStackMetadata() *DataSourceStackMetadata {
return &DataSourceStackMetadata{
Finalizers: []string{},
Labels: map[string]string{},
}
}

View File

@@ -0,0 +1,293 @@
//
// Code generated by grafana-app-sdk. DO NOT EDIT.
//
package v1alpha1
import (
"fmt"
"github.com/grafana/grafana-app-sdk/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"time"
)
// +k8s:openapi-gen=true
type DataSourceStack struct {
metav1.TypeMeta `json:",inline" yaml:",inline"`
metav1.ObjectMeta `json:"metadata" yaml:"metadata"`
// Spec is the spec of the DataSourceStack
Spec DataSourceStackSpec `json:"spec" yaml:"spec"`
}
func (o *DataSourceStack) GetSpec() any {
return o.Spec
}
func (o *DataSourceStack) SetSpec(spec any) error {
cast, ok := spec.(DataSourceStackSpec)
if !ok {
return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec)
}
o.Spec = cast
return nil
}
func (o *DataSourceStack) GetSubresources() map[string]any {
return map[string]any{}
}
func (o *DataSourceStack) GetSubresource(name string) (any, bool) {
switch name {
default:
return nil, false
}
}
func (o *DataSourceStack) SetSubresource(name string, value any) error {
switch name {
default:
return fmt.Errorf("subresource '%s' does not exist", name)
}
}
func (o *DataSourceStack) GetStaticMetadata() resource.StaticMetadata {
gvk := o.GroupVersionKind()
return resource.StaticMetadata{
Name: o.ObjectMeta.Name,
Namespace: o.ObjectMeta.Namespace,
Group: gvk.Group,
Version: gvk.Version,
Kind: gvk.Kind,
}
}
func (o *DataSourceStack) SetStaticMetadata(metadata resource.StaticMetadata) {
o.Name = metadata.Name
o.Namespace = metadata.Namespace
o.SetGroupVersionKind(schema.GroupVersionKind{
Group: metadata.Group,
Version: metadata.Version,
Kind: metadata.Kind,
})
}
func (o *DataSourceStack) GetCommonMetadata() resource.CommonMetadata {
dt := o.DeletionTimestamp
var deletionTimestamp *time.Time
if dt != nil {
deletionTimestamp = &dt.Time
}
// Legacy ExtraFields support
extraFields := make(map[string]any)
if o.Annotations != nil {
extraFields["annotations"] = o.Annotations
}
if o.ManagedFields != nil {
extraFields["managedFields"] = o.ManagedFields
}
if o.OwnerReferences != nil {
extraFields["ownerReferences"] = o.OwnerReferences
}
return resource.CommonMetadata{
UID: string(o.UID),
ResourceVersion: o.ResourceVersion,
Generation: o.Generation,
Labels: o.Labels,
CreationTimestamp: o.CreationTimestamp.Time,
DeletionTimestamp: deletionTimestamp,
Finalizers: o.Finalizers,
UpdateTimestamp: o.GetUpdateTimestamp(),
CreatedBy: o.GetCreatedBy(),
UpdatedBy: o.GetUpdatedBy(),
ExtraFields: extraFields,
}
}
func (o *DataSourceStack) SetCommonMetadata(metadata resource.CommonMetadata) {
o.UID = types.UID(metadata.UID)
o.ResourceVersion = metadata.ResourceVersion
o.Generation = metadata.Generation
o.Labels = metadata.Labels
o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp)
if metadata.DeletionTimestamp != nil {
dt := metav1.NewTime(*metadata.DeletionTimestamp)
o.DeletionTimestamp = &dt
} else {
o.DeletionTimestamp = nil
}
o.Finalizers = metadata.Finalizers
if o.Annotations == nil {
o.Annotations = make(map[string]string)
}
if !metadata.UpdateTimestamp.IsZero() {
o.SetUpdateTimestamp(metadata.UpdateTimestamp)
}
if metadata.CreatedBy != "" {
o.SetCreatedBy(metadata.CreatedBy)
}
if metadata.UpdatedBy != "" {
o.SetUpdatedBy(metadata.UpdatedBy)
}
// Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields
if metadata.ExtraFields != nil {
if annotations, ok := metadata.ExtraFields["annotations"]; ok {
if cast, ok := annotations.(map[string]string); ok {
o.Annotations = cast
}
}
if managedFields, ok := metadata.ExtraFields["managedFields"]; ok {
if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok {
o.ManagedFields = cast
}
}
if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok {
if cast, ok := ownerReferences.([]metav1.OwnerReference); ok {
o.OwnerReferences = cast
}
}
}
}
func (o *DataSourceStack) GetCreatedBy() string {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
return o.ObjectMeta.Annotations["grafana.com/createdBy"]
}
func (o *DataSourceStack) SetCreatedBy(createdBy string) {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy
}
func (o *DataSourceStack) GetUpdateTimestamp() time.Time {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"])
return parsed
}
func (o *DataSourceStack) SetUpdateTimestamp(updateTimestamp time.Time) {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339)
}
func (o *DataSourceStack) GetUpdatedBy() string {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
return o.ObjectMeta.Annotations["grafana.com/updatedBy"]
}
func (o *DataSourceStack) SetUpdatedBy(updatedBy string) {
if o.ObjectMeta.Annotations == nil {
o.ObjectMeta.Annotations = make(map[string]string)
}
o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy
}
func (o *DataSourceStack) Copy() resource.Object {
return resource.CopyObject(o)
}
func (o *DataSourceStack) DeepCopyObject() runtime.Object {
return o.Copy()
}
func (o *DataSourceStack) DeepCopy() *DataSourceStack {
cpy := &DataSourceStack{}
o.DeepCopyInto(cpy)
return cpy
}
func (o *DataSourceStack) DeepCopyInto(dst *DataSourceStack) {
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
dst.TypeMeta.Kind = o.TypeMeta.Kind
o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta)
o.Spec.DeepCopyInto(&dst.Spec)
}
// Interface compliance compile-time check
var _ resource.Object = &DataSourceStack{}
// +k8s:openapi-gen=true
type DataSourceStackList struct {
metav1.TypeMeta `json:",inline" yaml:",inline"`
metav1.ListMeta `json:"metadata" yaml:"metadata"`
Items []DataSourceStack `json:"items" yaml:"items"`
}
func (o *DataSourceStackList) DeepCopyObject() runtime.Object {
return o.Copy()
}
func (o *DataSourceStackList) Copy() resource.ListObject {
cpy := &DataSourceStackList{
TypeMeta: o.TypeMeta,
Items: make([]DataSourceStack, len(o.Items)),
}
o.ListMeta.DeepCopyInto(&cpy.ListMeta)
for i := 0; i < len(o.Items); i++ {
if item, ok := o.Items[i].Copy().(*DataSourceStack); ok {
cpy.Items[i] = *item
}
}
return cpy
}
func (o *DataSourceStackList) GetItems() []resource.Object {
items := make([]resource.Object, len(o.Items))
for i := 0; i < len(o.Items); i++ {
items[i] = &o.Items[i]
}
return items
}
func (o *DataSourceStackList) SetItems(items []resource.Object) {
o.Items = make([]DataSourceStack, len(items))
for i := 0; i < len(items); i++ {
o.Items[i] = *items[i].(*DataSourceStack)
}
}
func (o *DataSourceStackList) DeepCopy() *DataSourceStackList {
cpy := &DataSourceStackList{}
o.DeepCopyInto(cpy)
return cpy
}
func (o *DataSourceStackList) DeepCopyInto(dst *DataSourceStackList) {
resource.CopyObjectInto(dst, o)
}
// Interface compliance compile-time check
var _ resource.ListObject = &DataSourceStackList{}
// Copy methods for all subresource types
// DeepCopy creates a full deep copy of Spec
func (s *DataSourceStackSpec) DeepCopy() *DataSourceStackSpec {
cpy := &DataSourceStackSpec{}
s.DeepCopyInto(cpy)
return cpy
}
// DeepCopyInto deep copies Spec into another Spec object
func (s *DataSourceStackSpec) DeepCopyInto(dst *DataSourceStackSpec) {
resource.CopyObjectInto(dst, s)
}

View File

@@ -0,0 +1,34 @@
//
// Code generated by grafana-app-sdk. DO NOT EDIT.
//
package v1alpha1
import (
"github.com/grafana/grafana-app-sdk/resource"
)
// schema is unexported to prevent accidental overwrites
var (
schemaDataSourceStack = resource.NewSimpleSchema("collections.grafana.app", "v1alpha1", &DataSourceStack{}, &DataSourceStackList{}, resource.WithKind("DataSourceStack"),
resource.WithPlural("datasourcestacks"), resource.WithScope(resource.NamespacedScope))
kindDataSourceStack = resource.Kind{
Schema: schemaDataSourceStack,
Codecs: map[resource.KindEncoding]resource.Codec{
resource.KindEncodingJSON: &DataSourceStackJSONCodec{},
},
}
)
// Kind returns a resource.Kind for this Schema with a JSON codec
func DataSourceStackKind() resource.Kind {
return kindDataSourceStack
}
// Schema returns a resource.SimpleSchema representation of DataSourceStack
func DataSourceStackSchema() *resource.SimpleSchema {
return schemaDataSourceStack
}
// Interface compliance checks
var _ resource.Schema = kindDataSourceStack

View File

@@ -0,0 +1,58 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v1alpha1
// +k8s:openapi-gen=true
type DataSourceStackTemplateSpec map[string]DataSourceStackDataSourceStackTemplateItem
// +k8s:openapi-gen=true
type DataSourceStackDataSourceStackTemplateItem struct {
// type
Group string `json:"group"`
// variable name / display name
Name string `json:"name"`
}
// NewDataSourceStackDataSourceStackTemplateItem creates a new DataSourceStackDataSourceStackTemplateItem object.
func NewDataSourceStackDataSourceStackTemplateItem() *DataSourceStackDataSourceStackTemplateItem {
return &DataSourceStackDataSourceStackTemplateItem{}
}
// +k8s:openapi-gen=true
type DataSourceStackModeSpec struct {
Name string `json:"name"`
Uid string `json:"uid"`
Definition DataSourceStackMode `json:"definition"`
}
// NewDataSourceStackModeSpec creates a new DataSourceStackModeSpec object.
func NewDataSourceStackModeSpec() *DataSourceStackModeSpec {
return &DataSourceStackModeSpec{}
}
// +k8s:openapi-gen=true
type DataSourceStackMode map[string]DataSourceStackModeItem
// +k8s:openapi-gen=true
type DataSourceStackModeItem struct {
// grafana data source uid
DataSourceRef string `json:"dataSourceRef"`
}
// NewDataSourceStackModeItem creates a new DataSourceStackModeItem object.
func NewDataSourceStackModeItem() *DataSourceStackModeItem {
return &DataSourceStackModeItem{}
}
// +k8s:openapi-gen=true
type DataSourceStackSpec struct {
Template DataSourceStackTemplateSpec `json:"template"`
Modes []DataSourceStackModeSpec `json:"modes"`
}
// NewDataSourceStackSpec creates a new DataSourceStackSpec object.
func NewDataSourceStackSpec() *DataSourceStackSpec {
return &DataSourceStackSpec{
Modes: []DataSourceStackModeSpec{},
}
}

View File

@@ -32,6 +32,19 @@ var StarsResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion,
},
)
var DatasourceStacksResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion,
"datasourcestacks", "datasourcestack", "DataSourceStack",
func() runtime.Object { return &DataSourceStack{} },
func() runtime.Object { return &DataSourceStackList{} },
utils.TableColumns{
Definition: []metav1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name"},
{Name: "Created At", Type: "date"},
},
// TODO: Reader?
},
)
var (
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

View File

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

View File

@@ -1,2 +1,4 @@
API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,DataSourceStackSpec,Modes
API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsSpec,Resource
API rule violation: 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

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.

View File

@@ -0,0 +1,47 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.gen';
export interface Metadata {
name: string;
namespace: string;
generateName?: string;
selfLink?: string;
uid?: string;
resourceVersion?: string;
generation?: number;
creationTimestamp?: string;
deletionTimestamp?: string;
deletionGracePeriodSeconds?: number;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: OwnerReference[];
finalizers?: string[];
managedFields?: ManagedFieldsEntry[];
}
export interface OwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller?: boolean;
blockOwnerDeletion?: boolean;
}
export interface ManagedFieldsEntry {
manager?: string;
operation?: string;
apiVersion?: string;
time?: string;
fieldsType?: string;
subresource?: string;
}
export interface DataSourceStack {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
}

View File

@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
export interface Metadata {
updateTimestamp: string;
createdBy: string;
uid: string;
creationTimestamp: string;
deletionTimestamp?: string;
finalizers: string[];
resourceVersion: string;
generation: number;
updatedBy: string;
labels: Record<string, string>;
}
export const defaultMetadata = (): Metadata => ({
updateTimestamp: "",
createdBy: "",
uid: "",
creationTimestamp: "",
finalizers: [],
resourceVersion: "",
generation: 0,
updatedBy: "",
labels: {},
});

View File

@@ -0,0 +1,53 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export type TemplateSpec = Record<string, DataSourceStackTemplateItem>;
export const defaultTemplateSpec = (): TemplateSpec => ({});
export interface DataSourceStackTemplateItem {
// type
group: string;
// variable name / display name
name: string;
}
export const defaultDataSourceStackTemplateItem = (): DataSourceStackTemplateItem => ({
group: "",
name: "",
});
export interface ModeSpec {
name: string;
uid: string;
definition: Mode;
}
export const defaultModeSpec = (): ModeSpec => ({
name: "",
uid: "",
definition: defaultMode(),
});
export type Mode = Record<string, ModeItem>;
export const defaultMode = (): Mode => ({});
export interface ModeItem {
// grafana data source uid
dataSourceRef: string;
}
export const defaultModeItem = (): ModeItem => ({
dataSourceRef: "",
});
export interface Spec {
template: TemplateSpec;
modes: ModeSpec[];
}
export const defaultSpec = (): Spec => ({
template: defaultTemplateSpec(),
modes: [],
});

View File

@@ -0,0 +1,47 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.gen';
export interface Metadata {
name: string;
namespace: string;
generateName?: string;
selfLink?: string;
uid?: string;
resourceVersion?: string;
generation?: number;
creationTimestamp?: string;
deletionTimestamp?: string;
deletionGracePeriodSeconds?: number;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: OwnerReference[];
finalizers?: string[];
managedFields?: ManagedFieldsEntry[];
}
export interface OwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller?: boolean;
blockOwnerDeletion?: boolean;
}
export interface ManagedFieldsEntry {
manager?: string;
operation?: string;
apiVersion?: string;
time?: string;
fieldsType?: string;
subresource?: string;
}
export interface Stars {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
}

View File

@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
export interface Metadata {
updateTimestamp: string;
createdBy: string;
uid: string;
creationTimestamp: string;
deletionTimestamp?: string;
finalizers: string[];
resourceVersion: string;
generation: number;
updatedBy: string;
labels: Record<string, string>;
}
export const defaultMetadata = (): Metadata => ({
updateTimestamp: "",
createdBy: "",
uid: "",
creationTimestamp: "",
finalizers: [],
resourceVersion: "",
generation: 0,
updatedBy: "",
labels: {},
});

View File

@@ -0,0 +1,24 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export interface Resource {
group: string;
kind: string;
// The set of resources
// +listType=set
names: string[];
}
export const defaultResource = (): Resource => ({
group: "",
kind: "",
names: [],
});
export interface Spec {
resource: Resource[];
}
export const defaultSpec = (): Spec => ({
resource: [],
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,6 @@ pluginMetaV0Alpha1: {
skipDataQuery?: bool
state?: "alpha" | "beta"
streaming?: bool
suggestions?: bool
tracing?: bool
iam?: #IAM
// +listType=atomic

View File

@@ -40,7 +40,6 @@ type PluginMetaJSONData struct {
SkipDataQuery *bool `json:"skipDataQuery,omitempty"`
State *PluginMetaJSONDataState `json:"state,omitempty"`
Streaming *bool `json:"streaming,omitempty"`
Suggestions *bool `json:"suggestions,omitempty"`
Tracing *bool `json:"tracing,omitempty"`
Iam *PluginMetaIAM `json:"iam,omitempty"`
// +listType=atomic

File diff suppressed because one or more lines are too long

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

View File

@@ -341,10 +341,6 @@
"type": "boolean",
"description": "Initialize plugin on startup. By default, the plugin initializes on first use, but when preload is set to true the plugin loads when the Grafana web app loads the first time. Only applicable to app plugins. When setting to `true`, implement [frontend code splitting](https://grafana.com/developers/plugin-tools/get-started/best-practices#app-plugins) to minimise performance implications."
},
"suggestions": {
"type": "boolean",
"description": "For panel plugins. If set to true, the plugin's suggestions supplier will be invoked and any suggestions returned will be included in the Suggestions pane in the Panel Editor."
},
"queryOptions": {
"type": "object",
"description": "For data source plugins. There is a query options section in the plugin's query editor and these options can be turned on if needed.",

View File

@@ -22,7 +22,7 @@ weight: 100
# Node graph
Node graphs are useful when you need to visualize elements that are related to each other. This is done by displaying circles&mdash;or _nodes_&mdash;for each element you want to visualize, connected by lines&mdash;or _edges_. By default, the visualization uses a [layered layout](#layout-algorithm) that positions the nodes into a network of connected circles.
Node graphs are useful when you need to visualize elements that are related to each other. This is done by displaying circles&mdash;or _nodes_&mdash;for each element you want to visualize, connected by lines&mdash;or _edges_. The visualization uses a directed force layout that positions the nodes into a network of connected circles.
Node graphs display useful information about each node, as well as the relationships between them, allowing you to visualize complex infrastructure maps, hierarchies, or execution diagrams.
@@ -123,32 +123,26 @@ You can pan the view by clicking outside any node or edge and dragging your mous
Use the buttons in the lower right corner to zoom in or out. You can also use the mouse wheel or touchpad scroll, together with either Ctrl or Cmd key to do so.
### Switch layouts
Switch quickly between displaying the visualization in graph or grid [layout](#layout-algorithm).
Click a node and select either **Show in Grid layout** or **Show in Graph layout**, depending on the current layout of the visualization:
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid-menu.png" max-width="750px" alt="Node graph in grid layout with node menu open" >}}
In grid layout, you can sort nodes by clicking on the stats inside the legend.
The marker next to the stat name shows which stat is currently used for sorting and the sorting direction:
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-legend-sort.png" max-width="550px" alt="Node graph legend sorting" >}}
Switching between grid and other layouts this way only changes the layout temporarily.
The visualization maintains the layout algorithm selected in the panel editor, and reverts to it when the dashboard refreshes.
For more information about layouts, refer to [Layout algorithm](#layout-algorithm).
<!-- if you have the panel in grid layout and switch it to graph, is it switching to layered? -->
### Hidden nodes
The number of nodes shown at a given time is limited to maintain a reasonable visualization performance. Nodes that are not currently visible are hidden behind clickable markers that show an approximate number of hidden nodes that are connected by a particular edge. You can click on the marker to expand the graph around that node.
![Node graph exploration](/media/docs/grafana/panels-visualizations/node-graph-exploration-8.0-2.png 'Node graph exploration')
### Grid view
You can switch to the grid view to have a better overview of the most interesting nodes in the graph. Grid view shows nodes in a grid without edges and can be sorted by stats shown inside the node or by stats represented by the a colored border of the nodes.
![Node graph grid](/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid-v11.3.png 'Node graph grid')
To sort the nodes, click on the stats inside the legend. The marker next to the stat name shows which stat is currently used for sorting and sorting direction.
![Node graph legend](/media/docs/grafana/panels-visualizations/screenshot-node-graph-legend-v11.3.png 'Node graph legend')
Click on the node and select "Show in Graph layout" option to switch back to graph layout and focus on the selected node, to show it in context of the full graph.
![Node graph grid to default](/media/docs/grafana/panels-visualizations/screenshot-node-graph-view-v11.3.png 'Node graph grid to default')
## Configuration options
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
@@ -161,24 +155,7 @@ The number of nodes shown at a given time is limited to maintain a reasonable vi
Use the following options to refine your node graph visualization.
#### Zoom mode
Choose how the node graph should handle zoom and scroll events:
- **Cooperative** - Allows you to scroll the visualization normally.
- **Greedy** - Reacts to all zoom gestures.
#### Layout algorithm
Choose how the visualization layout is generated:
- **Layered** - Default. Creates a predictable and orderly layout, especially useful for service graphs.
- **Force** - Uses a physics-based force layout algorithm that's useful with a large number of nodes (500+).
- **Grid** - Arranges nodes into a grid format to provide a better overview of the most interesting nodes in the graph. This layout shows nodes in a grid without edges and can be sorted by the stats shown inside the node or by the ones represented by the a colored border of the nodes.
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid.png" max-width="650px" alt="Node graph in grid layout" >}}
For more information about using the graph in grid layout, refer to [Switch layouts](#switch-layouts).
- **Zoom mode** - Choose how the node graph should handle zoom and scroll events.
### Nodes options
@@ -262,6 +239,6 @@ Optional fields:
| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behavior depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. |
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/iconography-icon--icons-overview) are allowed. |
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/iconography-icon--icons-overview)) are allowed. |
| nodeRadius | number | Radius value in pixels. Used to manage node size. |
| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` |

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:*",

View File

@@ -715,9 +715,11 @@ export {
export {
type VisualizationSuggestion,
type VisualizationSuggestionsSupplier,
type VisualizationSuggestionsSupplierFn,
type PanelPluginVisualizationSuggestion,
type VisualizationSuggestionsBuilder,
VisualizationSuggestionScore,
VisualizationSuggestionsBuilder,
VisualizationSuggestionsListAppender,
} from './types/suggestions';
export {
type MatcherConfig,

View File

@@ -1,18 +1,14 @@
import { createDataFrame } from '../dataframe/processDataFrame';
import { identityOverrideProcessor } from '../field/overrides/processors';
import {
StandardEditorsRegistryItem,
standardEditorsRegistry,
standardFieldConfigEditorRegistry,
} from '../field/standardFieldConfigEditorRegistry';
import { FieldType } from '../types/dataFrame';
import { FieldConfigProperty, FieldConfigPropertyItem } from '../types/fieldOverrides';
import { PanelMigrationModel } from '../types/panel';
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '../types/suggestions';
import { PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
import { PanelPlugin } from './PanelPlugin';
import { getPanelDataSummary } from './suggestions/getPanelDataSummary';
describe('PanelPlugin', () => {
describe('declarative options', () => {
@@ -487,107 +483,4 @@ describe('PanelPlugin', () => {
});
});
});
describe('suggestions', () => {
it('should register a suggestions supplier', () => {
const panel = new PanelPlugin(() => <div>Panel</div>);
panel.meta = panel.meta || {};
panel.meta.id = 'test-panel';
panel.meta.name = 'Test Panel';
panel.setSuggestionsSupplier((ds) => {
if (!ds.hasFieldType(FieldType.number)) {
return;
}
return [
{
name: 'Number Panel',
score: VisualizationSuggestionScore.Good,
},
];
});
const suggestions = panel.getSuggestions(
getPanelDataSummary([createDataFrame({ fields: [{ type: FieldType.number, name: 'Value' }] })])
);
expect(suggestions).toHaveLength(1);
expect(suggestions![0].pluginId).toBe(panel.meta.id);
expect(suggestions![0].name).toBe('Number Panel');
expect(
panel.getSuggestions(
getPanelDataSummary([createDataFrame({ fields: [{ type: FieldType.string, name: 'Value' }] })])
)
).toBeUndefined();
});
it('should not throw for the old syntax, but also should not register suggestions', () => {
jest.spyOn(console, 'warn').mockImplementation();
class DeprecatedSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder): void {
const appender = builder.getListAppender({
name: 'Deprecated Suggestion',
pluginId: 'deprecated-plugin',
options: {},
});
if (builder.dataSummary.hasNumberField) {
appender.append({});
}
}
}
const panel = new PanelPlugin(() => <div>Panel</div>);
expect(() => {
panel.setSuggestionsSupplier(new DeprecatedSuggestionsSupplier());
}).not.toThrow();
expect(console.warn).toHaveBeenCalled();
expect(
panel.getSuggestions(
getPanelDataSummary([
createDataFrame({
fields: [{ type: FieldType.number, name: 'Value', values: [1, 2, 3, 4, 5] }],
}),
])
)
).toBeUndefined();
});
it('should support the deprecated pattern of getSuggestionsSupplier with builder', () => {
jest.spyOn(console, 'warn').mockImplementation();
const panel = new PanelPlugin(() => <div>Panel</div>).setSuggestionsSupplier((ds) => {
if (!ds.hasFieldType(FieldType.number)) {
return;
}
return [
{
name: 'Number Panel',
score: VisualizationSuggestionScore.Good,
},
];
});
const oldSupplier = panel.getSuggestionsSupplier();
const builder1 = new VisualizationSuggestionsBuilder([
createDataFrame({ fields: [{ type: FieldType.number, name: 'Value' }] }),
]);
oldSupplier.getSuggestionsForData(builder1);
const suggestions1 = builder1.getList();
expect(suggestions1).toHaveLength(1);
expect(suggestions1![0].pluginId).toBe(panel.meta.id);
expect(suggestions1![0].name).toBe('Number Panel');
const builder2 = new VisualizationSuggestionsBuilder([
createDataFrame({ fields: [{ type: FieldType.string, name: 'Value' }] }),
]);
oldSupplier.getSuggestionsForData(builder2);
const suggestions2 = builder2.getList();
expect(suggestions2).toHaveLength(0);
});
});
});

View File

@@ -1,4 +1,4 @@
import { defaultsDeep, set } from 'lodash';
import { set } from 'lodash';
import { ComponentClass, ComponentType } from 'react';
import { FieldConfigOptionsRegistry } from '../field/FieldConfigOptionsRegistry';
@@ -14,19 +14,11 @@ import {
PanelPluginDataSupport,
} from '../types/panel';
import { GrafanaPlugin } from '../types/plugin';
import {
getSuggestionHash,
PanelPluginVisualizationSuggestion,
VisualizationSuggestion,
VisualizationSuggestionsSupplierDeprecated,
VisualizationSuggestionsSupplier,
VisualizationSuggestionsBuilder,
} from '../types/suggestions';
import { VisualizationSuggestionsSupplierFn, VisualizationSuggestionsSupplier } from '../types/suggestions';
import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
import { deprecationWarning } from '../utils/deprecationWarning';
import { createFieldConfigRegistry } from './registryFactories';
import { PanelDataSummary } from './suggestions/getPanelDataSummary';
/** @beta */
export type StandardOptionConfig = {
@@ -117,7 +109,7 @@ export class PanelPlugin<
};
private optionsSupplier?: PanelOptionsSupplier<TOptions>;
private suggestionsSupplier?: VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>;
private suggestionsSupplier?: VisualizationSuggestionsSupplier;
panel: ComponentType<PanelProps<TOptions>> | null;
editor?: ComponentClass<PanelEditorProps<TOptions>>;
@@ -371,84 +363,56 @@ export class PanelPlugin<
}
/**
* @deprecated use VisualizationSuggestionsSupplier
* @deprecated use VisualizationSuggestionsSupplierFn
*/
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplierDeprecated): this;
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier): this;
/**
* @alpha
* sets function that can return visualization examples and suggestions.
*/
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>): this;
setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplierFn<TOptions, TFieldConfigOptions>): this;
setSuggestionsSupplier(
supplier:
| VisualizationSuggestionsSupplier<TOptions, TFieldConfigOptions>
| VisualizationSuggestionsSupplierDeprecated
supplier: VisualizationSuggestionsSupplier | VisualizationSuggestionsSupplierFn<TOptions, TFieldConfigOptions>
): this {
if (typeof supplier !== 'function') {
deprecationWarning(
'PanelPlugin',
'plugin.setSuggestionsSupplier(new Supplier())',
'plugin.setSuggestionsSupplier(dataSummary => [...])'
);
return this;
}
this.suggestionsSupplier = supplier;
this.suggestionsSupplier =
typeof supplier === 'function'
? {
getSuggestionsForData: (builder) => {
const appender = builder.getListAppender<TOptions, TFieldConfigOptions>({
pluginId: this.meta.id,
name: this.meta.name,
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
});
const result = supplier(builder.dataSummary);
if (Array.isArray(result)) {
appender.appendAll(result);
}
},
}
: supplier;
return this;
}
/**
* Returns the suggestions supplier
* @alpha
* get suggestions based on the PanelDataSummary
*/
getSuggestions(
panelDataSummary: PanelDataSummary
): Array<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>> | void {
const withDefaults = (
suggestion: VisualizationSuggestion<TOptions, TFieldConfigOptions>
): Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'> =>
defaultsDeep(suggestion, {
pluginId: this.meta.id,
name: this.meta.name,
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
} satisfies Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'>);
return this.suggestionsSupplier?.(panelDataSummary)?.map(
(s): PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions> => {
const suggestionWithDefaults = withDefaults(s);
return Object.assign(suggestionWithDefaults, { hash: getSuggestionHash(suggestionWithDefaults) });
}
);
getSuggestionsSupplier(): VisualizationSuggestionsSupplier | undefined {
return this.suggestionsSupplier;
}
/**
* @deprecated use getSuggestions
* we have to keep this method intact to support cloud-onboarding plugin.
* @alpha
* returns whether the plugin has configured suggestions
*/
getSuggestionsSupplier() {
const withDefaults = (
suggestion: VisualizationSuggestion<TOptions, TFieldConfigOptions>
): Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'> =>
defaultsDeep(suggestion, {
pluginId: this.meta.id,
name: this.meta.name,
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
} satisfies Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfigOptions>, 'hash'>);
return {
getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => {
deprecationWarning('PanelPlugin', 'getSuggestionsSupplier()', 'getSuggestions(panelDataSummary)');
this.suggestionsSupplier?.(builder.dataSummary)?.forEach((s) => {
builder.getListAppender(withDefaults(s)).append(s);
});
},
};
hasSuggestions(): boolean {
return this.suggestionsSupplier !== undefined;
}
hasPluginId(pluginId: string) {

View File

@@ -1143,11 +1143,6 @@ export interface FeatureToggles {
*/
newVizSuggestions?: boolean;
/**
* Enable all plugins to supply visualization suggestions (including 3rd party plugins)
* @default false
*/
externalVizSuggestions?: boolean;
/**
* Restrict PanelChrome contents with overflow: hidden;
* @default true
*/

View File

@@ -20,8 +20,6 @@ export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, forma
export interface PanelPluginMeta extends PluginMeta {
/** Indicates that panel does not issue queries */
skipDataQuery?: boolean;
/** Indicates that the panel implements suggestions */
suggestions?: boolean;
/** Indicates that panel should not be available in visualisation picker */
hideFromList?: boolean;
/** Sort order */

View File

@@ -2,10 +2,11 @@ import { defaultsDeep } from 'lodash';
import { DataTransformerConfig } from '@grafana/schema';
import { getPanelDataSummary, PanelDataSummary } from '../panel/suggestions/getPanelDataSummary';
import { PanelDataSummary, getPanelDataSummary } from '../panel/suggestions/getPanelDataSummary';
import { DataFrame } from './dataFrame';
import { PanelModel } from './dashboard';
import { FieldConfigSource } from './fieldOverrides';
import { PanelData } from './panel';
/**
* @internal
@@ -107,6 +108,35 @@ export enum VisualizationSuggestionScore {
OK = 50,
}
/**
* @internal
* TODO this will move into the grafana app code once suppliers are migrated.
*/
export class VisualizationSuggestionsBuilder {
/** Summary stats for current data */
dataSummary: PanelDataSummary;
private list: PanelPluginVisualizationSuggestion[] = [];
constructor(
/** Current data */
public data?: PanelData,
/** Current panel & options */
public panel?: PanelModel
) {
this.dataSummary = getPanelDataSummary(data?.series);
}
getListAppender<TOptions extends unknown, TFieldConfig extends {} = {}>(
defaults: Omit<PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>, 'hash'>
) {
return new VisualizationSuggestionsListAppender<TOptions, TFieldConfig>(this.list, defaults);
}
getList() {
return this.list;
}
}
/**
* @alpha
* TODO: this name is temporary; it will become just "VisualizationSuggestionsSupplier" when the other interface is deleted.
@@ -117,48 +147,40 @@ export enum VisualizationSuggestionScore {
* - returns an array of VisualizationSuggestions
* - boolean return equates to "show a single suggestion card for this panel plugin with the default options" (true = show, false or void = hide)
*/
export type VisualizationSuggestionsSupplier<TOptions extends unknown, TFieldConfig extends {} = {}> = (
export type VisualizationSuggestionsSupplierFn<TOptions extends unknown, TFieldConfig extends {} = {}> = (
panelDataSummary: PanelDataSummary
) => Array<VisualizationSuggestion<TOptions, TFieldConfig>> | void;
/**
* DEPRECATED - the below exports need to remain in the code base to help make the transition for the Polystat plugin, which implements
* suggestions using the old API. These should be removed for Grafana 13.
* @deprecated use VisualizationSuggestionsSupplierFn instead.
*/
/**
* @deprecated use VisualizationSuggestionsSupplier
*/
export interface VisualizationSuggestionsSupplierDeprecated {
export type VisualizationSuggestionsSupplier = {
/**
* Adds suitable suggestions for the current data
*/
getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void;
}
};
/**
* @deprecated use VisualizationSuggestionsSupplier
* @internal
* TODO this will move into the grafana app code once suppliers are migrated.
*/
export class VisualizationSuggestionsBuilder {
public dataSummary: PanelDataSummary;
public list: PanelPluginVisualizationSuggestion[] = [];
export class VisualizationSuggestionsListAppender<TOptions extends unknown, TFieldConfig extends {} = {}> {
constructor(
private list: VisualizationSuggestion[],
private defaults: Partial<PanelPluginVisualizationSuggestion<TOptions, TFieldConfig>> = {}
) {}
constructor(dataFrames: DataFrame[]) {
this.dataSummary = getPanelDataSummary(dataFrames);
append(suggestion: VisualizationSuggestion<TOptions, TFieldConfig>) {
this.appendAll([suggestion]);
}
getList(): PanelPluginVisualizationSuggestion[] {
return this.list;
}
getListAppender(suggestionDefaults: Omit<PanelPluginVisualizationSuggestion, 'hash'>) {
const withDefaults = (suggestion: VisualizationSuggestion): PanelPluginVisualizationSuggestion => {
const s = defaultsDeep({}, suggestion, suggestionDefaults);
return {
...s,
hash: getSuggestionHash(s),
};
};
return {
append: (suggestion: VisualizationSuggestion) => {
this.list.push(withDefaults(suggestion));
},
};
appendAll(suggestions: Array<VisualizationSuggestion<TOptions, TFieldConfig>>) {
this.list.push(
...suggestions.map((s): PanelPluginVisualizationSuggestion<TOptions, TFieldConfig> => {
const suggestionWithDefaults = defaultsDeep(s, this.defaults);
return Object.assign(suggestionWithDefaults, { hash: getSuggestionHash(suggestionWithDefaults) });
})
);
}
}

View File

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

View File

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

View File

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

View File

@@ -518,7 +518,6 @@ const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
height: '100%',
position: 'relative',
}),
panel: css({

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)

View File

@@ -164,7 +164,6 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel),
BaseURL: panel.BaseURL,
SkipDataQuery: panel.SkipDataQuery,
Suggestions: panel.Suggestions,
HideFromList: panel.HideFromList,
ReleaseState: string(panel.State),
Signature: string(panel.Signature),

View File

@@ -222,10 +222,6 @@ var (
// MStatTotalRepositories is a metric total amount of repositories
MStatTotalRepositories prometheus.Gauge
// MUnifiedStorageMigrationStatus indicates the migration status for unified storage in this instance.
// Possible values: 0 (default/undefined), 1 (migration disabled), 2 (migration would run).
MUnifiedStorageMigrationStatus prometheus.Gauge
)
const (
@@ -695,12 +691,6 @@ func init() {
Help: "total amount of repositories",
Namespace: ExporterName,
})
MUnifiedStorageMigrationStatus = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "unified_storage_migration_status",
Help: "indicates whether this instance would run unified storage migrations (0=undefined, 1=migration disabled, 2=would run)",
Namespace: ExporterName,
})
}
// SetBuildInformation sets the build information for this binary
@@ -839,6 +829,5 @@ func initMetricVars(reg prometheus.Registerer) {
MStatTotalRepositories,
MFolderIDsAPICount,
MFolderIDsServiceCount,
MUnifiedStorageMigrationStatus,
)
}

View File

@@ -319,7 +319,6 @@ type PanelDTO struct {
HideFromList bool `json:"hideFromList"`
Sort int `json:"sort"`
SkipDataQuery bool `json:"skipDataQuery"`
Suggestions bool `json:"suggestions,omitempty"`
ReleaseState string `json:"state"`
BaseURL string `json:"baseUrl"`
Signature string `json:"signature"`

View File

@@ -105,7 +105,6 @@ type JSONData struct {
// Panel settings
SkipDataQuery bool `json:"skipDataQuery"`
Suggestions bool `json:"suggestions,omitempty"`
// App settings
AutoEnabled bool `json:"autoEnabled"`

View File

@@ -0,0 +1,88 @@
package collections
import (
"context"
"fmt"
collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/datasources/service/client"
"k8s.io/apiserver/pkg/admission"
)
var _ builder.APIGroupValidation = (*DatasourceStacksValidator)(nil)
type DatasourceStacksValidator struct {
dsClient client.DataSourceConnectionClient
}
func GetDatasourceStacksValidator(dsClient client.DataSourceConnectionClient) builder.APIGroupValidation {
return &DatasourceStacksValidator{dsClient: dsClient}
}
func (v *DatasourceStacksValidator) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
obj := a.GetObject()
operation := a.GetOperation()
if operation == admission.Connect {
return fmt.Errorf("Connect operation is not allowed (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
}
if operation != admission.Create && operation != admission.Update {
return nil
}
cast, ok := obj.(*collections.DataSourceStack)
if !ok {
return fmt.Errorf("object is not of type *collections.DataSourceStack (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
}
// get the keys from the template
template := cast.Spec.Template
templateNames := map[string]bool{}
for _, item := range template {
// template items cannot be empty
if item.Group == "" || item.Name == "" {
return fmt.Errorf("template items cannot be empty (%s %s)", a.GetName(), a.GetKind().GroupVersion().String())
}
// template names must be unique
if _, exists := templateNames[item.Name]; exists {
return fmt.Errorf("template item names must be unique. name '%s' already exists (%s %s)", item.Name, a.GetName(), a.GetKind().GroupVersion().String())
}
templateNames[item.Name] = true
}
// for each mode, check that the keys are in the template
modes := cast.Spec.Modes
for _, mode := range modes {
for key, item := range mode.Definition {
// if a key is not in the template, return an error
if _, ok := template[key]; !ok {
return fmt.Errorf("key '%s' is not in the DataSourceStack template (%s %s)", key, a.GetName(), a.GetKind().GroupVersion().String())
}
exists, err := v.checkDatasourceExists(ctx, item.DataSourceRef)
if err != nil || !exists {
return fmt.Errorf("datasource '%s' in group '%s' does not exist (%s %s): %w", item.DataSourceRef, template[key].Group, a.GetName(), a.GetKind().GroupVersion().String(), err)
}
}
}
return nil
}
func (v *DatasourceStacksValidator) checkDatasourceExists(ctx context.Context, name string) (bool, error) {
dsConn, err := v.dsClient.GetByUID(ctx, name)
if err != nil {
return false, err
}
if dsConn == nil {
return false, nil
}
return true, nil
}

View File

@@ -0,0 +1,212 @@
package collections_test
import (
"context"
"testing"
collectionsv1alpha1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1"
queryv0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/collections"
datasourcesclient "github.com/grafana/grafana/pkg/services/datasources/service/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
)
func TestDataSourceValidator_Validate(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
operation admission.Operation
object runtime.Object
needMockDSClient bool // only set to true if you expect to make a call to the datasource client
dsClientReturnValue *queryv0alpha1.DataSourceConnection
dsClientReturnError error
expectError bool
errorMsg string
}{
{
name: "should return no error for invalid kind",
operation: admission.Delete,
object: &collectionsv1alpha1.Stars{},
expectError: false,
},
{
name: "should return error for Connect operation",
operation: admission.Connect,
object: &collectionsv1alpha1.DataSourceStack{},
expectError: true,
},
{
name: "template items cannot be empty",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{},
},
},
},
expectError: true,
errorMsg: "template items cannot be empty (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "template item name keys must be unique",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
"key2": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
},
},
expectError: true,
errorMsg: "template item names must be unique. name 'foo' already exists (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "mode keys must exist in the template",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
{
Name: "prod",
Definition: collectionsv1alpha1.DataSourceStackMode{
"notintemplate": collectionsv1alpha1.DataSourceStackModeItem{
DataSourceRef: "foo",
},
},
},
},
},
},
expectError: true,
errorMsg: "key 'notintemplate' is not in the DataSourceStack template (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "error if data source does not exist",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
{
Name: "prod",
Definition: collectionsv1alpha1.DataSourceStackMode{
"key1": collectionsv1alpha1.DataSourceStackModeItem{
DataSourceRef: "ref",
},
},
},
},
},
},
needMockDSClient: true,
dsClientReturnValue: nil, // no result - this is the default anyway
expectError: true,
errorMsg: "datasource 'ref' in group 'foo.grafana' does not exist (test-datasourcestack collections.grafana.app/v1alpha1)",
},
{
name: "valid request",
operation: admission.Create,
object: &collectionsv1alpha1.DataSourceStack{
Spec: collectionsv1alpha1.DataSourceStackSpec{
Template: collectionsv1alpha1.DataSourceStackTemplateSpec{
"key1": collectionsv1alpha1.DataSourceStackDataSourceStackTemplateItem{
Name: "foo",
Group: "foo.grafana",
},
},
Modes: []collectionsv1alpha1.DataSourceStackModeSpec{
{
Name: "prod",
Definition: collectionsv1alpha1.DataSourceStackMode{
"key1": collectionsv1alpha1.DataSourceStackModeItem{
DataSourceRef: "ref",
},
},
},
},
},
},
needMockDSClient: true,
dsClientReturnValue: &queryv0alpha1.DataSourceConnection{}, // returning any non-nil value will pass validation
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
attrs := &FakeAdmissionAttributes{
Operation: tt.operation,
Object: tt.object,
Name: "test-datasourcestack",
Kind: schema.GroupVersionKind{Group: "collections.grafana.app", Version: "v1alpha1", Kind: "DataSourceStack"},
}
var client *datasourcesclient.MockDataSourceConnectionClient
if tt.needMockDSClient {
client = datasourcesclient.NewMockDataSourceConnectionClient(t)
client.On("GetByUID", mock.Anything, mock.Anything).Return(tt.dsClientReturnValue, tt.dsClientReturnError)
}
validator := collections.GetDatasourceStacksValidator(client)
err := validator.Validate(ctx, attrs, nil)
if tt.expectError {
assert.Error(t, err)
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
type FakeAdmissionAttributes struct {
admission.Attributes
Operation admission.Operation
Object runtime.Object
Name string
Kind schema.GroupVersionKind
}
func (m *FakeAdmissionAttributes) GetOperation() admission.Operation {
return m.Operation
}
func (m *FakeAdmissionAttributes) GetObject() runtime.Object {
return m.Object
}
func (m *FakeAdmissionAttributes) GetName() string {
return m.Name
}
func (m *FakeAdmissionAttributes) GetKind() schema.GroupVersionKind {
return m.Kind
}

View File

@@ -1,11 +1,13 @@
package collections
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 {

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

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

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(

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,147 @@
package client
import (
"context"
"errors"
"net/http"
datasourcev0alpha1 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
queryv0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver"
"k8s.io/client-go/kubernetes"
)
// DataSourceConnectionClient can get information about data source connections.
//
//go:generate mockery --name DataSourceConnectionClient --structname MockDataSourceConnectionClient --inpackage --filename=client_mock.go --with-expecter
type DataSourceConnectionClient interface {
GetByUID(ctx context.Context, uid string) (*queryv0alpha1.DataSourceConnection, error)
}
func ProvideDataSourceConnectionClientFactory(
restConfigProvider apiserver.RestConfigProvider,
) DataSourceConnectionClientFactory {
return func(configProvider apiserver.RestConfigProvider) DataSourceConnectionClient {
return &dataSourceConnectionClient{
configProvider: configProvider,
}
}
}
type DataSourceConnectionClientFactory func(configProvider apiserver.RestConfigProvider) DataSourceConnectionClient
type dataSourceConnectionClient struct {
configProvider apiserver.RestConfigProvider
}
func (dc *dataSourceConnectionClient) Get(ctx context.Context, group, version, name string) (*queryv0alpha1.DataSourceConnection, error) {
cfg, err := dc.configProvider.GetRestConfig(ctx)
if err != nil {
return nil, err
}
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
if version == "" {
version = "v0alpha1"
}
result := client.RESTClient().Get().
Prefix("apis", group, version).
Namespace("default"). // TODO do something about namespace
Resource("datasources").
Name(name).
Do(ctx)
if err = result.Error(); err != nil {
return nil, err
}
var statusCode int
result = result.StatusCode(&statusCode)
if statusCode == http.StatusNotFound {
return nil, errors.New("not found")
}
fullDS := datasourcev0alpha1.DataSource{}
err = result.Into(&fullDS)
if err != nil {
return nil, err
}
dsConnection := &queryv0alpha1.DataSourceConnection{
Title: fullDS.Spec.Title(),
Datasource: queryv0alpha1.DataSourceConnectionRef{
Group: fullDS.GroupVersionKind().Group,
Name: fullDS.ObjectMeta.Name,
Version: fullDS.GroupVersionKind().Version,
},
}
return dsConnection, nil
}
func (dc *dataSourceConnectionClient) GetByUID(ctx context.Context, uid string) (*queryv0alpha1.DataSourceConnection, error) {
cfg, err := dc.configProvider.GetRestConfig(ctx)
if err != nil {
return nil, err
}
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
// use the list endpoint with a fieldSelector so that can get multiple results
// in the case of a non-unique "uid". This should not be possible when we are
// backed by the legacy database, but wont be guaranteed when we are using
// uniStore as the names will not be guaranteed unique across apiGroups. We
// error below if more than one result is returned.
result := client.RESTClient().Get().
Prefix("apis", "query.grafana.app", "v0alpha1").
Namespace("default"). // TODO do something about namespace
Resource("connections").
Param("fieldSelector", "metadata.name="+uid).
Do(ctx)
if err = result.Error(); err != nil {
return nil, err
}
var statusCode int
result = result.StatusCode(&statusCode)
if statusCode == http.StatusNotFound {
return nil, errors.New("not found")
}
dsList := datasourcev0alpha1.DataSourceList{}
err = result.Into(&dsList)
if err != nil {
return nil, err
}
if len(dsList.Items) == 0 {
return nil, errors.New("not found")
}
if len(dsList.Items) > 1 {
return nil, errors.New("multiple connections found")
}
fullDS := dsList.Items[0]
dsConnection := &queryv0alpha1.DataSourceConnection{
Title: fullDS.Spec.Title(),
Datasource: queryv0alpha1.DataSourceConnectionRef{
Group: fullDS.GroupVersionKind().Group,
Name: fullDS.ObjectMeta.Name,
Version: fullDS.GroupVersionKind().Version,
},
}
return dsConnection, nil
}

View File

@@ -0,0 +1,96 @@
// Code generated by mockery v2.53.3. DO NOT EDIT.
package client
import (
context "context"
v0alpha1 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockDataSourceConnectionClient is an autogenerated mock type for the DataSourceConnectionClient type
type MockDataSourceConnectionClient struct {
mock.Mock
}
type MockDataSourceConnectionClient_Expecter struct {
mock *mock.Mock
}
func (_m *MockDataSourceConnectionClient) EXPECT() *MockDataSourceConnectionClient_Expecter {
return &MockDataSourceConnectionClient_Expecter{mock: &_m.Mock}
}
// GetByUID provides a mock function with given fields: ctx, uid
func (_m *MockDataSourceConnectionClient) GetByUID(ctx context.Context, uid string) (*v0alpha1.DataSourceConnection, error) {
ret := _m.Called(ctx, uid)
if len(ret) == 0 {
panic("no return value specified for GetByUID")
}
var r0 *v0alpha1.DataSourceConnection
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*v0alpha1.DataSourceConnection, error)); ok {
return rf(ctx, uid)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *v0alpha1.DataSourceConnection); ok {
r0 = rf(ctx, uid)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v0alpha1.DataSourceConnection)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, uid)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockDataSourceConnectionClient_GetByUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByUID'
type MockDataSourceConnectionClient_GetByUID_Call struct {
*mock.Call
}
// GetByUID is a helper method to define mock.On call
// - ctx context.Context
// - uid string
func (_e *MockDataSourceConnectionClient_Expecter) GetByUID(ctx interface{}, uid interface{}) *MockDataSourceConnectionClient_GetByUID_Call {
return &MockDataSourceConnectionClient_GetByUID_Call{Call: _e.mock.On("GetByUID", ctx, uid)}
}
func (_c *MockDataSourceConnectionClient_GetByUID_Call) Run(run func(ctx context.Context, uid string)) *MockDataSourceConnectionClient_GetByUID_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *MockDataSourceConnectionClient_GetByUID_Call) Return(_a0 *v0alpha1.DataSourceConnection, _a1 error) *MockDataSourceConnectionClient_GetByUID_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockDataSourceConnectionClient_GetByUID_Call) RunAndReturn(run func(context.Context, string) (*v0alpha1.DataSourceConnection, error)) *MockDataSourceConnectionClient_GetByUID_Call {
_c.Call.Return(run)
return _c
}
// NewMockDataSourceConnectionClient creates a new instance of MockDataSourceConnectionClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockDataSourceConnectionClient(t interface {
mock.TestingT
Cleanup(func())
}) *MockDataSourceConnectionClient {
mock := &MockDataSourceConnectionClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -1884,14 +1884,6 @@ var (
Owner: grafanaDatavizSquad,
Expression: "false",
},
{
Name: "externalVizSuggestions",
Description: "Enable all plugins to supply visualization suggestions (including 3rd party plugins)",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDatavizSquad,
Expression: "false",
},
{
Name: "preventPanelChromeOverflow",
Description: "Restrict PanelChrome contents with overflow: hidden;",

View File

@@ -256,7 +256,6 @@ cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false
pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false
newGauge,experimental,@grafana/dataviz-squad,false,false,true
newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
256 pluginInstallAPISync experimental @grafana/plugins-platform-backend false false false
257 newGauge experimental @grafana/dataviz-squad false false true
258 newVizSuggestions preview @grafana/dataviz-squad false false true
externalVizSuggestions experimental @grafana/dataviz-squad false false true
259 preventPanelChromeOverflow preview @grafana/grafana-frontend-platform false false true
260 jaegerEnableGrpcEndpoint experimental @grafana/oss-big-tent false false false
261 pluginStoreServiceLoading experimental @grafana/plugins-platform-backend false false false

View File

@@ -1383,20 +1383,6 @@
"codeowner": "@grafana/identity-access-team"
}
},
{
"metadata": {
"name": "externalVizSuggestions",
"resourceVersion": "1763498528748",
"creationTimestamp": "2025-11-18T20:42:08Z"
},
"spec": {
"description": "Enable all plugins to supply visualization suggestions (including 3rd party plugins)",
"stage": "experimental",
"codeowner": "@grafana/dataviz-squad",
"frontend": true,
"expression": "false"
}
},
{
"metadata": {
"name": "extraThemes",

View File

@@ -221,7 +221,7 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode
// Add Service Center as a standalone nav item under Alerts & IRM
if alertsSection := treeRoot.FindById(navtree.NavIDAlertsAndIncidents); alertsSection != nil {
serviceLink := &navtree.NavLink{
Text: "Service center",
Text: "Service Center",
Id: "standalone-plugin-page-slo-services",
Url: s.cfg.AppSubURL + "/a/grafana-slo-app/services",
SortWeight: 1,

View File

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

View File

@@ -8,7 +8,6 @@ import (
"github.com/grafana/grafana/pkg/util/osutil"
)
// nolint:unused
var migratedUnifiedResources = []string{
//"playlists.playlist.grafana.app",
"folders.folder.grafana.app",
@@ -59,16 +58,14 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
// Set indexer config for unified storage
section := cfg.Raw.Section("unified_storage")
cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false)
// TODO: Re-enable once migrations are ready and disabled on cloud
//cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false)
cfg.DisableDataMigrations = true
if !cfg.DisableDataMigrations && cfg.getUnifiedStorageType() == "unified" {
// Helper log to find instances running migrations in the future
cfg.Logger.Info("Unified migration configs not yet enforced")
//cfg.enforceMigrationToUnifiedConfigs() // TODO: uncomment when ready for release
cfg.enforceMigrationToUnifiedConfigs()
} else {
// Helper log to find instances disabling migration
cfg.Logger.Info("Unified migration configs enforcement disabled", "storage_type", cfg.getUnifiedStorageType(), "disable_data_migrations", cfg.DisableDataMigrations)
cfg.EnableSearch = section.Key("enable_search").MustBool(false)
}
cfg.EnableSearch = section.Key("enable_search").MustBool(false)
cfg.MaxPageSizeBytes = section.Key("max_page_size_bytes").MustInt(0)
cfg.IndexPath = section.Key("index_path").String()
cfg.IndexWorkers = section.Key("index_workers").MustInt(10)
@@ -105,7 +102,6 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("")
}
// nolint:unused
// enforceMigrationToUnifiedConfigs enforces configurations required to run migrated resources in mode 5
// All migrated resources in MigratedUnifiedResources are set to mode 5 and unified search is enabled
func (cfg *Cfg) enforceMigrationToUnifiedConfigs() {

View File

@@ -8,7 +8,6 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/migrations/contract"
@@ -56,12 +55,8 @@ func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error {
// skip migrations if disabled in config
if p.cfg.DisableDataMigrations {
metrics.MUnifiedStorageMigrationStatus.Set(1)
logger.Info("Data migrations are disabled, skipping")
return nil
} else {
metrics.MUnifiedStorageMigrationStatus.Set(2)
logger.Info("Data migrations not yet enforced, skipping")
}
// TODO: Re-enable once migrations are ready

View File

@@ -35,33 +35,6 @@ type ListOptions struct {
Limit int64 // maximum number of results to return. 0 means no limit.
}
// BatchOpMode controls the semantics of each operation in a batch
type BatchOpMode int
const (
// BatchOpPut performs an upsert: create or update (never fails on key state)
BatchOpPut BatchOpMode = iota
// BatchOpCreate creates a new key, fails if the key already exists
BatchOpCreate
// BatchOpUpdate updates an existing key, fails if the key doesn't exist
BatchOpUpdate
// BatchOpDelete removes a key, idempotent (never fails on key state)
BatchOpDelete
)
// BatchOp represents a single operation in an atomic batch
type BatchOp struct {
Mode BatchOpMode
Key string
Value []byte // For Put/Create/Update operations, nil for Delete
}
// Maximum limit for batch operations
const MaxBatchOps = 20
// ErrKeyAlreadyExists is returned when BatchOpCreate is used on an existing key
var ErrKeyAlreadyExists = errors.New("key already exists")
type KV interface {
// Keys returns all the keys in the store
Keys(ctx context.Context, section string, opt ListOptions) iter.Seq2[string, error]
@@ -87,17 +60,6 @@ type KV interface {
// UnixTimestamp returns the current time in seconds since Epoch.
// This is used to ensure the server and client are not too far apart in time.
UnixTimestamp(ctx context.Context) (int64, error)
// Batch executes all operations atomically within a single transaction.
// If any operation fails, all operations are rolled back.
// Operations are executed in order; the batch stops on first failure.
//
// Operation semantics:
// - BatchOpPut: Upsert (create or update), never fails on key state
// - BatchOpCreate: Fail with ErrKeyAlreadyExists if key exists
// - BatchOpUpdate: Fail with ErrNotFound if key doesn't exist
// - BatchOpDelete: Idempotent, never fails on key state
Batch(ctx context.Context, section string, ops []BatchOp) error
}
var _ KV = &badgerKV{}
@@ -398,69 +360,3 @@ func (k *badgerKV) BatchDelete(ctx context.Context, section string, keys []strin
return txn.Commit()
}
func (k *badgerKV) Batch(ctx context.Context, section string, ops []BatchOp) error {
if k.db.IsClosed() {
return fmt.Errorf("database is closed")
}
if section == "" {
return fmt.Errorf("section is required")
}
if len(ops) > MaxBatchOps {
return fmt.Errorf("too many operations: %d > %d", len(ops), MaxBatchOps)
}
txn := k.db.NewTransaction(true)
defer txn.Discard()
for _, op := range ops {
keyWithSection := section + "/" + op.Key
switch op.Mode {
case BatchOpCreate:
// Check that key doesn't exist, then set
_, err := txn.Get([]byte(keyWithSection))
if err == nil {
return ErrKeyAlreadyExists
}
if !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err := txn.Set([]byte(keyWithSection), op.Value); err != nil {
return err
}
case BatchOpUpdate:
// Check that key exists, then set
_, err := txn.Get([]byte(keyWithSection))
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
if err := txn.Set([]byte(keyWithSection), op.Value); err != nil {
return err
}
case BatchOpPut:
// Upsert: create or update
if err := txn.Set([]byte(keyWithSection), op.Value); err != nil {
return err
}
case BatchOpDelete:
// Idempotent delete - don't error if not found
if err := txn.Delete([]byte(keyWithSection)); err != nil {
return err
}
default:
return fmt.Errorf("unknown operation mode: %d", op.Mode)
}
}
return txn.Commit()
}

View File

@@ -28,7 +28,6 @@ const (
TestKVUnixTimestamp = "unix timestamp"
TestKVBatchGet = "batch get operations"
TestKVBatchDelete = "batch delete operations"
TestKVBatch = "batch operations"
)
// NewKVFunc is a function that creates a new KV instance for testing
@@ -70,7 +69,6 @@ func RunKVTest(t *testing.T, newKV NewKVFunc, opts *KVTestOptions) {
{TestKVUnixTimestamp, runTestKVUnixTimestamp},
{TestKVBatchGet, runTestKVBatchGet},
{TestKVBatchDelete, runTestKVBatchDelete},
{TestKVBatch, runTestKVBatch},
}
for _, tc := range cases {
@@ -803,259 +801,3 @@ func saveKVHelper(t *testing.T, kv resource.KV, ctx context.Context, section, ke
err = writer.Close()
require.NoError(t, err)
}
func runTestKVBatch(t *testing.T, kv resource.KV, nsPrefix string) {
ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second))
section := nsPrefix + "-batch"
t.Run("batch with empty section", func(t *testing.T) {
err := kv.Batch(ctx, "", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "section is required")
})
t.Run("batch with empty ops succeeds", func(t *testing.T) {
err := kv.Batch(ctx, section, nil)
require.NoError(t, err)
})
t.Run("batch put creates new key", func(t *testing.T) {
ops := []resource.BatchOp{
{Mode: resource.BatchOpPut, Key: "put-key", Value: []byte("put-value")},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify the key was created
reader, err := kv.Get(ctx, section, "put-key")
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "put-value", string(value))
err = reader.Close()
require.NoError(t, err)
})
t.Run("batch put updates existing key", func(t *testing.T) {
// First create a key
saveKVHelper(t, kv, ctx, section, "put-update-key", strings.NewReader("original-value"))
ops := []resource.BatchOp{
{Mode: resource.BatchOpPut, Key: "put-update-key", Value: []byte("updated-value")},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify the key was updated
reader, err := kv.Get(ctx, section, "put-update-key")
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "updated-value", string(value))
err = reader.Close()
require.NoError(t, err)
})
t.Run("batch create succeeds for new key", func(t *testing.T) {
ops := []resource.BatchOp{
{Mode: resource.BatchOpCreate, Key: "create-new-key", Value: []byte("new-value")},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify the key was created
reader, err := kv.Get(ctx, section, "create-new-key")
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "new-value", string(value))
err = reader.Close()
require.NoError(t, err)
})
t.Run("batch create fails for existing key", func(t *testing.T) {
// First create a key
saveKVHelper(t, kv, ctx, section, "create-exists-key", strings.NewReader("existing-value"))
ops := []resource.BatchOp{
{Mode: resource.BatchOpCreate, Key: "create-exists-key", Value: []byte("new-value")},
}
err := kv.Batch(ctx, section, ops)
assert.ErrorIs(t, err, resource.ErrKeyAlreadyExists)
// Verify the original value is unchanged
reader, err := kv.Get(ctx, section, "create-exists-key")
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "existing-value", string(value))
err = reader.Close()
require.NoError(t, err)
})
t.Run("batch update succeeds for existing key", func(t *testing.T) {
// First create a key
saveKVHelper(t, kv, ctx, section, "update-exists-key", strings.NewReader("original-value"))
ops := []resource.BatchOp{
{Mode: resource.BatchOpUpdate, Key: "update-exists-key", Value: []byte("updated-value")},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify the key was updated
reader, err := kv.Get(ctx, section, "update-exists-key")
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "updated-value", string(value))
err = reader.Close()
require.NoError(t, err)
})
t.Run("batch update fails for non-existent key", func(t *testing.T) {
ops := []resource.BatchOp{
{Mode: resource.BatchOpUpdate, Key: "update-nonexistent-key", Value: []byte("new-value")},
}
err := kv.Batch(ctx, section, ops)
assert.ErrorIs(t, err, resource.ErrNotFound)
// Verify the key was not created
_, err = kv.Get(ctx, section, "update-nonexistent-key")
assert.ErrorIs(t, err, resource.ErrNotFound)
})
t.Run("batch delete removes existing key", func(t *testing.T) {
// First create a key
saveKVHelper(t, kv, ctx, section, "delete-exists-key", strings.NewReader("to-be-deleted"))
ops := []resource.BatchOp{
{Mode: resource.BatchOpDelete, Key: "delete-exists-key"},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify the key was deleted
_, err = kv.Get(ctx, section, "delete-exists-key")
assert.ErrorIs(t, err, resource.ErrNotFound)
})
t.Run("batch delete is idempotent for non-existent key", func(t *testing.T) {
ops := []resource.BatchOp{
{Mode: resource.BatchOpDelete, Key: "delete-nonexistent-key"},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err) // Should succeed even though key doesn't exist
})
t.Run("batch multiple operations atomic success", func(t *testing.T) {
ops := []resource.BatchOp{
{Mode: resource.BatchOpPut, Key: "multi-key1", Value: []byte("value1")},
{Mode: resource.BatchOpPut, Key: "multi-key2", Value: []byte("value2")},
{Mode: resource.BatchOpPut, Key: "multi-key3", Value: []byte("value3")},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify all keys were created
for i := 1; i <= 3; i++ {
key := fmt.Sprintf("multi-key%d", i)
reader, err := kv.Get(ctx, section, key)
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, fmt.Sprintf("value%d", i), string(value))
err = reader.Close()
require.NoError(t, err)
}
})
t.Run("batch multiple operations atomic rollback on failure", func(t *testing.T) {
// First create a key that will cause the batch to fail
saveKVHelper(t, kv, ctx, section, "rollback-exists", strings.NewReader("existing"))
ops := []resource.BatchOp{
{Mode: resource.BatchOpPut, Key: "rollback-new1", Value: []byte("value1")},
{Mode: resource.BatchOpCreate, Key: "rollback-exists", Value: []byte("should-fail")}, // This will fail
{Mode: resource.BatchOpPut, Key: "rollback-new2", Value: []byte("value2")},
}
err := kv.Batch(ctx, section, ops)
assert.ErrorIs(t, err, resource.ErrKeyAlreadyExists)
// Verify rollback: the first operation should NOT have persisted
_, err = kv.Get(ctx, section, "rollback-new1")
assert.ErrorIs(t, err, resource.ErrNotFound)
// Verify the third operation was not executed
_, err = kv.Get(ctx, section, "rollback-new2")
assert.ErrorIs(t, err, resource.ErrNotFound)
})
t.Run("batch mixed operations", func(t *testing.T) {
// Setup: create a key to update and one to delete
saveKVHelper(t, kv, ctx, section, "mixed-update", strings.NewReader("original"))
saveKVHelper(t, kv, ctx, section, "mixed-delete", strings.NewReader("to-delete"))
ops := []resource.BatchOp{
{Mode: resource.BatchOpCreate, Key: "mixed-create", Value: []byte("created")},
{Mode: resource.BatchOpUpdate, Key: "mixed-update", Value: []byte("updated")},
{Mode: resource.BatchOpDelete, Key: "mixed-delete"},
{Mode: resource.BatchOpPut, Key: "mixed-put", Value: []byte("put")},
}
err := kv.Batch(ctx, section, ops)
require.NoError(t, err)
// Verify create
reader, err := kv.Get(ctx, section, "mixed-create")
require.NoError(t, err)
value, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "created", string(value))
err = reader.Close()
require.NoError(t, err)
// Verify update
reader, err = kv.Get(ctx, section, "mixed-update")
require.NoError(t, err)
value, err = io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "updated", string(value))
err = reader.Close()
require.NoError(t, err)
// Verify delete
_, err = kv.Get(ctx, section, "mixed-delete")
assert.ErrorIs(t, err, resource.ErrNotFound)
// Verify put
reader, err = kv.Get(ctx, section, "mixed-put")
require.NoError(t, err)
value, err = io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "put", string(value))
err = reader.Close()
require.NoError(t, err)
})
t.Run("batch too many operations", func(t *testing.T) {
ops := make([]resource.BatchOp, resource.MaxBatchOps+1)
for i := range ops {
ops[i] = resource.BatchOp{Mode: resource.BatchOpPut, Key: fmt.Sprintf("key-%d", i), Value: []byte("value")}
}
err := kv.Batch(ctx, section, ops)
assert.Error(t, err)
assert.Contains(t, err.Error(), "too many operations")
})
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,252 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import {
Card,
EmptyState,
FilterInput,
IconButton,
LinkButton,
Spinner,
Stack,
TagList,
useStyles2,
} from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { Resource, ResourceList, GroupVersionResource } from 'app/features/apiserver/types';
// Define the DataSourceStack spec type based on the backend Go types
export interface DataSourceStackTemplateItem {
group: string;
name: string;
}
export interface DataSourceStackModeItem {
dataSourceRef: string;
}
export interface DataSourceStackModeSpec {
name: string;
uid: string;
definition: Record<string, DataSourceStackModeItem>;
}
export interface DataSourceStackSpec {
template: Record<string, DataSourceStackTemplateItem>;
modes: DataSourceStackModeSpec[];
}
// GroupVersionResource for datasourcestacks
const datasourceStacksGVR: GroupVersionResource = {
group: 'collections.grafana.app',
version: 'v1alpha1',
resource: 'datasourcestacks',
};
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
export function DataSourceStacksPage() {
const [stacks, setStacks] = useState<Array<Resource<DataSourceStackSpec>>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const styles = useStyles2(getStyles);
const fetchStacks = useCallback(async () => {
try {
setLoading(true);
const response: ResourceList<DataSourceStackSpec> = await datasourceStacksClient.list();
setStacks(response.items);
} catch (err) {
console.error('Failed to fetch datasource stacks:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch datasource stacks');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStacks();
}, [fetchStacks]);
const onDeleteStack = (stackName: string) => async () => {
await datasourceStacksClient.delete(stackName, false);
fetchStacks();
};
// Filter stacks based on search query
const filteredStacks = useMemo(() => {
if (!searchQuery) {
return stacks;
}
const query = searchQuery.toLowerCase();
return stacks.filter((stack) => {
const nameMatch = stack.metadata.name?.toLowerCase().includes(query);
const templateMatch = Object.values(stack.spec.template).some(
(template) => template.name.toLowerCase().includes(query) || template.group.toLowerCase().includes(query)
);
return nameMatch || templateMatch;
});
}, [stacks, searchQuery]);
const actions =
stacks.length > 0 ? (
<LinkButton variant="primary" icon="plus" href="/connections/stacks/new">
<Trans i18nKey="connections.stacks-list-view.add-stack">Add stack</Trans>
</LinkButton>
) : undefined;
const pageNav = {
text: t('connections.stacks-list-view.title', 'Data source stacks'),
subTitle: t(
'connections.stacks-list-view.subtitle',
'Manage your data source stacks to group environments like dev, staging, and production'
),
};
return (
<Page navId="connections-stacks" pageNav={pageNav} actions={actions}>
<Page.Contents>
<DataSourceStacksListContent
stacks={filteredStacks}
loading={loading}
error={error}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
onDeleteStack={onDeleteStack}
styles={styles}
/>
</Page.Contents>
</Page>
);
}
interface DataSourceStacksListContentProps {
stacks: Array<Resource<DataSourceStackSpec>>;
loading: boolean;
error: string | null;
searchQuery: string;
setSearchQuery: (query: string) => void;
styles: ReturnType<typeof getStyles>;
onDeleteStack: (stackName: string) => () => Promise<void>;
}
function DataSourceStacksListContent({
stacks,
loading,
error,
searchQuery,
setSearchQuery,
styles,
onDeleteStack,
}: DataSourceStacksListContentProps) {
if (loading) {
return <Spinner />;
}
if (error) {
return (
<EmptyState
variant="not-found"
message={t('connections.stacks-list-view.error', 'Failed to load data source stacks')}
>
<div>{error}</div>
</EmptyState>
);
}
if (stacks.length === 0 && !searchQuery) {
return (
<EmptyState
message={t(
'connections.stacks-list-view.empty.no-rules-created',
"You haven't created any data source stacks yet"
)}
variant="call-to-action"
>
<div>
<Trans i18nKey="connections.stacks-list-view.empty.description">
Use data source stacks to group environments like dev, stg, and prod. Reference the stack in your query, and
Grafana automatically selects the right data source for that environment.
</Trans>
</div>
<LinkButton variant="primary" icon="plus" size="lg" href="/connections/stacks/new">
<Trans i18nKey="connections.stacks-list-view.empty.new-stack">New stack</Trans>
</LinkButton>
</EmptyState>
);
}
return (
<Stack direction="column" gap={2}>
<div className={styles.searchContainer}>
<FilterInput
value={searchQuery}
onChange={setSearchQuery}
placeholder={t('connections.stacks-list-view.search-placeholder', 'Search by name or type')}
/>
</div>
{stacks.length === 0 && searchQuery ? (
<EmptyState
variant="not-found"
message={t('connections.stacks-list-view.no-results', 'No data source stacks found')}
/>
) : (
<ul className={styles.list}>
{stacks.map((stack) => (
<li key={stack.metadata.name}>
<Card noMargin href={`/connections/stacks/edit/${stack.metadata.name}`}>
<Card.Heading>{stack.metadata.name}</Card.Heading>
<Card.Tags>
<Stack direction="row" gap={2} alignItems="center">
<TagList tags={getDatasourceList(stack.spec)} />
<IconButton
name="trash-alt"
variant="destructive"
aria-label={t('connections.stacks-list-view.delete-stack', 'Delete stack')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeleteStack(stack.metadata.name)();
}}
/>
</Stack>
</Card.Tags>
</Card>
</li>
))}
</ul>
)}
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
searchContainer: css({
marginBottom: theme.spacing(2),
maxWidth: '500px',
}),
list: css({
listStyle: 'none',
display: 'grid',
gap: theme.spacing(1),
}),
});
const getDatasourceList = (stack: DataSourceStackSpec): string[] => {
return Array.from(
// remove duplicates
new Set(
Object.values(stack.template).map((template) => {
const match = template.group.match(/^grafana-(.+)-datasource$/);
if (match && match[1]) {
return match[1].charAt(0).toUpperCase() + match[1].slice(1);
}
return template.name.charAt(0).toUpperCase() + template.name.slice(1);
})
)
);
};

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { t } from '@grafana/i18n';
import { EmptyState, Spinner } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { Resource, GroupVersionResource } from 'app/features/apiserver/types';
import {
StackForm,
transformStackSpecToFormValues,
} from 'app/features/datasources/components/new-stack-form/StackForm';
import { StackFormValues } from 'app/features/datasources/components/new-stack-form/types';
import { DataSourceStackSpec } from './DataSourceStacksPage';
const datasourceStacksGVR: GroupVersionResource = {
group: 'collections.grafana.app',
version: 'v1alpha1',
resource: 'datasourcestacks',
};
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
export function EditStackPage() {
const { uid } = useParams<{ uid: string }>();
const [stack, setStack] = useState<Resource<DataSourceStackSpec> | null>(null);
const [formValues, setFormValues] = useState<StackFormValues | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchStack = async () => {
if (!uid) {
setError('No stack UID provided');
setLoading(false);
return;
}
try {
setLoading(true);
const response = await datasourceStacksClient.get(uid);
setStack(response);
const values = transformStackSpecToFormValues(response.metadata.name || '', response.spec);
setFormValues(values);
} catch (err) {
console.error('Failed to fetch datasource stack:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch datasource stack');
} finally {
setLoading(false);
}
};
fetchStack();
}, [uid]);
const pageNav = {
text: stack?.metadata.name
? t('connections.edit-stack-page.title-with-name', 'Edit {{name}}', { name: stack.metadata.name })
: t('connections.edit-stack-page.title', 'Edit Data Source Stack'),
subTitle: t('connections.edit-stack-page.subtitle', 'Modify your data source stack configuration'),
};
return (
<Page navId="connections-stacks" pageNav={pageNav}>
<Page.Contents>
<EditStackContent loading={loading} error={error} formValues={formValues} />
</Page.Contents>
</Page>
);
}
interface EditStackContentProps {
loading: boolean;
error: string | null;
formValues: StackFormValues | null;
}
function EditStackContent({ loading, error, formValues }: EditStackContentProps) {
if (loading) {
return <Spinner />;
}
if (error) {
return (
<EmptyState
variant="not-found"
message={t('connections.edit-stack-page.error', 'Failed to load data source stack')}
>
<div>{error}</div>
</EmptyState>
);
}
if (!formValues) {
return (
<EmptyState
variant="not-found"
message={t('connections.edit-stack-page.not-found', 'Data source stack not found')}
/>
);
}
return <StackForm existing={formValues} />;
}

View File

@@ -0,0 +1,19 @@
import { Page } from 'app/core/components/Page/Page';
import { StackForm } from 'app/features/datasources/components/new-stack-form/StackForm';
export function NewStackPage() {
return (
<Page
navId="connections-stacks"
pageNav={{
text: 'New Data Source Stack',
subTitle: 'Add a new data source stack',
active: true,
}}
>
<Page.Contents>
<StackForm />
</Page.Contents>
</Page>
);
}

View File

@@ -31,6 +31,7 @@ import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import { PanelModelCompatibilityWrapper } from '../utils/PanelModelCompatibilityWrapper';
import {
activateSceneObjectAndParentTree,
getDashboardSceneFor,
@@ -120,7 +121,8 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
dataObject.subscribeToState(async () => {
const { data } = dataObject.state;
if (hasData(data) && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) {
const suggestions = await getAllSuggestions(data);
const panelModel = new PanelModelCompatibilityWrapper(panel);
const suggestions = await getAllSuggestions(data, panelModel);
if (suggestions.length > 0) {
const defaultFirstSuggestion = suggestions[0];

View File

@@ -171,7 +171,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
>
<div className={cx(styles.rightControls, editPanel && styles.rightControlsWrap)}>
{!hideTimeControls && (
<div className={styles.fixedControls}>
<div className={styles.timeControls}>
<timePicker.Component model={timePicker} />
<refreshPicker.Component model={refreshPicker} />
</div>
@@ -181,11 +181,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
<DashboardControlsButton dashboard={dashboard} />
</div>
)}
{config.featureToggles.dashboardNewLayouts && (
<div className={styles.fixedControls}>
<DashboardControlActions dashboard={dashboard} />
</div>
)}
{config.featureToggles.dashboardNewLayouts && <DashboardControlActions dashboard={dashboard} />}
{!hideLinksControls && !editPanel && <DashboardLinksControls links={links} dashboard={dashboard} />}
</div>
{!hideVariableControls && (
@@ -278,12 +274,12 @@ function getStyles(theme: GrafanaTheme2) {
display: 'flex',
gap: theme.spacing(1),
float: 'right',
alignItems: 'flex-start',
alignItems: 'center',
flexWrap: 'wrap',
maxWidth: '100%',
minWidth: 0,
}),
fixedControls: css({
timeControls: css({
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing(1),

View File

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

View File

@@ -294,6 +294,7 @@ exports[`Given a scene with custom quick ranges should save quick ranges to save
"options": [],
"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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { render, fireEvent } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { selectors } from '@grafana/e2e-selectors';
@@ -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();
});
});
});

View File

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

View File

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

View File

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

View File

@@ -5,13 +5,50 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n';
import { VariableValueOption } from '@grafana/scenes';
import { Button, InlineFieldRow, InlineLabel, useStyles2, Text } from '@grafana/ui';
import { Button, InlineFieldRow, InlineLabel, InteractiveTable, Text, useStyles2 } from '@grafana/ui';
export interface VariableValuesPreviewProps {
export interface Props {
options: VariableValueOption[];
hasMultiProps?: boolean;
}
export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) => {
export const VariableValuesPreview = ({ options, hasMultiProps }: Props) => {
if (!options.length) {
return null;
}
if (hasMultiProps) {
return <VariableValuesWithPropsPreview options={options} />;
}
return <VariableValuesWithoutPropsPreview options={options} />;
};
VariableValuesPreview.displayName = 'VariableValuesPreview';
function VariableValuesWithPropsPreview({ options }: { options: VariableValueOption[] }) {
const styles = useStyles2(getStyles);
const data = options.map((o) => ({ label: String(o.label), value: String(o.value), ...o.properties }));
// the first item in data may be the "All" option, which does not have any extra properties, so we try the 2nd item to determine the column names
const columns = Object.keys(data[1] || data[0]).map((id) => ({ id, header: id, sortType: 'alphanumeric' as const }));
return (
<div className={styles.previewContainer} style={{ gap: '8px' }}>
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
</Text>
<InteractiveTable
className={styles.table}
columns={columns}
data={data}
getRowId={(r) => String(r.value)}
pageSize={8}
/>
</div>
);
}
function VariableValuesWithoutPropsPreview({ options }: { options: VariableValueOption[] }) {
const styles = useStyles2(getStyles);
const [previewLimit, setPreviewLimit] = useState(20);
const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]);
const showMoreOptions = useCallback(
@@ -21,15 +58,10 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
},
[previewLimit, setPreviewLimit]
);
const styles = useStyles2(getStyles);
useEffect(() => setPreviewOptions(options.slice(0, previewLimit)), [previewLimit, options]);
if (!previewOptions.length) {
return null;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', marginTop: '16px' }}>
<div className={styles.previewContainer}>
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
</Text>
@@ -51,12 +83,12 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
)}
</div>
);
};
VariableValuesPreview.displayName = 'VariableValuesPreview';
}
VariableValuesWithoutPropsPreview.displayName = 'VariableValuesWithoutPropsPreview';
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
previewContainer: css({
display: 'flex',
flexDirection: 'column',
marginTop: theme.spacing(2),
@@ -71,5 +103,10 @@ function getStyles(theme: GrafanaTheme2) {
textOverflow: 'ellipsis',
maxWidth: '50vw',
}),
table: css({
td: css({
padding: theme.spacing(0.5, 1),
}),
}),
};
}

View File

@@ -5,117 +5,225 @@ import { CustomVariable } from '@grafana/scenes';
import { CustomVariableEditor } from './CustomVariableEditor';
function setup(options: Partial<ConstructorParameters<typeof CustomVariable>[0]> = {}) {
return {
variable: new CustomVariable({
name: 'customVar',
...options,
}),
onRunQuery: jest.fn(),
};
}
function renderEditor(ui: React.ReactNode) {
const renderResult = render(ui);
const elements = {
formatButton: (label: string) => renderResult.queryByLabelText(label) as HTMLElement,
queryInput: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
) as HTMLTextAreaElement,
multiValueCheckbox: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
) as HTMLInputElement,
allowCustomValueCheckbox: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
) as HTMLInputElement,
includeAllCheckbox: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
) as HTMLInputElement,
customAllValueInput: () =>
renderResult.queryByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
) as HTMLInputElement,
};
return {
...renderResult,
elements,
actions: {
updateValuesInput(newQuery: string) {
fireEvent.change(elements.queryInput(), { target: { value: newQuery } });
fireEvent.blur(elements.queryInput());
},
changeValuesFormat(newFormat: 'csv' | 'json') {
const targetLabel = newFormat === 'json' ? 'Object values in a JSON array' : 'Values separated by comma';
const formatButton = elements.formatButton(targetLabel);
if (formatButton === null) {
throw new Error(`Unable to fire a "click" event - button with label "${targetLabel}" not found in DOM`);
}
fireEvent.click(formatButton);
},
},
};
}
describe('CustomVariableEditor', () => {
it('should render the CustomVariableForm with correct initial values', () => {
const variable = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
isMulti: true,
includeAll: true,
allValue: 'test',
describe('CSV values format', () => {
it('should render CustomVariableForm with the correct initial values', () => {
const { variable, onRunQuery } = setup({
query: 'test, test2',
value: 'test',
isMulti: true,
includeAll: true,
allowCustomValue: true,
allValue: 'all',
});
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
expect(elements.queryInput().value).toBe('test, test2');
expect(elements.multiValueCheckbox().checked).toBe(true);
expect(elements.allowCustomValueCheckbox().checked).toBe(true);
expect(elements.includeAllCheckbox().checked).toBe(true);
expect(elements.customAllValueInput().value).toBe('all');
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
it('should update the variable state when some input values change ("Multi-value", "Allow custom values" & "Include All option")', () => {
const { variable, onRunQuery } = setup({
query: 'test, test2',
value: 'test',
isMulti: false,
allowCustomValue: false,
includeAll: false,
});
const queryInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
) as HTMLInputElement;
const allValueInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
) as HTMLInputElement;
const multiCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
) as HTMLInputElement;
const includeAllCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
) as HTMLInputElement;
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
expect(queryInput.value).toBe('test, test2');
expect(allValueInput.value).toBe('test');
expect(multiCheckbox.checked).toBe(true);
expect(includeAllCheckbox.checked).toBe(true);
expect(elements.multiValueCheckbox().checked).toBe(false);
expect(elements.allowCustomValueCheckbox().checked).toBe(false);
expect(elements.includeAllCheckbox().checked).toBe(false);
// include-all-custom input appears after include-all checkbox is checked only
expect(elements.customAllValueInput()).not.toBeInTheDocument();
fireEvent.click(elements.multiValueCheckbox());
fireEvent.click(elements.allowCustomValueCheckbox());
fireEvent.click(elements.includeAllCheckbox());
expect(variable.state.isMulti).toBe(true);
expect(variable.state.allowCustomValue).toBe(true);
expect(variable.state.includeAll).toBe(true);
expect(elements.customAllValueInput()).toBeInTheDocument();
});
describe('when the values textarea loses focus after its value has changed', () => {
it('should update the query in the variable state and call the onRunQuery callback', async () => {
const { variable, onRunQuery } = setup({ query: 'test, test2', value: 'test' });
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
actions.updateValuesInput('test3, test4');
expect(variable.state.query).toBe('test3, test4');
expect(onRunQuery).toHaveBeenCalled();
});
});
describe('when the "Custom all value" input loses focus after its value has changed', () => {
it('should update the variable state', () => {
const { variable, onRunQuery } = setup({
query: 'test, test2',
value: 'test',
isMulti: true,
includeAll: true,
});
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
fireEvent.change(elements.customAllValueInput(), { target: { value: 'new custom all' } });
fireEvent.blur(elements.customAllValueInput());
expect(variable.state.allValue).toBe('new custom all');
});
});
});
it('should update the variable state when input values change', () => {
const variable = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
describe('JSON values format', () => {
const initialJsonQuery = `[
{"value":1,"text":"Development","aws":"dev","azure":"development"},
{"value":2,"text":"Production","aws":"prod","azure":"production"}
]`;
it('should render CustomVariableForm with the correct initial values', () => {
const { variable, onRunQuery } = setup({
valuesFormat: 'json',
query: initialJsonQuery,
isMulti: true,
includeAll: true,
});
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
expect(elements.queryInput().value).toBe(initialJsonQuery);
expect(elements.multiValueCheckbox().checked).toBe(true);
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
expect(elements.includeAllCheckbox().checked).toBe(true);
expect(elements.customAllValueInput()).not.toBeInTheDocument();
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
describe('when the values textarea loses focus after its value has changed', () => {
describe('if the value is valid JSON', () => {
it('should update the query in the variable state and call the onRunQuery callback', async () => {
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
const multiCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
);
const includeAllCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
);
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
const allowCustomValueCheckbox = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
);
actions.updateValuesInput('[]');
// It include-all-custom input appears after include-all checkbox is checked only
expect(() =>
getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput)
).toThrow('Unable to find an element');
expect(variable.state.query).toBe('[]');
expect(onRunQuery).toHaveBeenCalled();
});
});
fireEvent.click(allowCustomValueCheckbox);
describe('if the value is NOT valid JSON', () => {
it('should display a validation error message and neither update the query in the variable state nor call the onRunQuery callback', async () => {
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
fireEvent.click(multiCheckbox);
const { actions, getByRole } = renderEditor(
<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />
);
fireEvent.click(includeAllCheckbox);
const allValueInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
);
actions.updateValuesInput('[x]');
expect(variable.state.isMulti).toBe(true);
expect(variable.state.includeAll).toBe(true);
expect(variable.state.allowCustomValue).toBe(false);
expect(allValueInput).toBeInTheDocument();
expect(getByRole('alert')).toHaveTextContent(`Unexpected token 'x', "[x]" is not valid JSON`);
expect(variable.state.query).toBe(initialJsonQuery);
expect(onRunQuery).not.toHaveBeenCalled();
});
});
});
});
it('should call update query and re-run query when input loses focus', async () => {
const variable = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
describe('when switching values format', () => {
it('should switch the visibility of the proper form inputs ("Allow custom values" and "Custom all value")', () => {
const { variable, onRunQuery } = setup({
valuesFormat: 'csv',
query: '',
isMulti: true,
includeAll: true,
allowCustomValue: true,
allValue: '',
});
const { elements, actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
expect(elements.customAllValueInput()).toBeInTheDocument();
actions.changeValuesFormat('json');
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
expect(elements.customAllValueInput()).not.toBeInTheDocument();
actions.changeValuesFormat('csv');
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
expect(elements.customAllValueInput()).toBeInTheDocument();
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput);
fireEvent.change(queryInput, { target: { value: 'test3, test4' } });
fireEvent.blur(queryInput);
expect(onRunQuery).toHaveBeenCalled();
expect(variable.state.query).toBe('test3, test4');
});
it('should update the variable state when all-custom-value input loses focus', () => {
const variable = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
isMulti: true,
includeAll: true,
});
const onRunQuery = jest.fn();
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
const allValueInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
) as HTMLInputElement;
fireEvent.change(allValueInput, { target: { value: 'new custom all' } });
fireEvent.blur(allValueInput);
expect(variable.state.allValue).toBe('new custom all');
});
});

View File

@@ -1,5 +1,7 @@
import { FormEvent, useCallback } from 'react';
import { isObject } from 'lodash';
import { FormEvent, useCallback, useState } from 'react';
import { CustomVariableModel, shallowCompare } from '@grafana/data';
import { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
@@ -14,7 +16,26 @@ interface CustomVariableEditorProps {
}
export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) {
const { query, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
const { query, valuesFormat, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
const [queryValidationError, setQueryValidationError] = useState<Error>();
const [prevQuery, setPrevQuery] = useState('');
const onValuesFormatChange = useCallback(
(format: CustomVariableModel['valuesFormat']) => {
variable.setState({ query: prevQuery });
variable.setState({ value: isMulti ? [] : undefined });
variable.setState({ valuesFormat: format });
variable.setState({ allowCustomValue: false });
variable.setState({ allValue: undefined });
onRunQuery();
setQueryValidationError(undefined);
if (query !== prevQuery) {
setPrevQuery(query);
}
},
[isMulti, onRunQuery, prevQuery, query, variable]
);
const onMultiChange = useCallback(
(event: FormEvent<HTMLInputElement>) => {
@@ -32,10 +53,20 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
const onQueryChange = useCallback(
(event: FormEvent<HTMLTextAreaElement>) => {
setPrevQuery('');
if (valuesFormat === 'json') {
const validationError = validateJsonQuery(event.currentTarget.value);
setQueryValidationError(validationError);
if (validationError) {
return;
}
}
variable.setState({ query: event.currentTarget.value });
onRunQuery();
},
[variable, onRunQuery]
[valuesFormat, variable, onRunQuery]
);
const onAllValueChange = useCallback(
@@ -55,15 +86,18 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
return (
<CustomVariableForm
query={query ?? ''}
valuesFormat={valuesFormat ?? 'csv'}
multi={!!isMulti}
allValue={allValue ?? ''}
includeAll={!!includeAll}
allowCustomValue={allowCustomValue}
queryValidationError={queryValidationError}
onQueryChange={onQueryChange}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onQueryChange={onQueryChange}
onAllValueChange={onAllValueChange}
onAllowCustomValueChange={onAllowCustomValueChange}
onValuesFormatChange={onValuesFormatChange}
/>
);
}
@@ -81,3 +115,47 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
}),
];
}
export const validateJsonQuery = (rawQuery: string): Error | undefined => {
const query = rawQuery.trim();
if (!query) {
return;
}
try {
const options = JSON.parse(query);
if (!Array.isArray(options)) {
throw new Error('Enter a valid JSON array of objects');
}
if (!options.length) {
return;
}
let errorIndex = options.findIndex((item) => !isObject(item));
if (errorIndex !== -1) {
throw new Error(`All items must be objects. The item at index ${errorIndex} is not an object.`);
}
const keys = Object.keys(options[0]);
if (!keys.includes('value')) {
throw new Error('Each object in the array must include at least a "value" property');
}
if (keys.includes('')) {
throw new Error('Object property names cannot be empty strings');
}
errorIndex = options.findIndex((o) => !shallowCompare(keys, Object.keys(o)));
if (errorIndex !== -1) {
throw new Error(
`All objects must have the same set of properties. The object at index ${errorIndex} does not match the expected properties`
);
}
return;
} catch (error) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return error as Error;
}
};

View File

@@ -1,15 +1,16 @@
import { useCallback, useRef } from 'react';
import { FormEvent, useCallback, useState } from 'react';
import { lastValueFrom } from 'rxjs';
import { CustomVariableModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { CustomVariable } from '@grafana/scenes';
import { Button, Modal, Stack } from '@grafana/ui';
import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana/ui';
import { VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
import { ValuesFormatSelector } from '../../components/CustomVariableForm';
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
import { ValuesBuilder } from './ValuesBuilder';
import { ValuesPreview } from './ValuesPreview';
import { validateJsonQuery } from './CustomVariableEditor';
interface ModalEditorProps {
variable: CustomVariable;
@@ -18,9 +19,49 @@ interface ModalEditorProps {
}
export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
const { query, valuesFormat, isMulti } = variable.useState();
const [prevQuery, setPrevQuery] = useState('');
const [queryValidationError, setQueryValidationError] = useState<Error>();
const handleOnAdd = useCallback(() => formRef.current?.addItem(), []);
const onValuesFormatChange = useCallback(
async (format: CustomVariableModel['valuesFormat']) => {
variable.setState({ query: prevQuery });
variable.setState({ value: isMulti ? [] : undefined });
variable.setState({ valuesFormat: format });
variable.setState({ allowCustomValue: false });
variable.setState({ allValue: undefined });
await lastValueFrom(variable.validateAndUpdate());
setQueryValidationError(undefined);
if (query !== prevQuery) {
setPrevQuery(query);
}
},
[isMulti, prevQuery, query, variable]
);
const onQueryChange = useCallback(
async (event: FormEvent<HTMLTextAreaElement>) => {
setPrevQuery('');
if (valuesFormat === 'json') {
const validationError = validateJsonQuery(event.currentTarget.value);
setQueryValidationError(validationError);
if (validationError) {
return;
}
}
variable.setState({ query: event.currentTarget.value });
await lastValueFrom(variable.validateAndUpdate());
},
[valuesFormat, variable]
);
const optionsForSelect = variable.getOptionsForSelect(false);
const hasJsonValuesFormat = variable.state.valuesFormat === 'json';
const hasMultiProps = hasJsonValuesFormat || optionsForSelect.every((o) => Boolean(o.properties));
return (
<Modal
@@ -29,10 +70,31 @@ export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
onDismiss={onClose}
>
<Stack direction="column" gap={2}>
<ValuesBuilder variable={variable} ref={formRef} />
<ValuesPreview variable={variable} />
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
<div>
<TextArea
id={valuesFormat}
key={valuesFormat}
rows={4}
defaultValue={query}
onBlur={onQueryChange}
placeholder={
valuesFormat === 'json'
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'1, 10, mykey : myvalue, myvalue, escaped\,value'
}
required
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
/>
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
</div>
<div>
<VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />
</div>
</Stack>
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={handleOnAdd} />}>
<Modal.ButtonRow>
<Button
variant="secondary"
fill="outline"

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, FormEvent } from 'react';
import { useState, FormEvent, useMemo, useEffect } from 'react';
import { useAsync } from 'react-use';
import { SelectableValue, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data';
@@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { QueryVariable, sceneGraph, SceneVariable } from '@grafana/scenes';
import { VariableRefresh, VariableSort } from '@grafana/schema';
import { Box, Button, Field, Modal, TextLink } from '@grafana/ui';
import { Box, Button, Field, Modal, Switch, TextLink } from '@grafana/ui';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
@@ -44,6 +44,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
allowCustomValue,
staticOptions,
staticOptionsOrder,
options,
} = variable.useState();
const { value: timeRange } = sceneGraph.getTimeRange(variable).useState();
@@ -93,6 +94,17 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
variable.setState({ staticOptionsOrder });
};
const hasMultiProps = useMemo(() => options.every((o) => Boolean(o.properties)), [options]);
useEffect(() => {
if (hasMultiProps) {
variable.setState({ allowCustomValue: false });
variable.setState({ allValue: '' });
variable.setState({ regex: '' });
variable.setState({ staticOptions: [] });
}
}, [hasMultiProps, variable]);
return (
<QueryVariableEditorForm
datasource={datasource ?? undefined}
@@ -103,6 +115,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
timeRange={timeRange}
regex={regex}
onRegExChange={onRegExChange}
disableRegexEdition={hasMultiProps}
sort={sort}
onSortChange={onSortChange}
refresh={refresh}
@@ -113,12 +126,15 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
onIncludeAllChange={onIncludeAllChange}
allValue={allValue ?? ''}
onAllValueChange={onAllValueChange}
disableCustomAllValue={hasMultiProps}
allowCustomValue={allowCustomValue}
onAllowCustomValueChange={onAllowCustomValueChange}
disableAllowCustomValue={hasMultiProps}
staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder}
onStaticOptionsChange={onStaticOptionsChange}
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
disableStaticOptions={hasMultiProps}
/>
);
}
@@ -250,6 +266,19 @@ export function Editor({ variable }: { variable: QueryVariable }) {
const isHasVariableOptions = hasVariableOptions(variable);
// TODO: remove me after finished testing - each DS can/should implement their own UI
const [returnsMultiProps, setReturnsMultiProps] = useState(false);
const onChangeReturnsMultipleProps = (e: FormEvent<HTMLInputElement>) => {
setReturnsMultiProps(e.currentTarget.checked);
variable.setState({ allowCustomValue: false });
variable.setState({ allValue: '' });
variable.setState({ regex: '' });
onStaticOptionsChange?.([]);
};
const optionsForSelect = variable.getOptionsForSelect(false);
const hasMultiProps = returnsMultiProps || optionsForSelect.every((o) => Boolean(o.properties));
return (
<div data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.editor}>
<Field
@@ -261,43 +290,64 @@ export function Editor({ variable }: { variable: QueryVariable }) {
</Field>
{selectedDatasource && VariableQueryEditor && (
<QueryEditor
onQueryChange={onQueryChange}
onLegacyQueryChange={onQueryChange}
datasource={selectedDatasource}
query={query}
VariableQueryEditor={VariableQueryEditor}
timeRange={timeRange}
/>
<Box marginBottom={2}>
<QueryEditor
onQueryChange={onQueryChange}
onLegacyQueryChange={onQueryChange}
datasource={selectedDatasource}
query={query}
VariableQueryEditor={VariableQueryEditor}
timeRange={timeRange}
/>
{/* TODO: remove me after finished testing - each DS can/should implement their own UI */}
<Field
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
label="Enable access to all the fields of the query results"
description={
<Trans i18nKey="">
Check{' '}
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
our docs
</TextLink>{' '}
for more information.
</Trans>
}
noMargin
>
<Switch onChange={onChangeReturnsMultipleProps} />
</Field>
</Box>
)}
<VariableTextAreaField
defaultValue={regex ?? ''}
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
description={
<div>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
Optional, if you want to extract part of a series name or metric node segment.
</Trans>
<br />
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
Named capture groups can be used to separate the display text and value (
<TextLink
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
external
>
see examples
</TextLink>
).
</Trans>
</div>
}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
onBlur={onRegExChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
width={52}
/>
{!returnsMultiProps && (
<VariableTextAreaField
defaultValue={regex ?? ''}
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
description={
<div>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
Optional, if you want to extract part of a series name or metric node segment.
</Trans>
<br />
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
Named capture groups can be used to separate the display text and value (
<TextLink
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
external
>
see examples
</TextLink>
).
</Trans>
</div>
}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
onBlur={onRegExChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
width={52}
/>
)}
<QueryVariableSortSelect
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2}
@@ -311,7 +361,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
refresh={refresh}
/>
{onStaticOptionsChange && onStaticOptionsOrderChange && (
{!returnsMultiProps && onStaticOptionsChange && onStaticOptionsOrderChange && (
<QueryVariableStaticOptions
staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder}
@@ -320,7 +370,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
/>
)}
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
</div>
);
}

View File

@@ -45,7 +45,11 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
'A wildcard regex or other value to represent All'
),
useShowIf: () => {
return variable.useState().includeAll ?? false;
const state = variable.useState();
const hasMultiProps =
('valuesFormat' in state && state.valuesFormat === 'json') ||
state.options.every((o) => Boolean(o.properties));
return hasMultiProps ? false : (state.includeAll ?? false);
},
render: (descriptor) => <CustomAllValueInput id={descriptor.props.id} variable={variable} />,
})
@@ -58,6 +62,13 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
'dashboard.edit-pane.variable.selection-options.allow-custom-values-description',
'Enables users to enter values'
),
useShowIf: () => {
const state = variable.useState();
const hasMultiProps =
('valuesFormat' in state && state.valuesFormat === 'json') ||
state.options.every((o) => Boolean(o.properties));
return !hasMultiProps;
},
render: (descriptor) => <AllowCustomSwitch id={descriptor.props.id} variable={variable} />,
})
);

View File

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

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