Alerting: Enable recording rules by default (#105603)
This commit is contained in:
+2
-12
@@ -1539,23 +1539,13 @@ max_annotations_to_keep =
|
||||
rule_query_offset = 1m
|
||||
|
||||
[recording_rules]
|
||||
# Enable recording rules. You must provide write credentials below.
|
||||
enabled = false
|
||||
|
||||
# Target URL (including write path) for recording rules.
|
||||
url =
|
||||
|
||||
# Optional username for basic authentication on recording rule write requests. Can be left blank to disable basic auth
|
||||
basic_auth_username =
|
||||
|
||||
# Optional assword for basic authentication on recording rule write requests. Can be left blank.
|
||||
basic_auth_password =
|
||||
# Enable recording rules.
|
||||
enabled = true
|
||||
|
||||
# Request timeout for recording rule writes.
|
||||
timeout = 10s
|
||||
|
||||
# Default data source UID to write to if not specified in the rule definition.
|
||||
# Only has effect if the grafanaManagedRecordingRulesDatasources feature toggle is enabled.
|
||||
default_datasource_uid =
|
||||
|
||||
# Optional custom headers to include in recording rule write requests.
|
||||
|
||||
+2
-12
@@ -1518,23 +1518,13 @@ rule_query_offset = 1m
|
||||
|
||||
#################################### Recording Rules #####################
|
||||
[recording_rules]
|
||||
# Enable recording rules. You must provide write credentials below.
|
||||
enabled = false
|
||||
|
||||
# Target URL (including write path) for recording rules.
|
||||
url =
|
||||
|
||||
# Optional username for basic authentication on recording rule write requests. Can be left blank to disable basic auth
|
||||
basic_auth_username =
|
||||
|
||||
# Optional assword for basic authentication on recording rule write requests. Can be left blank.
|
||||
basic_auth_password =
|
||||
# Enable recording rules.
|
||||
enabled = true
|
||||
|
||||
# Request timeout for recording rule writes.
|
||||
timeout = 30s
|
||||
|
||||
# Default data source UID to write to if not specified in the rule definition.
|
||||
# Only has effect if the grafanaManagedRecordingRulesDatasources feature toggle is enabled.
|
||||
default_datasource_uid =
|
||||
|
||||
# Optional custom headers to include in recording rule write requests.
|
||||
|
||||
@@ -27,15 +27,12 @@ Grafana provides an internal tool in Alerting which allows you to import Mimir a
|
||||
|
||||
## Before you begin
|
||||
|
||||
The `alertingMigrationUI` [feature flag](/docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles/) needs to be enabled to use this feature.
|
||||
To import recording rules, they [must be configured](ref:configure-recording-rules), and the `grafanaManagedRecordingRulesDatasources` [feature flag](/docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles/) must be enabled.
|
||||
|
||||
To use the migration tool, you need the following [RBAC permissions](/docs/grafana/latest/administration/roles-and-permissions/access-control/):
|
||||
|
||||
- Alerting: Rules Writer
|
||||
- Alerting: Set provisioning status
|
||||
- Datasources: Reader
|
||||
- Folders: Creator
|
||||
- Folders: Creator
|
||||
{{< admonition type="note" >}}
|
||||
The Folders permission is optional and only necessary if you want to create new folders for your target namespace. If your account doesn't have permissions to view a namespace, the tool creates a new one. It is a best practice to prepare an import plan before you convert all your alert rules.
|
||||
{{< /admonition >}}
|
||||
|
||||
@@ -22,8 +22,6 @@ You can convert data source-managed alert rules to Grafana-managed alert rules w
|
||||
|
||||
## Before you begin
|
||||
|
||||
To import recording rules, they [must be configured](ref:configure-recording-rules), and the `grafanaManagedRecordingRulesDatasources` [feature flag](/docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles/) must be enabled.
|
||||
|
||||
To import data source-managed alert rules with Grafana Mimirtool, you need to have the Grafana Mimirtool command-line tool installed.
|
||||
|
||||
You need a service account the following [RBAC permissions](/docs/grafana/latest/administration/roles-and-permissions/access-control/):
|
||||
|
||||
+6
-19
@@ -63,31 +63,18 @@ To configure Grafana-managed recording rules, complete the following steps.
|
||||
|
||||
This section only applies to Grafana OSS and Grafana Enterprise.
|
||||
|
||||
Enable the feature by setting `enabled = true` in the `[recording_rules]` section of the Grafana configuration file. Provide the URL of your Prometheus-compatible remote-write endpoint in the `url` field, along with optional credentials or headers.
|
||||
If a rule does not explicitly specify a target data source for writing (for example, if it was created before Grafana 12.1), Grafana will fall back to the `default_datasource_uid` set in the configuration:
|
||||
|
||||
```
|
||||
[recording_rules]
|
||||
enabled = true
|
||||
url = http://my-example-prometheus.local:9090/api/prom/push
|
||||
basic_auth_username = my-user
|
||||
basic_auth_password = my-pass
|
||||
|
||||
[recording_rules.custom_headers]
|
||||
X-My-Header = MyValue
|
||||
```
|
||||
|
||||
### Per-rule data source
|
||||
|
||||
To choose the remote-write Prometheus data source individually for each recording rule, also enable the `grafanaManagedRecordingRulesDatasources` feature flag.
|
||||
|
||||
When this flag is on, Grafana does not use the `url` defined in the configuration file, and the rule editor shows a dropdown to select the target data source. If a rule does not specify a target, for example it was created before the flag was enabled, Grafana writes to the data source identified by `default_datasource_uid` in the Grafana configuration:
|
||||
|
||||
```
|
||||
[recording_rules]
|
||||
|
||||
default_datasource_uid = my-uid
|
||||
```
|
||||
|
||||
If you previously configured recording rules using the `url` and `basic_auth_*` configuration options, these are no longer supported. You must either:
|
||||
|
||||
- Set `default_datasource_uid` in the `[recording_rules]` section of the configuration file to point to the target data source
|
||||
- Or, before upgrading to Grafana 12.1, enable the `grafanaManagedRecordingRulesDatasources` feature flag and update each recording rule individually to include a target data source
|
||||
|
||||
## Add new recording rule
|
||||
|
||||
To create a new Grafana-managed recording rule:
|
||||
|
||||
@@ -21,67 +21,66 @@ For more information about feature release stages, refer to [Release life cycle
|
||||
|
||||
Most [generally available](https://grafana.com/docs/release-life-cycle/#general-availability) features are enabled by default. You can disable these feature by setting the feature flag to "false" in the configuration.
|
||||
|
||||
| Feature toggle name | Description | Enabled by default |
|
||||
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
||||
| `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | |
|
||||
| `publicDashboardsScene` | Enables public dashboard rendering using scenes | Yes |
|
||||
| `featureHighlights` | Highlight Grafana Enterprise features | |
|
||||
| `correlations` | Correlations page | Yes |
|
||||
| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes |
|
||||
| `nestedFolders` | Enable folder nesting | Yes |
|
||||
| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes |
|
||||
| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes |
|
||||
| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes |
|
||||
| `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes |
|
||||
| `unifiedRequestLog` | Writes error logs to the request logger | Yes |
|
||||
| `pluginsDetailsRightPanel` | Enables right panel for the plugins details page | Yes |
|
||||
| `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries | Yes |
|
||||
| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes |
|
||||
| `transformationsRedesign` | Enables the transformations redesign | Yes |
|
||||
| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes |
|
||||
| `angularDeprecationUI` | Display Angular warnings in dashboards and panels | Yes |
|
||||
| `dashgpt` | Enable AI powered features in dashboards | Yes |
|
||||
| `externalCorePlugins` | Allow core plugins to be loaded as external | Yes |
|
||||
| `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes |
|
||||
| `formatString` | Enable format string transformer | Yes |
|
||||
| `kubernetesClientDashboardsFolders` | Route the folder and dashboard service requests to k8s | Yes |
|
||||
| `lokiStructuredMetadata` | Enables the loki data source to request structured metadata from the Loki server | Yes |
|
||||
| `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | Yes |
|
||||
| `annotationPermissionUpdate` | Change the way annotation permissions work by scoping them to folders and dashboards. | Yes |
|
||||
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes |
|
||||
| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | Yes |
|
||||
| `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes |
|
||||
| `ssoSettingsApi` | Enables the SSO settings API and the OAuth configuration UIs in Grafana | Yes |
|
||||
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes |
|
||||
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes |
|
||||
| `lokiQueryHints` | Enables query hints for Loki | Yes |
|
||||
| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | |
|
||||
| `onPremToCloudMigrations` | Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack. | Yes |
|
||||
| `groupToNestedTableTransformation` | Enables the group to nested table transformation | Yes |
|
||||
| `newPDFRendering` | New implementation for the dashboard-to-PDF rendering | Yes |
|
||||
| `tlsMemcached` | Use TLS-enabled memcached in the enterprise caching feature | Yes |
|
||||
| `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector | Yes |
|
||||
| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes |
|
||||
| `newDashboardSharingComponent` | Enables the new sharing drawer design | Yes |
|
||||
| `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | |
|
||||
| `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | Yes |
|
||||
| `pinNavItems` | Enables pinning of nav items | Yes |
|
||||
| `failWrongDSUID` | Throws an error if a data source has an invalid UIDs | Yes |
|
||||
| `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes |
|
||||
| `newFiltersUI` | Enables new combobox style UI for the Ad hoc filters variable in scenes architecture | Yes |
|
||||
| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions | Yes |
|
||||
| `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | Yes |
|
||||
| `pluginsSriChecks` | Enables SRI checks for plugin assets | |
|
||||
| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | |
|
||||
| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes |
|
||||
| `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes |
|
||||
| `azureMonitorEnableUserAuth` | Enables user auth for Azure Monitor datasource only | Yes |
|
||||
| `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | Yes |
|
||||
| `lokiLabelNamesQueryApi` | Defaults to using the Loki `/labels` API instead of `/series` | Yes |
|
||||
| `grafanaManagedRecordingRulesDatasources` | Enables writing to data sources for Grafana-managed recording rules. | |
|
||||
| `alertingMigrationUI` | Enables the alerting migration UI, to migrate data source-managed rules to Grafana-managed rules | Yes |
|
||||
| `alertingImportYAMLUI` | Enables a UI feature for importing rules from a Prometheus file to Grafana-managed rules | Yes |
|
||||
| `unifiedNavbars` | Enables unified navbars | |
|
||||
| Feature toggle name | Description | Enabled by default |
|
||||
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
||||
| `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | |
|
||||
| `publicDashboardsScene` | Enables public dashboard rendering using scenes | Yes |
|
||||
| `featureHighlights` | Highlight Grafana Enterprise features | |
|
||||
| `correlations` | Correlations page | Yes |
|
||||
| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes |
|
||||
| `nestedFolders` | Enable folder nesting | Yes |
|
||||
| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes |
|
||||
| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes |
|
||||
| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes |
|
||||
| `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes |
|
||||
| `unifiedRequestLog` | Writes error logs to the request logger | Yes |
|
||||
| `pluginsDetailsRightPanel` | Enables right panel for the plugins details page | Yes |
|
||||
| `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries | Yes |
|
||||
| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes |
|
||||
| `transformationsRedesign` | Enables the transformations redesign | Yes |
|
||||
| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes |
|
||||
| `angularDeprecationUI` | Display Angular warnings in dashboards and panels | Yes |
|
||||
| `dashgpt` | Enable AI powered features in dashboards | Yes |
|
||||
| `externalCorePlugins` | Allow core plugins to be loaded as external | Yes |
|
||||
| `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes |
|
||||
| `formatString` | Enable format string transformer | Yes |
|
||||
| `kubernetesClientDashboardsFolders` | Route the folder and dashboard service requests to k8s | Yes |
|
||||
| `lokiStructuredMetadata` | Enables the loki data source to request structured metadata from the Loki server | Yes |
|
||||
| `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | Yes |
|
||||
| `annotationPermissionUpdate` | Change the way annotation permissions work by scoping them to folders and dashboards. | Yes |
|
||||
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes |
|
||||
| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | Yes |
|
||||
| `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes |
|
||||
| `ssoSettingsApi` | Enables the SSO settings API and the OAuth configuration UIs in Grafana | Yes |
|
||||
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes |
|
||||
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes |
|
||||
| `lokiQueryHints` | Enables query hints for Loki | Yes |
|
||||
| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | |
|
||||
| `onPremToCloudMigrations` | Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack. | Yes |
|
||||
| `groupToNestedTableTransformation` | Enables the group to nested table transformation | Yes |
|
||||
| `newPDFRendering` | New implementation for the dashboard-to-PDF rendering | Yes |
|
||||
| `tlsMemcached` | Use TLS-enabled memcached in the enterprise caching feature | Yes |
|
||||
| `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector | Yes |
|
||||
| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes |
|
||||
| `newDashboardSharingComponent` | Enables the new sharing drawer design | Yes |
|
||||
| `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | |
|
||||
| `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | Yes |
|
||||
| `pinNavItems` | Enables pinning of nav items | Yes |
|
||||
| `failWrongDSUID` | Throws an error if a data source has an invalid UIDs | Yes |
|
||||
| `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes |
|
||||
| `newFiltersUI` | Enables new combobox style UI for the Ad hoc filters variable in scenes architecture | Yes |
|
||||
| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions | Yes |
|
||||
| `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | Yes |
|
||||
| `pluginsSriChecks` | Enables SRI checks for plugin assets | |
|
||||
| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | |
|
||||
| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes |
|
||||
| `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes |
|
||||
| `azureMonitorEnableUserAuth` | Enables user auth for Azure Monitor datasource only | Yes |
|
||||
| `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | Yes |
|
||||
| `lokiLabelNamesQueryApi` | Defaults to using the Loki `/labels` API instead of `/series` | Yes |
|
||||
| `alertingMigrationUI` | Enables the alerting migration UI, to migrate data source-managed rules to Grafana-managed rules | Yes |
|
||||
| `alertingImportYAMLUI` | Enables a UI feature for importing rules from a Prometheus file to Grafana-managed rules | Yes |
|
||||
| `unifiedNavbars` | Enables unified navbars | |
|
||||
|
||||
## Public preview feature toggles
|
||||
|
||||
|
||||
@@ -907,11 +907,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
alertRuleRestore?: boolean;
|
||||
/**
|
||||
* Enables writing to data sources for Grafana-managed recording rules.
|
||||
* @default false
|
||||
*/
|
||||
grafanaManagedRecordingRulesDatasources?: boolean;
|
||||
/**
|
||||
* Enables running Infinity queries in parallel
|
||||
*/
|
||||
infinityRunQueriesInParallel?: boolean;
|
||||
|
||||
@@ -1554,14 +1554,6 @@ var (
|
||||
Owner: grafanaAlertingSquad,
|
||||
Expression: "true", // enabled by default
|
||||
},
|
||||
{
|
||||
Name: "grafanaManagedRecordingRulesDatasources",
|
||||
Description: "Enables writing to data sources for Grafana-managed recording rules.",
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
Owner: grafanaAlertingSquad,
|
||||
AllowSelfServe: false,
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "infinityRunQueriesInParallel",
|
||||
Description: "Enables running Infinity queries in parallel",
|
||||
|
||||
@@ -204,7 +204,6 @@ newShareReportDrawer,experimental,@grafana/sharing-squad,false,false,false
|
||||
rendererDisableAppPluginsPreload,experimental,@grafana/sharing-squad,false,false,true
|
||||
assetSriChecks,experimental,@grafana/frontend-ops,false,false,true
|
||||
alertRuleRestore,preview,@grafana/alerting-squad,false,false,false
|
||||
grafanaManagedRecordingRulesDatasources,GA,@grafana/alerting-squad,false,false,false
|
||||
infinityRunQueriesInParallel,privatePreview,@grafana/oss-big-tent,false,false,false
|
||||
inviteUserExperimental,experimental,@grafana/sharing-squad,false,false,true
|
||||
alertingMigrationUI,GA,@grafana/alerting-squad,false,false,true
|
||||
|
||||
|
@@ -827,10 +827,6 @@ const (
|
||||
// Enables the alert rule restore feature
|
||||
FlagAlertRuleRestore = "alertRuleRestore"
|
||||
|
||||
// FlagGrafanaManagedRecordingRulesDatasources
|
||||
// Enables writing to data sources for Grafana-managed recording rules.
|
||||
FlagGrafanaManagedRecordingRulesDatasources = "grafanaManagedRecordingRulesDatasources"
|
||||
|
||||
// FlagInfinityRunQueriesInParallel
|
||||
// Enables running Infinity queries in parallel
|
||||
FlagInfinityRunQueriesInParallel = "infinityRunQueriesInParallel"
|
||||
|
||||
@@ -1449,6 +1449,7 @@
|
||||
"name": "grafanaManagedRecordingRulesDatasources",
|
||||
"resourceVersion": "1746998568254",
|
||||
"creationTimestamp": "2025-03-07T13:30:40Z",
|
||||
"deletionTimestamp": "2025-05-19T10:18:04Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-05-11 21:22:48.254378 +0000 UTC"
|
||||
}
|
||||
|
||||
@@ -62,11 +62,6 @@ var (
|
||||
"alerting.recordingRulesNotEnabled",
|
||||
errutil.WithPublicMessage("Cannot import recording rules: Feature not enabled."),
|
||||
).Errorf("recording rules not enabled")
|
||||
|
||||
errRecordingRulesDatasourcesNotEnabled = errutil.ValidationFailed(
|
||||
"alerting.recordingRulesDatasourcesNotEnabled",
|
||||
errutil.WithPublicMessage("Cannot import recording rules: Configuration of target datasources not enabled."),
|
||||
).Errorf("recording rules target datasources configuration not enabled")
|
||||
)
|
||||
|
||||
func errInvalidHeaderValue(header string, err error) error {
|
||||
@@ -399,11 +394,6 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *context
|
||||
logger.Error("Cannot import recording rules", "error", errRecordingRulesNotEnabled)
|
||||
return errorToResponse(errRecordingRulesNotEnabled)
|
||||
}
|
||||
|
||||
if !srv.featureToggles.IsEnabledGlobally(featuremgmt.FlagGrafanaManagedRecordingRulesDatasources) {
|
||||
logger.Error("Cannot import recording rules", "error", errRecordingRulesDatasourcesNotEnabled)
|
||||
return errorToResponse(errRecordingRulesDatasourcesNotEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
grafanaGroup, err := srv.convertToGrafanaRuleGroup(
|
||||
|
||||
@@ -295,45 +295,25 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
|
||||
|
||||
t.Run("with disabled recording rules", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
recordingRules bool
|
||||
recordingRulesTargetDS bool
|
||||
expectedStatus int
|
||||
name string
|
||||
recordingRules bool
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "when recording rules are enabled",
|
||||
recordingRules: true,
|
||||
recordingRulesTargetDS: true,
|
||||
expectedStatus: http.StatusAccepted,
|
||||
name: "when recording rules are enabled",
|
||||
recordingRules: true,
|
||||
expectedStatus: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "when recording rules are disabled",
|
||||
recordingRules: false,
|
||||
recordingRulesTargetDS: true,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "when target datasources for recording rules are disabled",
|
||||
recordingRules: true,
|
||||
recordingRulesTargetDS: false,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "when both recording rules and target datasources are disabled",
|
||||
recordingRules: false,
|
||||
recordingRulesTargetDS: false,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
name: "when recording rules are disabled",
|
||||
recordingRules: false,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var features featuremgmt.FeatureToggles
|
||||
if tc.recordingRulesTargetDS {
|
||||
features = featuremgmt.WithFeatures(featuremgmt.FlagGrafanaManagedRecordingRulesDatasources)
|
||||
} else {
|
||||
features = featuremgmt.WithFeatures()
|
||||
}
|
||||
features := featuremgmt.WithFeatures()
|
||||
|
||||
srv, _, _, _ := createConvertPrometheusSrv(t, withFeatureToggles(features))
|
||||
srv.cfg.RecordingRules.Enabled = tc.recordingRules
|
||||
@@ -1317,7 +1297,6 @@ func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOption
|
||||
provenanceStore: fakes.NewFakeProvisioningStore(),
|
||||
fakeAccessControlRuleService: &acfakes.FakeRuleService{},
|
||||
quotaChecker: quotas,
|
||||
featureToggles: featuremgmt.WithFeatures(featuremgmt.FlagGrafanaManagedRecordingRulesDatasources),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
|
||||
@@ -317,7 +317,7 @@ func (ng *AlertNG) init() error {
|
||||
evalFactory := eval.NewEvaluatorFactory(ng.Cfg.UnifiedAlerting, ng.DataSourceCache, ng.ExpressionService)
|
||||
conditionValidator := eval.NewConditionValidator(ng.DataSourceCache, ng.ExpressionService, ng.pluginsStore)
|
||||
|
||||
recordingWriter, err := createRecordingWriter(ng.FeatureToggles, ng.Cfg.UnifiedAlerting.RecordingRules, ng.httpClientProvider, ng.DataSourceService, clk, ng.Metrics.GetRemoteWriterMetrics())
|
||||
recordingWriter, err := createRecordingWriter(ng.Cfg.UnifiedAlerting.RecordingRules, ng.httpClientProvider, ng.DataSourceService, clk, ng.Metrics.GetRemoteWriterMetrics())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize recording writer: %w", err)
|
||||
}
|
||||
@@ -649,25 +649,20 @@ func createRemoteAlertmanager(ctx context.Context, cfg remote.AlertmanagerConfig
|
||||
return remote.NewAlertmanager(ctx, cfg, notifier.NewFileStore(cfg.OrgID, kvstore), decryptFn, autogenFn, m, tracer)
|
||||
}
|
||||
|
||||
func createRecordingWriter(featureToggles featuremgmt.FeatureToggles, settings setting.RecordingRuleSettings, httpClientProvider httpclient.Provider, datasourceService datasources.DataSourceService, clock clock.Clock, m *metrics.RemoteWriter) (schedule.RecordingWriter, error) {
|
||||
func createRecordingWriter(settings setting.RecordingRuleSettings, httpClientProvider httpclient.Provider, datasourceService datasources.DataSourceService, clock clock.Clock, m *metrics.RemoteWriter) (schedule.RecordingWriter, error) {
|
||||
logger := log.New("ngalert.writer")
|
||||
|
||||
if settings.Enabled {
|
||||
if featureToggles.IsEnabledGlobally(featuremgmt.FlagGrafanaManagedRecordingRulesDatasources) {
|
||||
cfg := writer.DatasourceWriterConfig{
|
||||
Timeout: settings.Timeout,
|
||||
CustomHeaders: settings.CustomHeaders,
|
||||
DefaultDatasourceUID: settings.DefaultDatasourceUID,
|
||||
}
|
||||
|
||||
logger.Info("Setting up remote write using data sources",
|
||||
"timeout", cfg.Timeout, "default_datasource_uid", cfg.DefaultDatasourceUID)
|
||||
|
||||
return writer.NewDatasourceWriter(cfg, datasourceService, httpClientProvider, clock, logger, m), nil
|
||||
} else {
|
||||
logger.Info("Setting up remote write using static configuration")
|
||||
return writer.NewPrometheusWriterWithSettings(settings, httpClientProvider, clock, logger, m)
|
||||
cfg := writer.DatasourceWriterConfig{
|
||||
Timeout: settings.Timeout,
|
||||
CustomHeaders: settings.CustomHeaders,
|
||||
DefaultDatasourceUID: settings.DefaultDatasourceUID,
|
||||
}
|
||||
|
||||
logger.Info("Setting up remote write using data sources",
|
||||
"timeout", cfg.Timeout, "default_datasource_uid", cfg.DefaultDatasourceUID)
|
||||
|
||||
return writer.NewDatasourceWriter(cfg, datasourceService, httpClientProvider, clock, logger, m), nil
|
||||
}
|
||||
|
||||
return writer.NoopWriter{}, nil
|
||||
|
||||
@@ -177,13 +177,6 @@ func blankRecordingRuleForTests(ctx context.Context) *recordingRule {
|
||||
}
|
||||
|
||||
func TestRecordingRule_Integration(t *testing.T) {
|
||||
t.Run("with prometheus writer", func(t *testing.T) {
|
||||
writeTarget := writer.NewTestRemoteWriteTarget(t)
|
||||
defer writeTarget.Close()
|
||||
writerReg := prometheus.NewPedanticRegistry()
|
||||
writer := setupPrometheusWriter(t, writeTarget, writerReg)
|
||||
testRecordingRule_Integration(t, writeTarget, writer, writerReg, "")
|
||||
})
|
||||
t.Run("with datasource writer", func(t *testing.T) {
|
||||
writeTarget := writer.NewTestRemoteWriteTarget(t)
|
||||
defer writeTarget.Close()
|
||||
@@ -798,14 +791,6 @@ func withQueryForHealth(health string) models.AlertRuleMutator {
|
||||
}
|
||||
}
|
||||
|
||||
func setupPrometheusWriter(t *testing.T, target *writer.TestRemoteWriteTarget, reg prometheus.Registerer) *writer.PrometheusWriter {
|
||||
provider := testClientProvider{}
|
||||
m := metrics.NewNGAlert(reg)
|
||||
wr, err := writer.NewPrometheusWriterWithSettings(target.ClientSettings(), provider, clock.NewMock(), log.NewNopLogger(), m.GetRemoteWriterMetrics())
|
||||
require.NoError(t, err)
|
||||
return wr
|
||||
}
|
||||
|
||||
func setupDatasourceWriter(t *testing.T, target *writer.TestRemoteWriteTarget, reg prometheus.Registerer, dsUID string) *writer.DatasourceWriter {
|
||||
provider := testClientProvider{}
|
||||
m := metrics.NewNGAlert(reg)
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,7 +15,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
@@ -250,34 +248,6 @@ type PrometheusWriterConfig struct {
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func NewPrometheusWriterWithSettings(
|
||||
settings setting.RecordingRuleSettings,
|
||||
httpClientProvider HttpClientProvider,
|
||||
clock clock.Clock,
|
||||
l log.Logger,
|
||||
metrics *metrics.RemoteWriter,
|
||||
) (*PrometheusWriter, error) {
|
||||
if err := validateSettings(settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
headers := make(http.Header)
|
||||
for k, v := range settings.CustomHeaders {
|
||||
headers.Add(k, v)
|
||||
}
|
||||
|
||||
cfg := PrometheusWriterConfig{
|
||||
URL: settings.URL,
|
||||
HTTPOptions: httpclient.Options{
|
||||
BasicAuth: createAuthOpts(settings.BasicAuthUsername, settings.BasicAuthPassword),
|
||||
Header: headers,
|
||||
},
|
||||
Timeout: settings.Timeout,
|
||||
}
|
||||
|
||||
return NewPrometheusWriter(cfg, httpClientProvider, clock, l, metrics)
|
||||
}
|
||||
|
||||
func NewPrometheusWriter(
|
||||
cfg PrometheusWriterConfig,
|
||||
httpClientProvider HttpClientProvider,
|
||||
@@ -310,34 +280,6 @@ func NewPrometheusWriter(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateSettings(settings setting.RecordingRuleSettings) error {
|
||||
if settings.BasicAuthUsername != "" && settings.BasicAuthPassword == "" {
|
||||
return fmt.Errorf("basic auth password is required if username is set")
|
||||
}
|
||||
|
||||
if _, err := url.Parse(settings.URL); err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if settings.Timeout <= 0 {
|
||||
return fmt.Errorf("timeout must be greater than 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createAuthOpts(username, password string) *httpclient.BasicAuthOptions {
|
||||
// If username is empty, do not use basic auth and ignore password.
|
||||
if username == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &httpclient.BasicAuthOptions{
|
||||
User: username,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
// Write writes the given frames to the Prometheus remote write endpoint.
|
||||
func (w PrometheusWriter) WriteDatasource(ctx context.Context, dsUID string, name string, t time.Time, frames data.Frames, orgID int64, extraLabels map[string]string) error {
|
||||
l := w.logger.FromContext(ctx)
|
||||
|
||||
@@ -19,74 +19,12 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
func TestValidateSettings(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
settings setting.RecordingRuleSettings
|
||||
err bool
|
||||
}{
|
||||
{
|
||||
name: "invalid url",
|
||||
settings: setting.RecordingRuleSettings{
|
||||
URL: "invalid url",
|
||||
},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "missing password",
|
||||
settings: setting.RecordingRuleSettings{
|
||||
URL: "http://localhost:9090",
|
||||
BasicAuthUsername: "user",
|
||||
},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "timeout is 0",
|
||||
settings: setting.RecordingRuleSettings{
|
||||
URL: "http://localhost:9090",
|
||||
BasicAuthUsername: "user",
|
||||
BasicAuthPassword: "password",
|
||||
Timeout: 0,
|
||||
},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "valid settings w/ auth",
|
||||
settings: setting.RecordingRuleSettings{
|
||||
URL: "http://localhost:9090",
|
||||
BasicAuthUsername: "user",
|
||||
BasicAuthPassword: "password",
|
||||
Timeout: 10,
|
||||
},
|
||||
err: false,
|
||||
},
|
||||
{
|
||||
name: "valid settings w/o auth",
|
||||
settings: setting.RecordingRuleSettings{
|
||||
URL: "http://localhost:9090",
|
||||
Timeout: 10,
|
||||
},
|
||||
err: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateSettings(tc.settings)
|
||||
if tc.err {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointsFromFrames(t *testing.T) {
|
||||
extraLabels := map[string]string{"extra": "label"}
|
||||
|
||||
|
||||
@@ -71,10 +71,7 @@ func (s *TestRemoteWriteTarget) DatasourceURL() string {
|
||||
|
||||
func (s *TestRemoteWriteTarget) ClientSettings() setting.RecordingRuleSettings {
|
||||
return setting.RecordingRuleSettings{
|
||||
URL: s.srv.URL + RemoteWriteEndpoint,
|
||||
Timeout: 1 * time.Second,
|
||||
BasicAuthUsername: "",
|
||||
BasicAuthPassword: "",
|
||||
Timeout: 1 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,9 +138,6 @@ type UnifiedAlertingSettings struct {
|
||||
|
||||
type RecordingRuleSettings struct {
|
||||
Enabled bool
|
||||
URL string
|
||||
BasicAuthUsername string
|
||||
BasicAuthPassword string
|
||||
CustomHeaders map[string]string
|
||||
Timeout time.Duration
|
||||
DefaultDatasourceUID string
|
||||
@@ -455,10 +452,7 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error {
|
||||
|
||||
rr := iniFile.Section("recording_rules")
|
||||
uaCfgRecordingRules := RecordingRuleSettings{
|
||||
Enabled: rr.Key("enabled").MustBool(false),
|
||||
URL: rr.Key("url").MustString(""),
|
||||
BasicAuthUsername: rr.Key("basic_auth_username").MustString(""),
|
||||
BasicAuthPassword: rr.Key("basic_auth_password").MustString(""),
|
||||
Enabled: rr.Key("enabled").MustBool(true),
|
||||
Timeout: rr.Key("timeout").MustDuration(defaultRecordingRequestTimeout),
|
||||
DefaultDatasourceUID: rr.Key("default_datasource_uid").MustString(""),
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ func TestIntegrationConvertPrometheusNotificationSettings(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"grafanaManagedRecordingRulesDatasources"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -113,7 +113,6 @@ func TestIntegrationConvertPrometheusEndpoints_RecordingRuleTargetDatasource(t *
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"grafanaManagedRecordingRulesDatasources"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
@@ -181,7 +180,6 @@ func TestIntegrationConvertPrometheusEndpoints(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"grafanaManagedRecordingRulesDatasources"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
@@ -387,7 +385,6 @@ func TestIntegrationConvertPrometheusEndpoints_UpdateRule(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"grafanaManagedRecordingRulesDatasources"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
@@ -468,7 +465,6 @@ func TestIntegrationConvertPrometheusEndpoints_Conflict(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"grafanaManagedRecordingRulesDatasources"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
@@ -550,7 +546,6 @@ func TestIntegrationConvertPrometheusEndpoints_CreatePausedRules(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"grafanaManagedRecordingRulesDatasources"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
@@ -658,7 +653,6 @@ func TestIntegrationConvertPrometheusEndpoints_FolderUIDHeader(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"grafanaManagedRecordingRulesDatasources"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
@@ -755,7 +749,6 @@ func TestIntegrationConvertPrometheusEndpoints_Provenance(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"grafanaManagedRecordingRulesDatasources"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
@@ -865,7 +858,6 @@ func TestIntegrationConvertPrometheusEndpoints_Delete(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"grafanaManagedRecordingRulesDatasources"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -76,6 +76,12 @@ describe('RuleEditor grafana recording rules', () => {
|
||||
|
||||
await user.type(await ui.inputs.name.find(), 'my great new rule');
|
||||
await user.type(await ui.inputs.metric.find(), 'metricName');
|
||||
|
||||
const targetDsField = await ui.inputs.targetDatasource.find();
|
||||
const dsPickerInput = await ui.inputs.dataSource.find(targetDsField);
|
||||
await user.click(dsPickerInput);
|
||||
await user.click(await screen.findByText('Prom'));
|
||||
|
||||
await selectFolderAndGroup(user);
|
||||
|
||||
await user.click(ui.buttons.save.get());
|
||||
@@ -94,6 +100,12 @@ describe('RuleEditor grafana recording rules', () => {
|
||||
const { user } = renderRuleEditor(undefined, 'grafana-recording');
|
||||
|
||||
await user.type(await ui.inputs.name.find(), 'my great new rule');
|
||||
|
||||
const targetDsField = await ui.inputs.targetDatasource.find();
|
||||
const dsPickerInput = await ui.inputs.dataSource.find(targetDsField);
|
||||
await user.click(dsPickerInput);
|
||||
await user.click(await screen.findByText('Prom'));
|
||||
|
||||
await selectFolderAndGroup(user);
|
||||
|
||||
await user.click(ui.buttons.save.get());
|
||||
|
||||
+2
@@ -106,6 +106,7 @@ exports[`RuleEditor grafana recording rules can create new grafana recording rul
|
||||
"record": {
|
||||
"from": "B",
|
||||
"metric": "metricName",
|
||||
"target_datasource_uid": "prometheus",
|
||||
},
|
||||
"title": "my great new rule",
|
||||
},
|
||||
@@ -235,6 +236,7 @@ exports[`RuleEditor grafana recording rules can create new grafana recording rul
|
||||
"record": {
|
||||
"from": "B",
|
||||
"metric": "metricName",
|
||||
"target_datasource_uid": "prometheus",
|
||||
},
|
||||
"title": "my great new rule",
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, useTranslate } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Field, Input, Stack, Text } from '@grafana/ui';
|
||||
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
|
||||
|
||||
@@ -110,9 +109,10 @@ export const AlertRuleNameAndMetric = () => {
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isGrafanaRecordingRule && config.featureToggles.grafanaManagedRecordingRulesDatasources && (
|
||||
{isGrafanaRecordingRule && (
|
||||
<Field
|
||||
id="target-data-source"
|
||||
data-testid="target-data-source"
|
||||
label={t('alerting.recording-rules.label-target-data-source', 'Target data source')}
|
||||
description={t(
|
||||
'alerting.recording-rules.description-target-data-source',
|
||||
|
||||
@@ -4,7 +4,6 @@ import { isUndefined } from 'lodash';
|
||||
|
||||
import { GrafanaTheme2, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
|
||||
import { Trans, useTranslate } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Icon, Link, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import { useDatasource } from 'app/features/datasources/hooks';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
@@ -64,10 +63,7 @@ export const Details = ({ rule }: DetailsProps) => {
|
||||
|
||||
const datasource = useDatasource(targetDatasourceUid);
|
||||
|
||||
const showTargetDatasource =
|
||||
config.featureToggles.grafanaManagedRecordingRulesDatasources &&
|
||||
targetDatasourceUid &&
|
||||
targetDatasourceUid !== 'grafana';
|
||||
const showTargetDatasource = targetDatasourceUid && targetDatasourceUid !== 'grafana';
|
||||
|
||||
const evaluationDuration = rule.promRule?.evaluationTime;
|
||||
const evaluationTimestamp = rule.promRule?.lastEvaluation;
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ui = {
|
||||
inputs: {
|
||||
name: byRole('textbox', { name: 'name' }),
|
||||
metric: byRole('textbox', { name: 'metric' }),
|
||||
targetDatasource: byTestId('target-data-source'),
|
||||
alertType: byTestId('alert-type-picker'),
|
||||
dataSource: byTestId(selectors.components.DataSourcePicker.inputV2),
|
||||
folder: byTestId('folder-picker'),
|
||||
|
||||
Reference in New Issue
Block a user