Merge branch 'main' into kristina/rtk-corr

This commit is contained in:
Kristina Durivage
2025-12-24 14:09:51 -06:00
95 changed files with 1800 additions and 459 deletions
+1 -1
View File
@@ -157,7 +157,7 @@ require (
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/google/wire v0.7.0 // indirect
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // indirect
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect
+2 -2
View File
@@ -619,8 +619,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0=
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.25.5
require (
github.com/go-kit/log v0.2.1
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4
github.com/grafana/grafana-app-sdk v0.48.7
github.com/grafana/grafana-app-sdk/logging v0.48.7
+2 -2
View File
@@ -243,8 +243,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0=
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI=
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo=
@@ -31,6 +31,10 @@ const (
maxLimit = 1000
Namespace = "grafana"
Subsystem = "alerting"
// LogQL field path for alert rule UID after JSON parsing.
// Loki flattens nested JSON fields with underscores: alert.labels.__alert_rule_uid__ -> alert_labels___alert_rule_uid__
lokiAlertRuleUIDField = "alert_labels___alert_rule_uid__"
)
var (
@@ -111,13 +115,13 @@ func buildQuery(query Query) (string, error) {
fmt.Sprintf(`%s=%q`, historian.LabelFrom, historian.LabelFromValue),
}
if query.RuleUID != nil {
selectors = append(selectors,
fmt.Sprintf(`%s=%q`, historian.LabelRuleUID, *query.RuleUID))
}
logql := fmt.Sprintf(`{%s} | json`, strings.Join(selectors, `,`))
// Add ruleUID filter as JSON line filter if specified.
if query.RuleUID != nil && *query.RuleUID != "" {
logql += fmt.Sprintf(` | %s = %q`, lokiAlertRuleUIDField, *query.RuleUID)
}
// Add receiver filter if specified.
if query.Receiver != nil && *query.Receiver != "" {
logql += fmt.Sprintf(` | receiver = %q`, *query.Receiver)
@@ -211,16 +215,13 @@ func parseLokiEntry(s lokiclient.Sample) (Entry, error) {
groupLabels = make(map[string]string)
}
alerts := make([]EntryAlert, len(lokiEntry.Alerts))
for i, a := range lokiEntry.Alerts {
alerts[i] = EntryAlert{
Status: a.Status,
Labels: a.Labels,
Annotations: a.Annotations,
StartsAt: a.StartsAt,
EndsAt: a.EndsAt,
}
}
alerts := []EntryAlert{{
Status: lokiEntry.Alert.Status,
Labels: lokiEntry.Alert.Labels,
Annotations: lokiEntry.Alert.Annotations,
StartsAt: lokiEntry.Alert.StartsAt,
EndsAt: lokiEntry.Alert.EndsAt,
}}
return Entry{
Timestamp: s.T,
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/grafana/alerting/models"
"github.com/grafana/alerting/notify/historian"
"github.com/grafana/alerting/notify/historian/lokiclient"
"github.com/grafana/grafana-app-sdk/logging"
@@ -133,9 +134,8 @@ func TestBuildQuery(t *testing.T) {
query: Query{
RuleUID: stringPtr("test-rule-uid"),
},
expected: fmt.Sprintf(`{%s=%q,%s=%q} | json`,
historian.LabelFrom, historian.LabelFromValue,
historian.LabelRuleUID, "test-rule-uid"),
expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid"`,
historian.LabelFrom, historian.LabelFromValue),
},
{
name: "query with receiver filter",
@@ -143,9 +143,8 @@ func TestBuildQuery(t *testing.T) {
RuleUID: stringPtr("test-rule-uid"),
Receiver: stringPtr("email-receiver"),
},
expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | receiver = "email-receiver"`,
historian.LabelFrom, historian.LabelFromValue,
historian.LabelRuleUID, "test-rule-uid"),
expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | receiver = "email-receiver"`,
historian.LabelFrom, historian.LabelFromValue),
},
{
name: "query with status filter",
@@ -153,9 +152,8 @@ func TestBuildQuery(t *testing.T) {
RuleUID: stringPtr("test-rule-uid"),
Status: createStatusPtr(v0alpha1.CreateNotificationqueryRequestNotificationStatusFiring),
},
expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | status = "firing"`,
historian.LabelFrom, historian.LabelFromValue,
historian.LabelRuleUID, "test-rule-uid"),
expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | status = "firing"`,
historian.LabelFrom, historian.LabelFromValue),
},
{
name: "query with success outcome filter",
@@ -163,9 +161,8 @@ func TestBuildQuery(t *testing.T) {
RuleUID: stringPtr("test-rule-uid"),
Outcome: outcomePtr(v0alpha1.CreateNotificationqueryRequestNotificationOutcomeSuccess),
},
expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | error = ""`,
historian.LabelFrom, historian.LabelFromValue,
historian.LabelRuleUID, "test-rule-uid"),
expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | error = ""`,
historian.LabelFrom, historian.LabelFromValue),
},
{
name: "query with error outcome filter",
@@ -173,9 +170,8 @@ func TestBuildQuery(t *testing.T) {
RuleUID: stringPtr("test-rule-uid"),
Outcome: outcomePtr(v0alpha1.CreateNotificationqueryRequestNotificationOutcomeError),
},
expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | error != ""`,
historian.LabelFrom, historian.LabelFromValue,
historian.LabelRuleUID, "test-rule-uid"),
expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | error != ""`,
historian.LabelFrom, historian.LabelFromValue),
},
{
name: "query with many filters",
@@ -185,9 +181,8 @@ func TestBuildQuery(t *testing.T) {
Status: createStatusPtr(v0alpha1.CreateNotificationqueryRequestNotificationStatusResolved),
Outcome: outcomePtr(v0alpha1.CreateNotificationqueryRequestNotificationOutcomeSuccess),
},
expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | receiver = "email-receiver" | status = "resolved" | error = ""`,
historian.LabelFrom, historian.LabelFromValue,
historian.LabelRuleUID, "test-rule-uid"),
expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | receiver = "email-receiver" | status = "resolved" | error = ""`,
historian.LabelFrom, historian.LabelFromValue),
},
{
name: "query with group label matcher",
@@ -277,19 +272,19 @@ func TestParseLokiEntry(t *testing.T) {
GroupLabels: map[string]string{
"alertname": "test-alert",
},
Alerts: []historian.NotificationHistoryLokiEntryAlert{
{
Status: "firing",
Labels: map[string]string{
"severity": "critical",
},
Annotations: map[string]string{
"summary": "Test alert",
},
StartsAt: now,
EndsAt: now.Add(1 * time.Hour),
Alert: historian.NotificationHistoryLokiEntryAlert{
Status: "firing",
Labels: map[string]string{
"severity": "critical",
},
Annotations: map[string]string{
"summary": "Test alert",
},
StartsAt: now,
EndsAt: now.Add(1 * time.Hour),
},
AlertIndex: 0,
AlertCount: 1,
Retry: false,
Duration: 100,
PipelineTime: now,
@@ -335,7 +330,9 @@ func TestParseLokiEntry(t *testing.T) {
Error: "notification failed",
GroupKey: "key:thing",
GroupLabels: map[string]string{},
Alerts: []historian.NotificationHistoryLokiEntryAlert{},
Alert: historian.NotificationHistoryLokiEntryAlert{},
AlertIndex: 0,
AlertCount: 1,
PipelineTime: now,
}),
},
@@ -347,7 +344,7 @@ func TestParseLokiEntry(t *testing.T) {
Outcome: OutcomeError,
GroupKey: "key:thing",
GroupLabels: map[string]string{},
Alerts: []EntryAlert{},
Alerts: []EntryAlert{{}},
Error: stringPtr("notification failed"),
PipelineTime: now,
},
@@ -365,7 +362,7 @@ func TestParseLokiEntry(t *testing.T) {
Status: Status("firing"),
Outcome: OutcomeSuccess,
GroupLabels: map[string]string{},
Alerts: []EntryAlert{},
Alerts: []EntryAlert{{}},
PipelineTime: now,
},
},
@@ -448,7 +445,9 @@ func TestLokiReader_RunQuery(t *testing.T) {
Receiver: "receiver-1",
Status: "firing",
GroupLabels: map[string]string{},
Alerts: []historian.NotificationHistoryLokiEntryAlert{},
Alert: historian.NotificationHistoryLokiEntryAlert{},
AlertIndex: 0,
AlertCount: 1,
PipelineTime: now,
}),
},
@@ -459,7 +458,9 @@ func TestLokiReader_RunQuery(t *testing.T) {
Receiver: "receiver-3",
Status: "firing",
GroupLabels: map[string]string{},
Alerts: []historian.NotificationHistoryLokiEntryAlert{},
Alert: historian.NotificationHistoryLokiEntryAlert{},
AlertIndex: 0,
AlertCount: 1,
PipelineTime: now,
}),
},
@@ -474,7 +475,9 @@ func TestLokiReader_RunQuery(t *testing.T) {
Receiver: "receiver-2",
Status: "firing",
GroupLabels: map[string]string{},
Alerts: []historian.NotificationHistoryLokiEntryAlert{},
Alert: historian.NotificationHistoryLokiEntryAlert{},
AlertIndex: 0,
AlertCount: 1,
PipelineTime: now,
}),
},
@@ -546,19 +549,19 @@ func createMockLokiResponse(timestamp time.Time) lokiclient.QueryRes {
GroupLabels: map[string]string{
"alertname": "test-alert",
},
Alerts: []historian.NotificationHistoryLokiEntryAlert{
{
Status: "firing",
Labels: map[string]string{
"severity": "critical",
},
Annotations: map[string]string{
"summary": "Test alert",
},
StartsAt: timestamp,
EndsAt: timestamp.Add(1 * time.Hour),
Alert: historian.NotificationHistoryLokiEntryAlert{
Status: "firing",
Labels: map[string]string{
"severity": "critical",
},
Annotations: map[string]string{
"summary": "Test alert",
},
StartsAt: timestamp,
EndsAt: timestamp.Add(1 * time.Hour),
},
AlertIndex: 0,
AlertCount: 1,
Retry: false,
Duration: 100,
PipelineTime: timestamp,
@@ -587,10 +590,19 @@ func createLokiEntryJSONWithNilLabels(t *testing.T, timestamp time.Time) string
"status": "firing",
"error": "",
"groupLabels": null,
"alerts": [],
"alert": {},
"alertIndex": 0,
"alertCount": 1,
"retry": false,
"duration": 0,
"pipelineTime": "%s"
}`, timestamp.Format(time.RFC3339Nano))
return jsonStr
}
func TestRuleUIDLabelConstant(t *testing.T) {
// Verify that models.RuleUIDLabel has the expected value.
// If this changes in the alerting module, our LogQL field path constant will be incorrect
// and filtering for a single alert rule by its UID will break.
assert.Equal(t, "__alert_rule_uid__", models.RuleUIDLabel)
}
+1 -1
View File
@@ -223,7 +223,7 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // indirect
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
+2 -2
View File
@@ -827,8 +827,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0=
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
+1 -1
View File
@@ -90,7 +90,7 @@ require (
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // indirect
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
+2 -2
View File
@@ -213,8 +213,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0=
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
+5
View File
@@ -1653,6 +1653,11 @@ loki_basic_auth_password =
# Accepts duration formats like: 30s, 1m, 1h.
rule_query_offset = 1m
# Default data source UID to use for query execution when importing Prometheus rules.
# This default is used when the X-Grafana-Alerting-Datasource-UID header is not provided.
# If not set, the header becomes required.
default_datasource_uid =
[recording_rules]
# Enable recording rules.
enabled = true
+5
View File
@@ -1615,6 +1615,11 @@ max_annotations_to_keep =
# Accepts duration formats like: 30s, 1m, 1h.
rule_query_offset = 1m
# Default data source UID to use for query execution when importing Prometheus rules.
# This default is used when the X-Grafana-Alerting-Datasource-UID header is not provided.
# If not set, the header becomes required.
default_datasource_uid =
#################################### Recording Rules #####################
[recording_rules]
# Enable recording rules.
@@ -242,6 +242,8 @@ Set to `true` to import recording rules in paused state.
The UID of the data source to use for alert rule queries.
If not specified in the header, Grafana uses the configured default from `unified_alerting.prometheus_conversion.default_datasource_uid`. If neither the header nor the configuration option is provided, the request fails.
#### `X-Grafana-Alerting-Target-Datasource-UID`
The UID of the target data source for recording rules. If not specified, the value from `X-Grafana-Alerting-Datasource-UID` is used.
@@ -2052,6 +2052,10 @@ This section applies only to rules imported as Grafana-managed rules. For more i
Set the query offset to imported Grafana-managed rules when `query_offset` is not defined in the original rule group configuration. The default value is `1m`.
#### `default_datasource_uid`
Set the default data source UID to use for query execution when importing Prometheus rules. Grafana uses this default when the `X-Grafana-Alerting-Datasource-UID` header isn't provided during import. If this option isn't set, the header becomes required. The default value is empty.
<hr>
### `[annotations]`
+1 -1
View File
@@ -87,7 +87,7 @@ require (
github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // @grafana/alerting-backend
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // @grafana/alerting-backend
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
+2 -2
View File
@@ -1622,8 +1622,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0=
github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
+12 -10
View File
@@ -793,7 +793,15 @@ github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
github.com/go-pdf/fpdf v0.6.0 h1:MlgtGIfsdMEEQJr2le6b/HNr1ZlQwxyWr77r2aj2U/8=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
@@ -982,7 +990,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9K
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU=
github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
@@ -1404,7 +1411,6 @@ github.com/richardartoul/molecule v1.0.0/go.mod h1:uvX/8buq8uVeiZiFht+0lqSLBHF+u
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
@@ -1623,7 +1629,6 @@ go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5queth
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/collector v0.121.0/go.mod h1:M4TlnmkjIgishm2DNCk9K3hMKTmAsY9w8cNFsp9EchM=
go.opentelemetry.io/collector v0.124.0/go.mod h1:QzERYfmHUedawjr8Ph/CBEEkVqWS8IlxRLAZt+KHlCg=
go.opentelemetry.io/collector/client v1.29.0/go.mod h1:LCUoEV2KCTKA1i+/txZaGsSPVWUcqeOV6wCfNsAippE=
@@ -1839,6 +1844,7 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/contrib/otelconf v0.15.0 h1:BLNiIUsrNcqhSKpsa6CnhE6LdrpY1A8X0szMVsu99eo=
go.opentelemetry.io/contrib/otelconf v0.15.0/go.mod h1:OPH1seO5z9dp1P26gnLtoM9ht7JDvh3Ws6XRHuXqImY=
go.opentelemetry.io/contrib/propagators/aws v1.37.0 h1:cp8AFiM/qjBm10C/ATIRnEDXpD5MBknrA0ANw4T2/ss=
@@ -1910,7 +1916,6 @@ go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v8
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
@@ -2118,8 +2123,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk=
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822 h1:zWFRixYR5QlotL+Uv3YfsPRENIrQFXiGs+iwqel6fOQ=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822/go.mod h1:h6yxum/C2qRb4txaZRLDHK8RyS0H/o2oEDeKY4onY/Y=
@@ -2150,10 +2155,9 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.
google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
@@ -2177,7 +2181,6 @@ google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7E
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE=
google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20 h1:MLBCGN1O7GzIx+cBiwfYPwtmZ41U3Mn/cotLJciaArI=
google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0=
@@ -2299,7 +2302,6 @@ sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ih
sigs.k8s.io/structured-merge-diff/v4 v4.5.0 h1:nbCitCK2hfnhyiKo6uf2HxUPTCodY6Qaf85SbDIaMBk=
sigs.k8s.io/structured-merge-diff/v4 v4.5.0/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4=
sigs.k8s.io/structured-merge-diff/v6 v6.2.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4=
@@ -243,6 +243,7 @@ const injectedRtkApi = api
type: queryArg['type'],
folder: queryArg.folder,
facet: queryArg.facet,
facetLimit: queryArg.facetLimit,
tags: queryArg.tags,
libraryPanel: queryArg.libraryPanel,
permission: queryArg.permission,
@@ -663,6 +664,8 @@ export type SearchDashboardsAndFoldersApiArg = {
folder?: string;
/** count distinct terms for selected fields */
facet?: string[];
/** maximum number of terms to return per facet (default 50, max 1000) */
facetLimit?: number;
/** tag query filter */
tags?: string[];
/** find dashboards that reference a given libraryPanel */
@@ -3,11 +3,18 @@ import { merge } from 'lodash';
import { toDataFrame } from '../dataframe/processDataFrame';
import { createTheme } from '../themes/createTheme';
import { ReducerID } from '../transformations/fieldReducer';
import { FieldType } from '../types/dataFrame';
import { FieldConfigPropertyItem } from '../types/fieldOverrides';
import { MappingType, SpecialValueMatch, ValueMapping } from '../types/valueMapping';
import { getDisplayProcessor } from './displayProcessor';
import { fixCellTemplateExpressions, getFieldDisplayValues, GetFieldDisplayValuesOptions } from './fieldDisplay';
import {
FieldSparkline,
fixCellTemplateExpressions,
getFieldDisplayValues,
GetFieldDisplayValuesOptions,
getSparklineHighlight,
} from './fieldDisplay';
import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry';
describe('FieldDisplay', () => {
@@ -556,3 +563,71 @@ describe('fixCellTemplateExpressions', () => {
);
});
});
describe('getSparklineHighlight', () => {
const sparkline: FieldSparkline = {
y: { name: 'A', type: FieldType.number, values: [null, 2, 3, 4, 10, 8, 8, 8, 9, null], config: {} },
};
it.each([
{
calc: ReducerID.last,
expected: {
type: 'point',
xIdx: 9,
},
},
{
calc: ReducerID.max,
expected: {
type: 'point',
xIdx: 4,
},
},
{
calc: ReducerID.min,
expected: {
type: 'point',
xIdx: 1,
},
},
{
calc: ReducerID.first,
expected: {
type: 'point',
xIdx: 0,
},
},
{
calc: ReducerID.firstNotNull,
expected: {
type: 'point',
xIdx: 1,
},
},
{
calc: ReducerID.lastNotNull,
expected: {
type: 'point',
xIdx: 8,
},
},
{
calc: ReducerID.mean,
expected: {
type: 'line',
y: 6.5,
},
},
{
calc: ReducerID.median,
expected: {
type: 'line',
y: 8,
},
},
])('it calculates the correct highlight for the $calc', ({ calc, expected }) => {
const result = getSparklineHighlight(sparkline, calc);
expect(result).toEqual(expected);
});
});
+81 -56
View File
@@ -3,7 +3,7 @@ import { isEmpty } from 'lodash';
import { DataFrameView } from '../dataframe/DataFrameView';
import { getTimeField } from '../dataframe/processDataFrame';
import { GrafanaTheme2 } from '../themes/types';
import { reduceField, ReducerID } from '../transformations/fieldReducer';
import { isReducerID, reduceField, ReducerID } from '../transformations/fieldReducer';
import { getFieldMatcher } from '../transformations/matchers';
import { FieldMatcherID } from '../transformations/matchers/ids';
import { ScopedVars } from '../types/ScopedVars';
@@ -43,6 +43,7 @@ export interface FieldSparkline {
x?: Field; // if this does not exist, use the index
timeRange?: TimeRange; // Optionally force an absolute time
highlightIndex?: number;
highlightLine?: number;
}
export interface FieldDisplay {
@@ -72,6 +73,76 @@ export interface GetFieldDisplayValuesOptions {
export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25;
interface SparklineHighlightPoint {
type: 'point';
xIdx: number;
}
interface SparklineHighlightLine {
type: 'line';
y: number;
}
export function getSparklineHighlight(
sparkline: FieldSparkline,
calc: ReducerID
): SparklineHighlightPoint | SparklineHighlightLine | void {
switch (calc) {
case ReducerID.last:
return { type: 'point', xIdx: sparkline.y.values.length - 1 };
case ReducerID.first:
return { type: 'point', xIdx: 0 };
case ReducerID.lastNotNull: {
for (let k = sparkline.y.values.length - 1; k >= 0; k--) {
const v = sparkline.y.values[k];
if (v !== null && v !== undefined && !Number.isNaN(v)) {
return { type: 'point', xIdx: k };
}
}
return;
}
case ReducerID.firstNotNull: {
for (let k = 0; k < sparkline.y.values.length; k++) {
const v = sparkline.y.values[k];
if (v !== null && v !== undefined && !Number.isNaN(v)) {
return { type: 'point', xIdx: k };
}
}
return;
}
case ReducerID.min: {
let minIdx = -1;
let prevMin = Infinity;
for (let k = 0; k < sparkline.y.values.length; k++) {
const v = sparkline.y.values[k];
if (v !== null && v !== undefined && !Number.isNaN(v) && v < prevMin) {
prevMin = v;
minIdx = k;
}
}
return minIdx >= 0 ? { type: 'point', xIdx: minIdx } : undefined;
}
case ReducerID.max: {
let maxIdx = -1;
let prevMax = -Infinity;
for (let k = 0; k < sparkline.y.values.length; k++) {
const v = sparkline.y.values[k];
if (v !== null && v !== undefined && !Number.isNaN(v) && v > prevMax) {
prevMax = v;
maxIdx = k;
}
}
return maxIdx >= 0 ? { type: 'point', xIdx: maxIdx } : undefined;
}
case ReducerID.mean:
return { type: 'line', y: reduceField({ field: sparkline.y, reducers: [ReducerID.mean] }).mean };
case ReducerID.median:
return { type: 'line', y: reduceField({ field: sparkline.y, reducers: [ReducerID.median] }).median };
default:
return;
}
}
export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => {
const { replaceVariables, reduceOptions, timeZone, theme } = options;
const calcs = reduceOptions.calcs.length ? reduceOptions.calcs : [ReducerID.last];
@@ -190,62 +261,16 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
y: dataFrame.fields[i],
x: timeField,
};
let highlightIdx: number | undefined = (() => {
switch (calc) {
case ReducerID.last:
return sparkline.y.values.length - 1;
case ReducerID.first:
return 0;
// TODO: #112977 enable more reducers for highlight index
// case ReducerID.lastNotNull: {
// for (let k = sparkline.y.values.length - 1; k >= 0; k--) {
// const v = sparkline.y.values[k];
// if (v !== null && v !== undefined && !Number.isNaN(v)) {
// return k;
// }
// }
// return;
// }
// case ReducerID.firstNotNull: {
// for (let k = 0; k < sparkline.y.values.length; k++) {
// const v = sparkline.y.values[k];
// if (v !== null && v !== undefined && !Number.isNaN(v)) {
// return k;
// }
// }
// return;
// }
// case ReducerID.min: {
// let minIdx = -1;
// let prevMin = Infinity;
// for (let k = 0; k < sparkline.y.values.length; k++) {
// const v = sparkline.y.values[k];
// if (v !== null && v !== undefined && !Number.isNaN(v) && v < prevMin) {
// prevMin = v;
// minIdx = k;
// }
// }
// return minIdx >= 0 ? minIdx : undefined;
// }
// case ReducerID.max: {
// let maxIdx = -1;
// let prevMax = -Infinity;
// for (let k = 0; k < sparkline.y.values.length; k++) {
// const v = sparkline.y.values[k];
// if (v !== null && v !== undefined && !Number.isNaN(v) && v > prevMax) {
// prevMax = v;
// maxIdx = k;
// }
// }
// return maxIdx >= 0 ? maxIdx : undefined;
// }
default:
return;
if (isReducerID(calc)) {
const sparklineHighlight = getSparklineHighlight(sparkline, calc);
switch (sparklineHighlight?.type) {
case 'point':
sparkline.highlightIndex = sparklineHighlight.xIdx;
break;
case 'line':
sparkline.highlightLine = sparklineHighlight.y;
break;
}
})();
if (typeof highlightIdx === 'number') {
sparkline.highlightIndex = highlightIdx;
}
}
@@ -67,7 +67,7 @@ export const RadialSparkline = memo(
return (
<div style={{ position: 'absolute', top: topPos }}>
<Sparkline height={height} width={width} sparkline={sparkline} theme={theme} config={config} />
<Sparkline height={height} width={width} sparkline={sparkline} theme={theme} config={config} showHighlights />
</div>
);
}
@@ -14,18 +14,18 @@ export interface SparklineProps extends Themeable2 {
height: number;
config?: FieldConfig<GraphFieldConfig>;
sparkline: FieldSparkline;
showHighlights?: boolean;
}
const SparklineFn: React.FC<SparklineProps> = memo((props) => {
const { sparkline, config: fieldConfig, theme, width, height } = props;
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig);
export const SparklineFn: React.FC<SparklineProps> = memo((props) => {
const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props;
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights);
if (warning) {
return null;
}
const data = preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame));
const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme);
const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme, showHighlights);
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
});
@@ -1,6 +1,6 @@
import { Field, FieldSparkline, FieldType } from '@grafana/data';
import { createTheme, Field, FieldSparkline, FieldType, toDataFrame } from '@grafana/data';
import { getYRange, preparePlotFrame } from './utils';
import { getYRange, prepareConfig, preparePlotFrame } from './utils';
describe('Prepare Sparkline plot frame', () => {
it('should return sorted array if x-axis numeric', () => {
@@ -201,3 +201,134 @@ describe('Get y range', () => {
expect(actual[0]).toBeLessThan(actual[1]!);
});
});
describe('prepareConfig', () => {
it('should not throw an error if there are multiple values', () => {
const sparkline: FieldSparkline = {
x: {
name: 'x',
values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000],
type: FieldType.time,
config: {},
},
y: {
name: 'y',
values: [1, 2, 3, 4, 5],
type: FieldType.number,
config: {},
},
};
const dataFrame = toDataFrame({
fields: [sparkline.x, sparkline.y],
});
const config = prepareConfig(sparkline, dataFrame, createTheme());
expect(config.series.length).toBe(1);
});
it('should not throw an error if there is a single value', () => {
const sparkline: FieldSparkline = {
x: {
name: 'x',
values: [1679839200000],
type: FieldType.time,
config: {},
},
y: {
name: 'y',
values: [1],
type: FieldType.number,
config: {},
},
};
const dataFrame = toDataFrame({
fields: [sparkline.x, sparkline.y],
});
const config = prepareConfig(sparkline, dataFrame, createTheme());
expect(config.series.length).toBe(1);
});
it('should not throw an error if there are no values', () => {
const sparkline: FieldSparkline = {
x: {
name: 'x',
values: [],
type: FieldType.time,
config: {},
},
y: {
name: 'y',
values: [],
type: FieldType.number,
config: {},
},
};
const dataFrame = toDataFrame({
fields: [sparkline.x, sparkline.y],
});
const config = prepareConfig(sparkline, dataFrame, createTheme());
expect(config.series.length).toBe(1);
});
it('should set up highlight series if showHighlights is true and highlightIdx exists', () => {
const sparkline: FieldSparkline = {
x: {
name: 'x',
values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000],
type: FieldType.time,
config: {},
},
y: {
name: 'y',
values: [1, 2, 3, 4, 5],
type: FieldType.number,
config: {},
},
highlightIndex: 2,
};
const dataFrame = toDataFrame({
fields: [sparkline.x, sparkline.y],
});
const config = prepareConfig(sparkline, dataFrame, createTheme(), true);
expect(config.series.length).toBe(1);
expect(config.series[0].getConfig().points).toEqual(
expect.objectContaining({
show: true,
filter: [2],
})
);
});
it('should not set up highlight series if showHighlights is false even if highlightIdx exists', () => {
const sparkline: FieldSparkline = {
x: {
name: 'x',
values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000],
type: FieldType.time,
config: {},
},
y: {
name: 'y',
values: [1, 2, 3, 4, 5],
type: FieldType.number,
config: {},
},
highlightIndex: 2,
};
const dataFrame = toDataFrame({
fields: [sparkline.x, sparkline.y],
});
const config = prepareConfig(sparkline, dataFrame, createTheme(), false);
expect(config.series.length).toBe(1);
expect(config.series[0].getConfig().points?.show).not.toBe(true);
});
});
@@ -2,6 +2,7 @@ import { Range } from 'uplot';
import {
applyNullInsertThreshold,
// colorManipulator,
DataFrame,
FieldConfig,
FieldSparkline,
@@ -22,6 +23,7 @@ import {
VisibilityMode,
ScaleDirection,
ScaleOrientation,
// FieldColorModeId,
} from '@grafana/schema';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
@@ -112,8 +114,7 @@ export function getYRange(alignedFrame: DataFrame): Range.MinMax {
return [roundedMin, roundedMax];
}
// TODO: #112977 enable highlight index
// const HIGHLIGHT_IDX_POINT_SIZE = 6;
const HIGHLIGHT_IDX_POINT_SIZE = 6;
const defaultConfig: GraphFieldConfig = {
drawStyle: GraphDrawStyle.Line,
@@ -124,7 +125,9 @@ const defaultConfig: GraphFieldConfig = {
export const prepareSeries = (
sparkline: FieldSparkline,
fieldConfig?: FieldConfig<GraphFieldConfig>
_theme: GrafanaTheme2,
fieldConfig?: FieldConfig<GraphFieldConfig>,
_showHighlights?: boolean
): { frame: DataFrame; warning?: string } => {
const frame = nullToValue(preparePlotFrame(sparkline, fieldConfig));
if (frame.fields.some((f) => f.values.length <= 1)) {
@@ -136,16 +139,41 @@ export const prepareSeries = (
frame,
};
}
// TODO:rgb(24, 24, 24) will address this.
// if (showHighlights && typeof sparkline.highlightLine === 'number') {
// const highlightY = sparkline.highlightLine;
// const colorMode = getFieldColorModeForField(sparkline.y);
// const seriesColor = colorMode.getCalculator(sparkline.y, theme)(highlightY, 0);
// frame.fields.push({
// name: 'highlightLine',
// type: FieldType.number,
// values: new Array(frame.length).fill(highlightY),
// config: {
// color: {
// mode: FieldColorModeId.Fixed,
// fixedColor: colorManipulator.lighten(seriesColor, 0.5),
// },
// custom: {
// lineStyle: {
// fill: 'dash',
// dash: [5, 2],
// },
// },
// },
// state: {},
// });
// }
return { frame };
};
export const prepareConfig = (
sparkline: FieldSparkline,
dataFrame: DataFrame,
theme: GrafanaTheme2
theme: GrafanaTheme2,
showHighlights?: boolean
): UPlotConfigBuilder => {
const builder = new UPlotConfigBuilder();
// const rangePad = HIGHLIGHT_IDX_POINT_SIZE / 2;
const rangePad = HIGHLIGHT_IDX_POINT_SIZE / 2;
builder.setCursor({
show: false,
@@ -206,13 +234,14 @@ export const prepareConfig = (
const colorMode = getFieldColorModeForField(field);
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
// TODO: #112977 enable highlight index and adjust padding accordingly
// const hasHighlightIndex = typeof sparkline.highlightIndex === 'number';
// if (hasHighlightIndex) {
// builder.setPadding([rangePad, rangePad, rangePad, rangePad]);
// }
const hasHighlightIndex = showHighlights && typeof sparkline.highlightIndex === 'number';
if (hasHighlightIndex) {
builder.setPadding([rangePad, rangePad, rangePad, rangePad]);
}
const pointsMode =
customConfig.drawStyle === GraphDrawStyle.Points // || hasHighlightIndex
customConfig.drawStyle === GraphDrawStyle.Points || hasHighlightIndex
? VisibilityMode.Always
: customConfig.showPoints;
@@ -227,9 +256,8 @@ export const prepareConfig = (
lineWidth: customConfig.lineWidth,
lineInterpolation: customConfig.lineInterpolation,
showPoints: pointsMode,
// TODO: #112977 enable highlight index
pointSize: /* hasHighlightIndex ? HIGHLIGHT_IDX_POINT_SIZE : */ customConfig.pointSize,
// pointsFilter: hasHighlightIndex ? [sparkline.highlightIndex!] : undefined,
pointSize: hasHighlightIndex ? HIGHLIGHT_IDX_POINT_SIZE : customConfig.pointSize,
pointsFilter: hasHighlightIndex ? [sparkline.highlightIndex!] : undefined,
fillOpacity: customConfig.fillOpacity,
fillColor: customConfig.fillColor,
lineStyle: customConfig.lineStyle,
+8 -56
View File
@@ -10,7 +10,6 @@ import (
"github.com/grafana/grafana/pkg/api/response"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) response.Response {
@@ -24,13 +23,13 @@ func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) respons
}
type NotifierPlugin struct {
Type string `json:"type"`
TypeAlias string `json:"typeAlias,omitempty"`
Name string `json:"name"`
Heading string `json:"heading"`
Description string `json:"description"`
Info string `json:"info"`
Options []Field `json:"options"`
Type string `json:"type"`
TypeAlias string `json:"typeAlias,omitempty"`
Name string `json:"name"`
Heading string `json:"heading"`
Description string `json:"description"`
Info string `json:"info"`
Options []schema.Field `json:"options"`
}
result := make([]*NotifierPlugin, 0, len(v2))
@@ -45,56 +44,9 @@ func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) respons
Description: s.Description,
Heading: s.Heading,
Info: s.Info,
Options: schemaFieldsToFields(s.Type, nil, v1.Options),
Options: v1.Options,
})
}
return response.JSON(http.StatusOK, result)
}
}
type Field struct {
Element schema.ElementType `json:"element"`
InputType schema.InputType `json:"inputType"`
Label string `json:"label"`
Description string `json:"description"`
Placeholder string `json:"placeholder"`
PropertyName string `json:"propertyName"`
SelectOptions []schema.SelectOption `json:"selectOptions"`
ShowWhen schema.ShowWhen `json:"showWhen"`
Required bool `json:"required"`
Protected bool `json:"protected,omitempty"`
ValidationRule string `json:"validationRule"`
Secure bool `json:"secure"`
DependsOn string `json:"dependsOn"`
SubformOptions []Field `json:"subformOptions"`
}
func schemaFieldsToFields(iType schema.IntegrationType, parent schema.IntegrationFieldPath, fields []schema.Field) []Field {
if fields == nil {
return nil
}
result := make([]Field, 0, len(fields))
for _, f := range fields {
result = append(result, schemaFieldToField(iType, parent, f))
}
return result
}
func schemaFieldToField(iType schema.IntegrationType, parent schema.IntegrationFieldPath, f schema.Field) Field {
return Field{
Element: f.Element,
InputType: f.InputType,
Label: f.Label,
Description: f.Description,
Placeholder: f.Placeholder,
PropertyName: f.PropertyName,
SelectOptions: f.SelectOptions,
ShowWhen: f.ShowWhen,
Required: f.Required,
ValidationRule: f.ValidationRule,
Secure: f.Secure,
DependsOn: f.DependsOn,
SubformOptions: schemaFieldsToFields(iType, append(parent, f.PropertyName), f.SubformOptions),
Protected: models.IsProtectedField(iType, append(parent, f.PropertyName)),
}
}
+19 -1
View File
@@ -115,6 +115,15 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
Schema: spec.ArrayProperty(spec.StringProperty()),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "facetLimit",
In: "query",
Description: "maximum number of terms to return per facet (default 50, max 1000)",
Required: false,
Schema: spec.Int64Property(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "tags",
@@ -340,6 +349,7 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, user identity.Requester, getDashboardsUIDsSharedWithUser func() ([]string, error)) (*resourcepb.ResourceSearchRequest, error) {
// get limit and offset from query params
limit := 50
facetLimit := 50
offset := 0
page := 1
if queryParams.Has("limit") {
@@ -422,11 +432,19 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
// The facet term fields
if facets, ok := queryParams["facet"]; ok {
if queryParams.Has("facetLimit") {
if parsed, err := strconv.Atoi(queryParams.Get("facetLimit")); err == nil && parsed > 0 {
facetLimit = parsed
if facetLimit > 1000 {
facetLimit = 1000
}
}
}
searchRequest.Facet = make(map[string]*resourcepb.ResourceSearchRequest_Facet)
for _, v := range facets {
searchRequest.Facet[v] = &resourcepb.ResourceSearchRequest_Facet{
Field: v,
Limit: 50,
Limit: int64(facetLimit),
}
}
}
@@ -818,6 +818,38 @@ func TestConvertHttpSearchRequestToResourceSearchRequest(t *testing.T) {
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"facet fields with custom limit": {
queryString: "facet=tags&facetLimit=500",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{
"tags": {Field: "tags", Limit: 500},
},
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"facet fields with limit exceeding max": {
queryString: "facet=tags&facetLimit=5000",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{
"tags": {Field: "tags", Limit: 1000},
},
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"tag filter": {
queryString: "tag=tag1&tag=tag2",
expected: &resourcepb.ResourceSearchRequest{
@@ -251,7 +251,12 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteRuleGroup(c *contex
logger.Info("Deleting Prometheus-imported rule group", "folder_uid", folder.UID, "folder_title", namespaceTitle, "group", group)
provenance := getProvenance(c)
err = srv.alertRuleService.DeleteRuleGroup(c.Req.Context(), c.SignedInUser, folder.UID, group, provenance)
filterOpts := &provisioning.FilterOptions{
NamespaceUIDs: []string{folder.UID},
RuleGroups: []string{group},
HasPrometheusRuleDefinition: util.Pointer(true),
}
err = srv.alertRuleService.DeleteRuleGroups(c.Req.Context(), c.SignedInUser, provenance, filterOpts)
if errors.Is(err, models.ErrAlertRuleGroupNotFound) {
return response.Empty(http.StatusNotFound)
}
@@ -370,6 +375,9 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *context
}
datasourceUID := strings.TrimSpace(c.Req.Header.Get(datasourceUIDHeader))
if datasourceUID == "" {
datasourceUID = srv.cfg.PrometheusConversion.DefaultDatasourceUID
}
if datasourceUID == "" {
return response.Err(errDatasourceUIDHeaderMissing)
}
@@ -75,6 +75,46 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
require.Contains(t, string(response.Body()), "Missing datasource UID header")
})
t.Run("without datasource UID header but with config default should succeed", func(t *testing.T) {
srv, _, ruleStore := createConvertPrometheusSrv(t)
// Set the config default
srv.cfg.PrometheusConversion.DefaultDatasourceUID = existingDSUID
rc := createRequestCtx()
rc.Req.Header.Set(datasourceUIDHeader, "")
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
require.Equal(t, http.StatusAccepted, response.Status())
// Verify that the config default datasource was used
assertRulesUseDatasource(t, ruleStore, existingDSUID, 2)
})
t.Run("header should take precedence over config default", func(t *testing.T) {
srv, dsCache, ruleStore := createConvertPrometheusSrv(t)
// Add another datasource
anotherDS := &datasources.DataSource{
UID: "another-ds",
Type: datasources.DS_PROMETHEUS,
}
dsCache.DataSources = append(dsCache.DataSources, anotherDS)
// Set the config default to one DS
srv.cfg.PrometheusConversion.DefaultDatasourceUID = "another-ds"
// But use the header to specify a different one
rc := createRequestCtx()
rc.Req.Header.Set(datasourceUIDHeader, existingDSUID)
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
require.Equal(t, http.StatusAccepted, response.Status())
// Verify that the header datasource was used, not the config default
assertRulesUseDatasource(t, ruleStore, existingDSUID, 2)
})
t.Run("with invalid datasource should return error", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
@@ -1390,7 +1430,7 @@ func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) {
t.Run("with disable provenance header should still be able to delete rules", func(t *testing.T) {
provenanceStore := fakes.NewFakeProvisioningStore()
srv, ruleStore, fldr, rule := initGroup("", groupName, withProvenanceStore(provenanceStore))
srv, ruleStore, fldr, rule := initGroup("prometheus definition", groupName, withProvenanceStore(provenanceStore))
// Mark the rule as provisioned with API provenance
err := provenanceStore.SetProvenance(context.Background(), rule, 1, models.ProvenanceConvertedPrometheus)
@@ -1411,6 +1451,37 @@ func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) {
require.Nil(t, remaining)
})
})
t.Run("should not delete non-imported rule groups", func(t *testing.T) {
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
rc := createRequestCtx()
fldr := randFolder()
fldr.ParentUID = ""
folderService.ExpectedFolder = fldr
folderService.ExpectedFolders = []*folder.Folder{fldr}
ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr)
rule := models.RuleGen.
With(models.RuleGen.WithNamespaceUID(fldr.UID)).
With(models.RuleGen.WithOrgID(1)).
With(models.RuleGen.WithGroupName(groupName)).
GenerateRef()
ruleStore.PutRule(context.Background(), rule)
// Attempt to delete via convert endpoint should return 404
response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, fldr.Title, groupName)
require.Equal(t, http.StatusNotFound, response.Status())
// Verify the rule is still present
remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{
UID: rule.UID,
OrgID: rule.OrgID,
})
require.NoError(t, err)
require.NotNil(t, remaining)
})
}
func TestRouteConvertPrometheusPostRuleGroups(t *testing.T) {
@@ -1730,6 +1801,26 @@ func createRequestCtx() *contextmodel.ReqContext {
}
}
// assertRulesUseDatasource retrieves all alert rules from the store and verifies they use the expected datasource
func assertRulesUseDatasource(t *testing.T, ruleStore *fakes.RuleStore, expectedDatasourceUID string, expectedRuleCount int) {
t.Helper()
rules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
OrgID: 1,
})
require.NoError(t, err)
require.Len(t, rules, expectedRuleCount)
for _, rule := range rules {
if rule.Record == nil {
require.NotEmpty(t, rule.Data)
require.Equal(t, expectedDatasourceUID, rule.Data[0].DatasourceUID, rule.Title, expectedDatasourceUID)
} else {
require.Equal(t, expectedDatasourceUID, rule.Record.TargetDatasourceUID)
}
}
}
// Test parseBooleanHeader function which handles boolean header values
func TestParseBooleanHeader(t *testing.T) {
headerName := "X-Test-Header"
@@ -2369,6 +2369,140 @@ func TestRouteGetRuleStatuses(t *testing.T) {
}
})
t.Run("multi-page pagination loads provenance correctly", func(t *testing.T) {
fakeStore, fakeAIM, api, fakeProvisioning := setupAPIFull(t)
// Create 3 groups with 1 rule each: groups 1 and 3 firing, group 2 normal
for i := 1; i <= 3; i++ {
rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) {
r.NamespaceUID = "ns-1"
r.RuleGroup = fmt.Sprintf("group-%d", i)
r.UID = fmt.Sprintf("rule-%d", i)
}, withClassicConditionSingleQuery()).GenerateRef()
alertState := eval.Normal
if i != 2 {
alertState = eval.Alerting
}
fakeAIM.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State {
s.State = alertState
s.Labels = data.Labels{"test": "label"}
return s
})
fakeStore.PutRule(context.Background(), rule)
}
// Set provenance for all rules
err := fakeProvisioning.SetProvenance(context.Background(),
&ngmodels.AlertRule{UID: "rule-1", OrgID: orgID}, orgID, ngmodels.ProvenanceAPI)
require.NoError(t, err)
err = fakeProvisioning.SetProvenance(context.Background(),
&ngmodels.AlertRule{UID: "rule-3", OrgID: orgID}, orgID, ngmodels.ProvenanceFile)
require.NoError(t, err)
// Request firing groups with group_limit=2 - fetches multiple pages, skipping group 2
req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&group_limit=2", nil)
require.NoError(t, err)
c := &contextmodel.ReqContext{
Context: &web.Context{Req: req},
SignedInUser: &user.SignedInUser{
OrgID: orgID,
Permissions: queryPermissions,
},
}
resp := api.RouteGetRuleStatuses(c)
require.Equal(t, http.StatusOK, resp.Status())
var res apimodels.RuleResponse
require.NoError(t, json.Unmarshal(resp.Body(), &res))
// Should return 2 firing groups
require.Len(t, res.Data.RuleGroups, 2)
require.Equal(t, "group-1", res.Data.RuleGroups[0].Name)
require.Equal(t, apimodels.Provenance(ngmodels.ProvenanceAPI), res.Data.RuleGroups[0].Rules[0].Provenance)
require.Equal(t, "group-3", res.Data.RuleGroups[1].Name)
require.Equal(t, apimodels.Provenance(ngmodels.ProvenanceFile), res.Data.RuleGroups[1].Rules[0].Provenance)
})
t.Run("provenance fetch error returns error response in paginated mode", func(t *testing.T) {
fakeStore, fakeAIM, api, fakeProvisioning := setupAPIFull(t)
rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) {
r.NamespaceUID = "ns-1"
r.RuleGroup = "group-1"
r.UID = "rule-1"
}, withClassicConditionSingleQuery()).GenerateRef()
fakeAIM.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State {
s.State = eval.Alerting
s.Labels = data.Labels{"test": "label"}
return s
})
fakeStore.PutRule(context.Background(), rule)
fakeProvisioning.GetProvenancesByUIDsFunc = func(ctx context.Context, orgID int64, resourceType string, uids []string) (map[string]ngmodels.Provenance, error) {
return nil, errors.New("database connection failed")
}
req, err := http.NewRequest("GET", "/api/v1/rules?group_limit=10", nil)
require.NoError(t, err)
c := &contextmodel.ReqContext{
Context: &web.Context{Req: req},
SignedInUser: &user.SignedInUser{
OrgID: orgID,
Permissions: queryPermissions,
},
}
resp := api.RouteGetRuleStatuses(c)
require.Equal(t, http.StatusInternalServerError, resp.Status())
var res apimodels.RuleResponse
require.NoError(t, json.Unmarshal(resp.Body(), &res))
require.Equal(t, "error", res.Status)
require.Contains(t, res.Error, "failed to load provenance")
})
t.Run("provenance fetch error returns error response in non-paginated mode", func(t *testing.T) {
fakeStore, fakeAIM, api, fakeProvisioning := setupAPIFull(t)
rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) {
r.NamespaceUID = "ns-1"
r.RuleGroup = "group-1"
r.UID = "rule-1"
}, withClassicConditionSingleQuery()).GenerateRef()
fakeAIM.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State {
s.State = eval.Alerting
s.Labels = data.Labels{"test": "label"}
return s
})
fakeStore.PutRule(context.Background(), rule)
fakeProvisioning.GetProvenancesFunc = func(ctx context.Context, orgID int64, resourceType string) (map[string]ngmodels.Provenance, error) {
return nil, errors.New("database connection failed")
}
req, err := http.NewRequest("GET", "/api/v1/rules", nil)
require.NoError(t, err)
c := &contextmodel.ReqContext{
Context: &web.Context{Req: req},
SignedInUser: &user.SignedInUser{
OrgID: orgID,
Permissions: queryPermissions,
},
}
resp := api.RouteGetRuleStatuses(c)
require.Equal(t, http.StatusInternalServerError, resp.Status())
var res apimodels.RuleResponse
require.NoError(t, json.Unmarshal(resp.Body(), &res))
require.Equal(t, "error", res.Status)
require.Contains(t, res.Error, "failed to load provenance")
})
t.Run("state filter continues when first page has no matches", func(t *testing.T) {
fakeStore, fakeAIM, api := setupAPI(t)
@@ -493,6 +493,7 @@ func TestValidateRuleNode_NoUID(t *testing.T) {
r.GrafanaManagedAlert.NoDataState = apimodels.OK
r.GrafanaManagedAlert.ExecErrState = apimodels.AlertingErrState
r.GrafanaManagedAlert.NotificationSettings = &apimodels.AlertRuleNotificationSettings{}
r.GrafanaManagedAlert.MissingSeriesEvalsToResolve = util.Pointer[int64](1)
r.For = func() *model.Duration { five := model.Duration(time.Second * 5); return &five }()
r.KeepFiringFor = func() *model.Duration { five := model.Duration(time.Second * 5); return &five }()
return &r
@@ -502,6 +503,7 @@ func TestValidateRuleNode_NoUID(t *testing.T) {
require.Empty(t, alert.NoDataState)
require.Empty(t, alert.ExecErrState)
require.Nil(t, alert.NotificationSettings)
require.Nil(t, alert.MissingSeriesEvalsToResolve)
require.Zero(t, alert.For)
require.Zero(t, alert.KeepFiringFor)
},
+47 -37
View File
@@ -189,42 +189,11 @@ func AlertRuleExportFromAlertRule(rule models.AlertRule) (definitions.AlertRuleE
data = append(data, query)
}
cPtr := &rule.Condition
if rule.Condition == "" {
cPtr = nil
}
noDataState := definitions.NoDataState(rule.NoDataState)
ndsPtr := &noDataState
if noDataState == "" {
ndsPtr = nil
}
execErrorState := definitions.ExecutionErrorState(rule.ExecErrState)
eesPtr := &execErrorState
if execErrorState == "" {
eesPtr = nil
}
result := definitions.AlertRuleExport{
UID: rule.UID,
Title: rule.Title,
For: model.Duration(rule.For),
KeepFiringFor: model.Duration(rule.KeepFiringFor),
Condition: cPtr,
Data: data,
DashboardUID: rule.DashboardUID,
PanelID: rule.PanelID,
NoDataState: ndsPtr,
ExecErrState: eesPtr,
IsPaused: rule.IsPaused,
NotificationSettings: AlertRuleNotificationSettingsExportFromNotificationSettings(rule.NotificationSettings),
Record: AlertRuleRecordExportFromRecord(rule.Record),
}
if rule.For.Seconds() > 0 {
result.ForString = util.Pointer(model.Duration(rule.For).String())
}
if rule.KeepFiringFor.Seconds() > 0 {
result.KeepFiringForString = util.Pointer(model.Duration(rule.KeepFiringFor).String())
UID: rule.UID,
Title: rule.Title,
Data: data,
IsPaused: rule.IsPaused,
}
if rule.Annotations != nil {
result.Annotations = &rule.Annotations
@@ -232,13 +201,54 @@ func AlertRuleExportFromAlertRule(rule models.AlertRule) (definitions.AlertRuleE
if rule.Labels != nil {
result.Labels = &rule.Labels
}
if rule.MissingSeriesEvalsToResolve != nil && *rule.MissingSeriesEvalsToResolve != -1 {
result.MissingSeriesEvalsToResolve = rule.MissingSeriesEvalsToResolve
if rule.Type() == models.RuleTypeRecording {
populateRecordingRuleExportFields(rule, &result)
} else {
populateAlertingRuleExportFields(rule, &result)
}
return result, nil
}
func populateRecordingRuleExportFields(rule models.AlertRule, result *definitions.AlertRuleExport) {
result.Record = AlertRuleRecordExportFromRecord(rule.Record)
}
func populateAlertingRuleExportFields(rule models.AlertRule, result *definitions.AlertRuleExport) {
result.DashboardUID = rule.DashboardUID
result.PanelID = rule.PanelID
result.NotificationSettings = AlertRuleNotificationSettingsExportFromNotificationSettings(rule.NotificationSettings)
if rule.Condition != "" {
result.Condition = &rule.Condition
}
if rule.NoDataState != "" {
noDataState := definitions.NoDataState(rule.NoDataState)
result.NoDataState = &noDataState
}
if rule.ExecErrState != "" {
execErrorState := definitions.ExecutionErrorState(rule.ExecErrState)
result.ExecErrState = &execErrorState
}
result.For = model.Duration(rule.For)
if rule.For > 0 {
result.ForString = util.Pointer(model.Duration(rule.For).String())
}
result.KeepFiringFor = model.Duration(rule.KeepFiringFor)
if rule.KeepFiringFor > 0 {
result.KeepFiringForString = util.Pointer(model.Duration(rule.KeepFiringFor).String())
}
if rule.MissingSeriesEvalsToResolve != nil && *rule.MissingSeriesEvalsToResolve != -1 {
result.MissingSeriesEvalsToResolve = rule.MissingSeriesEvalsToResolve
}
}
func encodeQueryModel(m map[string]any) (string, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
@@ -9,6 +9,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/util"
)
func TestToModel(t *testing.T) {
@@ -115,6 +116,102 @@ func TestToModel(t *testing.T) {
})
}
func TestAlertRuleExportFromAlertRule(t *testing.T) {
alertingRule := models.RuleGen.With(
models.RuleGen.WithNotEmptyLabels(2, "lbl-"),
models.RuleGen.WithAnnotations(map[string]string{"ann-key": "ann-value"}),
models.RuleGen.WithFor(2*time.Minute),
models.RuleGen.WithKeepFiringFor(5*time.Minute),
models.RuleGen.WithNotificationSettingsGen(models.NotificationSettingsGen()),
).Generate()
recordingRule := models.RuleGen.With(
models.RuleGen.WithAllRecordingRules(),
models.RuleGen.WithNotEmptyLabels(2, "lbl-"),
models.RuleGen.WithAnnotations(map[string]string{"ann-key": "ann-value"}),
).Generate()
// Build expected exported recording rule
recordingRuleData, err := AlertQueryExportFromAlertQuery(recordingRule.Data[0])
require.NoError(t, err)
expectedRecordingRuleExport := definitions.AlertRuleExport{
UID: recordingRule.UID,
Title: recordingRule.Title,
Data: []definitions.AlertQueryExport{recordingRuleData},
Annotations: &recordingRule.Annotations,
Labels: &recordingRule.Labels,
Record: &definitions.AlertRuleRecordExport{
Metric: recordingRule.Record.Metric,
From: recordingRule.Record.From,
TargetDatasourceUID: util.Pointer(recordingRule.Record.TargetDatasourceUID),
},
}
// Build expected exported alerting rule
alertingRuleData, err := AlertQueryExportFromAlertQuery(alertingRule.Data[0])
require.NoError(t, err)
noDataState := definitions.NoDataState(alertingRule.NoDataState)
execErrState := definitions.ExecutionErrorState(alertingRule.ExecErrState)
expectedAlertingRuleExport := definitions.AlertRuleExport{
UID: alertingRule.UID,
Title: alertingRule.Title,
Condition: &alertingRule.Condition,
Data: []definitions.AlertQueryExport{alertingRuleData},
DashboardUID: alertingRule.DashboardUID,
PanelID: alertingRule.PanelID,
NoDataState: &noDataState,
ExecErrState: &execErrState,
For: prommodel.Duration(alertingRule.For),
KeepFiringFor: prommodel.Duration(alertingRule.KeepFiringFor),
ForString: util.Pointer(prommodel.Duration(alertingRule.For).String()),
KeepFiringForString: util.Pointer(prommodel.Duration(alertingRule.KeepFiringFor).String()),
Annotations: &alertingRule.Annotations,
Labels: &alertingRule.Labels,
NotificationSettings: AlertRuleNotificationSettingsExportFromNotificationSettings(alertingRule.NotificationSettings),
MissingSeriesEvalsToResolve: alertingRule.MissingSeriesEvalsToResolve,
}
testCases := []struct {
name string
rule models.AlertRule
expected definitions.AlertRuleExport
}{
{
name: "export recording rule",
rule: recordingRule,
expected: expectedRecordingRuleExport,
},
{
name: "export alerting rule",
rule: alertingRule,
expected: expectedAlertingRuleExport,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
exported, err := AlertRuleExportFromAlertRule(tc.rule)
require.NoError(t, err)
require.Equal(t, tc.expected, exported)
})
}
}
func TestAlertQueryExportFromAlertQuery(t *testing.T) {
query := models.RuleGen.GenerateQuery()
exported, err := AlertQueryExportFromAlertQuery(query)
require.NoError(t, err)
require.Equal(t, query.RefID, exported.RefID)
require.Equal(t, query.DatasourceUID, exported.DatasourceUID)
require.Equal(t, int64(time.Duration(query.RelativeTimeRange.From).Seconds()), exported.RelativeTimeRange.FromSeconds)
require.Equal(t, int64(time.Duration(query.RelativeTimeRange.To).Seconds()), exported.RelativeTimeRange.ToSeconds)
require.NotNil(t, exported.QueryType)
require.Equal(t, query.QueryType, *exported.QueryType)
require.NotNil(t, exported.Model)
require.NotEmpty(t, exported.ModelString)
}
func TestAlertRuleMetadataFromModelMetadata(t *testing.T) {
t.Run("should convert model metadata to api metadata", func(t *testing.T) {
modelMetadata := models.AlertRuleMetadata{
@@ -54,6 +54,7 @@ type StatusReader interface {
type ProvenanceStore interface {
GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]ngmodels.Provenance, error)
GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]ngmodels.Provenance, error)
}
type PrometheusSrv struct {
@@ -328,14 +329,6 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon
span.AddEvent("User permissions checked")
span.SetAttributes(attribute.Int("allowedNamespaces", len(allowedNamespaces)))
provenanceRecords, err := srv.provenanceStore.GetProvenances(c.Req.Context(), c.GetOrgID(), (&ngmodels.AlertRule{}).ResourceType())
if err != nil {
ruleResponse.Status = "error"
ruleResponse.Error = fmt.Sprintf("failed to get provenances visible to the user: %s", err.Error())
ruleResponse.ErrorType = apiv1.ErrServer
return response.JSON(ruleResponse.HTTPStatusCode(), ruleResponse)
}
ruleResponse = PrepareRuleGroupStatusesV2(
srv.log,
srv.store,
@@ -347,7 +340,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon
},
RuleStatusMutatorGenerator(srv.status),
RuleAlertStateMutatorGenerator(srv.manager),
provenanceRecords,
srv.provenanceStore,
)
return response.JSON(ruleResponse.HTTPStatusCode(), ruleResponse)
@@ -454,6 +447,7 @@ func RuleAlertStateMutatorGenerator(manager state.AlertInstanceManager) RuleAler
type paginationContext struct {
opts RuleGroupStatusesOptions
provenanceRecords map[string]ngmodels.Provenance
provenanceStore ProvenanceStore
ruleStatusMutator RuleStatusMutator
alertStateMutator RuleAlertStateMutator
@@ -532,6 +526,37 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert
)
span.AddEvent("Alert rules retrieved from store")
// Load provenance for this page's rules
if ctx.provenanceStore != nil {
maxGroups := getInt64WithDefault(ctx.opts.Query, "group_limit", -1)
maxRules := getInt64WithDefault(ctx.opts.Query, "rule_limit", -1)
if maxGroups > 0 || maxRules > 0 {
// Paginated, fetch and merge provenances for this page
uids := make([]string, 0, len(ruleList))
for _, rule := range ruleList {
uids = append(uids, rule.UID)
}
pageProvenances, err := ctx.provenanceStore.GetProvenancesByUIDs(ctx.opts.Ctx, ctx.opts.OrgID, (&ngmodels.AlertRule{}).ResourceType(), uids)
if err != nil {
return pageResult{}, fmt.Errorf("failed to load provenance: %w", err)
}
if ctx.provenanceRecords == nil {
ctx.provenanceRecords = pageProvenances
} else {
maps.Copy(ctx.provenanceRecords, pageProvenances)
}
} else if ctx.provenanceRecords == nil {
// Not paginated, fetch all once
var err error
ctx.provenanceRecords, err = ctx.provenanceStore.GetProvenances(ctx.opts.Ctx, ctx.opts.OrgID, (&ngmodels.AlertRule{}).ResourceType())
if err != nil {
return pageResult{}, fmt.Errorf("failed to load provenance: %w", err)
}
}
}
span.AddEvent("Provenances retrieved from store")
groupedRules := getGroupedRules(log, ruleList, ctx.ruleNamesSet, ctx.opts.AllowedNamespaces)
result := pageResult{
@@ -643,7 +668,7 @@ func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *pagina
return allGroups, rulesTotals, continueToken, nil
}
func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse {
func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceStore ProvenanceStore) apimodels.RuleResponse {
ctx, span := tracer.Start(opts.Ctx, "api.prometheus.PrepareRuleGroupStatusesV2")
defer span.End()
opts.Ctx = ctx
@@ -835,7 +860,8 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
span.SetAttributes(attribute.Bool("compact", compact))
pagCtx := &paginationContext{
opts: opts,
provenanceRecords: provenanceRecords,
provenanceRecords: nil,
provenanceStore: provenanceStore,
ruleStatusMutator: ruleStatusMutator,
alertStateMutator: alertStateMutator,
namespaceUIDs: namespaceUIDs,
@@ -173,4 +173,49 @@ resource "grafana_rule_group" "rule_group_d3e8424bfbf66bc3" {
from = "condition"
}
}
rule {
name = "recording rule with target"
data {
ref_id = "query"
relative_time_range {
from = 18000
to = 10800
}
datasource_uid = "000000002"
model = "{\"expr\":\"rate(http_requests_total[5m])\",\"hide\":false,\"interval\":\"\",\"intervalMs\":1000,\"legendFormat\":\"\",\"maxDataPoints\":100,\"refId\":\"query\"}"
}
data {
ref_id = "reduced"
relative_time_range {
from = 18000
to = 10800
}
datasource_uid = "__expr__"
model = "{\"expression\":\"query\",\"hide\":false,\"intervalMs\":1000,\"maxDataPoints\":100,\"reducer\":\"mean\",\"refId\":\"reduced\",\"type\":\"reduce\"}"
}
data {
ref_id = "condition"
relative_time_range {
from = 18000
to = 10800
}
datasource_uid = "__expr__"
model = "{\"expression\":\"$reduced > 5\",\"hide\":false,\"intervalMs\":1000,\"maxDataPoints\":100,\"refId\":\"condition\",\"type\":\"math\"}"
}
is_paused = false
record {
metric = "http_requests_rate"
from = "condition"
target_datasource_uid = "000000003"
}
}
}
@@ -233,6 +233,67 @@
"metric": "test_metric",
"from": "condition"
}
},
{
"title": "recording rule with target",
"data": [
{
"refId": "query",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid": "000000002",
"model": {
"expr": "rate(http_requests_total[5m])",
"hide": false,
"interval": "",
"intervalMs": 1000,
"legendFormat": "",
"maxDataPoints": 100,
"refId": "query"
}
},
{
"refId": "reduced",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid": "__expr__",
"model": {
"expression": "query",
"hide": false,
"intervalMs": 1000,
"maxDataPoints": 100,
"reducer": "mean",
"refId": "reduced",
"type": "reduce"
}
},
{
"refId": "condition",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid": "__expr__",
"model": {
"expression": "$reduced \u003e 5",
"hide": false,
"intervalMs": 1000,
"maxDataPoints": 100,
"refId": "condition",
"type": "math"
}
}
],
"isPaused": false,
"record": {
"metric": "http_requests_rate",
"from": "condition",
"targetDatasourceUid": "000000003"
}
}
]
}
@@ -187,3 +187,48 @@ groups:
record:
metric: test_metric
from: condition
- title: recording rule with target
data:
- refId: query
relativeTimeRange:
from: 18000
to: 10800
datasourceUid: "000000002"
model:
expr: rate(http_requests_total[5m])
hide: false
interval: ""
intervalMs: 1000
legendFormat: ""
maxDataPoints: 100
refId: query
- refId: reduced
relativeTimeRange:
from: 18000
to: 10800
datasourceUid: __expr__
model:
expression: query
hide: false
intervalMs: 1000
maxDataPoints: 100
reducer: mean
refId: reduced
type: reduce
- refId: condition
relativeTimeRange:
from: 18000
to: 10800
datasourceUid: __expr__
model:
expression: $reduced > 5
hide: false
intervalMs: 1000
maxDataPoints: 100
refId: condition
type: math
isPaused: false
record:
metric: http_requests_rate
from: condition
targetDatasourceUid: "000000003"
@@ -240,6 +240,71 @@
"from": "condition"
}
}
},
{
"grafana_alert": {
"title": "recording rule with target",
"data": [
{
"refId": "query",
"queryType": "",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid": "000000002",
"model": {
"expr": "rate(http_requests_total[5m])",
"hide": false,
"interval": "",
"intervalMs": 1000,
"legendFormat": "",
"maxDataPoints": 100,
"refId": "query"
}
},
{
"refId": "reduced",
"queryType": "",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid": "__expr__",
"model": {
"expression": "query",
"hide": false,
"intervalMs": 1000,
"maxDataPoints": 100,
"reducer": "mean",
"refId": "reduced",
"type": "reduce"
}
},
{
"refId": "condition",
"queryType": "",
"relativeTimeRange": {
"from": 18000,
"to": 10800
},
"datasourceUid": "__expr__",
"model": {
"expression": "$reduced > 5",
"hide": false,
"intervalMs": 1000,
"maxDataPoints": 100,
"refId": "condition",
"type": "math"
}
}
],
"record": {
"metric": "http_requests_rate",
"from": "condition",
"target_datasource_uid": "000000003"
}
}
}
]
}
@@ -193,6 +193,7 @@ func validateRecordingRuleFields(in *apimodels.PostableExtendedRuleNode, newRule
newRule.For = 0
newRule.KeepFiringFor = 0
newRule.NotificationSettings = nil
newRule.MissingSeriesEvalsToResolve = nil
return newRule, nil
}
+1 -54
View File
@@ -169,62 +169,9 @@ func HasIntegrationsDifferentProtectedFields(existing, incoming *Integration) []
var result []schema.IntegrationFieldPath
settingsDiff := diff.GetSettingsPaths()
for _, path := range settingsDiff {
if IsProtectedField(incoming.Config.Type(), path) {
if incoming.Config.IsProtectedField(path) {
result = append(result, path)
}
}
return result
}
// IsProtectedField returns true if the field at the given path is existing protected one.
// This includes:
// 1. URL fields marked as secure in the schema (e.g., webhook URLs with credentials)
// 2. URL fields NOT marked as secure but could contain credentials (e.g., API endpoints)
func IsProtectedField(integrationType schema.IntegrationType, path schema.IntegrationFieldPath) bool {
str := strings.ToLower(string(integrationType))
pathStr := path.String()
switch str {
case "prometheus-alertmanager":
return pathStr == "url"
case "dingding":
return pathStr == "url" // marked as secure
case "discord":
return pathStr == "url" // marked as secure (webhook URL)
case "googlechat":
return pathStr == "url" // marked as secure
case "jira":
return pathStr == "api_url"
case "kafka":
return pathStr == "kafkaRestProxy"
case "line":
return false
case "mqtt":
return pathStr == "brokerUrl"
case "oncall":
return pathStr == "url"
case "opsgenie":
return pathStr == "apiUrl"
case "pagerduty":
return pathStr == "url"
case "sensugo":
return pathStr == "url"
case "slack":
return pathStr == "url" || pathStr == "endpointUrl"
case "teams":
return pathStr == "url"
case "victorops":
return pathStr == "url" // marked as secure
case "webex":
return pathStr == "api_url"
case "webhook":
return pathStr == "url" ||
pathStr == "http_config.oauth2.token_url" ||
pathStr == "http_config.oauth2.proxy_config.proxy_url"
case "wecom":
return pathStr == "url" || // marked as secure
pathStr == "endpointUrl"
default:
return false
}
}
@@ -485,6 +485,7 @@ func assignReceiverConfigsUIDs(c []*definitions.PostableApiReceiver) error {
type provisioningStore interface {
GetProvenance(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error)
GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error)
GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error)
SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error
DeleteProvenance(ctx context.Context, o models.Provisionable, org int64) error
}
+5 -5
View File
@@ -272,16 +272,16 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom
RuleGroup: promGroup.Name,
IsPaused: isPaused,
Record: record,
}
if !isRecordingRule {
result.NotificationSettings = p.cfg.NotificationSettings
// MissingSeriesEvalsToResolve is set to 1 to match the Prometheus behaviour.
// Prometheus resolves alerts as soon as the series disappears.
// By setting this value to 1 we ensure that the alert is resolved on the first evaluation
// that doesn't have the series.
MissingSeriesEvalsToResolve: util.Pointer[int64](1),
}
if !isRecordingRule {
result.NotificationSettings = p.cfg.NotificationSettings
result.MissingSeriesEvalsToResolve = util.Pointer[int64](1)
}
if p.cfg.KeepOriginalRuleDefinition != nil && *p.cfg.KeepOriginalRuleDefinition {
+6 -1
View File
@@ -358,7 +358,12 @@ func TestPrometheusRulesToGrafana(t *testing.T) {
require.Equal(t, models.Duration(evalOffset), grafanaRule.Data[0].RelativeTimeRange.To)
require.Equal(t, models.Duration(10*time.Minute+evalOffset), grafanaRule.Data[0].RelativeTimeRange.From)
require.Equal(t, util.Pointer(int64(1)), grafanaRule.MissingSeriesEvalsToResolve)
if promRule.Record != "" {
require.Nil(t, grafanaRule.MissingSeriesEvalsToResolve)
} else {
require.Equal(t, util.Pointer(int64(1)), grafanaRule.MissingSeriesEvalsToResolve)
}
require.Equal(t, models.OkErrState, grafanaRule.ExecErrState)
require.Equal(t, models.OK, grafanaRule.NoDataState)
@@ -19,6 +19,7 @@ type alertmanagerConfigStore interface {
type ProvisioningStore interface {
GetProvenance(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error)
GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error)
GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error)
SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error
DeleteProvenance(ctx context.Context, o models.Provisionable, org int64) error
}
@@ -188,6 +188,67 @@ func (_c *MockProvisioningStore_GetProvenances_Call) RunAndReturn(run func(conte
return _c
}
// GetProvenancesByUIDs provides a mock function with given fields: ctx, org, resourceType, uids
func (_m *MockProvisioningStore) GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) {
ret := _m.Called(ctx, org, resourceType, uids)
if len(ret) == 0 {
panic("no return value specified for GetProvenancesByUIDs")
}
var r0 map[string]models.Provenance
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int64, string, []string) (map[string]models.Provenance, error)); ok {
return rf(ctx, org, resourceType, uids)
}
if rf, ok := ret.Get(0).(func(context.Context, int64, string, []string) map[string]models.Provenance); ok {
r0 = rf(ctx, org, resourceType, uids)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[string]models.Provenance)
}
}
if rf, ok := ret.Get(1).(func(context.Context, int64, string, []string) error); ok {
r1 = rf(ctx, org, resourceType, uids)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockProvisioningStore_GetProvenancesByUIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProvenancesByUIDs'
type MockProvisioningStore_GetProvenancesByUIDs_Call struct {
*mock.Call
}
// GetProvenancesByUIDs is a helper method to define mock.On call
// - ctx context.Context
// - org int64
// - resourceType string
// - uids []string
func (_e *MockProvisioningStore_Expecter) GetProvenancesByUIDs(ctx interface{}, org interface{}, resourceType interface{}, uids interface{}) *MockProvisioningStore_GetProvenancesByUIDs_Call {
return &MockProvisioningStore_GetProvenancesByUIDs_Call{Call: _e.mock.On("GetProvenancesByUIDs", ctx, org, resourceType, uids)}
}
func (_c *MockProvisioningStore_GetProvenancesByUIDs_Call) Run(run func(ctx context.Context, org int64, resourceType string, uids []string)) *MockProvisioningStore_GetProvenancesByUIDs_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(int64), args[2].(string), args[3].([]string))
})
return _c
}
func (_c *MockProvisioningStore_GetProvenancesByUIDs_Call) Return(_a0 map[string]models.Provenance, _a1 error) *MockProvisioningStore_GetProvenancesByUIDs_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockProvisioningStore_GetProvenancesByUIDs_Call) RunAndReturn(run func(context.Context, int64, string, []string) (map[string]models.Provenance, error)) *MockProvisioningStore_GetProvenancesByUIDs_Call {
_c.Call.Return(run)
return _c
}
// SetProvenance provides a mock function with given fields: ctx, o, org, p
func (_m *MockProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error {
ret := _m.Called(ctx, o, org, p)
+9 -7
View File
@@ -101,13 +101,13 @@ func (e *Evaluation) Fingerprint() fingerprint {
type alertRulesRegistry struct {
rules map[models.AlertRuleKey]*models.AlertRule
folderTitles map[models.FolderKey]string
mu sync.Mutex
mu sync.RWMutex
}
// all returns all rules in the registry.
func (r *alertRulesRegistry) all() ([]*models.AlertRule, map[models.FolderKey]string) {
r.mu.Lock()
defer r.mu.Unlock()
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]*models.AlertRule, 0, len(r.rules))
for _, rule := range r.rules {
result = append(result, rule)
@@ -116,8 +116,8 @@ func (r *alertRulesRegistry) all() ([]*models.AlertRule, map[models.FolderKey]st
}
func (r *alertRulesRegistry) get(k models.AlertRuleKey) *models.AlertRule {
r.mu.Lock()
defer r.mu.Unlock()
r.mu.RLock()
defer r.mu.RUnlock()
return r.rules[k]
}
@@ -157,12 +157,14 @@ func (r *alertRulesRegistry) del(k models.AlertRuleKey) (*models.AlertRule, bool
}
func (r *alertRulesRegistry) isEmpty() bool {
r.mu.Lock()
defer r.mu.Unlock()
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.rules) == 0
}
func (r *alertRulesRegistry) needsUpdate(keys []models.AlertRuleKeyWithVersion) bool {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.rules) != len(keys) {
return true
}
+6 -4
View File
@@ -232,9 +232,7 @@ func (st *Manager) Get(orgID int64, alertRuleUID string, stateId data.Fingerprin
return st.cache.get(orgID, alertRuleUID, stateId)
}
// DeleteStateByRuleUID removes the rule instances from cache and instanceStore. A closed channel is returned to be able
// to gracefully handle the clear state step in scheduler in case we do not need to use the historian to save state
// history.
// DeleteStateByRuleUID removes the rule instances from cache and instanceStore.
func (st *Manager) DeleteStateByRuleUID(ctx context.Context, ruleKey ngModels.AlertRuleKeyWithGroup, reason string) []StateTransition {
logger := st.log.FromContext(ctx)
logger.Debug("Resetting state of the rule")
@@ -292,10 +290,14 @@ func (st *Manager) ForgetStateByRuleUID(ctx context.Context, ruleKey ngModels.Al
// ResetStateByRuleUID removes the rule instances from cache and instanceStore and saves state history. If the state
// history has to be saved, rule must not be nil.
func (st *Manager) ResetStateByRuleUID(ctx context.Context, rule *ngModels.AlertRule, reason string) []StateTransition {
if rule == nil {
return nil
}
ruleKey := rule.GetKeyWithGroup()
transitions := st.DeleteStateByRuleUID(ctx, ruleKey, reason)
if rule == nil || st.historian == nil || len(transitions) == 0 {
if st.historian == nil || len(transitions) == 0 {
return transitions
}
@@ -2051,6 +2051,39 @@ func TestIntegrationResetStateByRuleUID(t *testing.T) {
}
}
func TestResetStateByRuleUID(t *testing.T) {
ctx := context.Background()
setupManager := func(historian state.Historian) *state.Manager {
cfg := state.ManagerCfg{
Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(),
ExternalURL: nil,
InstanceStore: &state.FakeInstanceStore{},
Images: &state.NoopImageService{},
Clock: clock.NewMock(),
Historian: historian,
Tracer: tracing.InitializeTracerForTest(),
Log: log.New("ngalert.state.manager"),
}
return state.NewManager(cfg, state.NewNoopPersister())
}
t.Run("with nil historian", func(t *testing.T) {
manager := setupManager(nil)
transitions := manager.ResetStateByRuleUID(ctx, nil, "test reason")
require.Empty(t, transitions)
})
t.Run("with historian", func(t *testing.T) {
manager := setupManager(&state.FakeHistorian{})
transitions := manager.ResetStateByRuleUID(ctx, nil, "test reason")
require.Empty(t, transitions)
})
}
func setCacheID(s *state.State) *state.State {
if s.CacheID != 0 {
return s
@@ -73,42 +73,42 @@ func TestBuildLabelMatcherJSON(t *testing.T) {
name: "MySQL MatchEqual with non-empty value",
dialect: migrator.NewMysqlDialect(),
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"},
wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?",
wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ?",
wantArgs: []any{"team", "alerting"},
},
{
name: "MySQL MatchEqual with empty value",
dialect: migrator.NewMysqlDialect(),
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""},
wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ? OR JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL)",
wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ? OR JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL)",
wantArgs: []any{"team", "", "team"},
},
{
name: "MySQL MatchNotEqual",
dialect: migrator.NewMysqlDialect(),
matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"},
wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)",
wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) != ?)",
wantArgs: []any{"team", "team", "alerting"},
},
{
name: "PostgreSQL MatchEqual with non-empty value",
dialect: migrator.NewPostgresDialect(),
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"},
wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?",
wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) = ?",
wantArgs: []any{"team", "alerting"},
},
{
name: "PostgreSQL MatchEqual with empty value",
dialect: migrator.NewPostgresDialect(),
matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""},
wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) = ? OR jsonb_extract_path_text(labels::jsonb, ?) IS NULL)",
wantSQL: "(jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) = ? OR jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL)",
wantArgs: []any{"team", "", "team"},
},
{
name: "PostgreSQL MatchNotEqual",
dialect: migrator.NewPostgresDialect(),
matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"},
wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)",
wantSQL: "(jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL OR jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) != ?)",
wantArgs: []any{"team", "team", "alerting"},
},
{
@@ -2462,6 +2462,12 @@ func TestIntegration_ListAlertRules(t *testing.T) {
ruleNonempty := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{"empty": "nonempty"}),
ruleGen.WithTitle("rule_nonempty")))
// include a rule with no labels at all,
// to ensure we handle that case correctly.
// JSON functions need to be able to handle null and empty string values.
ruleNoLabels := createRule(t, store, ruleGen.With(
ruleGen.WithLabels(map[string]string{}),
ruleGen.WithTitle("rule_no_labels")))
tc := []struct {
name string
@@ -2487,7 +2493,7 @@ func TestIntegration_ListAlertRules(t *testing.T) {
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchNotEqual, "team", "alerting"); return m }(),
},
expectedRules: []*models.AlertRule{ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty},
expectedRules: []*models.AlertRule{ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty, ruleNoLabels},
},
{
name: "special characters in labels are handled correctly",
@@ -2536,7 +2542,7 @@ func TestIntegration_ListAlertRules(t *testing.T) {
labelMatchers: labels.Matchers{
func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "empty", ""); return m }(),
},
expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty},
expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNoLabels},
},
{
name: "inequality matcher on non-existent label matches all rules",
@@ -2546,7 +2552,7 @@ func TestIntegration_ListAlertRules(t *testing.T) {
return m
}(),
},
expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty},
expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty, ruleNoLabels},
},
}
+6 -6
View File
@@ -13,9 +13,9 @@ import (
func jsonEquals(dialect migrator.Dialect, column, key, value string) (string, []any) {
switch dialect.DriverName() {
case migrator.MySQL:
return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?))) = ?", column), []any{key, value}
return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?))) = ?", column), []any{key, value}
case migrator.Postgres:
return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) = ?", column), []any{key, value}
return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) = ?", column), []any{key, value}
default:
return "", nil
}
@@ -25,9 +25,9 @@ func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string,
var jx string
switch dialect.DriverName() {
case migrator.MySQL:
jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?)))", column)
jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)))", column)
case migrator.Postgres:
jx = fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?)", column)
jx = fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?)", column)
default:
return "", nil
}
@@ -37,9 +37,9 @@ func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string,
func jsonKeyMissing(dialect migrator.Dialect, column, key string) (string, []any) {
switch dialect.DriverName() {
case migrator.MySQL:
return fmt.Sprintf("JSON_EXTRACT(%s, CONCAT('$.', ?)) IS NULL", column), []any{key}
return fmt.Sprintf("JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)) IS NULL", column), []any{key}
case migrator.Postgres:
return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) IS NULL", column), []any{key}
return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) IS NULL", column), []any{key}
default:
return "", nil
}
+6 -6
View File
@@ -23,7 +23,7 @@ func TestJsonEquals(t *testing.T) {
column: "labels",
key: "team",
value: "alerting",
wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?",
wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ?",
wantArgs: []any{"team", "alerting"},
},
{
@@ -32,7 +32,7 @@ func TestJsonEquals(t *testing.T) {
column: "labels",
key: "team",
value: "alerting",
wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?",
wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) = ?",
wantArgs: []any{"team", "alerting"},
},
}
@@ -62,7 +62,7 @@ func TestJsonNotEquals(t *testing.T) {
column: "labels",
key: "team",
value: "alerting",
wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)",
wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) != ?)",
wantArgs: []any{"team", "team", "alerting"},
},
{
@@ -71,7 +71,7 @@ func TestJsonNotEquals(t *testing.T) {
column: "labels",
key: "team",
value: "alerting",
wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)",
wantSQL: "(jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL OR jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) != ?)",
wantArgs: []any{"team", "team", "alerting"},
},
}
@@ -99,7 +99,7 @@ func TestJsonKeyMissing(t *testing.T) {
dialect: migrator.NewMysqlDialect(),
column: "labels",
key: "team",
wantSQL: "JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL",
wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL",
wantArgs: []any{"team"},
},
{
@@ -107,7 +107,7 @@ func TestJsonKeyMissing(t *testing.T) {
dialect: migrator.NewPostgresDialect(),
column: "labels",
key: "team",
wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) IS NULL",
wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL",
wantArgs: []any{"team"},
},
}
@@ -62,6 +62,30 @@ func (st DBstore) GetProvenances(ctx context.Context, org int64, resourceType st
return resultMap, err
}
// GetProvenancesByUIDs gets the provenance status for specific UIDs.
func (st DBstore) GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) {
if len(uids) == 0 {
return map[string]models.Provenance{}, nil
}
result := make(map[string]models.Provenance, len(uids))
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
rawData, err := sess.Table(provenanceRecord{}).
Where("record_type = ? AND org_id = ?", resourceType, org).
In("record_key", uids).
Cols("record_key", "provenance").
QueryString()
if err != nil {
return fmt.Errorf("failed to query for existing provenance status: %w", err)
}
for _, data := range rawData {
result[data["record_key"]] = models.Provenance(data["provenance"])
}
return nil
})
return result, err
}
// SetProvenance changes the provenance status for a provisionable object.
func (st DBstore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error {
recordType := o.ResourceType()
@@ -133,6 +133,55 @@ func TestIntegrationProvisioningStore(t *testing.T) {
require.Equal(t, models.ProvenanceAPI, p[rule2.UID])
})
t.Run("Store should return provenances by UIDs", func(t *testing.T) {
const orgID = 124
rule1 := models.AlertRule{UID: "uid-1", OrgID: orgID}
rule2 := models.AlertRule{UID: "uid-2", OrgID: orgID}
rule3 := models.AlertRule{UID: "uid-3", OrgID: orgID}
err := store.SetProvenance(context.Background(), &rule1, orgID, models.ProvenanceFile)
require.NoError(t, err)
err = store.SetProvenance(context.Background(), &rule2, orgID, models.ProvenanceAPI)
require.NoError(t, err)
err = store.SetProvenance(context.Background(), &rule3, orgID, models.ProvenanceFile)
require.NoError(t, err)
// Fetch only rule1 and rule2
p, err := store.GetProvenancesByUIDs(context.Background(), orgID, rule1.ResourceType(), []string{rule1.UID, rule2.UID})
require.NoError(t, err)
require.Len(t, p, 2)
require.Equal(t, models.ProvenanceFile, p[rule1.UID])
require.Equal(t, models.ProvenanceAPI, p[rule2.UID])
_, exists := p[rule3.UID]
require.False(t, exists)
})
t.Run("GetProvenancesByUIDs returns empty map for empty UIDs", func(t *testing.T) {
p, err := store.GetProvenancesByUIDs(context.Background(), 1, "alertRule", []string{})
require.NoError(t, err)
require.Empty(t, p)
})
t.Run("GetProvenancesByUIDs respects org ID", func(t *testing.T) {
const orgID1 = 125
const orgID2 = 126
rule := models.AlertRule{UID: "cross-org-uid"}
err := store.SetProvenance(context.Background(), &rule, orgID1, models.ProvenanceFile)
require.NoError(t, err)
// Should not find in different org
p, err := store.GetProvenancesByUIDs(context.Background(), orgID2, rule.ResourceType(), []string{rule.UID})
require.NoError(t, err)
require.Empty(t, p)
// Should find in correct org
p, err = store.GetProvenancesByUIDs(context.Background(), orgID1, rule.ResourceType(), []string{rule.UID})
require.NoError(t, err)
require.Len(t, p, 1)
require.Equal(t, models.ProvenanceFile, p[rule.UID])
})
t.Run("Store should delete provenance correctly", func(t *testing.T) {
const orgID = 1234
ruleOrg := models.AlertRule{
@@ -8,12 +8,13 @@ import (
)
type FakeProvisioningStore struct {
Calls []Call
Records map[int64]map[string]models.Provenance
GetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error)
GetProvenancesFunc func(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error)
SetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error
DeleteProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) error
Calls []Call
Records map[int64]map[string]models.Provenance
GetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error)
GetProvenancesFunc func(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error)
GetProvenancesByUIDsFunc func(ctx context.Context, orgID int64, resourceType string, uids []string) (map[string]models.Provenance, error)
SetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error
DeleteProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) error
}
func NewFakeProvisioningStore() *FakeProvisioningStore {
@@ -51,6 +52,23 @@ func (f *FakeProvisioningStore) GetProvenances(ctx context.Context, orgID int64,
return results, nil
}
func (f *FakeProvisioningStore) GetProvenancesByUIDs(ctx context.Context, orgID int64, resourceType string, uids []string) (map[string]models.Provenance, error) {
f.Calls = append(f.Calls, Call{MethodName: "GetProvenancesByUIDs", Arguments: []any{ctx, orgID, resourceType, uids}})
if f.GetProvenancesByUIDsFunc != nil {
return f.GetProvenancesByUIDsFunc(ctx, orgID, resourceType, uids)
}
results := make(map[string]models.Provenance)
if val, ok := f.Records[orgID]; ok {
for _, uid := range uids {
key := uid + resourceType
if prov, ok := val[key]; ok {
results[uid] = prov
}
}
}
return results, nil
}
func (f *FakeProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error {
f.Calls = append(f.Calls, Call{MethodName: "SetProvenance", Arguments: []any{ctx, o, org, p}})
if f.SetProvenanceFunc != nil {
@@ -19,6 +19,7 @@ const (
testFileDasboardTypoSupport = "./testdata/alert_rules/dasboard-typo-support"
testFileMultipleRules = "./testdata/alert_rules/multiple-rules"
testFileMultipleFiles = "./testdata/alert_rules/multiple-files"
testFileRecordingRules = "./testdata/alert_rules/recording-rules"
testFileCorrectProperties_cp = "./testdata/contact_points/correct-properties"
testFileCorrectPropertiesWithOrg_cp = "./testdata/contact_points/correct-properties-with-org"
testFileEmptyUID = "./testdata/contact_points/empty-uid"
@@ -188,4 +189,43 @@ func TestConfigReader(t *testing.T) {
}
})
})
t.Run("recording rules should parse correctly", func(t *testing.T) {
ruleFiles, err := configReader.readConfig(ctx, testFileRecordingRules)
require.NoError(t, err)
require.Len(t, ruleFiles, 2)
findRule := func(title string) *AlertingFile {
for _, rf := range ruleFiles {
if rf.Groups[0].Title == title {
return rf
}
}
return nil
}
ruleWithTarget := findRule("recording_rules_group")
require.NotNil(t, ruleWithTarget)
require.Len(t, ruleWithTarget.Groups, 1)
require.Len(t, ruleWithTarget.Groups[0].Rules, 1)
ruleWith := ruleWithTarget.Groups[0].Rules[0]
require.NotNil(t, ruleWith.Record)
require.Equal(t, "my_recorded_metric", ruleWith.Record.Metric)
require.Equal(t, "A", ruleWith.Record.From)
require.Equal(t, "mimir-uid", ruleWith.Record.TargetDatasourceUID)
ruleWithoutTarget := findRule("recording_rules_group_no_target")
require.NotNil(t, ruleWithoutTarget)
require.Len(t, ruleWithoutTarget.Groups, 1)
require.Len(t, ruleWithoutTarget.Groups[0].Rules, 1)
ruleWithout := ruleWithoutTarget.Groups[0].Rules[0]
require.NotNil(t, ruleWithout.Record)
require.Equal(t, "http_requests_rate", ruleWithout.Record.Metric)
require.Equal(t, "A", ruleWithout.Record.From)
require.Equal(t, "", ruleWithout.Record.TargetDatasourceUID)
})
}
@@ -303,13 +303,15 @@ func (nsV1 *NotificationSettingsV1) mapToModel() (models.NotificationSettings, e
}
type RecordV1 struct {
Metric values.StringValue `json:"metric" yaml:"metric"`
From values.StringValue `json:"from" yaml:"from"`
Metric values.StringValue `json:"metric" yaml:"metric"`
From values.StringValue `json:"from" yaml:"from"`
TargetDatasourceUID values.StringValue `json:"targetDatasourceUid" yaml:"targetDatasourceUid"`
}
func (record *RecordV1) mapToModel() (models.Record, error) {
return models.Record{
Metric: record.Metric.Value(),
From: record.From.Value(),
Metric: record.Metric.Value(),
From: record.From.Value(),
TargetDatasourceUID: record.TargetDatasourceUID.Value(),
}, nil
}
@@ -208,6 +208,15 @@ func TestRecordingRules(t *testing.T) {
_, err := rule.mapToModel(1)
require.NoError(t, err)
})
t.Run("a valid rule with empty targetDatasourceUid should not error", func(t *testing.T) {
rule := validRecordingRuleV1(t)
rule.Record.TargetDatasourceUID = stringToStringValue("")
model, err := rule.mapToModel(1)
require.NoError(t, err)
require.NotNil(t, model.Record)
require.Equal(t, "", model.Record.TargetDatasourceUID)
})
}
func TestNotificationsSettingsV1MapToModel(t *testing.T) {
@@ -307,80 +316,43 @@ func TestNotificationsSettingsV1MapToModel(t *testing.T) {
func validRuleGroupV1(t *testing.T) AlertRuleGroupV1 {
t.Helper()
var (
orgID values.Int64Value
name values.StringValue
folder values.StringValue
interval values.StringValue
)
var orgID values.Int64Value
err := yaml.Unmarshal([]byte("1"), &orgID)
require.NoError(t, err)
err = yaml.Unmarshal([]byte("Test"), &name)
require.NoError(t, err)
err = yaml.Unmarshal([]byte("Test"), &folder)
require.NoError(t, err)
err = yaml.Unmarshal([]byte("10s"), &interval)
require.NoError(t, err)
return AlertRuleGroupV1{
OrgID: orgID,
Name: name,
Folder: folder,
Interval: interval,
Name: stringToStringValue("Test"),
Folder: stringToStringValue("Test"),
Interval: stringToStringValue("10s"),
Rules: []AlertRuleV1{},
}
}
func validRuleV1(t *testing.T) AlertRuleV1 {
t.Helper()
var (
title values.StringValue
uid values.StringValue
forDuration values.StringValue
condition values.StringValue
)
err := yaml.Unmarshal([]byte("test"), &title)
require.NoError(t, err)
err = yaml.Unmarshal([]byte("test_uid"), &uid)
require.NoError(t, err)
err = yaml.Unmarshal([]byte("10s"), &forDuration)
require.NoError(t, err)
err = yaml.Unmarshal([]byte("A"), &condition)
require.NoError(t, err)
return AlertRuleV1{
Title: title,
UID: uid,
For: forDuration,
Condition: condition,
Title: stringToStringValue("test"),
UID: stringToStringValue("test_uid"),
For: stringToStringValue("10s"),
Condition: stringToStringValue("A"),
Data: []QueryV1{{}},
}
}
func validRecordingRuleV1(t *testing.T) AlertRuleV1 {
t.Helper()
var (
title values.StringValue
uid values.StringValue
forDuration values.StringValue
metric values.StringValue
from values.StringValue
)
err := yaml.Unmarshal([]byte("test"), &title)
require.NoError(t, err)
err = yaml.Unmarshal([]byte("test_uid"), &uid)
require.NoError(t, err)
err = yaml.Unmarshal([]byte("10s"), &forDuration)
require.NoError(t, err)
err = yaml.Unmarshal([]byte("test_metric"), &metric)
require.NoError(t, err)
err = yaml.Unmarshal([]byte("A"), &from)
require.NoError(t, err)
return AlertRuleV1{
Title: title,
UID: uid,
For: forDuration,
Title: stringToStringValue("test"),
UID: stringToStringValue("test_uid"),
For: stringToStringValue("10s"),
Record: &RecordV1{
Metric: metric,
From: from,
Metric: stringToStringValue("test_metric"),
From: stringToStringValue("A"),
TargetDatasourceUID: stringToStringValue("test_target_datasource"),
},
Data: []QueryV1{{}},
}
@@ -0,0 +1,26 @@
apiVersion: 1
groups:
- name: recording_rules_group
folder: my_folder
interval: 1m
rules:
- uid: recording_rule_with_target
title: my_recording_rule_with_target
condition: A
data:
- refId: A
queryType: ''
relativeTimeRange:
from: 600
to: 0
datasourceUid: prometheus-uid
model:
expr: up{instance="localhost:9090"}
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
record:
metric: my_recorded_metric
from: A
targetDatasourceUid: mimir-uid
@@ -0,0 +1,25 @@
apiVersion: 1
groups:
- name: recording_rules_group_no_target
folder: my_folder
interval: 1m
rules:
- uid: recording_rule_without_target
title: my_recording_rule_without_target
condition: A
data:
- refId: A
queryType: ''
relativeTimeRange:
from: 600
to: 0
datasourceUid: prometheus-uid
model:
expr: rate(http_requests_total[5m])
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
record:
metric: http_requests_rate
from: A
+4 -1
View File
@@ -190,6 +190,8 @@ type UnifiedAlertingReservedLabelSettings struct {
type UnifiedAlertingPrometheusConversionSettings struct {
// RuleQueryOffset defines a time offset to apply to rule queries during conversion from Prometheus to Grafana format
RuleQueryOffset time.Duration
// DefaultDatasourceUID is the default datasource UID to use when converting Prometheus rules if not specified via header
DefaultDatasourceUID string
}
type UnifiedAlertingLokiSettings struct {
@@ -536,7 +538,8 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error {
prometheusConversion := iniFile.Section("unified_alerting.prometheus_conversion")
uaCfg.PrometheusConversion = UnifiedAlertingPrometheusConversionSettings{
RuleQueryOffset: prometheusConversion.Key("rule_query_offset").MustDuration(time.Minute),
RuleQueryOffset: prometheusConversion.Key("rule_query_offset").MustDuration(time.Minute),
DefaultDatasourceUID: prometheusConversion.Key("default_datasource_uid").MustString(""),
}
rr := iniFile.Section("recording_rules")
@@ -93,6 +93,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": true,
"dependsOn": "",
@@ -225,6 +226,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": true,
"dependsOn": "",
@@ -1300,6 +1302,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": true,
"dependsOn": "",
@@ -1405,6 +1408,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -2476,6 +2480,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -2645,6 +2650,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -2935,6 +2941,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -3139,6 +3146,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -4405,6 +4413,7 @@
"is": ""
},
"required": false,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -5334,6 +5343,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -6630,6 +6640,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -6928,6 +6939,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": true,
"dependsOn": "token",
@@ -6946,6 +6958,7 @@
"is": ""
},
"required": false,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -9237,6 +9250,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -11515,6 +11529,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": true,
"dependsOn": "",
@@ -12308,6 +12323,7 @@
"is": ""
},
"required": false,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -13001,6 +13017,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -13443,6 +13460,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -13641,6 +13659,7 @@
"is": ""
},
"required": false,
"protected": true,
"validationRule": "",
"secure": false,
"dependsOn": "",
@@ -15072,6 +15091,7 @@
"is": ""
},
"required": true,
"protected": true,
"validationRule": "",
"secure": true,
"dependsOn": "secret",
@@ -72,6 +72,33 @@
},
"isPaused": false,
"missing_series_evals_to_resolve": 2
},
{
"uid": "<dynamic>",
"title": "RecordingRule1",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 0,
"to": 0
},
"datasourceUid": "__expr__",
"model": {
"expression": "1 + 1",
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": "A",
"type": "math"
}
}
],
"isPaused": false,
"record": {
"metric": "test_metric",
"from": "A",
"targetDatasourceUid": "test-datasource-uid"
}
}
]
}
@@ -97,6 +97,44 @@
},
"missing_series_evals_to_resolve": 2
}
},
{
"expr": "",
"for": "0s",
"keep_firing_for": "0s",
"grafana_alert": {
"title": "RecordingRule1",
"data": [
{
"refId": "A",
"queryType": "",
"relativeTimeRange": {
"from": 0,
"to": 0
},
"datasourceUid": "__expr__",
"model": {
"expression": "1 + 1",
"intervalMs": 1000,
"maxDataPoints": 43200,
"type": "math"
}
}
],
"updated": "2023-09-29T17:37:19Z",
"intervalSeconds": 60,
"version": 1,
"uid": "<dynamic>",
"namespace_uid": "<dynamic>",
"rule_group": "Group1",
"is_paused": false,
"record": {
"metric": "test_metric",
"from": "A",
"target_datasource_uid": "test-datasource-uid"
},
"metadata": {}
}
}
]
}
@@ -53,6 +53,26 @@
"exec_err_state": "Alerting",
"missing_series_evals_to_resolve": 2
}
},
{
"grafana_alert": {
"title": "RecordingRule1",
"data": [
{
"refId": "A",
"datasourceUid": "__expr__",
"model": {
"expression": "1 + 1",
"type": "math"
}
}
],
"record": {
"metric": "test_metric",
"from": "A",
"target_datasource_uid": "test-datasource-uid"
}
}
}
]
}
+1
View File
@@ -249,6 +249,7 @@ func convertGettableGrafanaRuleToPostable(gettable *apimodels.GettableGrafanaRul
ExecErrState: gettable.ExecErrState,
IsPaused: &gettable.IsPaused,
NotificationSettings: gettable.NotificationSettings,
Record: gettable.Record,
Metadata: gettable.Metadata,
}
}
@@ -1802,6 +1802,15 @@
}
}
},
{
"name": "facetLimit",
"in": "query",
"description": "maximum number of terms to return per facet (default 50, max 1000)",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "tags",
"in": "query",
@@ -14,13 +14,12 @@ export interface Props {
}
export const FolderRepo = memo(function FolderRepo({ folder }: Props) {
// skip rendering if:
// folder is not present
// folder have parentUID
// folder is not managed
// if whole instance is provisioned
const isProvisionedInstance = useIsProvisionedInstance();
const skipRender = getShouldSkipRender(folder, isProvisionedInstance);
// Check if we can skip early without needing the useIsProvisionedInstance query
// This reduces RTK Query subscriptions and prevents re-render loops on API errors
const canSkipEarly = getCanSkipEarly(folder);
const isProvisionedInstance = useIsProvisionedInstance({ skip: canSkipEarly });
const skipRender = canSkipEarly || isProvisionedInstance;
const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({
folderName: skipRender ? undefined : folder?.uid,
@@ -51,11 +50,19 @@ export const FolderRepo = memo(function FolderRepo({ folder }: Props) {
);
});
function getShouldSkipRender(folder: FolderDTO | DashboardViewItem | undefined, isProvisionedInstance?: boolean) {
// Skip render if parentUID is present, then we should skip rendering. we only display icon for root folders
const hasParent = folder && Boolean('parentUID' in folder && folder.parentUID);
// Skip render if folder is not managed by Repo
const isNotManaged = folder && folder.managedBy !== ManagerKind.Repo;
return !folder || hasParent || isNotManaged || isProvisionedInstance;
// Check conditions that don't require the useIsProvisionedInstance hook
function getCanSkipEarly(folder: FolderDTO | DashboardViewItem | undefined): boolean {
if (!folder) {
return true;
}
// Skip render if parentUID is present - we only display icon for root folders
const hasParent = Boolean('parentUID' in folder && folder.parentUID);
if (hasParent) {
return true;
}
const isNotManaged = folder.managedBy !== ManagerKind.Repo;
if (isNotManaged) {
return true;
}
return false;
}
@@ -43,10 +43,10 @@ export function BrowseView({ folderUID, width, height, permissions, isReadOnlyRe
const selectedItems = useCheckboxSelectionState();
const childrenByParentUID = useChildrenByParentUIDState();
const canSelect = canSelectItems(permissions);
const isProvisionedInstance = useIsProvisionedInstance();
const provisioningEnabled = config.featureToggles.provisioning;
const hasNoRole = contextSrv.user.orgRole === OrgRole.None;
const { data: settingsData } = useGetFrontendSettingsQuery(!provisioningEnabled || hasNoRole ? skipToken : undefined);
const isProvisionedInstance = useIsProvisionedInstance({ settings: settingsData });
const rootItems = useSelector(rootItemsSelector);
const [, stateManager] = useSearchStateManager();
@@ -25,6 +25,7 @@ export function RecentlyViewedDashboards() {
value: recentDashboards = [],
loading,
retry,
error,
} = useAsyncRetry(async () => {
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
return [];
@@ -62,7 +63,18 @@ export function RecentlyViewedDashboards() {
className={styles.title}
contentClassName={styles.content}
>
{/* placeholder */}
{error && (
<>
<Text color="secondary">
<Trans i18nKey="browse-dashboards.recently-viewed.error">
Recently viewed dashboards couldn’t be loaded.
</Trans>
</Text>
<Button onClick={retry} size="xs" fill="text">
{t('browse-dashboards.recently-viewed.retry', 'Retry')}
</Button>
</>
)}
{loading && <Spinner />}
{/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */}
{!loading && recentDashboards.length === 0 && (
@@ -94,7 +94,11 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
style={{ '--depth': depth } as React.CSSProperties}
>
<div className={cx(styles.row, { [styles.rowSelected]: isSelected })}>
<div
className={cx(styles.row, isEditing ? styles.rowEditMode : styles.rowViewMode, {
[styles.rowSelected]: isSelected,
})}
>
<div className={styles.indentation}></div>
{isContainer && (
<button
@@ -191,13 +195,19 @@ function getStyles(theme: GrafanaTheme2) {
display: 'flex',
gap: theme.spacing(0.5),
borderRadius: theme.shape.radius.default,
}),
rowEditMode: css({
'&:hover': {
color: theme.colors.text.primary,
outline: `1px dashed ${theme.colors.border.strong}`,
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.05),
},
}),
rowViewMode: css({
'&:hover': {
textDecoration: 'underline',
},
}),
rowSelected: css({
color: theme.colors.text.primary,
outline: `1px dashed ${theme.colors.primary.border} !important`,
@@ -12,7 +12,7 @@ import {
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils, setRunRequest } from '@grafana/runtime';
import { SceneCanvasText, SceneDataTransformer, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { SceneCanvasText, SceneDataTransformer, SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import * as libpanels from 'app/features/library-panels/state/api';
import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
@@ -183,6 +183,51 @@ describe('InspectJsonTab', () => {
expect(tab.state.onClose).toHaveBeenCalled();
});
it('Can update gridPos and forces layout re-render', async () => {
const { tab, panel, scene } = await buildTestScene();
// Get the layout manager and spy on the grid's forceRender
const layoutManager = scene.state.body as DefaultGridLayoutManager;
const grid = layoutManager.state.grid as SceneGridLayout;
const forceRenderSpy = jest.spyOn(grid, 'forceRender');
const originalGridItem = panel.parent as DashboardGridItem;
expect(originalGridItem.state.x).toBe(0);
expect(originalGridItem.state.y).toBe(0);
expect(originalGridItem.state.width).toBe(8);
expect(originalGridItem.state.height).toBe(10);
tab.onCodeEditorBlur(`{
"id": 12,
"type": "table",
"title": "Panel A",
"gridPos": {
"x": 5,
"y": 10,
"w": 12,
"h": 8
},
"options": {},
"fieldConfig": {},
"transformations": [],
"transparent": false
}`);
tab.onApplyChange();
const panel2 = findVizPanelByKey(scene, panel.state.key)!;
const gridItem = panel2.parent as DashboardGridItem;
// Verify all gridPos properties are updated
expect(gridItem.state.x).toBe(5);
expect(gridItem.state.y).toBe(10);
expect(gridItem.state.width).toBe(12);
expect(gridItem.state.height).toBe(8);
// Verify forceRender was called on the layout to apply position changes
expect(forceRenderSpy).toHaveBeenCalled();
});
it('Can show panel json for V2 dashboard specification', async () => {
const { tab } = await buildTestSceneWithV2Spec();
@@ -9,6 +9,7 @@ import {
SceneDataTransformer,
sceneGraph,
SceneGridItemStateLike,
SceneGridLayout,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
@@ -168,6 +169,12 @@ export class InspectJsonTab extends SceneObjectBase<InspectJsonTabState> {
panel.parent.setState(newState);
// Force the grid layout to re-render with the new positions
const layout = sceneGraph.getLayout(panel);
if (layout instanceof SceneGridLayout) {
layout.forceRender();
}
//Report relevant updates
reportPanelInspectInteraction(InspectTab.JSON, 'apply', {
panel_type_changed: panel.state.pluginId !== panelModel.type,
@@ -34,9 +34,10 @@ export const useGetResourceRepositoryView = ({
const hasNoRole = contextSrv.user.orgRole === OrgRole.None;
const provisioningEnabled = config.featureToggles.provisioning;
const { data: settingsData, isLoading: isSettingsLoading } = useGetFrontendSettingsQuery(
!provisioningEnabled || skipQuery || hasNoRole ? skipToken : undefined
);
const shouldSkipSettings = !provisioningEnabled || skipQuery || hasNoRole || (!name && !folderName);
const settingsQueryArg = shouldSkipSettings ? skipToken : undefined;
const { data: settingsData, isLoading: isSettingsLoading } = useGetFrontendSettingsQuery(settingsQueryArg);
const skipFolderQuery = !folderName || !provisioningEnabled || skipQuery || hasNoRole;
const { data: folder, isLoading: isFolderLoading } = useGetFolderQuery(
@@ -5,13 +5,22 @@ import { config } from '@grafana/runtime';
import { RepositoryViewList, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { contextSrv } from 'app/core/services/context_srv';
export function useIsProvisionedInstance(settings?: RepositoryViewList) {
interface UseIsProvisionedInstanceOptions {
settings?: RepositoryViewList;
skip?: boolean;
}
export function useIsProvisionedInstance(options: UseIsProvisionedInstanceOptions = {}) {
const { settings, skip: skipQuery } = options;
const hasNoRole = contextSrv.user.orgRole === OrgRole.None;
const skip = !config.featureToggles.provisioning || hasNoRole;
const skip = !config.featureToggles.provisioning || hasNoRole || skipQuery;
const settingsQuery = useGetFrontendSettingsQuery(settings || skip ? skipToken : undefined);
if (!settings) {
settings = settingsQuery.data;
if (settingsQuery.isError) {
return false;
}
return settings?.items?.some((item) => item.target === 'instance');
const effectiveSettings = settings ?? settingsQuery.data;
return effectiveSettings?.items?.some((item) => item.target === 'instance');
}
@@ -106,7 +106,7 @@ export class UnifiedSearcher implements GrafanaSearcher {
async tags(query: SearchQuery): Promise<TermCount[]> {
const qry = query.query ?? '*';
let uri = `${searchURI}?facet=tags&query=${qry}&limit=1`;
let uri = `${searchURI}?facet=tags&facetLimit=1000&query=${qry}&limit=1`;
const resp = await getBackendSrv().get<SearchAPIResponse>(uri);
return resp.facets?.tags?.terms || [];
}
+2
View File
@@ -3780,6 +3780,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3748,6 +3748,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3748,6 +3748,8 @@
"recently-viewed": {
"clear": "Clear history",
"empty": "Nothing viewed yet",
"error": "Recently viewed dashboards couldn’t be loaded.",
"retry": "Retry",
"title": "Recently viewed"
},
"restore": {
+2
View File
@@ -3748,6 +3748,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3748,6 +3748,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3748,6 +3748,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3732,6 +3732,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3748,6 +3748,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3732,6 +3732,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3732,6 +3732,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3748,6 +3748,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3780,6 +3780,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3748,6 +3748,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3748,6 +3748,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3780,6 +3780,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3748,6 +3748,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3748,6 +3748,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3732,6 +3732,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {
+2
View File
@@ -3732,6 +3732,8 @@
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
},
"restore": {