Alerting: Rework rule editor layout (#103744)

This commit is contained in:
Gilles De Mey
2025-04-29 15:45:45 +02:00
committed by GitHub
parent 8f922bf76d
commit 97a1614cde
20 changed files with 506 additions and 318 deletions
+115
View File
@@ -1020,7 +1020,122 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx:5381": [
<<<<<<< HEAD
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],
"public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SmartAlertTypeDetector.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/alerting/unified/components/rule-editor/rule-types/GrafanaManagedAlert.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-editor/rule-types/MimirOrLokiAlert.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-editor/rule-types/MimirOrLokiRecordingRule.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-viewer/PausedBadge.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rules/AlertStateTag.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rules/CloneRule.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/rules/RuleListErrors.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
],
"public/app/features/alerting/unified/components/rules/RuleListStateSection.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rules/RuleState.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rules/RuleStats.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rules/RulesGroup.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rules/state-history/LokiStateHistory.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rules/state-history/StateHistory.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/settings/VersionManager.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/silences/SilencedAlertsTableRow.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
=======
[0, 0, 0, "Do not use any type assertions.", "0"]
>>>>>>> origin/main
],
"public/app/features/alerting/unified/components/silences/SilencesEditor.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
@@ -78,7 +78,7 @@ describe('RuleEditor grafana recording rules', () => {
await user.type(await ui.inputs.metric.find(), 'metricName');
await selectFolderAndGroup(user);
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
@@ -96,7 +96,7 @@ describe('RuleEditor grafana recording rules', () => {
await user.type(await ui.inputs.name.find(), 'my great new rule');
await selectFolderAndGroup(user);
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
const requests = await capture;
expect(requests).toHaveLength(0);
});
@@ -43,9 +43,13 @@ export function DurationQuickPick({ selectedDuration, groupEvaluationInterval, o
onSelect(duration);
}}
>
{duration === '0s' ? t('alerting.duration-quick-pick.none', 'None') : duration}
{stringifyPendingPeriod(duration)}
</Button>
))}
</Stack>
);
}
export function stringifyPendingPeriod(duration: string): string {
return duration === '0s' ? t('alerting.duration-quick-pick.none', 'None') : duration;
}
@@ -5,8 +5,7 @@ import { useParams } from 'react-router-dom-v5-compat';
import { GrafanaTheme2 } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { Alert, Button, ConfirmModal, Spinner, Stack, useStyles2 } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { Alert, Button, Spinner, Stack, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/core';
import { Trans, t } from 'app/core/internationalization';
@@ -22,7 +21,7 @@ import {
rulerRuleType,
} from 'app/features/alerting/unified/utils/rules';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { RuleGroupIdentifier, RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting';
import { RuleGroupIdentifier, RuleWithLocation } from 'app/types/unified-alerting';
import { PostableRuleGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
import {
@@ -41,10 +40,7 @@ import {
RulerGroupUpdatedResponse,
isGrafanaGroupUpdatedResponse,
} from '../../../api/alertRuleModel';
import { shouldUseAlertingListViewV2, shouldUsePrometheusRulesPrimary } from '../../../featureToggles';
import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup';
import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup';
import { useReturnTo } from '../../../hooks/useReturnTo';
import {
defaultFormValuesForRuleType,
formValuesFromExistingRule,
@@ -63,9 +59,7 @@ import {
formValuesToRulerGrafanaRuleDTO,
formValuesToRulerRuleDTO,
} from '../../../utils/rule-form';
import * as ruleId from '../../../utils/rule-id';
import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier, stringifyIdentifier } from '../../../utils/rule-id';
import { createRelativeUrl } from '../../../utils/url';
import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier } from '../../../utils/rule-id';
import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter';
import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
import AnnotationsStep from '../AnnotationsStep';
@@ -83,27 +77,21 @@ type Props = {
isManualRestore?: boolean;
};
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
const alertingListViewV2 = shouldUseAlertingListViewV2();
export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => {
const styles = useStyles2(getStyles);
const notifyApp = useAppNotification();
const { redirectToDetailsPage } = useRedirectToDetailsPage();
const routeParams = useParams<{ type: string; id: string }>();
const uidFromParams = routeParams.id;
const { redirectToDetailsPage } = useRedirectToDetailsPage(uidFromParams);
const [showEditYaml, setShowEditYaml] = useState(false);
const [deleteRuleFromGroup] = useDeleteRuleFromGroup();
const [addRuleToRuleGroup] = useAddRuleToRuleGroup();
const [updateRuleInRuleGroup] = useUpdateRuleInRuleGroup();
const { returnTo } = useReturnTo();
const routeParams = useParams<{ type: string; id: string }>();
const ruleType = translateRouteParamToRuleType(routeParams.type);
const uidFromParams = routeParams.id || '';
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
const defaultValues: RuleFormValues = useMemo(() => {
// If we have an existing AND a prefill, then we're coming from the restore dialog
// and we want to merge the two
@@ -157,7 +145,7 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
};
// @todo why is error not propagated to form?
const submit = async (values: RuleFormValues, exitOnSave: boolean) => {
const submit = async (values: RuleFormValues): Promise<void> => {
const { type, evaluateEvery } = values;
if (conditionErrorMsg !== '') {
@@ -208,41 +196,8 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
);
}
const { dataSourceName, namespaceName, groupName } = targetRuleGroupIdentifier;
// V2 list is based on eventually consistent Prometheus API.
// When a new rule group is created it takes a while for the new rule group to be reflected in the V2 list.
// To avoid user confusion we redirect to the details page which is driven by a strongly consistent Ruler API..
if (alertingListViewV2) {
redirectToDetailsPage(ruleDefinition, targetRuleGroupIdentifier, saveResult);
return;
}
if (exitOnSave) {
const returnToUrl = returnTo || getReturnToUrl(targetRuleGroupIdentifier, ruleDefinition);
locationService.push(returnToUrl);
return;
} else {
// we stay in the same page
// Cloud Ruler rules identifier changes on update due to containing rule name and hash components
// After successful update we need to update the URL to avoid displaying 404 errors
if (rulerRuleType.dataSource.rule(ruleDefinition)) {
const updatedRuleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, ruleDefinition);
locationService.replace(`/alerting/${encodeURIComponent(stringifyIdentifier(updatedRuleIdentifier))}/edit`);
}
}
};
const deleteRule = async () => {
if (existing) {
const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(existing);
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule);
await deleteRuleFromGroup.execute(ruleGroupIdentifier, ruleIdentifier);
locationService.replace(returnTo ?? '/alerting/list');
}
redirectToDetailsPage(ruleDefinition, targetRuleGroupIdentifier, saveResult);
return;
};
const onInvalid: SubmitErrorHandler<RuleFormValues> = (errors): void => {
@@ -266,49 +221,14 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
locationService.getHistory().goBack();
};
const actionButtons = (
<Stack justifyContent="flex-end" alignItems="center">
<Button
data-testid="save-rule-and-exit"
variant="primary"
type="button"
size="sm"
onClick={handleSubmit((values) => submit(values, true), onInvalid)}
disabled={isSubmitting}
>
{isSubmitting && <Spinner className={styles.buttonSpinner} inline={true} />}
<Trans i18nKey="alerting.alert-rule-form.action-buttons.save-exit">Save rule and exit</Trans>
</Button>
<Button variant="secondary" disabled={isSubmitting} type="button" onClick={cancelRuleCreation} size="sm">
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
</Button>
{existing ? (
<Button fill="outline" variant="destructive" type="button" onClick={() => setShowDeleteModal(true)} size="sm">
<Trans i18nKey="alerting.alert-rule-form.action-buttons.delete">Delete</Trans>
</Button>
) : null}
{existing && isCortexLokiOrRecordingRule(watch) && (
<Button
variant="secondary"
type="button"
onClick={() => setShowEditYaml(true)}
disabled={isSubmitting}
size="sm"
>
<Trans i18nKey="alerting.alert-rule-form.action-buttons.edit-yaml">Edit YAML</Trans>
</Button>
)}
</Stack>
);
const isPaused = rulerRuleType.grafana.alertingRule(existing?.rule) && isPausedRule(existing?.rule);
if (!type) {
return null;
}
const isPaused = rulerRuleType.grafana.rule(existing?.rule) && isPausedRule(existing?.rule);
return (
<FormProvider {...formAPI}>
<AppChromeUpdate actions={actionButtons} />
<form onSubmit={(e) => e.preventDefault()} className={styles.form}>
<div className={styles.contentOuter}>
{isManualRestore && (
@@ -348,39 +268,55 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
{!isRecordingRuleByType(type) && <AnnotationsStep />}
</>
)}
{/* actions */}
<Stack direction="row" alignItems="center">
<Button
data-testid="save-rule"
variant="primary"
type="button"
onClick={handleSubmit((values) => submit(values), onInvalid)}
disabled={isSubmitting}
icon={isSubmitting ? 'spinner' : undefined}
>
{isSubmitting && <Spinner className={styles.buttonSpinner} inline={true} />}
<Trans i18nKey="alerting.alert-rule-form.action-buttons.save">Save</Trans>
</Button>
<Button variant="secondary" disabled={isSubmitting} type="button" onClick={cancelRuleCreation}>
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
</Button>
{existing && isCortexLokiOrRecordingRule(watch) && (
<Button variant="secondary" type="button" onClick={() => setShowEditYaml(true)} disabled={isSubmitting}>
<Trans i18nKey="alerting.alert-rule-form.action-buttons.edit-yaml">Edit YAML</Trans>
</Button>
)}
</Stack>
</Stack>
</div>
</form>
{showDeleteModal ? (
<ConfirmModal
isOpen={true}
title={t('alerting.alert-rule-form.title-delete-rule', 'Delete rule')}
body="Deleting this rule will permanently remove it. Are you sure you want to delete this rule?"
confirmText="Yes, delete"
icon="exclamation-triangle"
onConfirm={deleteRule}
onDismiss={() => setShowDeleteModal(false)}
/>
) : null}
{showEditYaml && (
<>
{isGrafanaManagedRuleByType(type) && (
{grafanaTypeRule && uidFromParams && (
<GrafanaRuleExporter alertUid={uidFromParams} onClose={() => setShowEditYaml(false)} />
)}
{!isGrafanaManagedRuleByType(type) && <RuleInspector onClose={() => setShowEditYaml(false)} />}
{!grafanaTypeRule && <RuleInspector onClose={() => setShowEditYaml(false)} />}
</>
)}
</FormProvider>
);
};
function useRedirectToDetailsPage() {
function useRedirectToDetailsPage(existingUid?: string) {
const notifyApp = useAppNotification();
const redirectGrafanaRule = useCallback(
(saveResult: GrafanaGroupUpdatedResponse) => {
const newOrUpdatedRuleUid = saveResult.created?.at(0) || saveResult.updated?.at(0);
// if the response contains no created or updated rules, we'll use the existing UID.
const newOrUpdatedRuleUid = (saveResult.created?.at(0) || saveResult.updated?.at(0)) ?? existingUid;
if (newOrUpdatedRuleUid) {
locationService.replace(
rulesNav.detailsPageLink('grafana', { uid: newOrUpdatedRuleUid, ruleSourceName: 'grafana' })
@@ -393,7 +329,7 @@ function useRedirectToDetailsPage() {
logWarning('Cannot navigate to the new rule details page. The rule was created but the UID is missing.');
}
},
[notifyApp]
[existingUid, notifyApp]
);
const redirectCloudRulerRule = useCallback((rule: RulerRuleDTO, groupId: RuleGroupIdentifier) => {
@@ -427,27 +363,6 @@ function useRedirectToDetailsPage() {
return { redirectToDetailsPage };
}
function getReturnToUrl(groupId: RuleGroupIdentifier, rule: RulerRuleDTO | PostableRuleGrafanaRuleDTO) {
const { dataSourceName, namespaceName, groupName } = groupId;
if (prometheusRulesPrimary && rulerRuleType.dataSource.rule(rule)) {
const ruleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, rule);
return createViewLinkFromIdentifier(ruleIdentifier);
}
// TODO We could add namespace and group filters but for GMA the namespace = uid which doesn't work with the filters
return '/alerting/list';
}
// The result of this function is passed to locationService.push()
// Hence it cannot contain the subpath prefix, so we cannot use createRelativeUrl for it
function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) {
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
const paramSource = encodeURIComponent(identifier.ruleSourceName);
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
}
const isCortexLokiOrRecordingRule = (watch: UseFormWatch<RuleFormValues>) => {
const [ruleType, dataSourceName] = watch(['type', 'dataSourceName']);
@@ -6,7 +6,6 @@ import { Button, LinkButton, LoadingPlaceholder, Stack } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { Trans, t } from 'app/core/internationalization';
import { AppChromeUpdate } from '../../../../../../core/components/AppChrome/AppChromeUpdate';
import {
PostableRulerRuleGroupDTO,
RulerRuleDTO,
@@ -82,38 +81,38 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
setExportData(undefined);
}, [setExportData]);
const actionButtons = [
<LinkButton href={returnTo} key="cancel" size="sm" variant="secondary" onClick={() => submit(undefined)}>
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
</LinkButton>,
<Button key="export-rule" size="sm" onClick={formAPI.handleSubmit((formValues) => submit(formValues), onInvalid)}>
<Trans i18nKey="alerting.modify-export-rule-form.action-buttons.export">Export</Trans>
</Button>,
];
return (
<FormProvider {...formAPI}>
<AppChromeUpdate actions={actionButtons} />
<form onSubmit={(e) => e.preventDefault()}>
<div>
<Stack direction="column" gap={3}>
{/* Step 1 */}
<AlertRuleNameAndMetric />
{/* Step 2 */}
<QueryAndExpressionsStep editingExistingRule={existing} onDataChange={checkAlertCondition} mode="draft" />
{/* Step 3-4-5 */}
<GrafanaFolderAndLabelsStep />
<Stack direction="column">
<form onSubmit={(e) => e.preventDefault()}>
<div>
<Stack direction="column" gap={3}>
{/* Step 1 */}
<AlertRuleNameAndMetric />
{/* Step 2 */}
<QueryAndExpressionsStep editingExistingRule={existing} onDataChange={checkAlertCondition} mode="draft" />
{/* Step 3-4-5 */}
<GrafanaFolderAndLabelsStep />
{/* Step 4 & 5 */}
<GrafanaEvaluationBehaviorStep existing={Boolean(existing)} enableProvisionedGroups={true} />
{/* Notifications step*/}
<NotificationsStep alertUid={alertUid} />
{/* Annotations only for cloud and Grafana */}
<AnnotationsStep />
</Stack>
</div>
</form>
{exportData && <GrafanaRuleDesignExporter exportValues={exportData} onClose={onClose} uid={alertUid} />}
{/* Step 4 & 5 */}
<GrafanaEvaluationBehaviorStep existing={Boolean(existing)} enableProvisionedGroups={true} />
{/* Notifications step*/}
<NotificationsStep alertUid={alertUid} />
{/* Annotations only for cloud and Grafana */}
<AnnotationsStep />
</Stack>
</div>
</form>
{exportData && <GrafanaRuleDesignExporter exportValues={exportData} onClose={onClose} uid={alertUid} />}
<Stack direction="row">
<Button key="export-rule" onClick={formAPI.handleSubmit((formValues) => submit(formValues), onInvalid)}>
<Trans i18nKey="alerting.modify-export-rule-form.action-buttons.export">Export</Trans>
</Button>
<LinkButton href={returnTo} key="cancel" variant="secondary" onClick={() => submit(undefined)}>
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
</LinkButton>
</Stack>
</Stack>
</FormProvider>
);
}
@@ -97,7 +97,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
// do not select a contact point
// save and check that call to backend was not made
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
expect(await screen.findByText('Contact point is required.')).toBeInTheDocument();
const capturedRequests = await capture;
@@ -129,7 +129,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
await selectContactPoint(user, contactPointName);
// save and check what was sent to backend
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
@@ -162,7 +162,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
await selectContactPoint(user, contactPointName);
// save and check what was sent to backend
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
expect(serializedRequests).toMatchSnapshot();
@@ -180,7 +180,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
await selectFolderAndGroup(user);
// save and check what was sent to backend
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
expect(serializedRequests).toMatchSnapshot();
@@ -198,7 +198,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
await user.click(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Notification).get()); // switch notifications step to advanced mode
// save and check what was sent to backend
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
expect(serializedRequests).toMatchSnapshot();
@@ -218,7 +218,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
await user.click(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Query).get()); // switch query step to advanced mode
// save and check what was sent to backend
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
expect(serializedRequests).toMatchSnapshot();
@@ -275,16 +275,17 @@ interface TitleProps {
health?: RuleHealth;
ruleType?: PromRuleType;
ruleOrigin?: RulePluginOrigin;
returnToHref?: string;
}
export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigin }: TitleProps) => {
export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigin, returnToHref = '' }: TitleProps) => {
const isRecordingRule = ruleType === PromRuleType.Recording;
const { returnTo } = useReturnTo('/alerting/list');
const { returnTo } = useReturnTo(returnToHref);
return (
<Stack direction="row" gap={1} minWidth={0} alignItems="center">
<LinkButton variant="secondary" icon="angle-left" href={returnTo} />
{returnToHref && <LinkButton variant="secondary" icon="angle-left" href={returnTo} />}
{ruleOrigin && <PluginOriginBadge pluginId={ruleOrigin.pluginId} size="lg" />}
<Text variant="h1" truncate>
{name}
@@ -70,7 +70,7 @@ interface EvaluationBehaviorSummaryProps {
}
const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) => {
const every = rule.group.interval;
const interval = rule.group.interval;
const lastEvaluation = rule.promRule?.lastEvaluation;
const lastEvaluationDuration = rule.promRule?.evaluationTime;
const metric = rulerRuleType.grafana.recordingRule(rule.rulerRule)
@@ -87,10 +87,10 @@ const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) =>
{metric}
</DetailsField>
)}
{every && (
{interval && (
<DetailsField label={t('alerting.evaluation-behavior-summary.label-evaluate', 'Evaluate')} horizontal={true}>
<Trans i18nKey="alerting.evaluation-behavior-summary.evaluate" values={{ every }}>
Every {{ every }}
<Trans i18nKey="alerting.evaluation-behavior-summary.evaluate" values={{ interval }}>
Every {{ interval }}
</Trans>
</DetailsField>
)}
@@ -1,9 +1,10 @@
import * as React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { getWrapper, render, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { getWrapper, render, waitFor, within } from 'test/test-utils';
import { byRole, byTestId } from 'testing-library-selector';
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
import { DashboardSearchItemType } from 'app/features/search/types';
import { AccessControlAction } from 'app/types';
import { RuleWithLocation } from 'app/types/unified-alerting';
import {
@@ -18,19 +19,22 @@ import { setupMswServer } from '../mockApi';
import {
grantUserPermissions,
mockDataSource,
mockFolder,
mockRulerAlertingRule,
mockRulerGrafanaRule,
mockRulerRuleGroup,
} from '../mocks';
import { grafanaRulerRule } from '../mocks/grafanaRulerApi';
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from '../mocks/rulerApi';
import { setFolderResponse } from '../mocks/server/configure';
import { AlertingQueryRunner } from '../state/AlertingQueryRunner';
import { setupDataSources } from '../testSetup/datasources';
import { RuleFormValues } from '../types/rule-form';
import { Annotation } from '../utils/constants';
import { hashRulerRule } from '../utils/rule-id';
import { CloneRuleEditor, cloneRuleDefinition } from './CloneRuleEditor';
import { ExistingRuleEditor } from './ExistingRuleEditor';
import { cloneRuleDefinition } from './clone.utils';
import { getDefaultFormValues } from './formDefaults';
jest.mock('../components/rule-editor/ExpressionEditor', () => ({
@@ -54,7 +58,6 @@ const ui = {
annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`),
labelValue: (idx: number) => byTestId(`label-value-${idx}`),
},
loadingIndicator: byText('Loading the rule...'),
};
const Providers = getWrapper({ renderWithRouter: true });
@@ -68,18 +71,50 @@ function Wrapper({ children }: React.PropsWithChildren<{}>) {
}
describe('CloneRuleEditor', function () {
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
const folder = {
title: 'Folder A',
uid: grafanaRulerRule.grafana_alert.namespace_uid,
id: 1,
type: DashboardSearchItemType.DashDB,
accessControl: {
[AccessControlAction.AlertingRuleUpdate]: true,
},
};
beforeEach(() => {
jest.clearAllMocks();
grantUserPermissions([
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleCreate,
AccessControlAction.DataSourcesRead,
AccessControlAction.FoldersRead,
AccessControlAction.AlertingRuleExternalRead,
AccessControlAction.AlertingRuleExternalWrite,
]);
const dataSources = {
default: mockDataSource({
uid: MIMIR_DATASOURCE_UID,
type: 'prometheus',
name: 'Mimir',
isDefault: true,
}),
};
setupDataSources(dataSources.default);
setFolderResponse(mockFolder(folder));
});
describe('Grafana-managed rules', function () {
it('should populate form values from the existing alert rule', async function () {
setupDataSources();
render(
<CloneRuleEditor sourceRuleId={{ uid: grafanaRulerRule.grafana_alert.uid, ruleSourceName: 'grafana' }} />,
<ExistingRuleEditor
identifier={{ uid: grafanaRulerRule.grafana_alert.uid, ruleSourceName: 'grafana' }}
clone={true}
/>,
{ wrapper: Wrapper }
);
await waitForElementToBeRemoved(ui.loadingIndicator.query());
await waitFor(() => {
expect(within(ui.inputs.group.get()).queryByTestId('Spinner')).not.toBeInTheDocument();
});
@@ -130,20 +165,19 @@ describe('CloneRuleEditor', function () {
});
render(
<CloneRuleEditor
sourceRuleId={{
<ExistingRuleEditor
identifier={{
ruleSourceName: 'my-prom-ds',
namespace: 'namespace-one',
groupName: 'group1',
ruleName: 'First Ruler Rule',
rulerRuleHash: hashRulerRule(originRule),
}}
clone={true}
/>,
{ wrapper: Wrapper }
);
await waitForElementToBeRemoved(ui.loadingIndicator.query());
await waitFor(() => {
expect(ui.inputs.name.get()).toHaveValue('First Ruler Rule (copy)');
});
@@ -1,76 +1,133 @@
import { Alert, LoadingPlaceholder } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { NavModelItem } from '@grafana/data';
import { Alert, Stack } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { RuleIdentifier } from 'app/types/unified-alerting';
import { AlertWarning } from '../AlertWarning';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { AlertRuleForm } from '../components/rule-editor/alert-rule-form/AlertRuleForm';
import { FederatedRuleWarning } from '../components/rule-viewer/FederatedRuleWarning';
import { useRuleWithLocation } from '../hooks/useCombinedRule';
import { useIsRuleEditable } from '../hooks/useIsRuleEditable';
import { RuleFormValues } from '../types/rule-form';
import { Annotation } from '../utils/constants';
import { stringifyErrorLike } from '../utils/misc';
import { rulerRuleToFormValues } from '../utils/rule-form';
import * as ruleId from '../utils/rule-id';
import { isFederatedRuleGroup, rulerRuleType } from '../utils/rules';
import { defaultPageNav } from './RuleEditor';
import { cloneRuleDefinition } from './clone.utils';
interface ExistingRuleEditorProps {
identifier: RuleIdentifier;
/** Provide prefill if we are trying to restore an old version of an alert rule but we need the user to manually tweak the values */
// Provide prefill if we are trying to restore an old version of an alert rule but we need the user to manually tweak the values
prefill?: Partial<RuleFormValues>;
// indicate if this is a manual restore
isManualRestore?: boolean;
// indicate if this is a cloning operation
clone?: boolean;
}
export function ExistingRuleEditor({ identifier, prefill }: ExistingRuleEditorProps) {
const [queryParams] = useQueryParams();
const isManualRestore = Boolean(queryParams.isManualRestore);
export function ExistingRuleEditor({
identifier,
prefill,
isManualRestore = false,
clone = false,
}: ExistingRuleEditorProps) {
const ruleSourceName = ruleId.ruleIdentifierToRuleSourceName(identifier);
const {
loading: loadingAlertRule,
result: ruleWithLocation,
error: fetchRuleError,
} = useRuleWithLocation({ ruleIdentifier: identifier });
const ruleSourceName = ruleId.ruleIdentifierToRuleSourceName(identifier);
const {
isEditable,
loading: loadingEditable,
error: errorEditable,
} = useIsRuleEditable(ruleSourceName, ruleWithLocation?.rule);
// error handling for fetching rule and rule RBAC
if (fetchRuleError || errorEditable) {
return (
<Alert
severity="error"
title={t('alerting.existing-rule-editor.title-failed-to-load-rule', 'Failed to load rule')}
>
{stringifyErrorLike(errorEditable ?? fetchRuleError)}
</Alert>
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav()}>
<Alert
severity="error"
title={t('alerting.existing-rule-editor.title-failed-to-load-rule', 'Failed to load rule')}
>
{stringifyErrorLike(errorEditable ?? fetchRuleError)}
</Alert>
</AlertingPageWrapper>
);
}
const loading = loadingAlertRule || loadingEditable;
if (loading) {
return <LoadingPlaceholder text={t('alerting.existing-rule-editor.text-loading-rule', 'Loading rule...')} />;
return (
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav()} isLoading={true}>
{null}
</AlertingPageWrapper>
);
}
if (!ruleWithLocation && !loading) {
return (
<AlertWarning title={t('alerting.existing-rule-editor.title-rule-not-found', 'Rule not found')}>
<Trans i18nKey="alerting.existing-rule-editor.sorry-this-rule-does-not-exist">
Sorry! This rule does not exist.
</Trans>
</AlertWarning>
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav()}>
<AlertWarning title={t('alerting.existing-rule-editor.title-rule-not-found', 'Rule not found')}>
<Trans i18nKey="alerting.existing-rule-editor.sorry-this-rule-does-not-exist">
Sorry! This rule does not exist.
</Trans>
</AlertWarning>
</AlertingPageWrapper>
);
}
if (isEditable === false) {
return (
<AlertWarning title={t('alerting.existing-rule-editor.title-cannot-edit-rule', 'Cannot edit rule')}>
<Trans i18nKey="alerting.existing-rule-editor.sorry-permission">
Sorry! You do not have permission to edit this rule.
</Trans>
</AlertWarning>
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav()}>
<AlertWarning title={t('alerting.existing-rule-editor.title-cannot-edit-rule', 'Cannot edit rule')}>
<Trans i18nKey="alerting.existing-rule-editor.sorry-permission">
Sorry! You do not have permission to edit this rule.
</Trans>
</AlertWarning>
</AlertingPageWrapper>
);
}
return <AlertRuleForm existing={ruleWithLocation} prefill={prefill} isManualRestore={isManualRestore} />;
// we shouldn't get here because loading / error handling happens before this
if (!ruleWithLocation) {
return null;
}
const rulerRule = ruleWithLocation.rule;
const summary = rulerRuleType.any.alertingRule(rulerRule) ? rulerRule.annotations?.[Annotation.summary] : null;
const isFederatedRule = isFederatedRuleGroup(ruleWithLocation.group);
const isRecordingRule = rulerRuleType.any.recordingRule(rulerRule);
const pageTitle = isRecordingRule
? t('alerting.editor.edit-recording-rule', 'Edit recording rule')
: t('alerting.editor.edit-alert-rule', 'Edit alert rule');
return (
<AlertingPageWrapper
navId="alert-list"
subTitle={
<Stack direction="column">
{summary}
{/* alerts and notifications and stuff */}
{isFederatedRule && <FederatedRuleWarning />}
</Stack>
}
pageNav={getPageNav({ text: pageTitle })}
>
{clone ? (
<AlertRuleForm prefill={rulerRuleToFormValues(cloneRuleDefinition(ruleWithLocation))} />
) : (
<AlertRuleForm existing={ruleWithLocation} prefill={prefill} isManualRestore={isManualRestore} />
)}
</AlertingPageWrapper>
);
}
const getPageNav = (pageNavOptions?: Partial<NavModelItem>): NavModelItem => {
return { ...defaultPageNav, id: 'alert-rule-edit', text: '', ...pageNavOptions };
};
@@ -1,9 +1,7 @@
import { useCallback } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { Trans, t } from 'app/core/internationalization';
import { RuleIdentifier } from 'app/types/unified-alerting';
import { AlertWarning } from '../AlertWarning';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
@@ -13,91 +11,98 @@ import { useRulesAccess } from '../utils/accessControlHooks';
import * as ruleId from '../utils/rule-id';
import { withPageErrorBoundary } from '../withPageErrorBoundary';
import { CloneRuleEditor } from './CloneRuleEditor';
import { ExistingRuleEditor } from './ExistingRuleEditor';
import { formValuesFromQueryParams, translateRouteParamToRuleType } from './formDefaults';
type RuleEditorPathParams = {
export type RuleEditorPathParams = {
id?: string;
type?: 'recording' | 'alerting' | 'grafana-recording';
};
const defaultPageNav: Partial<NavModelItem> = {
icon: 'bell',
export const defaultPageNav: Partial<NavModelItem> = {
id: 'alert-rule-view',
};
// sadly we only get the "type" when a new rule is being created, when editing an existing recording rule we can't actually know it from the URL
const getPageNav = (identifier?: RuleIdentifier, type?: RuleEditorPathParams['type']) => {
if (type === 'recording' || type === 'grafana-recording') {
if (identifier) {
// this branch should never trigger actually, the type param isn't used when editing rules
return { ...defaultPageNav, id: 'alert-rule-edit', text: 'Edit recording rule' };
} else {
return { ...defaultPageNav, id: 'alert-rule-add', text: 'New recording rule' };
}
}
if (identifier) {
// keep this one ambiguous, don't mentiond a specific alert type here
return { ...defaultPageNav, id: 'alert-rule-edit', text: 'Edit rule' };
} else {
return { ...defaultPageNav, id: 'alert-rule-add', text: 'New alert rule' };
}
};
const RuleEditor = () => {
const { identifier, type } = useRuleEditorPathParams();
const { copyFromIdentifier, queryDefaults, isManualRestore } = useRuleEditorQueryParams();
const { identifier } = useRuleEditorPathParams();
const cloneIdentifier = useIdentifierFromCopy();
const isManualRestore = useManualRestore();
const { canCreateGrafanaRules, canCreateCloudRules, canEditRules } = useRulesAccess();
const getContent = useCallback(() => {
if (!identifier && !canCreateGrafanaRules && !canCreateCloudRules) {
return (
<AlertWarning title={t('alerting.rule-editor.get-content.title-cannot-create-rules', 'Cannot create rules')}>
<Trans i18nKey="alerting.rule-editor.get-content.sorry-allowed-create-rules">
Sorry! You are not allowed to create rules.
</Trans>
</AlertWarning>
);
}
if (!identifier && !canCreateGrafanaRules && !canCreateCloudRules) {
return (
<AlertWarning title={t('alerting.rule-editor.get-content.title-cannot-create-rules', 'Cannot create rules')}>
<Trans i18nKey="alerting.rule-editor.get-content.sorry-allowed-create-rules">
Sorry! You are not allowed to create rules.
</Trans>
</AlertWarning>
);
}
if (identifier && !canEditRules(identifier.ruleSourceName)) {
return (
<AlertWarning title={t('alerting.rule-editor.get-content.title-cannot-edit-rules', 'Cannot edit rules')}>
<Trans i18nKey="alerting.rule-editor.get-content.sorry-allowed-rules">
Sorry! You are not allowed to edit rules.
</Trans>
</AlertWarning>
);
}
if (identifier && !canEditRules(identifier.ruleSourceName)) {
return (
<AlertWarning title={t('alerting.rule-editor.get-content.title-cannot-edit-rules', 'Cannot edit rules')}>
<Trans i18nKey="alerting.rule-editor.get-content.sorry-allowed-rules">
Sorry! You are not allowed to edit rules.
</Trans>
</AlertWarning>
);
}
if (identifier) {
return <ExistingRuleEditor key={JSON.stringify(identifier)} identifier={identifier} prefill={queryDefaults} />;
}
if (identifier) {
return (
<ExistingRuleEditor key={JSON.stringify(identifier)} identifier={identifier} isManualRestore={isManualRestore} />
);
}
if (copyFromIdentifier) {
return <CloneRuleEditor sourceRuleId={copyFromIdentifier} />;
}
// new alert rule
return <AlertRuleForm prefill={queryDefaults} isManualRestore={isManualRestore} />;
}, [
canCreateCloudRules,
canCreateGrafanaRules,
canEditRules,
copyFromIdentifier,
identifier,
queryDefaults,
isManualRestore,
]);
if (cloneIdentifier) {
return (
<ExistingRuleEditor
key={JSON.stringify(identifier)}
identifier={cloneIdentifier}
clone={true}
isManualRestore={isManualRestore}
/>
);
}
// for new alerting or recording rules
return <NewRuleEditor />;
};
export const RECORDING_TYPE = ['grafana-recording', 'recording'];
/**
* This one is used for creating new rules (both alerting and recording rules)
*/
function NewRuleEditor() {
const prefill = useDefaultsFromQuery();
const isManualRestore = useManualRestore();
const { type = '', identifier = '' } = useRuleEditorPathParams();
const isExisting = Boolean(identifier);
const isRecordingRule = RECORDING_TYPE.includes(type);
const newText = isRecordingRule
? t('alerting.editor.new-recording-rule', 'New recording rule')
: t('alerting.editor.new-alert-rule', 'New alert rule');
const editText = isRecordingRule
? t('alerting.editor.edit-recording-rule', 'Edit recording rule')
: t('alerting.editor.edit-alert-rule', 'Edit alert rule');
return (
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav(identifier, type)}>
{getContent()}
<AlertingPageWrapper
navId="alert-list"
pageNav={{
id: 'alert-rule-add',
text: isExisting ? editText : newText,
}}
>
<AlertRuleForm prefill={prefill} isManualRestore={isManualRestore} />
</AlertingPageWrapper>
);
};
}
// The pageNav property makes it difficult to only rely on AlertingPageWrapper
// to catch errors.
@@ -112,13 +117,16 @@ function useRuleEditorPathParams() {
return { identifier, type };
}
function useRuleEditorQueryParams() {
const { type } = useParams<RuleEditorPathParams>();
function useIdentifierFromCopy() {
const [searchParams] = useURLSearchParams();
const copyFromId = searchParams.get('copyFrom') ?? undefined;
const copyFromIdentifier = ruleId.tryParse(copyFromId);
const isManualRestore = searchParams.has('isManualRestore');
return ruleId.tryParse(copyFromId);
}
function useDefaultsFromQuery() {
const { type } = useRuleEditorPathParams();
const [searchParams] = useURLSearchParams();
const ruleType = translateRouteParamToRuleType(type);
@@ -126,5 +134,12 @@ function useRuleEditorQueryParams() {
? formValuesFromQueryParams(searchParams.get('defaults') ?? '', ruleType)
: undefined;
return { copyFromIdentifier, queryDefaults, isManualRestore };
return queryDefaults;
}
function useManualRestore() {
const [searchParams] = useURLSearchParams();
const isManualRestore = searchParams.has('isManualRestore');
return isManualRestore;
}
@@ -80,7 +80,7 @@ describe('RuleEditor cloud', () => {
// save and check what was sent to backend
const capture = captureRequests();
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
@@ -96,27 +96,19 @@ describe('RuleEditor grafana managed rules', () => {
//check that folder is in the list
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations[Annotation.summary]);
expect(screen.getByText('New folder')).toBeInTheDocument();
//check that slashed folders are not in the list
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
expect(ui.inputs.folder.get()).not.toHaveTextContent(new RegExp(slashedFolder.title));
//check that slashes warning is only shown once user search slashes
//todo: move this test to a unit test in FolderAndGroup unit test
// const folderInput = await ui.inputs.folderContainer.find();
// expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument();
// await user.type(within(folderInput).getByRole('combobox'), 'new slashed //');
// expect(within(folderInput).getByText("Folders with '/' character are not allowed.")).toBeInTheDocument();
// await user.keyboard('{backspace} {backspace}{backspace}');
// expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument();
// add an annotation
await user.click(screen.getByText('Add custom annotation'));
await user.type(screen.getByPlaceholderText('Enter custom annotation name...'), 'custom');
await user.type(screen.getByPlaceholderText('Enter custom annotation content...'), 'value');
// save and check what was sent to backend
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
});
it('saves evaluation interval correctly', async () => {
@@ -140,7 +132,7 @@ describe('RuleEditor grafana managed rules', () => {
(req) => req.method === 'POST' && req.url.includes('/api/ruler/grafana/api/v1/rules/uuid020c61ef')
);
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
const [request] = await capture;
const postBody = await request.json();
@@ -158,6 +150,6 @@ describe('Data source managed rules', () => {
it('should show an error if the data source does not exist', async () => {
renderRuleEditor('cri%24grafana-cloudd%24delete me%24delete me 3%24recording_rule_delete_2%24-476183141');
expect(await screen.findByText(/unable to find data source/i)).toBeInTheDocument();
expect(await screen.findByText(/not found/i)).toBeInTheDocument();
});
});
@@ -1,7 +1,7 @@
import * as React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption, selectOptionInTest } from 'test/helpers/selectOptionInTest';
import { screen } from 'test/test-utils';
import { screen, waitFor } from 'test/test-utils';
import { byRole } from 'testing-library-selector';
import { contextSrv } from 'app/core/services/context_srv';
@@ -73,7 +73,7 @@ describe('RuleEditor grafana managed rules', () => {
await clickSelectOption(groupInput, grafanaRulerGroup.name);
await user.type(ui.inputs.annotationValue(1).get(), 'some description');
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully');
const requests = await capture;
@@ -128,12 +128,12 @@ describe('RuleEditor grafana managed rules', () => {
const nameInput = await ui.inputs.name.find();
expect(nameInput).toHaveValue(grafanaRulerRule.grafana_alert.title);
//check that folder is in the list
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
await waitFor(() => expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)));
expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations[Annotation.summary]);
expect(ui.manualRestoreBanner.get()).toBeInTheDocument(); // check that manual restore banner is shown
await user.click(ui.buttons.saveAndExit.get());
await user.click(ui.buttons.save.get());
expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully');
const requests = await capture;
@@ -80,7 +80,7 @@ describe('RuleEditor recording rules', () => {
await userEvent.type(await ui.inputs.expr.find(), 'up == 1');
// try to save, find out that recording rule name is invalid
await userEvent.click(ui.buttons.saveAndExit.get());
await userEvent.click(ui.buttons.save.get());
await waitFor(() =>
expect(
byText(
@@ -95,7 +95,7 @@ describe('RuleEditor recording rules', () => {
// save and check what was sent to backend
const capture = captureRequests();
await userEvent.click(ui.buttons.saveAndExit.get());
await userEvent.click(ui.buttons.save.get());
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
@@ -0,0 +1,39 @@
import { cloneDeep } from 'lodash';
import { RuleWithLocation } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { generateCopiedName } from '../utils/duplicate';
import { getRuleName, rulerRuleType } from '../utils/rules';
export function changeRuleName(rule: RulerRuleDTO, newName: string) {
if (rulerRuleType.grafana.rule(rule)) {
rule.grafana_alert.title = newName;
}
if (rulerRuleType.dataSource.alertingRule(rule)) {
rule.alert = newName;
}
if (rulerRuleType.dataSource.recordingRule(rule)) {
rule.record = newName;
}
}
export function cloneRuleDefinition(rule: RuleWithLocation<RulerRuleDTO>) {
const ruleClone = cloneDeep(rule);
changeRuleName(
ruleClone.rule,
generateCopiedName(getRuleName(ruleClone.rule), ruleClone.group.rules.map(getRuleName))
);
if (rulerRuleType.grafana.rule(ruleClone.rule)) {
ruleClone.rule.grafana_alert.uid = '';
// Provisioned alert rules have provisioned alert group which cannot be used in UI
if (Boolean(ruleClone.rule.grafana_alert.provenance)) {
ruleClone.group = { name: '', rules: ruleClone.group.rules };
}
}
return ruleClone;
}
@@ -19,6 +19,7 @@ import {
DataSourceRuleGroupIdentifier,
FilterState,
RuleIdentifier,
RuleWithLocation,
RulesSource,
SilenceFilterState,
} from 'app/types/unified-alerting';
@@ -55,6 +56,15 @@ export function createViewLinkV2(
return rulesNav.detailsPageLink(ruleSourceName, identifier, returnTo ? { returnTo } : undefined);
}
export function createViewLinkFromRuleWithLocation(ruleWithLocation: RuleWithLocation) {
const ruleSourceName = ruleWithLocation.ruleSourceName;
const identifier = ruleId.fromRuleWithLocation(ruleWithLocation);
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
const paramSource = encodeURIComponent(ruleSourceName);
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`);
}
export function createExploreLink(datasource: DataSourceRef, query: string) {
const { uid, type } = datasource;
@@ -208,6 +208,10 @@ export function getPendingPeriod(rule: CombinedRule): string | undefined {
return undefined;
}
export function getPendingPeriodFromRulerRule(rule: RulerRuleDTO) {
return rulerRuleType.any.alertingRule(rule) ? rule.for : undefined;
}
export function getKeepFiringfor(rule: CombinedRule): string | undefined {
if (rulerRuleType.any.recordingRule(rule.rulerRule)) {
return undefined;
+9 -6
View File
@@ -408,11 +408,9 @@
},
"alert-rule-form": {
"action-buttons": {
"delete": "Delete",
"edit-yaml": "Edit YAML",
"save-exit": "Save rule and exit"
},
"title-delete-rule": "Delete rule"
"save": "Save"
}
},
"alert-rule-name-and-metric": {
"aria-label-name": "name",
@@ -895,12 +893,18 @@
"text-loading-template": "Loading template...",
"title-failed-to-fetch-notification-template": "Failed to fetch notification template"
},
"editor": {
"edit-alert-rule": "Edit alert rule",
"edit-recording-rule": "Edit recording rule",
"new-alert-rule": "New alert rule",
"new-recording-rule": "New recording rule"
},
"error-modal": {
"failed-to-update-your-configuration": "Failed to update your configuration:",
"title-something-went-wrong": "Something went wrong"
},
"evaluation-behavior-summary": {
"evaluate": "Every {{every}}",
"evaluate": "Every {{interval}}",
"label-evaluate": "Evaluate",
"label-evaluation-time": "Evaluation time",
"label-last-evaluation": "Last evaluation",
@@ -929,7 +933,6 @@
"existing-rule-editor": {
"sorry-permission": "Sorry! You do not have permission to edit this rule.",
"sorry-this-rule-does-not-exist": "Sorry! This rule does not exist.",
"text-loading-rule": "Loading rule...",
"title-cannot-edit-rule": "Cannot edit rule",
"title-failed-to-load-rule": "Failed to load rule",
"title-rule-not-found": "Rule not found"
+1 -1
View File
@@ -39,7 +39,7 @@ export const ui = {
byTestId(selectors.components.AlertRules.stepAdvancedModeSwitch(stepNo.toString())),
},
buttons: {
saveAndExit: byRole('button', { name: 'Save rule and exit' }),
save: byTestId('save-rule'),
addAnnotation: byRole('button', { name: /Add info/ }),
addLabel: byRole('button', { name: /Add label/ }),
preview: byRole('button', { name: /^Preview$/ }),