diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index 2aba16b9489..6bad69b84a9 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -221,6 +221,9 @@ FieldConfig: { // The behavior when clicking on a result links?: [...] + // Define interactive HTTP requests that can be triggered from data visualizations. + actions?: [...Action] + // Alternative to empty string noValue?: string @@ -364,6 +367,47 @@ FieldColor: { // Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) DashboardLinkType: "link" | "dashboards" +ActionType: "fetch" | "infinity" + +FetchOptions: { + method: HttpRequestMethod + url: string + body?: string + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: [...[...string]] + headers?: [...[...string]] +} + +InfinityOptions: FetchOptions & { + datasourceUid: string +} + +HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH" + +// Action variable type +ActionVariableType: "string" + +ActionVariable: { + key: string + name: string + type: ActionVariableType +} + +Action: { + type: ActionType + title: string + fetch?: FetchOptions + infinity?: InfinityOptions + confirmation?: string + oneClick?: bool + variables?: [...ActionVariable] + style?: { + backgroundColor?: string + } +} + // --- Common types --- Kind: { kind: string diff --git a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue index 0e9dcee43b4..93a195a732e 100644 --- a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue @@ -219,6 +219,9 @@ FieldConfig: { // The behavior when clicking on a result links?: [...] + // Define interactive HTTP requests that can be triggered from data visualizations. + actions?: [...Action] + // Alternative to empty string noValue?: string @@ -362,6 +365,48 @@ FieldColor: { // Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) DashboardLinkType: "link" | "dashboards" +ActionType: "fetch" | "infinity" + +FetchOptions: { + method: HttpRequestMethod + url: string + body?: string + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: [...[...string]] + headers?: [...[...string]] +} + +InfinityOptions: FetchOptions & { + datasourceUid: string +} + +HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH" + +// Action variable type +ActionVariableType: "string" + +ActionVariable: { + key: string + name: string + type: ActionVariableType +} + +Action: { + type: ActionType + title: string + fetch?: FetchOptions + infinity?: InfinityOptions + confirmation?: string + oneClick?: bool + variables?: [...ActionVariable] + style?: { + backgroundColor?: string + } +} + + // --- Common types --- Kind: { kind: string diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue index 895a7bc946a..fb971b2cc3f 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue @@ -302,6 +302,59 @@ lineage: schemas: [{ // - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu #DashboardLinkPlacement: "inControlsMenu" @cuetsy(kind="type") + // Dashboard action type + #ActionType: "fetch" | "infinity" @cuetsy(kind="type") + + // Fetch options + #FetchOptions: { + method: #HttpRequestMethod + url: string + body?: string + // These are 2D arrays of strings, each representing a key-value pair + // We are defining this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: [...[...string]] + headers?: [...[...string]] + } @cuetsy(kind="interface") + + // Infinity options + #InfinityOptions: { + method: #HttpRequestMethod + url: string + body?: string + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: [...[...string]] + headers?: [...[...string]] + datasourceUid: string + } @cuetsy(kind="interface") + + #HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH" @cuetsy(kind="type") + + // Action variable type + #ActionVariableType: "string" @cuetsy(kind="type") + + #ActionVariable: { + key: string + name: string + type: #ActionVariableType + } @cuetsy(kind="interface") + + // Dashboard action + #Action: { + type: #ActionType + title: string + fetch?: #FetchOptions + infinity?: #InfinityOptions + confirmation?: string + oneClick?: bool + variables?: [...#ActionVariable] + style?: { + backgroundColor?: string + } + } @cuetsy(kind="interface") + // Dashboard variable type // `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. // `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). @@ -731,6 +784,9 @@ lineage: schemas: [{ // The behavior when clicking on a result links?: [...] @grafanamaturity(NeedsExpertReview) + // Define interactive HTTP requests that can be triggered from data visualizations. + actions?: [...#Action] @grafanamaturity(NeedsExpertReview) + // Alternative to empty string noValue?: string @grafanamaturity(NeedsExpertReview) diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue index 895a7bc946a..fb971b2cc3f 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue @@ -302,6 +302,59 @@ lineage: schemas: [{ // - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu #DashboardLinkPlacement: "inControlsMenu" @cuetsy(kind="type") + // Dashboard action type + #ActionType: "fetch" | "infinity" @cuetsy(kind="type") + + // Fetch options + #FetchOptions: { + method: #HttpRequestMethod + url: string + body?: string + // These are 2D arrays of strings, each representing a key-value pair + // We are defining this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: [...[...string]] + headers?: [...[...string]] + } @cuetsy(kind="interface") + + // Infinity options + #InfinityOptions: { + method: #HttpRequestMethod + url: string + body?: string + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: [...[...string]] + headers?: [...[...string]] + datasourceUid: string + } @cuetsy(kind="interface") + + #HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH" @cuetsy(kind="type") + + // Action variable type + #ActionVariableType: "string" @cuetsy(kind="type") + + #ActionVariable: { + key: string + name: string + type: #ActionVariableType + } @cuetsy(kind="interface") + + // Dashboard action + #Action: { + type: #ActionType + title: string + fetch?: #FetchOptions + infinity?: #InfinityOptions + confirmation?: string + oneClick?: bool + variables?: [...#ActionVariable] + style?: { + backgroundColor?: string + } + } @cuetsy(kind="interface") + // Dashboard variable type // `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. // `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). @@ -731,6 +784,9 @@ lineage: schemas: [{ // The behavior when clicking on a result links?: [...] @grafanamaturity(NeedsExpertReview) + // Define interactive HTTP requests that can be triggered from data visualizations. + actions?: [...#Action] @grafanamaturity(NeedsExpertReview) + // Alternative to empty string noValue?: string @grafanamaturity(NeedsExpertReview) diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue index f566d3775b6..602f639f81a 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue @@ -225,6 +225,9 @@ FieldConfig: { // The behavior when clicking on a result links?: [...] + // Define interactive HTTP requests that can be triggered from data visualizations. + actions?: [...Action] + // Alternative to empty string noValue?: string @@ -368,6 +371,47 @@ FieldColor: { // Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) DashboardLinkType: "link" | "dashboards" +ActionType: "fetch" | "infinity" + +FetchOptions: { + method: HttpRequestMethod + url: string + body?: string + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: [...[...string]] + headers?: [...[...string]] +} + +InfinityOptions: FetchOptions & { + datasourceUid: string +} + +HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH" + +// Action variable type +ActionVariableType: "string" + +ActionVariable: { + key: string + name: string + type: ActionVariableType +} + +Action: { + type: ActionType + title: string + fetch?: FetchOptions + infinity?: InfinityOptions + confirmation?: string + oneClick?: bool + variables?: [...ActionVariable] + style?: { + backgroundColor?: string + } +} + // --- Common types --- Kind: { kind: string diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index f1e36092724..3f4c7d9f1f5 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -392,6 +392,8 @@ type DashboardFieldConfig struct { Color *DashboardFieldColor `json:"color,omitempty"` // The behavior when clicking on a result Links []interface{} `json:"links,omitempty"` + // Define interactive HTTP requests that can be triggered from data visualizations. + Actions []DashboardAction `json:"actions,omitempty"` // Alternative to empty string NoValue *string `json:"noValue,omitempty"` // custom is specified by the FieldConfig field @@ -623,6 +625,95 @@ const ( DashboardFieldColorSeriesByModeLast DashboardFieldColorSeriesByMode = "last" ) +// +k8s:openapi-gen=true +type DashboardAction struct { + Type DashboardActionType `json:"type"` + Title string `json:"title"` + Fetch *DashboardFetchOptions `json:"fetch,omitempty"` + Infinity *DashboardInfinityOptions `json:"infinity,omitempty"` + Confirmation *string `json:"confirmation,omitempty"` + OneClick *bool `json:"oneClick,omitempty"` + Variables []DashboardActionVariable `json:"variables,omitempty"` + Style *DashboardV2alpha1ActionStyle `json:"style,omitempty"` +} + +// NewDashboardAction creates a new DashboardAction object. +func NewDashboardAction() *DashboardAction { + return &DashboardAction{} +} + +// +k8s:openapi-gen=true +type DashboardActionType string + +const ( + DashboardActionTypeFetch DashboardActionType = "fetch" + DashboardActionTypeInfinity DashboardActionType = "infinity" +) + +// +k8s:openapi-gen=true +type DashboardFetchOptions struct { + Method DashboardHttpRequestMethod `json:"method"` + Url string `json:"url"` + Body *string `json:"body,omitempty"` + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + QueryParams [][]string `json:"queryParams,omitempty"` + Headers [][]string `json:"headers,omitempty"` +} + +// NewDashboardFetchOptions creates a new DashboardFetchOptions object. +func NewDashboardFetchOptions() *DashboardFetchOptions { + return &DashboardFetchOptions{} +} + +// +k8s:openapi-gen=true +type DashboardHttpRequestMethod string + +const ( + DashboardHttpRequestMethodGET DashboardHttpRequestMethod = "GET" + DashboardHttpRequestMethodPUT DashboardHttpRequestMethod = "PUT" + DashboardHttpRequestMethodPOST DashboardHttpRequestMethod = "POST" + DashboardHttpRequestMethodDELETE DashboardHttpRequestMethod = "DELETE" + DashboardHttpRequestMethodPATCH DashboardHttpRequestMethod = "PATCH" +) + +// +k8s:openapi-gen=true +type DashboardInfinityOptions struct { + Method DashboardHttpRequestMethod `json:"method"` + Url string `json:"url"` + Body *string `json:"body,omitempty"` + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + QueryParams [][]string `json:"queryParams,omitempty"` + DatasourceUid string `json:"datasourceUid"` + Headers [][]string `json:"headers,omitempty"` +} + +// NewDashboardInfinityOptions creates a new DashboardInfinityOptions object. +func NewDashboardInfinityOptions() *DashboardInfinityOptions { + return &DashboardInfinityOptions{} +} + +// +k8s:openapi-gen=true +type DashboardActionVariable struct { + Key string `json:"key"` + Name string `json:"name"` + Type string `json:"type"` +} + +// NewDashboardActionVariable creates a new DashboardActionVariable object. +func NewDashboardActionVariable() *DashboardActionVariable { + return &DashboardActionVariable{ + Type: DashboardActionVariableType, + } +} + +// Action variable type +// +k8s:openapi-gen=true +const DashboardActionVariableType = "string" + // +k8s:openapi-gen=true type DashboardDynamicConfigValue struct { Id string `json:"id"` @@ -1831,6 +1922,16 @@ func NewDashboardV2alpha1SpecialValueMapOptions() *DashboardV2alpha1SpecialValue } } +// +k8s:openapi-gen=true +type DashboardV2alpha1ActionStyle struct { + BackgroundColor *string `json:"backgroundColor,omitempty"` +} + +// NewDashboardV2alpha1ActionStyle creates a new DashboardV2alpha1ActionStyle object. +func NewDashboardV2alpha1ActionStyle() *DashboardV2alpha1ActionStyle { + return &DashboardV2alpha1ActionStyle{} +} + // +k8s:openapi-gen=true type DashboardRepeatOptionsDirection string diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go index 7445000d7f8..7c61faa8924 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go @@ -23,6 +23,9 @@ func ValidateDashboardSpec(obj *Dashboard) field.ErrorList { } } + // Custom validation for action query params and headers + validateAndTrimActionArrays(obj) + if err := cuejson.Validate(data, getCueSchema()); err != nil { errs := field.ErrorList{} @@ -60,6 +63,61 @@ func ValidateDashboardSpec(obj *Dashboard) field.ErrorList { return nil } +// Validates and trims action query params and headers to exactly 2 elements each +// This is because we couldn't generate with cue a go struct that would have exactly two strings in each sub-array +func validateAndTrimActionArrays(obj *Dashboard) { + for _, element := range obj.Spec.Elements { + if element.PanelKind != nil { + panelElement := element.PanelKind + if panelElement.Spec.VizConfig.Spec.FieldConfig.Defaults.Actions != nil { + processActions(panelElement.Spec.VizConfig.Spec.FieldConfig.Defaults.Actions) + } + } + } +} + +// Helper function to process action arrays +func processActions(actions []DashboardAction) { + for _, action := range actions { + // Process FetchOptions if present + if action.Fetch != nil { + if action.Fetch.QueryParams != nil { + action.Fetch.QueryParams = trimStringArrays(action.Fetch.QueryParams) + } + if action.Fetch.Headers != nil { + action.Fetch.Headers = trimStringArrays(action.Fetch.Headers) + } + } + + // Process InfinityOptions if present + if action.Infinity != nil { + if action.Infinity.QueryParams != nil { + action.Infinity.QueryParams = trimStringArrays(action.Infinity.QueryParams) + } + if action.Infinity.Headers != nil { + action.Infinity.Headers = trimStringArrays(action.Infinity.Headers) + } + } + } +} + +// Helper function to trim 2D string arrays to exactly 2 elements per sub-array +func trimStringArrays(arrays [][]string) [][]string { + if arrays == nil { + return arrays + } + + result := make([][]string, len(arrays)) + for i, arr := range arrays { + if len(arr) > 2 { + result[i] = arr[:2] + } else { + result[i] = arr + } + } + return result +} + func formatErrorPath(path []string) string { return strings.Join(path, ".") } diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation_test.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation_test.go new file mode 100644 index 00000000000..1bac5cfa9e8 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation_test.go @@ -0,0 +1,495 @@ +package v2alpha1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestTrimStringArrays(t *testing.T) { + tests := []struct { + name string + input [][]string + expected [][]string + }{ + { + name: "nil input", + input: nil, + expected: nil, + }, + { + name: "empty input", + input: [][]string{}, + expected: [][]string{}, + }, + { + name: "arrays with exactly 2 elements", + input: [][]string{{"key1", "value1"}, {"key2", "value2"}}, + expected: [][]string{{"key1", "value1"}, {"key2", "value2"}}, + }, + { + name: "arrays with less than 2 elements", + input: [][]string{{"key1"}, {}}, + expected: [][]string{{"key1"}, {}}, + }, + { + name: "arrays with more than 2 elements", + input: [][]string{{"key1", "value1", "extra1"}, {"key2", "value2", "extra2", "extra3"}}, + expected: [][]string{{"key1", "value1"}, {"key2", "value2"}}, + }, + { + name: "mixed arrays", + input: [][]string{{"key1"}, {"key2", "value2"}, {"key3", "value3", "extra"}}, + expected: [][]string{{"key1"}, {"key2", "value2"}, {"key3", "value3"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := trimStringArrays(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestProcessActions(t *testing.T) { + tests := []struct { + name string + actions []DashboardAction + expected []DashboardAction + }{ + { + name: "empty actions", + actions: []DashboardAction{}, + expected: []DashboardAction{}, + }, + { + name: "action with fetch options having oversized arrays", + actions: []DashboardAction{ + { + Type: DashboardActionTypeFetch, + Title: "Test Fetch", + Fetch: &DashboardFetchOptions{ + Method: DashboardHttpRequestMethodGET, + Url: "http://example.com", + QueryParams: [][]string{ + {"param1", "value1", "extra1"}, + {"param2", "value2"}, + }, + Headers: [][]string{ + {"header1", "value1", "extra1", "extra2"}, + {"header2", "value2"}, + }, + }, + }, + }, + expected: []DashboardAction{ + { + Type: DashboardActionTypeFetch, + Title: "Test Fetch", + Fetch: &DashboardFetchOptions{ + Method: DashboardHttpRequestMethodGET, + Url: "http://example.com", + QueryParams: [][]string{ + {"param1", "value1"}, + {"param2", "value2"}, + }, + Headers: [][]string{ + {"header1", "value1"}, + {"header2", "value2"}, + }, + }, + }, + }, + }, + { + name: "action with infinity options having oversized arrays", + actions: []DashboardAction{ + { + Type: DashboardActionTypeInfinity, + Title: "Test Infinity", + Infinity: &DashboardInfinityOptions{ + Method: DashboardHttpRequestMethodPOST, + Url: "http://example.com", + DatasourceUid: "test-uid", + QueryParams: [][]string{ + {"param1", "value1", "extra1"}, + {"param2", "value2"}, + }, + Headers: [][]string{ + {"header1", "value1", "extra1", "extra2"}, + {"header2", "value2"}, + }, + }, + }, + }, + expected: []DashboardAction{ + { + Type: DashboardActionTypeInfinity, + Title: "Test Infinity", + Infinity: &DashboardInfinityOptions{ + Method: DashboardHttpRequestMethodPOST, + Url: "http://example.com", + DatasourceUid: "test-uid", + QueryParams: [][]string{ + {"param1", "value1"}, + {"param2", "value2"}, + }, + Headers: [][]string{ + {"header1", "value1"}, + {"header2", "value2"}, + }, + }, + }, + }, + }, + { + name: "action without fetch or infinity options", + actions: []DashboardAction{ + { + Type: DashboardActionTypeFetch, + Title: "Test Action", + }, + }, + expected: []DashboardAction{ + { + Type: DashboardActionTypeFetch, + Title: "Test Action", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a copy to avoid modifying the original + actionsCopy := make([]DashboardAction, len(tt.actions)) + for i, action := range tt.actions { + actionsCopy[i] = action + // Deep copy the fetch options if they exist + if action.Fetch != nil { + fetchCopy := *action.Fetch + if action.Fetch.QueryParams != nil { + fetchCopy.QueryParams = make([][]string, len(action.Fetch.QueryParams)) + for j, param := range action.Fetch.QueryParams { + fetchCopy.QueryParams[j] = make([]string, len(param)) + copy(fetchCopy.QueryParams[j], param) + } + } + if action.Fetch.Headers != nil { + fetchCopy.Headers = make([][]string, len(action.Fetch.Headers)) + for j, header := range action.Fetch.Headers { + fetchCopy.Headers[j] = make([]string, len(header)) + copy(fetchCopy.Headers[j], header) + } + } + actionsCopy[i].Fetch = &fetchCopy + } + // Deep copy the infinity options if they exist + if action.Infinity != nil { + infinityCopy := *action.Infinity + if action.Infinity.QueryParams != nil { + infinityCopy.QueryParams = make([][]string, len(action.Infinity.QueryParams)) + for j, param := range action.Infinity.QueryParams { + infinityCopy.QueryParams[j] = make([]string, len(param)) + copy(infinityCopy.QueryParams[j], param) + } + } + if action.Infinity.Headers != nil { + infinityCopy.Headers = make([][]string, len(action.Infinity.Headers)) + for j, header := range action.Infinity.Headers { + infinityCopy.Headers[j] = make([]string, len(header)) + copy(infinityCopy.Headers[j], header) + } + } + actionsCopy[i].Infinity = &infinityCopy + } + } + + processActions(actionsCopy) + assert.Equal(t, tt.expected, actionsCopy) + }) + } +} + +func TestValidateAndTrimActionArrays(t *testing.T) { + tests := []struct { + name string + dashboard *Dashboard + expected *Dashboard + }{ + { + name: "dashboard with no elements", + dashboard: &Dashboard{ + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v2beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dashboard", + }, + Spec: DashboardSpec{ + Elements: map[string]DashboardElement{}, + }, + }, + expected: &Dashboard{ + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v2beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dashboard", + }, + Spec: DashboardSpec{ + Elements: map[string]DashboardElement{}, + }, + }, + }, + { + name: "dashboard with panel having actions with oversized arrays", + dashboard: &Dashboard{ + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v2beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dashboard", + }, + Spec: DashboardSpec{ + Elements: map[string]DashboardElement{ + "panel1": { + PanelKind: &DashboardPanelKind{ + Spec: DashboardPanelSpec{ + VizConfig: DashboardVizConfigKind{ + Spec: DashboardVizConfigSpec{ + FieldConfig: DashboardFieldConfigSource{ + Defaults: DashboardFieldConfig{ + Actions: []DashboardAction{ + { + Type: DashboardActionTypeFetch, + Title: "Test Action", + Fetch: &DashboardFetchOptions{ + Method: DashboardHttpRequestMethodGET, + Url: "http://example.com", + QueryParams: [][]string{ + {"param1", "value1", "extra1"}, + {"param2", "value2"}, + }, + Headers: [][]string{ + {"header1", "value1", "extra1", "extra2"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &Dashboard{ + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v2beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dashboard", + }, + Spec: DashboardSpec{ + Elements: map[string]DashboardElement{ + "panel1": { + PanelKind: &DashboardPanelKind{ + Spec: DashboardPanelSpec{ + VizConfig: DashboardVizConfigKind{ + Spec: DashboardVizConfigSpec{ + FieldConfig: DashboardFieldConfigSource{ + Defaults: DashboardFieldConfig{ + Actions: []DashboardAction{ + { + Type: DashboardActionTypeFetch, + Title: "Test Action", + Fetch: &DashboardFetchOptions{ + Method: DashboardHttpRequestMethodGET, + Url: "http://example.com", + QueryParams: [][]string{ + {"param1", "value1"}, + {"param2", "value2"}, + }, + Headers: [][]string{ + {"header1", "value1"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "dashboard with panel having no actions", + dashboard: &Dashboard{ + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v2beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dashboard", + }, + Spec: DashboardSpec{ + Elements: map[string]DashboardElement{ + "panel1": { + PanelKind: &DashboardPanelKind{ + Spec: DashboardPanelSpec{ + VizConfig: DashboardVizConfigKind{ + Spec: DashboardVizConfigSpec{ + FieldConfig: DashboardFieldConfigSource{ + Defaults: DashboardFieldConfig{ + Actions: nil, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &Dashboard{ + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v2beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dashboard", + }, + Spec: DashboardSpec{ + Elements: map[string]DashboardElement{ + "panel1": { + PanelKind: &DashboardPanelKind{ + Spec: DashboardPanelSpec{ + VizConfig: DashboardVizConfigKind{ + Spec: DashboardVizConfigSpec{ + FieldConfig: DashboardFieldConfigSource{ + Defaults: DashboardFieldConfig{ + Actions: nil, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a deep copy to avoid modifying the original + dashboardCopy := deepCopyDashboard(tt.dashboard) + + validateAndTrimActionArrays(dashboardCopy) + assert.Equal(t, tt.expected, dashboardCopy) + }) + } +} + +// Helper function to create a deep copy of a Dashboard for testing +func deepCopyDashboard(original *Dashboard) *Dashboard { + if original == nil { + return nil + } + + result := &Dashboard{ + TypeMeta: original.TypeMeta, + ObjectMeta: original.ObjectMeta, + Spec: DashboardSpec{ + Elements: make(map[string]DashboardElement), + }, + Status: original.Status, + } + + for key, element := range original.Spec.Elements { + elementCopy := element + + if element.PanelKind != nil { + panelCopy := *element.PanelKind + elementCopy.PanelKind = &panelCopy + + if element.PanelKind.Spec.VizConfig.Spec.FieldConfig.Defaults.Actions != nil { + actions := element.PanelKind.Spec.VizConfig.Spec.FieldConfig.Defaults.Actions + actionsCopy := make([]DashboardAction, len(actions)) + + for j, action := range actions { + actionsCopy[j] = action + + // Deep copy fetch options + if action.Fetch != nil { + fetchCopy := *action.Fetch + if action.Fetch.QueryParams != nil { + fetchCopy.QueryParams = make([][]string, len(action.Fetch.QueryParams)) + for k, param := range action.Fetch.QueryParams { + fetchCopy.QueryParams[k] = make([]string, len(param)) + copy(fetchCopy.QueryParams[k], param) + } + } + if action.Fetch.Headers != nil { + fetchCopy.Headers = make([][]string, len(action.Fetch.Headers)) + for k, header := range action.Fetch.Headers { + fetchCopy.Headers[k] = make([]string, len(header)) + copy(fetchCopy.Headers[k], header) + } + } + actionsCopy[j].Fetch = &fetchCopy + } + + // Deep copy infinity options + if action.Infinity != nil { + infinityCopy := *action.Infinity + if action.Infinity.QueryParams != nil { + infinityCopy.QueryParams = make([][]string, len(action.Infinity.QueryParams)) + for k, param := range action.Infinity.QueryParams { + infinityCopy.QueryParams[k] = make([]string, len(param)) + copy(infinityCopy.QueryParams[k], param) + } + } + if action.Infinity.Headers != nil { + infinityCopy.Headers = make([][]string, len(action.Infinity.Headers)) + for k, header := range action.Infinity.Headers { + infinityCopy.Headers[k] = make([]string, len(header)) + copy(infinityCopy.Headers[k], header) + } + } + actionsCopy[j].Infinity = &infinityCopy + } + } + + elementCopy.PanelKind.Spec.VizConfig.Spec.FieldConfig.Defaults.Actions = actionsCopy + } + } + + if element.LibraryPanelKind != nil { + libraryCopy := *element.LibraryPanelKind + elementCopy.LibraryPanelKind = &libraryCopy + } + + result.Spec.Elements[key] = elementCopy + } + + return result +} diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go index a53ba6483cb..0d2d96af8bc 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go @@ -18,6 +18,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.AnnotationPermission": schema_pkg_apis_dashboard_v2alpha1_AnnotationPermission(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.Dashboard": schema_pkg_apis_dashboard_v2alpha1_Dashboard(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAccess": schema_pkg_apis_dashboard_v2alpha1_DashboardAccess(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAction": schema_pkg_apis_dashboard_v2alpha1_DashboardAction(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardActionVariable": schema_pkg_apis_dashboard_v2alpha1_DashboardActionVariable(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdHocFilterWithLabels": schema_pkg_apis_dashboard_v2alpha1_DashboardAdHocFilterWithLabels(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdhocVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardAdhocVariableKind(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdhocVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardAdhocVariableSpec(ref), @@ -52,6 +54,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDatasourceVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardDatasourceVariableSpec(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDynamicConfigValue": schema_pkg_apis_dashboard_v2alpha1_DashboardDynamicConfigValue(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardElementReference": schema_pkg_apis_dashboard_v2alpha1_DashboardElementReference(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFetchOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardFetchOptions(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFieldColor": schema_pkg_apis_dashboard_v2alpha1_DashboardFieldColor(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFieldConfig": schema_pkg_apis_dashboard_v2alpha1_DashboardFieldConfig(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFieldConfigSource": schema_pkg_apis_dashboard_v2alpha1_DashboardFieldConfigSource(ref), @@ -63,6 +66,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardGridLayoutSpec(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGroupByVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardGroupByVariableKind(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGroupByVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardGroupByVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardInfinityOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardInfinityOptions(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardIntervalVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardIntervalVariableKind(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardIntervalVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardIntervalVariableSpec(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardJSONCodec": schema_pkg_apis_dashboard_v2alpha1_DashboardJSONCodec(ref), @@ -109,6 +113,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTimeRangeOption": schema_pkg_apis_dashboard_v2alpha1_DashboardTimeRangeOption(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTimeSettingsSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardTimeSettingsSpec(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTransformationKind": schema_pkg_apis_dashboard_v2alpha1_DashboardTransformationKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1ActionStyle": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1ActionStyle(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1FieldConfigSourceOverrides": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1FieldConfigSourceOverrides(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1RangeMapOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1RangeMapOptions(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1RegexMapOptions": schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1RegexMapOptions(ref), @@ -309,6 +314,109 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardAccess(ref common.ReferenceCall } } +func schema_pkg_apis_dashboard_v2alpha1_DashboardAction(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "type": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "fetch": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFetchOptions"), + }, + }, + "infinity": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardInfinityOptions"), + }, + }, + "confirmation": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "oneClick": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, + "variables": { + 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/dashboard/pkg/apis/dashboard/v2alpha1.DashboardActionVariable"), + }, + }, + }, + }, + }, + "style": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1ActionStyle"), + }, + }, + }, + Required: []string{"type", "title"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardActionVariable", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFetchOptions", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardInfinityOptions", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardV2alpha1ActionStyle"}, + } +} + +func schema_pkg_apis_dashboard_v2alpha1_DashboardActionVariable(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "key": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "type": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"key", "name", "type"}, + }, + }, + } +} + func schema_pkg_apis_dashboard_v2alpha1_DashboardAdHocFilterWithLabels(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -1805,6 +1913,82 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardElementReference(ref common.Ref } } +func schema_pkg_apis_dashboard_v2alpha1_DashboardFetchOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "method": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "url": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "body": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "queryParams": { + SchemaProps: spec.SchemaProps{ + Description: "These are 2D arrays of strings, each representing a key-value pair We are defining them this way because we can't generate a go struct that that would have exactly two strings in each sub-array", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + }, + }, + "headers": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + }, + }, + }, + Required: []string{"method", "url"}, + }, + }, + } +} + func schema_pkg_apis_dashboard_v2alpha1_DashboardFieldColor(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -1957,6 +2141,20 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardFieldConfig(ref common.Referenc }, }, }, + "actions": { + SchemaProps: spec.SchemaProps{ + Description: "Define interactive HTTP requests that can be triggered from data visualizations.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAction"), + }, + }, + }, + }, + }, "noValue": { SchemaProps: spec.SchemaProps{ Description: "Alternative to empty string", @@ -1983,7 +2181,7 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardFieldConfig(ref common.Referenc }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFieldColor", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardThresholdsConfig", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAction", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardFieldColor", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardThresholdsConfig", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap"}, } } @@ -2345,6 +2543,89 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardGroupByVariableSpec(ref common. } } +func schema_pkg_apis_dashboard_v2alpha1_DashboardInfinityOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "method": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "url": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "body": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "queryParams": { + SchemaProps: spec.SchemaProps{ + Description: "These are 2D arrays of strings, each representing a key-value pair We are defining them this way because we can't generate a go struct that that would have exactly two strings in each sub-array", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + }, + }, + "datasourceUid": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "headers": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + }, + }, + }, + Required: []string{"method", "url", "datasourceUid"}, + }, + }, + } +} + func schema_pkg_apis_dashboard_v2alpha1_DashboardIntervalVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -4337,6 +4618,24 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardTransformationKind(ref common.R } } +func schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1ActionStyle(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "backgroundColor": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_pkg_apis_dashboard_v2alpha1_DashboardV2alpha1FieldConfigSourceOverrides(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list index 95eb59de600..84157df6895 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi_violation_exceptions.list @@ -1,3 +1,4 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardAction,Variables API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardAdHocFilterWithLabels,ValueLabels API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardAdHocFilterWithLabels,Values API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardAdhocVariableSpec,BaseFilters @@ -9,11 +10,16 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/ API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardCustomVariableSpec,Options API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardDashboardLink,Tags API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardDatasourceVariableSpec,Options +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardFetchOptions,Headers +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardFetchOptions,QueryParams +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardFieldConfig,Actions API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardFieldConfig,Links API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardFieldConfig,Mappings API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardFieldConfigSource,Overrides API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardGridLayoutSpec,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardGroupByVariableSpec,Options +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardInfinityOptions,Headers +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardInfinityOptions,QueryParams API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardIntervalVariableSpec,Options API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardMetadata,Finalizers API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1,DashboardPanelSpec,Links diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue index 000383a069e..8e0ea0ef763 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue @@ -223,6 +223,9 @@ FieldConfig: { // The behavior when clicking on a result links?: [...] + // Define interactive HTTP requests that can be triggered from data visualizations. + actions?: [...Action] + // Alternative to empty string noValue?: string @@ -366,6 +369,48 @@ FieldColor: { // Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource) DashboardLinkType: "link" | "dashboards" +ActionType: "fetch" | "infinity" + +FetchOptions: { + method: HttpRequestMethod + url: string + body?: string + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: [...[...string]] + headers?: [...[...string]] +} + +InfinityOptions: FetchOptions & { + datasourceUid: string +} + +HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH" + +// Action variable type +ActionVariableType: "string" + +ActionVariable: { + key: string + name: string + type: ActionVariableType +} + +Action: { + type: ActionType + title: string + fetch?: FetchOptions + infinity?: InfinityOptions + confirmation?: string + oneClick?: bool + variables?: [...ActionVariable] + style?: { + backgroundColor?: string + } +} + + // --- Common types --- Kind: { kind: string diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go index fedd4dfd20a..b94789abcbd 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go @@ -386,6 +386,8 @@ type DashboardFieldConfig struct { Color *DashboardFieldColor `json:"color,omitempty"` // The behavior when clicking on a result Links []interface{} `json:"links,omitempty"` + // Define interactive HTTP requests that can be triggered from data visualizations. + Actions []DashboardAction `json:"actions,omitempty"` // Alternative to empty string NoValue *string `json:"noValue,omitempty"` // custom is specified by the FieldConfig field @@ -617,6 +619,95 @@ const ( DashboardFieldColorSeriesByModeLast DashboardFieldColorSeriesByMode = "last" ) +// +k8s:openapi-gen=true +type DashboardAction struct { + Type DashboardActionType `json:"type"` + Title string `json:"title"` + Fetch *DashboardFetchOptions `json:"fetch,omitempty"` + Infinity *DashboardInfinityOptions `json:"infinity,omitempty"` + Confirmation *string `json:"confirmation,omitempty"` + OneClick *bool `json:"oneClick,omitempty"` + Variables []DashboardActionVariable `json:"variables,omitempty"` + Style *DashboardV2beta1ActionStyle `json:"style,omitempty"` +} + +// NewDashboardAction creates a new DashboardAction object. +func NewDashboardAction() *DashboardAction { + return &DashboardAction{} +} + +// +k8s:openapi-gen=true +type DashboardActionType string + +const ( + DashboardActionTypeFetch DashboardActionType = "fetch" + DashboardActionTypeInfinity DashboardActionType = "infinity" +) + +// +k8s:openapi-gen=true +type DashboardFetchOptions struct { + Method DashboardHttpRequestMethod `json:"method"` + Url string `json:"url"` + Body *string `json:"body,omitempty"` + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + QueryParams [][]string `json:"queryParams,omitempty"` + Headers [][]string `json:"headers,omitempty"` +} + +// NewDashboardFetchOptions creates a new DashboardFetchOptions object. +func NewDashboardFetchOptions() *DashboardFetchOptions { + return &DashboardFetchOptions{} +} + +// +k8s:openapi-gen=true +type DashboardHttpRequestMethod string + +const ( + DashboardHttpRequestMethodGET DashboardHttpRequestMethod = "GET" + DashboardHttpRequestMethodPUT DashboardHttpRequestMethod = "PUT" + DashboardHttpRequestMethodPOST DashboardHttpRequestMethod = "POST" + DashboardHttpRequestMethodDELETE DashboardHttpRequestMethod = "DELETE" + DashboardHttpRequestMethodPATCH DashboardHttpRequestMethod = "PATCH" +) + +// +k8s:openapi-gen=true +type DashboardInfinityOptions struct { + Method DashboardHttpRequestMethod `json:"method"` + Url string `json:"url"` + Body *string `json:"body,omitempty"` + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + QueryParams [][]string `json:"queryParams,omitempty"` + DatasourceUid string `json:"datasourceUid"` + Headers [][]string `json:"headers,omitempty"` +} + +// NewDashboardInfinityOptions creates a new DashboardInfinityOptions object. +func NewDashboardInfinityOptions() *DashboardInfinityOptions { + return &DashboardInfinityOptions{} +} + +// +k8s:openapi-gen=true +type DashboardActionVariable struct { + Key string `json:"key"` + Name string `json:"name"` + Type string `json:"type"` +} + +// NewDashboardActionVariable creates a new DashboardActionVariable object. +func NewDashboardActionVariable() *DashboardActionVariable { + return &DashboardActionVariable{ + Type: DashboardActionVariableType, + } +} + +// Action variable type +// +k8s:openapi-gen=true +const DashboardActionVariableType = "string" + // +k8s:openapi-gen=true type DashboardDynamicConfigValue struct { Id string `json:"id"` @@ -1853,6 +1944,16 @@ func NewDashboardV2beta1SpecialValueMapOptions() *DashboardV2beta1SpecialValueMa } } +// +k8s:openapi-gen=true +type DashboardV2beta1ActionStyle struct { + BackgroundColor *string `json:"backgroundColor,omitempty"` +} + +// NewDashboardV2beta1ActionStyle creates a new DashboardV2beta1ActionStyle object. +func NewDashboardV2beta1ActionStyle() *DashboardV2beta1ActionStyle { + return &DashboardV2beta1ActionStyle{} +} + // +k8s:openapi-gen=true type DashboardV2beta1GroupByVariableKindDatasource struct { Name *string `json:"name,omitempty"` diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/validation.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/validation.go index 3946c0d9b14..7c859626b98 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/validation.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/validation.go @@ -23,6 +23,9 @@ func ValidateDashboardSpec(obj *Dashboard) field.ErrorList { } } + // Custom validation for action query params and headers + validateAndTrimActionArrays(obj) + if err := cuejson.Validate(data, getCueSchema()); err != nil { errs := field.ErrorList{} @@ -60,6 +63,61 @@ func ValidateDashboardSpec(obj *Dashboard) field.ErrorList { return nil } +// Validates and trims action query params and headers to exactly 2 elements each +// This is because we couldn't generate with cue a go struct that would have exactly two strings in each sub-array +func validateAndTrimActionArrays(obj *Dashboard) { + for _, element := range obj.Spec.Elements { + if element.PanelKind != nil { + panelElement := element.PanelKind + if panelElement.Spec.VizConfig.Spec.FieldConfig.Defaults.Actions != nil { + processActions(panelElement.Spec.VizConfig.Spec.FieldConfig.Defaults.Actions) + } + } + } +} + +// Helper function to process action arrays +func processActions(actions []DashboardAction) { + for _, action := range actions { + // Process FetchOptions if present + if action.Fetch != nil { + if action.Fetch.QueryParams != nil { + action.Fetch.QueryParams = trimStringArrays(action.Fetch.QueryParams) + } + if action.Fetch.Headers != nil { + action.Fetch.Headers = trimStringArrays(action.Fetch.Headers) + } + } + + // Process InfinityOptions if present + if action.Infinity != nil { + if action.Infinity.QueryParams != nil { + action.Infinity.QueryParams = trimStringArrays(action.Infinity.QueryParams) + } + if action.Infinity.Headers != nil { + action.Infinity.Headers = trimStringArrays(action.Infinity.Headers) + } + } + } +} + +// Helper function to trim 2D string arrays to exactly 2 elements per sub-array +func trimStringArrays(arrays [][]string) [][]string { + if arrays == nil { + return arrays + } + + result := make([][]string, len(arrays)) + for i, arr := range arrays { + if len(arr) > 2 { + result[i] = arr[:2] + } else { + result[i] = arr + } + } + return result +} + func formatErrorPath(path []string) string { return strings.Join(path, ".") } diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/validation_test.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/validation_test.go new file mode 100644 index 00000000000..226f329a761 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/validation_test.go @@ -0,0 +1,495 @@ +package v2beta1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestTrimStringArrays(t *testing.T) { + tests := []struct { + name string + input [][]string + expected [][]string + }{ + { + name: "nil input", + input: nil, + expected: nil, + }, + { + name: "empty input", + input: [][]string{}, + expected: [][]string{}, + }, + { + name: "arrays with exactly 2 elements", + input: [][]string{{"key1", "value1"}, {"key2", "value2"}}, + expected: [][]string{{"key1", "value1"}, {"key2", "value2"}}, + }, + { + name: "arrays with less than 2 elements", + input: [][]string{{"key1"}, {}}, + expected: [][]string{{"key1"}, {}}, + }, + { + name: "arrays with more than 2 elements", + input: [][]string{{"key1", "value1", "extra1"}, {"key2", "value2", "extra2", "extra3"}}, + expected: [][]string{{"key1", "value1"}, {"key2", "value2"}}, + }, + { + name: "mixed arrays", + input: [][]string{{"key1"}, {"key2", "value2"}, {"key3", "value3", "extra"}}, + expected: [][]string{{"key1"}, {"key2", "value2"}, {"key3", "value3"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := trimStringArrays(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestProcessActions(t *testing.T) { + tests := []struct { + name string + actions []DashboardAction + expected []DashboardAction + }{ + { + name: "empty actions", + actions: []DashboardAction{}, + expected: []DashboardAction{}, + }, + { + name: "action with fetch options having oversized arrays", + actions: []DashboardAction{ + { + Type: DashboardActionTypeFetch, + Title: "Test Fetch", + Fetch: &DashboardFetchOptions{ + Method: DashboardHttpRequestMethodGET, + Url: "http://example.com", + QueryParams: [][]string{ + {"param1", "value1", "extra1"}, + {"param2", "value2"}, + }, + Headers: [][]string{ + {"header1", "value1", "extra1", "extra2"}, + {"header2", "value2"}, + }, + }, + }, + }, + expected: []DashboardAction{ + { + Type: DashboardActionTypeFetch, + Title: "Test Fetch", + Fetch: &DashboardFetchOptions{ + Method: DashboardHttpRequestMethodGET, + Url: "http://example.com", + QueryParams: [][]string{ + {"param1", "value1"}, + {"param2", "value2"}, + }, + Headers: [][]string{ + {"header1", "value1"}, + {"header2", "value2"}, + }, + }, + }, + }, + }, + { + name: "action with infinity options having oversized arrays", + actions: []DashboardAction{ + { + Type: DashboardActionTypeInfinity, + Title: "Test Infinity", + Infinity: &DashboardInfinityOptions{ + Method: DashboardHttpRequestMethodPOST, + Url: "http://example.com", + DatasourceUid: "test-uid", + QueryParams: [][]string{ + {"param1", "value1", "extra1"}, + {"param2", "value2"}, + }, + Headers: [][]string{ + {"header1", "value1", "extra1", "extra2"}, + {"header2", "value2"}, + }, + }, + }, + }, + expected: []DashboardAction{ + { + Type: DashboardActionTypeInfinity, + Title: "Test Infinity", + Infinity: &DashboardInfinityOptions{ + Method: DashboardHttpRequestMethodPOST, + Url: "http://example.com", + DatasourceUid: "test-uid", + QueryParams: [][]string{ + {"param1", "value1"}, + {"param2", "value2"}, + }, + Headers: [][]string{ + {"header1", "value1"}, + {"header2", "value2"}, + }, + }, + }, + }, + }, + { + name: "action without fetch or infinity options", + actions: []DashboardAction{ + { + Type: DashboardActionTypeFetch, + Title: "Test Action", + }, + }, + expected: []DashboardAction{ + { + Type: DashboardActionTypeFetch, + Title: "Test Action", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a copy to avoid modifying the original + actionsCopy := make([]DashboardAction, len(tt.actions)) + for i, action := range tt.actions { + actionsCopy[i] = action + // Deep copy the fetch options if they exist + if action.Fetch != nil { + fetchCopy := *action.Fetch + if action.Fetch.QueryParams != nil { + fetchCopy.QueryParams = make([][]string, len(action.Fetch.QueryParams)) + for j, param := range action.Fetch.QueryParams { + fetchCopy.QueryParams[j] = make([]string, len(param)) + copy(fetchCopy.QueryParams[j], param) + } + } + if action.Fetch.Headers != nil { + fetchCopy.Headers = make([][]string, len(action.Fetch.Headers)) + for j, header := range action.Fetch.Headers { + fetchCopy.Headers[j] = make([]string, len(header)) + copy(fetchCopy.Headers[j], header) + } + } + actionsCopy[i].Fetch = &fetchCopy + } + // Deep copy the infinity options if they exist + if action.Infinity != nil { + infinityCopy := *action.Infinity + if action.Infinity.QueryParams != nil { + infinityCopy.QueryParams = make([][]string, len(action.Infinity.QueryParams)) + for j, param := range action.Infinity.QueryParams { + infinityCopy.QueryParams[j] = make([]string, len(param)) + copy(infinityCopy.QueryParams[j], param) + } + } + if action.Infinity.Headers != nil { + infinityCopy.Headers = make([][]string, len(action.Infinity.Headers)) + for j, header := range action.Infinity.Headers { + infinityCopy.Headers[j] = make([]string, len(header)) + copy(infinityCopy.Headers[j], header) + } + } + actionsCopy[i].Infinity = &infinityCopy + } + } + + processActions(actionsCopy) + assert.Equal(t, tt.expected, actionsCopy) + }) + } +} + +func TestValidateAndTrimActionArrays(t *testing.T) { + tests := []struct { + name string + dashboard *Dashboard + expected *Dashboard + }{ + { + name: "dashboard with no elements", + dashboard: &Dashboard{ + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v2beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dashboard", + }, + Spec: DashboardSpec{ + Elements: map[string]DashboardElement{}, + }, + }, + expected: &Dashboard{ + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v2beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dashboard", + }, + Spec: DashboardSpec{ + Elements: map[string]DashboardElement{}, + }, + }, + }, + { + name: "dashboard with panel having actions with oversized arrays", + dashboard: &Dashboard{ + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v2beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dashboard", + }, + Spec: DashboardSpec{ + Elements: map[string]DashboardElement{ + "panel1": { + PanelKind: &DashboardPanelKind{ + Spec: DashboardPanelSpec{ + VizConfig: DashboardVizConfigKind{ + Spec: DashboardVizConfigSpec{ + FieldConfig: DashboardFieldConfigSource{ + Defaults: DashboardFieldConfig{ + Actions: []DashboardAction{ + { + Type: DashboardActionTypeFetch, + Title: "Test Action", + Fetch: &DashboardFetchOptions{ + Method: DashboardHttpRequestMethodGET, + Url: "http://example.com", + QueryParams: [][]string{ + {"param1", "value1", "extra1"}, + {"param2", "value2"}, + }, + Headers: [][]string{ + {"header1", "value1", "extra1", "extra2"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &Dashboard{ + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v2beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dashboard", + }, + Spec: DashboardSpec{ + Elements: map[string]DashboardElement{ + "panel1": { + PanelKind: &DashboardPanelKind{ + Spec: DashboardPanelSpec{ + VizConfig: DashboardVizConfigKind{ + Spec: DashboardVizConfigSpec{ + FieldConfig: DashboardFieldConfigSource{ + Defaults: DashboardFieldConfig{ + Actions: []DashboardAction{ + { + Type: DashboardActionTypeFetch, + Title: "Test Action", + Fetch: &DashboardFetchOptions{ + Method: DashboardHttpRequestMethodGET, + Url: "http://example.com", + QueryParams: [][]string{ + {"param1", "value1"}, + {"param2", "value2"}, + }, + Headers: [][]string{ + {"header1", "value1"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "dashboard with panel having no actions", + dashboard: &Dashboard{ + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v2beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dashboard", + }, + Spec: DashboardSpec{ + Elements: map[string]DashboardElement{ + "panel1": { + PanelKind: &DashboardPanelKind{ + Spec: DashboardPanelSpec{ + VizConfig: DashboardVizConfigKind{ + Spec: DashboardVizConfigSpec{ + FieldConfig: DashboardFieldConfigSource{ + Defaults: DashboardFieldConfig{ + Actions: nil, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &Dashboard{ + TypeMeta: metav1.TypeMeta{ + Kind: "Dashboard", + APIVersion: "v2beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dashboard", + }, + Spec: DashboardSpec{ + Elements: map[string]DashboardElement{ + "panel1": { + PanelKind: &DashboardPanelKind{ + Spec: DashboardPanelSpec{ + VizConfig: DashboardVizConfigKind{ + Spec: DashboardVizConfigSpec{ + FieldConfig: DashboardFieldConfigSource{ + Defaults: DashboardFieldConfig{ + Actions: nil, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a deep copy to avoid modifying the original + dashboardCopy := deepCopyDashboard(tt.dashboard) + + validateAndTrimActionArrays(dashboardCopy) + assert.Equal(t, tt.expected, dashboardCopy) + }) + } +} + +// Helper function to create a deep copy of a Dashboard for testing +func deepCopyDashboard(original *Dashboard) *Dashboard { + if original == nil { + return nil + } + + result := &Dashboard{ + TypeMeta: original.TypeMeta, + ObjectMeta: original.ObjectMeta, + Spec: DashboardSpec{ + Elements: make(map[string]DashboardElement), + }, + Status: original.Status, + } + + for key, element := range original.Spec.Elements { + elementCopy := element + + if element.PanelKind != nil { + panelCopy := *element.PanelKind + elementCopy.PanelKind = &panelCopy + + if element.PanelKind.Spec.VizConfig.Spec.FieldConfig.Defaults.Actions != nil { + actions := element.PanelKind.Spec.VizConfig.Spec.FieldConfig.Defaults.Actions + actionsCopy := make([]DashboardAction, len(actions)) + + for j, action := range actions { + actionsCopy[j] = action + + // Deep copy fetch options + if action.Fetch != nil { + fetchCopy := *action.Fetch + if action.Fetch.QueryParams != nil { + fetchCopy.QueryParams = make([][]string, len(action.Fetch.QueryParams)) + for k, param := range action.Fetch.QueryParams { + fetchCopy.QueryParams[k] = make([]string, len(param)) + copy(fetchCopy.QueryParams[k], param) + } + } + if action.Fetch.Headers != nil { + fetchCopy.Headers = make([][]string, len(action.Fetch.Headers)) + for k, header := range action.Fetch.Headers { + fetchCopy.Headers[k] = make([]string, len(header)) + copy(fetchCopy.Headers[k], header) + } + } + actionsCopy[j].Fetch = &fetchCopy + } + + // Deep copy infinity options + if action.Infinity != nil { + infinityCopy := *action.Infinity + if action.Infinity.QueryParams != nil { + infinityCopy.QueryParams = make([][]string, len(action.Infinity.QueryParams)) + for k, param := range action.Infinity.QueryParams { + infinityCopy.QueryParams[k] = make([]string, len(param)) + copy(infinityCopy.QueryParams[k], param) + } + } + if action.Infinity.Headers != nil { + infinityCopy.Headers = make([][]string, len(action.Infinity.Headers)) + for k, header := range action.Infinity.Headers { + infinityCopy.Headers[k] = make([]string, len(header)) + copy(infinityCopy.Headers[k], header) + } + } + actionsCopy[j].Infinity = &infinityCopy + } + } + + elementCopy.PanelKind.Spec.VizConfig.Spec.FieldConfig.Defaults.Actions = actionsCopy + } + } + + if element.LibraryPanelKind != nil { + libraryCopy := *element.LibraryPanelKind + elementCopy.LibraryPanelKind = &libraryCopy + } + + result.Spec.Elements[key] = elementCopy + } + + return result +} diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go index 8e3e22c83be..064f5f395b0 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go @@ -18,6 +18,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.AnnotationPermission": schema_pkg_apis_dashboard_v2beta1_AnnotationPermission(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.Dashboard": schema_pkg_apis_dashboard_v2beta1_Dashboard(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAccess": schema_pkg_apis_dashboard_v2beta1_DashboardAccess(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAction": schema_pkg_apis_dashboard_v2beta1_DashboardAction(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardActionVariable": schema_pkg_apis_dashboard_v2beta1_DashboardActionVariable(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdHocFilterWithLabels": schema_pkg_apis_dashboard_v2beta1_DashboardAdHocFilterWithLabels(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdhocVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardAdhocVariableKind(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdhocVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardAdhocVariableSpec(ref), @@ -51,6 +53,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDatasourceVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardDatasourceVariableSpec(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDynamicConfigValue": schema_pkg_apis_dashboard_v2beta1_DashboardDynamicConfigValue(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardElementReference": schema_pkg_apis_dashboard_v2beta1_DashboardElementReference(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFetchOptions": schema_pkg_apis_dashboard_v2beta1_DashboardFetchOptions(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldColor": schema_pkg_apis_dashboard_v2beta1_DashboardFieldColor(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldConfig": schema_pkg_apis_dashboard_v2beta1_DashboardFieldConfig(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldConfigSource": schema_pkg_apis_dashboard_v2beta1_DashboardFieldConfigSource(ref), @@ -62,6 +65,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGridLayoutSpec": schema_pkg_apis_dashboard_v2beta1_DashboardGridLayoutSpec(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGroupByVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardGroupByVariableKind(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardGroupByVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardGroupByVariableSpec(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardInfinityOptions": schema_pkg_apis_dashboard_v2beta1_DashboardInfinityOptions(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardIntervalVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardIntervalVariableKind(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardIntervalVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardIntervalVariableSpec(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardJSONCodec": schema_pkg_apis_dashboard_v2beta1_DashboardJSONCodec(ref), @@ -108,6 +112,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTimeRangeOption": schema_pkg_apis_dashboard_v2beta1_DashboardTimeRangeOption(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTimeSettingsSpec": schema_pkg_apis_dashboard_v2beta1_DashboardTimeSettingsSpec(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardTransformationKind": schema_pkg_apis_dashboard_v2beta1_DashboardTransformationKind(ref), + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1ActionStyle": schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1ActionStyle(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1AdhocVariableKindDatasource": schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1AdhocVariableKindDatasource(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1DataQueryKindDatasource": schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1DataQueryKindDatasource(ref), "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1FieldConfigSourceOverrides": schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1FieldConfigSourceOverrides(ref), @@ -311,6 +316,109 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardAccess(ref common.ReferenceCallb } } +func schema_pkg_apis_dashboard_v2beta1_DashboardAction(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "type": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "fetch": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFetchOptions"), + }, + }, + "infinity": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardInfinityOptions"), + }, + }, + "confirmation": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "oneClick": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, + "variables": { + 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/dashboard/pkg/apis/dashboard/v2beta1.DashboardActionVariable"), + }, + }, + }, + }, + }, + "style": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1ActionStyle"), + }, + }, + }, + Required: []string{"type", "title"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardActionVariable", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFetchOptions", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardInfinityOptions", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardV2beta1ActionStyle"}, + } +} + +func schema_pkg_apis_dashboard_v2beta1_DashboardActionVariable(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "key": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "type": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"key", "name", "type"}, + }, + }, + } +} + func schema_pkg_apis_dashboard_v2beta1_DashboardAdHocFilterWithLabels(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -1836,6 +1944,82 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardElementReference(ref common.Refe } } +func schema_pkg_apis_dashboard_v2beta1_DashboardFetchOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "method": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "url": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "body": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "queryParams": { + SchemaProps: spec.SchemaProps{ + Description: "These are 2D arrays of strings, each representing a key-value pair We are defining them this way because we can't generate a go struct that that would have exactly two strings in each sub-array", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + }, + }, + "headers": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + }, + }, + }, + Required: []string{"method", "url"}, + }, + }, + } +} + func schema_pkg_apis_dashboard_v2beta1_DashboardFieldColor(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -1988,6 +2172,20 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardFieldConfig(ref common.Reference }, }, }, + "actions": { + SchemaProps: spec.SchemaProps{ + Description: "Define interactive HTTP requests that can be triggered from data visualizations.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAction"), + }, + }, + }, + }, + }, "noValue": { SchemaProps: spec.SchemaProps{ Description: "Alternative to empty string", @@ -2014,7 +2212,7 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardFieldConfig(ref common.Reference }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldColor", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardThresholdsConfig", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap"}, + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAction", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardFieldColor", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardThresholdsConfig", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardValueMapOrRangeMapOrRegexMapOrSpecialValueMap"}, } } @@ -2389,6 +2587,89 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardGroupByVariableSpec(ref common.R } } +func schema_pkg_apis_dashboard_v2beta1_DashboardInfinityOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "method": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "url": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "body": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + "queryParams": { + SchemaProps: spec.SchemaProps{ + Description: "These are 2D arrays of strings, each representing a key-value pair We are defining them this way because we can't generate a go struct that that would have exactly two strings in each sub-array", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + }, + }, + "datasourceUid": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "headers": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + }, + }, + }, + Required: []string{"method", "url", "datasourceUid"}, + }, + }, + } +} + func schema_pkg_apis_dashboard_v2beta1_DashboardIntervalVariableKind(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -4389,6 +4670,24 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardTransformationKind(ref common.Re } } +func schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1ActionStyle(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "backgroundColor": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_pkg_apis_dashboard_v2beta1_DashboardV2beta1AdhocVariableKindDatasource(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi_violation_exceptions.list b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi_violation_exceptions.list index d516d5c8748..ef7c8aa4a3a 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi_violation_exceptions.list +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi_violation_exceptions.list @@ -1,3 +1,4 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardAction,Variables API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardAdHocFilterWithLabels,ValueLabels API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardAdHocFilterWithLabels,Values API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardAdhocVariableSpec,BaseFilters @@ -9,11 +10,16 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/ API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardCustomVariableSpec,Options API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardDashboardLink,Tags API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardDatasourceVariableSpec,Options +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardFetchOptions,Headers +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardFetchOptions,QueryParams +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardFieldConfig,Actions API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardFieldConfig,Links API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardFieldConfig,Mappings API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardFieldConfigSource,Overrides API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardGridLayoutSpec,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardGroupByVariableSpec,Options +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardInfinityOptions,Headers +API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardInfinityOptions,QueryParams API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardIntervalVariableSpec,Options API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardMetadata,Finalizers API rule violation: list_type_missing,github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1,DashboardPanelSpec,Links diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index ab4da8db7d9..20571c8211a 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -298,6 +298,59 @@ lineage: schemas: [{ // - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu #DashboardLinkPlacement: "inControlsMenu" @cuetsy(kind="type") + // Dashboard action type + #ActionType: "fetch" | "infinity" @cuetsy(kind="type") + + // Fetch options + #FetchOptions: { + method: #HttpRequestMethod + url: string + body?: string + // These are 2D arrays of strings, each representing a key-value pair + // We are defining this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: [...[...string]] + headers?: [...[...string]] + } @cuetsy(kind="interface") + + // Infinity options + #InfinityOptions: { + method: #HttpRequestMethod + url: string + body?: string + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: [...[...string]] + headers?: [...[...string]] + datasourceUid: string + } @cuetsy(kind="interface") + + #HttpRequestMethod: "GET" | "PUT" | "POST" | "DELETE" | "PATCH" @cuetsy(kind="type") + + // Action variable type + #ActionVariableType: "string" @cuetsy(kind="type") + + #ActionVariable: { + key: string + name: string + type: #ActionVariableType + } @cuetsy(kind="interface") + + // Dashboard action + #Action: { + type: #ActionType + title: string + fetch?: #FetchOptions + infinity?: #InfinityOptions + confirmation?: string + oneClick?: bool + variables?: [...#ActionVariable] + style?: { + backgroundColor?: string + } + } @cuetsy(kind="interface") + // Dashboard variable type // `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. // `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only). @@ -727,6 +780,9 @@ lineage: schemas: [{ // The behavior when clicking on a result links?: [...] @grafanamaturity(NeedsExpertReview) + // Define interactive HTTP requests that can be triggered from data visualizations. + actions?: [...#Action] @grafanamaturity(NeedsExpertReview) + // Alternative to empty string noValue?: string @grafanamaturity(NeedsExpertReview) diff --git a/packages/grafana-schema/src/index.gen.ts b/packages/grafana-schema/src/index.gen.ts index b608be01349..5646058691b 100644 --- a/packages/grafana-schema/src/index.gen.ts +++ b/packages/grafana-schema/src/index.gen.ts @@ -15,6 +15,13 @@ export type { DashboardLink, DashboardLinkType, DashboardLinkPlacement, + ActionType, + FetchOptions, + InfinityOptions, + HttpRequestMethod, + ActionVariableType, + ActionVariable, + Action, VariableType, FieldColorSeriesByMode, FieldColor, @@ -37,6 +44,9 @@ export { VariableRefresh, VariableSort, defaultDashboardLink, + defaultFetchOptions, + defaultInfinityOptions, + defaultAction, FieldColorModeId, defaultGridPos, ThresholdsMode, diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index baf09ea16ce..d158d884aa0 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -363,6 +363,87 @@ export type DashboardLinkType = ('link' | 'dashboards'); */ export type DashboardLinkPlacement = 'inControlsMenu'; +/** + * Dashboard action type + */ +export type ActionType = ('fetch' | 'infinity'); + +/** + * Fetch options + */ +export interface FetchOptions { + body?: string; + headers?: Array>; + method: HttpRequestMethod; + /** + * These are 2D arrays of strings, each representing a key-value pair + * We are defining this way because we can't generate a go struct that + * that would have exactly two strings in each sub-array + */ + queryParams?: Array>; + url: string; +} + +export const defaultFetchOptions: Partial = { + headers: [], + queryParams: [], +}; + +/** + * Infinity options + */ +export interface InfinityOptions { + body?: string; + datasourceUid: string; + headers?: Array>; + method: HttpRequestMethod; + /** + * These are 2D arrays of strings, each representing a key-value pair + * We are defining them this way because we can't generate a go struct that + * that would have exactly two strings in each sub-array + */ + queryParams?: Array>; + url: string; +} + +export const defaultInfinityOptions: Partial = { + headers: [], + queryParams: [], +}; + +export type HttpRequestMethod = ('GET' | 'PUT' | 'POST' | 'DELETE' | 'PATCH'); + +/** + * Action variable type + */ +export type ActionVariableType = 'string'; + +export interface ActionVariable { + key: string; + name: string; + type: ActionVariableType; +} + +/** + * Dashboard action + */ +export interface Action { + confirmation?: string; + fetch?: FetchOptions; + infinity?: InfinityOptions; + oneClick?: boolean; + style?: { + backgroundColor?: string; + }; + title: string; + type: ActionType; + variables?: Array; +} + +export const defaultAction: Partial = { + variables: [], +}; + /** * Dashboard variable type * `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on. @@ -916,6 +997,10 @@ export const defaultMatcherConfig: Partial = { * Field options allow you to change how the data is displayed in your visualizations. */ export interface FieldConfig { + /** + * Define interactive HTTP requests that can be triggered from data visualizations. + */ + actions?: Array; /** * Panel color configuration */ @@ -1001,6 +1086,7 @@ export interface FieldConfig { } export const defaultFieldConfig: Partial = { + actions: [], links: [], mappings: [], }; diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts index d821bff4532..8c6adae3078 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts @@ -318,6 +318,8 @@ export interface FieldConfig { color?: FieldColor; // The behavior when clicking on a result links?: any[]; + // Define interactive HTTP requests that can be triggered from data visualizations. + actions?: Action[]; // Alternative to empty string noValue?: string; // custom is specified by the FieldConfig field @@ -505,6 +507,81 @@ export type FieldColorSeriesByMode = "min" | "max" | "last"; export const defaultFieldColorSeriesByMode = (): FieldColorSeriesByMode => ("min"); +export interface Action { + type: ActionType; + title: string; + fetch?: FetchOptions; + infinity?: InfinityOptions; + confirmation?: string; + oneClick?: boolean; + variables?: ActionVariable[]; + style?: { + backgroundColor?: string; + }; +} + +export const defaultAction = (): Action => ({ + type: "fetch", + title: "", +}); + +export type ActionType = "fetch" | "infinity"; + +export const defaultActionType = (): ActionType => ("fetch"); + +export interface FetchOptions { + method: HttpRequestMethod; + url: string; + body?: string; + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: string[][]; + headers?: string[][]; +} + +export const defaultFetchOptions = (): FetchOptions => ({ + method: "GET", + url: "", +}); + +export type HttpRequestMethod = "GET" | "PUT" | "POST" | "DELETE" | "PATCH"; + +export const defaultHttpRequestMethod = (): HttpRequestMethod => ("GET"); + +export interface InfinityOptions { + method: HttpRequestMethod; + url: string; + body?: string; + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: string[][]; + datasourceUid: string; + headers?: string[][]; +} + +export const defaultInfinityOptions = (): InfinityOptions => ({ + method: "GET", + url: "", + datasourceUid: "", +}); + +export interface ActionVariable { + key: string; + name: string; + type: "string"; +} + +export const defaultActionVariable = (): ActionVariable => ({ + key: "", + name: "", + type: ActionVariableType, +}); + +// Action variable type +export const ActionVariableType = "string"; + export interface DynamicConfigValue { id: string; value?: any; diff --git a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts index 65739dc88ec..e095f7b948b 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts @@ -317,6 +317,8 @@ export interface FieldConfig { color?: FieldColor; // The behavior when clicking on a result links?: any[]; + // Define interactive HTTP requests that can be triggered from data visualizations. + actions?: Action[]; // Alternative to empty string noValue?: string; // custom is specified by the FieldConfig field @@ -504,6 +506,81 @@ export type FieldColorSeriesByMode = "min" | "max" | "last"; export const defaultFieldColorSeriesByMode = (): FieldColorSeriesByMode => ("min"); +export interface Action { + type: ActionType; + title: string; + fetch?: FetchOptions; + infinity?: InfinityOptions; + confirmation?: string; + oneClick?: boolean; + variables?: ActionVariable[]; + style?: { + backgroundColor?: string; + }; +} + +export const defaultAction = (): Action => ({ + type: "fetch", + title: "", +}); + +export type ActionType = "fetch" | "infinity"; + +export const defaultActionType = (): ActionType => ("fetch"); + +export interface FetchOptions { + method: HttpRequestMethod; + url: string; + body?: string; + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: string[][]; + headers?: string[][]; +} + +export const defaultFetchOptions = (): FetchOptions => ({ + method: "GET", + url: "", +}); + +export type HttpRequestMethod = "GET" | "PUT" | "POST" | "DELETE" | "PATCH"; + +export const defaultHttpRequestMethod = (): HttpRequestMethod => ("GET"); + +export interface InfinityOptions { + method: HttpRequestMethod; + url: string; + body?: string; + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + queryParams?: string[][]; + datasourceUid: string; + headers?: string[][]; +} + +export const defaultInfinityOptions = (): InfinityOptions => ({ + method: "GET", + url: "", + datasourceUid: "", +}); + +export interface ActionVariable { + key: string; + name: string; + type: "string"; +} + +export const defaultActionVariable = (): ActionVariable => ({ + key: "", + name: "", + type: ActionVariableType, +}); + +// Action variable type +export const ActionVariableType = "string"; + export interface DynamicConfigValue { id: string; value?: any; diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go index e01c43d2229..fa72f781f75 100644 --- a/pkg/kinds/dashboard/dashboard_spec_gen.go +++ b/pkg/kinds/dashboard/dashboard_spec_gen.go @@ -427,6 +427,8 @@ type FieldConfig struct { Color *FieldColor `json:"color,omitempty"` // The behavior when clicking on a result Links []any `json:"links,omitempty"` + // Define interactive HTTP requests that can be triggered from data visualizations. + Actions []Action `json:"actions,omitempty"` // Alternative to empty string NoValue *string `json:"noValue,omitempty"` // custom is specified by the FieldConfig field @@ -654,6 +656,92 @@ const ( FieldColorSeriesByModeLast FieldColorSeriesByMode = "last" ) +// Dashboard action +type Action struct { + Type ActionType `json:"type"` + Title string `json:"title"` + Fetch *FetchOptions `json:"fetch,omitempty"` + Infinity *InfinityOptions `json:"infinity,omitempty"` + Confirmation *string `json:"confirmation,omitempty"` + OneClick *bool `json:"oneClick,omitempty"` + Variables []ActionVariable `json:"variables,omitempty"` + Style *DashboardActionStyle `json:"style,omitempty"` +} + +// NewAction creates a new Action object. +func NewAction() *Action { + return &Action{} +} + +// Dashboard action type +type ActionType string + +const ( + ActionTypeFetch ActionType = "fetch" + ActionTypeInfinity ActionType = "infinity" +) + +// Fetch options +type FetchOptions struct { + Method HttpRequestMethod `json:"method"` + Url string `json:"url"` + Body *string `json:"body,omitempty"` + // These are 2D arrays of strings, each representing a key-value pair + // We are defining this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + QueryParams [][]string `json:"queryParams,omitempty"` + Headers [][]string `json:"headers,omitempty"` +} + +// NewFetchOptions creates a new FetchOptions object. +func NewFetchOptions() *FetchOptions { + return &FetchOptions{} +} + +type HttpRequestMethod string + +const ( + HttpRequestMethodGET HttpRequestMethod = "GET" + HttpRequestMethodPUT HttpRequestMethod = "PUT" + HttpRequestMethodPOST HttpRequestMethod = "POST" + HttpRequestMethodDELETE HttpRequestMethod = "DELETE" + HttpRequestMethodPATCH HttpRequestMethod = "PATCH" +) + +// Infinity options +type InfinityOptions struct { + Method HttpRequestMethod `json:"method"` + Url string `json:"url"` + Body *string `json:"body,omitempty"` + // These are 2D arrays of strings, each representing a key-value pair + // We are defining them this way because we can't generate a go struct that + // that would have exactly two strings in each sub-array + QueryParams [][]string `json:"queryParams,omitempty"` + Headers [][]string `json:"headers,omitempty"` + DatasourceUid string `json:"datasourceUid"` +} + +// NewInfinityOptions creates a new InfinityOptions object. +func NewInfinityOptions() *InfinityOptions { + return &InfinityOptions{} +} + +type ActionVariable struct { + Key string `json:"key"` + Name string `json:"name"` + Type string `json:"type"` +} + +// NewActionVariable creates a new ActionVariable object. +func NewActionVariable() *ActionVariable { + return &ActionVariable{ + Type: ActionVariableType, + } +} + +// Action variable type +const ActionVariableType = "string" + type DynamicConfigValue struct { Id string `json:"id"` Value any `json:"value,omitempty"` @@ -1042,6 +1130,15 @@ func NewDashboardSpecialValueMapOptions() *DashboardSpecialValueMapOptions { } } +type DashboardActionStyle struct { + BackgroundColor *string `json:"backgroundColor,omitempty"` +} + +// NewDashboardActionStyle creates a new DashboardActionStyle object. +func NewDashboardActionStyle() *DashboardActionStyle { + return &DashboardActionStyle{} +} + type PanelRepeatDirection string const ( diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json index aab8e377285..ddc33887775 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json @@ -1060,6 +1060,71 @@ } } }, + "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardAction": { + "type": "object", + "required": [ + "type", + "title" + ], + "properties": { + "confirmation": { + "type": "string" + }, + "fetch": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardFetchOptions" + }, + "infinity": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardInfinityOptions" + }, + "oneClick": { + "type": "boolean" + }, + "style": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardV2alpha1ActionStyle" + }, + "title": { + "type": "string", + "default": "" + }, + "type": { + "type": "string", + "default": "" + }, + "variables": { + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardActionVariable" + } + ] + } + } + } + }, + "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardActionVariable": { + "type": "object", + "required": [ + "key", + "name", + "type" + ], + "properties": { + "key": { + "type": "string", + "default": "" + }, + "name": { + "type": "string", + "default": "" + }, + "type": { + "type": "string", + "default": "" + } + } + }, "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardAdHocFilterWithLabels": { "description": "Define the AdHocFilterWithLabels type", "type": "object", @@ -2062,6 +2127,47 @@ } } }, + "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardFetchOptions": { + "type": "object", + "required": [ + "method", + "url" + ], + "properties": { + "body": { + "type": "string" + }, + "headers": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string", + "default": "" + } + } + }, + "method": { + "type": "string", + "default": "" + }, + "queryParams": { + "description": "These are 2D arrays of strings, each representing a key-value pair We are defining them this way because we can't generate a go struct that that would have exactly two strings in each sub-array", + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string", + "default": "" + } + } + }, + "url": { + "type": "string", + "default": "" + } + } + }, "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardFieldColor": { "description": "Map a field to a color.", "type": "object", @@ -2088,6 +2194,18 @@ "description": "The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results. Each column within this structure is called a field. A field can represent a single time series or table column. Field options allow you to change how the data is displayed in your visualizations.", "type": "object", "properties": { + "actions": { + "description": "Define interactive HTTP requests that can be triggered from data visualizations.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardAction" + } + ] + } + }, "color": { "description": "Panel color configuration", "allOf": [ @@ -2427,6 +2545,52 @@ } } }, + "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardInfinityOptions": { + "type": "object", + "required": [ + "method", + "url", + "datasourceUid" + ], + "properties": { + "body": { + "type": "string" + }, + "datasourceUid": { + "type": "string", + "default": "" + }, + "headers": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string", + "default": "" + } + } + }, + "method": { + "type": "string", + "default": "" + }, + "queryParams": { + "description": "These are 2D arrays of strings, each representing a key-value pair We are defining them this way because we can't generate a go struct that that would have exactly two strings in each sub-array", + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string", + "default": "" + } + } + }, + "url": { + "type": "string", + "default": "" + } + } + }, "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardIntervalVariableKind": { "description": "Interval variable kind", "type": "object", @@ -3735,6 +3899,14 @@ } } }, + "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardV2alpha1ActionStyle": { + "type": "object", + "properties": { + "backgroundColor": { + "type": "string" + } + } + }, "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardV2alpha1FieldConfigSourceOverrides": { "type": "object", "required": [ diff --git a/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.test.ts b/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.test.ts index b1633283c97..548be1e0233 100644 --- a/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformToV2TypesUtils.test.ts @@ -21,33 +21,33 @@ describe('transformToV2TypesUtils', () => { expect(transformCursorSynctoEnum(undefined)).toBe(defaultDashboardCursorSync()); }); }); -}); -describe('transformVariableRefreshToEnum', () => { - it('should return the correct enum value for variable refresh', () => { - expect(transformVariableRefreshToEnum(0)).toBe('never'); - expect(transformVariableRefreshToEnum(1)).toBe('onDashboardLoad'); - expect(transformVariableRefreshToEnum(2)).toBe('onTimeRangeChanged'); - expect(transformVariableRefreshToEnum(undefined)).toBe(defaultVariableRefresh()); + describe('transformVariableRefreshToEnum', () => { + it('should return the correct enum value for variable refresh', () => { + expect(transformVariableRefreshToEnum(0)).toBe('never'); + expect(transformVariableRefreshToEnum(1)).toBe('onDashboardLoad'); + expect(transformVariableRefreshToEnum(2)).toBe('onTimeRangeChanged'); + expect(transformVariableRefreshToEnum(undefined)).toBe(defaultVariableRefresh()); + }); }); -}); -describe('transformVariableHideToEnum', () => { - it('should return the correct enum value for variable hide', () => { - expect(transformVariableHideToEnum(0)).toBe('dontHide'); - expect(transformVariableHideToEnum(1)).toBe('hideLabel'); - expect(transformVariableHideToEnum(2)).toBe('hideVariable'); - expect(transformVariableHideToEnum(undefined)).toBe(defaultVariableHide()); + describe('transformVariableHideToEnum', () => { + it('should return the correct enum value for variable hide', () => { + expect(transformVariableHideToEnum(0)).toBe('dontHide'); + expect(transformVariableHideToEnum(1)).toBe('hideLabel'); + expect(transformVariableHideToEnum(2)).toBe('hideVariable'); + expect(transformVariableHideToEnum(undefined)).toBe(defaultVariableHide()); + }); }); -}); -describe('transformSortVariableToEnum', () => { - it('should return the correct enum value for variable sort', () => { - expect(transformSortVariableToEnum(0)).toBe('disabled'); - expect(transformSortVariableToEnum(1)).toBe('alphabeticalAsc'); - expect(transformSortVariableToEnum(2)).toBe('alphabeticalDesc'); - expect(transformSortVariableToEnum(3)).toBe('numericalAsc'); - expect(transformSortVariableToEnum(4)).toBe('numericalDesc'); - expect(transformSortVariableToEnum(undefined)).toBe(defaultVariableSort()); + describe('transformSortVariableToEnum', () => { + it('should return the correct enum value for variable sort', () => { + expect(transformSortVariableToEnum(0)).toBe('disabled'); + expect(transformSortVariableToEnum(1)).toBe('alphabeticalAsc'); + expect(transformSortVariableToEnum(2)).toBe('alphabeticalDesc'); + expect(transformSortVariableToEnum(3)).toBe('numericalAsc'); + expect(transformSortVariableToEnum(4)).toBe('numericalDesc'); + expect(transformSortVariableToEnum(undefined)).toBe(defaultVariableSort()); + }); }); });