mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Adds support for editing group details for Grafana managed rules (#53120)
This commit is contained in:
@@ -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 };
|
@@ -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)}
|
||||
|
@@ -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'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
@@ -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">
|
@@ -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
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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} />
|
||||
|
@@ -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 (
|
||||
|
@@ -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 };
|
||||
}
|
||||
|
||||
|
@@ -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>,
|
||||
|
Reference in New Issue
Block a user