Alerting: Show evaluation interval global limit warning (#52942)

Co-authored-by: George Robinson <george.robinson@grafana.com>
This commit is contained in:
Konrad Lalik
2022-08-01 15:01:14 +02:00
committed by GitHub
parent 314eb5223f
commit f877f79bbc
13 changed files with 140 additions and 20 deletions

View File

@@ -73,6 +73,10 @@ export interface GrafanaJavascriptAgentConfig {
apiKey: string; apiKey: string;
} }
export interface UnifiedAlertingConfig {
minInterval: string;
}
/** /**
* Describes the plugins that should be preloaded prior to start Grafana. * Describes the plugins that should be preloaded prior to start Grafana.
* *
@@ -201,6 +205,7 @@ export interface GrafanaConfig {
geomapDefaultBaseLayer?: MapLayerOptions; geomapDefaultBaseLayer?: MapLayerOptions;
geomapDisableCustomBaseLayer?: boolean; geomapDisableCustomBaseLayer?: boolean;
unifiedAlertingEnabled: boolean; unifiedAlertingEnabled: boolean;
unifiedAlerting: UnifiedAlertingConfig;
angularSupportEnabled: boolean; angularSupportEnabled: boolean;
feedbackLinksEnabled: boolean; feedbackLinksEnabled: boolean;
secretsManagerPluginEnabled: boolean; secretsManagerPluginEnabled: boolean;

View File

@@ -119,6 +119,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
geomapDefaultBaseLayerConfig?: MapLayerOptions; geomapDefaultBaseLayerConfig?: MapLayerOptions;
geomapDisableCustomBaseLayer?: boolean; geomapDisableCustomBaseLayer?: boolean;
unifiedAlertingEnabled = false; unifiedAlertingEnabled = false;
unifiedAlerting = { minInterval: '' };
applicationInsightsConnectionString?: string; applicationInsightsConnectionString?: string;
applicationInsightsEndpointUrl?: string; applicationInsightsEndpointUrl?: string;
recordedQueries = { recordedQueries = {

View File

@@ -166,6 +166,7 @@ export const getAvailableIcons = () =>
'square-shape', 'square-shape',
'star', 'star',
'step-backward', 'step-backward',
'stopwatch-slash',
'sync', 'sync',
'table', 'table',
'tag-alt', 'tag-alt',

View File

@@ -179,6 +179,9 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
"enabled": hs.Cfg.SectionWithEnvOverrides("reporting").Key("enabled").MustBool(true), "enabled": hs.Cfg.SectionWithEnvOverrides("reporting").Key("enabled").MustBool(true),
}, },
"unifiedAlertingEnabled": hs.Cfg.UnifiedAlerting.Enabled, "unifiedAlertingEnabled": hs.Cfg.UnifiedAlerting.Enabled,
"unifiedAlerting": map[string]interface{}{
"minInterval": hs.Cfg.UnifiedAlerting.MinInterval.String(),
},
} }
if hs.ThumbService != nil { if hs.ThumbService != nil {

View File

@@ -27,14 +27,15 @@ import {
mockPromRuleNamespace, mockPromRuleNamespace,
mockRulerGrafanaRule, mockRulerGrafanaRule,
} from './mocks'; } from './mocks';
import { getAllDataSources } from './utils/config'; import * as config from './utils/config';
import { Annotation } from './utils/constants'; import { Annotation } from './utils/constants';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import * as ruleFormUtils from './utils/rule-form'; import * as ruleFormUtils from './utils/rule-form';
jest.mock('./api/prometheus'); jest.mock('./api/prometheus');
jest.mock('./api/ruler'); jest.mock('./api/ruler');
jest.mock('./utils/config');
jest.spyOn(config, 'getAllDataSources');
const dataSources = { const dataSources = {
prometheus: mockDataSource<PromOptions>({ prometheus: mockDataSource<PromOptions>({
@@ -52,7 +53,7 @@ dataSources.prometheus.meta.alerting = true;
dataSources.default.meta.alerting = true; dataSources.default.meta.alerting = true;
const mocks = { const mocks = {
getAllDataSources: jest.mocked(getAllDataSources), getAllDataSources: jest.mocked(config.getAllDataSources),
api: { api: {
fetchRules: jest.mocked(fetchRules), fetchRules: jest.mocked(fetchRules),
fetchRulerRules: jest.mocked(fetchRulerRules), fetchRulerRules: jest.mocked(fetchRulerRules),

View File

@@ -22,7 +22,7 @@ import { discoverFeatures } from './api/buildInfo';
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { disableRBAC, mockDataSource, MockDataSourceSrv, mockFolder } from './mocks'; import { disableRBAC, mockDataSource, MockDataSourceSrv, mockFolder } from './mocks';
import { getAllDataSources } from './utils/config'; import * as config from './utils/config';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { getDefaultQueries } from './utils/rule-form'; import { getDefaultQueries } from './utils/rule-form';
@@ -35,7 +35,6 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
jest.mock('./api/buildInfo'); jest.mock('./api/buildInfo');
jest.mock('./api/ruler'); jest.mock('./api/ruler');
jest.mock('./utils/config');
jest.mock('../../../../app/features/manage-dashboards/state/actions'); jest.mock('../../../../app/features/manage-dashboards/state/actions');
// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. // there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
@@ -45,8 +44,10 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({
QueryEditorRow: () => <p>hi</p>, QueryEditorRow: () => <p>hi</p>,
})); }));
jest.spyOn(config, 'getAllDataSources');
const mocks = { const mocks = {
getAllDataSources: jest.mocked(getAllDataSources), getAllDataSources: jest.mocked(config.getAllDataSources),
searchFolders: jest.mocked(searchFolders), searchFolders: jest.mocked(searchFolders),
api: { api: {
discoverFeatures: jest.mocked(discoverFeatures), discoverFeatures: jest.mocked(discoverFeatures),

View File

@@ -30,13 +30,12 @@ import {
somePromRules, somePromRules,
someRulerRules, someRulerRules,
} from './mocks'; } from './mocks';
import { getAllDataSources } from './utils/config'; import * as config from './utils/config';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
jest.mock('./api/buildInfo'); jest.mock('./api/buildInfo');
jest.mock('./api/prometheus'); jest.mock('./api/prometheus');
jest.mock('./api/ruler'); jest.mock('./api/ruler');
jest.mock('./utils/config');
jest.mock('app/core/core', () => ({ jest.mock('app/core/core', () => ({
appEvents: { appEvents: {
subscribe: () => { subscribe: () => {
@@ -46,8 +45,10 @@ jest.mock('app/core/core', () => ({
}, },
})); }));
jest.spyOn(config, 'getAllDataSources');
const mocks = { const mocks = {
getAllDataSourcesMock: jest.mocked(getAllDataSources), getAllDataSourcesMock: jest.mocked(config.getAllDataSources),
api: { api: {
discoverFeatures: jest.mocked(discoverFeatures), discoverFeatures: jest.mocked(discoverFeatures),

View File

@@ -1,12 +1,18 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React, { FC, useState } from 'react'; import React, { FC, useState } from 'react';
import { useFormContext, RegisterOptions } from 'react-hook-form'; import { RegisterOptions, useFormContext } from 'react-hook-form';
import { parseDuration, durationToMilliseconds, GrafanaTheme2 } from '@grafana/data'; import { durationToMilliseconds, GrafanaTheme2, parseDuration } from '@grafana/data';
import { Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui'; import { config } from '@grafana/runtime';
import { Alert, Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui';
import { RuleFormValues } from '../../types/rule-form'; import { RuleFormValues } from '../../types/rule-form';
import { positiveDurationValidationPattern, durationValidationPattern } from '../../utils/time'; import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
import {
durationValidationPattern,
parseDurationToMilliseconds,
positiveDurationValidationPattern,
} from '../../utils/time';
import { CollapseToggle } from '../CollapseToggle'; import { CollapseToggle } from '../CollapseToggle';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker'; import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
@@ -22,10 +28,13 @@ const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({
}, },
pattern: durationValidationPattern, pattern: durationValidationPattern,
validate: (value) => { validate: (value) => {
const evaluateEveryDuration = parseDuration(evaluateEvery); const millisFor = parseDurationToMilliseconds(value);
const forDuration = parseDuration(value); const millisEvery = parseDurationToMilliseconds(evaluateEvery);
const millisFor = durationToMilliseconds(forDuration);
const millisEvery = durationToMilliseconds(evaluateEveryDuration); // 0 is a special value meaning for equals evaluation interval
if (millisFor === 0) {
return true;
}
return millisFor >= millisEvery ? true : 'For must be greater than or equal to evaluate every.'; return millisFor >= millisEvery ? true : 'For must be greater than or equal to evaluate every.';
}, },
@@ -61,6 +70,8 @@ export const GrafanaEvaluationBehavior: FC = () => {
watch, watch,
} = useFormContext<RuleFormValues>(); } = useFormContext<RuleFormValues>();
const { exceedsLimit: exceedsGlobalEvaluationLimit } = checkEvaluationIntervalGlobalLimit(watch('evaluateEvery'));
const evaluateEveryId = 'eval-every-input'; const evaluateEveryId = 'eval-every-input';
const evaluateForId = 'eval-for-input'; const evaluateForId = 'eval-for-input';
@@ -79,7 +90,15 @@ export const GrafanaEvaluationBehavior: FC = () => {
> >
Evaluate every Evaluate every
</InlineLabel> </InlineLabel>
<Input id={evaluateEveryId} width={8} {...register('evaluateEvery', evaluateEveryValidationOptions)} /> <Field
className={styles.inlineField}
error={errors.evaluateEvery?.message}
invalid={!!errors.evaluateEvery}
validationMessageHorizontalOverflow={true}
>
<Input id={evaluateEveryId} width={8} {...register('evaluateEvery', evaluateEveryValidationOptions)} />
</Field>
<InlineLabel <InlineLabel
htmlFor={evaluateForId} htmlFor={evaluateForId}
width={7} width={7}
@@ -101,6 +120,14 @@ export const GrafanaEvaluationBehavior: FC = () => {
</Field> </Field>
</div> </div>
</Field> </Field>
{exceedsGlobalEvaluationLimit && (
<Alert severity="warning" title="Global evalutation interval limit exceeded">
A minimum evaluation interval of{' '}
<span className={styles.globalLimitValue}>{config.unifiedAlerting.minInterval}</span> has been configured in
Grafana. <br />
Please contact the administrator to configure a lower interval.
</Alert>
)}
<CollapseToggle <CollapseToggle
isCollapsed={!showErrorHandling} isCollapsed={!showErrorHandling}
onToggle={(collapsed) => setShowErrorHandling(!collapsed)} onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
@@ -159,4 +186,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
collapseToggle: css` collapseToggle: css`
margin: ${theme.spacing(2, 0, 2, -1)}; margin: ${theme.spacing(2, 0, 2, -1)};
`, `,
globalLimitValue: css`
font-weight: ${theme.typography.fontWeightBold};
`,
}); });

View File

@@ -0,0 +1,49 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data/src';
import { config } from '@grafana/runtime/src';
import { Icon, Tooltip, useStyles2 } from '@grafana/ui/src';
import { CombinedRule } from '../../../../../types/unified-alerting';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
interface RuleConfigStatusProps {
rule: CombinedRule;
}
export function RuleConfigStatus({ rule }: RuleConfigStatusProps) {
const styles = useStyles2(getStyles);
const { exceedsLimit } = checkEvaluationIntervalGlobalLimit(rule.group.interval ?? '');
if (!exceedsLimit) {
return null;
}
return (
<Tooltip
theme="error"
content={
<div>
A minimum evaluation interval of{' '}
<span className={styles.globalLimitValue}>{config.unifiedAlerting.minInterval}</span> has been configured in
Grafana and will be used instead of the {rule.group.interval} interval configured for the Rule Group.
</div>
}
>
<Icon name="stopwatch-slash" className={styles.icon} />
</Tooltip>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
globalLimitValue: css`
font-weight: ${theme.typography.fontWeightBold};
`,
icon: css`
fill: ${theme.colors.warning.text};
`,
};
}

View File

@@ -11,6 +11,7 @@ interface Prom {
export const RuleHealth: FC<Prom> = ({ rule }) => { export const RuleHealth: FC<Prom> = ({ rule }) => {
const style = useStyles2(getStyle); const style = useStyles2(getStyle);
if (rule.health === 'err' || rule.health === 'error') { if (rule.health === 'err' || rule.health === 'error') {
return ( return (
<Tooltip theme="error" content={rule.lastError || 'No error message provided.'}> <Tooltip theme="error" content={rule.lastError || 'No error message provided.'}>
@@ -21,6 +22,7 @@ export const RuleHealth: FC<Prom> = ({ rule }) => {
</Tooltip> </Tooltip>
); );
} }
return <>{rule.health}</>; return <>{rule.health}</>;
}; };
@@ -29,6 +31,7 @@ const getStyle = (theme: GrafanaTheme2) => ({
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
color: ${theme.colors.warning.text}; color: ${theme.colors.warning.text};
& > * + * { & > * + * {
margin-left: ${theme.spacing(1)}; margin-left: ${theme.spacing(1)};
} }

View File

@@ -15,6 +15,7 @@ import { ProvisioningBadge } from '../Provisioning';
import { RuleLocation } from '../RuleLocation'; import { RuleLocation } from '../RuleLocation';
import { Tokenize } from '../Tokenize'; import { Tokenize } from '../Tokenize';
import { RuleConfigStatus } from './RuleConfigStatus';
import { RuleDetails } from './RuleDetails'; import { RuleDetails } from './RuleDetails';
import { RuleHealth } from './RuleHealth'; import { RuleHealth } from './RuleHealth';
import { RuleState } from './RuleState'; import { RuleState } from './RuleState';
@@ -148,11 +149,17 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) {
}, },
size: '100px', size: '100px',
}, },
{
id: 'warnings',
label: '',
renderCell: ({ data: combinedRule }) => <RuleConfigStatus rule={combinedRule} />,
size: '45px',
},
{ {
id: 'health', id: 'health',
label: 'Health', label: 'Health',
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
renderCell: ({ data: { promRule } }) => (promRule ? <RuleHealth rule={promRule} /> : null), renderCell: ({ data: { promRule, group } }) => (promRule ? <RuleHealth rule={promRule} /> : null),
size: '75px', size: '75px',
}, },
]; ];

View File

@@ -1,6 +1,19 @@
import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; import { DataSourceInstanceSettings, DataSourceJsonData, isValidGoDuration, rangeUtil } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> { export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> {
return Object.values(config.datasources); return Object.values(config.datasources);
} }
export function checkEvaluationIntervalGlobalLimit(alertGroupEvaluateEvery: string) {
if (!isValidGoDuration(config.unifiedAlerting.minInterval)) {
return { globalLimit: 0, exceedsLimit: false };
}
const evaluateEveryMs = rangeUtil.intervalToMs(alertGroupEvaluateEvery);
const evaluateEveryGlobalLimitMs = rangeUtil.intervalToMs(config.unifiedAlerting.minInterval);
const exceedsLimit = evaluateEveryGlobalLimitMs > evaluateEveryMs && evaluateEveryMs > 0;
return { globalLimit: evaluateEveryGlobalLimitMs, exceedsLimit };
}

View File

@@ -1,3 +1,4 @@
import { durationToMilliseconds, parseDuration } from '@grafana/data';
import { describeInterval } from '@grafana/data/src/datetime/rangeutil'; import { describeInterval } from '@grafana/data/src/datetime/rangeutil';
import { TimeOptions } from '../types/time'; import { TimeOptions } from '../types/time';
@@ -35,3 +36,7 @@ export const durationValidationPattern = {
TimeOptions TimeOptions
).join(', ')}`, ).join(', ')}`,
}; };
export function parseDurationToMilliseconds(duration: string) {
return durationToMilliseconds(parseDuration(duration));
}