Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d4325c9dd | |||
| ba79a2bbd6 | |||
| 3175275c25 | |||
| 30c87fef95 | |||
| 86d8b3ada8 | |||
| 0b9046be15 | |||
| 20eeff3e7b | |||
| ec55871b9b | |||
| 0bfcc55411 | |||
| 016301c304 | |||
| 5e05289bc8 | |||
| be734e970e | |||
| 05681efee3 | |||
| 844a7332b9 | |||
| 4a3cf7abaf | |||
| 1cbbce160d | |||
| 47fbff6136 | |||
| d98dd3e952 |
@@ -129,7 +129,7 @@ DashboardLink: {
|
||||
placement?: DashboardLinkPlacement
|
||||
}
|
||||
|
||||
// Dashboard Link placement. Defines where the link should be displayed.
|
||||
// Dashboard Link placement. Defines where the link should be displayed.
|
||||
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
|
||||
DashboardLinkPlacement: "inControlsMenu"
|
||||
|
||||
@@ -932,6 +932,7 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
@@ -935,6 +935,7 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
@@ -222,8 +222,10 @@ lineage: schemas: [{
|
||||
// Optional field, if you want to extract part of a series name or metric node segment.
|
||||
// Named capture groups can be used to separate the display text and value.
|
||||
regex?: string
|
||||
// Determine whether regex applies to variable value or display text
|
||||
regexApplyTo?: #VariableRegexApplyTo
|
||||
// Optional, indicates whether a custom type variable uses CSV or JSON to define its values
|
||||
valuesFormat?: "csv" | "json" | *"csv"
|
||||
// Determine whether regex applies to variable value or display text
|
||||
regexApplyTo?: #VariableRegexApplyTo
|
||||
// Additional static options for query variable
|
||||
staticOptions?: [...#VariableOption]
|
||||
// Ordering of static options in relation to options returned from data source for query variable
|
||||
|
||||
@@ -222,8 +222,10 @@ lineage: schemas: [{
|
||||
// Optional field, if you want to extract part of a series name or metric node segment.
|
||||
// Named capture groups can be used to separate the display text and value.
|
||||
regex?: string
|
||||
// Determine whether regex applies to variable value or display text
|
||||
regexApplyTo?: #VariableRegexApplyTo
|
||||
// Optional, indicates whether a custom type variable uses CSV or JSON to define its values
|
||||
valuesFormat?: "csv" | "json" | *"csv"
|
||||
// Determine whether regex applies to variable value or display text
|
||||
regexApplyTo?: #VariableRegexApplyTo
|
||||
// Additional static options for query variable
|
||||
staticOptions?: [...#VariableOption]
|
||||
// Ordering of static options in relation to options returned from data source for query variable
|
||||
|
||||
@@ -133,7 +133,7 @@ DashboardLink: {
|
||||
placement?: DashboardLinkPlacement
|
||||
}
|
||||
|
||||
// Dashboard Link placement. Defines where the link should be displayed.
|
||||
// Dashboard Link placement. Defines where the link should be displayed.
|
||||
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
|
||||
DashboardLinkPlacement: "inControlsMenu"
|
||||
|
||||
@@ -936,6 +936,7 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
+21
-12
@@ -1703,18 +1703,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind {
|
||||
// Custom variable specification
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardCustomVariableSpec struct {
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Current DashboardVariableOption `json:"current"`
|
||||
Options []DashboardVariableOption `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
IncludeAll bool `json:"includeAll"`
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Hide DashboardVariableHide `json:"hide"`
|
||||
SkipUrlSync bool `json:"skipUrlSync"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowCustomValue bool `json:"allowCustomValue"`
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Current DashboardVariableOption `json:"current"`
|
||||
Options []DashboardVariableOption `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
IncludeAll bool `json:"includeAll"`
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Hide DashboardVariableHide `json:"hide"`
|
||||
SkipUrlSync bool `json:"skipUrlSync"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowCustomValue bool `json:"allowCustomValue"`
|
||||
ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"`
|
||||
}
|
||||
|
||||
// NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object.
|
||||
@@ -2098,6 +2099,14 @@ const (
|
||||
DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardCustomVariableSpecValuesFormat string
|
||||
|
||||
const (
|
||||
DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv"
|
||||
DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardPanelKindOrLibraryPanelKind struct {
|
||||
PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"`
|
||||
|
||||
@@ -1548,6 +1548,12 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardCustomVariableSpec(ref common.R
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"valuesFormat": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"},
|
||||
},
|
||||
|
||||
@@ -939,6 +939,7 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
+21
-12
@@ -1707,18 +1707,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind {
|
||||
// Custom variable specification
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardCustomVariableSpec struct {
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Current DashboardVariableOption `json:"current"`
|
||||
Options []DashboardVariableOption `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
IncludeAll bool `json:"includeAll"`
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Hide DashboardVariableHide `json:"hide"`
|
||||
SkipUrlSync bool `json:"skipUrlSync"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowCustomValue bool `json:"allowCustomValue"`
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Current DashboardVariableOption `json:"current"`
|
||||
Options []DashboardVariableOption `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
IncludeAll bool `json:"includeAll"`
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Hide DashboardVariableHide `json:"hide"`
|
||||
SkipUrlSync bool `json:"skipUrlSync"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowCustomValue bool `json:"allowCustomValue"`
|
||||
ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"`
|
||||
}
|
||||
|
||||
// NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object.
|
||||
@@ -2133,6 +2134,14 @@ const (
|
||||
DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardCustomVariableSpecValuesFormat string
|
||||
|
||||
const (
|
||||
DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv"
|
||||
DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardPanelKindOrLibraryPanelKind struct {
|
||||
PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"`
|
||||
|
||||
@@ -1560,6 +1560,12 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableSpec(ref common.Re
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"valuesFormat": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"},
|
||||
},
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -1336,6 +1336,17 @@ func buildCustomVariable(varMap map[string]interface{}, commonProps CommonVariab
|
||||
customVar.Spec.AllValue = &allValue
|
||||
}
|
||||
|
||||
if valuesFormat := schemaversion.GetStringValue(varMap, "valuesFormat"); valuesFormat != "" {
|
||||
switch valuesFormat {
|
||||
case string(dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson):
|
||||
format := dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson
|
||||
customVar.Spec.ValuesFormat = &format
|
||||
case string(dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv):
|
||||
format := dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv
|
||||
customVar.Spec.ValuesFormat = &format
|
||||
}
|
||||
}
|
||||
|
||||
return dashv2alpha1.DashboardVariableKind{
|
||||
CustomVariableKind: customVar,
|
||||
}, nil
|
||||
|
||||
@@ -685,6 +685,7 @@ func convertVariable_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableKind,
|
||||
SkipUrlSync: in.CustomVariableKind.Spec.SkipUrlSync,
|
||||
Description: in.CustomVariableKind.Spec.Description,
|
||||
AllowCustomValue: in.CustomVariableKind.Spec.AllowCustomValue,
|
||||
ValuesFormat: convertCustomValuesFormat_V2alpha1_to_V2beta1(in.CustomVariableKind.Spec.ValuesFormat),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -758,6 +759,23 @@ func convertVariable_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableKind,
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertCustomValuesFormat_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardCustomVariableSpecValuesFormat) *dashv2beta1.DashboardCustomVariableSpecValuesFormat {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch *in {
|
||||
case dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson:
|
||||
v := dashv2beta1.DashboardCustomVariableSpecValuesFormatJson
|
||||
return &v
|
||||
case dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv:
|
||||
v := dashv2beta1.DashboardCustomVariableSpecValuesFormatCsv
|
||||
return &v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func convertQueryVariableSpec_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardQueryVariableSpec, out *dashv2beta1.DashboardQueryVariableSpec, scope conversion.Scope) error {
|
||||
out.Name = in.Name
|
||||
out.Current = convertVariableOption_V2alpha1_to_V2beta1(in.Current)
|
||||
|
||||
@@ -217,6 +217,13 @@ metaV0Alpha1: {
|
||||
title: string
|
||||
description?: string
|
||||
}]
|
||||
// +listType=atomic
|
||||
addedFunctions?: [...{
|
||||
// +listType=set
|
||||
targets: [...string]
|
||||
title: string
|
||||
description?: string
|
||||
}]
|
||||
// +listType=set
|
||||
// +listMapKey=id
|
||||
exposedComponents?: [...{
|
||||
|
||||
@@ -193,6 +193,8 @@ type MetaExtensions struct {
|
||||
AddedComponents []MetaV0alpha1ExtensionsAddedComponents `json:"addedComponents,omitempty"`
|
||||
// +listType=atomic
|
||||
AddedLinks []MetaV0alpha1ExtensionsAddedLinks `json:"addedLinks,omitempty"`
|
||||
// +listType=atomic
|
||||
AddedFunctions []MetaV0alpha1ExtensionsAddedFunctions `json:"addedFunctions,omitempty"`
|
||||
// +listType=set
|
||||
// +listMapKey=id
|
||||
ExposedComponents []MetaV0alpha1ExtensionsExposedComponents `json:"exposedComponents,omitempty"`
|
||||
@@ -396,6 +398,21 @@ func NewMetaV0alpha1ExtensionsAddedLinks() *MetaV0alpha1ExtensionsAddedLinks {
|
||||
}
|
||||
}
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type MetaV0alpha1ExtensionsAddedFunctions struct {
|
||||
// +listType=set
|
||||
Targets []string `json:"targets"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// NewMetaV0alpha1ExtensionsAddedFunctions creates a new MetaV0alpha1ExtensionsAddedFunctions object.
|
||||
func NewMetaV0alpha1ExtensionsAddedFunctions() *MetaV0alpha1ExtensionsAddedFunctions {
|
||||
return &MetaV0alpha1ExtensionsAddedFunctions{
|
||||
Targets: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type MetaV0alpha1ExtensionsExposedComponents struct {
|
||||
Id string `json:"id"`
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -367,7 +367,8 @@ func jsonDataToMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.MetaJSOND
|
||||
|
||||
// Map Extensions
|
||||
if len(jsonData.Extensions.AddedLinks) > 0 || len(jsonData.Extensions.AddedComponents) > 0 ||
|
||||
len(jsonData.Extensions.ExposedComponents) > 0 || len(jsonData.Extensions.ExtensionPoints) > 0 {
|
||||
len(jsonData.Extensions.ExposedComponents) > 0 || len(jsonData.Extensions.ExtensionPoints) > 0 ||
|
||||
len(jsonData.Extensions.AddedFunctions) > 0 {
|
||||
extensions := &pluginsv0alpha1.MetaExtensions{}
|
||||
|
||||
if len(jsonData.Extensions.AddedLinks) > 0 {
|
||||
@@ -398,6 +399,20 @@ func jsonDataToMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.MetaJSOND
|
||||
}
|
||||
}
|
||||
|
||||
if len(jsonData.Extensions.AddedFunctions) > 0 {
|
||||
extensions.AddedFunctions = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsAddedFunctions, 0, len(jsonData.Extensions.AddedFunctions))
|
||||
for _, comp := range jsonData.Extensions.AddedFunctions {
|
||||
v0Comp := pluginsv0alpha1.MetaV0alpha1ExtensionsAddedFunctions{
|
||||
Targets: comp.Targets,
|
||||
Title: comp.Title,
|
||||
}
|
||||
if comp.Description != "" {
|
||||
v0Comp.Description = &comp.Description
|
||||
}
|
||||
extensions.AddedFunctions = append(extensions.AddedFunctions, v0Comp)
|
||||
}
|
||||
}
|
||||
|
||||
if len(jsonData.Extensions.ExposedComponents) > 0 {
|
||||
extensions.ExposedComponents = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsExposedComponents, 0, len(jsonData.Extensions.ExposedComponents))
|
||||
for _, comp := range jsonData.Extensions.ExposedComponents {
|
||||
|
||||
@@ -48,6 +48,14 @@ Recording rules can be helpful in various scenarios, such as:
|
||||
|
||||
The evaluation group of the recording rule determines how often the metric is pre-computed.
|
||||
|
||||
## Recommendations
|
||||
|
||||
- **Use frequent evaluation intervals**. Set frequent evaluation intervals for recording rules. Long intervals, such as an hour, can cause the recorded metric to be stale and lead to misaligned alert rule evaluations, especially when combined with a long pending period.
|
||||
- **Align alert evaluation with recording frequency**. The evaluation interval of an alert rule that depends on a recorded metric should be aligned with the recording rule's interval. If a recording rule runs every 3 minutes, the alert rule should also be evaluated at a similar frequency to ensure it acts on fresh data.
|
||||
- **Use `_over_time` functions for instant queries**. Since all alert rules are ultimately executed as an instant query, you can use functions like `max_over_time(my_metric[5m])` as an instant query. This allows you to get an aggregated value over a period without using a range query and a reduce expression.
|
||||
|
||||
## Types of recording rules
|
||||
|
||||
Similar to alert rules, Grafana supports two types of recording rules:
|
||||
|
||||
1. [Grafana-managed recording rules](ref:grafana-managed-recording-rules), which can query any Grafana data source supported by alerting. It's the recommended option.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/
|
||||
description: This section provides a set of guides for useful alerting practices and recommendations
|
||||
keywords:
|
||||
- grafana
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Best practices
|
||||
title: Grafana Alerting best practices
|
||||
weight: 170
|
||||
---
|
||||
|
||||
# Grafana Alerting best practices
|
||||
|
||||
This section provides a set of guides and examples of best practices for Grafana Alerting. Here you can learn more about how to handle common alert management problems and you can see examples of more advanced usage of Grafana Alerting.
|
||||
|
||||
{{< section >}}
|
||||
|
||||
Designing and configuring an alert management set up that works takes time. Here are some additional tips on how to create an effective alert management set up:
|
||||
|
||||
{{< shared id="alert-planning-fundamentals" >}}
|
||||
|
||||
**Which are the key metrics for your business that you want to monitor and alert on?**
|
||||
|
||||
- Find events that are important to know about and not so trivial or frequent that recipients ignore them.
|
||||
- Alerts should only be created for big events that require immediate attention or intervention.
|
||||
- Consider quality over quantity.
|
||||
|
||||
**How do you want to organize your alerts and notifications?**
|
||||
|
||||
- Be selective about who you set to receive alerts. Consider sending them to the right teams, whoever is on call, and the specific channels.
|
||||
- Think carefully about priority and severity levels.
|
||||
- Automate as far as possible provisioning Alerting resources with the API or Terraform.
|
||||
|
||||
**Which information should you include in notifications?**
|
||||
|
||||
- Consider who the alert receivers and responders are.
|
||||
- Share information that helps responders identify and address potential issues.
|
||||
- Link alerts to dashboards to guide responders on which data to investigate.
|
||||
|
||||
**How can you reduce alert fatigue?**
|
||||
|
||||
- Avoid noisy, unnecessary alerts by using silences, mute timings, or pausing alert rule evaluation.
|
||||
- Continually tune your alert rules to review effectiveness. Remove alert rules to avoid duplication or ineffective alerts.
|
||||
- Continually review your thresholds and evaluation rules.
|
||||
|
||||
**How should you configure recording rules?**
|
||||
|
||||
- Use frequent evaluation intervals. It is recommended to set a frequent evaluation interval for recording rules. Long intervals, such as an hour, can cause the recorded metric to be stale and lead to misaligned alert rule evaluations, especially when combined with a long pending period.
|
||||
- Understand query types. Grafana Alerting uses both **Instant** and **Range** queries. Instant queries fetch a single data point, while Range queries fetch a series of data points over time. When using a Range query in an alert condition, you must use a Reduce expression to aggregate the series into a single value.
|
||||
- Align alert evaluation with recording frequency. The evaluation interval of an alert rule that depends on a recorded metric should be aligned with the recording rule's interval. If a recording rule runs every 3 minutes, the alert rule should also be evaluated at a similar frequency to ensure it acts on fresh data.
|
||||
- Use `_over_time` functions for instant queries. Since all alert rules are ultimately executed as an instant query, you can use functions like `max_over_time(my_metric[1h])` as an instant query. This allows you to get an aggregated value over a period without using a range query and a reduce expression.
|
||||
|
||||
{{< /shared >}}
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/examples/
|
||||
description: This section provides a set of guides for useful alerting practices and recommendations
|
||||
keywords:
|
||||
- grafana
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Examples
|
||||
title: Examples
|
||||
weight: 180
|
||||
---
|
||||
|
||||
# Examples
|
||||
|
||||
This section provides practical examples that show how to work with different types of alerting data, apply alert design patterns, reuse alert logic, and take advantage of specific Grafana Alerting features.
|
||||
|
||||
This section includes:
|
||||
|
||||
{{< section >}}
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/dynamic-labels
|
||||
aliases:
|
||||
- ../best-practices/dynamic-labels/ # /docs/grafana/<GRAFANA_VERSION>/alerting/best-practices/dynamic-labels/
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/examples/dynamic-labels
|
||||
description: This example shows how to define dynamic labels based on query values, along with important behavior to keep in mind when using them.
|
||||
keywords:
|
||||
- grafana
|
||||
@@ -10,7 +12,7 @@ labels:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Examples of dynamic labels
|
||||
menuTitle: Dynamic labels
|
||||
title: Example of dynamic labels in alert instances
|
||||
weight: 1104
|
||||
refs:
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/dynamic-thresholds
|
||||
aliases:
|
||||
- ../best-practices/dynamic-thresholds/ # /docs/grafana/<GRAFANA_VERSION>/alerting/best-practices/dynamic-thresholds/
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/examples/dynamic-thresholds
|
||||
description: This example shows how to use a distinct threshold value per dimension using multi-dimensional alerts and a Math expression.
|
||||
keywords:
|
||||
- grafana
|
||||
@@ -10,7 +12,7 @@ labels:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Examples of dynamic thresholds
|
||||
menuTitle: Dynamic thresholds
|
||||
title: Example of dynamic thresholds per dimension
|
||||
weight: 1105
|
||||
refs:
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/high-cardinality-alerts/
|
||||
aliases:
|
||||
- ../best-practices/high-cardinality-alerts/ # /docs/grafana/<GRAFANA_VERSION>/alerting/best-practices/high-cardinality-alerts/
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/examples/high-cardinality-alerts/
|
||||
description: Learn how to detect and alert on high-cardinality metrics that can overload your metrics backend and increase observability costs.
|
||||
keywords:
|
||||
- grafana
|
||||
@@ -8,7 +10,7 @@ labels:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Examples of high-cardinality alerts
|
||||
menuTitle: High-cardinality alerts
|
||||
title: Examples of high-cardinality alerts
|
||||
weight: 1105
|
||||
refs:
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/multi-dimensional-alerts/
|
||||
aliases:
|
||||
- ../best-practices/multi-dimensional-alerts/ # /docs/grafana/<GRAFANA_VERSION>/alerting/best-practices/multi-dimensional-alerts/
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/examples/multi-dimensional-alerts/
|
||||
description: This example shows how a single alert rule can generate multiple alert instances using time series data.
|
||||
keywords:
|
||||
- grafana
|
||||
@@ -8,7 +10,7 @@ labels:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Examples of multi-dimensional alerts
|
||||
menuTitle: Multi-dimensional alerts
|
||||
title: Example of multi-dimensional alerts on time series data
|
||||
weight: 1101
|
||||
refs:
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/table-data
|
||||
aliases:
|
||||
- ../best-practices/table-data/ # /docs/grafana/<GRAFANA_VERSION>/alerting/best-practices/table-data/
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/examples/table-data
|
||||
description: This example shows how to create an alert rule using table data.
|
||||
keywords:
|
||||
- grafana
|
||||
@@ -8,7 +10,7 @@ labels:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Examples of table data
|
||||
menuTitle: Table data
|
||||
title: Example of alerting on tabular data
|
||||
weight: 1102
|
||||
refs:
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/trace-based-alerts/
|
||||
aliases:
|
||||
- ../best-practices/trace-based-alerts/ # /docs/grafana/<GRAFANA_VERSION>/alerting/best-practices/trace-based-alerts/
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/examples/trace-based-alerts/
|
||||
description: This guide provides introductory examples and distinct approaches for setting up trace-based alerts in Grafana.
|
||||
keywords:
|
||||
- grafana
|
||||
@@ -8,7 +10,7 @@ labels:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
title: Examples of trace-based alerts
|
||||
title: Trace-based alerts
|
||||
weight: 1103
|
||||
refs:
|
||||
testdata-data-source:
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/tutorials/
|
||||
aliases:
|
||||
- ../best-practices/tutorials/ # /docs/grafana/<GRAFANA_VERSION>/alerting/best-practices/tutorials/
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/examples/tutorials/
|
||||
description: This section provides a set of step-by-step tutorials guides to get started with Grafana Aletings.
|
||||
keywords:
|
||||
- grafana
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/guides/
|
||||
description: This section provides a set of guides for useful alerting practices and recommendations
|
||||
keywords:
|
||||
- grafana
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Guides
|
||||
title: Guides
|
||||
weight: 170
|
||||
refs:
|
||||
examples:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/examples/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/examples/
|
||||
tutorials:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/examples/tutorials/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/examples/tutorials/
|
||||
---
|
||||
|
||||
# Guides
|
||||
|
||||
Guides in the Grafana Alerting documentation provide best practices and practical recommendations to help you move from a basic alerting setup to real-world use cases.
|
||||
|
||||
These guides cover topics such as:
|
||||
|
||||
{{< section >}}
|
||||
|
||||
For more hands-on examples, refer to [Examples](ref:examples) and [Tutorials](ref:tutorials).
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
aliases:
|
||||
- ../best-practices/ # /docs/grafana/<GRAFANA_VERSION>/alerting/best-practices/
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/guides/best-practices/
|
||||
description: Designing and configuring an effective alerting system takes time. This guide focuses on building alerting systems that scale with real-world operations.
|
||||
keywords:
|
||||
- grafana
|
||||
- alerting
|
||||
- guide
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Best practices
|
||||
title: Best practices
|
||||
weight: 1010
|
||||
refs:
|
||||
recovery-threshold:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/queries-conditions/#recovery-threshold
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/fundamentals/alert-rules/queries-conditions/#recovery-threshold
|
||||
keep-firing-for:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rule-evaluation/#keep-firing-for
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/fundamentals/alert-rule-evaluation/#keep-firing-for
|
||||
pending-period:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rule-evaluation/#pending-period
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/fundamentals/alert-rule-evaluation/#pending-period
|
||||
silences:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/configure-notifications/create-silence/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/create-silence/
|
||||
timing-options:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/notifications/group-alert-notifications/#timing-options
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/group-alert-notifications/#timing-options
|
||||
group-alert-notifications:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/notifications/group-alert-notifications/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/group-alert-notifications/
|
||||
notification-policies:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/notifications/notification-policies/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/notification-policies/
|
||||
annotations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/annotation-label/#annotations
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/#annotations
|
||||
multi-dimensional-alerts:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/examples/multi-dimensional-alerts/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/examples/multi-dimensional-alerts/
|
||||
---
|
||||
|
||||
# Alerting best practices
|
||||
|
||||
Designing and configuring an effective alerting system takes time. This guide focuses on building alerting systems that scale with real-world operations.
|
||||
|
||||
The practices described here are intentionally high-level and apply regardless of tooling. Whether you use Prometheus, Grafana Alerting, or another stack, the same constraints apply: complex systems, imperfect signals, and humans on call.
|
||||
|
||||
Alerting is never finished. It evolves with incidents, organizational changes, and the systems it’s meant to protect.
|
||||
|
||||
{{< shared id="alert-planning-fundamentals" >}}
|
||||
|
||||
## Prioritize symptoms, but don’t ignore infrastructure signals
|
||||
|
||||
Alerts should primarily detect user-facing failures, not internal component behavior. Users don't care that a pod restarted; they care when the application is slow or failing. Symptom-based alerts tie directly to user impact.
|
||||
|
||||
Reliability metrics that impact users—latency, errors, availability—are better paging signals than infrastructure events or internal errors.
|
||||
|
||||
That said, infrastructure signals still matter. They can act as early warning indicators and are often useful when alerting maturity is low. A sustained spike in CPU or memory usage might not justify a page, but it can help explain or anticipate symptom-based failures.
|
||||
|
||||
Infrastructure alerts tend to be noisy and are often ignored when treated like paging signals. They are usually better suited for lower-severity channels such as dashboards, alert lists, or non-paging destinations like a dedicated Slack channel, where they can be monitored without interrupting on-call.
|
||||
|
||||
The key is balance as your alerting matures. Use infrastructure alerts to support diagnosis and prevention, not as a replacement for symptom-based alerts.
|
||||
|
||||
## Escalate priority based on confidence
|
||||
|
||||
Alert priority is often tied to user impact and the urgency to respond, but confidence should determine when escalation is necessary.
|
||||
|
||||
In this context, escalation defines how responders are notified as confidence grows. This can include increasing alert priority, widening notification, paging additional responders, or opening an incident once intervention is clearly required.
|
||||
|
||||
Early signals are often ambiguous, and confidence in a non-transient failure is usually low. Paging too early creates noise; paging too late means users are impacted for longer before anyone acts. A small or sudden increase in latency may not justify immediate action, but it can indicate a failure in progress.
|
||||
|
||||
Confidence increases as signals become stronger or begin to correlate.
|
||||
|
||||
Escalation is justified when issues are sustained or reinforced by multiple signals. For example, high latency combined with a rising error rate, or the same event firing over a sustained period. These patterns reduce the chance of transient noise and increase the likelihood of real impact.
|
||||
|
||||
Use confidence in user impact to drive escalation and avoid unnecessary pages.
|
||||
|
||||
## Scope alerts for scalability and actionability
|
||||
|
||||
In distributed systems, avoid creating separate alert rules for every host, service, or endpoint. Instead, define alert rules that scale automatically using [multi-dimensional alert rules](ref:multi-dimensional-alerts). This reduces rule duplication and allows alerting to scale as the system grows.
|
||||
|
||||
Start simple. Default to a single dimension such as `service` or `endpoint` to keep alerts manageable. Add dimensions only when they improve actionability. For example, when missing a dimension like `region` hides failures or doesn't provide enough information to act quickly.
|
||||
|
||||
Additional dimensions like `region` or `instance` can help identify the root cause, but more isn't always better.
|
||||
|
||||
## Design alerts for first responders and clear actions
|
||||
|
||||
Alerts should be designed for the first responder, not the person who created the alert. Anyone on call should be able to understand what's wrong and what to do next without deep knowledge of the system or alert configuration.
|
||||
|
||||
Avoid vague alerts that force responders to spend time figuring out context. Every alert should clearly explain why it exists, what triggered it, and how to investigate. Use [annotations](ref:annotations) to link to relevant dashboards and runbooks, which are essential for faster resolution.
|
||||
|
||||
Alerts should indicate a real problem and be actionable, even if the impact is low. Informational alerts add noise without improving reliability.
|
||||
|
||||
If no action is possible, it shouldn't be an alert—consider using a dashboard instead. Over time, alerts behave like technical debt: easy to create, costly to maintain, and hard to remove.
|
||||
|
||||
Review alerts often and remove those that don’t lead to action.
|
||||
|
||||
## Alerts should have an owner and system scope
|
||||
|
||||
Alerts without ownership are often ignored. Every alert must have an owner: a team responsible for maintaining the alert and responding when it fires.
|
||||
|
||||
Alerts must also define a system scope, such as a service or infrastructure component. Scope provides organizational context and connects alerts with ownership. Defining clear scopes is easier when services are treated as first-class entities, and organizations are built around service ownership.
|
||||
|
||||
> [Service Center in Grafana Cloud](/docs/grafana-cloud/alerting-and-irm/service-center/) can help operate a service-oriented view of your system and align alert scope with ownership.
|
||||
|
||||
After scope, ownership, and alert priority are defined, routing determines where alerts go and how they escalate. **Notification routing is as important as the alerts**.
|
||||
|
||||
Alerts should be delivered to the right team and channel based on priority, ownership, and team workflows. Use [notification policies](ref:notification-policies) to define a routing tree that matches the context of your service or scope:
|
||||
|
||||
- Define a parent policy for default routing within the scope.
|
||||
- Define nested policies for specific cases or higher-priority issues.
|
||||
|
||||
## Prevent notification overload with alert grouping
|
||||
|
||||
Without alert grouping, responders can receive many notifications for the same underlying problem.
|
||||
|
||||
For example, a database failure can trigger several alerts at the same time like increased latency, higher error rates, and internal errors. Paging separately for each symptom quickly turns into notification spam, even though there is a single root cause.
|
||||
|
||||
[Notification grouping](ref:group-alert-notifications) consolidates related alerts into a single notification. Instead of receiving multiple pages for the same issue, responders get one alert that represents the incident and includes all related firing alerts.
|
||||
|
||||
Grouping should follow operational boundaries such as service or owner, as defined by notification policies. Downstream or cascading failures should be grouped together so they surface as one issue rather than many.
|
||||
|
||||
## Mitigate flapping alerts
|
||||
|
||||
Short-lived failure spikes often trigger alerts that auto-resolve quickly. Alerting on transient failures creates noise and leads responders to ignore them.
|
||||
|
||||
Require issues to persist before alerting. Set a [pending period](ref:pending-period) to define how long a condition must remain true before firing. For example, instead of alerting immediately on high error rate, require it to stay above the threshold for some minutes.
|
||||
|
||||
Also, stabilize alerts by tuning query ranges and aggregations. Using raw data makes alerts sensitive to noise. Instead, evaluate over a time window and aggregate the data to smooth short spikes.
|
||||
|
||||
```promql
|
||||
# Reacts to transient spikes. Avoid this.
|
||||
cpu_usage > 90
|
||||
|
||||
# Smooth fluctuations.
|
||||
avg_over_time(cpu_usage[5m]) > 90
|
||||
```
|
||||
|
||||
For latency and error-based alerts, percentiles are often more useful than averages:
|
||||
|
||||
```promql
|
||||
quantile_over_time(0.95, http_duration_seconds[5m]) > 3
|
||||
```
|
||||
|
||||
Finally, avoid rapid resolve-and-fire notifications by using [`keep_firing_for`](ref:keep-firing-for) or [recovery thresholds](ref:recovery-threshold) to keep alerts active briefly during recovery. Both options reduce flapping and unnecessary notifications.
|
||||
|
||||
## Graduate symptom-based alerts into SLOs
|
||||
|
||||
When a symptom-based alert fires frequently, it usually indicates a reliability concern that should be measured and managed more deliberately. This is often a sign that the alert could evolve into an [SLO](/docs/grafana-cloud/alerting-and-irm/slo/).
|
||||
|
||||
Traditional alerts create pressure to react immediately, while error budgets introduce a buffer of time to act, changing how urgency is handled. Alerts can then be defined in terms of error budget burn rate rather than reacting to every minor deviation.
|
||||
|
||||
SLOs also align distinct teams around common reliability goals by providing a shared definition of what "good" looks like. They help consolidate multiple symptom alerts into a single user-facing objective.
|
||||
|
||||
For example, instead of several teams alerting on high latency, a single SLO can be used across teams to capture overall API performance.
|
||||
|
||||
## Integrate alerting into incident post-mortems
|
||||
|
||||
Every incident is an opportunity to improve alerting. After each incident, evaluate whether alerts helped responders act quickly or added unnecessary noise.
|
||||
|
||||
Assess which alerts fired, and how they influenced incident response. Review whether alerts triggered too late, too early, or without enough context, and adjust thresholds, priority, or escalation based on what actually happened.
|
||||
|
||||
Use [silences](ref:silences) during active incidents to reduce repeated notifications, but scope them carefully to avoid silencing unrelated alerts.
|
||||
|
||||
Post-mortems should evaluate alerts with root causes and lessons learned. If responders lacked key information during the incident, enrich alerts with additional context, dashboards, or better guidance.
|
||||
|
||||
## Alerts should be continuously improved
|
||||
|
||||
Alerting is an iterative process. Alerts that aren’t reviewed and refined lose effectiveness as systems and traffic patterns change.
|
||||
|
||||
Schedule regular reviews of existing alerts. Remove alerts that don’t lead to action, and tune alerts or thresholds that fire too often without providing useful signal. Reduce false positives to combat alert fatigue.
|
||||
|
||||
Prioritize clarity and simplicity in alert design. Simpler alerts are easier to understand, maintain, and trust under pressure. Favor fewer high-quality, actionable alerts over a large number of low-value ones.
|
||||
|
||||
Use dashboards and observability tools for investigation, not alerts.
|
||||
|
||||
{{< /shared >}}
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/connectivity-errors/
|
||||
aliases:
|
||||
- ../best-practices/connectivity-errors/ # /docs/grafana/<GRAFANA_VERSION>/alerting/best-practices/connectivity-errors/
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/guides/connectivity-errors/
|
||||
description: Learn how to detect and handle connectivity issues in alerts using Prometheus, Grafana Alerting, or both.
|
||||
keywords:
|
||||
- grafana
|
||||
@@ -14,7 +16,7 @@ labels:
|
||||
- oss
|
||||
menuTitle: Handle connectivity errors
|
||||
title: Handle connectivity errors in alerts
|
||||
weight: 1010
|
||||
weight: 1020
|
||||
refs:
|
||||
pending-period:
|
||||
- pattern: /docs/grafana/
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/missing-data/
|
||||
aliases:
|
||||
- ../best-practices/missing-data/ # /docs/grafana/<GRAFANA_VERSION>/alerting/best-practices/missing-data/
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/guides/missing-data/
|
||||
description: Learn how to detect missing metrics and design alerts that handle gaps in data in Prometheus and Grafana Alerting.
|
||||
keywords:
|
||||
- grafana
|
||||
@@ -14,7 +16,7 @@ labels:
|
||||
- oss
|
||||
menuTitle: Handle missing data
|
||||
title: Handle missing data in Grafana Alerting
|
||||
weight: 1020
|
||||
weight: 1030
|
||||
refs:
|
||||
connectivity-errors-guide:
|
||||
- pattern: /docs/grafana/
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import { flows, type Variable } from './utils';
|
||||
import { flows, saveDashboard, type Variable } from './utils';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
@@ -64,20 +64,7 @@ test.describe(
|
||||
label: 'VariableUnderTest',
|
||||
};
|
||||
|
||||
// common steps to add a new variable
|
||||
await flows.newEditPaneVariableClick(dashboardPage, selectors);
|
||||
await flows.newEditPanelCommonVariableInputs(dashboardPage, selectors, variable);
|
||||
|
||||
// set the textbox variable value
|
||||
const type = 'variable-type Value';
|
||||
const fieldLabel = dashboardPage.getByGrafanaSelector(
|
||||
selectors.components.PanelEditor.OptionsPane.fieldLabel(type)
|
||||
);
|
||||
await expect(fieldLabel).toBeVisible();
|
||||
const inputField = fieldLabel.locator('input');
|
||||
await expect(inputField).toBeVisible();
|
||||
await inputField.fill(variable.value);
|
||||
await inputField.blur();
|
||||
await flows.addNewTextBoxVariable(dashboardPage, variable);
|
||||
|
||||
// select the variable in the dashboard and confirm the variable value is set
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItem).click();
|
||||
@@ -140,5 +127,94 @@ test.describe(
|
||||
await expect(panelContent).toBeVisible();
|
||||
await expect(markdownContent).toContainText('VariableUnderTest: 10m');
|
||||
});
|
||||
test('can hide a variable', async ({ dashboardPage, selectors, page }) => {
|
||||
const variable: Variable = {
|
||||
type: 'textbox',
|
||||
name: 'VariableUnderTest',
|
||||
value: 'foo',
|
||||
label: 'VariableUnderTest',
|
||||
};
|
||||
|
||||
await saveDashboard(dashboardPage, page, selectors, 'can hide a variable');
|
||||
await flows.addNewTextBoxVariable(dashboardPage, variable);
|
||||
|
||||
// check the variable is visible in the dashboard
|
||||
const variableLabel = dashboardPage.getByGrafanaSelector(
|
||||
selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label)
|
||||
);
|
||||
await expect(variableLabel).toBeVisible();
|
||||
// hide the variable
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.General.generalDisplaySelect)
|
||||
.click();
|
||||
await page.getByText('Hidden', { exact: true }).click();
|
||||
|
||||
// check that the variable is still visible
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!))
|
||||
).toBeVisible();
|
||||
|
||||
// save dashboard and exit edit mode and check variable is not visible
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!))
|
||||
).toBeHidden();
|
||||
// refresh and check that variable isn't visible
|
||||
await page.reload();
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!))
|
||||
).toBeHidden();
|
||||
// check that the variable is visible in edit mode
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!))
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('can hide variable under the controls menu', async ({ dashboardPage, selectors, page }) => {
|
||||
const variable: Variable = {
|
||||
type: 'textbox',
|
||||
name: 'VariableUnderTest',
|
||||
value: 'foo',
|
||||
label: 'VariableUnderTest',
|
||||
};
|
||||
await saveDashboard(dashboardPage, page, selectors, 'can hide a variable in controls menu');
|
||||
|
||||
await flows.addNewTextBoxVariable(dashboardPage, variable);
|
||||
|
||||
// check the variable is visible in the dashboard
|
||||
const variableLabel = dashboardPage.getByGrafanaSelector(
|
||||
selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label)
|
||||
);
|
||||
await expect(variableLabel).toBeVisible();
|
||||
// hide the variable
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.General.generalDisplaySelect)
|
||||
.click();
|
||||
await page.getByText('Controls menu', { exact: true }).click();
|
||||
|
||||
// check that the variable is hidden under the controls menu
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!))
|
||||
).toBeHidden();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.ControlsButton).click();
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!))
|
||||
).toBeVisible();
|
||||
|
||||
// save dashboard and refresh
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
await page.reload();
|
||||
|
||||
//check that the variable is hidden under the controls menu
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!))
|
||||
).toBeHidden();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.ControlsButton).click();
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!))
|
||||
).toBeVisible();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -79,6 +79,20 @@ export const flows = {
|
||||
await variableLabelInput.blur();
|
||||
}
|
||||
},
|
||||
async addNewTextBoxVariable(dashboardPage: DashboardPage, variable: Variable) {
|
||||
await flows.newEditPaneVariableClick(dashboardPage, selectors);
|
||||
await flows.newEditPanelCommonVariableInputs(dashboardPage, selectors, variable);
|
||||
// set the textbox variable value
|
||||
const type = 'variable-type Value';
|
||||
const fieldLabel = dashboardPage.getByGrafanaSelector(
|
||||
selectors.components.PanelEditor.OptionsPane.fieldLabel(type)
|
||||
);
|
||||
await expect(fieldLabel).toBeVisible();
|
||||
const inputField = fieldLabel.locator('input');
|
||||
await expect(inputField).toBeVisible();
|
||||
await inputField.fill(variable.value);
|
||||
await inputField.blur();
|
||||
},
|
||||
};
|
||||
|
||||
export type Variable = {
|
||||
@@ -89,8 +103,16 @@ export type Variable = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export async function saveDashboard(dashboardPage: DashboardPage, page: Page, selectors: E2ESelectorGroups) {
|
||||
export async function saveDashboard(
|
||||
dashboardPage: DashboardPage,
|
||||
page: Page,
|
||||
selectors: E2ESelectorGroups,
|
||||
title?: string
|
||||
) {
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.saveButton).click();
|
||||
if (title) {
|
||||
await page.getByTestId(selectors.components.Drawer.DashboardSaveDrawer.saveAsTitleInput).fill(title);
|
||||
}
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Drawer.DashboardSaveDrawer.saveButton).click();
|
||||
await expect(page.getByText('Dashboard saved')).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -218,8 +218,10 @@ lineage: schemas: [{
|
||||
// Optional field, if you want to extract part of a series name or metric node segment.
|
||||
// Named capture groups can be used to separate the display text and value.
|
||||
regex?: string
|
||||
// Determine whether regex applies to variable value or display text
|
||||
regexApplyTo?: #VariableRegexApplyTo
|
||||
// Optional, indicates whether a custom type variable uses CSV or JSON to define its values
|
||||
valuesFormat?: "csv" | "json" | *"csv"
|
||||
// Determine whether regex applies to variable value or display text
|
||||
regexApplyTo?: #VariableRegexApplyTo
|
||||
// Additional static options for query variable
|
||||
staticOptions?: [...#VariableOption]
|
||||
// Ordering of static options in relation to options returned from data source for query variable
|
||||
|
||||
+2
-2
@@ -295,8 +295,8 @@
|
||||
"@grafana/plugin-ui": "^0.11.1",
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/scenes": "6.52.0",
|
||||
"@grafana/scenes-react": "6.52.0",
|
||||
"@grafana/scenes": "v6.52.1",
|
||||
"@grafana/scenes-react": "v6.52.1",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
||||
@@ -699,6 +699,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
playlistsReconciler?: boolean;
|
||||
/**
|
||||
* Enable passwordless login via magic link authentication
|
||||
*/
|
||||
passwordlessMagicLinkAuthentication?: boolean;
|
||||
/**
|
||||
* Display Related Logs in Grafana Metrics Drilldown
|
||||
*/
|
||||
exploreMetricsRelatedLogs?: boolean;
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface IntervalVariableModel extends VariableWithOptions {
|
||||
|
||||
export interface CustomVariableModel extends VariableWithMultiSupport {
|
||||
type: 'custom';
|
||||
valuesFormat?: 'csv' | 'json';
|
||||
}
|
||||
|
||||
export interface DataSourceVariableModel extends VariableWithMultiSupport {
|
||||
|
||||
@@ -266,6 +266,9 @@ export const versionedPages = {
|
||||
Controls: {
|
||||
'11.1.0': 'data-testid dashboard controls',
|
||||
},
|
||||
ControlsButton: {
|
||||
'12.3.0': 'data-testid dashboard controls button',
|
||||
},
|
||||
SubMenu: {
|
||||
submenu: {
|
||||
[MIN_GRAFANA_VERSION]: 'Dashboard submenu',
|
||||
|
||||
@@ -211,6 +211,10 @@ export interface VariableModel {
|
||||
* Type of variable
|
||||
*/
|
||||
type: VariableType;
|
||||
/**
|
||||
* Optional, indicates whether a custom type variable uses CSV or JSON to define its values
|
||||
*/
|
||||
valuesFormat?: ('csv' | 'json');
|
||||
}
|
||||
|
||||
export const defaultVariableModel: Partial<VariableModel> = {
|
||||
@@ -220,6 +224,7 @@ export const defaultVariableModel: Partial<VariableModel> = {
|
||||
options: [],
|
||||
skipUrlSync: false,
|
||||
staticOptions: [],
|
||||
valuesFormat: 'csv',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -317,6 +317,7 @@ export const handyTestingSchema: Spec = {
|
||||
query: 'option1, option2',
|
||||
skipUrlSync: false,
|
||||
allowCustomValue: true,
|
||||
valuesFormat: 'csv',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -300,7 +300,7 @@ export interface FieldConfig {
|
||||
description?: string;
|
||||
// An explicit path to the field in the datasource. When the frame meta includes a path,
|
||||
// This will default to `${frame.meta.path}/${field.name}
|
||||
//
|
||||
//
|
||||
// When defined, this value can be used as an identifier within the datasource scope, and
|
||||
// may be used to update the results
|
||||
path?: string;
|
||||
@@ -1353,6 +1353,7 @@ export interface CustomVariableSpec {
|
||||
skipUrlSync: boolean;
|
||||
description?: string;
|
||||
allowCustomValue: boolean;
|
||||
valuesFormat?: "csv" | "json";
|
||||
}
|
||||
|
||||
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
|
||||
@@ -1365,6 +1366,7 @@ export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
|
||||
hide: "dontHide",
|
||||
skipUrlSync: false,
|
||||
allowCustomValue: true,
|
||||
valuesFormat: undefined,
|
||||
});
|
||||
|
||||
// Group variable kind
|
||||
@@ -1549,4 +1551,3 @@ export const defaultSpec = (): Spec => ({
|
||||
title: "",
|
||||
variables: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -1359,6 +1359,7 @@ export interface CustomVariableSpec {
|
||||
skipUrlSync: boolean;
|
||||
description?: string;
|
||||
allowCustomValue: boolean;
|
||||
valuesFormat?: "csv" | "json";
|
||||
}
|
||||
|
||||
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
|
||||
|
||||
+2
-1
@@ -226,7 +226,8 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
r.Post("/api/user/email/start-verify", reqSignedInNoAnonymous, routing.Wrap(hs.StartEmailVerificaton))
|
||||
}
|
||||
|
||||
if hs.Cfg.PasswordlessMagicLinkAuth.Enabled {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Cfg.PasswordlessMagicLinkAuth.Enabled && hs.Features.IsEnabledGlobally(featuremgmt.FlagPasswordlessMagicLinkAuthentication) {
|
||||
r.Post("/api/login/passwordless/start", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), hs.StartPasswordless)
|
||||
r.Post("/api/login/passwordless/authenticate", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginPasswordless))
|
||||
}
|
||||
|
||||
@@ -410,7 +410,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
DisableSignoutMenu: hs.Cfg.DisableSignoutMenu,
|
||||
}
|
||||
|
||||
if hs.Cfg.PasswordlessMagicLinkAuth.Enabled {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Cfg.PasswordlessMagicLinkAuth.Enabled && hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagPasswordlessMagicLinkAuthentication) {
|
||||
hasEnabledProviders := hs.samlEnabled() || hs.authnService.IsClientEnabled(authn.ClientLDAP)
|
||||
|
||||
if !hasEnabledProviders {
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/caching"
|
||||
@@ -28,6 +27,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
|
||||
|
||||
+10
@@ -837,6 +837,8 @@ type VariableModel struct {
|
||||
// Optional field, if you want to extract part of a series name or metric node segment.
|
||||
// Named capture groups can be used to separate the display text and value.
|
||||
Regex *string `json:"regex,omitempty"`
|
||||
// Optional, indicates whether a custom type variable uses CSV or JSON to define its values
|
||||
ValuesFormat *VariableModelValuesFormat `json:"valuesFormat,omitempty"`
|
||||
// Determine whether regex applies to variable value or display text
|
||||
RegexApplyTo *VariableRegexApplyTo `json:"regexApplyTo,omitempty"`
|
||||
// Additional static options for query variable
|
||||
@@ -852,6 +854,7 @@ func NewVariableModel() *VariableModel {
|
||||
Multi: (func(input bool) *bool { return &input })(false),
|
||||
AllowCustomValue: (func(input bool) *bool { return &input })(true),
|
||||
IncludeAll: (func(input bool) *bool { return &input })(false),
|
||||
ValuesFormat: (func(input VariableModelValuesFormat) *VariableModelValuesFormat { return &input })(VariableModelValuesFormatCsv),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1191,6 +1194,13 @@ const (
|
||||
DataTransformerConfigTopicAlertStates DataTransformerConfigTopic = "alertStates"
|
||||
)
|
||||
|
||||
type VariableModelValuesFormat string
|
||||
|
||||
const (
|
||||
VariableModelValuesFormatCsv VariableModelValuesFormat = "csv"
|
||||
VariableModelValuesFormatJson VariableModelValuesFormat = "json"
|
||||
)
|
||||
|
||||
type VariableModelStaticOptionsOrder string
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/grpcplugin"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2"
|
||||
"github.com/grafana/grafana/pkg/plugins/log"
|
||||
@@ -27,10 +26,6 @@ func New(providers ...PluginBackendProvider) *Service {
|
||||
}
|
||||
}
|
||||
|
||||
func ProvideService(coreRegistry *coreplugin.Registry) *Service {
|
||||
return New(coreRegistry.BackendFactoryProvider(), DefaultProvider)
|
||||
}
|
||||
|
||||
func (s *Service) BackendFactory(ctx context.Context, p *plugins.Plugin) backendplugin.PluginFactoryFunc {
|
||||
for _, provider := range s.providerChain {
|
||||
if factory := provider(ctx, p); factory != nil {
|
||||
|
||||
@@ -122,6 +122,7 @@ type DashboardsAPIBuilder struct {
|
||||
publicDashboardService publicdashboards.Service
|
||||
snapshotService dashboardsnapshots.Service
|
||||
snapshotOptions dashv0.SnapshotSharingOptions
|
||||
snapshotStorage rest.Storage // for dual-write support in routes
|
||||
namespacer request.NamespaceMapper
|
||||
dashboardActivityChannel live.DashboardActivityChannel
|
||||
isStandalone bool // skips any handling including anything to do with legacy storage
|
||||
@@ -747,15 +748,26 @@ func (b *DashboardsAPIBuilder) storageForVersion(
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy only (for now) and only v0alpha1
|
||||
// Snapshots - only v0alpha1
|
||||
if snapshots != nil && dashboards.GroupVersion().Version == "v0alpha1" {
|
||||
snapshotLegacyStore := &snapshot.SnapshotLegacyStore{
|
||||
ResourceInfo: *snapshots,
|
||||
Service: b.snapshotService,
|
||||
Namespacer: b.namespacer,
|
||||
}
|
||||
storage[snapshots.StoragePath()] = snapshotLegacyStore
|
||||
storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(dashboards, b.snapshotService)
|
||||
|
||||
unifiedSnapshotStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, *snapshots, opts.OptsGetter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshotGr := snapshots.GroupResource()
|
||||
snapshotDualWrite, err := opts.DualWriteBuilder(snapshotGr, snapshotLegacyStore, unifiedSnapshotStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storage[snapshots.StoragePath()] = snapshotDualWrite
|
||||
b.snapshotStorage = snapshotDualWrite // store for use in routes
|
||||
storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(snapshotDualWrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -979,7 +991,9 @@ func (b *DashboardsAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.API
|
||||
|
||||
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
|
||||
searchAPIRoutes := b.search.GetAPIRoutes(defs)
|
||||
snapshotAPIRoutes := snapshot.GetRoutes(b.snapshotService, b.snapshotOptions, defs)
|
||||
snapshotAPIRoutes := snapshot.GetRoutes(b.snapshotService, b.snapshotOptions, defs, func() rest.Storage {
|
||||
return b.snapshotStorage
|
||||
})
|
||||
|
||||
return &builder.APIRoutes{
|
||||
Namespace: append(searchAPIRoutes.Namespace, snapshotAPIRoutes.Namespace...),
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
dashV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
@@ -59,7 +60,10 @@ func convertSnapshotToK8sResource(v *dashboardsnapshots.DashboardSnapshot, names
|
||||
Namespace: namespacer(v.OrgID),
|
||||
},
|
||||
Spec: dashV0.SnapshotSpec{
|
||||
Title: &v.Name,
|
||||
Title: &v.Name,
|
||||
Expires: &expires,
|
||||
External: &v.External,
|
||||
ExternalUrl: &v.ExternalURL,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -78,3 +82,68 @@ func convertSnapshotToK8sResource(v *dashboardsnapshots.DashboardSnapshot, names
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
// convertK8sResourceToCreateCommand converts a K8s Snapshot to a CreateDashboardSnapshotCommand
|
||||
func convertK8sResourceToCreateCommand(snap *dashV0.Snapshot, orgID int64, userID int64) *dashboardsnapshots.CreateDashboardSnapshotCommand {
|
||||
cmd := &dashboardsnapshots.CreateDashboardSnapshotCommand{
|
||||
OrgID: orgID,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
// Map title
|
||||
if snap.Spec.Title != nil {
|
||||
cmd.Name = *snap.Spec.Title
|
||||
}
|
||||
|
||||
// Map dashboard (convert map[string]interface{} to *common.Unstructured)
|
||||
if snap.Spec.Dashboard != nil {
|
||||
cmd.Dashboard = &common.Unstructured{Object: snap.Spec.Dashboard}
|
||||
}
|
||||
|
||||
// Map expires
|
||||
if snap.Spec.Expires != nil {
|
||||
cmd.Expires = *snap.Spec.Expires
|
||||
}
|
||||
|
||||
// Map external settings
|
||||
if snap.Spec.External != nil && *snap.Spec.External {
|
||||
cmd.External = true
|
||||
if snap.Spec.ExternalUrl != nil {
|
||||
cmd.ExternalURL = *snap.Spec.ExternalUrl
|
||||
}
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// convertCreateCmdToK8sSnapshot converts a CreateDashboardSnapshotCommand request to a K8s Snapshot
|
||||
// Used by routes.go to create a Snapshot object from the incoming create command
|
||||
func convertCreateCmdToK8sSnapshot(cmd *dashboardsnapshots.CreateDashboardSnapshotCommand, namespace string) *dashV0.Snapshot {
|
||||
snap := &dashV0.Snapshot{
|
||||
TypeMeta: dashV0.SnapshotResourceInfo.TypeMeta(),
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: namespace,
|
||||
},
|
||||
Spec: dashV0.SnapshotSpec{
|
||||
Title: &cmd.Name,
|
||||
},
|
||||
}
|
||||
|
||||
// Convert *common.Unstructured to map[string]interface{}
|
||||
if cmd.Dashboard != nil {
|
||||
snap.Spec.Dashboard = cmd.Dashboard.Object
|
||||
}
|
||||
|
||||
if cmd.Expires > 0 {
|
||||
snap.Spec.Expires = &cmd.Expires
|
||||
}
|
||||
|
||||
if cmd.External {
|
||||
snap.Spec.External = &cmd.External
|
||||
if cmd.ExternalURL != "" {
|
||||
snap.Spec.ExternalUrl = &cmd.ExternalURL
|
||||
}
|
||||
}
|
||||
|
||||
return snap
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
k8srequest "k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
@@ -14,6 +17,7 @@ import (
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
@@ -22,7 +26,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharingOptions, defs map[string]common.OpenAPIDefinition) *builder.APIRoutes {
|
||||
func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharingOptions, defs map[string]common.OpenAPIDefinition, storageGetter func() rest.Storage) *builder.APIRoutes {
|
||||
prefix := dashv0.SnapshotResourceInfo.GroupResource().Resource
|
||||
tags := []string{dashv0.SnapshotResourceInfo.GroupVersionKind().Kind}
|
||||
|
||||
@@ -97,9 +101,10 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin
|
||||
},
|
||||
},
|
||||
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := identity.GetRequester(r.Context())
|
||||
ctx := r.Context()
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
errhttp.Write(r.Context(), err, w)
|
||||
errhttp.Write(ctx, err, w)
|
||||
return
|
||||
}
|
||||
wrap := &contextmodel.ReqContext{
|
||||
@@ -107,11 +112,15 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin
|
||||
Req: r,
|
||||
Resp: web.NewResponseWriter(r.Method, w),
|
||||
},
|
||||
// SignedInUser: user, ????????????
|
||||
}
|
||||
|
||||
if !options.SnapshotsEnabled {
|
||||
wrap.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
|
||||
return
|
||||
}
|
||||
vars := mux.Vars(r)
|
||||
info, err := authlib.ParseNamespace(vars["namespace"])
|
||||
namespace := vars["namespace"]
|
||||
info, err := authlib.ParseNamespace(namespace)
|
||||
if err != nil {
|
||||
wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil)
|
||||
return
|
||||
@@ -128,8 +137,82 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin
|
||||
return
|
||||
}
|
||||
|
||||
// Use the existing snapshot service
|
||||
dashboardsnapshots.CreateDashboardSnapshot(wrap, options, cmd, service)
|
||||
if cmd.External && !options.ExternalEnabled {
|
||||
wrap.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// fill cmd data
|
||||
if cmd.Name == "" {
|
||||
cmd.Name = "Unnamed snapshot"
|
||||
}
|
||||
cmd.OrgID = user.GetOrgID()
|
||||
cmd.UserID, _ = identity.UserIdentifier(user.GetID())
|
||||
|
||||
//originalDashboardURL, err := dashboardsnapshots.CreateOriginalDashboardURL(&cmd)
|
||||
|
||||
// TODO: add logic for external and internal snapshots
|
||||
if cmd.External {
|
||||
// TODO: if it is an external dashboard make a POST to the public snapshot server
|
||||
} else {
|
||||
|
||||
}
|
||||
|
||||
// TODO: validate dashboard exists. Need to call dashboards api, Maybe in a validation hook?
|
||||
|
||||
storage := storageGetter()
|
||||
if storage == nil {
|
||||
errhttp.Write(ctx, fmt.Errorf("snapshot storage not available"), w)
|
||||
return
|
||||
}
|
||||
creater, ok := storage.(rest.Creater)
|
||||
if !ok {
|
||||
errhttp.Write(ctx, fmt.Errorf("snapshot storage does not support create"), w)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert command to K8s Snapshot
|
||||
snapshot := convertCreateCmdToK8sSnapshot(&cmd, namespace)
|
||||
|
||||
snapshot.SetGenerateName("snapshot-")
|
||||
|
||||
// Set namespace in context for k8s storage layer
|
||||
ctx = k8srequest.WithNamespace(ctx, namespace)
|
||||
|
||||
// Create via storage (dual-write mode decides legacy, unified, or both)
|
||||
result, err := creater.Create(ctx, snapshot, nil, &metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract key and deleteKey from result
|
||||
accessor, err := utils.MetaAccessor(result)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, fmt.Errorf("failed to access result metadata: %w", err), w)
|
||||
return
|
||||
}
|
||||
|
||||
deleteKey, err := util.GetRandomString(32)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, fmt.Errorf("failed to generate delete key: %w", err), w)
|
||||
}
|
||||
|
||||
key := accessor.GetName()
|
||||
//deleteKey := ""
|
||||
//if annotations := accessor.GetAnnotations(); annotations != nil {
|
||||
// deleteKey = annotations["grafana.app/delete-key"]
|
||||
//}
|
||||
|
||||
// Build response
|
||||
response := dashv0.DashboardCreateResponse{
|
||||
Key: key,
|
||||
DeleteKey: deleteKey,
|
||||
URL: setting.ToAbsUrl("dashboard/snapshot/" + key),
|
||||
DeleteURL: setting.ToAbsUrl("api/snapshots-delete/" + deleteKey),
|
||||
}
|
||||
|
||||
wrap.JSON(http.StatusOK, response)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -20,7 +21,10 @@ var (
|
||||
_ rest.SingularNameProvider = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.Getter = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.Lister = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.Creater = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.Updater = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.GracefulDeleter = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.CollectionDeleter = (*SnapshotLegacyStore)(nil)
|
||||
_ rest.Storage = (*SnapshotLegacyStore)(nil)
|
||||
)
|
||||
|
||||
@@ -129,3 +133,51 @@ func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *met
|
||||
}
|
||||
return nil, s.ResourceInfo.NewNotFound(name)
|
||||
}
|
||||
|
||||
// Create implements rest.Creater
|
||||
func (s *SnapshotLegacyStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
snap, ok := obj.(*dashV0.Snapshot)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected Snapshot object, got %T", obj)
|
||||
}
|
||||
|
||||
// Run validation if provided
|
||||
if createValidation != nil {
|
||||
if err := createValidation(ctx, obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Get user identity from context
|
||||
requester, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get requester: %w", err)
|
||||
}
|
||||
|
||||
userID, err := requester.GetInternalID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user ID: %w", err)
|
||||
}
|
||||
|
||||
// Convert K8s resource to service command
|
||||
cmd := convertK8sResourceToCreateCommand(snap, requester.GetOrgID(), userID)
|
||||
|
||||
// Create the snapshot via service
|
||||
result, err := s.Service.CreateDashboardSnapshot(ctx, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert result back to K8s resource
|
||||
return convertSnapshotToK8sResource(result, s.Namespacer), nil
|
||||
}
|
||||
|
||||
// Update implements rest.Updater - snapshots are immutable, so this returns an error
|
||||
func (s *SnapshotLegacyStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
|
||||
return nil, false, fmt.Errorf("snapshots are immutable and cannot be updated")
|
||||
}
|
||||
|
||||
// DeleteCollection implements rest.CollectionDeleter
|
||||
func (s *SnapshotLegacyStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
||||
return nil, fmt.Errorf("delete collection is not supported for snapshots")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -10,22 +11,19 @@ import (
|
||||
|
||||
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
)
|
||||
|
||||
// Currently only works with v0alpha1
|
||||
type dashboardREST struct {
|
||||
Service dashboardsnapshots.Service
|
||||
getter rest.Getter
|
||||
}
|
||||
|
||||
func NewDashboardREST(
|
||||
resourceInfo utils.ResourceInfo,
|
||||
service dashboardsnapshots.Service,
|
||||
getter rest.Getter,
|
||||
) (rest.Storage, error) {
|
||||
return &dashboardREST{
|
||||
Service: service,
|
||||
getter: getter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -58,22 +56,30 @@ func (r *dashboardREST) ProducesObject(verb string) interface{} {
|
||||
}
|
||||
|
||||
func (r *dashboardREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||
_, err := request.NamespaceInfoFrom(ctx, true)
|
||||
ns, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap, err := r.Service.GetDashboardSnapshot(ctx, &dashboardsnapshots.GetDashboardSnapshotQuery{Key: name})
|
||||
|
||||
// Get the snapshot from unified storage
|
||||
obj, err := r.getter.Get(ctx, name, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snap, ok := obj.(*dashv0.Snapshot)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected Snapshot, got %T", obj)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
// TODO... support conversions (not required in v0)
|
||||
dash := &dashv0.Dashboard{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: name,
|
||||
Namespace: ns.Value,
|
||||
},
|
||||
Spec: v0alpha1.Unstructured{
|
||||
Object: snap.Dashboard.MustMap(),
|
||||
Object: snap.Spec.Dashboard,
|
||||
},
|
||||
}
|
||||
responder.Object(200, dash)
|
||||
|
||||
@@ -276,7 +276,7 @@ func (b *APIBuilder) oneFlagHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if b.providerType == setting.GOFFProviderType || b.providerType == setting.OFREPProviderType {
|
||||
if b.providerType == setting.FeaturesServiceProviderType || b.providerType == setting.OFREPProviderType {
|
||||
b.proxyFlagReq(ctx, flagKey, isAuthedReq, w, r)
|
||||
return
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func (b *APIBuilder) allFlagsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
isAuthedReq := b.isAuthenticatedRequest(r)
|
||||
span.SetAttributes(attribute.Bool("authenticated", isAuthedReq))
|
||||
|
||||
if b.providerType == setting.GOFFProviderType || b.providerType == setting.OFREPProviderType {
|
||||
if b.providerType == setting.FeaturesServiceProviderType || b.providerType == setting.OFREPProviderType {
|
||||
b.proxyAllFlagReq(ctx, isAuthedReq, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
Generated
+5
-6
@@ -37,8 +37,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/login/social/socialimpl"
|
||||
"github.com/grafana/grafana/pkg/middleware/csrf"
|
||||
"github.com/grafana/grafana/pkg/middleware/loggermw"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin"
|
||||
provider2 "github.com/grafana/grafana/pkg/plugins/backendplugin/provider"
|
||||
manager4 "github.com/grafana/grafana/pkg/plugins/manager"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/filestore"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/process"
|
||||
@@ -178,6 +176,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/angulardetectorsprovider"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/angularinspector"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/angularpatternsstore"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/installsync"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever"
|
||||
@@ -557,7 +556,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
zipkinService := zipkin.ProvideService(httpclientProvider)
|
||||
jaegerService := jaeger.ProvideService(httpclientProvider)
|
||||
corepluginRegistry := coreplugin.ProvideCoreRegistry(tracer, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService)
|
||||
providerService := provider2.ProvideService(corepluginRegistry)
|
||||
backendFactoryProvider := coreplugin.ProvideCoreProvider(corepluginRegistry)
|
||||
processService := process.ProvideService()
|
||||
retrieverService := retriever.ProvideService(sqlStore, apikeyService, kvStore, userService, orgService)
|
||||
serviceAccountPermissionsService, err := ossaccesscontrol.ProvideServiceAccountPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, retrieverService, acimplService, teamService, userService, actionSetService)
|
||||
@@ -573,7 +572,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
service13 := service6.ProvideService(sqlStore, secretsService)
|
||||
serviceregistrationService := serviceregistration.ProvideService(cfg, featureToggles, registryRegistry, service13)
|
||||
noop := provisionedplugins.NewNoop()
|
||||
initialize := pipeline.ProvideInitializationStage(pluginManagementCfg, inMemory, providerService, processService, serviceregistrationService, acimplService, actionSetService, envVarsProvider, tracingService, noop)
|
||||
initialize := pipeline.ProvideInitializationStage(pluginManagementCfg, inMemory, backendFactoryProvider, processService, serviceregistrationService, acimplService, actionSetService, envVarsProvider, tracingService, noop)
|
||||
terminate, err := pipeline.ProvideTerminationStage(pluginManagementCfg, inMemory, processService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1217,7 +1216,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
zipkinService := zipkin.ProvideService(httpclientProvider)
|
||||
jaegerService := jaeger.ProvideService(httpclientProvider)
|
||||
corepluginRegistry := coreplugin.ProvideCoreRegistry(tracer, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService)
|
||||
providerService := provider2.ProvideService(corepluginRegistry)
|
||||
backendFactoryProvider := coreplugin.ProvideCoreProvider(corepluginRegistry)
|
||||
processService := process.ProvideService()
|
||||
retrieverService := retriever.ProvideService(sqlStore, apikeyService, kvStore, userService, orgService)
|
||||
serviceAccountPermissionsService, err := ossaccesscontrol.ProvideServiceAccountPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, retrieverService, acimplService, teamService, userService, actionSetService)
|
||||
@@ -1233,7 +1232,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
service13 := service6.ProvideService(sqlStore, secretsService)
|
||||
serviceregistrationService := serviceregistration.ProvideService(cfg, featureToggles, registryRegistry, service13)
|
||||
noop := provisionedplugins.NewNoop()
|
||||
initialize := pipeline.ProvideInitializationStage(pluginManagementCfg, inMemory, providerService, processService, serviceregistrationService, acimplService, actionSetService, envVarsProvider, tracingService, noop)
|
||||
initialize := pipeline.ProvideInitializationStage(pluginManagementCfg, inMemory, backendFactoryProvider, processService, serviceregistrationService, acimplService, actionSetService, envVarsProvider, tracingService, noop)
|
||||
terminate, err := pipeline.ProvideTerminationStage(pluginManagementCfg, inMemory, processService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -3,11 +3,13 @@ package dualwrite
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
|
||||
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
)
|
||||
@@ -19,14 +21,30 @@ type legacyTupleCollector func(ctx context.Context, orgID int64) (map[string]map
|
||||
type zanzanaTupleCollector func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error)
|
||||
|
||||
type resourceReconciler struct {
|
||||
name string
|
||||
legacy legacyTupleCollector
|
||||
zanzana zanzanaTupleCollector
|
||||
client zanzana.Client
|
||||
name string
|
||||
legacy legacyTupleCollector
|
||||
zanzana zanzanaTupleCollector
|
||||
client zanzana.Client
|
||||
orphanObjectPrefix string
|
||||
orphanRelations []string
|
||||
}
|
||||
|
||||
func newResourceReconciler(name string, legacy legacyTupleCollector, zanzana zanzanaTupleCollector, client zanzana.Client) resourceReconciler {
|
||||
return resourceReconciler{name, legacy, zanzana, client}
|
||||
func newResourceReconciler(name string, legacy legacyTupleCollector, zanzanaCollector zanzanaTupleCollector, client zanzana.Client) resourceReconciler {
|
||||
r := resourceReconciler{name: name, legacy: legacy, zanzana: zanzanaCollector, client: client}
|
||||
|
||||
// we only need to worry about orphaned tuples for reconcilers that use the managed permissions collector (i.e. dashboards & folders)
|
||||
switch name {
|
||||
case "managed folder permissions":
|
||||
// prefix for folders is `folder:`
|
||||
r.orphanObjectPrefix = zanzana.NewObjectEntry(zanzana.TypeFolder, "", "", "", "")
|
||||
r.orphanRelations = append([]string{}, zanzana.RelationsFolder...)
|
||||
case "managed dashboard permissions":
|
||||
// prefix for dashboards will be `resource:dashboard.grafana.app/dashboards/`
|
||||
r.orphanObjectPrefix = fmt.Sprintf("%s/", zanzana.NewObjectEntry(zanzana.TypeResource, dashboardV1.APIGroup, dashboardV1.DASHBOARD_RESOURCE, "", ""))
|
||||
r.orphanRelations = append([]string{}, zanzana.RelationsResouce...)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (r resourceReconciler) reconcile(ctx context.Context, namespace string) error {
|
||||
@@ -35,6 +53,15 @@ func (r resourceReconciler) reconcile(ctx context.Context, namespace string) err
|
||||
return err
|
||||
}
|
||||
|
||||
// 0. Fetch all tuples currently stored in Zanzana. This will be used later on
|
||||
// to cleanup orphaned tuples.
|
||||
// This order needs to be kept (fetching from Zanzana first) to avoid accidentally
|
||||
// cleaning up new tuples that were added after the legacy tuples were fetched.
|
||||
allTuplesInZanzana, err := r.readAllTuples(ctx, namespace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read all tuples from zanzana for %s: %w", r.name, err)
|
||||
}
|
||||
|
||||
// 1. Fetch grafana resources stored in grafana db.
|
||||
res, err := r.legacy(ctx, info.OrgID)
|
||||
if err != nil {
|
||||
@@ -87,6 +114,14 @@ func (r resourceReconciler) reconcile(ctx context.Context, namespace string) err
|
||||
}
|
||||
}
|
||||
|
||||
// when the last managed permission for a resource is removed, the legacy results will no
|
||||
// longer contain any tuples for that resource. this process cleans it up when applicable.
|
||||
orphans, err := r.collectOrphanDeletes(ctx, namespace, allTuplesInZanzana, res)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to collect orphan deletes (%s): %w", r.name, err)
|
||||
}
|
||||
deletes = append(deletes, orphans...)
|
||||
|
||||
if len(writes) == 0 && len(deletes) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -119,3 +154,79 @@ func (r resourceReconciler) reconcile(ctx context.Context, namespace string) err
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectOrphanDeletes collects tuples that are no longer present in the legacy results
|
||||
// but still are present in zanzana. when that is the case, we need to delete the tuple from
|
||||
// zanzana. this will happen when the last managed permission for a resource is removed.
|
||||
// this is only used for dashboards and folders, as those are the only resources that use the managed permissions collector.
|
||||
func (r resourceReconciler) collectOrphanDeletes(
|
||||
ctx context.Context,
|
||||
namespace string,
|
||||
allTuplesInZanzana []*authzextv1.Tuple,
|
||||
legacyReturnedTuples map[string]map[string]*openfgav1.TupleKey,
|
||||
) ([]*openfgav1.TupleKeyWithoutCondition, error) {
|
||||
if r.orphanObjectPrefix == "" || len(r.orphanRelations) == 0 {
|
||||
return []*openfgav1.TupleKeyWithoutCondition{}, nil
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
out := []*openfgav1.TupleKeyWithoutCondition{}
|
||||
|
||||
// what relation types we are interested in cleaning up
|
||||
relationsToCleanup := map[string]struct{}{}
|
||||
for _, rel := range r.orphanRelations {
|
||||
relationsToCleanup[rel] = struct{}{}
|
||||
}
|
||||
|
||||
for _, tuple := range allTuplesInZanzana {
|
||||
if tuple == nil || tuple.Key == nil {
|
||||
continue
|
||||
}
|
||||
// only cleanup the particular relation types we are interested in
|
||||
if _, ok := relationsToCleanup[tuple.Key.Relation]; !ok {
|
||||
continue
|
||||
}
|
||||
// only cleanup the particular object types we are interested in (either dashboards or folders)
|
||||
if !strings.HasPrefix(tuple.Key.Object, r.orphanObjectPrefix) {
|
||||
continue
|
||||
}
|
||||
// if legacy returned this object, it's not orphaned
|
||||
if _, ok := legacyReturnedTuples[tuple.Key.Object]; ok {
|
||||
continue
|
||||
}
|
||||
// keep track of the tuples we have already seen and marked for deletion
|
||||
key := fmt.Sprintf("%s|%s|%s", tuple.Key.User, tuple.Key.Relation, tuple.Key.Object)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, &openfgav1.TupleKeyWithoutCondition{
|
||||
User: tuple.Key.User,
|
||||
Relation: tuple.Key.Relation,
|
||||
Object: tuple.Key.Object,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r resourceReconciler) readAllTuples(ctx context.Context, namespace string) ([]*authzextv1.Tuple, error) {
|
||||
var (
|
||||
out []*authzextv1.Tuple
|
||||
continueToken string
|
||||
)
|
||||
for {
|
||||
res, err := r.client.Read(ctx, &authzextv1.ReadRequest{
|
||||
Namespace: namespace,
|
||||
ContinuationToken: continueToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, res.Tuples...)
|
||||
continueToken = res.ContinuationToken
|
||||
if continueToken == "" {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package dualwrite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
)
|
||||
|
||||
type fakeZanzanaClient struct {
|
||||
readTuples []*authzextv1.Tuple
|
||||
writeReqs []*authzextv1.WriteRequest
|
||||
}
|
||||
|
||||
func (f *fakeZanzanaClient) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authzextv1.ReadResponse, error) {
|
||||
return &authzextv1.ReadResponse{
|
||||
Tuples: f.readTuples,
|
||||
ContinuationToken: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeZanzanaClient) Write(ctx context.Context, req *authzextv1.WriteRequest) error {
|
||||
f.writeReqs = append(f.writeReqs, req)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeZanzanaClient) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) {
|
||||
return &authzextv1.BatchCheckResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeZanzanaClient) Mutate(ctx context.Context, req *authzextv1.MutateRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeZanzanaClient) Query(ctx context.Context, req *authzextv1.QueryRequest) (*authzextv1.QueryResponse, error) {
|
||||
return &authzextv1.QueryResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeZanzanaClient) Check(ctx context.Context, info authlib.AuthInfo, req authlib.CheckRequest, folder string) (authlib.CheckResponse, error) {
|
||||
return authlib.CheckResponse{Allowed: true}, nil
|
||||
}
|
||||
|
||||
func (f *fakeZanzanaClient) Compile(ctx context.Context, info authlib.AuthInfo, req authlib.ListRequest) (authlib.ItemChecker, authlib.Zookie, error) {
|
||||
return func(name, folder string) bool { return true }, authlib.NoopZookie{}, nil
|
||||
}
|
||||
|
||||
func TestResourceReconciler_OrphanedManagedDashboardTuplesAreDeleted(t *testing.T) {
|
||||
legacy := func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
||||
return map[string]map[string]*openfgav1.TupleKey{}, nil
|
||||
}
|
||||
zCollector := func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) {
|
||||
return map[string]*openfgav1.TupleKey{}, nil
|
||||
}
|
||||
|
||||
fake := &fakeZanzanaClient{}
|
||||
r := newResourceReconciler("managed dashboard permissions", legacy, zCollector, fake)
|
||||
|
||||
require.NotEmpty(t, r.orphanObjectPrefix)
|
||||
require.NotEmpty(t, r.orphanRelations)
|
||||
|
||||
relAllowed := r.orphanRelations[0]
|
||||
objAllowed := r.orphanObjectPrefix + "dash-uid-1"
|
||||
|
||||
fake.readTuples = []*authzextv1.Tuple{
|
||||
// should be removed
|
||||
{
|
||||
Key: &authzextv1.TupleKey{
|
||||
User: "user:1",
|
||||
Relation: relAllowed,
|
||||
Object: objAllowed,
|
||||
},
|
||||
},
|
||||
|
||||
// same relation but different object type/prefix - should stay
|
||||
{
|
||||
Key: &authzextv1.TupleKey{
|
||||
User: "user:1",
|
||||
Relation: relAllowed,
|
||||
Object: "folder:some-folder",
|
||||
},
|
||||
},
|
||||
// same prefix but different relation - should stay
|
||||
{
|
||||
Key: &authzextv1.TupleKey{
|
||||
User: "user:1",
|
||||
Relation: zanzana.RelationParent,
|
||||
Object: objAllowed,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := r.reconcile(context.Background(), authlib.OrgNamespaceFormatter(1))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, fake.writeReqs, 1)
|
||||
wr := fake.writeReqs[0]
|
||||
require.NotNil(t, wr.Deletes)
|
||||
require.Nil(t, wr.Writes)
|
||||
|
||||
require.Len(t, wr.Deletes.TupleKeys, 1)
|
||||
del := wr.Deletes.TupleKeys[0]
|
||||
require.Equal(t, "user:1", del.User)
|
||||
require.Equal(t, relAllowed, del.Relation)
|
||||
require.Equal(t, objAllowed, del.Object)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package authnimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/remotecache"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
@@ -81,7 +83,8 @@ func ProvideRegistration(
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.PasswordlessMagicLinkAuth.Enabled {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if cfg.PasswordlessMagicLinkAuth.Enabled && features.IsEnabled(context.Background(), featuremgmt.FlagPasswordlessMagicLinkAuthentication) {
|
||||
hasEnabledProviders := authnSvc.IsClientEnabled(authn.ClientSAML) || authnSvc.IsClientEnabled(authn.ClientLDAP)
|
||||
if !hasEnabledProviders {
|
||||
oauthInfos := socialService.GetOAuthInfoProviders()
|
||||
|
||||
@@ -32,6 +32,8 @@ func NewOpenFGAServer(cfg setting.ZanzanaServerSettings, store storage.OpenFGADa
|
||||
opts := []server.OpenFGAServiceV1Option{
|
||||
server.WithDatastore(store),
|
||||
server.WithLogger(zlogger.New(logger)),
|
||||
|
||||
// Cache settings
|
||||
server.WithCheckCacheLimit(cfg.CacheSettings.CheckCacheLimit),
|
||||
server.WithCacheControllerEnabled(cfg.CacheSettings.CacheControllerEnabled),
|
||||
server.WithCacheControllerTTL(cfg.CacheSettings.CacheControllerTTL),
|
||||
@@ -40,16 +42,25 @@ func NewOpenFGAServer(cfg setting.ZanzanaServerSettings, store storage.OpenFGADa
|
||||
server.WithCheckIteratorCacheEnabled(cfg.CacheSettings.CheckIteratorCacheEnabled),
|
||||
server.WithCheckIteratorCacheMaxResults(cfg.CacheSettings.CheckIteratorCacheMaxResults),
|
||||
server.WithCheckIteratorCacheTTL(cfg.CacheSettings.CheckIteratorCacheTTL),
|
||||
|
||||
// ListObjects settings
|
||||
server.WithListObjectsMaxResults(cfg.ListObjectsMaxResults),
|
||||
server.WithListObjectsIteratorCacheEnabled(cfg.CacheSettings.ListObjectsIteratorCacheEnabled),
|
||||
server.WithListObjectsIteratorCacheMaxResults(cfg.CacheSettings.ListObjectsIteratorCacheMaxResults),
|
||||
server.WithListObjectsIteratorCacheTTL(cfg.CacheSettings.ListObjectsIteratorCacheTTL),
|
||||
server.WithListObjectsDeadline(cfg.ListObjectsDeadline),
|
||||
|
||||
// Shared iterator settings
|
||||
server.WithSharedIteratorEnabled(cfg.CacheSettings.SharedIteratorEnabled),
|
||||
server.WithSharedIteratorLimit(cfg.CacheSettings.SharedIteratorLimit),
|
||||
server.WithSharedIteratorTTL(cfg.CacheSettings.SharedIteratorTTL),
|
||||
server.WithListObjectsDeadline(cfg.ListObjectsDeadline),
|
||||
|
||||
server.WithContextPropagationToDatastore(true),
|
||||
}
|
||||
|
||||
openfgaOpts := withOpenFGAOptions(cfg)
|
||||
opts = append(opts, openfgaOpts...)
|
||||
|
||||
srv, err := server.NewServerWithOpts(opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -58,6 +69,129 @@ func NewOpenFGAServer(cfg setting.ZanzanaServerSettings, store storage.OpenFGADa
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
func withOpenFGAOptions(cfg setting.ZanzanaServerSettings) []server.OpenFGAServiceV1Option {
|
||||
opts := make([]server.OpenFGAServiceV1Option, 0)
|
||||
|
||||
listOpts := withListOptions(cfg)
|
||||
opts = append(opts, listOpts...)
|
||||
|
||||
// Check settings
|
||||
if cfg.OpenFgaServerSettings.MaxConcurrentReadsForCheck != 0 {
|
||||
opts = append(opts, server.WithMaxConcurrentReadsForCheck(cfg.OpenFgaServerSettings.MaxConcurrentReadsForCheck))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.CheckDatabaseThrottleThreshold != 0 || cfg.OpenFgaServerSettings.CheckDatabaseThrottleDuration != 0 {
|
||||
opts = append(opts, server.WithCheckDatabaseThrottle(cfg.OpenFgaServerSettings.CheckDatabaseThrottleThreshold, cfg.OpenFgaServerSettings.CheckDatabaseThrottleDuration))
|
||||
}
|
||||
|
||||
// Batch check settings
|
||||
if cfg.OpenFgaServerSettings.MaxConcurrentChecksPerBatchCheck != 0 {
|
||||
opts = append(opts, server.WithMaxConcurrentChecksPerBatchCheck(cfg.OpenFgaServerSettings.MaxConcurrentChecksPerBatchCheck))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.MaxChecksPerBatchCheck != 0 {
|
||||
opts = append(opts, server.WithMaxChecksPerBatchCheck(cfg.OpenFgaServerSettings.MaxChecksPerBatchCheck))
|
||||
}
|
||||
|
||||
// Resolve node settings
|
||||
if cfg.OpenFgaServerSettings.ResolveNodeLimit != 0 {
|
||||
opts = append(opts, server.WithResolveNodeLimit(cfg.OpenFgaServerSettings.ResolveNodeLimit))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ResolveNodeBreadthLimit != 0 {
|
||||
opts = append(opts, server.WithResolveNodeBreadthLimit(cfg.OpenFgaServerSettings.ResolveNodeBreadthLimit))
|
||||
}
|
||||
|
||||
// Dispatch throttling settings
|
||||
if cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverEnabled {
|
||||
opts = append(opts, server.WithDispatchThrottlingCheckResolverEnabled(cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverEnabled))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverFrequency != 0 {
|
||||
opts = append(opts, server.WithDispatchThrottlingCheckResolverFrequency(cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverFrequency))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverThreshold != 0 {
|
||||
opts = append(opts, server.WithDispatchThrottlingCheckResolverThreshold(cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverThreshold))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverMaxThreshold != 0 {
|
||||
opts = append(opts, server.WithDispatchThrottlingCheckResolverMaxThreshold(cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverMaxThreshold))
|
||||
}
|
||||
|
||||
// Shadow check/query settings
|
||||
if cfg.OpenFgaServerSettings.ShadowCheckResolverTimeout != 0 {
|
||||
opts = append(opts, server.WithShadowCheckResolverTimeout(cfg.OpenFgaServerSettings.ShadowCheckResolverTimeout))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ShadowListObjectsQueryTimeout != 0 {
|
||||
opts = append(opts, server.WithShadowListObjectsQueryTimeout(cfg.OpenFgaServerSettings.ShadowListObjectsQueryTimeout))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ShadowListObjectsQueryMaxDeltaItems != 0 {
|
||||
opts = append(opts, server.WithShadowListObjectsQueryMaxDeltaItems(cfg.OpenFgaServerSettings.ShadowListObjectsQueryMaxDeltaItems))
|
||||
}
|
||||
|
||||
if cfg.OpenFgaServerSettings.RequestTimeout != 0 {
|
||||
opts = append(opts, server.WithRequestTimeout(cfg.OpenFgaServerSettings.RequestTimeout))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.MaxAuthorizationModelSizeInBytes != 0 {
|
||||
opts = append(opts, server.WithMaxAuthorizationModelSizeInBytes(cfg.OpenFgaServerSettings.MaxAuthorizationModelSizeInBytes))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.AuthorizationModelCacheSize != 0 {
|
||||
opts = append(opts, server.WithAuthorizationModelCacheSize(cfg.OpenFgaServerSettings.AuthorizationModelCacheSize))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ChangelogHorizonOffset != 0 {
|
||||
opts = append(opts, server.WithChangelogHorizonOffset(cfg.OpenFgaServerSettings.ChangelogHorizonOffset))
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func withListOptions(cfg setting.ZanzanaServerSettings) []server.OpenFGAServiceV1Option {
|
||||
opts := make([]server.OpenFGAServiceV1Option, 0)
|
||||
|
||||
// ListObjects settings
|
||||
if cfg.OpenFgaServerSettings.MaxConcurrentReadsForListObjects != 0 {
|
||||
opts = append(opts, server.WithMaxConcurrentReadsForListObjects(cfg.OpenFgaServerSettings.MaxConcurrentReadsForListObjects))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingEnabled {
|
||||
opts = append(opts, server.WithListObjectsDispatchThrottlingEnabled(cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingEnabled))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingFrequency != 0 {
|
||||
opts = append(opts, server.WithListObjectsDispatchThrottlingFrequency(cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingFrequency))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingThreshold != 0 {
|
||||
opts = append(opts, server.WithListObjectsDispatchThrottlingThreshold(cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingThreshold))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingMaxThreshold != 0 {
|
||||
opts = append(opts, server.WithListObjectsDispatchThrottlingMaxThreshold(cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingMaxThreshold))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ListObjectsDatabaseThrottleThreshold != 0 || cfg.OpenFgaServerSettings.ListObjectsDatabaseThrottleDuration != 0 {
|
||||
opts = append(opts, server.WithListObjectsDatabaseThrottle(cfg.OpenFgaServerSettings.ListObjectsDatabaseThrottleThreshold, cfg.OpenFgaServerSettings.ListObjectsDatabaseThrottleDuration))
|
||||
}
|
||||
|
||||
// ListUsers settings
|
||||
if cfg.OpenFgaServerSettings.ListUsersDeadline != 0 {
|
||||
opts = append(opts, server.WithListUsersDeadline(cfg.OpenFgaServerSettings.ListUsersDeadline))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ListUsersMaxResults != 0 {
|
||||
opts = append(opts, server.WithListUsersMaxResults(cfg.OpenFgaServerSettings.ListUsersMaxResults))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.MaxConcurrentReadsForListUsers != 0 {
|
||||
opts = append(opts, server.WithMaxConcurrentReadsForListUsers(cfg.OpenFgaServerSettings.MaxConcurrentReadsForListUsers))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingEnabled {
|
||||
opts = append(opts, server.WithListUsersDispatchThrottlingEnabled(cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingEnabled))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingFrequency != 0 {
|
||||
opts = append(opts, server.WithListUsersDispatchThrottlingFrequency(cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingFrequency))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingThreshold != 0 {
|
||||
opts = append(opts, server.WithListUsersDispatchThrottlingThreshold(cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingThreshold))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingMaxThreshold != 0 {
|
||||
opts = append(opts, server.WithListUsersDispatchThrottlingMaxThreshold(cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingMaxThreshold))
|
||||
}
|
||||
if cfg.OpenFgaServerSettings.ListUsersDatabaseThrottleThreshold != 0 || cfg.OpenFgaServerSettings.ListUsersDatabaseThrottleDuration != 0 {
|
||||
opts = append(opts, server.WithListUsersDatabaseThrottle(cfg.OpenFgaServerSettings.ListUsersDatabaseThrottleThreshold, cfg.OpenFgaServerSettings.ListUsersDatabaseThrottleDuration))
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func NewOpenFGAHttpServer(cfg setting.ZanzanaServerSettings, srv grpcserver.Provider) (*http.Server, error) {
|
||||
dialOpts := []grpc.DialOption{
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
|
||||
@@ -15,7 +15,9 @@ import (
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
@@ -61,6 +63,7 @@ type CleanUpService struct {
|
||||
orgService org.Service
|
||||
teamService team.Service
|
||||
dataSourceService datasources.DataSourceService
|
||||
dynamicClientFactory func(*rest.Config) (dynamic.Interface, error)
|
||||
}
|
||||
|
||||
func ProvideService(cfg *setting.Cfg, Features featuremgmt.FeatureToggles, serverLockService *serverlock.ServerLockService,
|
||||
@@ -86,6 +89,9 @@ func ProvideService(cfg *setting.Cfg, Features featuremgmt.FeatureToggles, serve
|
||||
orgService: orgService,
|
||||
teamService: teamService,
|
||||
dataSourceService: dataSourceService,
|
||||
dynamicClientFactory: func(c *rest.Config) (dynamic.Interface, error) {
|
||||
return dynamic.NewForConfig(c)
|
||||
},
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -230,14 +236,93 @@ func (srv *CleanUpService) shouldCleanupTempFile(filemtime time.Time, now time.T
|
||||
|
||||
func (srv *CleanUpService) deleteExpiredSnapshots(ctx context.Context) {
|
||||
logger := srv.log.FromContext(ctx)
|
||||
cmd := dashboardsnapshots.DeleteExpiredSnapshotsCommand{}
|
||||
if err := srv.dashboardSnapshotService.DeleteExpiredSnapshots(ctx, &cmd); err != nil {
|
||||
logger.Error("Failed to delete expired snapshots", "error", err.Error())
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if srv.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesSnapshots) {
|
||||
srv.deleteKubernetesExpiredSnapshots(ctx)
|
||||
} else {
|
||||
logger.Debug("Deleted expired snapshots", "rows affected", cmd.DeletedRows)
|
||||
cmd := dashboardsnapshots.DeleteExpiredSnapshotsCommand{}
|
||||
if err := srv.dashboardSnapshotService.DeleteExpiredSnapshots(ctx, &cmd); err != nil {
|
||||
logger.Error("Failed to delete expired snapshots", "error", err.Error())
|
||||
} else {
|
||||
logger.Debug("Deleted expired snapshots", "rows affected", cmd.DeletedRows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *CleanUpService) deleteKubernetesExpiredSnapshots(ctx context.Context) {
|
||||
logger := srv.log.FromContext(ctx)
|
||||
logger.Debug("Starting deleting expired Kubernetes snapshots")
|
||||
|
||||
// Create the dynamic client for Kubernetes API
|
||||
restConfig, err := srv.clientConfigProvider.GetRestConfig(ctx)
|
||||
if err != nil {
|
||||
logger.Error("Failed to get REST config for Kubernetes client", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
client, err := srv.dynamicClientFactory(restConfig)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Kubernetes client", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Set up the GroupVersionResource for snapshots
|
||||
gvr := v0alpha1.SnapshotKind().GroupVersionResource()
|
||||
|
||||
// Expiration time is now
|
||||
expirationTime := time.Now()
|
||||
expirationTimestamp := expirationTime.UnixMilli()
|
||||
deletedCount := 0
|
||||
|
||||
// List and delete expired snapshots across all namespaces
|
||||
orgs, err := srv.orgService.Search(ctx, &org.SearchOrgsQuery{})
|
||||
if err != nil {
|
||||
logger.Error("Failed to list organizations", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for _, o := range orgs {
|
||||
ctx, _ := identity.WithServiceIdentity(ctx, o.ID)
|
||||
namespaceMapper := request.GetNamespaceMapper(srv.Cfg)
|
||||
snapshots, err := client.Resource(gvr).Namespace(namespaceMapper(o.ID)).List(ctx, v1.ListOptions{})
|
||||
if err != nil {
|
||||
logger.Error("Failed to list snapshots", "error", err.Error())
|
||||
return
|
||||
}
|
||||
// Check each snapshot for expiration
|
||||
for _, item := range snapshots.Items {
|
||||
// Convert unstructured object to Snapshot struct
|
||||
var snapshot v0alpha1.Snapshot
|
||||
err := runtime.DefaultUnstructuredConverter.FromUnstructured(item.Object, &snapshot)
|
||||
if err != nil {
|
||||
logger.Error("Failed to convert unstructured object to snapshot", "name", item.GetName(), "namespace", item.GetNamespace(), "error", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// Only delete expired snapshots
|
||||
if snapshot.Spec.Expires != nil && *snapshot.Spec.Expires < expirationTimestamp {
|
||||
namespace := snapshot.Namespace
|
||||
name := snapshot.Name
|
||||
|
||||
err := client.Resource(gvr).Namespace(namespace).Delete(ctx, name, v1.DeleteOptions{})
|
||||
if err != nil {
|
||||
// Check if it's a "not found" error, which is expected if the resource was already deleted
|
||||
if k8serrors.IsNotFound(err) {
|
||||
logger.Debug("Snapshot already deleted", "name", name, "namespace", namespace)
|
||||
} else {
|
||||
logger.Error("Failed to delete expired snapshot", "name", name, "namespace", namespace, "error", err.Error())
|
||||
}
|
||||
} else {
|
||||
deletedCount++
|
||||
logger.Debug("Successfully deleted expired snapshot", "name", name, "namespace", namespace, "creationTime", snapshot.CreationTimestamp.Unix(), "expirationTime", expirationTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("Deleted expired Kubernetes snapshots", "count", deletedCount)
|
||||
}
|
||||
|
||||
func (srv *CleanUpService) deleteExpiredDashboardVersions(ctx context.Context) {
|
||||
logger := srv.log.FromContext(ctx)
|
||||
cmd := dashver.DeleteExpiredVersionsCommand{}
|
||||
@@ -318,7 +403,7 @@ func (srv *CleanUpService) deleteStaleKubernetesShortURLs(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := dynamic.NewForConfig(restConfig)
|
||||
client, err := srv.dynamicClientFactory(restConfig)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Kubernetes client", "error", err.Error())
|
||||
return
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
package cleanup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/org/orgtest"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
@@ -36,3 +55,223 @@ func TestCleanUpTmpFiles(t *testing.T) {
|
||||
require.False(t, service.shouldCleanupTempFile(weekAgo, now))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteExpiredSnapshots_LegacyMode(t *testing.T) {
|
||||
t.Run("calls DeleteExpiredSnapshots on success", func(t *testing.T) {
|
||||
mockSnapService := dashboardsnapshots.NewMockService(t)
|
||||
mockSnapService.On("DeleteExpiredSnapshots", mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
service := &CleanUpService{
|
||||
log: log.New("cleanup"),
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
dashboardSnapshotService: mockSnapService,
|
||||
}
|
||||
|
||||
service.deleteExpiredSnapshots(context.Background())
|
||||
|
||||
mockSnapService.AssertCalled(t, "DeleteExpiredSnapshots", mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("handles error gracefully", func(t *testing.T) {
|
||||
mockSnapService := dashboardsnapshots.NewMockService(t)
|
||||
mockSnapService.On("DeleteExpiredSnapshots", mock.Anything, mock.Anything).Return(errors.New("db error"))
|
||||
|
||||
service := &CleanUpService{
|
||||
log: log.New("cleanup"),
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
dashboardSnapshotService: mockSnapService,
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
service.deleteExpiredSnapshots(context.Background())
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteExpiredSnapshots_KubernetesMode(t *testing.T) {
|
||||
t.Run("deletes expired snapshots across multiple orgs", func(t *testing.T) {
|
||||
// Create expired snapshots - one per org
|
||||
expiredTime := time.Now().Add(-time.Hour).UnixMilli()
|
||||
expiredSnapshot1 := createUnstructuredSnapshot("expired-snap-1", "org-1", expiredTime)
|
||||
expiredSnapshot2 := createUnstructuredSnapshot("expired-snap-2", "org-2", expiredTime)
|
||||
|
||||
// Track which namespaces were queried
|
||||
namespacesQueried := make(map[string]bool)
|
||||
|
||||
mockResource := new(mockResourceInterface)
|
||||
mockResource.On("Namespace", mock.Anything).Run(func(args mock.Arguments) {
|
||||
ns := args.Get(0).(string)
|
||||
namespacesQueried[ns] = true
|
||||
}).Return(mockResource)
|
||||
mockResource.On("List", mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{
|
||||
Items: []unstructured.Unstructured{*expiredSnapshot1, *expiredSnapshot2},
|
||||
}, nil)
|
||||
mockResource.On("Delete", mock.Anything, "expired-snap-1", mock.Anything, mock.Anything).Return(nil)
|
||||
mockResource.On("Delete", mock.Anything, "expired-snap-2", mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
mockDynClient := new(mockDynamicClient)
|
||||
mockDynClient.On("Resource", mock.Anything).Return(mockResource)
|
||||
|
||||
service := createK8sCleanupService(t, mockDynClient)
|
||||
service.deleteExpiredSnapshots(context.Background())
|
||||
|
||||
// Verify multiple namespaces were queried (one per org)
|
||||
require.GreaterOrEqual(t, len(namespacesQueried), 2, "expected at least 2 namespaces to be queried")
|
||||
// Verify both snapshots were deleted
|
||||
mockResource.AssertCalled(t, "Delete", mock.Anything, "expired-snap-1", mock.Anything, mock.Anything)
|
||||
mockResource.AssertCalled(t, "Delete", mock.Anything, "expired-snap-2", mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("skips non-expired snapshots", func(t *testing.T) {
|
||||
// Setup with future timestamp
|
||||
futureTime := time.Now().Add(time.Hour).UnixMilli()
|
||||
futureSnapshot := createUnstructuredSnapshot("future-snap", "org-1", futureTime)
|
||||
|
||||
mockResource := new(mockResourceInterface)
|
||||
mockResource.On("Namespace", mock.Anything).Return(mockResource)
|
||||
mockResource.On("List", mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{
|
||||
Items: []unstructured.Unstructured{*futureSnapshot},
|
||||
}, nil)
|
||||
|
||||
mockDynClient := new(mockDynamicClient)
|
||||
mockDynClient.On("Resource", mock.Anything).Return(mockResource)
|
||||
|
||||
service := createK8sCleanupService(t, mockDynClient)
|
||||
service.deleteExpiredSnapshots(context.Background())
|
||||
|
||||
mockResource.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("handles REST config error", func(t *testing.T) {
|
||||
service := &CleanUpService{
|
||||
log: log.New("cleanup"),
|
||||
Cfg: &setting.Cfg{},
|
||||
Features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesSnapshots),
|
||||
clientConfigProvider: apiserver.WithoutRestConfig,
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
service.deleteExpiredSnapshots(context.Background())
|
||||
})
|
||||
|
||||
t.Run("handles not found error gracefully", func(t *testing.T) {
|
||||
expiredTime := time.Now().Add(-time.Hour).UnixMilli()
|
||||
expiredSnapshot := createUnstructuredSnapshot("expired-snap", "org-1", expiredTime)
|
||||
|
||||
notFoundErr := k8serrors.NewNotFound(schema.GroupResource{}, "expired-snap")
|
||||
|
||||
mockResource := new(mockResourceInterface)
|
||||
mockResource.On("Namespace", mock.Anything).Return(mockResource)
|
||||
mockResource.On("List", mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{
|
||||
Items: []unstructured.Unstructured{*expiredSnapshot},
|
||||
}, nil)
|
||||
mockResource.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(notFoundErr)
|
||||
|
||||
mockDynClient := new(mockDynamicClient)
|
||||
mockDynClient.On("Resource", mock.Anything).Return(mockResource)
|
||||
|
||||
service := createK8sCleanupService(t, mockDynClient)
|
||||
|
||||
// Should not panic - not found is expected
|
||||
service.deleteExpiredSnapshots(context.Background())
|
||||
mockResource.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create unstructured snapshots for testing
|
||||
func createUnstructuredSnapshot(name, namespace string, expiresMillis int64) *unstructured.Unstructured {
|
||||
snapshot := &v0alpha1.Snapshot{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
},
|
||||
Spec: v0alpha1.SnapshotSpec{
|
||||
Expires: &expiresMillis,
|
||||
},
|
||||
}
|
||||
obj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(snapshot)
|
||||
return &unstructured.Unstructured{Object: obj}
|
||||
}
|
||||
|
||||
// Helper to create CleanUpService configured for Kubernetes mode with standard two-org setup
|
||||
func createK8sCleanupService(t *testing.T, mockDynClient *mockDynamicClient) *CleanUpService {
|
||||
mockOrgSvc := orgtest.NewMockService(t)
|
||||
mockOrgSvc.On("Search", mock.Anything, mock.Anything).Return([]*org.OrgDTO{
|
||||
{ID: 1, Name: "org1"},
|
||||
{ID: 2, Name: "org2"},
|
||||
}, nil)
|
||||
|
||||
return &CleanUpService{
|
||||
log: log.New("cleanup"),
|
||||
Cfg: &setting.Cfg{},
|
||||
Features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesSnapshots),
|
||||
clientConfigProvider: apiserver.RestConfigProviderFunc(func(ctx context.Context) (*rest.Config, error) {
|
||||
return &rest.Config{}, nil
|
||||
}),
|
||||
orgService: mockOrgSvc,
|
||||
dynamicClientFactory: func(cfg *rest.Config) (dynamic.Interface, error) {
|
||||
return mockDynClient, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// mockDynamicClient is a minimal mock for dynamic.Interface
|
||||
type mockDynamicClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockDynamicClient) Resource(resource schema.GroupVersionResource) dynamic.NamespaceableResourceInterface {
|
||||
args := m.Called(resource)
|
||||
return args.Get(0).(dynamic.NamespaceableResourceInterface)
|
||||
}
|
||||
|
||||
// mockResourceInterface is a minimal mock for dynamic.ResourceInterface
|
||||
type mockResourceInterface struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockResourceInterface) Namespace(ns string) dynamic.ResourceInterface {
|
||||
args := m.Called(ns)
|
||||
return args.Get(0).(dynamic.ResourceInterface)
|
||||
}
|
||||
|
||||
func (m *mockResourceInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*unstructured.UnstructuredList), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockResourceInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions, subresources ...string) error {
|
||||
args := m.Called(ctx, name, opts, subresources)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// Unused methods - panic if called unexpectedly
|
||||
func (m *mockResourceInterface) Create(ctx context.Context, obj *unstructured.Unstructured, opts metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) Update(ctx context.Context, obj *unstructured.Unstructured, opts metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, opts metav1.UpdateOptions) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) Get(ctx context.Context, name string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) Apply(ctx context.Context, name string, obj *unstructured.Unstructured, opts metav1.ApplyOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (m *mockResourceInterface) ApplyStatus(ctx context.Context, name string, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
@@ -37,10 +37,15 @@ var client = &http.Client{
|
||||
}
|
||||
|
||||
func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSharingOptions, cmd CreateDashboardSnapshotCommand, svc Service) {
|
||||
// perform all validations in the beginning
|
||||
if !cfg.SnapshotsEnabled {
|
||||
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
|
||||
return
|
||||
}
|
||||
if cmd.External && !cfg.ExternalEnabled {
|
||||
c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
|
||||
return
|
||||
}
|
||||
|
||||
uid := cmd.Dashboard.GetNestedString("uid")
|
||||
user, err := identity.GetRequester(c.Req.Context())
|
||||
@@ -67,17 +72,17 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh
|
||||
cmd.ExternalURL = ""
|
||||
cmd.OrgID = user.GetOrgID()
|
||||
cmd.UserID, _ = identity.UserIdentifier(user.GetID())
|
||||
originalDashboardURL, err := createOriginalDashboardURL(&cmd)
|
||||
originalDashboardURL, err := CreateOriginalDashboardURL(&cmd)
|
||||
if err != nil {
|
||||
c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err)
|
||||
return
|
||||
}
|
||||
|
||||
if cmd.External {
|
||||
if !cfg.ExternalEnabled {
|
||||
c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
|
||||
return
|
||||
}
|
||||
//if !cfg.ExternalEnabled {
|
||||
// c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
|
||||
// return
|
||||
//}
|
||||
|
||||
resp, err := createExternalDashboardSnapshot(cmd, cfg.ExternalSnapshotURL)
|
||||
if err != nil {
|
||||
@@ -203,7 +208,7 @@ func createExternalDashboardSnapshot(cmd CreateDashboardSnapshotCommand, externa
|
||||
return &createSnapshotResponse, nil
|
||||
}
|
||||
|
||||
func createOriginalDashboardURL(cmd *CreateDashboardSnapshotCommand) (string, error) {
|
||||
func CreateOriginalDashboardURL(cmd *CreateDashboardSnapshotCommand) (string, error) {
|
||||
dashUID := cmd.Dashboard.GetNestedString("uid")
|
||||
if ok := util.IsValidShortUID(dashUID); !ok {
|
||||
return "", fmt.Errorf("invalid dashboard UID")
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
)
|
||||
|
||||
func newGOFFProvider(url string, client *http.Client) (openfeature.FeatureProvider, error) {
|
||||
func newFeaturesServiceProvider(url string, client *http.Client) (openfeature.FeatureProvider, error) {
|
||||
options := gofeatureflag.ProviderOptions{
|
||||
Endpoint: url,
|
||||
// consider using github.com/grafana/grafana/pkg/infra/httpclient/provider.go
|
||||
@@ -19,11 +19,11 @@ const (
|
||||
|
||||
// OpenFeatureConfig holds configuration for initializing OpenFeature
|
||||
type OpenFeatureConfig struct {
|
||||
// ProviderType is either "static", "goff", or "ofrep"
|
||||
// ProviderType is either "static", "features-service", or "ofrep"
|
||||
ProviderType string
|
||||
// URL is the GOFF or OFREP service URL (required for GOFF + OFREP providers)
|
||||
// URL is the remote provider's URL (required for features-service + OFREP providers)
|
||||
URL *url.URL
|
||||
// HTTPClient is a pre-configured HTTP client (optional, used for GOFF + OFREP providers)
|
||||
// HTTPClient is a pre-configured HTTP client (optional, used by features-service + OFREP providers)
|
||||
HTTPClient *http.Client
|
||||
// StaticFlags are the feature flags to use with static provider
|
||||
StaticFlags map[string]bool
|
||||
@@ -35,9 +35,9 @@ type OpenFeatureConfig struct {
|
||||
|
||||
// InitOpenFeature initializes OpenFeature with the provided configuration
|
||||
func InitOpenFeature(config OpenFeatureConfig) error {
|
||||
// For GOFF + OFREP providers, ensure we have a URL
|
||||
if (config.ProviderType == setting.GOFFProviderType || config.ProviderType == setting.OFREPProviderType) && (config.URL == nil || config.URL.String() == "") {
|
||||
return fmt.Errorf("URL is required for GOFF + OFREP providers")
|
||||
// For remote providers, ensure we have a URL
|
||||
if (config.ProviderType == setting.FeaturesServiceProviderType || config.ProviderType == setting.OFREPProviderType) && (config.URL == nil || config.URL.String() == "") {
|
||||
return fmt.Errorf("URL is required for remote providers")
|
||||
}
|
||||
|
||||
p, err := createProvider(config.ProviderType, config.URL, config.StaticFlags, config.HTTPClient)
|
||||
@@ -66,10 +66,10 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
|
||||
}
|
||||
|
||||
var httpcli *http.Client
|
||||
if cfg.OpenFeature.ProviderType == setting.GOFFProviderType || cfg.OpenFeature.ProviderType == setting.OFREPProviderType {
|
||||
if cfg.OpenFeature.ProviderType == setting.FeaturesServiceProviderType || cfg.OpenFeature.ProviderType == setting.OFREPProviderType {
|
||||
var m *clientauthmiddleware.TokenExchangeMiddleware
|
||||
|
||||
if cfg.OpenFeature.ProviderType == setting.GOFFProviderType {
|
||||
if cfg.OpenFeature.ProviderType == setting.FeaturesServiceProviderType {
|
||||
m, err = clientauthmiddleware.NewTokenExchangeMiddleware(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token exchange middleware: %w", err)
|
||||
@@ -103,13 +103,13 @@ func createProvider(
|
||||
staticFlags map[string]bool,
|
||||
httpClient *http.Client,
|
||||
) (openfeature.FeatureProvider, error) {
|
||||
if providerType == setting.GOFFProviderType || providerType == setting.OFREPProviderType {
|
||||
if providerType == setting.FeaturesServiceProviderType || providerType == setting.OFREPProviderType {
|
||||
if u == nil || u.String() == "" {
|
||||
return nil, fmt.Errorf("feature provider url is required for GOFFProviderType + OFREPProviderType")
|
||||
return nil, fmt.Errorf("feature provider url is required for FeaturesServiceProviderType + OFREPProviderType")
|
||||
}
|
||||
|
||||
if providerType == setting.GOFFProviderType {
|
||||
return newGOFFProvider(u.String(), httpClient)
|
||||
if providerType == setting.FeaturesServiceProviderType {
|
||||
return newFeaturesServiceProvider(u.String(), httpClient)
|
||||
}
|
||||
|
||||
if providerType == setting.OFREPProviderType {
|
||||
|
||||
@@ -35,9 +35,9 @@ func TestCreateProvider(t *testing.T) {
|
||||
expectedProvider: setting.StaticProviderType,
|
||||
},
|
||||
{
|
||||
name: "goff provider",
|
||||
name: "features-service provider",
|
||||
cfg: setting.OpenFeatureSettings{
|
||||
ProviderType: setting.GOFFProviderType,
|
||||
ProviderType: setting.FeaturesServiceProviderType,
|
||||
URL: u,
|
||||
TargetingKey: "grafana",
|
||||
},
|
||||
@@ -45,12 +45,12 @@ func TestCreateProvider(t *testing.T) {
|
||||
Namespace: "*",
|
||||
Audiences: []string{"features.grafana.app"},
|
||||
},
|
||||
expectedProvider: setting.GOFFProviderType,
|
||||
expectedProvider: setting.FeaturesServiceProviderType,
|
||||
},
|
||||
{
|
||||
name: "goff provider with failing token exchange",
|
||||
name: "features-service provider with failing token exchange",
|
||||
cfg: setting.OpenFeatureSettings{
|
||||
ProviderType: setting.GOFFProviderType,
|
||||
ProviderType: setting.FeaturesServiceProviderType,
|
||||
URL: u,
|
||||
TargetingKey: "grafana",
|
||||
},
|
||||
@@ -58,7 +58,7 @@ func TestCreateProvider(t *testing.T) {
|
||||
Namespace: "*",
|
||||
Audiences: []string{"features.grafana.app"},
|
||||
},
|
||||
expectedProvider: setting.GOFFProviderType,
|
||||
expectedProvider: setting.FeaturesServiceProviderType,
|
||||
failSigning: true,
|
||||
},
|
||||
{
|
||||
@@ -107,7 +107,7 @@ func TestCreateProvider(t *testing.T) {
|
||||
|
||||
tokenExchangeMiddleware := middleware.TestingTokenExchangeMiddleware(tokenExchangeClient)
|
||||
httpClient, err := createHTTPClient(tokenExchangeMiddleware)
|
||||
require.NoError(t, err, "failed to create goff http client")
|
||||
require.NoError(t, err, "failed to create features-service http client")
|
||||
provider, err := createProvider(tc.cfg.ProviderType, tc.cfg.URL, nil, httpClient)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestCreateProvider(t *testing.T) {
|
||||
require.NoError(t, err, "failed to set provider")
|
||||
|
||||
switch tc.expectedProvider {
|
||||
case setting.GOFFProviderType:
|
||||
case setting.FeaturesServiceProviderType:
|
||||
_, ok := provider.(*gofeatureflag.Provider)
|
||||
assert.True(t, ok, "expected provider to be of type goff.Provider")
|
||||
|
||||
@@ -141,10 +141,10 @@ func testGoFFProvider(t *testing.T, failSigning bool) {
|
||||
_, err := openfeature.NewDefaultClient().BooleanValueDetails(ctx, "test", false, openfeature.NewEvaluationContext("test", map[string]interface{}{"test": "test"}))
|
||||
|
||||
// Error related to the token exchange should be returned if signing fails
|
||||
// otherwise, it should return a connection refused error since the goff URL is not set
|
||||
// otherwise, it should return a connection refused error since the features-service URL is not set
|
||||
if failSigning {
|
||||
assert.ErrorContains(t, err, "failed to exchange token: error signing token", "should return an error when signing fails")
|
||||
} else {
|
||||
assert.ErrorContains(t, err, "connect: connection refused", "should return an error when goff url is not set")
|
||||
assert.ErrorContains(t, err, "connect: connection refused", "should return an error when features-service url is not set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1155,7 +1155,14 @@ var (
|
||||
Owner: grafanaAppPlatformSquad,
|
||||
RequiresRestart: true,
|
||||
},
|
||||
{
|
||||
{
|
||||
Name: "passwordlessMagicLinkAuthentication",
|
||||
Description: "Enable passwordless login via magic link authentication",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: identityAccessTeam,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "exploreMetricsRelatedLogs",
|
||||
Description: "Display Related Logs in Grafana Metrics Drilldown",
|
||||
Stage: FeatureStageExperimental,
|
||||
|
||||
Generated
+1
@@ -160,6 +160,7 @@ timeRangePan,experimental,@grafana/dataviz-squad,false,false,true
|
||||
newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true
|
||||
azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
|
||||
playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false
|
||||
exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true
|
||||
prometheusSpecialCharsInLabelValues,experimental,@grafana/oss-big-tent,false,false,true
|
||||
enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false
|
||||
|
||||
|
Generated
+4
@@ -483,6 +483,10 @@ const (
|
||||
// Enables experimental reconciler for playlists
|
||||
FlagPlaylistsReconciler = "playlistsReconciler"
|
||||
|
||||
// FlagPasswordlessMagicLinkAuthentication
|
||||
// Enable passwordless login via magic link authentication
|
||||
FlagPasswordlessMagicLinkAuthentication = "passwordlessMagicLinkAuthentication"
|
||||
|
||||
// FlagEnableExtensionsAdminPage
|
||||
// Enables the extension admin page regardless of development mode
|
||||
FlagEnableExtensionsAdminPage = "enableExtensionsAdminPage"
|
||||
|
||||
+1
-2
@@ -2661,8 +2661,7 @@
|
||||
"metadata": {
|
||||
"name": "passwordlessMagicLinkAuthentication",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2024-11-14T13:50:55Z",
|
||||
"deletionTimestamp": "2026-01-08T15:33:29Z"
|
||||
"creationTimestamp": "2024-11-14T13:50:55Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable passwordless login via magic link authentication",
|
||||
|
||||
+7
-1
@@ -14,6 +14,8 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/provider"
|
||||
"github.com/grafana/grafana/pkg/plugins/log"
|
||||
"github.com/grafana/grafana/pkg/tsdb/azuremonitor"
|
||||
cloudmonitoring "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring"
|
||||
@@ -92,6 +94,10 @@ func NewRegistry(store map[string]backendplugin.PluginFactoryFunc) *Registry {
|
||||
}
|
||||
}
|
||||
|
||||
func ProvideCoreProvider(coreRegistry *Registry) plugins.BackendFactoryProvider {
|
||||
return provider.New(coreRegistry.BackendFactoryProvider(), provider.DefaultProvider)
|
||||
}
|
||||
|
||||
func ProvideCoreRegistry(tracer trace.Tracer, am *azuremonitor.Service, cw *cloudwatch.Service, cm *cloudmonitoring.Service,
|
||||
es *elasticsearch.Service, grap *graphite.Service, idb *influxdb.Service, lk *loki.Service, otsdb *opentsdb.Service,
|
||||
pr *prometheus.Service, t *tempo.Service, td *testdatasource.Service, pg *postgres.Service, my *mysql.Service,
|
||||
@@ -156,7 +162,7 @@ func asBackendPlugin(svc any) backendplugin.PluginFactoryFunc {
|
||||
|
||||
if opts.QueryDataHandler != nil || opts.CallResourceHandler != nil ||
|
||||
opts.CheckHealthHandler != nil || opts.StreamHandler != nil {
|
||||
return New(opts)
|
||||
return coreplugin.New(opts)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/auth"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/envvars"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angularinspector"
|
||||
@@ -19,6 +18,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginassets"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins"
|
||||
)
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/auth"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/provider"
|
||||
"github.com/grafana/grafana/pkg/plugins/envvars"
|
||||
"github.com/grafana/grafana/pkg/plugins/log"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/client"
|
||||
@@ -39,6 +37,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/angularinspector"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/angularpatternsstore"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/clientmiddleware"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/installsync"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever/dynamic"
|
||||
@@ -146,8 +145,7 @@ var WireSet = wire.NewSet(
|
||||
// WireExtensionSet provides a wire.ProviderSet of plugin providers that can be
|
||||
// extended.
|
||||
var WireExtensionSet = wire.NewSet(
|
||||
provider.ProvideService,
|
||||
wire.Bind(new(plugins.BackendFactoryProvider), new(*provider.Service)),
|
||||
coreplugin.ProvideCoreProvider,
|
||||
signature.ProvideOSSAuthorizer,
|
||||
wire.Bind(new(plugins.PluginLoaderAuthorizer), new(*signature.UnsignedPluginAuthorizer)),
|
||||
ProvideClientWithMiddlewares,
|
||||
|
||||
@@ -19,10 +19,10 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/fs"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/services/searchV2"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/provider"
|
||||
pluginsCfg "github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/client"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/loader"
|
||||
@@ -27,6 +25,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginassets"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources"
|
||||
@@ -52,7 +51,7 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core
|
||||
disc := pipeline.ProvideDiscoveryStage(pCfg, reg)
|
||||
boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), pluginassets.NewLocalProvider())
|
||||
valid := pipeline.ProvideValidationStage(pCfg, signature.NewValidator(signature.NewUnsignedAuthorizer(pCfg)), angularInspector)
|
||||
init := pipeline.ProvideInitializationStage(pCfg, reg, provider.ProvideService(coreRegistry), proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop())
|
||||
init := pipeline.ProvideInitializationStage(pCfg, reg, coreplugin.ProvideCoreProvider(coreRegistry), proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop())
|
||||
term, err := pipeline.ProvideTerminationStage(pCfg, reg, proc)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -98,7 +97,7 @@ func CreateTestLoader(t *testing.T, cfg *pluginsCfg.PluginManagementCfg, opts Lo
|
||||
if opts.Initializer == nil {
|
||||
reg := registry.ProvideService()
|
||||
coreRegistry := coreplugin.NewRegistry(make(map[string]backendplugin.PluginFactoryFunc))
|
||||
opts.Initializer = pipeline.ProvideInitializationStage(cfg, reg, provider.ProvideService(coreRegistry), process.ProvideService(), &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop())
|
||||
opts.Initializer = pipeline.ProvideInitializationStage(cfg, reg, coreplugin.ProvideCoreProvider(coreRegistry), process.ProvideService(), &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop())
|
||||
}
|
||||
|
||||
if opts.Terminator == nil {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
StaticProviderType = "static"
|
||||
GOFFProviderType = "goff"
|
||||
OFREPProviderType = "ofrep"
|
||||
StaticProviderType = "static"
|
||||
FeaturesServiceProviderType = "features-service"
|
||||
OFREPProviderType = "ofrep"
|
||||
)
|
||||
|
||||
type OpenFeatureSettings struct {
|
||||
@@ -34,7 +34,7 @@ func (cfg *Cfg) readOpenFeatureSettings() error {
|
||||
|
||||
cfg.OpenFeature.TargetingKey = config.Key("targetingKey").MustString(defaultTargetingKey)
|
||||
|
||||
if strURL != "" && (cfg.OpenFeature.ProviderType == GOFFProviderType || cfg.OpenFeature.ProviderType == OFREPProviderType) {
|
||||
if strURL != "" && (cfg.OpenFeature.ProviderType == FeaturesServiceProviderType || cfg.OpenFeature.ProviderType == OFREPProviderType) {
|
||||
u, err := url.Parse(strURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid feature provider url: %w", err)
|
||||
|
||||
@@ -37,6 +37,8 @@ type ZanzanaServerSettings struct {
|
||||
OpenFGAHttpAddr string
|
||||
// Cache settings
|
||||
CacheSettings OpenFgaCacheSettings
|
||||
// OpenFGA server settings
|
||||
OpenFgaServerSettings OpenFgaServerSettings
|
||||
// Max number of results returned by ListObjects() query. Default is 1000.
|
||||
ListObjectsMaxResults uint32
|
||||
// Deadline for the ListObjects() query. Default is 3 seconds.
|
||||
@@ -50,6 +52,92 @@ type ZanzanaServerSettings struct {
|
||||
AllowInsecure bool
|
||||
}
|
||||
|
||||
type OpenFgaServerSettings struct {
|
||||
// ListObjects settings
|
||||
// Max number of concurrent datastore reads for ListObjects queries
|
||||
MaxConcurrentReadsForListObjects uint32
|
||||
// Enable dispatch throttling for ListObjects queries
|
||||
ListObjectsDispatchThrottlingEnabled bool
|
||||
// Frequency for dispatch throttling in ListObjects queries
|
||||
ListObjectsDispatchThrottlingFrequency time.Duration
|
||||
// Threshold for dispatch throttling in ListObjects queries
|
||||
ListObjectsDispatchThrottlingThreshold uint32
|
||||
// Max threshold for dispatch throttling in ListObjects queries
|
||||
ListObjectsDispatchThrottlingMaxThreshold uint32
|
||||
// Database throttle threshold for ListObjects queries
|
||||
ListObjectsDatabaseThrottleThreshold int
|
||||
// Database throttle duration for ListObjects queries
|
||||
ListObjectsDatabaseThrottleDuration time.Duration
|
||||
|
||||
// ListUsers settings
|
||||
// Deadline for ListUsers queries
|
||||
ListUsersDeadline time.Duration
|
||||
// Max number of results returned by ListUsers queries
|
||||
ListUsersMaxResults uint32
|
||||
// Max number of concurrent datastore reads for ListUsers queries
|
||||
MaxConcurrentReadsForListUsers uint32
|
||||
// Enable dispatch throttling for ListUsers queries
|
||||
ListUsersDispatchThrottlingEnabled bool
|
||||
// Frequency for dispatch throttling in ListUsers queries
|
||||
ListUsersDispatchThrottlingFrequency time.Duration
|
||||
// Threshold for dispatch throttling in ListUsers queries
|
||||
ListUsersDispatchThrottlingThreshold uint32
|
||||
// Max threshold for dispatch throttling in ListUsers queries
|
||||
ListUsersDispatchThrottlingMaxThreshold uint32
|
||||
// Database throttle threshold for ListUsers queries
|
||||
ListUsersDatabaseThrottleThreshold int
|
||||
// Database throttle duration for ListUsers queries
|
||||
ListUsersDatabaseThrottleDuration time.Duration
|
||||
|
||||
// Check settings
|
||||
// Max number of concurrent datastore reads for Check queries
|
||||
MaxConcurrentReadsForCheck uint32
|
||||
// Database throttle threshold for Check queries
|
||||
CheckDatabaseThrottleThreshold int
|
||||
// Database throttle duration for Check queries
|
||||
CheckDatabaseThrottleDuration time.Duration
|
||||
|
||||
// Batch check settings
|
||||
// Max number of concurrent checks per batch check request
|
||||
MaxConcurrentChecksPerBatchCheck uint32
|
||||
// Max number of checks per batch check request
|
||||
MaxChecksPerBatchCheck uint32
|
||||
|
||||
// Resolve node settings
|
||||
// Max number of nodes that can be resolved in a single query
|
||||
ResolveNodeLimit uint32
|
||||
// Max breadth of nodes that can be resolved in a single query
|
||||
ResolveNodeBreadthLimit uint32
|
||||
|
||||
// Dispatch throttling settings for Check resolver
|
||||
// Enable dispatch throttling for Check resolver
|
||||
DispatchThrottlingCheckResolverEnabled bool
|
||||
// Frequency for dispatch throttling in Check resolver
|
||||
DispatchThrottlingCheckResolverFrequency time.Duration
|
||||
// Threshold for dispatch throttling in Check resolver
|
||||
DispatchThrottlingCheckResolverThreshold uint32
|
||||
// Max threshold for dispatch throttling in Check resolver
|
||||
DispatchThrottlingCheckResolverMaxThreshold uint32
|
||||
|
||||
// Shadow check/query settings
|
||||
// Timeout for shadow check resolver
|
||||
ShadowCheckResolverTimeout time.Duration
|
||||
// Timeout for shadow ListObjects query
|
||||
ShadowListObjectsQueryTimeout time.Duration
|
||||
// Max delta items for shadow ListObjects query
|
||||
ShadowListObjectsQueryMaxDeltaItems int
|
||||
|
||||
// Request settings
|
||||
// Global request timeout
|
||||
RequestTimeout time.Duration
|
||||
// Max size in bytes for authorization model
|
||||
MaxAuthorizationModelSizeInBytes int
|
||||
// Size of the authorization model cache
|
||||
AuthorizationModelCacheSize int
|
||||
// Offset for changelog horizon
|
||||
ChangelogHorizonOffset int
|
||||
}
|
||||
|
||||
// Parameters to configure OpenFGA cache.
|
||||
type OpenFgaCacheSettings struct {
|
||||
// Number of items that will be kept in the in-memory cache used to resolve Check queries.
|
||||
@@ -156,5 +244,56 @@ func (cfg *Cfg) readZanzanaSettings() {
|
||||
zs.CacheSettings.SharedIteratorLimit = uint32(serverSec.Key("shared_iterator_limit").MustUint(1000))
|
||||
zs.CacheSettings.SharedIteratorTTL = serverSec.Key("shared_iterator_ttl").MustDuration(10 * time.Second)
|
||||
|
||||
openfgaSec := cfg.SectionWithEnvOverrides("openfga")
|
||||
|
||||
// ListObjects settings
|
||||
zs.OpenFgaServerSettings.MaxConcurrentReadsForListObjects = uint32(openfgaSec.Key("max_concurrent_reads_for_list_objects").MustUint(0))
|
||||
zs.OpenFgaServerSettings.ListObjectsDispatchThrottlingEnabled = openfgaSec.Key("list_objects_dispatch_throttling_enabled").MustBool(false)
|
||||
zs.OpenFgaServerSettings.ListObjectsDispatchThrottlingFrequency = openfgaSec.Key("list_objects_dispatch_throttling_frequency").MustDuration(0)
|
||||
zs.OpenFgaServerSettings.ListObjectsDispatchThrottlingThreshold = uint32(openfgaSec.Key("list_objects_dispatch_throttling_threshold").MustUint(0))
|
||||
zs.OpenFgaServerSettings.ListObjectsDispatchThrottlingMaxThreshold = uint32(openfgaSec.Key("list_objects_dispatch_throttling_max_threshold").MustUint(0))
|
||||
zs.OpenFgaServerSettings.ListObjectsDatabaseThrottleThreshold = openfgaSec.Key("list_objects_database_throttle_threshold").MustInt(0)
|
||||
zs.OpenFgaServerSettings.ListObjectsDatabaseThrottleDuration = openfgaSec.Key("list_objects_database_throttle_duration").MustDuration(0)
|
||||
|
||||
// ListUsers settings
|
||||
zs.OpenFgaServerSettings.ListUsersDeadline = openfgaSec.Key("list_users_deadline").MustDuration(0)
|
||||
zs.OpenFgaServerSettings.ListUsersMaxResults = uint32(openfgaSec.Key("list_users_max_results").MustUint(0))
|
||||
zs.OpenFgaServerSettings.MaxConcurrentReadsForListUsers = uint32(openfgaSec.Key("max_concurrent_reads_for_list_users").MustUint(0))
|
||||
zs.OpenFgaServerSettings.ListUsersDispatchThrottlingEnabled = openfgaSec.Key("list_users_dispatch_throttling_enabled").MustBool(false)
|
||||
zs.OpenFgaServerSettings.ListUsersDispatchThrottlingFrequency = openfgaSec.Key("list_users_dispatch_throttling_frequency").MustDuration(0)
|
||||
zs.OpenFgaServerSettings.ListUsersDispatchThrottlingThreshold = uint32(openfgaSec.Key("list_users_dispatch_throttling_threshold").MustUint(0))
|
||||
zs.OpenFgaServerSettings.ListUsersDispatchThrottlingMaxThreshold = uint32(openfgaSec.Key("list_users_dispatch_throttling_max_threshold").MustUint(0))
|
||||
zs.OpenFgaServerSettings.ListUsersDatabaseThrottleThreshold = openfgaSec.Key("list_users_database_throttle_threshold").MustInt(0)
|
||||
zs.OpenFgaServerSettings.ListUsersDatabaseThrottleDuration = openfgaSec.Key("list_users_database_throttle_duration").MustDuration(0)
|
||||
|
||||
// Check settings
|
||||
zs.OpenFgaServerSettings.MaxConcurrentReadsForCheck = uint32(openfgaSec.Key("max_concurrent_reads_for_check").MustUint(0))
|
||||
zs.OpenFgaServerSettings.CheckDatabaseThrottleThreshold = openfgaSec.Key("check_database_throttle_threshold").MustInt(0)
|
||||
zs.OpenFgaServerSettings.CheckDatabaseThrottleDuration = openfgaSec.Key("check_database_throttle_duration").MustDuration(0)
|
||||
|
||||
// Batch check settings
|
||||
zs.OpenFgaServerSettings.MaxConcurrentChecksPerBatchCheck = uint32(openfgaSec.Key("max_concurrent_checks_per_batch_check").MustUint(0))
|
||||
zs.OpenFgaServerSettings.MaxChecksPerBatchCheck = uint32(openfgaSec.Key("max_checks_per_batch_check").MustUint(0))
|
||||
|
||||
// Resolve node settings
|
||||
zs.OpenFgaServerSettings.ResolveNodeLimit = uint32(openfgaSec.Key("resolve_node_limit").MustUint(0))
|
||||
zs.OpenFgaServerSettings.ResolveNodeBreadthLimit = uint32(openfgaSec.Key("resolve_node_breadth_limit").MustUint(0))
|
||||
|
||||
// Dispatch throttling settings for Check resolver
|
||||
zs.OpenFgaServerSettings.DispatchThrottlingCheckResolverEnabled = openfgaSec.Key("dispatch_throttling_check_resolver_enabled").MustBool(false)
|
||||
zs.OpenFgaServerSettings.DispatchThrottlingCheckResolverFrequency = openfgaSec.Key("dispatch_throttling_check_resolver_frequency").MustDuration(0)
|
||||
zs.OpenFgaServerSettings.DispatchThrottlingCheckResolverThreshold = uint32(openfgaSec.Key("dispatch_throttling_check_resolver_threshold").MustUint(0))
|
||||
zs.OpenFgaServerSettings.DispatchThrottlingCheckResolverMaxThreshold = uint32(openfgaSec.Key("dispatch_throttling_check_resolver_max_threshold").MustUint(0))
|
||||
|
||||
// Shadow check/query settings
|
||||
zs.OpenFgaServerSettings.ShadowCheckResolverTimeout = openfgaSec.Key("shadow_check_resolver_timeout").MustDuration(0)
|
||||
zs.OpenFgaServerSettings.ShadowListObjectsQueryTimeout = openfgaSec.Key("shadow_list_objects_query_timeout").MustDuration(0)
|
||||
zs.OpenFgaServerSettings.ShadowListObjectsQueryMaxDeltaItems = openfgaSec.Key("shadow_list_objects_query_max_delta_items").MustInt(0)
|
||||
|
||||
zs.OpenFgaServerSettings.RequestTimeout = openfgaSec.Key("request_timeout").MustDuration(0)
|
||||
zs.OpenFgaServerSettings.MaxAuthorizationModelSizeInBytes = openfgaSec.Key("max_authorization_model_size_in_bytes").MustInt(0)
|
||||
zs.OpenFgaServerSettings.AuthorizationModelCacheSize = openfgaSec.Key("authorization_model_cache_size").MustInt(0)
|
||||
zs.OpenFgaServerSettings.ChangelogHorizonOffset = openfgaSec.Key("changelog_horizon_offset").MustInt(0)
|
||||
|
||||
cfg.ZanzanaServer = zs
|
||||
}
|
||||
|
||||
@@ -1786,6 +1786,13 @@
|
||||
"skipUrlSync": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"valuesFormat": {
|
||||
"enum": [
|
||||
"csv",
|
||||
"json"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -1801,6 +1801,13 @@
|
||||
"skipUrlSync": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"valuesFormat": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"csv",
|
||||
"json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -321,7 +321,7 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
|
||||
_, err = openFeatureSect.NewKey("enable_api", strconv.FormatBool(opts.OpenFeatureAPIEnabled))
|
||||
require.NoError(t, err)
|
||||
if !opts.OpenFeatureAPIEnabled {
|
||||
_, err = openFeatureSect.NewKey("provider", "static") // in practice, APIEnabled being false goes with goff type, but trying to make tests work
|
||||
_, err = openFeatureSect.NewKey("provider", "static") // in practice, APIEnabled being false goes with features-service type, but trying to make tests work
|
||||
require.NoError(t, err)
|
||||
_, err = openFeatureSect.NewKey("targetingKey", "grafana")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -19,6 +19,8 @@ import { AddVariableButton } from './VariableControlsAddButton';
|
||||
|
||||
export function VariableControls({ dashboard }: { dashboard: DashboardScene }) {
|
||||
const { variables } = sceneGraph.getVariables(dashboard)!.useState();
|
||||
const { isEditing } = dashboard.useState();
|
||||
const isEditingNewLayouts = isEditing && config.featureToggles.dashboardNewLayouts;
|
||||
|
||||
// Get visible variables for drilldown layout
|
||||
const visibleVariables = variables.filter((v) => v.state.hide !== VariableHide.inControlsMenu);
|
||||
@@ -35,13 +37,22 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) {
|
||||
// Variables to render (exclude adhoc/groupby when drilldown controls are shown in top row)
|
||||
const variablesToRender = hasDrilldownControls
|
||||
? restVariables.filter((v) => v.state.hide !== VariableHide.inControlsMenu)
|
||||
: variables.filter((v) => v.state.hide !== VariableHide.inControlsMenu);
|
||||
: variables.filter(
|
||||
(v) =>
|
||||
// if we're editing in dynamic dashboards, still shows hidden variable but greyed out
|
||||
(isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) ||
|
||||
v.state.hide !== VariableHide.inControlsMenu
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{variablesToRender.length > 0 &&
|
||||
variablesToRender.map((variable) => (
|
||||
<VariableValueSelectWrapper key={variable.state.key} variable={variable} />
|
||||
<VariableValueSelectWrapper
|
||||
key={variable.state.key}
|
||||
variable={variable}
|
||||
isEditingNewLayouts={isEditingNewLayouts}
|
||||
/>
|
||||
))}
|
||||
|
||||
{config.featureToggles.dashboardNewLayouts ? <AddVariableButton dashboard={dashboard} /> : null}
|
||||
@@ -52,14 +63,17 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) {
|
||||
interface VariableSelectProps {
|
||||
variable: SceneVariable;
|
||||
inMenu?: boolean;
|
||||
isEditingNewLayouts?: boolean;
|
||||
}
|
||||
|
||||
export function VariableValueSelectWrapper({ variable, inMenu }: VariableSelectProps) {
|
||||
export function VariableValueSelectWrapper({ variable, inMenu, isEditingNewLayouts }: VariableSelectProps) {
|
||||
const state = useSceneObjectState<SceneVariableState>(variable, { shouldActivateOrKeepAlive: true });
|
||||
const { isSelected, onSelect, isSelectable } = useElementSelection(variable.state.key);
|
||||
const isHidden = state.hide === VariableHide.hideVariable;
|
||||
const shouldShowHiddenVariables = isEditingNewLayouts && isHidden;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (state.hide === VariableHide.hideVariable) {
|
||||
if (isHidden && !isEditingNewLayouts) {
|
||||
if (variable.UNSAFE_renderAsHidden) {
|
||||
return <variable.Component model={variable} />;
|
||||
}
|
||||
@@ -97,6 +111,7 @@ export function VariableValueSelectWrapper({ variable, inMenu }: VariableSelectP
|
||||
<div
|
||||
className={cx(
|
||||
styles.switchMenuContainer,
|
||||
shouldShowHiddenVariables && styles.hidden,
|
||||
isSelected && 'dashboard-selected-element',
|
||||
isSelectable && !isSelected && 'dashboard-selectable-element'
|
||||
)}
|
||||
@@ -120,6 +135,7 @@ export function VariableValueSelectWrapper({ variable, inMenu }: VariableSelectP
|
||||
<div
|
||||
className={cx(
|
||||
styles.verticalContainer,
|
||||
shouldShowHiddenVariables && styles.hidden,
|
||||
isSelected && 'dashboard-selected-element',
|
||||
isSelectable && !isSelected && 'dashboard-selectable-element'
|
||||
)}
|
||||
@@ -136,6 +152,7 @@ export function VariableValueSelectWrapper({ variable, inMenu }: VariableSelectP
|
||||
<div
|
||||
className={cx(
|
||||
styles.container,
|
||||
shouldShowHiddenVariables && styles.hidden,
|
||||
isSelected && 'dashboard-selected-element',
|
||||
isSelectable && !isSelected && 'dashboard-selectable-element'
|
||||
)}
|
||||
@@ -223,4 +240,13 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
hidden: css({
|
||||
opacity: 0.6,
|
||||
'&:hover': css({
|
||||
opacity: 1,
|
||||
}),
|
||||
label: css({
|
||||
textDecoration: 'line-through',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Dropdown, ToolbarButton, useStyles2 } from '@grafana/ui';
|
||||
|
||||
@@ -33,6 +34,7 @@ export function DashboardControlsButton({ dashboard }: { dashboard: DashboardSce
|
||||
<ToolbarButton
|
||||
aria-label={t('dashboard.controls.menu.aria-label', DASHBOARD_CONTROLS_MENU_ARIA_LABEL)}
|
||||
title={t('dashboard.controls.menu.title', DASHBOARD_CONTROLS_MENU_TITLE)}
|
||||
data-testid={selectors.pages.Dashboard.ControlsButton}
|
||||
icon="sliders-v-alt"
|
||||
iconSize="md"
|
||||
variant="canvas"
|
||||
|
||||
@@ -345,6 +345,16 @@ describe('DashboardSceneSerializer', () => {
|
||||
type: 'textbox',
|
||||
name: 'search',
|
||||
},
|
||||
{
|
||||
name: 'custom_csv',
|
||||
type: 'custom',
|
||||
valuesFormat: 'csv',
|
||||
},
|
||||
{
|
||||
name: 'custom_json',
|
||||
type: 'custom',
|
||||
valuesFormat: 'json',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -359,6 +369,9 @@ describe('DashboardSceneSerializer', () => {
|
||||
panel_type_row_count: 1,
|
||||
variable_type_query_count: 2,
|
||||
variable_type_textbox_count: 1,
|
||||
variable_type_custom_count: 2,
|
||||
variable_type_custom_csv_count: 1,
|
||||
variable_type_custom_json_count: 1,
|
||||
settings_nowdelay: undefined,
|
||||
settings_livenow: true,
|
||||
varsWithDataSource: [
|
||||
@@ -701,7 +714,9 @@ describe('DashboardSceneSerializer', () => {
|
||||
panel_type_timeseries_count: 6,
|
||||
variable_type_adhoc_count: 1,
|
||||
variable_type_datasource_count: 1,
|
||||
variable_type_custom_count: 1,
|
||||
variable_type_custom_count: 3,
|
||||
variable_type_custom_csv_count: 2,
|
||||
variable_type_custom_json_count: 1,
|
||||
variable_type_query_count: 1,
|
||||
varsWithDataSource: [
|
||||
{ type: 'query', datasource: 'cloudwatch' },
|
||||
@@ -714,7 +729,7 @@ describe('DashboardSceneSerializer', () => {
|
||||
panelCount: 6,
|
||||
rowCount: 6,
|
||||
tabCount: 4,
|
||||
templateVariableCount: 4,
|
||||
templateVariableCount: 6,
|
||||
maxNestingLevel: 3,
|
||||
dashStructure:
|
||||
'[{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"},{"kind":"panel"},{"kind":"panel"}]},{"kind":"tab","children":[]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"panel"}]}]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"}]},{"kind":"tab","children":[{"kind":"panel"}]}]}]}]',
|
||||
@@ -866,6 +881,7 @@ describe('DashboardSceneSerializer', () => {
|
||||
query: 'app1',
|
||||
skipUrlSync: false,
|
||||
allowCustomValue: true,
|
||||
valuesFormat: 'csv',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
+5
@@ -294,6 +294,7 @@ exports[`Given a scene with custom quick ranges should save quick ranges to save
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
@@ -680,6 +681,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
|
||||
"options": [],
|
||||
"query": "A,B,C,D,E,F,E,G,H,I,J,K,L",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
@@ -698,6 +700,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
|
||||
"options": [],
|
||||
"query": "Bob : 1, Rob : 2,Sod : 3, Hod : 4, Cod : 5",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1021,6 +1024,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
@@ -1381,6 +1385,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
|
||||
+1
@@ -196,6 +196,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
|
||||
"options": [],
|
||||
"query": "option1, option2",
|
||||
"skipUrlSync": false,
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -376,6 +376,7 @@ describe('sceneVariablesSetToVariables', () => {
|
||||
"options": [],
|
||||
"query": "test,test1,test2",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
}
|
||||
`);
|
||||
});
|
||||
@@ -1180,6 +1181,7 @@ describe('sceneVariablesSetToVariables', () => {
|
||||
"options": [],
|
||||
"query": "test,test1,test2",
|
||||
"skipUrlSync": false,
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -120,6 +120,9 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
|
||||
allValue: variable.state.allValue,
|
||||
includeAll: variable.state.includeAll,
|
||||
...(variable.state.allowCustomValue !== undefined && { allowCustomValue: variable.state.allowCustomValue }),
|
||||
// Ensure we persist the backend default when not specified to stay aligned with
|
||||
// transformSaveModelSchemaV2ToScene which injects 'csv' on load.
|
||||
valuesFormat: variable.state.valuesFormat ?? 'csv',
|
||||
};
|
||||
variables.push(customVariable);
|
||||
} else if (sceneUtils.isDataSourceVariable(variable)) {
|
||||
@@ -408,6 +411,7 @@ export function sceneVariablesSetToSchemaV2Variables(
|
||||
allValue: variable.state.allValue,
|
||||
includeAll: variable.state.includeAll ?? false,
|
||||
allowCustomValue: variable.state.allowCustomValue ?? true,
|
||||
valuesFormat: variable.state.valuesFormat ?? 'csv',
|
||||
},
|
||||
};
|
||||
variables.push(customVariable);
|
||||
|
||||
@@ -1169,6 +1169,57 @@
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": true,
|
||||
"current": {
|
||||
"text": "test",
|
||||
"value": "test"
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"name": "custom0",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "test",
|
||||
"value": "test"
|
||||
}
|
||||
],
|
||||
"valuesFormat": "csv",
|
||||
"query": "test",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": true,
|
||||
"current": {
|
||||
"text": "test",
|
||||
"value": "test"
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"name": "custom0",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "test",
|
||||
"value": "test",
|
||||
"properties": {
|
||||
"testProp": "test"
|
||||
}
|
||||
}
|
||||
],
|
||||
"valuesFormat": "json",
|
||||
"query": "test",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "DatasourceVariable",
|
||||
"spec": {
|
||||
|
||||
+2
-1
@@ -343,12 +343,12 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
|
||||
}
|
||||
return new AdHocFiltersVariable(adhocVariableState);
|
||||
}
|
||||
|
||||
if (variable.kind === defaultCustomVariableKind().kind) {
|
||||
return new CustomVariable({
|
||||
...commonProperties,
|
||||
value: variable.spec.current?.value ?? '',
|
||||
text: variable.spec.current?.text ?? '',
|
||||
|
||||
query: variable.spec.query,
|
||||
isMulti: variable.spec.multi,
|
||||
allValue: variable.spec.allValue || undefined,
|
||||
@@ -357,6 +357,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
|
||||
skipUrlSync: variable.spec.skipUrlSync,
|
||||
hide: transformVariableHideToEnumV1(variable.spec.hide),
|
||||
...(variable.spec.allowCustomValue !== undefined && { allowCustomValue: variable.spec.allowCustomValue }),
|
||||
valuesFormat: variable.spec.valuesFormat || 'csv',
|
||||
});
|
||||
} else if (variable.kind === defaultQueryVariableKind().kind) {
|
||||
return new QueryVariable({
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { SceneVariable } from '@grafana/scenes';
|
||||
import { VariableHide, defaultVariableModel } from '@grafana/schema';
|
||||
import { Button, LoadingPlaceholder, ConfirmModal, ModalsController, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { Button, ConfirmModal, LoadingPlaceholder, ModalsController, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { VariableDisplaySelect } from 'app/features/dashboard-scene/settings/variables/components/VariableDisplaySelect';
|
||||
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
|
||||
import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField';
|
||||
@@ -68,6 +68,8 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
|
||||
const onDisplayChange = (display: VariableHide) => variable.setState({ hide: display });
|
||||
|
||||
const isHasVariableOptions = hasVariableOptions(variable);
|
||||
const optionsForSelect = isHasVariableOptions ? variable.getOptionsForSelect(false) : [];
|
||||
const hasMultiProps = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json';
|
||||
|
||||
const onDeleteVariable = (hideModal: () => void) => () => {
|
||||
reportInteraction('Delete variable');
|
||||
@@ -123,7 +125,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
|
||||
|
||||
{EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />}
|
||||
|
||||
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
|
||||
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<Stack gap={2}>
|
||||
|
||||
+75
-1
@@ -1,9 +1,16 @@
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { CustomVariableForm } from './CustomVariableForm';
|
||||
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
const actual = jest.requireActual('@grafana/runtime');
|
||||
actual.config.featureToggles = { multiPropsVariables: true };
|
||||
return actual;
|
||||
});
|
||||
|
||||
describe('CustomVariableForm', () => {
|
||||
const onQueryChange = jest.fn();
|
||||
const onMultiChange = jest.fn();
|
||||
@@ -130,4 +137,71 @@ describe('CustomVariableForm', () => {
|
||||
expect(onMultiChange).not.toHaveBeenCalled();
|
||||
expect(onIncludeAllChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('JSON values format', () => {
|
||||
test('should render the form fields correctly', async () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<CustomVariableForm
|
||||
query="query"
|
||||
valuesFormat="json"
|
||||
multi={true}
|
||||
allowCustomValue={true}
|
||||
includeAll={true}
|
||||
allValue="custom value"
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('JSON'));
|
||||
|
||||
const multiCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
);
|
||||
const allowCustomValueCheckbox = queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
|
||||
);
|
||||
const includeAllCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
);
|
||||
const allValueInput = queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
);
|
||||
|
||||
expect(multiCheckbox).toBeInTheDocument();
|
||||
expect(multiCheckbox).toBeChecked();
|
||||
expect(includeAllCheckbox).toBeInTheDocument();
|
||||
expect(includeAllCheckbox).toBeChecked();
|
||||
|
||||
expect(allowCustomValueCheckbox).not.toBeInTheDocument();
|
||||
expect(allValueInput).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display validation error', async () => {
|
||||
const validationError = new Error('Ooops! Validation error.');
|
||||
|
||||
const { findByText } = render(
|
||||
<CustomVariableForm
|
||||
query="query"
|
||||
valuesFormat="json"
|
||||
queryValidationError={validationError}
|
||||
multi={false}
|
||||
includeAll={false}
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('JSON'));
|
||||
|
||||
const errorEl = await findByText(validationError.message);
|
||||
expect(errorEl).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+144
-1
@@ -1,7 +1,10 @@
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
import { CustomVariableModel } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Alert, FieldValidationMessage, Icon, RadioButtonGroup, Stack, TextLink, Tooltip } from '@grafana/ui';
|
||||
|
||||
import { SelectionOptionsForm } from './SelectionOptionsForm';
|
||||
import { VariableLegend } from './VariableLegend';
|
||||
@@ -9,10 +12,12 @@ import { VariableTextAreaField } from './VariableTextAreaField';
|
||||
|
||||
interface CustomVariableFormProps {
|
||||
query: string;
|
||||
valuesFormat?: CustomVariableModel['valuesFormat'];
|
||||
multi: boolean;
|
||||
allValue?: string | null;
|
||||
includeAll: boolean;
|
||||
allowCustomValue?: boolean;
|
||||
queryValidationError?: Error;
|
||||
onQueryChange: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
onMultiChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
@@ -20,9 +25,137 @@ interface CustomVariableFormProps {
|
||||
onQueryBlur?: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
onAllValueBlur?: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
|
||||
}
|
||||
|
||||
export function CustomVariableForm({
|
||||
query,
|
||||
valuesFormat,
|
||||
multi,
|
||||
allValue,
|
||||
includeAll,
|
||||
allowCustomValue,
|
||||
queryValidationError,
|
||||
onQueryChange,
|
||||
onMultiChange,
|
||||
onIncludeAllChange,
|
||||
onAllValueChange,
|
||||
onAllowCustomValueChange,
|
||||
onValuesFormatChange,
|
||||
}: CustomVariableFormProps) {
|
||||
if (!config.featureToggles.multiPropsVariables) {
|
||||
return (
|
||||
<CustomVariableFormNonMultiProps
|
||||
displayMultiPropsWarningBanner={valuesFormat === 'json'}
|
||||
query={query}
|
||||
multi={multi}
|
||||
allValue={allValue}
|
||||
includeAll={includeAll}
|
||||
allowCustomValue={allowCustomValue}
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onAllValueChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VariableLegend>
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.custom-options">Custom options</Trans>
|
||||
</VariableLegend>
|
||||
|
||||
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
|
||||
|
||||
<VariableTextAreaField
|
||||
// we don't use a controlled component so we make sure the textarea content is cleared when changing format by providing a key
|
||||
key={valuesFormat}
|
||||
name=""
|
||||
placeholder={
|
||||
valuesFormat === 'json'
|
||||
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
|
||||
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'1, 10, mykey : myvalue, myvalue, escaped\,value'
|
||||
}
|
||||
defaultValue={query}
|
||||
onBlur={onQueryChange}
|
||||
required
|
||||
width={52}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
|
||||
/>
|
||||
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
|
||||
|
||||
<VariableLegend>
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.selection-options">Selection options</Trans>
|
||||
</VariableLegend>
|
||||
<SelectionOptionsForm
|
||||
multi={multi}
|
||||
includeAll={includeAll}
|
||||
allValue={allValue}
|
||||
allowCustomValue={allowCustomValue}
|
||||
disableAllowCustomValue={valuesFormat === 'json'}
|
||||
disableCustomAllValue={valuesFormat === 'json'}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValuesFormatSelectorProps {
|
||||
valuesFormat?: CustomVariableModel['valuesFormat'];
|
||||
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
|
||||
}
|
||||
|
||||
export function ValuesFormatSelector({ valuesFormat, onValuesFormatChange }: ValuesFormatSelectorProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={1}>
|
||||
<RadioButtonGroup
|
||||
value={valuesFormat}
|
||||
onChange={onValuesFormatChange}
|
||||
options={[
|
||||
{
|
||||
value: 'csv',
|
||||
label: config.featureToggles.multiPropsVariables
|
||||
? t('dashboard-scene.custom-variable-form.name-csv-values', 'CSV')
|
||||
: t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma'),
|
||||
},
|
||||
{
|
||||
value: 'json',
|
||||
label: t('dashboard-scene.custom-variable-form.name-json-values', 'JSON'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{valuesFormat === 'json' && (
|
||||
<Tooltip
|
||||
content={
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.json-values-tooltip">
|
||||
Provide a JSON representing an array of objects, where each object can have any number of properties.
|
||||
<br />
|
||||
Check{' '}
|
||||
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
|
||||
our docs
|
||||
</TextLink>{' '}
|
||||
for more information.
|
||||
</Trans>
|
||||
}
|
||||
placement="top"
|
||||
interactive
|
||||
>
|
||||
<Icon name="info-circle" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomVariableFormNonMultiProps({
|
||||
displayMultiPropsWarningBanner,
|
||||
query,
|
||||
multi,
|
||||
allValue,
|
||||
@@ -33,13 +166,23 @@ export function CustomVariableForm({
|
||||
onIncludeAllChange,
|
||||
onAllValueChange,
|
||||
onAllowCustomValueChange,
|
||||
}: CustomVariableFormProps) {
|
||||
}: CustomVariableFormProps & { displayMultiPropsWarningBanner: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<VariableLegend>
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.custom-options">Custom options</Trans>
|
||||
</VariableLegend>
|
||||
|
||||
{displayMultiPropsWarningBanner && (
|
||||
<div style={{ maxWidth: '25%' }}>
|
||||
{/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */}
|
||||
<Alert severity="warning" title="Custom options with multi-properties are unavailable">
|
||||
This feature is temporarily disabled, sorry for any inconvenience. Please recreate these options without
|
||||
multi-properties.
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VariableTextAreaField
|
||||
name={t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma')}
|
||||
defaultValue={query}
|
||||
|
||||
+18
-13
@@ -10,7 +10,9 @@ interface SelectionOptionsFormProps {
|
||||
multi: boolean;
|
||||
includeAll: boolean;
|
||||
allowCustomValue?: boolean;
|
||||
disableAllowCustomValue?: boolean;
|
||||
allValue?: string | null;
|
||||
disableCustomAllValue?: boolean;
|
||||
onMultiChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onAllowCustomValueChange?: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onIncludeAllChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
@@ -20,8 +22,10 @@ interface SelectionOptionsFormProps {
|
||||
export function SelectionOptionsForm({
|
||||
multi,
|
||||
allowCustomValue,
|
||||
disableAllowCustomValue,
|
||||
includeAll,
|
||||
allValue,
|
||||
disableCustomAllValue,
|
||||
onMultiChange,
|
||||
onAllowCustomValueChange,
|
||||
onIncludeAllChange,
|
||||
@@ -39,18 +43,19 @@ export function SelectionOptionsForm({
|
||||
onChange={onMultiChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch}
|
||||
/>
|
||||
{onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup
|
||||
<VariableCheckboxField
|
||||
value={allowCustomValue ?? true}
|
||||
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
|
||||
description={t(
|
||||
'dashboard-scene.selection-options-form.description-enables-users-custom-values',
|
||||
'Enables users to add custom values to the list'
|
||||
)}
|
||||
onChange={onAllowCustomValueChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
|
||||
/>
|
||||
)}
|
||||
{!disableAllowCustomValue &&
|
||||
onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup
|
||||
<VariableCheckboxField
|
||||
value={allowCustomValue ?? true}
|
||||
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
|
||||
description={t(
|
||||
'dashboard-scene.selection-options-form.description-enables-users-custom-values',
|
||||
'Enables users to add custom values to the list'
|
||||
)}
|
||||
onChange={onAllowCustomValueChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
|
||||
/>
|
||||
)}
|
||||
<VariableCheckboxField
|
||||
value={includeAll}
|
||||
name={t('dashboard-scene.selection-options-form.name-include-all-option', 'Include All option')}
|
||||
@@ -61,7 +66,7 @@ export function SelectionOptionsForm({
|
||||
onChange={onIncludeAllChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch}
|
||||
/>
|
||||
{includeAll && (
|
||||
{!disableCustomAllValue && includeAll && (
|
||||
<VariableTextField
|
||||
defaultValue={allValue ?? ''}
|
||||
onBlur={onAllValueChange}
|
||||
|
||||
+4
@@ -38,6 +38,10 @@ export function VariableDisplaySelect({ onChange, display, type, minWidth = 52 }
|
||||
{
|
||||
value: VariableHide.hideVariable,
|
||||
label: t('dashboard-scene.variable-display-select.options.hidden.label', 'Hidden'),
|
||||
description: t(
|
||||
'dashboard-scene.variable-display-select.options.hidden.description',
|
||||
'Only visible in edit mode'
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
|
||||
+93
-18
@@ -1,17 +1,94 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { MouseEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { MouseEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { VariableValueOption } from '@grafana/scenes';
|
||||
import { Button, InlineFieldRow, InlineLabel, useStyles2, Text } from '@grafana/ui';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { VariableValueOption, VariableValueOptionProperties } from '@grafana/scenes';
|
||||
import { Button, InlineFieldRow, InlineLabel, InteractiveTable, Text, useStyles2 } from '@grafana/ui';
|
||||
|
||||
export interface VariableValuesPreviewProps {
|
||||
export interface Props {
|
||||
options: VariableValueOption[];
|
||||
hasMultiProps?: boolean;
|
||||
}
|
||||
|
||||
export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) => {
|
||||
export const VariableValuesPreview = ({ options, hasMultiProps }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const hasOptions = options.length > 0;
|
||||
const displayMultiPropsPreview = config.featureToggles.multiPropsVariables && hasMultiProps;
|
||||
|
||||
return (
|
||||
<div className={styles.previewContainer} style={{ gap: '8px' }}>
|
||||
<Text variant="bodySmall" weight="medium">
|
||||
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values" values={{ count: options.length }}>
|
||||
Preview of values ({'{{count}}'})
|
||||
</Trans>
|
||||
{hasOptions && displayMultiPropsPreview && <VariableValuesWithPropsPreview options={options} />}
|
||||
{hasOptions && !displayMultiPropsPreview && <VariableValuesWithoutPropsPreview options={options} />}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function VariableValuesWithPropsPreview({ options }: { options: VariableValueOption[] }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { data, columns } = useMemo(() => {
|
||||
const data = options.map(({ label, value, properties }) => ({
|
||||
label: String(label),
|
||||
value: String(value),
|
||||
...flattenProperties(properties),
|
||||
}));
|
||||
|
||||
return {
|
||||
data,
|
||||
columns: Object.keys(data[0] ?? {}).map((id) => ({
|
||||
id,
|
||||
// see https://github.com/TanStack/table/issues/1671
|
||||
header: unsanitizeKey(id),
|
||||
sortType: 'alphanumeric' as const,
|
||||
})),
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
return (
|
||||
<InteractiveTable
|
||||
className={styles.table}
|
||||
columns={columns}
|
||||
data={data}
|
||||
getRowId={(r) => String(r.value)}
|
||||
pageSize={8}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sanitizeKey = (key: string) => key.replace(/\./g, '__dot__');
|
||||
const unsanitizeKey = (key: string) => key.replace(/__dot__/g, '.');
|
||||
|
||||
function flattenProperties(properties?: VariableValueOptionProperties, path = ''): Record<string, string> {
|
||||
if (properties === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
const newPath = path ? `${path}.${key}` : key;
|
||||
|
||||
if (typeof value === 'object') {
|
||||
Object.assign(result, flattenProperties(value, newPath));
|
||||
} else {
|
||||
// see https://github.com/TanStack/table/issues/1671
|
||||
result[sanitizeKey(newPath)] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function VariableValuesWithoutPropsPreview({ options }: { options: VariableValueOption[] }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [previewLimit, setPreviewLimit] = useState(20);
|
||||
const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]);
|
||||
const showMoreOptions = useCallback(
|
||||
@@ -21,18 +98,10 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
|
||||
},
|
||||
[previewLimit, setPreviewLimit]
|
||||
);
|
||||
const styles = useStyles2(getStyles);
|
||||
useEffect(() => setPreviewOptions(options.slice(0, previewLimit)), [previewLimit, options]);
|
||||
|
||||
if (!previewOptions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', marginTop: '16px' }}>
|
||||
<Text variant="bodySmall" weight="medium">
|
||||
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
|
||||
</Text>
|
||||
<>
|
||||
<InlineFieldRow>
|
||||
{previewOptions.map((o, index) => (
|
||||
<InlineFieldRow key={`${o.value}-${index}`} className={styles.optionContainer}>
|
||||
@@ -49,16 +118,17 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
|
||||
</Button>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
VariableValuesPreview.displayName = 'VariableValuesPreview';
|
||||
}
|
||||
VariableValuesWithoutPropsPreview.displayName = 'VariableValuesWithoutPropsPreview';
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrapper: css({
|
||||
previewContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
optionContainer: css({
|
||||
@@ -71,5 +141,10 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '50vw',
|
||||
}),
|
||||
table: css({
|
||||
td: css({
|
||||
padding: theme.spacing(0.5, 1),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+207
-93
@@ -5,117 +5,231 @@ import { CustomVariable } from '@grafana/scenes';
|
||||
|
||||
import { CustomVariableEditor } from './CustomVariableEditor';
|
||||
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
const actual = jest.requireActual('@grafana/runtime');
|
||||
actual.config.featureToggles = { multiPropsVariables: true };
|
||||
return actual;
|
||||
});
|
||||
|
||||
function setup(options: Partial<ConstructorParameters<typeof CustomVariable>[0]> = {}) {
|
||||
return {
|
||||
variable: new CustomVariable({
|
||||
name: 'customVar',
|
||||
...options,
|
||||
}),
|
||||
onRunQuery: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderEditor(ui: React.ReactNode) {
|
||||
const renderResult = render(ui);
|
||||
|
||||
const elements = {
|
||||
formatButton: (label: string) => renderResult.queryByLabelText(label) as HTMLElement,
|
||||
queryInput: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
|
||||
) as HTMLTextAreaElement,
|
||||
multiValueCheckbox: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
) as HTMLInputElement,
|
||||
allowCustomValueCheckbox: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
|
||||
) as HTMLInputElement,
|
||||
includeAllCheckbox: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
) as HTMLInputElement,
|
||||
customAllValueInput: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
) as HTMLInputElement,
|
||||
};
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
elements,
|
||||
actions: {
|
||||
updateValuesInput(newQuery: string) {
|
||||
fireEvent.change(elements.queryInput(), { target: { value: newQuery } });
|
||||
fireEvent.blur(elements.queryInput());
|
||||
},
|
||||
changeValuesFormat(newFormat: 'csv' | 'json') {
|
||||
const targetLabel = newFormat === 'json' ? 'JSON' : 'CSV';
|
||||
|
||||
const formatButton = elements.formatButton(targetLabel);
|
||||
if (formatButton === null) {
|
||||
throw new Error(`Unable to fire a "click" event - button with label "${targetLabel}" not found in DOM`);
|
||||
}
|
||||
|
||||
fireEvent.click(formatButton);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('CustomVariableEditor', () => {
|
||||
it('should render the CustomVariableForm with correct initial values', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
allValue: 'test',
|
||||
describe('CSV values format', () => {
|
||||
it('should render CustomVariableForm with the correct initial values', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
allowCustomValue: true,
|
||||
allValue: 'all',
|
||||
});
|
||||
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(elements.queryInput().value).toBe('test, test2');
|
||||
expect(elements.multiValueCheckbox().checked).toBe(true);
|
||||
expect(elements.allowCustomValueCheckbox().checked).toBe(true);
|
||||
expect(elements.includeAllCheckbox().checked).toBe(true);
|
||||
expect(elements.customAllValueInput().value).toBe('all');
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
it('should update the variable state when some input values change ("Multi-value", "Allow custom values" & "Include All option")', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: false,
|
||||
allowCustomValue: false,
|
||||
includeAll: false,
|
||||
});
|
||||
|
||||
const queryInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
|
||||
) as HTMLInputElement;
|
||||
const allValueInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
) as HTMLInputElement;
|
||||
const multiCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
) as HTMLInputElement;
|
||||
const includeAllCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
) as HTMLInputElement;
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(queryInput.value).toBe('test, test2');
|
||||
expect(allValueInput.value).toBe('test');
|
||||
expect(multiCheckbox.checked).toBe(true);
|
||||
expect(includeAllCheckbox.checked).toBe(true);
|
||||
expect(elements.multiValueCheckbox().checked).toBe(false);
|
||||
expect(elements.allowCustomValueCheckbox().checked).toBe(false);
|
||||
expect(elements.includeAllCheckbox().checked).toBe(false);
|
||||
// include-all-custom input appears after include-all checkbox is checked only
|
||||
expect(elements.customAllValueInput()).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(elements.multiValueCheckbox());
|
||||
fireEvent.click(elements.allowCustomValueCheckbox());
|
||||
fireEvent.click(elements.includeAllCheckbox());
|
||||
|
||||
expect(variable.state.isMulti).toBe(true);
|
||||
expect(variable.state.allowCustomValue).toBe(true);
|
||||
expect(variable.state.includeAll).toBe(true);
|
||||
expect(elements.customAllValueInput()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('when the values textarea loses focus after its value has changed', () => {
|
||||
it('should update the query in the variable state and call the onRunQuery callback', async () => {
|
||||
const { variable, onRunQuery } = setup({ query: 'test, test2', value: 'test' });
|
||||
|
||||
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
actions.updateValuesInput('test3, test4');
|
||||
|
||||
expect(variable.state.query).toBe('test3, test4');
|
||||
expect(onRunQuery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the "Custom all value" input loses focus after its value has changed', () => {
|
||||
it('should update the variable state', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
});
|
||||
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
fireEvent.change(elements.customAllValueInput(), { target: { value: 'new custom all' } });
|
||||
fireEvent.blur(elements.customAllValueInput());
|
||||
|
||||
expect(variable.state.allValue).toBe('new custom all');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the variable state when input values change', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
describe('JSON values format', () => {
|
||||
const initialJsonQuery = `[
|
||||
{"value":1,"text":"Development","aws":"dev","azure":"development"},
|
||||
{"value":2,"text":"Production","aws":"prod","azure":"production"}
|
||||
]`;
|
||||
|
||||
it('should render CustomVariableForm with the correct initial values', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
valuesFormat: 'json',
|
||||
query: initialJsonQuery,
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
});
|
||||
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(elements.queryInput().value).toBe(initialJsonQuery);
|
||||
expect(elements.multiValueCheckbox().checked).toBe(true);
|
||||
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
|
||||
expect(elements.includeAllCheckbox().checked).toBe(true);
|
||||
expect(elements.customAllValueInput()).not.toBeInTheDocument();
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
describe('when the values textarea loses focus after its value has changed', () => {
|
||||
describe('if the value is valid JSON', () => {
|
||||
it('should update the query in the variable state and call the onRunQuery callback', async () => {
|
||||
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
|
||||
|
||||
const multiCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
);
|
||||
const includeAllCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
);
|
||||
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
const allowCustomValueCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
|
||||
);
|
||||
actions.updateValuesInput('[]');
|
||||
|
||||
// It include-all-custom input appears after include-all checkbox is checked only
|
||||
expect(() =>
|
||||
getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput)
|
||||
).toThrow('Unable to find an element');
|
||||
expect(variable.state.query).toBe('[]');
|
||||
expect(onRunQuery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(allowCustomValueCheckbox);
|
||||
describe('if the value is NOT valid JSON', () => {
|
||||
it('should display a validation error message and neither update the query in the variable state nor call the onRunQuery callback', async () => {
|
||||
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
|
||||
|
||||
fireEvent.click(multiCheckbox);
|
||||
const { actions, getByRole } = renderEditor(
|
||||
<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />
|
||||
);
|
||||
|
||||
fireEvent.click(includeAllCheckbox);
|
||||
const allValueInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
);
|
||||
actions.updateValuesInput('[x]');
|
||||
|
||||
expect(variable.state.isMulti).toBe(true);
|
||||
expect(variable.state.includeAll).toBe(true);
|
||||
expect(variable.state.allowCustomValue).toBe(false);
|
||||
expect(allValueInput).toBeInTheDocument();
|
||||
expect(getByRole('alert')).toHaveTextContent(`Unexpected token 'x', "[x]" is not valid JSON`);
|
||||
expect(variable.state.query).toBe(initialJsonQuery);
|
||||
expect(onRunQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call update query and re-run query when input loses focus', async () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
describe('when switching values format', () => {
|
||||
it('should switch the visibility of the proper form inputs ("Allow custom values" and "Custom all value")', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
valuesFormat: 'csv',
|
||||
query: '',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
allowCustomValue: true,
|
||||
allValue: '',
|
||||
});
|
||||
|
||||
const { elements, actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
|
||||
expect(elements.customAllValueInput()).toBeInTheDocument();
|
||||
|
||||
actions.changeValuesFormat('json');
|
||||
|
||||
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
|
||||
expect(elements.customAllValueInput()).not.toBeInTheDocument();
|
||||
|
||||
actions.changeValuesFormat('csv');
|
||||
|
||||
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
|
||||
expect(elements.customAllValueInput()).toBeInTheDocument();
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput);
|
||||
fireEvent.change(queryInput, { target: { value: 'test3, test4' } });
|
||||
fireEvent.blur(queryInput);
|
||||
|
||||
expect(onRunQuery).toHaveBeenCalled();
|
||||
expect(variable.state.query).toBe('test3, test4');
|
||||
});
|
||||
|
||||
it('should update the variable state when all-custom-value input loses focus', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
const allValueInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(allValueInput, { target: { value: 'new custom all' } });
|
||||
fireEvent.blur(allValueInput);
|
||||
|
||||
expect(variable.state.allValue).toBe('new custom all');
|
||||
});
|
||||
});
|
||||
|
||||
+105
-5
@@ -1,16 +1,42 @@
|
||||
import { FormEvent, useCallback } from 'react';
|
||||
import { isObject } from 'lodash';
|
||||
import { FormEvent, useCallback, useState } from 'react';
|
||||
|
||||
import { CustomVariable } from '@grafana/scenes';
|
||||
import { CustomVariableModel, shallowCompare } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { CustomVariable, SceneVariable } from '@grafana/scenes';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { CustomVariableForm } from '../../components/CustomVariableForm';
|
||||
|
||||
import { PaneItem } from './PaneItem';
|
||||
|
||||
interface CustomVariableEditorProps {
|
||||
variable: CustomVariable;
|
||||
onRunQuery: () => void;
|
||||
}
|
||||
|
||||
export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) {
|
||||
const { query, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
|
||||
const { query, valuesFormat, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
|
||||
const [queryValidationError, setQueryValidationError] = useState<Error>();
|
||||
|
||||
const [prevQuery, setPrevQuery] = useState('');
|
||||
const onValuesFormatChange = useCallback(
|
||||
(format: CustomVariableModel['valuesFormat']) => {
|
||||
variable.setState({ query: prevQuery });
|
||||
variable.setState({ value: isMulti ? [] : undefined });
|
||||
variable.setState({ valuesFormat: format });
|
||||
variable.setState({ allowCustomValue: false });
|
||||
variable.setState({ allValue: undefined });
|
||||
onRunQuery();
|
||||
|
||||
setQueryValidationError(undefined);
|
||||
if (query !== prevQuery) {
|
||||
setPrevQuery(query);
|
||||
}
|
||||
},
|
||||
[isMulti, onRunQuery, prevQuery, query, variable]
|
||||
);
|
||||
|
||||
const onMultiChange = useCallback(
|
||||
(event: FormEvent<HTMLInputElement>) => {
|
||||
@@ -28,10 +54,24 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
(event: FormEvent<HTMLTextAreaElement>) => {
|
||||
setPrevQuery('');
|
||||
|
||||
if (config.featureToggles.multiPropsVariables && valuesFormat === 'json') {
|
||||
const validationError = validateJsonQuery(event.currentTarget.value.trim());
|
||||
setQueryValidationError(validationError);
|
||||
if (validationError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.featureToggles.multiPropsVariables) {
|
||||
variable.setState({ valuesFormat: 'csv' });
|
||||
}
|
||||
|
||||
variable.setState({ query: event.currentTarget.value });
|
||||
onRunQuery();
|
||||
},
|
||||
[variable, onRunQuery]
|
||||
[valuesFormat, variable, onRunQuery]
|
||||
);
|
||||
|
||||
const onAllValueChange = useCallback(
|
||||
@@ -51,15 +91,75 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
|
||||
return (
|
||||
<CustomVariableForm
|
||||
query={query ?? ''}
|
||||
valuesFormat={valuesFormat ?? 'csv'}
|
||||
multi={!!isMulti}
|
||||
allValue={allValue ?? ''}
|
||||
includeAll={!!includeAll}
|
||||
allowCustomValue={allowCustomValue}
|
||||
queryValidationError={queryValidationError}
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onQueryChange={onQueryChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
onValuesFormatChange={onValuesFormatChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneItemDescriptor[] {
|
||||
if (!(variable instanceof CustomVariable)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
|
||||
id: 'custom-variable-values',
|
||||
render: ({ props }) => <PaneItem id={props.id} variable={variable} />,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export const validateJsonQuery = (query: string): Error | undefined => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = JSON.parse(query);
|
||||
|
||||
if (!Array.isArray(options)) {
|
||||
throw new Error('Enter a valid JSON array of objects');
|
||||
}
|
||||
|
||||
if (!options.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let errorIndex = options.findIndex((item) => !isObject(item));
|
||||
if (errorIndex !== -1) {
|
||||
throw new Error(`All items must be objects. The item at index ${errorIndex} is not an object.`);
|
||||
}
|
||||
|
||||
const keys = Object.keys(options[0]);
|
||||
if (!keys.includes('value')) {
|
||||
throw new Error('Each object in the array must include at least a "value" property');
|
||||
}
|
||||
if (keys.includes('')) {
|
||||
throw new Error('Object property names cannot be empty strings');
|
||||
}
|
||||
|
||||
errorIndex = options.findIndex((o) => !shallowCompare(keys, Object.keys(o)));
|
||||
if (errorIndex !== -1) {
|
||||
throw new Error(
|
||||
`All objects must have the same set of properties. The object at index ${errorIndex} does not match the expected properties`
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return error as Error;
|
||||
}
|
||||
};
|
||||
|
||||
+116
-43
@@ -1,23 +1,43 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { FormEvent, useMemo, useRef, useState } from 'react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { CustomVariableModel } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { CustomVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes';
|
||||
import { Button, Modal, Stack } from '@grafana/ui';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { CustomVariable } from '@grafana/scenes';
|
||||
import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana/ui';
|
||||
|
||||
import { dashboardEditActions } from '../../../../edit-pane/shared';
|
||||
import { VariableStaticOptionsForm, VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
|
||||
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
|
||||
import { ValuesFormatSelector } from '../../components/CustomVariableForm';
|
||||
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
|
||||
|
||||
import { validateJsonQuery } from './CustomVariableEditor';
|
||||
import { ModalEditorNonMultiProps } from './ModalEditorNonMultiProps';
|
||||
|
||||
interface ModalEditorProps {
|
||||
variable: CustomVariable;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ModalEditor(props: ModalEditorProps) {
|
||||
const { formRef, onCloseModal, options, onChangeOptions, onAddNewOption, onSaveOptions } = useModalEditor(props);
|
||||
if (!config.featureToggles.multiPropsVariables) {
|
||||
return <ModalEditorNonMultiProps {...props} />;
|
||||
}
|
||||
return <ModalEditorMultiProps {...props} />;
|
||||
}
|
||||
|
||||
function ModalEditorMultiProps(props: ModalEditorProps) {
|
||||
const {
|
||||
valuesFormat,
|
||||
query,
|
||||
queryValidationError,
|
||||
options,
|
||||
onCloseModal,
|
||||
onValuesFormatChange,
|
||||
onQueryChange,
|
||||
onSaveOptions,
|
||||
} = useModalEditor(props);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -28,10 +48,31 @@ export function ModalEditor(props: ModalEditorProps) {
|
||||
closeOnEscape={false}
|
||||
>
|
||||
<Stack direction="column" gap={2}>
|
||||
<VariableStaticOptionsForm options={options} onChange={onChangeOptions} ref={formRef} isInModal />
|
||||
<VariableValuesPreview options={options} />
|
||||
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
|
||||
<div>
|
||||
<TextArea
|
||||
id={valuesFormat}
|
||||
key={valuesFormat}
|
||||
rows={4}
|
||||
defaultValue={query}
|
||||
onChange={onQueryChange}
|
||||
placeholder={
|
||||
valuesFormat === 'json'
|
||||
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
|
||||
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'1, 10, mykey : myvalue, myvalue, escaped\,value'
|
||||
}
|
||||
required
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
|
||||
/>
|
||||
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
|
||||
</div>
|
||||
<div>
|
||||
<VariableValuesPreview options={options} hasMultiProps={valuesFormat === 'json'} />
|
||||
</div>
|
||||
</Stack>
|
||||
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={onAddNewOption} />}>
|
||||
<Modal.ButtonRow>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
@@ -43,6 +84,7 @@ export function ModalEditor(props: ModalEditorProps) {
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onSaveOptions}
|
||||
disabled={Boolean(queryValidationError)}
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.applyButton}
|
||||
>
|
||||
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.apply">Apply</Trans>
|
||||
@@ -53,51 +95,82 @@ export function ModalEditor(props: ModalEditorProps) {
|
||||
}
|
||||
|
||||
function useModalEditor({ variable, onClose }: ModalEditorProps) {
|
||||
const { query } = variable.state;
|
||||
const [options, setOptions] = useState(() => transformQueryToOptions(variable, query));
|
||||
const initialQueryRef = useRef(query);
|
||||
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
|
||||
const initialValuesFormatRef = useRef(variable.state.valuesFormat);
|
||||
const initialQueryRef = useRef(variable.state.query);
|
||||
const [valuesFormat, setValuesFormat] = useState(() => variable.state.valuesFormat);
|
||||
const [query, setQuery] = useState(() => variable.state.query);
|
||||
const [prevQuery, setPrevQuery] = useState('');
|
||||
const [queryValidationError, setQueryValidationError] = useState<Error>();
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (valuesFormat === 'csv') {
|
||||
return variable.transformCsvStringToOptions(query, false).map(({ label, value }) => ({
|
||||
value,
|
||||
label: value === label ? '' : label,
|
||||
}));
|
||||
} else {
|
||||
return variable.transformJsonToOptions(query);
|
||||
}
|
||||
}, [query, valuesFormat, variable]);
|
||||
|
||||
return {
|
||||
formRef,
|
||||
onCloseModal: onClose,
|
||||
valuesFormat,
|
||||
query,
|
||||
queryValidationError,
|
||||
options,
|
||||
onChangeOptions: setOptions,
|
||||
onAddNewOption() {
|
||||
formRef.current?.addItem();
|
||||
onCloseModal: onClose,
|
||||
onValuesFormatChange(newFormat: CustomVariableModel['valuesFormat']) {
|
||||
setQuery(prevQuery);
|
||||
setValuesFormat(newFormat);
|
||||
setQueryValidationError(undefined);
|
||||
if (query !== prevQuery) {
|
||||
setPrevQuery(query);
|
||||
}
|
||||
},
|
||||
onQueryChange(event: FormEvent<HTMLTextAreaElement>) {
|
||||
setPrevQuery('');
|
||||
if (valuesFormat === 'json') {
|
||||
const validationError = validateJsonQuery(event.currentTarget.value);
|
||||
setQueryValidationError(validationError);
|
||||
if (validationError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setQuery(event.currentTarget.value);
|
||||
},
|
||||
onSaveOptions() {
|
||||
dashboardEditActions.edit({
|
||||
source: variable,
|
||||
description: t('dashboard.edit-pane.variable.custom-options.change-value', 'Change variable value'),
|
||||
perform: () => {
|
||||
variable.setState({ query: transformOptionsToQuery(options) });
|
||||
lastValueFrom(variable.validateAndUpdate!());
|
||||
description: t('dashboard-scene.use-modal-editor.description.change-variable-query', 'Change variable query'),
|
||||
perform: async () => {
|
||||
if (!config.featureToggles.multiPropsVariables) {
|
||||
variable.setState({ valuesFormat: 'csv', query, value: undefined });
|
||||
} else {
|
||||
variable.setState({ valuesFormat, query, value: undefined });
|
||||
}
|
||||
|
||||
if (valuesFormat === 'json') {
|
||||
variable.setState({ allowCustomValue: false, allValue: undefined });
|
||||
}
|
||||
|
||||
await lastValueFrom(variable.validateAndUpdate!());
|
||||
},
|
||||
undo: () => {
|
||||
variable.setState({ query: initialQueryRef.current });
|
||||
lastValueFrom(variable.validateAndUpdate!());
|
||||
undo: async () => {
|
||||
variable.setState({
|
||||
valuesFormat: initialValuesFormatRef.current,
|
||||
query: initialQueryRef.current,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
if (initialValuesFormatRef.current === 'json') {
|
||||
variable.setState({ allowCustomValue: false });
|
||||
variable.setState({ allValue: undefined });
|
||||
}
|
||||
|
||||
await lastValueFrom(variable.validateAndUpdate!());
|
||||
},
|
||||
});
|
||||
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const transformQueryToOptions = (variable: ModalEditorProps['variable'], query: string) =>
|
||||
variable.transformCsvStringToOptions(query, false).map(({ label, value }) => ({
|
||||
value,
|
||||
label: value === label ? '' : label,
|
||||
}));
|
||||
|
||||
const formatOption = (option: VariableValueOption) => {
|
||||
if (!option.label || option.label === option.value) {
|
||||
return escapeEntities(option.value);
|
||||
}
|
||||
return `${escapeEntities(option.label)} : ${escapeEntities(String(option.value))}`;
|
||||
};
|
||||
|
||||
const escapeEntities = (text: VariableValueSingle) => String(text).trim().replaceAll(',', '\\,');
|
||||
|
||||
const transformOptionsToQuery = (options: VariableValueOption[]) => options.map(formatOption).join(', ');
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { CustomVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes';
|
||||
import { Alert, Button, Modal, Stack } from '@grafana/ui';
|
||||
|
||||
import { dashboardEditActions } from '../../../../edit-pane/shared';
|
||||
import { VariableStaticOptionsForm, VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
|
||||
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
|
||||
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
|
||||
|
||||
interface ModalEditorProps {
|
||||
variable: CustomVariable;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ModalEditorNonMultiProps(props: ModalEditorProps) {
|
||||
const {
|
||||
displayMultiPropsWarningBanner,
|
||||
formRef,
|
||||
onCloseModal,
|
||||
options,
|
||||
onChangeOptions,
|
||||
onAddNewOption,
|
||||
onSaveOptions,
|
||||
} = useModalEditor(props);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('dashboard.edit-pane.variable.custom-options.modal-title', 'Custom Variable')}
|
||||
isOpen={true}
|
||||
onDismiss={onCloseModal}
|
||||
closeOnBackdropClick={false}
|
||||
closeOnEscape={false}
|
||||
>
|
||||
{displayMultiPropsWarningBanner && (
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
<Alert severity="warning" title="Custom options with multi-properties are unavailable">
|
||||
This feature is temporarily disabled, sorry for any inconvenience. Please recreate these options without
|
||||
multi-properties.
|
||||
</Alert>
|
||||
)}
|
||||
<Stack direction="column" gap={2}>
|
||||
<VariableStaticOptionsForm options={options} onChange={onChangeOptions} ref={formRef} isInModal />
|
||||
<VariableValuesPreview options={options} />
|
||||
</Stack>
|
||||
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={onAddNewOption} />}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
onClick={onCloseModal}
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.closeButton}
|
||||
>
|
||||
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.discard">Discard</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onSaveOptions}
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.applyButton}
|
||||
>
|
||||
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.apply">Apply</Trans>
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function useModalEditor({ variable, onClose }: ModalEditorProps) {
|
||||
const { query, valuesFormat } = variable.state;
|
||||
const [options, setOptions] = useState(() => transformQueryToOptions(variable, query));
|
||||
const initialQueryRef = useRef(query);
|
||||
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
|
||||
|
||||
return {
|
||||
displayMultiPropsWarningBanner: valuesFormat === 'json',
|
||||
formRef,
|
||||
onCloseModal: onClose,
|
||||
options,
|
||||
onChangeOptions: setOptions,
|
||||
onAddNewOption() {
|
||||
formRef.current?.addItem();
|
||||
},
|
||||
onSaveOptions() {
|
||||
dashboardEditActions.edit({
|
||||
source: variable,
|
||||
description: t('dashboard.edit-pane.variable.custom-options.change-value', 'Change variable value'),
|
||||
perform: () => {
|
||||
variable.setState({ query: transformOptionsToQuery(options) });
|
||||
lastValueFrom(variable.validateAndUpdate!());
|
||||
},
|
||||
undo: () => {
|
||||
variable.setState({ query: initialQueryRef.current });
|
||||
lastValueFrom(variable.validateAndUpdate!());
|
||||
},
|
||||
});
|
||||
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const transformQueryToOptions = (variable: ModalEditorProps['variable'], query: string) =>
|
||||
variable.transformCsvStringToOptions(query, false).map(({ label, value }) => ({
|
||||
value,
|
||||
label: value === label ? '' : label,
|
||||
}));
|
||||
|
||||
const formatOption = (option: VariableValueOption) => {
|
||||
if (!option.label || option.label === option.value) {
|
||||
return escapeEntities(option.value);
|
||||
}
|
||||
return `${escapeEntities(option.label)} : ${escapeEntities(String(option.value))}`;
|
||||
};
|
||||
|
||||
const escapeEntities = (text: VariableValueSingle) => String(text).trim().replaceAll(',', '\\,');
|
||||
|
||||
const transformOptionsToQuery = (options: VariableValueOption[]) => options.map(formatOption).join(', ');
|
||||
+15
-1
@@ -1,11 +1,19 @@
|
||||
import { useCallback, useId, useMemo, useRef } from 'react';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { MultiValueVariable, SceneVariableValueChangedEvent } from '@grafana/scenes';
|
||||
import { Input, Switch } from '@grafana/ui';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
function useVariableHasMultiProps(variable: MultiValueVariable) {
|
||||
const state = variable.useState();
|
||||
const hasMultiProps =
|
||||
config.featureToggles.multiPropsVariables && 'valuesFormat' in state && state.valuesFormat === 'json';
|
||||
return hasMultiProps;
|
||||
}
|
||||
|
||||
export function useVariableSelectionOptionsCategory(variable: MultiValueVariable): OptionsPaneCategoryDescriptor {
|
||||
const multiValueId = useId();
|
||||
const includeAllId = useId();
|
||||
@@ -45,7 +53,9 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
|
||||
'A wildcard regex or other value to represent All'
|
||||
),
|
||||
useShowIf: () => {
|
||||
return variable.useState().includeAll ?? false;
|
||||
const state = variable.useState();
|
||||
const hasMultiProps = useVariableHasMultiProps(variable);
|
||||
return hasMultiProps ? false : (state.includeAll ?? false);
|
||||
},
|
||||
render: (descriptor) => <CustomAllValueInput id={descriptor.props.id} variable={variable} />,
|
||||
})
|
||||
@@ -58,6 +68,10 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
|
||||
'dashboard.edit-pane.variable.selection-options.allow-custom-values-description',
|
||||
'Enables users to enter values'
|
||||
),
|
||||
useShowIf: () => {
|
||||
const hasMultiProps = useVariableHasMultiProps(variable);
|
||||
return !hasMultiProps;
|
||||
},
|
||||
render: (descriptor) => <AllowCustomSwitch id={descriptor.props.id} variable={variable} />,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ export function getLocalVariableValueSet(
|
||||
name: variable.state.name,
|
||||
value,
|
||||
text,
|
||||
properties: variable.state.options.find((o) => o.value === value)?.properties,
|
||||
isMulti: variable.state.isMulti,
|
||||
includeAll: variable.state.includeAll,
|
||||
}),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user