Alerting: Adds support for editing group details for Grafana managed rules (#53120)

This commit is contained in:
Gilles De Mey
2022-08-04 17:39:28 +02:00
committed by GitHub
parent c65b4c732f
commit ca2b97b095
10 changed files with 104 additions and 39 deletions

View File

@@ -0,0 +1,15 @@
import React, { FC } from 'react';
import { config } from '@grafana/runtime';
import { Alert } from '@grafana/ui';
const EvaluationIntervalLimitExceeded: FC = () => (
<Alert severity="warning" title="Global evalutation interval limit exceeded">
A minimum evaluation interval of <strong>{config.unifiedAlerting.minInterval}</strong> has been configured in
Grafana.
<br />
Please contact the administrator to configure a lower interval.
</Alert>
);
export { EvaluationIntervalLimitExceeded };

View File

@@ -3,8 +3,7 @@ import React, { FC, useState } from 'react';
import { RegisterOptions, useFormContext } from 'react-hook-form';
import { durationToMilliseconds, GrafanaTheme2, parseDuration } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Alert, Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui';
import { Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui';
import { RuleFormValues } from '../../types/rule-form';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
@@ -14,6 +13,7 @@ import {
positiveDurationValidationPattern,
} from '../../utils/time';
import { CollapseToggle } from '../CollapseToggle';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
import { PreviewRule } from './PreviewRule';
@@ -120,14 +120,7 @@ export const GrafanaEvaluationBehavior: FC = () => {
</Field>
</div>
</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>
)}
{exceedsGlobalEvaluationLimit && <EvaluationIntervalLimitExceeded />}
<CollapseToggle
isCollapsed={!showErrorHandling}
onToggle={(collapsed) => setShowErrorHandling(!collapsed)}

View File

@@ -60,6 +60,7 @@ export const CloudRules: FC<Props> = ({ namespaces, expandAll }) => {
key={`${getRulesSourceUid(namespace.rulesSource)}-${namespace.name}-${group.name}`}
namespace={namespace}
expandAll={expandAll}
viewMode={'grouped'}
/>
);
})}

View File

