Adding duration configuration to Stack Monitoring Cluster Health rule (#147565)

## Summary

This PR fixes #145843 by adding the ability to configure `duration` for
the Stack Monitoring "Legacy Rules" along with a set of default rule
parameters and custom validation; which can be configured per rule.
There are three new attributes added the the LEGACY_RULE_DETAILS object:

- `defaults` – The default values for parameters (so we can set the
default duration to `2m` for Cluster Health)
- `expressionConfig` – Configuration for turning on/off UI elements
(like duration)
- `validate` – A custom validate function (so we can ensure `duration`
is provided)

This will also allow us control over which of the legacy rules gets
duration and which ones we want to keep "as is". It also makes room for
adding additional UI features in the future.

<img width="618" alt="image"
src="https://user-images.githubusercontent.com/41702/207685736-d8dc3023-66d0-4e40-a564-830f290ec1e1.png">

### Checklist

Delete any items that are not applicable to this PR.

- [X] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [X] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Chris Cowan 2022-12-15 12:23:21 -07:00 committed by GitHub
parent c6af65a391
commit 747704544f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 114 additions and 39 deletions

View file

@ -6,8 +6,9 @@
*/
import { i18n } from '@kbn/i18n';
import { CommonAlertParamDetail } from './types/alerts';
import { CommonAlertParamDetail, ExpressionConfig } from './types/alerts';
import { AlertParamType } from './enums';
import { validateDuration } from './validate_duration';
/**
* Helper string to add as a tag in every logging call
@ -252,10 +253,18 @@ export const RULE_THREAD_POOL_WRITE_REJECTIONS = `${RULE_PREFIX}alert_thread_poo
export const RULE_CCR_READ_EXCEPTIONS = `${RULE_PREFIX}ccr_read_exceptions`;
export const RULE_LARGE_SHARD_SIZE = `${RULE_PREFIX}shard_size`;
interface LegacyRuleDetails {
label: string;
description: string;
defaults?: Record<string, unknown>;
expressionConfig?: ExpressionConfig;
validate?: (input: any) => { errors: {} };
}
/**
* Legacy rules details/label for server and public use
*/
export const LEGACY_RULE_DETAILS = {
export const LEGACY_RULE_DETAILS: Record<string, LegacyRuleDetails> = {
[RULE_CLUSTER_HEALTH]: {
label: i18n.translate('xpack.monitoring.alerts.clusterHealth.label', {
defaultMessage: 'Cluster health',
@ -263,6 +272,13 @@ export const LEGACY_RULE_DETAILS = {
description: i18n.translate('xpack.monitoring.alerts.clusterHealth.description', {
defaultMessage: 'Alert when the health of the cluster changes.',
}),
defaults: {
duration: '2m',
},
expressionConfig: {
showDuration: true,
},
validate: validateDuration,
},
[RULE_ELASTICSEARCH_VERSION_MISMATCH]: {
label: i18n.translate('xpack.monitoring.alerts.elasticsearchVersionMismatch.label', {

View file

@ -286,3 +286,7 @@ export interface AlertVersions {
ccs?: string;
versions: string[];
}
export interface ExpressionConfig {
showDuration?: boolean;
}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { RuleTypeParams, ValidationResult } from '@kbn/triggers-actions-ui-plugin/public';
export interface ValidateDurationOptions extends RuleTypeParams {
duration: string;
}
export const validateDuration = (inputValues: ValidateDurationOptions): ValidationResult => {
const validationResult = { errors: {} };
const errors: { [key: string]: string[] } = {
duration: [],
};
if (!inputValues.duration) {
errors.duration.push(
i18n.translate('xpack.monitoring.alerts.validation.duration', {
defaultMessage: 'A valid duration is required.',
})
);
}
validationResult.errors = errors;
return validationResult;
};

View file

@ -5,10 +5,9 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import type { RuleTypeParams } from '@kbn/alerting-plugin/common';
import type { RuleTypeModel, ValidationResult } from '@kbn/triggers-actions-ui-plugin/public';
import type { RuleTypeModel } from '@kbn/triggers-actions-ui-plugin/public';
import { validateDuration, ValidateDurationOptions } from '../../../common/validate_duration';
import {
RULE_CCR_READ_EXCEPTIONS,
RULE_DETAILS,
@ -20,29 +19,9 @@ import {
LazyExpressionProps,
} from '../components/param_details_form/lazy_expression';
interface ValidateOptions extends RuleTypeParams {
duration: string;
}
const validate = (inputValues: ValidateOptions): ValidationResult => {
const validationResult = { errors: {} };
const errors: { [key: string]: string[] } = {
duration: [],
};
if (!inputValues.duration) {
errors.duration.push(
i18n.translate('xpack.monitoring.alerts.validation.duration', {
defaultMessage: 'A valid duration is required.',
})
);
}
validationResult.errors = errors;
return validationResult;
};
export function createCCRReadExceptionsAlertType(
config: MonitoringConfig
): RuleTypeModel<ValidateOptions> {
): RuleTypeModel<ValidateDurationOptions> {
return {
id: RULE_CCR_READ_EXCEPTIONS,
description: RULE_DETAILS[RULE_CCR_READ_EXCEPTIONS].description,
@ -57,7 +36,7 @@ export function createCCRReadExceptionsAlertType(
paramDetails={RULE_DETAILS[RULE_CCR_READ_EXCEPTIONS].paramDetails}
/>
),
validate,
validate: validateDuration,
defaultActionMessage: '{{context.internalFullMessage}}',
requiresAppContext: RULE_REQUIRES_APP_CONTEXT,
};

View file

@ -10,7 +10,7 @@ import { EuiForm, EuiFormRow, EuiSpacer } from '@elastic/eui';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { debounce } from 'lodash';
import { i18n } from '@kbn/i18n';
import { CommonAlertParamDetails } from '../../../../common/types/alerts';
import { CommonAlertParamDetails, ExpressionConfig } from '../../../../common/types/alerts';
import { AlertParamDuration } from '../../flyout_expressions/alert_param_duration';
import { AlertParamType } from '../../../../common/enums';
import { AlertParamPercentage } from '../../flyout_expressions/alert_param_percentage';
@ -31,6 +31,8 @@ export interface Props {
paramDetails: CommonAlertParamDetails;
dataViews: DataViewsPublicPluginStart;
config?: MonitoringConfig;
defaults?: Record<string, unknown>;
expressionConfig?: ExpressionConfig;
}
export const Expression: React.FC<Props> = (props) => {

View file

@ -12,10 +12,19 @@ import { useDerivedIndexPattern } from '../components/param_details_form/use_der
import { convertKueryToElasticSearchQuery } from '../../lib/kuery';
import { KueryBar } from '../../components/kuery_bar';
import { Props } from '../components/param_details_form/expression';
import { AlertParamDuration } from '../flyout_expressions/alert_param_duration';
const FILTER_TYPING_DEBOUNCE_MS = 500;
export const Expression = ({ ruleParams, config, setRuleParams, dataViews }: Props) => {
export const Expression = ({
ruleParams,
config,
setRuleParams,
dataViews,
errors,
defaults,
expressionConfig,
}: Props) => {
const { derivedIndexPattern } = useDerivedIndexPattern(dataViews, config);
const onFilterChange = useCallback(
(filter: string) => {
@ -45,8 +54,26 @@ export const Expression = ({ ruleParams, config, setRuleParams, dataViews }: Pro
<></>
);
const duration = expressionConfig?.showDuration ? (
<EuiFormRow>
<AlertParamDuration
key="duration"
name={'duration'}
duration={ruleParams.duration ?? defaults?.duration}
label={i18n.translate('xpack.monitoring.alerts.legacy.paramDetails.duration.label', {
defaultMessage: 'In the last',
})}
errors={errors.duration}
setRuleParams={setRuleParams}
/>
</EuiFormRow>
) : (
<></>
);
return (
<EuiForm component="form">
{duration}
<EuiFormRow
fullWidth
label={i18n.translate('xpack.monitoring.alerts.filterLable', {

View file

@ -15,8 +15,11 @@ import {
import type { MonitoringConfig } from '../../types';
import { LazyExpression, LazyExpressionProps } from './lazy_expression';
const DEFAULT_VALIDATE = () => ({ errors: {} });
export function createLegacyAlertTypes(config: MonitoringConfig): RuleTypeModel[] {
return LEGACY_RULES.map((legacyAlert) => {
const validate = LEGACY_RULE_DETAILS[legacyAlert].validate ?? DEFAULT_VALIDATE;
return {
id: legacyAlert,
description: LEGACY_RULE_DETAILS[legacyAlert].description,
@ -25,10 +28,15 @@ export function createLegacyAlertTypes(config: MonitoringConfig): RuleTypeModel[
return `${docLinks.links.monitoring.alertsKibanaClusterAlerts}`;
},
ruleParamsExpression: (props: LazyExpressionProps) => (
<LazyExpression {...props} config={config} />
<LazyExpression
{...props}
defaults={LEGACY_RULE_DETAILS[legacyAlert].defaults}
expressionConfig={LEGACY_RULE_DETAILS[legacyAlert].expressionConfig}
config={config}
/>
),
defaultActionMessage: '{{context.internalFullMessage}}',
validate: () => ({ errors: {} }),
validate,
requiresAppContext: RULE_REQUIRES_APP_CONTEXT,
};
});

View file

@ -61,7 +61,12 @@ export class ClusterHealthRule extends BaseRule {
esClient: ElasticsearchClient,
clusters: AlertCluster[]
): Promise<AlertData[]> {
const healths = await fetchClusterHealth(esClient, clusters, params.filterQuery);
const healths = await fetchClusterHealth(
esClient,
clusters,
params.filterQuery,
params.duration
);
return healths.map((clusterHealth) => {
const shouldFire = clusterHealth.health !== AlertClusterHealthType.Green;
const severity =

View file

@ -55,10 +55,15 @@ describe('fetchClusterHealth', () => {
});
it('should call ES with correct query', async () => {
const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser;
await fetchClusterHealth(esClient, [
{ clusterUuid: '1', clusterName: 'foo1' },
{ clusterUuid: '2', clusterName: 'foo2' },
]);
await fetchClusterHealth(
esClient,
[
{ clusterUuid: '1', clusterName: 'foo1' },
{ clusterUuid: '2', clusterName: 'foo2' },
],
undefined,
'1h'
);
expect(esClient.search).toHaveBeenCalledWith({
index:
'*:.monitoring-es-*,.monitoring-es-*,*:metrics-elasticsearch.stack_monitoring.cluster_stats-*,metrics-elasticsearch.stack_monitoring.cluster_stats-*',
@ -90,7 +95,7 @@ describe('fetchClusterHealth', () => {
minimum_should_match: 1,
},
},
{ range: { timestamp: { gte: 'now-2m' } } },
{ range: { timestamp: { gte: 'now-1h' } } },
],
},
},

View file

@ -15,7 +15,8 @@ import { CCS_REMOTE_PATTERN } from '../../../common/constants';
export async function fetchClusterHealth(
esClient: ElasticsearchClient,
clusters: AlertCluster[],
filterQuery?: string
filterQuery?: string,
duration: string = '2m'
): Promise<AlertClusterHealth[]> {
const indexPatterns = getIndexPatterns({
config: Globals.app.config,
@ -58,7 +59,7 @@ export async function fetchClusterHealth(
{
range: {
timestamp: {
gte: 'now-2m',
gte: `now-${duration}`,
},
},
},