merge main

This commit is contained in:
Ryan McKinley
2025-02-13 18:17:00 +03:00
38 changed files with 689 additions and 670 deletions
+4 -2
View File
@@ -1327,8 +1327,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
],
"public/app/core/utils/richHistory.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
@@ -33,7 +33,7 @@ Grafana Enterprise includes integrations with more ways to authenticate your use
Supported auth providers:
- [Auth Proxy]({{< relref "../setup-grafana/configure-security/configure-authentication/auth-proxy#team-sync-enterprise-only" >}})
- [Auth Proxy](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/auth-proxy#team-sync-enterprise-only)
- [Azure AD](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/azuread#group-sync-enterprise-only)
- [Generic OAuth integration](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/generic-oauth#configure-group-synchronization)
- [GitHub OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/github#configure-group-synchronization)
@@ -27,16 +27,15 @@ This mechanism allows Grafana to remove an existing synchronized user from a tea
## Supported providers
- [Auth Proxy]({{< relref "./configure-authentication/auth-proxy#team-sync-enterprise-only" >}})
- [Azure AD](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-authentication/azuread#group-sync-enterprise-only)
- [Azure AD](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/azuread#group-sync-enterprise-only)
- [Generic OAuth integration](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/generic-oauth#configure-group-synchronization)
- [GitHub OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/github#configure-group-synchronization)
- [GitLab OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/gitlab#configure-group-synchronization)
- [Google OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/google#configure-group-synchronization)
- [LDAP](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization)
- [Okta](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only)
- [SAML](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/saml#configure-group-synchronization)
- [Auth Proxy](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/auth-proxy/#team-sync-enterprise-only)
- [Azure AD](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/azuread#group-sync-enterprise-only)
- [Generic OAuth integration](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/generic-oauth#configure-group-synchronization)
- [GitHub OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/github#configure-group-synchronization)
- [GitLab OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/gitlab#configure-group-synchronization)
- [Google OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/google#configure-group-synchronization)
- [LDAP](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization)
- [Okta](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only)
- [SAML](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/saml#configure-group-synchronization)
## Synchronize a Grafana team with an external group
+1 -1
View File
@@ -111,7 +111,7 @@ require (
github.com/influxdata/influxql v1.4.0 // @grafana/partner-datasources
github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // @grafana/grafana-app-platform-squad
github.com/jmespath-community/go-jmespath v1.1.1 // @grafana/identity-access-team
github.com/jmespath/go-jmespath v0.4.0 // indirect; // @grafana/grafana-backend-group
github.com/jmespath/go-jmespath v0.4.0 // // @grafana/grafana-backend-group
github.com/jmoiron/sqlx v1.3.5 // @grafana/grafana-backend-group
github.com/json-iterator/go v1.1.12 // @grafana/grafana-backend-group
github.com/lib/pq v1.10.9 // @grafana/grafana-backend-group
@@ -6,10 +6,7 @@ import (
DashboardV2Spec: {
// Title of dashboard.
title: string
// Description of dashboard.
description?: string
annotations: [...AnnotationQueryKind]
// Configuration of dashboard cursor sync behavior.
// "Off" for no shared crosshair or tooltip (default).
@@ -17,6 +14,19 @@ DashboardV2Spec: {
// "Tooltip" for shared crosshair AND shared tooltip.
cursorSync: DashboardCursorSync
// Description of dashboard.
description?: string
// Whether a dashboard is editable or not.
editable?: bool | *true
elements: [ElementReference.name]: Element
layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind
// Links with references to other dashboards or external websites.
links: [...DashboardLink]
// When set to true, the dashboard will redraw panels at an interval matching the pixel width.
// This will keep data "moving left" regardless of the query refresh rate. This setting helps
// avoid dashboards presenting stale live data.
@@ -25,30 +35,20 @@ DashboardV2Spec: {
// When set to true, the dashboard will load all panels in the dashboard when it's loaded.
preload: bool
// Whether a dashboard is editable or not.
editable?: bool | *true
// Links with references to other dashboards or external websites.
links: [...DashboardLink]
// Plugins only. The version of the dashboard installed together with the plugin.
// This is used to determine if the dashboard should be updated when the plugin is updated.
revision?: uint16
// Tags associated with dashboard.
tags: [...string]
timeSettings: TimeSettingsSpec
// Title of dashboard.
title: string
// Configured template variables.
variables: [...VariableKind]
elements: [ElementReference.name]: Element
annotations: [...AnnotationQueryKind]
layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind
// Plugins only. The version of the dashboard installed together with the plugin.
// This is used to determine if the dashboard should be updated when the plugin is updated.
revision?: uint16
}
// Supported dashboard elements
@@ -5,49 +5,50 @@ import * as common from '@grafana/schema';
export interface DashboardV2Spec {
// Title of dashboard.
title: string;
// Description of dashboard.
description?: string;
annotations: AnnotationQueryKind[];
// Configuration of dashboard cursor sync behavior.
// "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip.
cursorSync: DashboardCursorSync;
// Description of dashboard.
description?: string;
// Whether a dashboard is editable or not.
editable?: boolean;
elements: Record<string, Element>;
layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind;
// Links with references to other dashboards or external websites.
links: DashboardLink[];
// When set to true, the dashboard will redraw panels at an interval matching the pixel width.
// This will keep data "moving left" regardless of the query refresh rate. This setting helps
// avoid dashboards presenting stale live data.
liveNow?: boolean;
// When set to true, the dashboard will load all panels in the dashboard when it's loaded.
preload: boolean;
// Whether a dashboard is editable or not.
editable?: boolean;
// Links with references to other dashboards or external websites.
links: DashboardLink[];
// Tags associated with dashboard.
tags: string[];
timeSettings: TimeSettingsSpec;
// Configured template variables.
variables: VariableKind[];
elements: Record<string, Element>;
annotations: AnnotationQueryKind[];
layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind;
// Plugins only. The version of the dashboard installed together with the plugin.
// This is used to determine if the dashboard should be updated when the plugin is updated.
revision?: number;
// Tags associated with dashboard.
tags: string[];
timeSettings: TimeSettingsSpec;
// Title of dashboard.
title: string;
// Configured template variables.
variables: VariableKind[];
}
export const defaultDashboardV2Spec = (): DashboardV2Spec => ({
title: "",
annotations: [],
cursorSync: "Off",
preload: false,
editable: true,
elements: {},
layout: defaultGridLayoutKind(),
links: [],
preload: false,
tags: [],
timeSettings: defaultTimeSettingsSpec(),
title: "",
variables: [],
elements: {},
annotations: [],
layout: defaultGridLayoutKind(),
});
// Supported dashboard elements
-4
View File
@@ -9,10 +9,6 @@ func Migrate(dash map[string]interface{}, targetVersion int) error {
inputVersion := schemaversion.GetSchemaVersion(dash)
dash["schemaVersion"] = inputVersion
if inputVersion < schemaversion.MINIUM_VERSION {
return schemaversion.NewMinimumVersionError(inputVersion)
}
for nextVersion := inputVersion + 1; nextVersion <= targetVersion; nextVersion++ {
if migration, ok := schemaversion.Migrations[nextVersion]; ok {
if err := migration(dash); err != nil {
@@ -22,15 +22,6 @@ func TestMigrate(t *testing.T) {
files, err := os.ReadDir(INPUT_DIR)
require.NoError(t, err)
t.Run("minimum version check", func(t *testing.T) {
err := migration.Migrate(map[string]interface{}{
"schemaVersion": schemaversion.MINIUM_VERSION - 1,
}, schemaversion.MINIUM_VERSION)
var minVersionErr = schemaversion.NewMinimumVersionError(schemaversion.MINIUM_VERSION - 1)
require.ErrorAs(t, err, &minVersionErr)
})
for _, f := range files {
if f.IsDir() {
continue
@@ -2,23 +2,8 @@ package schemaversion
import "fmt"
var _ error = &MinimumVersionError{}
var _ error = &MigrationError{}
// MinimumVersionError is an error that is returned when the schema version is below the minimum version.
func NewMinimumVersionError(inputVersion int) *MinimumVersionError {
return &MinimumVersionError{inputVersion: inputVersion}
}
// MinimumVersionError is an error type for minimum version errors.
type MinimumVersionError struct {
inputVersion int
}
func (e *MinimumVersionError) Error() string {
return fmt.Errorf("input schema version is below minimum version. input: %d minimum: %d", e.inputVersion, MINIUM_VERSION).Error()
}
// ErrMigrationFailed is an error that is returned when a migration fails.
func NewMigrationError(msg string, currentVersion, targetVersion int) *MigrationError {
return &MigrationError{
@@ -4,10 +4,7 @@ import "strconv"
type SchemaVersionMigrationFunc func(map[string]interface{}) error
const (
MINIUM_VERSION = 36
LATEST_VERSION = 41
)
const LATEST_VERSION = 41
var Migrations = map[int]SchemaVersionMigrationFunc{
37: V37,
+1 -8
View File
@@ -1,8 +1,6 @@
package v1alpha1
import (
"errors"
conversion "k8s.io/apimachinery/pkg/conversion"
klog "k8s.io/klog/v2"
@@ -15,12 +13,7 @@ func Convert_v0alpha1_Unstructured_To_v1alpha1_DashboardSpec(in *common.Unstruct
out.Unstructured = *in
err := migration.Migrate(out.Unstructured.Object, schemaversion.LATEST_VERSION)
if err != nil {
minErr := &schemaversion.MinimumVersionError{}
if errors.As(err, &minErr) {
out.Unstructured.Object["__migrationError"] = err.Error()
} else {
return err
}
return err
}
t, ok := out.Unstructured.Object["title"].(string)
+1 -8
View File
@@ -1,8 +1,6 @@
package v2alpha1
import (
"errors"
conversion "k8s.io/apimachinery/pkg/conversion"
klog "k8s.io/klog/v2"
@@ -15,12 +13,7 @@ func Convert_v0alpha1_Unstructured_To_v2alpha1_DashboardSpec(in *common.Unstruct
out.Unstructured = *in
err := migration.Migrate(out.Unstructured.Object, schemaversion.LATEST_VERSION)
if err != nil {
minErr := &schemaversion.MinimumVersionError{}
if errors.As(err, &minErr) {
out.Unstructured.Object["__migrationError"] = err.Error()
} else {
return err
}
return err
}
t, ok := out.Unstructured.Object["title"].(string)
+1 -1
View File
@@ -289,7 +289,7 @@ func InstallAPIs(
if storageOpts.StorageType != options.StorageTypeLegacy {
dualWrite = func(gr schema.GroupResource, legacy grafanarest.LegacyStorage, storage grafanarest.Storage) (grafanarest.Storage, error) {
// Dashboards + Folders may be managed (depends on feature toggles and database state)
if dualWriteService.ShouldManage(gr) {
if dualWriteService != nil && dualWriteService.ShouldManage(gr) {
return dualWriteService.NewStorage(gr, legacy, storage) // eventually this can replace this whole function
}
-75
View File
@@ -143,81 +143,6 @@ func expandAnnotationsAndLabels(ctx context.Context, log log.Logger, alertRule *
return lbs, annotations
}
func (c *cache) create(ctx context.Context, log log.Logger, alertRule *ngModels.AlertRule, result eval.Result, extraLabels data.Labels, externalURL *url.URL) *State {
lbs, annotations := expandAnnotationsAndLabels(ctx, log, alertRule, result, extraLabels, externalURL)
cacheID := lbs.Fingerprint()
// For new states, we set StartsAt & EndsAt to EvaluatedAt as this is the
// expected value for a Normal state during state transition.
newState := State{
OrgID: alertRule.OrgID,
AlertRuleUID: alertRule.UID,
CacheID: cacheID,
State: eval.Normal,
StateReason: "",
ResultFingerprint: result.Instance.Fingerprint(), // remember original result fingerprint
LatestResult: nil,
Error: nil,
Image: nil,
Annotations: annotations,
Labels: lbs,
Values: nil,
StartsAt: result.EvaluatedAt,
EndsAt: result.EvaluatedAt,
ResolvedAt: nil,
LastSentAt: nil,
LastEvaluationString: "",
LastEvaluationTime: result.EvaluatedAt,
EvaluationDuration: result.EvaluationDuration,
}
existingState := c.get(alertRule.OrgID, alertRule.UID, cacheID)
if existingState == nil {
return &newState
}
// if there is existing state, copy over the current values that may be needed to determine the final state.
// TODO remove some unnecessary assignments below because they are overridden in setNextState
newState.State = existingState.State
newState.StateReason = existingState.StateReason
newState.Image = existingState.Image
newState.LatestResult = existingState.LatestResult
newState.Error = existingState.Error
newState.Values = existingState.Values
newState.LastEvaluationString = existingState.LastEvaluationString
newState.StartsAt = existingState.StartsAt
newState.EndsAt = existingState.EndsAt
newState.ResolvedAt = existingState.ResolvedAt
newState.LastSentAt = existingState.LastSentAt
// Annotations can change over time, however we also want to maintain
// certain annotations across evaluations
for key := range ngModels.InternalAnnotationNameSet { // Changing in
value, ok := existingState.Annotations[key]
if !ok {
continue
}
// If the annotation is not present then it should be copied from
// the current state to the new state
if _, ok = newState.Annotations[key]; !ok {
newState.Annotations[key] = value
}
}
// if the current state is "data source error" then it may have additional labels that may not exist in the new state.
// See https://github.com/grafana/grafana/blob/c7fdf8ce706c2c9d438f5e6eabd6e580bac4946b/pkg/services/ngalert/state/state.go#L161-L163
// copy known labels over to the new instance, it can help reduce flapping
// TODO fix this?
if existingState.State == eval.Error && result.State == eval.Error {
setIfExist := func(lbl string) {
if v, ok := existingState.Labels[lbl]; ok {
newState.Labels[lbl] = v
}
}
setIfExist("datasource_uid")
setIfExist("ref_id")
}
return &newState
}
// expand returns the expanded templates of all annotations or labels for the template data.
// If a template cannot be expanded due to an error in the template the original template is
// maintained and an error is added to the multierror. All errors in the multierror are
-311
View File
@@ -3,15 +3,11 @@ package state
import (
"context"
"errors"
"fmt"
"math/rand"
"net/url"
"testing"
"time"
"github.com/google/uuid"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
@@ -118,313 +114,6 @@ func Test_expand(t *testing.T) {
})
}
func Test_create(t *testing.T) {
url := &url.URL{
Scheme: "http",
Host: "localhost:3000",
Path: "/test",
}
l := log.New("test")
c := newCache()
gen := models.RuleGen
generateRule := gen.With(gen.WithNotEmptyLabels(5, "rule-")).GenerateRef
t.Run("should combine all labels", func(t *testing.T) {
rule := generateRule()
extraLabels := models.GenerateAlertLabels(5, "extra-")
result := eval.Result{
Instance: models.GenerateAlertLabels(5, "result-"),
}
state := c.create(context.Background(), l, rule, result, extraLabels, url)
for key, expected := range extraLabels {
require.Equal(t, expected, state.Labels[key])
}
assert.Len(t, state.Labels, len(extraLabels)+len(rule.Labels)+len(result.Instance))
for key, expected := range extraLabels {
assert.Equal(t, expected, state.Labels[key])
}
for key, expected := range rule.Labels {
assert.Equal(t, expected, state.Labels[key])
}
for key, expected := range result.Instance {
assert.Equal(t, expected, state.Labels[key])
}
})
t.Run("extra labels should take precedence over rule and result labels", func(t *testing.T) {
rule := generateRule()
extraLabels := models.GenerateAlertLabels(2, "extra-")
result := eval.Result{
Instance: models.GenerateAlertLabels(5, "result-"),
}
for key := range extraLabels {
rule.Labels[key] = "rule-" + util.GenerateShortUID()
result.Instance[key] = "result-" + util.GenerateShortUID()
}
state := c.create(context.Background(), l, rule, result, extraLabels, url)
for key, expected := range extraLabels {
require.Equal(t, expected, state.Labels[key])
}
})
t.Run("rule labels should take precedence over result labels", func(t *testing.T) {
rule := generateRule()
extraLabels := models.GenerateAlertLabels(2, "extra-")
result := eval.Result{
Instance: models.GenerateAlertLabels(5, "result-"),
}
for key := range rule.Labels {
result.Instance[key] = "result-" + util.GenerateShortUID()
}
state := c.create(context.Background(), l, rule, result, extraLabels, url)
for key, expected := range rule.Labels {
require.Equal(t, expected, state.Labels[key])
}
})
t.Run("rule labels should be able to be expanded with result and extra labels", func(t *testing.T) {
result := eval.Result{
Instance: models.GenerateAlertLabels(5, "result-"),
}
rule := generateRule()
extraLabels := models.GenerateAlertLabels(2, "extra-")
labelTemplates := make(data.Labels)
for key := range extraLabels {
labelTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
}
for key := range result.Instance {
labelTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
}
rule.Labels = labelTemplates
state := c.create(context.Background(), l, rule, result, extraLabels, url)
for key, expected := range extraLabels {
assert.Equal(t, expected, state.Labels["rule-"+key])
}
for key, expected := range result.Instance {
assert.Equal(t, expected, state.Labels["rule-"+key])
}
})
t.Run("rule annotations should be able to be expanded with result and extra labels", func(t *testing.T) {
result := eval.Result{
Instance: models.GenerateAlertLabels(5, "result-"),
}
rule := generateRule()
extraLabels := models.GenerateAlertLabels(2, "extra-")
annotationTemplates := make(data.Labels)
for key := range extraLabels {
annotationTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
}
for key := range result.Instance {
annotationTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
}
rule.Annotations = annotationTemplates
state := c.create(context.Background(), l, rule, result, extraLabels, url)
for key, expected := range extraLabels {
assert.Equal(t, expected, state.Annotations["rule-"+key])
}
for key, expected := range result.Instance {
assert.Equal(t, expected, state.Annotations["rule-"+key])
}
})
t.Run("when result labels collide with system labels from LabelsUserCannotSpecify", func(t *testing.T) {
result := eval.Result{
Instance: models.GenerateAlertLabels(5, "result-"),
}
m := models.LabelsUserCannotSpecify
t.Cleanup(func() {
models.LabelsUserCannotSpecify = m
})
models.LabelsUserCannotSpecify = map[string]struct{}{
"__label1__": {},
"label2__": {},
"__label3": {},
"label4": {},
}
result.Instance["__label1__"] = uuid.NewString()
result.Instance["label2__"] = uuid.NewString()
result.Instance["__label3"] = uuid.NewString()
result.Instance["label4"] = uuid.NewString()
rule := generateRule()
state := c.create(context.Background(), l, rule, result, nil, url)
for key := range models.LabelsUserCannotSpecify {
assert.NotContains(t, state.Labels, key)
}
assert.Contains(t, state.Labels, "label1")
assert.Equal(t, state.Labels["label1"], result.Instance["__label1__"])
assert.Contains(t, state.Labels, "label2")
assert.Equal(t, state.Labels["label2"], result.Instance["label2__"])
assert.Contains(t, state.Labels, "label3")
assert.Equal(t, state.Labels["label3"], result.Instance["__label3"])
assert.Contains(t, state.Labels, "label4_user")
assert.Equal(t, state.Labels["label4_user"], result.Instance["label4"])
t.Run("should drop label if renamed collides with existing", func(t *testing.T) {
result.Instance["label1"] = uuid.NewString()
result.Instance["label1_user"] = uuid.NewString()
result.Instance["label4_user"] = uuid.NewString()
state = c.create(context.Background(), l, rule, result, nil, url)
assert.NotContains(t, state.Labels, "__label1__")
assert.Contains(t, state.Labels, "label1")
assert.Equal(t, state.Labels["label1"], result.Instance["label1"])
assert.Equal(t, state.Labels["label1_user"], result.Instance["label1_user"])
assert.NotContains(t, state.Labels, "label4")
assert.Equal(t, state.Labels["label4_user"], result.Instance["label4_user"])
})
})
t.Run("creates a state with preset fields if there is no current state", func(t *testing.T) {
rule := generateRule()
extraLabels := models.GenerateAlertLabels(2, "extra-")
result := eval.Result{
Instance: models.GenerateAlertLabels(5, "result-"),
}
expectedLbl, expectedAnn := expandAnnotationsAndLabels(context.Background(), l, rule, result, extraLabels, url)
state := c.create(context.Background(), l, rule, result, extraLabels, url)
assert.Equal(t, rule.OrgID, state.OrgID)
assert.Equal(t, rule.UID, state.AlertRuleUID)
assert.Equal(t, state.Labels.Fingerprint(), state.CacheID)
assert.Equal(t, result.State, state.State)
assert.Equal(t, "", state.StateReason)
assert.Equal(t, result.Instance.Fingerprint(), state.ResultFingerprint)
assert.Nil(t, state.LatestResult)
assert.Nil(t, state.Error)
assert.Nil(t, state.Image)
assert.EqualValues(t, expectedAnn, state.Annotations)
assert.EqualValues(t, expectedLbl, state.Labels)
assert.Nil(t, state.Values)
assert.Equal(t, result.EvaluatedAt, state.StartsAt)
assert.Equal(t, result.EvaluatedAt, state.EndsAt)
assert.Nil(t, state.ResolvedAt)
assert.Nil(t, state.LastSentAt)
assert.Equal(t, "", state.LastEvaluationString)
assert.Equal(t, result.EvaluatedAt, state.LastEvaluationTime)
assert.Equal(t, result.EvaluationDuration, state.EvaluationDuration)
})
t.Run("it populates some fields from the current state if it exists", func(t *testing.T) {
rule := generateRule()
extraLabels := models.GenerateAlertLabels(2, "extra-")
result := eval.Result{
Instance: models.GenerateAlertLabels(5, "result-"),
}
expectedLbl, expectedAnn := expandAnnotationsAndLabels(context.Background(), l, rule, result, extraLabels, url)
current := randomSate(rule.GetKey())
current.CacheID = expectedLbl.Fingerprint()
c.set(&current)
state := c.create(context.Background(), l, rule, result, extraLabels, url)
assert.Equal(t, rule.OrgID, state.OrgID)
assert.Equal(t, rule.UID, state.AlertRuleUID)
assert.Equal(t, state.Labels.Fingerprint(), state.CacheID)
assert.Equal(t, result.Instance.Fingerprint(), state.ResultFingerprint)
assert.EqualValues(t, expectedAnn, state.Annotations)
assert.EqualValues(t, expectedLbl, state.Labels)
assert.Equal(t, result.EvaluatedAt, state.LastEvaluationTime)
assert.Equal(t, result.EvaluationDuration, state.EvaluationDuration)
assert.Equal(t, current.State, state.State)
assert.Equal(t, current.StateReason, state.StateReason)
assert.Equal(t, current.Image, state.Image)
assert.Equal(t, current.LatestResult, state.LatestResult)
assert.Equal(t, current.Error, state.Error)
assert.Equal(t, current.Values, state.Values)
assert.Equal(t, current.StartsAt, state.StartsAt)
assert.Equal(t, current.EndsAt, state.EndsAt)
assert.Equal(t, current.ResolvedAt, state.ResolvedAt)
assert.Equal(t, current.LastSentAt, state.LastSentAt)
assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString)
t.Run("if result Error and current state is Error it should copy datasource_uid and ref_id labels", func(t *testing.T) {
current = randomSate(rule.GetKey())
current.CacheID = expectedLbl.Fingerprint()
current.State = eval.Error
current.Labels["datasource_uid"] = util.GenerateShortUID()
current.Labels["ref_id"] = util.GenerateShortUID()
c.set(&current)
result.State = eval.Error
state = c.create(context.Background(), l, rule, result, extraLabels, url)
l := expectedLbl.Copy()
l["datasource_uid"] = current.Labels["datasource_uid"]
l["ref_id"] = current.Labels["ref_id"]
assert.Equal(t, current.CacheID, state.CacheID)
assert.EqualValues(t, l, state.Labels)
assert.Equal(t, rule.OrgID, state.OrgID)
assert.Equal(t, rule.UID, state.AlertRuleUID)
assert.Equal(t, result.Instance.Fingerprint(), state.ResultFingerprint)
assert.EqualValues(t, expectedAnn, state.Annotations)
assert.Equal(t, result.EvaluatedAt, state.LastEvaluationTime)
assert.Equal(t, result.EvaluationDuration, state.EvaluationDuration)
assert.Equal(t, current.State, state.State)
assert.Equal(t, current.StateReason, state.StateReason)
assert.Equal(t, current.Image, state.Image)
assert.Equal(t, current.LatestResult, state.LatestResult)
assert.Equal(t, current.Error, state.Error)
assert.Equal(t, current.Values, state.Values)
assert.Equal(t, current.StartsAt, state.StartsAt)
assert.Equal(t, current.EndsAt, state.EndsAt)
assert.Equal(t, current.ResolvedAt, state.ResolvedAt)
assert.Equal(t, current.LastSentAt, state.LastSentAt)
assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString)
})
t.Run("copies system-owned annotations from current state", func(t *testing.T) {
current = randomSate(rule.GetKey())
current.CacheID = expectedLbl.Fingerprint()
current.State = eval.Error
for key := range models.InternalAnnotationNameSet {
current.Annotations[key] = util.GenerateShortUID()
}
c.set(&current)
result.State = eval.Error
state = c.create(context.Background(), l, rule, result, extraLabels, url)
ann := expectedAnn.Copy()
for key := range models.InternalAnnotationNameSet {
ann[key] = current.Annotations[key]
}
assert.EqualValues(t, expectedLbl, state.Labels)
assert.EqualValues(t, ann, state.Annotations)
})
})
}
func Test_mergeLabels(t *testing.T) {
t.Run("merges two maps", func(t *testing.T) {
a := models.GenerateAlertLabels(5, "set1-")
+15 -113
View File
@@ -444,9 +444,16 @@ func (st *Manager) setNextStateForRule(ctx context.Context, alertRule *ngModels.
}
transitions := make([]StateTransition, 0, len(results))
for _, result := range results {
currentState := st.cache.create(ctx, logger, alertRule, result, extraLabels, st.externalURL)
s := st.setNextState(alertRule, currentState, result, nil, logger, takeImageFn)
st.cache.set(currentState) // replace the existing state with the new one
newState := newState(ctx, logger, alertRule, result, extraLabels, st.externalURL)
if curState := st.cache.get(alertRule.OrgID, alertRule.UID, newState.CacheID); curState != nil {
patch(newState, curState, result)
}
start := st.clock.Now()
s := newState.transition(alertRule, result, nil, logger, takeImageFn)
if st.metrics != nil {
st.metrics.StateUpdateDuration.Observe(st.clock.Now().Sub(start).Seconds())
}
st.cache.set(newState) // replace the existing state with the new one
transitions = append(transitions, s)
}
return transitions
@@ -459,8 +466,12 @@ func (st *Manager) setNextStateForAll(alertRule *ngModels.AlertRule, result eval
states: make(map[data.Fingerprint]*State, len(currentStates)),
}
for _, currentState := range currentStates {
start := st.clock.Now()
newState := currentState.Copy()
t := st.setNextState(alertRule, newState, result, extraAnnotations, logger, takeImageFn)
t := newState.transition(alertRule, result, extraAnnotations, logger, takeImageFn)
if st.metrics != nil {
st.metrics.StateUpdateDuration.Observe(st.clock.Now().Sub(start).Seconds())
}
updated.states[newState.CacheID] = newState
transitions = append(transitions, t)
}
@@ -468,115 +479,6 @@ func (st *Manager) setNextStateForAll(alertRule *ngModels.AlertRule, result eval
return transitions
}
// Set the current state based on evaluation results
func (st *Manager) setNextState(alertRule *ngModels.AlertRule, currentState *State, result eval.Result, extraAnnotations data.Labels, logger log.Logger, takeImageFn takeImageFn) StateTransition {
start := st.clock.Now()
currentState.LastEvaluationTime = result.EvaluatedAt
currentState.EvaluationDuration = result.EvaluationDuration
currentState.SetNextValues(result)
currentState.LatestResult = &Evaluation{
EvaluationTime: result.EvaluatedAt,
EvaluationState: result.State,
Values: currentState.Values,
Condition: alertRule.Condition,
}
currentState.LastEvaluationString = result.EvaluationString
oldState := currentState.State
oldReason := currentState.StateReason
// Add the instance to the log context to help correlate log lines for a state
logger = logger.New("instance", result.Instance)
// if the current state is Error but the result is different, then we need o clean up the extra labels
// that were added after the state key was calculated
// https://github.com/grafana/grafana/blob/1df4d332c982dc5e394201bb2ef35b442727ce63/pkg/services/ngalert/state/state.go#L298-L311
// Usually, it happens in the case of classic conditions when the evalResult does not have labels.
//
// This is temporary change to make sure that the labels are not persistent in the state after it was in Error state
// TODO yuri. Remove it when correct Error result with labels is provided
if currentState.State == eval.Error && result.State != eval.Error {
// This is possible because state was updated after the CacheID was calculated.
_, curOk := currentState.Labels["ref_id"]
_, resOk := result.Instance["ref_id"]
if curOk && !resOk {
delete(currentState.Labels, "ref_id")
}
_, curOk = currentState.Labels["datasource_uid"]
_, resOk = result.Instance["datasource_uid"]
if curOk && !resOk {
delete(currentState.Labels, "datasource_uid")
}
}
switch result.State {
case eval.Normal:
logger.Debug("Setting next state", "handler", "resultNormal")
resultNormal(currentState, alertRule, result, logger, "")
case eval.Alerting:
logger.Debug("Setting next state", "handler", "resultAlerting")
resultAlerting(currentState, alertRule, result, logger, "")
case eval.Error:
logger.Debug("Setting next state", "handler", "resultError")
resultError(currentState, alertRule, result, logger)
case eval.NoData:
logger.Debug("Setting next state", "handler", "resultNoData")
resultNoData(currentState, alertRule, result, logger)
case eval.Pending: // we do not emit results with this state
logger.Debug("Ignoring set next state as result is pending")
}
// Set reason iff: result and state are different, reason is not Alerting or Normal
currentState.StateReason = ""
if currentState.State != result.State &&
result.State != eval.Normal &&
result.State != eval.Alerting {
currentState.StateReason = resultStateReason(result, alertRule)
}
// Set Resolved property so the scheduler knows to send a postable alert
// to Alertmanager.
newlyResolved := false
if oldState == eval.Alerting && currentState.State == eval.Normal {
currentState.ResolvedAt = &result.EvaluatedAt
newlyResolved = true
} else if currentState.State != eval.Normal && currentState.State != eval.Pending { // Retain the last resolved time for Normal->Normal and Normal->Pending.
currentState.ResolvedAt = nil
}
if reason := shouldTakeImage(currentState.State, oldState, currentState.Image, newlyResolved); reason != "" {
image := takeImageFn(reason)
if image != nil {
currentState.Image = image
}
}
for key, val := range extraAnnotations {
currentState.Annotations[key] = val
}
nextState := StateTransition{
State: currentState,
PreviousState: oldState,
PreviousStateReason: oldReason,
}
if st.metrics != nil {
st.metrics.StateUpdateDuration.Observe(st.clock.Now().Sub(start).Seconds())
}
return nextState
}
func resultStateReason(result eval.Result, rule *ngModels.AlertRule) string {
if rule.ExecErrState == ngModels.KeepLastErrState || rule.NoDataState == ngModels.KeepLast {
return ngModels.ConcatReasons(result.State.String(), ngModels.StateReasonKeepLast)
}
return result.State.String()
}
func (st *Manager) GetAll(orgID int64) []*State {
allStates := st.cache.getAll(orgID)
return allStates
+174
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"maps"
"math"
"net/url"
"strings"
"time"
@@ -76,6 +77,35 @@ type State struct {
EvaluationDuration time.Duration
}
func newState(ctx context.Context, log log.Logger, alertRule *models.AlertRule, result eval.Result, extraLabels data.Labels, externalURL *url.URL) *State {
lbs, annotations := expandAnnotationsAndLabels(ctx, log, alertRule, result, extraLabels, externalURL)
cacheID := lbs.Fingerprint()
// For new states, we set StartsAt & EndsAt to EvaluatedAt as this is the
// expected value for a Normal state during state transition.
return &State{
OrgID: alertRule.OrgID,
AlertRuleUID: alertRule.UID,
CacheID: cacheID,
State: eval.Normal,
StateReason: "",
ResultFingerprint: result.Instance.Fingerprint(), // remember original result fingerprint
LatestResult: nil,
Error: nil,
Image: nil,
Annotations: annotations,
Labels: lbs,
Values: nil,
StartsAt: result.EvaluatedAt,
EndsAt: result.EvaluatedAt,
ResolvedAt: nil,
LastSentAt: nil,
LastEvaluationString: "",
LastEvaluationTime: result.EvaluatedAt,
EvaluationDuration: result.EvaluationDuration,
}
}
// Copy creates a shallow copy of the State except for labels and annotations.
func (a *State) Copy() *State {
// Deep copy annotations and labels
@@ -664,3 +694,147 @@ func GetRuleExtraLabels(l log.Logger, rule *models.AlertRule, folderTitle string
}
return extraLabels
}
func patch(newState, existingState *State, result eval.Result) {
// if there is existing state, copy over the current values that may be needed to determine the final state.
// TODO remove some unnecessary assignments below because they are overridden in setNextState
newState.State = existingState.State
newState.StateReason = existingState.StateReason
newState.Image = existingState.Image
newState.LatestResult = existingState.LatestResult
newState.Error = existingState.Error
newState.Values = existingState.Values
newState.LastEvaluationString = existingState.LastEvaluationString
newState.StartsAt = existingState.StartsAt
newState.EndsAt = existingState.EndsAt
newState.ResolvedAt = existingState.ResolvedAt
newState.LastSentAt = existingState.LastSentAt
// Annotations can change over time, however we also want to maintain
// certain annotations across evaluations
for key := range models.InternalAnnotationNameSet { // Changing in
value, ok := existingState.Annotations[key]
if !ok {
continue
}
// If the annotation is not present then it should be copied from
// the current state to the new state
if _, ok = newState.Annotations[key]; !ok {
newState.Annotations[key] = value
}
}
// if the current state is "data source error" then it may have additional labels that may not exist in the new state.
// See https://github.com/grafana/grafana/blob/c7fdf8ce706c2c9d438f5e6eabd6e580bac4946b/pkg/services/ngalert/state/state.go#L161-L163
// copy known labels over to the new instance, it can help reduce flapping
// TODO fix this?
if existingState.State == eval.Error && result.State == eval.Error {
setIfExist := func(lbl string) {
if v, ok := existingState.Labels[lbl]; ok {
newState.Labels[lbl] = v
}
}
setIfExist("datasource_uid")
setIfExist("ref_id")
}
}
func (a *State) transition(alertRule *models.AlertRule, result eval.Result, extraAnnotations data.Labels, logger log.Logger, takeImageFn takeImageFn) StateTransition {
a.LastEvaluationTime = result.EvaluatedAt
a.EvaluationDuration = result.EvaluationDuration
a.SetNextValues(result)
a.LatestResult = &Evaluation{
EvaluationTime: result.EvaluatedAt,
EvaluationState: result.State,
Values: a.Values,
Condition: alertRule.Condition,
}
a.LastEvaluationString = result.EvaluationString
oldState := a.State
oldReason := a.StateReason
// Add the instance to the log context to help correlate log lines for a state
logger = logger.New("instance", result.Instance)
// if the current state is Error but the result is different, then we need o clean up the extra labels
// that were added after the state key was calculated
// https://github.com/grafana/grafana/blob/1df4d332c982dc5e394201bb2ef35b442727ce63/pkg/services/ngalert/state/state.go#L298-L311
// Usually, it happens in the case of classic conditions when the evalResult does not have labels.
//
// This is temporary change to make sure that the labels are not persistent in the state after it was in Error state
// TODO yuri. Remove it when correct Error result with labels is provided
if a.State == eval.Error && result.State != eval.Error {
// This is possible because state was updated after the CacheID was calculated.
_, curOk := a.Labels["ref_id"]
_, resOk := result.Instance["ref_id"]
if curOk && !resOk {
delete(a.Labels, "ref_id")
}
_, curOk = a.Labels["datasource_uid"]
_, resOk = result.Instance["datasource_uid"]
if curOk && !resOk {
delete(a.Labels, "datasource_uid")
}
}
switch result.State {
case eval.Normal:
logger.Debug("Setting next state", "handler", "resultNormal")
resultNormal(a, alertRule, result, logger, "")
case eval.Alerting:
logger.Debug("Setting next state", "handler", "resultAlerting")
resultAlerting(a, alertRule, result, logger, "")
case eval.Error:
logger.Debug("Setting next state", "handler", "resultError")
resultError(a, alertRule, result, logger)
case eval.NoData:
logger.Debug("Setting next state", "handler", "resultNoData")
resultNoData(a, alertRule, result, logger)
case eval.Pending: // we do not emit results with this state
logger.Debug("Ignoring set next state as result is pending")
}
// Set reason iff: result and state are different, reason is not Alerting or Normal
a.StateReason = ""
if a.State != result.State &&
result.State != eval.Normal &&
result.State != eval.Alerting {
a.StateReason = resultStateReason(result, alertRule)
}
// Set Resolved property so the scheduler knows to send a postable alert
// to Alertmanager.
newlyResolved := false
if oldState == eval.Alerting && a.State == eval.Normal {
a.ResolvedAt = &result.EvaluatedAt
newlyResolved = true
} else if a.State != eval.Normal && a.State != eval.Pending { // Retain the last resolved time for Normal->Normal and Normal->Pending.
a.ResolvedAt = nil
}
if reason := shouldTakeImage(a.State, oldState, a.Image, newlyResolved); reason != "" {
image := takeImageFn(reason)
if image != nil {
a.Image = image
}
}
for key, val := range extraAnnotations {
a.Annotations[key] = val
}
nextState := StateTransition{
State: a,
PreviousState: oldState,
PreviousStateReason: oldReason,
}
return nextState
}
func resultStateReason(result eval.Result, rule *models.AlertRule) string {
if rule.ExecErrState == models.KeepLastErrState || rule.NoDataState == models.KeepLast {
return models.ConcatReasons(result.State.String(), models.StateReasonKeepLast)
}
return result.State.String()
}
@@ -14,7 +14,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
func BenchmarkGetOrCreateTest(b *testing.B) {
func BenchmarkCreateAndPatch(b *testing.B) {
cache := newCache()
rule := models.RuleGen.With(func(rule *models.AlertRule) {
for i := 0; i < 2; i++ {
@@ -43,7 +43,11 @@ func BenchmarkGetOrCreateTest(b *testing.B) {
// values := make([]int64, count)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = cache.create(ctx, log, rule, result, nil, u)
s := newState(ctx, log, rule, result, nil, u)
current := cache.get(rule.OrgID, rule.UID, s.CacheID)
if current == nil {
patch(s, current, result)
}
}
})
}
+331
View File
@@ -3,14 +3,17 @@ package state
import (
"context"
"errors"
"fmt"
"math"
"math/rand"
"net/url"
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/golang/mock/gomock"
"github.com/google/uuid"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -801,3 +804,331 @@ func TestGetRuleExtraLabels(t *testing.T) {
})
}
}
func TestNewState(t *testing.T) {
url := &url.URL{
Scheme: "http",
Host: "localhost:3000",
Path: "/test",
}
l := log.New("test")
gen := ngmodels.RuleGen
generateRule := gen.With(gen.WithNotEmptyLabels(5, "rule-")).GenerateRef
t.Run("should combine all labels", func(t *testing.T) {
rule := generateRule()
extraLabels := ngmodels.GenerateAlertLabels(5, "extra-")
result := eval.Result{
Instance: ngmodels.GenerateAlertLabels(5, "result-"),
}
state := newState(context.Background(), l, rule, result, extraLabels, url)
for key, expected := range extraLabels {
require.Equal(t, expected, state.Labels[key])
}
assert.Len(t, state.Labels, len(extraLabels)+len(rule.Labels)+len(result.Instance))
for key, expected := range extraLabels {
assert.Equal(t, expected, state.Labels[key])
}
for key, expected := range rule.Labels {
assert.Equal(t, expected, state.Labels[key])
}
for key, expected := range result.Instance {
assert.Equal(t, expected, state.Labels[key])
}
})
t.Run("extra labels should take precedence over rule and result labels", func(t *testing.T) {
rule := generateRule()
extraLabels := ngmodels.GenerateAlertLabels(2, "extra-")
result := eval.Result{
Instance: ngmodels.GenerateAlertLabels(5, "result-"),
}
for key := range extraLabels {
rule.Labels[key] = "rule-" + util.GenerateShortUID()
result.Instance[key] = "result-" + util.GenerateShortUID()
}
state := newState(context.Background(), l, rule, result, extraLabels, url)
for key, expected := range extraLabels {
require.Equal(t, expected, state.Labels[key])
}
})
t.Run("rule labels should take precedence over result labels", func(t *testing.T) {
rule := generateRule()
extraLabels := ngmodels.GenerateAlertLabels(2, "extra-")
result := eval.Result{
Instance: ngmodels.GenerateAlertLabels(5, "result-"),
}
for key := range rule.Labels {
result.Instance[key] = "result-" + util.GenerateShortUID()
}
state := newState(context.Background(), l, rule, result, extraLabels, url)
for key, expected := range rule.Labels {
require.Equal(t, expected, state.Labels[key])
}
})
t.Run("rule labels should be able to be expanded with result and extra labels", func(t *testing.T) {
result := eval.Result{
Instance: ngmodels.GenerateAlertLabels(5, "result-"),
}
rule := generateRule()
extraLabels := ngmodels.GenerateAlertLabels(2, "extra-")
labelTemplates := make(data.Labels)
for key := range extraLabels {
labelTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
}
for key := range result.Instance {
labelTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
}
rule.Labels = labelTemplates
state := newState(context.Background(), l, rule, result, extraLabels, url)
for key, expected := range extraLabels {
assert.Equal(t, expected, state.Labels["rule-"+key])
}
for key, expected := range result.Instance {
assert.Equal(t, expected, state.Labels["rule-"+key])
}
})
t.Run("rule annotations should be able to be expanded with result and extra labels", func(t *testing.T) {
result := eval.Result{
Instance: ngmodels.GenerateAlertLabels(5, "result-"),
}
rule := generateRule()
extraLabels := ngmodels.GenerateAlertLabels(2, "extra-")
annotationTemplates := make(data.Labels)
for key := range extraLabels {
annotationTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
}
for key := range result.Instance {
annotationTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key)
}
rule.Annotations = annotationTemplates
state := newState(context.Background(), l, rule, result, extraLabels, url)
for key, expected := range extraLabels {
assert.Equal(t, expected, state.Annotations["rule-"+key])
}
for key, expected := range result.Instance {
assert.Equal(t, expected, state.Annotations["rule-"+key])
}
})
t.Run("when result labels collide with system labels from LabelsUserCannotSpecify", func(t *testing.T) {
result := eval.Result{
Instance: ngmodels.GenerateAlertLabels(5, "result-"),
}
m := ngmodels.LabelsUserCannotSpecify
t.Cleanup(func() {
ngmodels.LabelsUserCannotSpecify = m
})
ngmodels.LabelsUserCannotSpecify = map[string]struct{}{
"__label1__": {},
"label2__": {},
"__label3": {},
"label4": {},
}
result.Instance["__label1__"] = uuid.NewString()
result.Instance["label2__"] = uuid.NewString()
result.Instance["__label3"] = uuid.NewString()
result.Instance["label4"] = uuid.NewString()
rule := generateRule()
state := newState(context.Background(), l, rule, result, nil, url)
for key := range ngmodels.LabelsUserCannotSpecify {
assert.NotContains(t, state.Labels, key)
}
assert.Contains(t, state.Labels, "label1")
assert.Equal(t, state.Labels["label1"], result.Instance["__label1__"])
assert.Contains(t, state.Labels, "label2")
assert.Equal(t, state.Labels["label2"], result.Instance["label2__"])
assert.Contains(t, state.Labels, "label3")
assert.Equal(t, state.Labels["label3"], result.Instance["__label3"])
assert.Contains(t, state.Labels, "label4_user")
assert.Equal(t, state.Labels["label4_user"], result.Instance["label4"])
t.Run("should drop label if renamed collides with existing", func(t *testing.T) {
result.Instance["label1"] = uuid.NewString()
result.Instance["label1_user"] = uuid.NewString()
result.Instance["label4_user"] = uuid.NewString()
state = newState(context.Background(), l, rule, result, nil, url)
assert.NotContains(t, state.Labels, "__label1__")
assert.Contains(t, state.Labels, "label1")
assert.Equal(t, state.Labels["label1"], result.Instance["label1"])
assert.Equal(t, state.Labels["label1_user"], result.Instance["label1_user"])
assert.NotContains(t, state.Labels, "label4")
assert.Equal(t, state.Labels["label4_user"], result.Instance["label4_user"])
})
})
t.Run("creates a state with preset fields if there is no current state", func(t *testing.T) {
rule := generateRule()
extraLabels := ngmodels.GenerateAlertLabels(2, "extra-")
result := eval.Result{
Instance: ngmodels.GenerateAlertLabels(5, "result-"),
}
expectedLbl, expectedAnn := expandAnnotationsAndLabels(context.Background(), l, rule, result, extraLabels, url)
state := newState(context.Background(), l, rule, result, extraLabels, url)
assert.Equal(t, rule.OrgID, state.OrgID)
assert.Equal(t, rule.UID, state.AlertRuleUID)
assert.Equal(t, state.Labels.Fingerprint(), state.CacheID)
assert.Equal(t, result.State, state.State)
assert.Equal(t, "", state.StateReason)
assert.Equal(t, result.Instance.Fingerprint(), state.ResultFingerprint)
assert.Nil(t, state.LatestResult)
assert.Nil(t, state.Error)
assert.Nil(t, state.Image)
assert.EqualValues(t, expectedAnn, state.Annotations)
assert.EqualValues(t, expectedLbl, state.Labels)
assert.Nil(t, state.Values)
assert.Equal(t, result.EvaluatedAt, state.StartsAt)
assert.Equal(t, result.EvaluatedAt, state.EndsAt)
assert.Nil(t, state.ResolvedAt)
assert.Nil(t, state.LastSentAt)
assert.Equal(t, "", state.LastEvaluationString)
assert.Equal(t, result.EvaluatedAt, state.LastEvaluationTime)
assert.Equal(t, result.EvaluationDuration, state.EvaluationDuration)
})
}
func TestPatch(t *testing.T) {
key := ngmodels.GenerateRuleKey(1)
t.Run("it populates some fields from the current state if it exists", func(t *testing.T) {
result := eval.Result{
Instance: ngmodels.GenerateAlertLabels(5, "result-"),
}
state := randomSate(key)
orig := state.Copy()
current := randomSate(key)
patch(&state, &current, result)
// Fields that should not change
assert.Equal(t, orig.OrgID, state.OrgID)
assert.Equal(t, orig.AlertRuleUID, state.AlertRuleUID)
assert.Equal(t, orig.CacheID, state.CacheID)
assert.Equal(t, orig.ResultFingerprint, state.ResultFingerprint)
assert.EqualValues(t, orig.Annotations, state.Annotations)
assert.EqualValues(t, orig.Labels, state.Labels)
assert.Equal(t, orig.LastEvaluationTime, state.LastEvaluationTime)
assert.Equal(t, orig.EvaluationDuration, state.EvaluationDuration)
assert.Equal(t, current.State, state.State)
assert.Equal(t, current.StateReason, state.StateReason)
assert.Equal(t, current.Image, state.Image)
assert.Equal(t, current.LatestResult, state.LatestResult)
assert.Equal(t, current.Error, state.Error)
assert.Equal(t, current.Values, state.Values)
assert.Equal(t, current.StartsAt, state.StartsAt)
assert.Equal(t, current.EndsAt, state.EndsAt)
assert.Equal(t, current.ResolvedAt, state.ResolvedAt)
assert.Equal(t, current.LastSentAt, state.LastSentAt)
assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString)
})
t.Run("copies system-owned annotations from current state", func(t *testing.T) {
state := randomSate(key)
orig := state.Copy()
expectedAnnotations := data.Labels(state.Annotations).Copy()
current := randomSate(key)
for key := range ngmodels.InternalAnnotationNameSet {
val := util.GenerateShortUID()
current.Annotations[key] = val
expectedAnnotations[key] = val
}
result := eval.Result{
Instance: ngmodels.GenerateAlertLabels(5, "result-"),
}
patch(&state, &current, result)
assert.EqualValues(t, expectedAnnotations, state.Annotations)
assert.Equal(t, current.State, state.State)
assert.Equal(t, current.StateReason, state.StateReason)
assert.Equal(t, current.Image, state.Image)
assert.Equal(t, current.LatestResult, state.LatestResult)
assert.Equal(t, current.Error, state.Error)
assert.Equal(t, current.Values, state.Values)
assert.Equal(t, current.StartsAt, state.StartsAt)
assert.Equal(t, current.EndsAt, state.EndsAt)
assert.Equal(t, current.ResolvedAt, state.ResolvedAt)
assert.Equal(t, current.LastSentAt, state.LastSentAt)
assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString)
// Fields that should not change
assert.Equal(t, orig.OrgID, state.OrgID)
assert.Equal(t, orig.AlertRuleUID, state.AlertRuleUID)
assert.Equal(t, orig.CacheID, state.CacheID)
assert.Equal(t, orig.ResultFingerprint, state.ResultFingerprint)
assert.EqualValues(t, orig.Labels, state.Labels)
assert.Equal(t, orig.LastEvaluationTime, state.LastEvaluationTime)
assert.Equal(t, orig.EvaluationDuration, state.EvaluationDuration)
})
t.Run("if result Error and current state is Error it should copy datasource_uid and ref_id labels", func(t *testing.T) {
state := randomSate(key)
orig := state.Copy()
current := randomSate(key)
current.State = eval.Error
current.Labels["datasource_uid"] = util.GenerateShortUID()
current.Labels["ref_id"] = util.GenerateShortUID()
result := eval.Result{
Instance: ngmodels.GenerateAlertLabels(5, "result-"),
State: eval.Error,
}
expectedLabels := orig.Labels.Copy()
expectedLabels["datasource_uid"] = current.Labels["datasource_uid"]
expectedLabels["ref_id"] = current.Labels["ref_id"]
patch(&state, &current, result)
assert.Equal(t, expectedLabels, state.Labels)
assert.Equal(t, current.State, state.State)
assert.Equal(t, current.StateReason, state.StateReason)
assert.Equal(t, current.Image, state.Image)
assert.Equal(t, current.LatestResult, state.LatestResult)
assert.Equal(t, current.Error, state.Error)
assert.Equal(t, current.Values, state.Values)
assert.Equal(t, current.StartsAt, state.StartsAt)
assert.Equal(t, current.EndsAt, state.EndsAt)
assert.Equal(t, current.ResolvedAt, state.ResolvedAt)
assert.Equal(t, current.LastSentAt, state.LastSentAt)
assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString)
// Fields that should not change
assert.Equal(t, orig.OrgID, state.OrgID)
assert.Equal(t, orig.AlertRuleUID, state.AlertRuleUID)
assert.Equal(t, orig.CacheID, state.CacheID)
assert.Equal(t, orig.ResultFingerprint, state.ResultFingerprint)
assert.Equal(t, orig.LastEvaluationTime, state.LastEvaluationTime)
assert.Equal(t, orig.EvaluationDuration, state.EvaluationDuration)
assert.EqualValues(t, orig.Annotations, state.Annotations)
})
}
+1
View File
@@ -7,6 +7,7 @@ describe('objects', () => {
deeper: 10,
foo: null,
arr: [null, 1, 'hello'],
value: -Infinity,
},
bar: undefined,
simple: 'A',
+4 -3
View File
@@ -1,16 +1,17 @@
import { isArray, isPlainObject } from 'lodash';
/** @returns a deep clone of the object, but with any null value removed */
export function sortedDeepCloneWithoutNulls<T extends {}>(value: T): T {
export function sortedDeepCloneWithoutNulls<T>(value: T): T {
if (isArray(value)) {
return value.map(sortedDeepCloneWithoutNulls) as unknown as T;
}
if (isPlainObject(value)) {
return Object.keys(value)
return Object.keys(value as { [key: string]: any })
.sort()
.reduce((acc: any, key) => {
const v = (value as any)[key];
if (v != null) {
// Remove null values and also -Infinity which is not a valid JSON value
if (v != null && v !== -Infinity) {
acc[key] = sortedDeepCloneWithoutNulls(v);
}
return acc;
@@ -40,7 +40,8 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
} else {
stateManager.loadDashboard({
uid: (route.routeName === DashboardRoutes.Provisioning ? path : uid) ?? '',
slug: slug,
type,
slug,
route: route.routeName as DashboardRoutes,
urlFolderUid: queryParams.folderUid,
});
@@ -267,11 +267,12 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
}
public async fetchDashboard({
type,
slug,
uid,
route,
urlFolderUid,
params,
slug,
}: LoadDashboardOptions): Promise<DashboardDTO | null> {
const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;
@@ -328,7 +329,7 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
}
: undefined;
rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid, queryParams);
rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid, queryParams);
if (route === DashboardRoutes.Embedded) {
rsp.meta.isEmbedded = true;
@@ -482,6 +483,8 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
}
public async fetchDashboard({
type,
slug,
uid,
route,
urlFolderUid,
@@ -534,7 +537,7 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
...params.variables,
}
: undefined;
rsp = await this.dashboardLoader.loadDashboard('db', '', uid, queryParams);
rsp = await this.dashboardLoader.loadDashboard(type || 'db', slug || '', uid, queryParams);
if (route === DashboardRoutes.Embedded) {
throw new Error('Method not implemented.');
// rsp.meta.isEmbedded = true;
@@ -30,6 +30,7 @@ import { ScrollRefElement } from 'app/core/components/NativeScrollbar';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { getNavModel } from 'app/core/selectors/navModel';
import store from 'app/core/store';
import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
@@ -675,7 +676,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
saveModel?: Dashboard | DashboardV2Spec,
meta?: DashboardMeta | DashboardWithAccessInfo<DashboardV2Spec>['metadata']
): void {
this._serializer.initialSaveModel = saveModel;
this._serializer.initialSaveModel = sortedDeepCloneWithoutNulls(saveModel);
this._serializer.metadata = meta;
}
@@ -36,6 +36,26 @@ jest.mock('@grafana/runtime', () => ({
getInstanceSettings: jest.fn(),
};
},
config: {
...jest.requireActual('@grafana/runtime').config,
bootData: {
settings: {
defaultDatasource: '-- Grafana --',
datasources: {
'-- Grafana --': {
name: 'Grafana',
meta: { id: 'grafana' },
type: 'datasource',
},
prometheus: {
name: 'prometheus',
meta: { id: 'prometheus' },
type: 'datasource',
},
},
},
},
},
}));
describe('DashboardSceneSerializer', () => {
@@ -293,7 +293,7 @@ describe('transformSaveModelSchemaV2ToScene', () => {
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.uid).toBe(MIXED_DATASOURCE_NAME);
});
it('should set panel ds as undefined if it is not mixed DS', () => {
it('should set ds if it is not mixed DS', () => {
const dashboard = cloneDeep(defaultDashboard);
getPanelElement(dashboard.spec, 'panel-1')?.spec.data.spec.queries.push({
kind: 'PanelQuery',
@@ -317,10 +317,13 @@ describe('transformSaveModelSchemaV2ToScene', () => {
const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels();
expect(vizPanels.length).toBe(3);
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource).toBeUndefined();
expect(getQueryRunnerFor(vizPanels[0])?.state.queries[0].datasource).toEqual({
type: 'prometheus',
uid: 'datasource1',
});
});
it('should set panel ds as mixed if one ds is undefined', () => {
it('should set panel ds as mixed if no panels have ds defined', () => {
const dashboard = cloneDeep(defaultDashboard);
getPanelElement(dashboard.spec, 'panel-1')?.spec.data.spec.queries.push({
@@ -234,7 +234,7 @@ function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined {
}
});
return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : undefined;
return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : datasource;
}
function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery {
@@ -11,6 +11,7 @@ import {
VizPanel,
} from '@grafana/scenes';
import { DataSourceRef } from '@grafana/schema';
import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object';
import {
DashboardV2Spec,
@@ -73,7 +74,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
const dashboardSchemaV2: DeepPartial<DashboardV2Spec> = {
//dashboard settings
title: sceneDash.title,
description: sceneDash.description ?? '',
description: sceneDash.description,
cursorSync: getCursorSync(sceneDash),
liveNow: getLiveNow(sceneDash),
preload: sceneDash.preload,
@@ -116,7 +117,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
try {
// validateDashboardSchemaV2 will throw an error if the dashboard is not valid
if (validateDashboardSchemaV2(dashboardSchemaV2)) {
return dashboardSchemaV2;
return sortedDeepCloneWithoutNulls(dashboardSchemaV2);
}
// should never reach this point, validation should throw an error
throw new Error('Error we could transform the dashboard to schema v2: ' + dashboardSchemaV2);
@@ -241,7 +242,7 @@ function getVizPanelQueries(vizPanel: VizPanel): PanelQueryKind[] {
const queries: PanelQueryKind[] = [];
const queryRunner = getQueryRunnerFor(vizPanel);
const vizPanelQueries = queryRunner?.state.queries;
const datasource = queryRunner?.state.datasource;
const datasource = queryRunner?.state.datasource ?? getDefaultDataSourceRef();
if (vizPanelQueries) {
vizPanelQueries.forEach((query) => {
@@ -250,7 +251,7 @@ function getVizPanelQueries(vizPanel: VizPanel): PanelQueryKind[] {
spec: omit(query, 'datasource', 'refId', 'hide'),
};
const querySpec: PanelQuerySpec = {
datasource: datasource ?? getDefaultDataSourceRef(),
datasource: query.datasource ?? datasource,
query: dataQuery,
refId: query.refId,
hidden: Boolean(query.hide),
@@ -446,7 +447,7 @@ function validateDashboardSchemaV2(dash: unknown): dash is DashboardV2Spec {
if ('title' in dash && typeof dash.title !== 'string') {
throw new Error('Title is not a string');
}
if ('description' in dash && typeof dash.description !== 'string') {
if ('description' in dash && dash.description !== undefined && typeof dash.description !== 'string') {
throw new Error('Description is not a string');
}
if ('cursorSync' in dash && typeof dash.cursorSync !== 'string') {
@@ -26,12 +26,12 @@ export interface Props extends GrafanaRouteComponentProps<DashboardPageRoutePara
export function SoloPanelPage({ queryParams }: Props) {
const stateManager = getDashboardScenePageStateManager();
const { dashboard, loadError } = stateManager.useState();
const { uid = '' } = useParams();
const { uid = '', type, slug } = useParams();
useEffect(() => {
stateManager.loadDashboard({ uid, route: DashboardRoutes.Embedded });
stateManager.loadDashboard({ uid, type, slug, route: DashboardRoutes.Embedded });
return () => stateManager.clearState();
}, [stateManager, queryParams, uid]);
}, [stateManager, queryParams, uid, type, slug]);
if (!queryParams.panelId) {
return <EntityNotFound entity="Panel" />;
@@ -64,7 +64,6 @@ export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: Dashboard
const [panel, error] = useSoloPanel(dashboard, panelId);
const { controls } = dashboard.useState();
const refreshPicker = controls?.useState()?.refreshPicker;
const styles = useStyles2(getStyles);
useEffect(() => {
@@ -487,7 +487,7 @@ describe('ResponseTransformers', () => {
expect(layout.spec.items[0].spec).toEqual({
element: {
kind: 'ElementReference',
name: '1',
name: 'panel-1',
},
x: 0,
y: 0,
@@ -495,7 +495,7 @@ describe('ResponseTransformers', () => {
height: 8,
repeat: { value: 'var1', direction: 'h', mode: 'variable', maxPerRow: undefined },
});
expect(spec.elements['1']).toEqual({
expect(spec.elements['panel-1']).toEqual({
kind: 'Panel',
spec: {
title: 'Panel Title',
@@ -550,14 +550,14 @@ describe('ResponseTransformers', () => {
expect(layout.spec.items[1].spec).toEqual({
element: {
kind: 'ElementReference',
name: '2',
name: 'panel-2',
},
x: 0,
y: 8,
width: 12,
height: 8,
});
expect(spec.elements['2']).toEqual({
expect(spec.elements['panel-2']).toEqual({
kind: 'LibraryPanel',
spec: {
libraryPanel: {
@@ -580,7 +580,7 @@ describe('ResponseTransformers', () => {
expect(panelInRow).toEqual({
element: {
kind: 'ElementReference',
name: '4',
name: 'panel-4',
},
x: 0,
y: 0,
@@ -598,7 +598,7 @@ describe('ResponseTransformers', () => {
expect(panelInCollapsedRow).toEqual({
element: {
kind: 'ElementReference',
name: '5',
name: 'panel-5',
},
x: 0,
y: 0,
@@ -383,7 +383,7 @@ function buildElement(p: Panel): [PanelKind | LibraryPanelKind, string] {
},
};
return [panelKind, p.id!.toString()];
return [panelKind, `panel-${p.id}`];
} else {
// PanelKind
@@ -433,7 +433,7 @@ function buildElement(p: Panel): [PanelKind | LibraryPanelKind, string] {
},
};
return [panelKind, p.id!.toString()];
return [panelKind, `panel-${p.id}`];
}
}
@@ -51,6 +51,7 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
route: props.route.routeName as DashboardRoutes,
uid: params.uid ?? '',
type: params.type,
slug: params.slug,
});
}, [params.uid, props.route.routeName]);
@@ -286,6 +286,8 @@ export class DashboardModel implements TimeModel {
*
* @internal and experimental
*/
// TODO: remove this as it's not being used anymore
// Also remove public/app/features/dashboard/utils/panelMerge.ts
updatePanels(panels: IPanelModel[]): PanelMergeInfo {
const info = mergePanels(this.panels, panels ?? []);
if (info.changed) {
@@ -4,7 +4,8 @@ import { FieldColorModeId, ThresholdsMode } from '@grafana/schema/src';
import { DashboardModel } from '../state/DashboardModel';
import { createDashboardModelFixture, createPanelSaveModel } from '../state/__fixtures__/dashboardFixtures';
describe('Merge dashboard panels', () => {
// skipping these tests because panelMerge is not used
describe.skip('Merge dashboard panels', () => {
describe('simple changes', () => {
let dashboard: DashboardModel;
let rawPanels: PanelModel[];
+4 -2
View File
@@ -97,8 +97,10 @@ export function getAppRoutes(): RouteDescriptor[] {
path: '/dashboard-solo/:type/:slug',
routeName: DashboardRoutes.Normal,
chromeless: true,
component: SafeDynamicImport(
() => import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage')
component: SafeDynamicImport(() =>
config.featureToggles.dashboardSceneSolo
? import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard-scene/solo/SoloPanelPage')
: import(/* webpackChunkName: "SoloPanelPageOld" */ '../features/dashboard/containers/SoloPanelPage')
),
},
{
+9 -19
View File
@@ -13,16 +13,11 @@
'use strict';
// accessible variables in this scope
let window, document, $, jQuery, moment, kbn;
// accessible variables in this scope: window, document, $, jQuery, moment, kbn;
// Setup some variables
let dashboard;
// All url parameters are available via the ARGS object
// eslint-disable-next-line no-redeclare
let ARGS;
// Initialize a skeleton with nothing but a rows array and service object
dashboard = {
rows: [],
@@ -56,6 +51,7 @@ for (let i = 0; i < rows; i++) {
height: '300px',
panels: [
{
id: 1,
title: 'Events',
type: 'graph',
span: 12,
@@ -63,23 +59,17 @@ for (let i = 0; i < rows; i++) {
linewidth: 2,
targets: [
{
target: "randomWalk('" + seriesName + "')",
scenarioId: 'random_walk',
refId: 'A',
seriesCount: 1,
alias: seriesName,
},
{
target: "randomWalk('random walk2')",
scenarioId: 'random_walk',
refId: 'B',
seriesCount: 1,
},
],
seriesOverrides: [
{
alias: '/random/',
yaxis: 2,
fill: 0,
linewidth: 5,
},
],
tooltip: {
shared: true,
},
},
],
});
+2 -1
View File
@@ -17,7 +17,7 @@
'use strict';
// accessible variables in this scope
let window, document, ARGS, $, jQuery, moment, kbn;
// let window, document, ARGS, $, jQuery, moment, kbn;
return function (callback) {
// Setup some variables
@@ -60,6 +60,7 @@ return function (callback) {
height: '300px',
panels: [
{
id: 1,
title: 'Async dashboard test',
type: 'text',
span: 12,
+24 -14
View File
@@ -14,14 +14,14 @@
'use strict';
// accessible variables in this scope
let window, document, $, jQuery, moment, kbn;
// let window, document, $, jQuery, moment, kbn;
// Setup some variables
let dashboard;
// All url parameters are available via the ARGS object
// eslint-disable-next-line no-redeclare
let ARGS;
// let ARGS;
// Initialize a skeleton with nothing but a rows array and service object
dashboard = {
@@ -44,19 +44,22 @@ dashboard.templating = {
list: [
{
name: 'test',
query: 'apps.backend.*',
refresh: 1,
type: 'query',
datasource: null,
hide: 2,
includeAll: false,
multi: false,
query: 'a,b,c\n',
skipUrlSync: false,
type: 'custom',
},
{
name: 'test2',
query: '*',
refresh: 1,
type: 'query',
datasource: null,
hide: 2,
name: 'seriesName',
label: 'Series name',
hide: 0,
includeAll: false,
multi: false,
query: 'series1,series2,series3\n',
skipUrlSync: false,
type: 'custom',
},
],
};
@@ -78,6 +81,7 @@ for (let i = 0; i < rows; i++) {
height: '300px',
panels: [
{
id: 1,
title: 'Events',
type: 'graph',
span: 12,
@@ -85,10 +89,16 @@ for (let i = 0; i < rows; i++) {
linewidth: 2,
targets: [
{
target: "randomWalk('" + seriesName + "')",
scenarioId: 'random_walk',
refId: 'A',
seriesCount: 1,
alias: seriesName,
},
{
target: "randomWalk('[[test2]]')",
scenarioId: 'random_walk',
refId: 'B',
seriesCount: 1,
alias: '${seriesName}',
},
],
},