Merge branch 'main' into kristina/rtk-corr
This commit is contained in:
+1
-1
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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=
|
||||
|
||||
+3
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{{}},
|
||||
}
|
||||
|
||||
Vendored
+26
@@ -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
|
||||
+25
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 || [];
|
||||
}
|
||||
|
||||
@@ -3780,6 +3780,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3748,6 +3748,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3748,6 +3748,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3748,6 +3748,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3748,6 +3748,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3732,6 +3732,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3748,6 +3748,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3732,6 +3732,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3732,6 +3732,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3748,6 +3748,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3780,6 +3780,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3748,6 +3748,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3748,6 +3748,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3780,6 +3780,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3748,6 +3748,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3748,6 +3748,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3732,6 +3732,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -3732,6 +3732,8 @@
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
},
|
||||
"restore": {
|
||||
|
||||
Reference in New Issue
Block a user