Compare commits

...

11 Commits

Author SHA1 Message Date
Alejandro Fraenkel 32975dd0a8 chore(i18n): extract translations for alert rules navigation
Add translation strings for the new Alert Rules navigation tabs:
- alerting.navigation.alert-rules
- alerting.navigation.recently-deleted

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-14 13:15:50 +01:00
Alejandro Fraenkel f7b7c53a09 feat(alerting): add Alert Rules tabs navigation with feature toggle
This change introduces Alert Rules tabs (list + recently deleted) when
the alertingNavigationV2 feature toggle is enabled, while maintaining
backward compatibility with the legacy navigation structure.

**Backend changes (navtree.go):**
- Add conditional navigation structure based on alertingNavigationV2 toggle
- When enabled: Create "alert-rules" parent with child nav items for permission checking
- Add "alert-rules-list" and "alert-rules-recently-deleted" child items
- Hide standalone "Recently deleted" menu item in V2 mode (shown as tab instead)
- When disabled: Keep legacy "alert-list" navigation structure

**Frontend changes:**
- Create useAlertRulesNav hook to manage Alert Rules navigation
- Hook checks feature toggle and returns appropriate navId and pageNav
- Filters tabs based on user permissions (via navIndex)
- Update RuleList.v1.tsx and RuleList.v2.tsx to use the hook
- Pass navId and pageNav to AlertingPageWrapper for proper navigation rendering

