From 9d1d0e72c2a40f5b8836898141370c38b1735acd Mon Sep 17 00:00:00 2001 From: Tito Lins Date: Wed, 14 Jan 2026 10:04:29 +0100 Subject: [PATCH 1/7] Alerting: add sync timer support (#114602) - add new feature flag to support enabling the dispatcher sync timer on the alertmanager - this attempts to synchronize the flushes across HA nodes to decrease amount of duplicate notifications --------- Co-authored-by: Yuri Tseretyan --- .../src/types/featureToggles.gen.ts | 4 ++ pkg/services/featuremgmt/registry.go | 11 +++- pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++ pkg/services/featuremgmt/toggles_gen.json | 17 +++++- pkg/services/ngalert/ngalert.go | 4 ++ pkg/services/ngalert/notifier/alertmanager.go | 26 ++++++++++ .../ngalert/notifier/dispatch_timer.go | 16 ++++++ .../ngalert/notifier/dispatch_timer_test.go | 36 +++++++++++++ pkg/services/ngalert/notifier/file_store.go | 10 ++++ .../ngalert/notifier/file_store_test.go | 45 ++++++++++++++++ .../ngalert/notifier/multiorg_alertmanager.go | 3 +- pkg/services/ngalert/notifier/state.go | 5 +- pkg/services/ngalert/notifier/testing.go | 52 +++++++++++++++++-- pkg/services/ngalert/remote/alertmanager.go | 24 +++++++-- .../client/alertmanager_configuration.go | 5 ++ 16 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 pkg/services/ngalert/notifier/dispatch_timer.go create mode 100644 pkg/services/ngalert/notifier/dispatch_timer_test.go diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index aa0581004e1..cdcd4b53092 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1251,4 +1251,8 @@ export interface FeatureToggles { * Enables profiles exemplars support in profiles drilldown */ profilesExemplars?: boolean; + /** + * Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods + */ + alertingSyncDispatchTimer?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 4c0456e9457..6b716789f8c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -981,7 +981,8 @@ var ( Stage: FeatureStageDeprecated, Owner: grafanaPartnerPluginsSquad, Expression: "true", // Enabled by default for now - }, { + }, + { Name: "alertingFilterV2", Description: "Enable the new alerting search experience", Stage: FeatureStageExperimental, @@ -2069,6 +2070,14 @@ var ( Owner: grafanaObservabilityTracesAndProfilingSquad, FrontendOnly: false, }, + { + Name: "alertingSyncDispatchTimer", + Description: "Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + RequiresRestart: true, + HideFromDocs: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index caba7cdab90..09c75a82041 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -280,3 +280,4 @@ multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true smoothingTransformation,experimental,@grafana/datapro,false,false,true secretsManagementAppPlatformAwsKeeper,experimental,@grafana/grafana-operator-experience-squad,false,false,false profilesExemplars,experimental,@grafana/observability-traces-and-profiling,false,false,false +alertingSyncDispatchTimer,experimental,@grafana/alerting-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index d68fa56ec8c..17498d7afb9 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -789,4 +789,8 @@ const ( // FlagProfilesExemplars // Enables profiles exemplars support in profiles drilldown FlagProfilesExemplars = "profilesExemplars" + + // FlagAlertingSyncDispatchTimer + // Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods + FlagAlertingSyncDispatchTimer = "alertingSyncDispatchTimer" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index bfefc20f08b..071f1b0671e 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -511,6 +511,20 @@ "frontend": true } }, + { + "metadata": { + "name": "alertingSyncDispatchTimer", + "resourceVersion": "1766161788928", + "creationTimestamp": "2025-12-19T16:29:48Z" + }, + "spec": { + "description": "Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "requiresRestart": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "alertingTriage", @@ -662,7 +676,8 @@ "metadata": { "name": "auditLoggingAppPlatform", "resourceVersion": "1767013056996", - "creationTimestamp": "2025-12-29T12:57:36Z" + "creationTimestamp": "2025-12-29T12:57:36Z", + "deletionTimestamp": "2026-01-06T09:18:36Z" }, "spec": { "description": "Enable audit logging with Kubernetes under app platform", diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 53e49621117..5177107a602 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -213,6 +213,9 @@ func (ng *AlertNG) init() error { SkipVerify: ng.Cfg.Smtp.SkipVerify, StaticHeaders: ng.Cfg.Smtp.StaticHeaders, } + runtimeConfig := remoteClient.RuntimeConfig{ + DispatchTimer: notifier.GetDispatchTimer(ng.FeatureToggles).String(), + } cfg := remote.AlertmanagerConfig{ BasicAuthPassword: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Password, @@ -222,6 +225,7 @@ func (ng *AlertNG) init() error { ExternalURL: ng.Cfg.AppURL, SmtpConfig: smtpCfg, Timeout: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Timeout, + RuntimeConfig: runtimeConfig, } autogenFn := func(ctx context.Context, logger log.Logger, orgID int64, cfg *definitions.PostableApiAlertingConfig, invalidReceiverAction notifier.InvalidReceiversAction) error { return notifier.AddAutogenConfig(ctx, logger, ng.store, orgID, cfg, invalidReceiverAction, ng.FeatureToggles) diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index f192ed88058..6d81d51dc75 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -33,6 +33,9 @@ const ( // How long we keep silences in the kvstore after they've expired. silenceRetention = 5 * 24 * time.Hour + + // How long we keep flushes in the kvstore after they've expired. + flushRetention = 5 * 24 * time.Hour ) type AlertingStore interface { @@ -44,8 +47,10 @@ type AlertingStore interface { type stateStore interface { SaveSilences(ctx context.Context, st alertingNotify.State) (int64, error) SaveNotificationLog(ctx context.Context, st alertingNotify.State) (int64, error) + SaveFlushLog(ctx context.Context, st alertingNotify.State) (int64, error) GetSilences(ctx context.Context) (string, error) GetNotificationLog(ctx context.Context) (string, error) + GetFlushLog(ctx context.Context) (string, error) } type alertmanager struct { @@ -101,6 +106,10 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A if err != nil { return nil, err } + flushLog, err := stateStore.GetFlushLog(ctx) + if err != nil { + return nil, err + } silencesOptions := maintenanceOptions{ initialState: silences, @@ -123,12 +132,29 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A } l := log.New("ngalert.notifier") + dispatchTimer := GetDispatchTimer(featureToggles) + + var flushLogOptions *maintenanceOptions + if dispatchTimer == alertingNotify.DispatchTimerSync { + flushLogOptions = &maintenanceOptions{ + initialState: flushLog, + retention: flushRetention, + maintenanceFrequency: maintenanceInterval, + maintenanceFunc: func(state alertingNotify.State) (int64, error) { + // Detached context here is to make sure that when the service is shut down the persist operation is executed. + return stateStore.SaveFlushLog(context.Background(), state) + }, + } + } + opts := alertingNotify.GrafanaAlertmanagerOpts{ ExternalURL: cfg.AppURL, AlertStoreCallback: nil, PeerTimeout: cfg.UnifiedAlerting.HAPeerTimeout, Silences: silencesOptions, Nflog: nflogOptions, + FlushLog: flushLogOptions, + DispatchTimer: dispatchTimer, Limits: alertingNotify.Limits{ MaxSilences: cfg.UnifiedAlerting.AlertmanagerMaxSilencesCount, MaxSilenceSizeBytes: cfg.UnifiedAlerting.AlertmanagerMaxSilenceSizeBytes, diff --git a/pkg/services/ngalert/notifier/dispatch_timer.go b/pkg/services/ngalert/notifier/dispatch_timer.go new file mode 100644 index 00000000000..04eaf8cb296 --- /dev/null +++ b/pkg/services/ngalert/notifier/dispatch_timer.go @@ -0,0 +1,16 @@ +package notifier + +import ( + alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +// GetDispatchTimer returns the appropriate dispatch timer based on feature toggles. +func GetDispatchTimer(features featuremgmt.FeatureToggles) (dt alertingNotify.DispatchTimer) { + //nolint:staticcheck // not yet migrated to OpenFeature + enabled := features.IsEnabledGlobally(featuremgmt.FlagAlertingSyncDispatchTimer) + if enabled { + dt = alertingNotify.DispatchTimerSync + } + return +} diff --git a/pkg/services/ngalert/notifier/dispatch_timer_test.go b/pkg/services/ngalert/notifier/dispatch_timer_test.go new file mode 100644 index 00000000000..3b42a562a32 --- /dev/null +++ b/pkg/services/ngalert/notifier/dispatch_timer_test.go @@ -0,0 +1,36 @@ +package notifier + +import ( + "testing" + + alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/stretchr/testify/require" +) + +func TestGetDispatchTimer(t *testing.T) { + tests := []struct { + name string + featureFlagValue bool + expected alertingNotify.DispatchTimer + }{ + { + name: "feature flag enabled returns sync timer", + featureFlagValue: true, + expected: alertingNotify.DispatchTimerSync, + }, + { + name: "feature flag disabled returns default timer", + featureFlagValue: false, + expected: alertingNotify.DispatchTimerDefault, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + features := featuremgmt.WithFeatures(featuremgmt.FlagAlertingSyncDispatchTimer, tt.featureFlagValue) + result := GetDispatchTimer(features) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/services/ngalert/notifier/file_store.go b/pkg/services/ngalert/notifier/file_store.go index e9628cb536e..3bb128d692d 100644 --- a/pkg/services/ngalert/notifier/file_store.go +++ b/pkg/services/ngalert/notifier/file_store.go @@ -15,6 +15,7 @@ const ( KVNamespace = "alertmanager" NotificationLogFilename = "notifications" SilencesFilename = "silences" + FlushLogFilename = "flushes" ) // FileStore is in charge of persisting the alertmanager files to the database. @@ -42,6 +43,10 @@ func (fileStore *FileStore) GetNotificationLog(ctx context.Context) (string, err return fileStore.contentFor(ctx, NotificationLogFilename) } +func (fileStore *FileStore) GetFlushLog(ctx context.Context) (string, error) { + return fileStore.contentFor(ctx, FlushLogFilename) +} + // contentFor returns the content for the given Alertmanager kvstore key. func (fileStore *FileStore) contentFor(ctx context.Context, filename string) (string, error) { // Then, let's attempt to read it from the database. @@ -74,6 +79,11 @@ func (fileStore *FileStore) SaveNotificationLog(ctx context.Context, st alerting return fileStore.persist(ctx, NotificationLogFilename, st) } +// SaveFlushLog saves the flush log to the database and returns the size of the unencoded state. +func (fileStore *FileStore) SaveFlushLog(ctx context.Context, st alertingNotify.State) (int64, error) { + return fileStore.persist(ctx, FlushLogFilename, st) +} + // persist takes care of persisting the binary representation of internal state to the database as a base64 encoded string. func (fileStore *FileStore) persist(ctx context.Context, filename string, st alertingNotify.State) (int64, error) { var size int64 diff --git a/pkg/services/ngalert/notifier/file_store_test.go b/pkg/services/ngalert/notifier/file_store_test.go index 1952eb5a0f1..7d4de602a0f 100644 --- a/pkg/services/ngalert/notifier/file_store_test.go +++ b/pkg/services/ngalert/notifier/file_store_test.go @@ -106,3 +106,48 @@ func TestFileStore_NotificationLog(t *testing.T) { t.Errorf("Unexpected Diff: %v", cmp.Diff(newState, decoded)) } } + +func TestFileStore_FlushLog(t *testing.T) { + store := fakes.NewFakeKVStore(t) + ctx := context.Background() + var orgId int64 = 1 + + // Initialize kvstore with empty flush log state. + initialState := flushLogState{} // FlushLog uses the same structure as nflog + decodedState, err := initialState.MarshalBinary() + require.NoError(t, err) + encodedState := base64.StdEncoding.EncodeToString(decodedState) + err = store.Set(ctx, orgId, KVNamespace, FlushLogFilename, encodedState) + require.NoError(t, err) + + fs := NewFileStore(orgId, store) + + // Load initial (empty). + flushLog, err := fs.GetFlushLog(ctx) + require.NoError(t, err) + decoded, err := decodeFlushLogState(strings.NewReader(flushLog)) + require.NoError(t, err) + if !cmp.Equal(initialState, decoded) { + t.Errorf("Unexpected Diff: %v", cmp.Diff(initialState, decoded)) + } + + // Save new flush log state. + now := time.Now() + oneHour := now.Add(time.Hour) + + v1 := createFlushLog(1, now, oneHour) + v2 := createFlushLog(2, now, oneHour) + newState := flushLogState{1: v1, 2: v2} + size, err := fs.SaveFlushLog(ctx, newState) + require.NoError(t, err) + require.Greater(t, size, int64(0)) + + // Load new. + flushLog, err = fs.GetFlushLog(ctx) + require.NoError(t, err) + decoded, err = decodeFlushLogState(strings.NewReader(flushLog)) + require.NoError(t, err) + if !cmp.Equal(newState, decoded) { + t.Errorf("Unexpected Diff: %v", cmp.Diff(newState, decoded)) + } +} diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index 4aa0151d18f..a10aee29ef6 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -82,6 +82,7 @@ type Alertmanager interface { type ExternalState struct { Silences []byte Nflog []byte + FlushLog []byte } // StateMerger describes a type that is able to merge external state (nflog, silences) with its own. @@ -378,7 +379,7 @@ func (moa *MultiOrgAlertmanager) SyncAlertmanagersForOrgs(ctx context.Context, o func (moa *MultiOrgAlertmanager) cleanupOrphanLocalOrgState(ctx context.Context, activeOrganizations map[int64]struct{}, ) { - storedFiles := []string{NotificationLogFilename, SilencesFilename} + storedFiles := []string{NotificationLogFilename, SilencesFilename, FlushLogFilename} for _, fileName := range storedFiles { keys, err := moa.kvStore.Keys(ctx, kvstore.AllOrganizations, KVNamespace, fileName) if err != nil { diff --git a/pkg/services/ngalert/notifier/state.go b/pkg/services/ngalert/notifier/state.go index c8551d2ed1a..04ba9d9a31d 100644 --- a/pkg/services/ngalert/notifier/state.go +++ b/pkg/services/ngalert/notifier/state.go @@ -5,5 +5,8 @@ func (am *alertmanager) MergeState(state ExternalState) error { if err := am.Base.MergeNflog(state.Nflog); err != nil { return err } - return am.Base.MergeSilences(state.Silences) + if err := am.Base.MergeSilences(state.Silences); err != nil { + return err + } + return am.Base.MergeFlushLog(state.FlushLog) } diff --git a/pkg/services/ngalert/notifier/testing.go b/pkg/services/ngalert/notifier/testing.go index 9fccf6d2f0d..c2b2190183f 100644 --- a/pkg/services/ngalert/notifier/testing.go +++ b/pkg/services/ngalert/notifier/testing.go @@ -11,6 +11,7 @@ import ( "time" "github.com/matttproud/golang_protobuf_extensions/pbutil" + "github.com/prometheus/alertmanager/flushlog/flushlogpb" "github.com/prometheus/alertmanager/nflog/nflogpb" "github.com/prometheus/alertmanager/silence/silencepb" "github.com/prometheus/common/model" @@ -228,15 +229,13 @@ func (f *FakeOrgStore) FetchOrgIds(_ context.Context) ([]int64, error) { return f.orgs, nil } -type NoValidation struct { -} +type NoValidation struct{} func (n NoValidation) Validate(_ models.NotificationSettings) error { return nil } -type RejectingValidation struct { -} +type RejectingValidation struct{} func (n RejectingValidation) Validate(s models.NotificationSettings) error { return ErrorReceiverDoesNotExist{ErrorReferenceInvalid: ErrorReferenceInvalid{Reference: s.Receiver}} @@ -365,6 +364,51 @@ func createNotificationLog(groupKey string, receiverName string, sentAt, expires } } +// https://github.com/grafana/prometheus-alertmanager/blob/main/flushlog/flushlog.go#L136-L136 +type flushLogState map[uint64]*flushlogpb.MeshFlushLog + +func (s flushLogState) MarshalBinary() ([]byte, error) { + var buf bytes.Buffer + + for _, e := range s { + if _, err := pbutil.WriteDelimited(&buf, e); err != nil { + return nil, err + } + } + return buf.Bytes(), nil +} + +func createFlushLog(groupFingerprint uint64, ts, expiresAt time.Time) *flushlogpb.MeshFlushLog { + return &flushlogpb.MeshFlushLog{ + FlushLog: &flushlogpb.FlushLog{ + GroupFingerprint: groupFingerprint, + Timestamp: ts, + }, + ExpiresAt: expiresAt, + } +} + +// decodeFlushLogState copied from decodeState in prometheus-alertmanager/flushlog/flushlog.go +func decodeFlushLogState(r io.Reader) (flushLogState, error) { + st := flushLogState{} + for { + var e flushlogpb.MeshFlushLog + _, err := pbutil.ReadDelimited(r, &e) + if err == nil { + if e.FlushLog == nil || e.FlushLog.GroupFingerprint == 0 || e.FlushLog.Timestamp.IsZero() { + return nil, errInvalidState + } + st[e.FlushLog.GroupFingerprint] = &e + continue + } + if errors.Is(err, io.EOF) { + break + } + return nil, err + } + return st, nil +} + type call struct { Method string Args []interface{} diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index 60740d935af..07fa5e3138f 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -47,6 +47,7 @@ import ( type stateStore interface { GetSilences(ctx context.Context) (string, error) GetNotificationLog(ctx context.Context) (string, error) + GetFlushLog(ctx context.Context) (string, error) } // AutogenFn is a function that adds auto-generated routes to a configuration. @@ -86,6 +87,8 @@ type Alertmanager struct { promoteConfig bool externalURL string + + runtimeConfig remoteClient.RuntimeConfig } type AlertmanagerConfig struct { @@ -111,6 +114,9 @@ type AlertmanagerConfig struct { // Timeout for the HTTP client. Timeout time.Duration + + // RuntimeConfig specifies runtime behavior settings for the remote Alertmanager. + RuntimeConfig remoteClient.RuntimeConfig } func (cfg *AlertmanagerConfig) Validate() error { @@ -203,6 +209,7 @@ func NewAlertmanager(ctx context.Context, cfg AlertmanagerConfig, store stateSto externalURL: cfg.ExternalURL, promoteConfig: cfg.PromoteConfig, smtp: cfg.SmtpConfig, + runtimeConfig: cfg.RuntimeConfig, } // Parse the default configuration once and remember its hash so we can compare it later. @@ -331,10 +338,11 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, crea AlertmanagerConfig: mergeResult.Config, Templates: templates, }, - CreatedAt: createdAtEpoch, - Promoted: am.promoteConfig, - ExternalURL: am.externalURL, - SmtpConfig: am.smtp, + CreatedAt: createdAtEpoch, + Promoted: am.promoteConfig, + ExternalURL: am.externalURL, + SmtpConfig: am.smtp, + RuntimeConfig: am.runtimeConfig, } cfgHash, err := calculateUserGrafanaConfigHash(payload) @@ -388,6 +396,8 @@ func (am *Alertmanager) GetRemoteState(ctx context.Context) (notifier.ExternalSt rs.Silences = p.Data case "nfl": rs.Nflog = p.Data + case "fls": + rs.FlushLog = p.Data default: return rs, fmt.Errorf("unknown part key %q", p.Key) } @@ -677,6 +687,12 @@ func (am *Alertmanager) getFullState(ctx context.Context) (string, error) { } parts = append(parts, alertingClusterPB.Part{Key: notifier.NotificationLogFilename, Data: []byte(notificationLog)}) + flushLog, err := am.state.GetFlushLog(ctx) + if err != nil { + return "", fmt.Errorf("error getting flush log: %w", err) + } + parts = append(parts, alertingClusterPB.Part{Key: notifier.FlushLogFilename, Data: []byte(flushLog)}) + fs := alertingClusterPB.FullState{ Parts: parts, } diff --git a/pkg/services/ngalert/remote/client/alertmanager_configuration.go b/pkg/services/ngalert/remote/client/alertmanager_configuration.go index a53132a8812..75246a6f32d 100644 --- a/pkg/services/ngalert/remote/client/alertmanager_configuration.go +++ b/pkg/services/ngalert/remote/client/alertmanager_configuration.go @@ -29,6 +29,10 @@ func (u *GrafanaAlertmanagerConfig) MarshalJSON() ([]byte, error) { return definition.MarshalJSONWithSecrets((*cfg)(u)) } +type RuntimeConfig struct { + DispatchTimer string `json:"dispatch_timer"` +} + type UserGrafanaConfig struct { GrafanaAlertmanagerConfig GrafanaAlertmanagerConfig `json:"configuration"` Hash string `json:"configuration_hash"` @@ -37,6 +41,7 @@ type UserGrafanaConfig struct { Promoted bool `json:"promoted"` ExternalURL string `json:"external_url"` SmtpConfig SmtpConfig `json:"smtp_config"` + RuntimeConfig RuntimeConfig `json:"runtime_config"` } func (mc *Mimir) GetGrafanaAlertmanagerConfig(ctx context.Context) (*UserGrafanaConfig, error) { From 78d507d285aeda286a95353ae09aa725453e4b99 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Wed, 14 Jan 2026 11:50:37 +0200 Subject: [PATCH 2/7] Dynamic Dashboards: Change the stage of the feature toggle (#116189) --- .../configure-grafana/feature-toggles/index.md | 1 + packages/grafana-data/src/types/featureToggles.gen.ts | 2 +- pkg/services/featuremgmt/registry.go | 4 ++-- pkg/services/featuremgmt/toggles_gen.csv | 2 +- pkg/services/featuremgmt/toggles_gen.go | 2 +- pkg/services/featuremgmt/toggles_gen.json | 11 +++++++---- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index b7f55555e07..813efb29eba 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -83,6 +83,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `reportingRetries` | Enables rendering retries for the reporting feature | | `externalServiceAccounts` | Automatic service account and token setup for plugins | | `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | +| `dashboardNewLayouts` | Enables new dashboard layouts | | `pdfTables` | Enables generating table data as PDF in reporting | | `canvasPanelPanZoom` | Allow pan and zoom in canvas panel | | `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index cdcd4b53092..1581af4e3d7 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -356,7 +356,7 @@ export interface FeatureToggles { */ dashboardScene?: boolean; /** - * Enables experimental new dashboard layouts + * Enables new dashboard layouts */ dashboardNewLayouts?: boolean; /** diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 6b716789f8c..b436639a896 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -574,8 +574,8 @@ var ( }, { Name: "dashboardNewLayouts", - Description: "Enables experimental new dashboard layouts", - Stage: FeatureStageExperimental, + Description: "Enables new dashboard layouts", + Stage: FeatureStagePublicPreview, FrontendOnly: false, // The restore backend feature changes behavior based on this flag Owner: grafanaDashboardsSquad, }, diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 09c75a82041..c605f098bb5 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -79,7 +79,7 @@ annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true dashboardScene,GA,@grafana/dashboards-squad,false,false,true -dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false +dashboardNewLayouts,preview,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 17498d7afb9..3a71abab00a 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -260,7 +260,7 @@ const ( FlagAnnotationPermissionUpdate = "annotationPermissionUpdate" // FlagDashboardNewLayouts - // Enables experimental new dashboard layouts + // Enables new dashboard layouts FlagDashboardNewLayouts = "dashboardNewLayouts" // FlagPdfTables diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 071f1b0671e..66cff415ec4 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1030,12 +1030,15 @@ { "metadata": { "name": "dashboardNewLayouts", - "resourceVersion": "1764664939750", - "creationTimestamp": "2024-10-23T08:55:45Z" + "resourceVersion": "1768382835527", + "creationTimestamp": "2024-10-23T08:55:45Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-14 09:27:15.527103 +0000 UTC" + } }, "spec": { - "description": "Enables experimental new dashboard layouts", - "stage": "experimental", + "description": "Enables new dashboard layouts", + "stage": "preview", "codeowner": "@grafana/dashboards-squad" } }, From d680537ea1072b14759eaba4daa9e56a729a1a53 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Wed, 14 Jan 2026 11:05:16 +0100 Subject: [PATCH 3/7] Advisor: Simplify interface used (#116191) --- apps/advisor/pkg/app/checks/datasourcecheck/check.go | 4 ++-- .../pkg/app/checks/datasourcecheck/missing_plugin_step.go | 2 +- apps/advisor/pkg/app/checks/ifaces.go | 8 ++++++++ apps/advisor/pkg/app/checks/plugincheck/check.go | 4 ++-- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check.go b/apps/advisor/pkg/app/checks/datasourcecheck/check.go index cae29e181fd..c35c47e45f6 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check.go @@ -28,7 +28,7 @@ type check struct { PluginStore pluginstore.Store PluginContextProvider PluginContextProvider PluginClient plugins.Client - PluginRepo repo.Service + PluginRepo checks.PluginInfoGetter GrafanaVersion string pluginCanBeInstalledCache map[string]bool pluginExistsCacheMu sync.RWMutex @@ -39,7 +39,7 @@ func New( pluginStore pluginstore.Store, pluginContextProvider PluginContextProvider, pluginClient plugins.Client, - pluginRepo repo.Service, + pluginRepo checks.PluginInfoGetter, grafanaVersion string, ) checks.Check { return &check{ diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/missing_plugin_step.go b/apps/advisor/pkg/app/checks/datasourcecheck/missing_plugin_step.go index 1d784f0a544..9b70f5d0896 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/missing_plugin_step.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/missing_plugin_step.go @@ -15,7 +15,7 @@ import ( type missingPluginStep struct { PluginStore pluginstore.Store - PluginRepo repo.Service + PluginRepo checks.PluginInfoGetter GrafanaVersion string } diff --git a/apps/advisor/pkg/app/checks/ifaces.go b/apps/advisor/pkg/app/checks/ifaces.go index 6573253b557..2b205933151 100644 --- a/apps/advisor/pkg/app/checks/ifaces.go +++ b/apps/advisor/pkg/app/checks/ifaces.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana-app-sdk/logging" advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1" + "github.com/grafana/grafana/pkg/plugins/repo" ) // Check returns metadata about the check being executed and the list of Steps @@ -37,3 +38,10 @@ type Step interface { // Run executes the step for an item and returns a report Run(ctx context.Context, log logging.Logger, obj *advisorv0alpha1.CheckSpec, item any) ([]advisorv0alpha1.CheckReportFailure, error) } + +// PluginInfoGetter is a minimal interface for retrieving plugin information from a repository. +// It contains only the GetPluginsInfo method used by plugincheck and datasourcecheck. +type PluginInfoGetter interface { + // GetPluginsInfo will return a list of plugins from grafana.com/api/plugins. + GetPluginsInfo(ctx context.Context, options repo.GetPluginsInfoOptions, compatOpts repo.CompatOpts) ([]repo.PluginInfo, error) +} diff --git a/apps/advisor/pkg/app/checks/plugincheck/check.go b/apps/advisor/pkg/app/checks/plugincheck/check.go index 3d261f81b67..00bc293e86c 100644 --- a/apps/advisor/pkg/app/checks/plugincheck/check.go +++ b/apps/advisor/pkg/app/checks/plugincheck/check.go @@ -17,7 +17,7 @@ const ( func New( pluginStore pluginstore.Store, - pluginRepo repo.Service, + pluginRepo checks.PluginInfoGetter, updateChecker pluginchecker.PluginUpdateChecker, pluginErrorResolver plugins.ErrorResolver, grafanaVersion string, @@ -33,7 +33,7 @@ func New( type check struct { PluginStore pluginstore.Store - PluginRepo repo.Service + PluginRepo checks.PluginInfoGetter updateChecker pluginchecker.PluginUpdateChecker pluginErrorResolver plugins.ErrorResolver GrafanaVersion string From afd84f033511d68fc4c787ca06794e132b37baf1 Mon Sep 17 00:00:00 2001 From: Natalia Bernarte Oses Date: Wed, 14 Jan 2026 11:10:51 +0100 Subject: [PATCH 4/7] Datagrid: Deprecate panel (#116071) * deprecate datagrid * Update docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> --------- Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> --- .../panels-visualizations/visualizations/datagrid/index.md | 4 +++- public/app/plugins/panel/datagrid/plugin.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md index b54326968a6..3a4a448ae85 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md @@ -30,7 +30,9 @@ refs: # Datagrid -{{< docs/experimental product="The datagrid visualization" featureFlag="`enableDatagridEditing`" >}} +{{< admonition type="caution" >}} +Starting with Grafana 12.4, Datagrid is deprecated. It will be removed in version 13.0. +{{< /admonition >}} Datagrids offer you the ability to create, edit, and fine-tune data within Grafana. As such, this panel can act as a data source for other panels inside a dashboard. diff --git a/public/app/plugins/panel/datagrid/plugin.json b/public/app/plugins/panel/datagrid/plugin.json index 72d430b0b87..4baeb456ea1 100644 --- a/public/app/plugins/panel/datagrid/plugin.json +++ b/public/app/plugins/panel/datagrid/plugin.json @@ -2,7 +2,7 @@ "type": "panel", "name": "Datagrid", "id": "datagrid", - "state": "beta", + "state": "deprecated", "info": { "author": { From 0d1e0bc21cf690e5df837f765b21a5ab6c0a9467 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Wed, 14 Jan 2026 11:29:43 +0100 Subject: [PATCH 5/7] PanelMenu: use openInNewTab links extensions API correctly (#116200) * Extensons: Make links use openInNewTab API * Use openInNewTab api correctly in the UI * Bump scenes * Fx circular dep * test * Revert "test" This reverts commit 8784a7992c60889dda824433331b7072ea80b0cd. --- package.json | 4 ++-- packages/grafana-data/src/index.ts | 2 +- packages/grafana-data/src/types/dataLink.ts | 3 +-- packages/grafana-data/src/types/linkTarget.ts | 4 ++++ packages/grafana-data/src/types/navModel.ts | 2 +- packages/grafana-data/src/types/panel.ts | 2 ++ .../plugins/extensions/getPluginExtensions.ts | 1 + .../app/features/plugins/extensions/utils.tsx | 2 ++ yarn.lock | 22 +++++++++---------- 9 files changed, 25 insertions(+), 17 deletions(-) create mode 100644 packages/grafana-data/src/types/linkTarget.ts diff --git a/package.json b/package.json index a6887b216e4..0a39ff67aea 100644 --- a/package.json +++ b/package.json @@ -293,8 +293,8 @@ "@grafana/plugin-ui": "^0.11.1", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "v6.52.1", - "@grafana/scenes-react": "v6.52.1", + "@grafana/scenes": "6.52.2", + "@grafana/scenes-react": "6.52.2", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 6027b566764..5ed081b00f0 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -844,7 +844,6 @@ export { DataLinkConfigOrigin, SupportedTransformationType, type InternalDataLink, - type LinkTarget, type LinkModel, type LinkModelSupplier, VariableOrigin, @@ -852,6 +851,7 @@ export { VariableSuggestionsScope, OneClickMode, } from './types/dataLink'; +export { type LinkTarget } from './types/linkTarget'; export { type Action, type ActionModel, diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts index 815b67f0352..ad556a75c76 100644 --- a/packages/grafana-data/src/types/dataLink.ts +++ b/packages/grafana-data/src/types/dataLink.ts @@ -1,5 +1,6 @@ import { ScopedVars } from './ScopedVars'; import { ExploreCorrelationHelperData, ExplorePanelsState } from './explore'; +import { LinkTarget } from './linkTarget'; import { InterpolateFunction } from './panel'; import { DataQuery } from './query'; import { TimeRange } from './time'; @@ -88,8 +89,6 @@ export interface InternalDataLink { range?: TimeRange; } -export type LinkTarget = '_blank' | '_self' | undefined; - /** * Processed Link Model. The values are ready to use */ diff --git a/packages/grafana-data/src/types/linkTarget.ts b/packages/grafana-data/src/types/linkTarget.ts new file mode 100644 index 00000000000..2cdd963da7a --- /dev/null +++ b/packages/grafana-data/src/types/linkTarget.ts @@ -0,0 +1,4 @@ +/** + * Target for links - controls whether link opens in new tab or same tab + */ +export type LinkTarget = '_blank' | '_self' | undefined; diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index f9ebb23fc07..815b9d04e2d 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -1,7 +1,7 @@ import { ComponentType } from 'react'; -import { LinkTarget } from './dataLink'; import { IconName } from './icon'; +import { LinkTarget } from './linkTarget'; export interface NavLinkDTO { id?: string; diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index acf1e36c905..b9d6491cf9b 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -11,6 +11,7 @@ import { DataFrame } from './dataFrame'; import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource'; import { FieldConfigSource } from './fieldOverrides'; import { IconName } from './icon'; +import { LinkTarget } from './linkTarget'; import { OptionEditorConfig } from './options'; import { PluginMeta } from './plugin'; import { AbsoluteTimeRange, TimeRange, TimeZone } from './time'; @@ -191,6 +192,7 @@ export interface PanelMenuItem { onClick?: (event: React.MouseEvent) => void; shortcut?: string; href?: string; + target?: LinkTarget; subMenu?: PanelMenuItem[]; } diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index c81fd7c26f4..6f02a7daf77 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -141,6 +141,7 @@ export const getPluginExtensions: GetExtensions = ({ description: overrides?.description || addedLink.description || '', path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined, category: overrides?.category || addedLink.category, + openInNewTab: overrides?.openInNewTab ?? addedLink.openInNewTab, }; extensions.push(extension); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index adb1ed8616d..0046aab0fe6 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -420,6 +420,7 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel href: extension.path, onClick: extension.onClick, iconClassName: extension.icon, + target: extension.openInNewTab ? '_blank' : undefined, }); continue; } @@ -433,6 +434,7 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel href: extension.path, onClick: extension.onClick, iconClassName: extension.icon, + target: extension.openInNewTab ? '_blank' : undefined, }); } diff --git a/yarn.lock b/yarn.lock index 41cc10d8353..1069acd8544 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3789,11 +3789,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:v6.52.1": - version: 6.52.1 - resolution: "@grafana/scenes-react@npm:6.52.1" +"@grafana/scenes-react@npm:6.52.2": + version: 6.52.2 + resolution: "@grafana/scenes-react@npm:6.52.2" dependencies: - "@grafana/scenes": "npm:6.52.1" + "@grafana/scenes": "npm:6.52.2" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3805,7 +3805,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/2f7c6ca8e26befd331808afb0cb934e2991e889a4de78be1122c536219676261c59c6204510761a1d4250fd44a3767818f0f225d23b2e7243cfc17baf8ca6ca3 + checksum: 10/c393faf6612e78254dab79b15cc970448d74ba9784ccda623953c5dbc21d91a8da94b7ad7d0d294eac51314cc193c419a7cb48295fd50b1f9c4472699669eb3e languageName: node linkType: hard @@ -3835,9 +3835,9 @@ __metadata: languageName: node linkType: hard -"@grafana/scenes@npm:6.52.1, @grafana/scenes@npm:v6.52.1": - version: 6.52.1 - resolution: "@grafana/scenes@npm:6.52.1" +"@grafana/scenes@npm:6.52.2": + version: 6.52.2 + resolution: "@grafana/scenes@npm:6.52.2" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3857,7 +3857,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/d6172b51121e03c7dcbf30046772f99fc45922c1f7b360a7c3d2c0391300e378f306cb78251dda3b30895679379c38db30e4d52fee67a56cd95f18f38aadf3fb + checksum: 10/f6dbe20db78bb1aa09cc38025534917887713d73119a172febb44700837ed859363ee0436b5f4bda6bc063f9432115e32519ab4c8da7834cf1fc22d43fea7711 languageName: node linkType: hard @@ -19790,8 +19790,8 @@ __metadata: "@grafana/plugin-ui": "npm:^0.11.1" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:v6.52.1" - "@grafana/scenes-react": "npm:v6.52.1" + "@grafana/scenes": "npm:6.52.2" + "@grafana/scenes-react": "npm:6.52.2" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*" From 170ac31c5ad5527c17e838949ce68dc5c5752c34 Mon Sep 17 00:00:00 2001 From: Alejandro Fraenkel Date: Wed, 14 Jan 2026 11:58:11 +0100 Subject: [PATCH 6/7] Alerting: Add alertingNavigationV2 feature toggle (#116215) feat(alerting): add alertingNavigationV2 feature toggle Introduces a new feature toggle to enable the improved Alerting navigation structure with grouped menu items. This toggle will allow: - Safe incremental rollout of navigation changes - Quick rollback if issues arise - Handling BE/FE deployment timing differences Toggle details: - Name: alertingNavigationV2 - Stage: Experimental - Owner: @grafana/alerting-squad - Default: false (disabled) - Affects: Both backend (navtree) and frontend (navigation hooks) --- .../grafana-data/src/types/featureToggles.gen.ts | 4 ++++ pkg/services/featuremgmt/registry.go | 7 +++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++++ pkg/services/featuremgmt/toggles_gen.json | 12 ++++++++++++ 5 files changed, 28 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 1581af4e3d7..eed0d330481 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -531,6 +531,10 @@ export interface FeatureToggles { */ alertingListViewV2?: boolean; /** + * Enables the new Alerting navigation structure with improved menu grouping + */ + alertingNavigationV2?: boolean; + /** * Enables saved searches for alert rules list */ alertingSavedSearches?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b436639a896..72623cba2fa 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -879,6 +879,13 @@ var ( Owner: grafanaAlertingSquad, FrontendOnly: true, }, + { + Name: "alertingNavigationV2", + Description: "Enables the new Alerting navigation structure with improved menu grouping", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + FrontendOnly: false, + }, { Name: "alertingSavedSearches", Description: "Enables saved searches for alert rules list", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c605f098bb5..61505b65571 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -121,6 +121,7 @@ dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false dashboardTemplates,preview,@grafana/sharing-squad,false,false,false alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true +alertingNavigationV2,experimental,@grafana/alerting-squad,false,false,false alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 3a71abab00a..db2b4484e42 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -371,6 +371,10 @@ const ( // Enables a flow to get started with a new dashboard from a template FlagDashboardTemplates = "dashboardTemplates" + // FlagAlertingNavigationV2 + // Enables the new Alerting navigation structure with improved menu grouping + FlagAlertingNavigationV2 = "alertingNavigationV2" + // FlagAlertingDisableSendAlertsExternal // Disables the ability to send alerts to an external Alertmanager datasource. FlagAlertingDisableSendAlertsExternal = "alertingDisableSendAlertsExternal" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 66cff415ec4..4832f93c309 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -348,6 +348,18 @@ "expression": "true" } }, + { + "metadata": { + "name": "alertingNavigationV2", + "resourceVersion": "1768320918269", + "creationTimestamp": "2026-01-13T16:15:18Z" + }, + "spec": { + "description": "Enables the new Alerting navigation structure with improved menu grouping", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad" + } + }, { "metadata": { "name": "alertingNotificationHistory", From 987c1fc6b68e1ae63f807f13fa3d01a2a7c80d14 Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Wed, 14 Jan 2026 12:07:53 +0100 Subject: [PATCH 7/7] feat(unified): add index scoring model config (#116210) * feat(unified): add bm25 index scoring model We want try BM25 scoring model since they have global scoring which we can probably re-use for fan-in/fan-out logic https://github.com/blevesearch/bleve/blob/32d98823c4b7482c62cc6c847508ed7659c23c37/docs/scoring.md#global-scoring * fix(plugins): update plugin test data --- pkg/setting/setting.go | 1 + pkg/setting/setting_unified_storage.go | 4 +++ pkg/storage/unified/search/bleve.go | 7 +++- .../unified/search/bleve_integration_test.go | 31 +++++++++++++++++ pkg/storage/unified/search/bleve_mappings.go | 6 ++-- .../unified/search/bleve_mappings_test.go | 2 +- .../unified/search/bleve_search_test.go | 2 ++ pkg/storage/unified/search/bleve_test.go | 3 ++ pkg/storage/unified/search/options.go | 1 + .../unified/sql/test/integration_test.go | 34 ++++++++++++------- .../api/plugins/data/expectedListResp.json | 30 ++++++++-------- 11 files changed, 89 insertions(+), 32 deletions(-) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 9667b82b9fa..1e26b9067ef 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -600,6 +600,7 @@ type Cfg struct { IndexRebuildInterval time.Duration IndexCacheTTL time.Duration IndexMinUpdateInterval time.Duration // Don't update index if it was updated less than this interval ago. + IndexScoringModel string // Note: Temporary config to switch the index scoring model and will be removed soon. MaxFileIndexAge time.Duration // Max age of file-based indexes. Index older than this will be rebuilt asynchronously. MinFileIndexBuildVersion string // Minimum version of Grafana that built the file-based index. If index was built with older Grafana, it will be rebuilt asynchronously. EnableSharding bool diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 21a3f455993..b47e8879826 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -123,6 +123,10 @@ func (cfg *Cfg) setUnifiedStorageConfig() { cfg.IndexRebuildInterval = section.Key("index_rebuild_interval").MustDuration(24 * time.Hour) cfg.IndexCacheTTL = section.Key("index_cache_ttl").MustDuration(10 * time.Minute) cfg.IndexMinUpdateInterval = section.Key("index_min_update_interval").MustDuration(0) + cfg.IndexScoringModel = section.Key("index_scoring_model").MustString("") + if cfg.IndexScoringModel != "" { + cfg.Logger.Info("Index scoring model set", "model", cfg.IndexScoringModel) + } cfg.SprinklesApiServer = section.Key("sprinkles_api_server").String() cfg.SprinklesApiServerPageLimit = section.Key("sprinkles_api_server_page_limit").MustInt(10000) cfg.CACertPath = section.Key("ca_cert_path").String() diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index eec7290633b..a988b71aa38 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -81,6 +81,11 @@ type BleveOptions struct { // Indexes that are not owned by current instance are eligible for cleanup. // If nil, all indexes are owned by the current instance. OwnsIndex func(key resource.NamespacedResource) (bool, error) + + // ScoringModel defines the scoring model used for the bleve indexes + // Default: index.TFIDFScoring + // Supported values: index.TFIDFScoring and index.BM25Scoring + ScoringModel string } type bleveBackend struct { @@ -368,7 +373,7 @@ func (b *bleveBackend) BuildIndex( attribute.String("reason", indexBuildReason), ) - mapper, err := GetBleveMappings(fields) + mapper, err := GetBleveMappings(b.opts.ScoringModel, fields) if err != nil { return nil, err } diff --git a/pkg/storage/unified/search/bleve_integration_test.go b/pkg/storage/unified/search/bleve_integration_test.go index 819fd5a8d9a..1f34444574f 100644 --- a/pkg/storage/unified/search/bleve_integration_test.go +++ b/pkg/storage/unified/search/bleve_integration_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + index "github.com/blevesearch/bleve_index_api" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -19,6 +20,7 @@ func TestBleveSearchBackend(t *testing.T) { backend, err := NewBleveBackend(BleveOptions{ Root: tempDir, FileThreshold: 5, + ScoringModel: index.BM25Scoring, }, nil) require.NoError(t, err) require.NotNil(t, backend) @@ -52,3 +54,32 @@ func TestSearchBackendBenchmark(t *testing.T) { unitest.BenchmarkSearchBackend(t, backend, opts) } + +func BenchmarkScoringModels(b *testing.B) { + models := []string{index.TFIDFScoring, index.BM25Scoring} + + for _, model := range models { + b.Run(model, func(b *testing.B) { + tempDir := b.TempDir() + + backend, err := NewBleveBackend(BleveOptions{ + Root: tempDir, + ScoringModel: model, + }, nil) + require.NoError(b, err) + require.NotNil(b, backend) + + b.Cleanup(backend.Stop) + + opts := &unitest.BenchmarkOptions{ + NumResources: 1000, + Concurrency: 4, + NumNamespaces: 10, + NumGroups: 10, + NumResourceTypes: 10, + } + + unitest.BenchmarkSearchBackend(b, backend, opts) + }) + } +} diff --git a/pkg/storage/unified/search/bleve_mappings.go b/pkg/storage/unified/search/bleve_mappings.go index 43adcbc607e..20eb2ffb8df 100644 --- a/pkg/storage/unified/search/bleve_mappings.go +++ b/pkg/storage/unified/search/bleve_mappings.go @@ -5,13 +5,15 @@ import ( "github.com/blevesearch/bleve/v2/analysis/analyzer/keyword" "github.com/blevesearch/bleve/v2/analysis/analyzer/standard" "github.com/blevesearch/bleve/v2/mapping" - "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) -func GetBleveMappings(fields resource.SearchableDocumentFields) (mapping.IndexMapping, error) { +func GetBleveMappings(scoringModel string, fields resource.SearchableDocumentFields) (mapping.IndexMapping, error) { mapper := bleve.NewIndexMapping() + if scoringModel != "" { + mapper.ScoringModel = scoringModel + } err := RegisterCustomAnalyzers(mapper) if err != nil { diff --git a/pkg/storage/unified/search/bleve_mappings_test.go b/pkg/storage/unified/search/bleve_mappings_test.go index 3b8027ee06e..821cf987990 100644 --- a/pkg/storage/unified/search/bleve_mappings_test.go +++ b/pkg/storage/unified/search/bleve_mappings_test.go @@ -13,7 +13,7 @@ import ( ) func TestDocumentMapping(t *testing.T) { - mappings, err := search.GetBleveMappings(nil) + mappings, err := search.GetBleveMappings("", nil) require.NoError(t, err) data := resource.IndexableDocument{ Title: "title", diff --git a/pkg/storage/unified/search/bleve_search_test.go b/pkg/storage/unified/search/bleve_search_test.go index c10aa3f6726..b221a60a7d6 100644 --- a/pkg/storage/unified/search/bleve_search_test.go +++ b/pkg/storage/unified/search/bleve_search_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/blevesearch/bleve/v2" + index "github.com/blevesearch/bleve_index_api" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -258,6 +259,7 @@ func newTestDashboardsIndex(t testing.TB, threshold int64, size int64, writer re backend, err := search.NewBleveBackend(search.BleveOptions{ Root: t.TempDir(), FileThreshold: threshold, // use in-memory for tests + ScoringModel: index.BM25Scoring, }, nil) require.NoError(t, err) diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index c9c3967cd58..a88951a100a 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/blevesearch/bleve/v2" + index "github.com/blevesearch/bleve_index_api" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" @@ -50,6 +51,7 @@ func TestBleveBackend(t *testing.T) { backend, err := NewBleveBackend(BleveOptions{ Root: tmpdir, FileThreshold: 5, // with more than 5 items we create a file on disk + ScoringModel: index.BM25Scoring, }, nil) require.NoError(t, err) t.Cleanup(backend.Stop) @@ -773,6 +775,7 @@ func setupBleveBackend(t *testing.T, options ...setupOption) (*bleveBackend, pro IndexCacheTTL: defaultIndexCacheTTL, Logger: log.NewNopLogger(), BuildVersion: buildVersion, + ScoringModel: index.BM25Scoring, } for _, opt := range options { opt(&opts) diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go index d450e9ae24b..64cf074f52c 100644 --- a/pkg/storage/unified/search/options.go +++ b/pkg/storage/unified/search/options.go @@ -46,6 +46,7 @@ func NewSearchOptions( BuildVersion: cfg.BuildVersion, OwnsIndex: ownsIndexFn, IndexMinUpdateInterval: cfg.IndexMinUpdateInterval, + ScoringModel: cfg.IndexScoringModel, }, indexMetrics) if err != nil { diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index 166e2dac372..f73bb61d679 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + index "github.com/blevesearch/bleve_index_api" "github.com/go-jose/go-jose/v4/jwt" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" @@ -129,21 +130,28 @@ func TestIntegrationSearchAndStorage(t *testing.T) { ctx := context.Background() - // Create a new bleve backend - search, err := search.NewBleveBackend(search.BleveOptions{ - FileThreshold: 0, - Root: t.TempDir(), - }, nil) - require.NoError(t, err) - require.NotNil(t, search) - t.Cleanup(search.Stop) + scoringModels := []string{index.TFIDFScoring, index.BM25Scoring} - // Create a new resource backend - storage, _ := newTestBackend(t, false, 0) - require.NotNil(t, storage) + for _, model := range scoringModels { + t.Run(model, func(t *testing.T) { + // Create a new bleve backend + search, err := search.NewBleveBackend(search.BleveOptions{ + FileThreshold: 0, + Root: t.TempDir(), + ScoringModel: model, + }, nil) + require.NoError(t, err) + require.NotNil(t, search) + t.Cleanup(search.Stop) - // Run the shared storage and search tests - unitest.RunTestSearchAndStorage(t, ctx, storage, search) + // Create a new resource backend + storage, _ := newTestBackend(t, false, 0) + require.NotNil(t, storage) + + // Run the shared storage and search tests + unitest.RunTestSearchAndStorage(t, ctx, storage, search) + }) + } } func TestClientServer(t *testing.T) { diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index 24f705eccd1..3d1ccce6a59 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -209,7 +209,7 @@ "path": "public/plugins/grafana-azure-monitor-datasource/img/azure_monitor_cpu.png" } ], - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": [ "azure", @@ -589,7 +589,7 @@ "hasUpdate": false, "defaultNavUrl": "/plugins/datagrid/", "category": "", - "state": "beta", + "state": "deprecated", "signature": "internal", "signatureType": "", "signatureOrg": "", @@ -880,7 +880,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -934,7 +934,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": [ "grafana", @@ -1000,7 +1000,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1217,7 +1217,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1325,7 +1325,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1375,7 +1375,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1425,7 +1425,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1575,7 +1575,7 @@ }, "build": {}, "screenshots": null, - "version": "", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -1629,7 +1629,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": [ "grafana", @@ -1734,7 +1734,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -2042,7 +2042,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -2092,7 +2092,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null }, @@ -2445,7 +2445,7 @@ }, "build": {}, "screenshots": null, - "version": "12.3.0-pre", + "version": "12.4.0-pre", "updated": "", "keywords": null },