@@ -2,15 +2,18 @@ import { css } from '@emotion/css';
import React, { useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { isValidGoDuration } from '@grafana/data';
import { Modal, Button, Form, Field, Input, useStyles2 } from '@grafana/ui';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { updateLotexNamespaceAndGroupAction } from '../../state/actions';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
import { getRulesSourceName } from '../../utils/datasource';
import { initialAsyncRequestState } from '../../utils/redux';
import { durationValidationPattern } from '../../utils/time';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
interface Props {
namespace: CombinedRuleNamespace;
@@ -71,7 +74,7 @@ export function EditCloudGroupModal(props: Props): React.ReactElement {
onClickBackdrop={onClose}
>
<Form defaultValues={defaultValues} onSubmit={onSubmit} key={JSON.stringify(defaultValues)}>
{({ register, errors, formState: { isDirty } }) => (
{({ register, errors, formState: { isDirty }, watch }) => (
<>
<Field label="Namespace" invalid={!!errors.namespaceName} error={errors.namespaceName?.message}>
<Input
@@ -99,9 +102,25 @@ export function EditCloudGroupModal(props: Props): React.ReactElement {
placeholder="1m"
{...register('groupInterval', {
pattern: durationValidationPattern,
validate: (input) => {
const validDuration = isValidGoDuration(input);
if (!validDuration) {
return 'Invalid duration. Valid example: 1m (Available units: h, m, s)';
}
const limitExceeded = !checkEvaluationIntervalGlobalLimit(input).exceedsLimit;
if (limitExceeded) {
return true;
}
return false;
},
})}
/>
</Field>
{checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && (
<EvaluationIntervalLimitExceeded />
)}
<Modal.ButtonRow>
<Button variant="secondary" type="button" disabled={loading} onClick={onClose} fill="outline">

View File

@@ -49,7 +49,13 @@ export const GrafanaRules: FC<Props> = ({ namespaces, expandAll }) => {
</div>
{pageItems.map(({ group, namespace }) => (
<RulesGroup group={group} key={`${namespace.name}-${group.name}`} namespace={namespace} expandAll={expandAll} />
<RulesGroup
group={group}
key={`${namespace.name}-${group.name}`}
namespace={namespace}
expandAll={expandAll}
viewMode={wantsGroupedView ? 'grouped' : 'list'}
/>
))}
{namespacesFormat?.length === 0 && <p>No rules found.</p>}
<Pagination

View File

@@ -45,7 +45,7 @@ describe('Rules group tests', () => {
function renderRulesGroup(namespace: CombinedRuleNamespace, group: CombinedRuleGroup) {
return render(
<Provider store={store}>
<RulesGroup group={group} namespace={namespace} expandAll={false} />
<RulesGroup group={group} namespace={namespace} expandAll={false} viewMode={'grouped'} />
</Provider>
);
}

View File

@@ -4,9 +4,7 @@ import React, { FC, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui';
import kbn from 'app/core/utils/kbn';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import { useFolder } from '../../hooks/useFolder';
@@ -14,22 +12,26 @@ import { useHasRuler } from '../../hooks/useHasRuler';
import { deleteRulesGroupAction } from '../../state/actions';
import { useRulesAccess } from '../../utils/accessControlHooks';
import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
import { makeFolderLink } from '../../utils/misc';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { CollapseToggle } from '../CollapseToggle';
import { RuleLocation } from '../RuleLocation';
import { ActionIcon } from './ActionIcon';
import { EditCloudGroupModal } from './EditCloudGroupModal';
import { EditCloudGroupModal } from './EditRuleGroupModal';
import { RuleStats } from './RuleStats';
import { RulesTable } from './RulesTable';
type ViewMode = 'grouped' | 'list';
interface Props {
namespace: CombinedRuleNamespace;
group: CombinedRuleGroup;
expandAll: boolean;
viewMode: ViewMode;
}
export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll }) => {
export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll, viewMode }) => {
const { rulesSource } = namespace;
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
@@ -54,6 +56,15 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll }
hasRuler(rulesSource) && rulerRulesLoaded(rulesSource) && !group.rules.find((rule) => !!rule.rulerRule);
const isFederated = isFederatedRuleGroup(group);
// check if group has provisioned items
const isProvisioned = group.rules.some((rule) => {
return isGrafanaRulerRule(rule.rulerRule) && rule.rulerRule.grafana_alert.provenance;
});
// check what view mode we are in
const isListView = viewMode === 'list';
const isGroupView = viewMode === 'grouped';
const deleteGroup = () => {
dispatch(deleteRulesGroupAction(namespace, group));
setIsDeletingGroup(false);
@@ -71,20 +82,34 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll }
);
} else if (rulesSource === GRAFANA_RULES_SOURCE_NAME) {
if (folderUID) {
const baseUrl = `${config.appSubUrl}/dashboards/f/${folderUID}/${kbn.slugifyForUrl(namespace.name)}`;
const baseUrl = makeFolderLink(folderUID);
if (folder?.canSave) {
actionIcons.push(
<ActionIcon
aria-label="edit folder"
key="edit"
icon="pen"
tooltip="edit folder"
to={baseUrl + '/settings'}
target="__blank"
/>
);
if (isGroupView && !isProvisioned) {
actionIcons.push(
<ActionIcon
aria-label="edit rule group"
data-testid="edit-group"
key="edit"
icon="pen"
tooltip="edit rule group"
onClick={() => setIsEditingGroup(true)}
/>
);
}
if (isListView) {
actionIcons.push(
<ActionIcon
aria-label="go to folder"
key="goto"
icon="folder-open"
tooltip="go to folder"
to={baseUrl}
target="__blank"
/>
);
}
}
if (folder?.canAdmin) {
if (folder?.canAdmin && isListView) {
actionIcons.push(
<ActionIcon
aria-label="manage permissions"
@@ -124,8 +149,7 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll }
}
// ungrouped rules are rules that are in the "default" group name
const isUngrouped = group.name === 'default';
const groupName = isUngrouped ? (
const groupName = isListView ? (
<RuleLocation namespace={namespace.name} />
) : (
<RuleLocation namespace={namespace.name} group={group.name} />

View File

@@ -677,24 +677,27 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk(
withSerializedError(
(async () => {
const { rulesSourceName, namespaceName, groupName, newNamespaceName, newGroupName, groupInterval } = options;
if (options.rulesSourceName === GRAFANA_RULES_SOURCE_NAME) {
throw new Error(`this action does not support Grafana rules`);
}
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, rulesSourceName);
// fetch rules and perform sanity checks
const rulesResult = await fetchRulerRules(rulerConfig);
if (!rulesResult[namespaceName]) {
const existingNamespace = Boolean(rulesResult[namespaceName]);
if (!existingNamespace) {
throw new Error(`Namespace "${namespaceName}" not found.`);
}
const existingGroup = rulesResult[namespaceName].find((group) => group.name === groupName);
if (!existingGroup) {
throw new Error(`Group "${groupName}" not found.`);
}
if (newGroupName !== groupName && !!rulesResult[namespaceName].find((group) => group.name === newGroupName)) {
throw new Error(`Group "${newGroupName}" already exists.`);
const newGroupAlreadyExists = Boolean(
rulesResult[namespaceName].find((group) => group.name === newGroupName)
);
if (newGroupName !== groupName && newGroupAlreadyExists) {
throw new Error(`Group "${newGroupName}" already exists in namespace "${namespaceName}".`);
}
if (newNamespaceName !== namespaceName && !!rulesResult[newNamespaceName]) {
const newNamespaceAlreadyExists = Boolean(rulesResult[newNamespaceName]);
if (newNamespaceName !== namespaceName && newNamespaceAlreadyExists) {
throw new Error(`Namespace "${newNamespaceName}" already exists.`);
}
if (

View File

@@ -6,7 +6,7 @@ export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSource
}
export function checkEvaluationIntervalGlobalLimit(alertGroupEvaluateEvery?: string) {
if (!alertGroupEvaluateEvery) {
if (!alertGroupEvaluateEvery || !isValidGoDuration(alertGroupEvaluateEvery)) {
return { globalLimit: 0, exceedsLimit: false };
}

View File

@@ -102,6 +102,10 @@ export function makeDataSourceLink<T>(dataSource: DataSourceInstanceSettings<T>)
return `${config.appSubUrl}/datasources/edit/${dataSource.uid}`;
}
export function makeFolderLink(folderUID: string): string {
return `${config.appSubUrl}/dashboards/f/${folderUID}`;
}
// keep retrying fn if it's error passes shouldRetry(error) and timeout has not elapsed yet
export function retryWhile<T, E = Error>(
fn: () => Promise<T>,