**Testing:**
- Go backend builds successfully with no errors
- Feature toggle alertingNavigationV2 exists and is properly configured

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-14 12:12:08 +01:00
Alejandro Fraenkel 170ac31c5a 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)
2026-01-14 11:58:11 +01:00
Dominik Prokop 0d1e0bc21c 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 8784a7992c.
2026-01-14 11:29:43 +01:00
Natalia Bernarte Oses afd84f0335 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>
2026-01-14 11:10:51 +01:00
Andres Martinez Gotor d680537ea1 Advisor: Simplify interface used (#116191) 2026-01-14 11:05:16 +01:00
Bogdan Matei 78d507d285 Dynamic Dashboards: Change the stage of the feature toggle (#116189) 2026-01-14 09:50:37 +00:00
Tito Lins 9d1d0e72c2 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 <yuriy.tseretyan@grafana.com>
2026-01-14 10:04:29 +01:00
Konrad Lalik fd955f90ac Alerting: Enable server-side folder search for GMA rules (#116201)
* Alerting: Support backend filtering for folder search

Updates the Grafana managed rules API and filter logic to support
server-side filtering by folder (namespace).

Changes:
- Add `searchFolder` parameter to `getGrafanaGroups` API endpoint
- Map filter state `namespace` to `searchFolder` in backend filter
- Disable client-side namespace filtering when backend filtering is enabled
- Update tests to verify correct behavior for folder search with backend filters

* Add missing property in filter options

* Update tests
2026-01-14 09:48:07 +01:00
Sonia Aguilar ccb032f376 Alerting: Single alertmanager contact points versions (#116076)
* POC ssingle AM

* wip

* add query param ?version=2

* wip2

* wip3

* Update logic

* update badges and tests

* remove unsused import

* fix: update NewReceiverView snapshots to include version field

* update translations

* fix: delegate version determination to backend for new integrations

- Remove hardcoded version: 'v1' from defaultChannelValues
- Reset version to undefined when integration type changes
- Backend uses GetCurrentVersion() when no version is provided
- Update snapshots to reflect version handling changes
- Remove unused getDefaultVersionForNotifier function

* update snapshot

* fix(alerting): fix contact point form issues

- Fix empty info alert showing when notifier.dto.info is undefined
- Fix options not loading for new contact points by using default creatable version

* fix(alerting): only show version badge for legacy integrations

* update tests for version badge and getOptionsForVersion changes

* docs: add comment explaining currentVersion field in NotifierDTO

* Show user-friendly 'Legacy' label for legacy integrations

- Replace technical version strings (v0mimir1, v0mimir2) with user-friendly labels
- v0mimir1 -> 'Legacy', v0mimir2 -> 'Legacy v2', etc.
- Technical version is still shown in tooltip for reference
- Add getLegacyVersionLabel() utility function
- Update tests for badge display and utility function

* Add v0mimir2 to test mock for Legacy v2 badge test

* hasLegacyIntegrations now uses isLegacyVersion

- Accept notifiers array to properly check canCreate: false
- No longer relies on version string comparison (v1 check)
- Uses isLegacyVersion for consistent legacy detection
- Update tests to pass notifiers and test correct behavior

* update translations
2026-01-14 08:31:13 +01:00
Alex Khomenko cf452c167b Provisioning: Do not show the page when the toggle is off (#116206) 2026-01-14 07:41:10 +02:00
56 changed files with 1451 additions and 125 deletions
@@ -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{
@@ -15,7 +15,7 @@ import (
type missingPluginStep struct {
PluginStore pluginstore.Store
PluginRepo repo.Service
PluginRepo checks.PluginInfoGetter
GrafanaVersion string
}
+8
View File
@@ -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)
}
@@ -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
@@ -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. |
@@ -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.
+2 -2
View File
@@ -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:*",
+1 -1
View File
@@ -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,
+1 -2
View File
@@ -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<T extends DataQuery = any> {
range?: TimeRange;
}
export type LinkTarget = '_blank' | '_self' | undefined;
/**
* Processed Link Model. The values are ready to use
*/
+9 -1
View File
@@ -356,7 +356,7 @@ export interface FeatureToggles {
*/
dashboardScene?: boolean;
/**
* Enables experimental new dashboard layouts
* Enables new dashboard layouts
*/
dashboardNewLayouts?: boolean;
/**
@@ -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;
@@ -1251,4 +1255,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;
}
@@ -0,0 +1,4 @@
/**
* Target for links - controls whether link opens in new tab or same tab
*/
export type LinkTarget = '_blank' | '_self' | undefined;
+1 -1
View File
@@ -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;
+2
View File
@@ -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[];
}
+19 -3
View File
@@ -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,
},
@@ -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",
@@ -981,7 +988,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 +2077,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,
},
}
)
+3 -1
View File
@@ -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
@@ -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
@@ -280,3 +281,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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
79 dashboardSceneForViewers GA @grafana/dashboards-squad false false true
80 dashboardSceneSolo GA @grafana/dashboards-squad false false true
81 dashboardScene GA @grafana/dashboards-squad false false true
82 dashboardNewLayouts experimental preview @grafana/dashboards-squad false false false
83 dashboardUndoRedo experimental @grafana/dashboards-squad false false true
84 unlimitedLayoutsNesting experimental @grafana/dashboards-squad false false true
85 drilldownRecommendations experimental @grafana/dashboards-squad false false true
121 suggestedDashboards experimental @grafana/sharing-squad false false false
122 dashboardTemplates preview @grafana/sharing-squad false false false
123 alertingListViewV2 privatePreview @grafana/alerting-squad false false true
124 alertingNavigationV2 experimental @grafana/alerting-squad false false false
125 alertingSavedSearches experimental @grafana/alerting-squad false false true
126 alertingDisableSendAlertsExternal experimental @grafana/alerting-squad false false false
127 preserveDashboardStateWhenNavigating experimental @grafana/dashboards-squad false false false
281 smoothingTransformation experimental @grafana/datapro false false true
282 secretsManagementAppPlatformAwsKeeper experimental @grafana/grafana-operator-experience-squad false false false
283 profilesExemplars experimental @grafana/observability-traces-and-profiling false false false
284 alertingSyncDispatchTimer experimental @grafana/alerting-squad false true false
+9 -1
View File
@@ -260,7 +260,7 @@ const (
FlagAnnotationPermissionUpdate = "annotationPermissionUpdate"
// FlagDashboardNewLayouts
// Enables experimental new dashboard layouts
// Enables new dashboard layouts
FlagDashboardNewLayouts = "dashboardNewLayouts"
// FlagPdfTables
@@ -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"
@@ -789,4 +793,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"
)
+35 -5
View File
@@ -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",
@@ -511,6 +523,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 +688,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",
@@ -1015,12 +1042,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"
}
},
+1 -2
View File
@@ -54,8 +54,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
}
//nolint:staticcheck // not yet migrated to OpenFeature
if c.HasRole(identity.RoleAdmin) &&
(s.cfg.StackID == "" || // show OnPrem even when provisioning is disabled
s.features.IsEnabledGlobally(featuremgmt.FlagProvisioning)) {
s.features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Provisioning",
Id: "provisioning",
+36 -9
View File
@@ -446,9 +446,32 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
}
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert rules", SubTitle: "Rules that determine whether an alert will fire", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
})
//nolint:staticcheck // not yet migrated to OpenFeature
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingNavigationV2) {
// New navigation: Alert rules parent (tabs managed on frontend)
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert rules", SubTitle: "Rules that determine whether an alert will fire", Id: "alert-rules", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
})
// Add child nav items for permission checking (tabs will be rendered by frontend)
// Alert rules list tab
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert rules", Id: "alert-rules-list", Url: s.cfg.AppSubURL + "/alerting/list",
})
// Recently deleted tab (check additional feature flags)
//nolint:staticcheck // not yet migrated to OpenFeature
if c.GetOrgRole() == org.RoleAdmin && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertRuleRestore) && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingRuleRecoverDeleted) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Recently deleted", Id: "alert-rules-recently-deleted", Url: s.cfg.AppSubURL + "/alerting/recently-deleted",
})
}
} else {
// Legacy navigation
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert rules", SubTitle: "Rules that determine whether an alert will fire", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
})
}
}
contactPointsPerms := []ac.Evaluator{
@@ -508,12 +531,16 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
}
//nolint:staticcheck // not yet migrated to OpenFeature
if c.GetOrgRole() == org.RoleAdmin && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertRuleRestore) && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingRuleRecoverDeleted) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Recently deleted",
SubTitle: "Any items listed here for more than 30 days will be automatically deleted.",
Id: "alerts/recently-deleted",
Url: s.cfg.AppSubURL + "/alerting/recently-deleted",
})
// Only show as standalone item in legacy navigation (V2 shows it as a tab under Alert rules)
//nolint:staticcheck // not yet migrated to OpenFeature
if !s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingNavigationV2) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Recently deleted",
SubTitle: "Any items listed here for more than 30 days will be automatically deleted.",
Id: "alerts/recently-deleted",
Url: s.cfg.AppSubURL + "/alerting/recently-deleted",
})
}
}
if c.GetOrgRole() == org.RoleAdmin {
+4
View File
@@ -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)
@@ -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,
@@ -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
}
@@ -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)
})
}
}
@@ -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
@@ -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))
}
}
@@ -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 {
+4 -1
View File
@@ -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)
}
+48 -4
View File
@@ -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{}
+20 -4
View File
@@ -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,
}
@@ -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) {
@@ -108,7 +108,9 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
}),
grafanaNotifiers: build.query<NotifierDTO[], void>({
query: () => ({ url: '/api/alert-notifiers' }),
// NOTE: version=2 parameter required for versioned schema (PR #109969)
// This parameter will be removed in future when v2 becomes default
query: () => ({ url: '/api/alert-notifiers?version=2' }),
transformResponse: (response: NotifierDTO[]) => {
const populateSecureFieldKey = (
option: NotificationChannelOption,
@@ -121,11 +123,16 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
),
});
// Keep versions array intact for version-specific options lookup
// Transform options with secureFieldKey population
return response.map((notifier) => ({
...notifier,
options: notifier.options.map((option) => {
return populateSecureFieldKey(option, '');
}),
options: (notifier.options || []).map((option) => populateSecureFieldKey(option, '')),
// Also transform options within each version
versions: notifier.versions?.map((version) => ({
...version,
options: (version.options || []).map((option) => populateSecureFieldKey(option, '')),
})),
}));
},
}),
@@ -46,6 +46,7 @@ export type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'nam
state?: PromAlertingRuleState[];
title?: string;
searchGroupName?: string;
searchFolder?: string;
type?: 'alerting' | 'recording';
ruleMatchers?: string[];
plugins?: 'hide' | 'only';
@@ -103,6 +104,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
title,
datasources,
searchGroupName,
searchFolder,
dashboardUid,
ruleMatchers,
plugins,
@@ -123,6 +125,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
datasource_uid: datasources,
'search.rule_name': title,
'search.rule_group': searchGroupName,
'search.folder': searchFolder,
dashboard_uid: dashboardUid,
rule_matcher: ruleMatchers,
plugins: plugins,
@@ -36,6 +36,24 @@ export const ProvisioningAlert = ({ resource, ...rest }: ProvisioningAlertProps)
);
};
export const ImportedContactPointAlert = (props: ExtraAlertProps) => {
return (
<Alert
title={t(
'alerting.provisioning.title-imported',
'This contact point was imported and cannot be edited through the UI'
)}
severity="info"
{...props}
>
<Trans i18nKey="alerting.provisioning.body-imported">
This contact point contains integrations that were imported from an external Alertmanager and is currently
read-only. The integrations will become editable after the migration process is complete.
</Trans>
</Alert>
);
};
export const ProvisioningBadge = ({
tooltip,
provenance,
@@ -1,11 +1,12 @@
import 'core-js/stable/structured-clone';
import { FormProvider, useForm } from 'react-hook-form';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { render } from 'test/test-utils';
import { render, screen } from 'test/test-utils';
import { byRole, byTestId } from 'testing-library-selector';
import { grafanaAlertNotifiers } from 'app/features/alerting/unified/mockGrafanaNotifiers';
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { NotifierDTO } from 'app/features/alerting/unified/types/alerting';
import { ChannelSubForm } from './ChannelSubForm';
import { GrafanaCommonChannelSettings } from './GrafanaCommonChannelSettings';
@@ -16,6 +17,7 @@ type TestChannelValues = {
type: string;
settings: Record<string, unknown>;
secureFields: Record<string, boolean>;
version?: string;
};
type TestReceiverFormValues = {
@@ -246,4 +248,241 @@ describe('ChannelSubForm', () => {
expect(slackUrl).toBeEnabled();
expect(slackUrl).toHaveValue('');
});
describe('version-specific options display', () => {
// Create a mock notifier with different options for v0 and v1
const legacyOptions = [
{
element: 'input' as const,
inputType: 'text',
label: 'Legacy URL',
description: 'The legacy endpoint URL',
placeholder: '',
propertyName: 'legacyUrl',
required: true,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
dependsOn: '',
},
];
const webhookWithVersions: NotifierDTO = {
...grafanaAlertNotifiers.webhook,
versions: [
{
version: 'v0mimir1',
label: 'Webhook (Legacy)',
description: 'Legacy webhook from Mimir',
canCreate: false,
options: legacyOptions,
},
{
version: 'v0mimir2',
label: 'Webhook (Legacy v2)',
description: 'Legacy webhook v2 from Mimir',
canCreate: false,
options: legacyOptions,
},
{
version: 'v1',
label: 'Webhook',
description: 'Sends HTTP POST request',
canCreate: true,
options: grafanaAlertNotifiers.webhook.options,
},
],
};
const versionedNotifiers: Notifier[] = [
{ dto: webhookWithVersions, meta: { enabled: true, order: 1 } },
{ dto: grafanaAlertNotifiers.slack, meta: { enabled: true, order: 2 } },
];
function VersionedTestFormWrapper({
defaults,
initial,
}: {
defaults: TestChannelValues;
initial?: TestChannelValues;
}) {
const form = useForm<TestReceiverFormValues>({
defaultValues: {
name: 'test-contact-point',
items: [defaults],
},
});
return (
<AlertmanagerProvider accessType="notification">
<FormProvider {...form}>
<ChannelSubForm
defaultValues={defaults}
initialValues={initial}
pathPrefix={`items.0.`}
integrationIndex={0}
notifiers={versionedNotifiers}
onDuplicate={jest.fn()}
commonSettingsComponent={GrafanaCommonChannelSettings}
isEditable={true}
isTestable={false}
canEditProtectedFields={true}
/>
</FormProvider>
</AlertmanagerProvider>
);
}
function renderVersionedForm(defaults: TestChannelValues, initial?: TestChannelValues) {
return render(<VersionedTestFormWrapper defaults={defaults} initial={initial} />);
}
it('should display v1 options when integration has v1 version', () => {
const webhookV1: TestChannelValues = {
__id: 'id-0',
type: 'webhook',
version: 'v1',
settings: { url: 'https://example.com' },
secureFields: {},
};
renderVersionedForm(webhookV1, webhookV1);
// Should show v1 URL field (from default options)
expect(ui.settings.webhook.url.get()).toBeInTheDocument();
// Should NOT show legacy URL field
expect(screen.queryByRole('textbox', { name: /Legacy URL/i })).not.toBeInTheDocument();
});
it('should display v0 options when integration has legacy version', () => {
const webhookV0: TestChannelValues = {
__id: 'id-0',
type: 'webhook',
version: 'v0mimir1',
settings: { legacyUrl: 'https://legacy.example.com' },
secureFields: {},
};
renderVersionedForm(webhookV0, webhookV0);
// Should show legacy URL field (from v0 options)
expect(screen.getByRole('textbox', { name: /Legacy URL/i })).toBeInTheDocument();
// Should NOT show v1 URL field
expect(ui.settings.webhook.url.query()).not.toBeInTheDocument();
});
it('should display "Legacy" badge for v0mimir1 integration', () => {
const webhookV0: TestChannelValues = {
__id: 'id-0',
type: 'webhook',
version: 'v0mimir1',
settings: { legacyUrl: 'https://legacy.example.com' },
secureFields: {},
};
renderVersionedForm(webhookV0, webhookV0);
// Should show "Legacy" badge for v0mimir1 integrations
expect(screen.getByText('Legacy')).toBeInTheDocument();
});
it('should display "Legacy v2" badge for v0mimir2 integration', () => {
const webhookV0v2: TestChannelValues = {
__id: 'id-0',
type: 'webhook',
version: 'v0mimir2',
settings: { legacyUrl: 'https://legacy.example.com' },
secureFields: {},
};
renderVersionedForm(webhookV0v2, webhookV0v2);
// Should show "Legacy v2" badge for v0mimir2 integrations
expect(screen.getByText('Legacy v2')).toBeInTheDocument();
});
it('should NOT display version badge for v1 integration', () => {
const webhookV1: TestChannelValues = {
__id: 'id-0',
type: 'webhook',
version: 'v1',
settings: { url: 'https://example.com' },
secureFields: {},
};
renderVersionedForm(webhookV1, webhookV1);
// Should NOT show version badge for non-legacy v1 integrations
expect(screen.queryByText('v1')).not.toBeInTheDocument();
});
it('should filter out notifiers with canCreate: false from dropdown', () => {
// Create a notifier that only has v0 versions (cannot be created)
const legacyOnlyNotifier: NotifierDTO = {
type: 'wechat',
name: 'WeChat',
heading: 'WeChat settings',
description: 'Sends notifications to WeChat',
options: [],
versions: [
{
version: 'v0mimir1',
label: 'WeChat (Legacy)',
description: 'Legacy WeChat',
canCreate: false,
options: [],
},
],
};
const notifiersWithLegacyOnly: Notifier[] = [
{ dto: webhookWithVersions, meta: { enabled: true, order: 1 } },
{ dto: legacyOnlyNotifier, meta: { enabled: true, order: 2 } },
];
function LegacyOnlyTestWrapper({ defaults }: { defaults: TestChannelValues }) {
const form = useForm<TestReceiverFormValues>({
defaultValues: {
name: 'test-contact-point',
items: [defaults],
},
});
return (
<AlertmanagerProvider accessType="notification">
<FormProvider {...form}>
<ChannelSubForm
defaultValues={defaults}
pathPrefix={`items.0.`}
integrationIndex={0}
notifiers={notifiersWithLegacyOnly}
onDuplicate={jest.fn()}
commonSettingsComponent={GrafanaCommonChannelSettings}
isEditable={true}
isTestable={false}
canEditProtectedFields={true}
/>
</FormProvider>
</AlertmanagerProvider>
);
}
render(
<LegacyOnlyTestWrapper
defaults={{
__id: 'id-0',
type: 'webhook',
settings: {},
secureFields: {},
}}
/>
);
// Webhook should be in dropdown (has v1 with canCreate: true)
expect(ui.typeSelector.get()).toHaveTextContent('Webhook');
// WeChat should NOT be in the options (only has v0 with canCreate: false)
// We can't easily check dropdown options without opening it, but the filter should work
});
});
});
@@ -6,7 +6,7 @@ import { Controller, FieldErrors, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
import { Alert, Badge, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
import { NotificationChannelOption } from 'app/features/alerting/unified/types/alerting';
import {
@@ -16,6 +16,12 @@ import {
GrafanaChannelValues,
ReceiverFormValues,
} from '../../../types/receiver-form';
import {
canCreateNotifier,
getLegacyVersionLabel,
getOptionsForVersion,
isLegacyVersion,
} from '../../../utils/notifier-versions';
import { OnCallIntegrationType } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
import { ChannelOptions } from './ChannelOptions';
@@ -62,6 +68,7 @@ export function ChannelSubForm<R extends ChannelValues>({
const channelFieldPath = `items.${integrationIndex}` as const;
const typeFieldPath = `${channelFieldPath}.type` as const;
const versionFieldPath = `${channelFieldPath}.version` as const;
const settingsFieldPath = `${channelFieldPath}.settings` as const;
const secureFieldsPath = `${channelFieldPath}.secureFields` as const;
@@ -104,6 +111,9 @@ export function ChannelSubForm<R extends ChannelValues>({
setValue(settingsFieldPath, defaultNotifierSettings);
setValue(secureFieldsPath, {});
// Reset version when changing type - backend will use its default
setValue(versionFieldPath, undefined);
}
// Restore initial value of an existing oncall integration
@@ -123,6 +133,7 @@ export function ChannelSubForm<R extends ChannelValues>({
setValue,
settingsFieldPath,
typeFieldPath,
versionFieldPath,
secureFieldsPath,
getValues,
watch,
@@ -164,24 +175,30 @@ export function ChannelSubForm<R extends ChannelValues>({
setValue(`${settingsFieldPath}.${fieldPath}`, undefined);
};
const typeOptions = useMemo(
(): SelectableValue[] =>
sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map<SelectableValue>(
({ dto: { name, type }, meta }) => ({
// @ts-expect-error ReactNode is supported
const typeOptions = useMemo((): SelectableValue[] => {
// Filter out notifiers that can't be created (e.g., v0-only integrations like WeChat)
// These are legacy integrations that only exist in Mimir and can't be created in Grafana
const creatableNotifiers = notifiers.filter(({ dto }) => canCreateNotifier(dto));
return sortBy(creatableNotifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map<SelectableValue>(
({ dto: { name, type }, meta }) => {
return {
// ReactNode is supported in Select label, but types don't reflect it
/* eslint-disable @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */
label: (
<Stack alignItems="center" gap={1}>
{name}
{meta?.badge}
</Stack>
),
) as any,
/* eslint-enable @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */
value: type,
description: meta?.description,
isDisabled: meta ? !meta.enabled : false,
})
),
[notifiers]
);
};
}
);
}, [notifiers]);
const handleTest = async () => {
await trigger();
@@ -198,10 +215,21 @@ export function ChannelSubForm<R extends ChannelValues>({
// Cloud AM takes no value at all
const isParseModeNone = parse_mode === 'None' || !parse_mode;
const showTelegramWarning = isTelegram && !isParseModeNone;
// Check if current integration is a legacy version (canCreate: false)
// Legacy integrations are read-only and cannot be edited
// Read version from existing integration data (stored in receiver config)
const integrationVersion = initialValues?.version || defaultValues.version;
const isLegacy = notifier ? isLegacyVersion(notifier.dto, integrationVersion) : false;
// Get the correct options based on the integration's version
// This ensures legacy (v0) integrations display the correct schema
const versionedOptions = notifier ? getOptionsForVersion(notifier.dto, integrationVersion) : [];
// if there are mandatory options defined, optional options will be hidden by a collapse
// if there aren't mandatory options, all options will be shown without collapse
const mandatoryOptions = notifier?.dto.options.filter((o) => o.required) ?? [];
const optionalOptions = notifier?.dto.options.filter((o) => !o.required) ?? [];
const mandatoryOptions = versionedOptions.filter((o) => o.required);
const optionalOptions = versionedOptions.filter((o) => !o.required);
const contactPointTypeInputId = `contact-point-type-${pathPrefix}`;
return (
@@ -214,21 +242,35 @@ export function ChannelSubForm<R extends ChannelValues>({
data-testid={`${pathPrefix}type`}
noMargin
>
<Controller
name={typeFieldPath}
control={control}
defaultValue={defaultValues.type}
render={({ field: { ref, onChange, ...field } }) => (
<Select
disabled={!isEditable}
inputId={contactPointTypeInputId}
{...field}
width={37}
options={typeOptions}
onChange={(value) => onChange(value?.value)}
<Stack direction="row" alignItems="center" gap={1}>
<Controller
name={typeFieldPath}
control={control}
defaultValue={defaultValues.type}
render={({ field: { ref, onChange, ...field } }) => (
<Select
disabled={!isEditable}
inputId={contactPointTypeInputId}
{...field}
width={37}
options={typeOptions}
onChange={(value) => onChange(value?.value)}
/>
)}
/>
{isLegacy && integrationVersion && (
<Badge
text={getLegacyVersionLabel(integrationVersion)}
color="orange"
icon="exclamation-triangle"
tooltip={t(
'alerting.channel-sub-form.tooltip-legacy-version',
'This is a legacy integration (version: {{version}}). It cannot be modified.',
{ version: integrationVersion }
)}
/>
)}
/>
</Stack>
</Field>
</div>
<div className={styles.buttons}>
@@ -292,7 +334,7 @@ export function ChannelSubForm<R extends ChannelValues>({
name: notifier.dto.name,
})}
>
{notifier.dto.info !== '' && (
{notifier.dto.info && (
<Alert title="" severity="info">
{notifier.dto.info}
</Alert>
@@ -18,12 +18,13 @@ import {
import { alertmanagerApi } from '../../../api/alertmanagerApi';
import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
import { hasLegacyIntegrations } from '../../../utils/notifier-versions';
import {
formChannelValuesToGrafanaChannelConfig,
formValuesToGrafanaReceiver,
grafanaReceiverToFormValues,
} from '../../../utils/receiver-form';
import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
import { ImportedContactPointAlert, ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
import { ReceiverTypes } from '../grafanaAppReceivers/onCall/onCall';
import { useOnCallIntegration } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
@@ -39,6 +40,8 @@ const defaultChannelValues: GrafanaChannelValues = Object.freeze({
secureFields: {},
disableResolveMessage: false,
type: 'email',
// version is intentionally not set here - it will be determined by the notifier's currentVersion
// when the integration is created/type is changed. The backend will use its default if not provided.
});
interface Props {
@@ -67,7 +70,6 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
} = useOnCallIntegration();
const { data: grafanaNotifiers = [], isLoading: isLoadingNotifiers } = useGrafanaNotifiersQuery();
const [testReceivers, setTestReceivers] = useState<Receiver[]>();
// transform receiver DTO to form values
@@ -135,15 +137,20 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
);
}
// Map notifiers to Notifier[] format for ReceiverForm
// The grafanaNotifiers include version-specific options via the versions array from the backend
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
const notifiers: Notifier[] = grafanaNotifiers.map((n) => {
if (n.type === ReceiverTypes.OnCall) {
return {
dto: extendOnCallNotifierFeatures(n),
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
dto: extendOnCallNotifierFeatures(n as any) as any,
meta: onCallNotifierMeta,
};
}
return { dto: n };
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
return { dto: n as any };
});
return (
@@ -163,7 +170,12 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
</Alert>
)}
{contactPoint?.provisioned && <ProvisioningAlert resource={ProvisionedResource.ContactPoint} />}
{contactPoint?.provisioned && hasLegacyIntegrations(contactPoint, grafanaNotifiers) && (
<ImportedContactPointAlert />
)}
{contactPoint?.provisioned && !hasLegacyIntegrations(contactPoint, grafanaNotifiers) && (
<ProvisioningAlert resource={ProvisionedResource.ContactPoint} />
)}
<ReceiverForm<GrafanaChannelValues>
contactPointId={contactPoint?.id}
@@ -0,0 +1,69 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { useSelector } from 'app/types/store';
export function useAlertRulesNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
// Check if V2 navigation is enabled
const useV2Nav = config.featureToggles.alertingNavigationV2;
if (!useV2Nav) {
// Legacy navigation: return simple navId
return {
navId: 'alert-list',
pageNav: undefined,
};
}
// V2 Navigation: Create tabs structure
const alertRulesNav = navIndex['alert-rules'];
if (!alertRulesNav) {
// Fallback to legacy if nav item doesn't exist
return {
navId: 'alert-list',
pageNav: undefined,
};
}
// All available tabs
const allTabs = [
{
id: 'alert-rules-list',
text: t('alerting.navigation.alert-rules', 'Alert rules'),
url: '/alerting/list',
active: location.pathname === '/alerting/list',
icon: 'list-ul',
parentItem: alertRulesNav,
},
{
id: 'alert-rules-recently-deleted',
text: t('alerting.navigation.recently-deleted', 'Recently deleted'),
url: '/alerting/recently-deleted',
active: location.pathname === '/alerting/recently-deleted',
icon: 'history',
parentItem: alertRulesNav,
},
].filter((tab) => {
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
const navItem = navIndex[tab.id];
return navItem !== undefined;
});
// Create pageNav that represents the Alert rules page with tabs as children
const pageNav: NavModelItem = {
...alertRulesNav,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'alert-rules',
pageNav,
};
}
@@ -20,6 +20,7 @@ import { shouldUsePrometheusRulesPrimary } from '../featureToggles';
import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces';
import { useFilteredRules, useRulesFilter } from '../hooks/useFilteredRules';
import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector';
import { useAlertRulesNav } from '../navigation/useAlertRulesNav';
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../state/actions';
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames } from '../utils/datasource';
@@ -38,6 +39,7 @@ const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1;
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
const RuleListV1 = () => {
const { navId, pageNav } = useAlertRulesNav();
const dispatch = useDispatch();
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
const [expandAll, setExpandAll] = useState(false);
@@ -119,7 +121,8 @@ const RuleListV1 = () => {
// We don't want to show the Loading... indicator for the whole page.
// We show separate indicators for Grafana-managed and Cloud rules
<AlertingPageWrapper
navId="alert-list"
navId={navId}
pageNav={pageNav}
isLoading={false}
renderTitle={(title) => <RuleListPageTitle title={title} />}
actions={<RuleListActionButtons hasAlertRulesCreated={hasAlertRulesCreated} />}
@@ -13,6 +13,7 @@ import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelecto
import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton';
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities';
import { useRulesFilter } from '../hooks/useFilteredRules';
import { useAlertRulesNav } from '../navigation/useAlertRulesNav';
import { getRulesDataSources } from '../utils/datasource';
import { FilterView } from './FilterView';
@@ -123,10 +124,12 @@ export function RuleListActions() {
export default function RuleListPage() {
const { isApplying } = useApplyDefaultSearch();
const { navId, pageNav } = useAlertRulesNav();
return (
<AlertingPageWrapper
navId="alert-list"
navId={navId}
pageNav={pageNav}
renderTitle={(title) => <RuleListPageTitle title={title} />}
isLoading={isApplying}
actions={<RuleListActions />}
@@ -455,6 +455,25 @@ describe('grafana-managed rules', () => {
expect(frontendFilter.ruleMatches(regularRule)).toBe(true);
expect(frontendFilter.ruleMatches(pluginRule)).toBe(true);
});
it('should include searchFolder in backend filter when namespace is provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ namespace: 'my-folder' }));
expect(backendFilter.searchFolder).toBe('my-folder');
});
it('should skip namespace filtering on frontend when backend filtering is enabled', () => {
const group: PromRuleGroupDTO = {
name: 'Test Group',
file: 'production/alerts',
rules: [],
interval: 60,
};
const { frontendFilter } = getGrafanaFilter(getFilter({ namespace: 'staging' }));
// Should return true because namespace filter is null (handled by backend)
expect(frontendFilter.groupMatches(group)).toBe(true);
});
});
describe('when alertingUIUseBackendFilters is disabled', () => {
@@ -537,6 +556,12 @@ describe('grafana-managed rules', () => {
expect(backendFilter.searchGroupName).toBeUndefined();
});
it('should not include searchFolder in backend filter', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ namespace: 'my-folder' }));
expect(backendFilter.searchFolder).toBeUndefined();
});
it('should perform groupName filtering on frontend', () => {
const group: PromRuleGroupDTO = {
name: 'CPU Usage Alerts',
@@ -706,8 +731,8 @@ describe('grafana-managed rules', () => {
expect(frontendFilter.groupMatches(group)).toBe(true);
});
it('should still apply always-frontend filters (namespace)', () => {
// Namespace filter should still work
it('should skip namespace filtering on frontend', () => {
// Namespace filter should be handled by backend
const group: PromRuleGroupDTO = {
name: 'Test Group',
file: 'production/alerts',
@@ -719,7 +744,7 @@ describe('grafana-managed rules', () => {
expect(nsFilter.groupMatches(group)).toBe(true);
const { frontendFilter: nsFilter2 } = getGrafanaFilter(getFilter({ namespace: 'staging' }));
expect(nsFilter2.groupMatches(group)).toBe(false);
expect(nsFilter2.groupMatches(group)).toBe(true);
});
it('should skip dataSourceNames filtering on frontend (handled by backend)', () => {
@@ -807,8 +832,8 @@ describe('grafana-managed rules', () => {
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false);
});
it('should return true for client-side only filters', () => {
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
it('should return false for namespace filter (handled by backend)', () => {
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(false);
});
it('should return false for plugins filter (handled by backend when feature toggle is enabled)', () => {
@@ -862,8 +887,8 @@ describe('grafana-managed rules', () => {
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
// Should return true for: always-frontend filters only (namespace)
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
// Should return false for: namespace (handled by backend)
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(false);
// plugins is backend-handled when both feature toggles are enabled
expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(false);
@@ -96,6 +96,7 @@ export function getGrafanaFilter(filterState: Partial<RulesFilter>) {
datasources: ruleFilterConfig.dataSourceNames ? undefined : datasourceUids,
ruleMatchers: ruleMatchersBackendFilter,
plugins: ruleFilterConfig.plugins ? undefined : normalizedFilterState.plugins,
searchFolder: groupFilterConfig.namespace ? undefined : normalizedFilterState.namespace,
};
return {
@@ -134,7 +135,7 @@ function buildGrafanaFilterConfigs() {
};
const groupFilterConfig: GroupFilterConfig = {
namespace: namespaceFilter,
namespace: useBackendFilters ? null : namespaceFilter,
groupName: useBackendFilters ? null : groupNameFilter,
};
@@ -45,6 +45,7 @@ interface GrafanaPromApiFilter {
contactPoint?: string;
title?: string;
searchGroupName?: string;
searchFolder?: string;
type?: 'alerting' | 'recording';
dashboardUid?: string;
}
@@ -75,6 +75,7 @@ describe('paginationLimits', () => {
{ contactPoint: 'slack' },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
{ namespace: 'production' },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
@@ -84,16 +85,6 @@ describe('paginationLimits', () => {
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
}
);
it.each<Partial<RulesFilter>>([
{ namespace: 'production' },
{ ruleState: PromAlertingRuleState.Firing, namespace: 'production' },
])('should return large limits for both when frontend filters are used: %p', (filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
});
});
describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => {
@@ -158,6 +149,7 @@ describe('paginationLimits', () => {
{ contactPoint: 'slack' },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
{ namespace: 'production' },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
@@ -167,16 +159,6 @@ describe('paginationLimits', () => {
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
}
);
it.each<Partial<RulesFilter>>([{ namespace: 'production' }])(
'should return large limits for both when frontend filters are used: %p',
(filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
}
);
});
});
});
@@ -80,6 +80,20 @@ export type CloudNotifierType =
| 'jira';
export type NotifierType = GrafanaNotifierType | CloudNotifierType;
/**
* Represents a specific version of a notifier integration
* Used for integration versioning during Single Alert Manager migration
*/
export interface NotifierVersion {
version: string;
label: string;
description: string;
options: NotificationChannelOption[];
/** Whether this version can be used to create new integrations */
canCreate?: boolean;
}
export interface NotifierDTO<T = NotifierType> {
name: string;
description: string;
@@ -88,6 +102,23 @@ export interface NotifierDTO<T = NotifierType> {
options: NotificationChannelOption[];
info?: string;
secure?: boolean;
/**
* Available versions for this notifier from the backend
* Each version contains version-specific options and metadata
*/
versions?: NotifierVersion[];
/**
* The default version that the backend will use when creating new integrations.
* Returned by the backend from /api/alert-notifiers?version=2
*
* - "v1" for most notifiers (modern Grafana version)
* - "v0mimir1" for legacy-only notifiers (e.g., WeChat)
*
* Note: Currently not used in the frontend. The backend handles version
* selection automatically. Could be used in the future to display
* version information or validate notifier capabilities.
*/
currentVersion?: string;
}
export interface NotificationChannelType {
@@ -8,6 +8,7 @@ import { ControlledField } from '../hooks/useControlledFieldArray';
export interface ChannelValues {
__id: string; // used to correlate form values to original DTOs
type: string;
version?: string; // Integration version (e.g. "v0" for Mimir legacy, "v1" for Grafana)
settings: Record<string, any>;
secureFields: Record<string, boolean | ''>;
}
@@ -0,0 +1,429 @@
import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types';
import { NotificationChannelOption, NotifierDTO, NotifierVersion } from '../types/alerting';
import {
canCreateNotifier,
getLegacyVersionLabel,
getOptionsForVersion,
hasLegacyIntegrations,
isLegacyVersion,
} from './notifier-versions';
// Helper to create a minimal NotifierDTO for testing
function createNotifier(overrides: Partial<NotifierDTO> = {}): NotifierDTO {
return {
name: 'Test Notifier',
description: 'Test description',
type: 'webhook',
heading: 'Test heading',
options: [
{
element: 'input',
inputType: 'text',
label: 'Default Option',
description: 'Default option description',
placeholder: '',
propertyName: 'defaultOption',
required: true,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
dependsOn: '',
},
],
...overrides,
};
}
// Helper to create a NotifierVersion for testing
function createVersion(overrides: Partial<NotifierVersion> = {}): NotifierVersion {
return {
version: 'v1',
label: 'Test Version',
description: 'Test version description',
options: [
{
element: 'input',
inputType: 'text',
label: 'Version Option',
description: 'Version option description',
placeholder: '',
propertyName: 'versionOption',
required: true,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
dependsOn: '',
},
],
...overrides,
};
}
describe('notifier-versions utilities', () => {
describe('canCreateNotifier', () => {
it('should return true if notifier has no versions array', () => {
const notifier = createNotifier({ versions: undefined });
expect(canCreateNotifier(notifier)).toBe(true);
});
it('should return true if notifier has empty versions array', () => {
const notifier = createNotifier({ versions: [] });
expect(canCreateNotifier(notifier)).toBe(true);
});
it('should return true if at least one version has canCreate: true', () => {
const notifier = createNotifier({
versions: [
createVersion({ version: 'v0mimir1', canCreate: false }),
createVersion({ version: 'v1', canCreate: true }),
],
});
expect(canCreateNotifier(notifier)).toBe(true);
});
it('should return true if at least one version has canCreate: undefined (defaults to true)', () => {
const notifier = createNotifier({
versions: [
createVersion({ version: 'v0mimir1', canCreate: false }),
createVersion({ version: 'v1', canCreate: undefined }),
],
});
expect(canCreateNotifier(notifier)).toBe(true);
});
it('should return false if all versions have canCreate: false', () => {
const notifier = createNotifier({
versions: [
createVersion({ version: 'v0mimir1', canCreate: false }),
createVersion({ version: 'v0mimir2', canCreate: false }),
],
});
expect(canCreateNotifier(notifier)).toBe(false);
});
it('should return false for notifiers like WeChat that only have legacy versions', () => {
const wechatNotifier = createNotifier({
name: 'WeChat',
type: 'wechat',
versions: [createVersion({ version: 'v0mimir1', canCreate: false })],
});
expect(canCreateNotifier(wechatNotifier)).toBe(false);
});
});
describe('isLegacyVersion', () => {
it('should return false if no version is specified', () => {
const notifier = createNotifier({
versions: [createVersion({ version: 'v0mimir1', canCreate: false })],
});
expect(isLegacyVersion(notifier, undefined)).toBe(false);
expect(isLegacyVersion(notifier, '')).toBe(false);
});
it('should return false if notifier has no versions array', () => {
const notifier = createNotifier({ versions: undefined });
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false);
});
it('should return false if notifier has empty versions array', () => {
const notifier = createNotifier({ versions: [] });
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false);
});
it('should return false if version is not found in versions array', () => {
const notifier = createNotifier({
versions: [createVersion({ version: 'v1', canCreate: true })],
});
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false);
});
it('should return false if version has canCreate: true', () => {
const notifier = createNotifier({
versions: [createVersion({ version: 'v1', canCreate: true })],
});
expect(isLegacyVersion(notifier, 'v1')).toBe(false);
});
it('should return false if version has canCreate: undefined', () => {
const notifier = createNotifier({
versions: [createVersion({ version: 'v1', canCreate: undefined })],
});
expect(isLegacyVersion(notifier, 'v1')).toBe(false);
});
it('should return true if version has canCreate: false', () => {
const notifier = createNotifier({
versions: [
createVersion({ version: 'v0mimir1', canCreate: false }),
createVersion({ version: 'v1', canCreate: true }),
],
});
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(true);
});
it('should correctly identify legacy versions in a mixed notifier', () => {
const notifier = createNotifier({
versions: [
createVersion({ version: 'v0mimir1', canCreate: false }),
createVersion({ version: 'v0mimir2', canCreate: false }),
createVersion({ version: 'v1', canCreate: true }),
],
});
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(true);
expect(isLegacyVersion(notifier, 'v0mimir2')).toBe(true);
expect(isLegacyVersion(notifier, 'v1')).toBe(false);
});
});
describe('getOptionsForVersion', () => {
const defaultOptions: NotificationChannelOption[] = [
{
element: 'input',
inputType: 'text',
label: 'Default URL',
description: 'Default URL description',
placeholder: '',
propertyName: 'url',
required: true,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
dependsOn: '',
},
];
const v0Options: NotificationChannelOption[] = [
{
element: 'input',
inputType: 'text',
label: 'Legacy URL',
description: 'Legacy URL description',
placeholder: '',
propertyName: 'legacyUrl',
required: true,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
dependsOn: '',
},
];
const v1Options: NotificationChannelOption[] = [
{
element: 'input',
inputType: 'text',
label: 'Modern URL',
description: 'Modern URL description',
placeholder: '',
propertyName: 'modernUrl',
required: true,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
dependsOn: '',
},
];
it('should return options from default creatable version if no version is specified', () => {
const notifier = createNotifier({
options: defaultOptions,
versions: [createVersion({ version: 'v1', options: v1Options, canCreate: true })],
});
// When no version specified, should use options from the default creatable version
expect(getOptionsForVersion(notifier, undefined)).toBe(v1Options);
});
it('should return default options if no version is specified and empty string is passed', () => {
const notifier = createNotifier({
options: defaultOptions,
versions: [createVersion({ version: 'v1', options: v1Options, canCreate: true })],
});
// Empty string is still a falsy version, so should use default creatable version
expect(getOptionsForVersion(notifier, '')).toBe(v1Options);
});
it('should return default options if notifier has no versions array', () => {
const notifier = createNotifier({
options: defaultOptions,
versions: undefined,
});
expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions);
});
it('should return default options if notifier has empty versions array', () => {
const notifier = createNotifier({
options: defaultOptions,
versions: [],
});
expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions);
});
it('should return default options if version is not found', () => {
const notifier = createNotifier({
options: defaultOptions,
versions: [createVersion({ version: 'v1', options: v1Options })],
});
expect(getOptionsForVersion(notifier, 'v0mimir1')).toBe(defaultOptions);
});
it('should return version-specific options when version is found', () => {
const notifier = createNotifier({
options: defaultOptions,
versions: [
createVersion({ version: 'v0mimir1', options: v0Options }),
createVersion({ version: 'v1', options: v1Options }),
],
});
expect(getOptionsForVersion(notifier, 'v0mimir1')).toBe(v0Options);
expect(getOptionsForVersion(notifier, 'v1')).toBe(v1Options);
});
it('should return default options if version found but has no options', () => {
const notifier = createNotifier({
options: defaultOptions,
versions: [
{
version: 'v1',
label: 'V1',
description: 'V1 description',
options: undefined as unknown as NotificationChannelOption[],
},
],
});
expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions);
});
});
describe('hasLegacyIntegrations', () => {
// Helper to create a minimal contact point for testing
function createContactPoint(overrides: Partial<GrafanaManagedContactPoint> = {}): GrafanaManagedContactPoint {
return {
name: 'Test Contact Point',
...overrides,
};
}
// Create notifiers with version info for testing
const notifiersWithVersions: NotifierDTO[] = [
createNotifier({
type: 'slack',
versions: [
createVersion({ version: 'v0mimir1', canCreate: false }),
createVersion({ version: 'v1', canCreate: true }),
],
}),
createNotifier({
type: 'webhook',
versions: [
createVersion({ version: 'v0mimir1', canCreate: false }),
createVersion({ version: 'v0mimir2', canCreate: false }),
createVersion({ version: 'v1', canCreate: true }),
],
}),
];
it('should return false if contact point is undefined', () => {
expect(hasLegacyIntegrations(undefined, notifiersWithVersions)).toBe(false);
});
it('should return false if notifiers is undefined', () => {
const contactPoint = createContactPoint({
grafana_managed_receiver_configs: [{ type: 'slack', settings: {}, version: 'v0mimir1' }],
});
expect(hasLegacyIntegrations(contactPoint, undefined)).toBe(false);
});
it('should return false if contact point has no integrations', () => {
const contactPoint = createContactPoint({ grafana_managed_receiver_configs: undefined });
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
});
it('should return false if contact point has empty integrations array', () => {
const contactPoint = createContactPoint({ grafana_managed_receiver_configs: [] });
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
});
it('should return false if all integrations have v1 version (canCreate: true)', () => {
const contactPoint = createContactPoint({
grafana_managed_receiver_configs: [
{ type: 'slack', settings: {}, version: 'v1' },
{ type: 'webhook', settings: {}, version: 'v1' },
],
});
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
});
it('should return false if all integrations have no version', () => {
const contactPoint = createContactPoint({
grafana_managed_receiver_configs: [
{ type: 'slack', settings: {} },
{ type: 'webhook', settings: {} },
],
});
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
});
it('should return true if any integration has a legacy version (canCreate: false)', () => {
const contactPoint = createContactPoint({
grafana_managed_receiver_configs: [
{ type: 'slack', settings: {}, version: 'v0mimir1' },
{ type: 'webhook', settings: {}, version: 'v1' },
],
});
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(true);
});
it('should return true if all integrations have legacy versions', () => {
const contactPoint = createContactPoint({
grafana_managed_receiver_configs: [
{ type: 'slack', settings: {}, version: 'v0mimir1' },
{ type: 'webhook', settings: {}, version: 'v0mimir2' },
],
});
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(true);
});
it('should return false if notifier type is not found in notifiers array', () => {
const contactPoint = createContactPoint({
grafana_managed_receiver_configs: [{ type: 'unknown', settings: {}, version: 'v0mimir1' }],
});
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
});
});
describe('getLegacyVersionLabel', () => {
it('should return "Legacy" for undefined version', () => {
expect(getLegacyVersionLabel(undefined)).toBe('Legacy');
});
it('should return "Legacy" for empty string version', () => {
expect(getLegacyVersionLabel('')).toBe('Legacy');
});
it('should return "Legacy" for v0mimir1', () => {
expect(getLegacyVersionLabel('v0mimir1')).toBe('Legacy');
});
it('should return "Legacy v2" for v0mimir2', () => {
expect(getLegacyVersionLabel('v0mimir2')).toBe('Legacy v2');
});
it('should return "Legacy v3" for v0mimir3', () => {
expect(getLegacyVersionLabel('v0mimir3')).toBe('Legacy v3');
});
it('should return "Legacy" for v1 (trailing 1)', () => {
expect(getLegacyVersionLabel('v1')).toBe('Legacy');
});
it('should return "Legacy v2" for v2 (trailing 2)', () => {
expect(getLegacyVersionLabel('v2')).toBe('Legacy v2');
});
it('should return "Legacy" for version strings without trailing number', () => {
expect(getLegacyVersionLabel('legacy')).toBe('Legacy');
});
});
});
@@ -0,0 +1,126 @@
/**
* Utilities for integration versioning
*
* These utilities help get version-specific options from the backend response
* (via /api/alert-notifiers?version=2)
*/
import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types';
import { NotificationChannelOption, NotifierDTO } from '../types/alerting';
/**
* Checks if a notifier can be used to create new integrations.
* A notifier can be created if it has at least one version with canCreate: true,
* or if it has no versions array (legacy behavior).
*
* @param notifier - The notifier DTO to check
* @returns True if the notifier can be used to create new integrations
*/
export function canCreateNotifier(notifier: NotifierDTO): boolean {
// If no versions array, assume it can be created (legacy behavior)
if (!notifier.versions || notifier.versions.length === 0) {
return true;
}
// Check if any version has canCreate: true (or undefined, which defaults to true)
return notifier.versions.some((v) => v.canCreate !== false);
}
/**
* Checks if a specific version is legacy (cannot be created).
* A version is legacy if it has canCreate: false in the notifier's versions array.
*
* @param notifier - The notifier DTO containing versions array
* @param version - The version string to check (e.g., 'v0mimir1', 'v1')
* @returns True if the version is legacy (canCreate: false)
*/
export function isLegacyVersion(notifier: NotifierDTO, version?: string): boolean {
// If no version specified or no versions array, it's not legacy
if (!version || !notifier.versions || notifier.versions.length === 0) {
return false;
}
// Find the matching version and check its canCreate property
const versionData = notifier.versions.find((v) => v.version === version);
// A version is legacy if canCreate is explicitly false
return versionData?.canCreate === false;
}
/**
* Gets the options for a specific version of a notifier.
* Used to display the correct form fields based on integration version.
*
* @param notifier - The notifier DTO containing versions array
* @param version - The version to get options for (e.g., 'v0', 'v1')
* @returns The options for the specified version, or default options if version not found
*/
export function getOptionsForVersion(notifier: NotifierDTO, version?: string): NotificationChannelOption[] {
// If no versions array, use default options
if (!notifier.versions || notifier.versions.length === 0) {
return notifier.options;
}
// If version is specified, find the matching version
if (version) {
const versionData = notifier.versions.find((v) => v.version === version);
// Return version-specific options if found, otherwise fall back to default
return versionData?.options ?? notifier.options;
}
// If no version specified, find the default creatable version (canCreate !== false)
const defaultVersion = notifier.versions.find((v) => v.canCreate !== false);
return defaultVersion?.options ?? notifier.options;
}
/**
* Checks if a contact point has any legacy (imported) integrations.
* A contact point has legacy integrations if any of its integrations uses a version
* with canCreate: false in the corresponding notifier's versions array.
*
* @param contactPoint - The contact point to check
* @param notifiers - Array of notifier DTOs to look up version info
* @returns True if the contact point has at least one legacy/imported integration
*/
export function hasLegacyIntegrations(contactPoint?: GrafanaManagedContactPoint, notifiers?: NotifierDTO[]): boolean {
if (!contactPoint?.grafana_managed_receiver_configs || !notifiers) {
return false;
}
return contactPoint.grafana_managed_receiver_configs.some((config) => {
const notifier = notifiers.find((n) => n.type === config.type);
return notifier ? isLegacyVersion(notifier, config.version) : false;
});
}
/**
* Gets a user-friendly label for a legacy version.
* Extracts the version number from the version string and formats it as:
* - "Legacy" for version 1 (e.g., v0mimir1)
* - "Legacy v2" for version 2 (e.g., v0mimir2)
* - etc.
*
* Precondition: This function assumes the version is already known to be legacy
* (i.e., canCreate: false). Use isLegacyVersion() to check before calling this.
*
* @param version - The version string (e.g., 'v0mimir1', 'v0mimir2')
* @returns A user-friendly label like "Legacy" or "Legacy v2"
*/
export function getLegacyVersionLabel(version?: string): string {
if (!version) {
return 'Legacy';
}
// Extract trailing number from version string (e.g., v0mimir1 → 1, v0mimir2 → 2)
const match = version.match(/(\d+)$/);
if (match) {
const num = parseInt(match[1], 10);
if (num === 1) {
return 'Legacy';
}
return `Legacy v${num}`;
}
return 'Legacy';
}
@@ -185,6 +185,7 @@ function grafanaChannelConfigToFormChannelValues(
const values: GrafanaChannelValues = {
__id: id,
type: channel.type as NotifierType,
version: channel.version,
provenance: channel.provenance,
settings: { ...channel.settings },
secureFields: { ...channel.secureFields },
@@ -239,6 +240,7 @@ export function formChannelValuesToGrafanaChannelConfig(
}),
secureFields: secureFieldsFromValues,
type: values.type,
version: values.version ?? existing?.version,
name,
disableResolveMessage:
values.disableResolveMessage ?? existing?.disableResolveMessage ?? defaults.disableResolveMessage,
@@ -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);
@@ -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,
});
}
@@ -2,7 +2,7 @@ import { FeatureToggles } from '@grafana/data';
import { config } from '@grafana/runtime';
import { RepositoryViewList } from 'app/api/clients/provisioning/v0alpha1';
export const requiredFeatureToggles: Array<keyof FeatureToggles> = ['provisioning', 'kubernetesDashboards'];
export const requiredFeatureToggles: Array<keyof FeatureToggles> = ['kubernetesDashboards'];
/**
* Checks if all required feature toggles are enabled
@@ -1,3 +1,4 @@
import { config } from '@grafana/runtime';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { RouteDescriptor } from 'app/core/navigation/types';
import { DashboardRoutes } from 'app/types/dashboard';
@@ -6,6 +7,11 @@ import { checkRequiredFeatures } from '../GettingStarted/features';
import { CONNECTIONS_URL, CONNECT_URL, GETTING_STARTED_URL, PROVISIONING_URL } from '../constants';
export function getProvisioningRoutes(): RouteDescriptor[] {
const featureToggles = config.featureToggles || {};
if (!featureToggles.provisioning) {
return [];
}
if (!checkRequiredFeatures()) {
return [
{
@@ -85,6 +85,10 @@ export type GrafanaManagedReceiverConfig = {
// SecureSettings?: GrafanaManagedReceiverConfigSettings<boolean>;
settings: GrafanaManagedReceiverConfigSettings;
type: string;
/**
* Version of the integration (e.g. "v0" for Mimir legacy, "v1" for Grafana)
*/
version?: string;
/**
* Name of the _receiver_, which in most cases will be the
* same as the contact point's name. This should not be used, and is optional because the
@@ -2,7 +2,7 @@
"type": "panel",
"name": "Datagrid",
"id": "datagrid",
"state": "beta",
"state": "deprecated",
"info": {
"author": {
+8 -1
View File
@@ -807,7 +807,8 @@
"label-integration": "Integration",
"label-notification-settings": "Notification settings",
"label-section": "Optional {{name}} settings",
"test": "Test"
"test": "Test",
"tooltip-legacy-version": "This is a legacy integration (version: {{version}}). It cannot be modified."
},
"classic-condition-viewer": {
"of": "OF",
@@ -1928,6 +1929,10 @@
"select-group": "Select group",
"select-namespace": "Select namespace"
},
"navigation": {
"alert-rules": "Alert rules",
"recently-deleted": "Recently deleted"
},
"need-help-info": {
"need-help": "Need help?"
},
@@ -2176,7 +2181,9 @@
"provisioning": {
"badge-tooltip-provenance": "This resource has been provisioned via {{provenance}} and cannot be edited through the UI",
"badge-tooltip-standard": "This resource has been provisioned and cannot be edited through the UI",
"body-imported": "This contact point contains integrations that were imported from an external Alertmanager and is currently read-only. The integrations will become editable after the migration process is complete.",
"body-provisioned": "This {{resource}} has been provisioned, that means it was created by config. Please contact your server admin to update this {{resource}}.",
"title-imported": "This contact point was imported and cannot be edited through the UI",
"title-provisioned": "This {{resource}} cannot be edited through the UI"
},
"provisioning-badge": {
+11 -11
View File
@@ -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:*"