Alerting: Alert creation UI changes (#73835)

This commit is contained in:
Gilles De Mey 2023-09-06 13:24:48 +02:00 committed by GitHub
parent d50ccd6741
commit a2c93bb8bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 452 additions and 546 deletions

1
.github/CODEOWNERS vendored
View File

@ -453,6 +453,7 @@ lerna.json @grafana/frontend-ops
/public/robots.txt @grafana/frontend-ops
/public/sass/ @grafana/grafana-frontend-platform
/public/test/ @grafana/grafana-frontend-platform
/public/test/helpers/alertingRuleEditor.tsx @grafana/alerting-frontend
/public/views/ @grafana/grafana-frontend-platform
/public/app/features/explore/Logs/ @grafana/observability-logs

View File

@ -71,7 +71,7 @@ afterAll(() => {
const ui = {
inputs: {
name: byRole('textbox', { name: /rule name name for the alert rule\./i }),
name: byRole('textbox', { name: 'name' }),
expr: byTestId('expr'),
folderContainer: byTestId(selectors.components.FolderPicker.containerV2),
namespace: byTestId('namespace-picker'),

View File

@ -5,6 +5,7 @@ import { NavModelItem } from '@grafana/data';
import { withErrorBoundary } from '@grafana/ui';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { useDispatch } from 'app/types';
import { RuleIdentifier } from 'app/types/unified-alerting';
import { AlertWarning } from './AlertWarning';
import { CloneRuleEditor } from './CloneRuleEditor';
@ -16,27 +17,37 @@ import { fetchRulesSourceBuildInfoAction } from './state/actions';
import { useRulesAccess } from './utils/accessControlHooks';
import * as ruleId from './utils/rule-id';
type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string }>;
type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string; type?: 'recording' | 'alerting' }>;
const defaultPageNav: Partial<NavModelItem> = {
icon: 'bell',
id: 'alert-rule-view',
};
const getPageNav = (state: 'edit' | 'add') => {
if (state === 'edit') {
return { ...defaultPageNav, id: 'alert-rule-edit', text: 'Edit rule' };
} else if (state === 'add') {
return { ...defaultPageNav, id: 'alert-rule-add', text: 'Add rule' };
// 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?: 'recording' | 'alerting') => {
if (type === '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' };
}
return undefined;
};
const RuleEditor = ({ match }: RuleEditorProps) => {
const dispatch = useDispatch();
const [searchParams] = useURLSearchParams();
const { id } = match.params;
const { id, type } = match.params;
const identifier = ruleId.tryParse(id, true);
const copyFromId = searchParams.get('copyFrom') ?? undefined;
@ -75,7 +86,7 @@ const RuleEditor = ({ match }: RuleEditorProps) => {
}, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier, loading]);
return (
<AlertingPageWrapper isLoading={loading} pageId="alert-list" pageNav={getPageNav(identifier ? 'edit' : 'add')}>
<AlertingPageWrapper isLoading={loading} pageId="alert-list" pageNav={getPageNav(identifier, type)}>
{getContent()}
</AlertingPageWrapper>
);

View File

@ -186,7 +186,7 @@ describe('RuleEditor cloud: checking editable data sources', () => {
await ui.inputs.name.find();
const switchToCloudButton = screen.getByText('Switch to data source-managed alert rule');
const switchToCloudButton = screen.getByText('Data source-managed');
expect(switchToCloudButton).toBeInTheDocument();
await userEvent.click(switchToCloudButton);

View File

@ -13,6 +13,12 @@ import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRul
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { mockApi, mockFeatureDiscoveryApi, setupMswServer } from './mockApi';
import { disableRBAC, mockDataSource } from './mocks';
import {
defaultAlertmanagerChoiceResponse,
emptyExternalAlertmanagersResponse,
mockAlertmanagerChoiceResponse,
mockAlertmanagersResponse,
} from './mocks/alertmanagerApi';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
import { setupDataSources } from './testSetup/datasources';
import { buildInfoResponse } from './testSetup/featureDiscovery';
@ -48,6 +54,8 @@ setupDataSources(dataSources.default);
const server = setupMswServer();
mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir);
mockAlertmanagerChoiceResponse(server, defaultAlertmanagerChoiceResponse);
mockAlertmanagersResponse(server, emptyExternalAlertmanagersResponse);
mockApi(server).eval({ results: {} });
// these tests are rather slow because we have to wait for various API calls and mocks to be called
@ -110,10 +118,11 @@ describe('RuleEditor cloud', () => {
expect(removeExpressionsButtons).toHaveLength(2);
// Needs to wait for featrue discovery API call to finish - Check if ruler enabled
await waitFor(() => expect(screen.getByText('Switch to data source-managed alert rule')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Data source-managed')).toBeInTheDocument());
const switchToCloudButton = screen.getByText('Switch to data source-managed alert rule');
const switchToCloudButton = screen.getByText('Data source-managed');
expect(switchToCloudButton).toBeInTheDocument();
expect(switchToCloudButton).not.toBeDisabled();
await user.click(switchToCloudButton);

View File

@ -1,8 +1,6 @@
import { css, cx } from '@emotion/css';
import React, { HTMLAttributes } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { IconSize, useStyles2, Button } from '@grafana/ui';
import { IconSize, Button } from '@grafana/ui';
interface Props extends HTMLAttributes<HTMLButtonElement> {
isCollapsed: boolean;
@ -23,8 +21,6 @@ export const CollapseToggle = ({
size = 'xl',
...restOfProps
}: Props) => {
const styles = useStyles2(getStyles);
return (
<Button
type="button"
@ -32,7 +28,7 @@ export const CollapseToggle = ({
variant="secondary"
aria-expanded={!isCollapsed}
aria-controls={idControlled}
className={cx(styles.expandButton, className)}
className={className}
icon={isCollapsed ? 'angle-right' : 'angle-down'}
onClick={() => onToggle(!isCollapsed)}
{...restOfProps}
@ -41,9 +37,3 @@ export const CollapseToggle = ({
</Button>
);
};
export const getStyles = (theme: GrafanaTheme2) => ({
expandButton: css`
margin-right: ${theme.spacing(1)};
`,
});

View File

@ -5,8 +5,19 @@ import { DeepMap, FieldError, FormProvider, useForm, useFormContext, UseFormWatc
import { Link, useParams } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { config, logInfo } from '@grafana/runtime';
import { Button, ConfirmModal, CustomScrollbar, Field, HorizontalGroup, Input, Spinner, useStyles2 } from '@grafana/ui';
import {
Button,
ConfirmModal,
CustomScrollbar,
Field,
HorizontalGroup,
Input,
Spinner,
Text,
useStyles2,
} from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { useAppNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/core';
@ -48,7 +59,6 @@ const recordingRuleNameValidationPattern = {
};
const AlertRuleNameInput = () => {
const styles = useStyles2(getStyles);
const {
register,
watch,
@ -56,22 +66,29 @@ const AlertRuleNameInput = () => {
} = useFormContext<RuleFormValues & { location?: string }>();
const ruleFormType = watch('type');
const entityName = ruleFormType === RuleFormType.cloudRecording ? 'recording rule' : 'alert rule';
return (
<RuleEditorSection stepNo={1} title="Set alert rule name.">
<Field
className={styles.formInput}
label="Rule name"
description="Name for the alert rule."
error={errors?.name?.message}
invalid={!!errors.name?.message}
<RuleEditorSection
stepNo={1}
title={`Enter ${entityName} name`}
description={
<Text variant="bodySmall" color="secondary">
{/* sigh language rules we should use translations ideally but for now we deal with "a" and "an" */}
Enter {entityName === 'alert rule' ? 'an' : 'a'} {entityName} name to identify your alert.
</Text>
}
>
<Field label="Name" error={errors?.name?.message} invalid={!!errors.name?.message}>
<Input
id="name"
width={35}
{...register('name', {
required: { value: true, message: 'Must enter an alert name' },
required: { value: true, message: 'Must enter a name' },
pattern: ruleFormType === RuleFormType.cloudRecording ? recordingRuleNameValidationPattern : undefined,
})}
placeholder="Give your alert rule a name."
aria-label="name"
placeholder={`Give your ${entityName} a name`}
/>
</Field>
</RuleEditorSection>
@ -254,7 +271,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
<form onSubmit={(e) => e.preventDefault()} className={styles.form}>
<div className={styles.contentOuter}>
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
<div className={styles.contentInner}>
<Stack direction="column" gap={3}>
{/* Step 1 */}
<AlertRuleNameInput />
{/* Step 2 */}
@ -282,7 +299,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
<NotificationsStep alertUid={uidFromParams} />
</>
)}
</div>
</Stack>
</CustomScrollbar>
</div>
</form>
@ -369,29 +386,15 @@ const getStyles = (theme: GrafanaTheme2) => {
display: flex;
flex-direction: column;
`,
contentInner: css`
flex: 1;
padding: ${theme.spacing(2)};
`,
contentOuter: css`
background: ${theme.colors.background.primary};
border: 1px solid ${theme.colors.border.weak};
border-radius: ${theme.shape.radius.default};
overflow: hidden;
flex: 1;
margin-top: ${theme.spacing(1)};
`,
flexRow: css`
display: flex;
flex-direction: row;
justify-content: flex-start;
`,
formInput: css`
width: 275px;
& + & {
margin-left: ${theme.spacing(3)};
}
`,
};
};

View File

@ -1,9 +1,8 @@
import { css } from '@emotion/css';
import React from 'react';
import { FieldArrayWithId, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { InputControl, useStyles2 } from '@grafana/ui';
import { Stack } from '@grafana/experimental';
import { InputControl, Text } from '@grafana/ui';
import { RuleFormValues } from '../../types/rule-form';
import { Annotation, annotationDescriptions, annotationLabels } from '../../utils/constants';
@ -21,58 +20,49 @@ const AnnotationHeaderField = ({
annotation: Annotation;
index: number;
}) => {
const styles = useStyles2(getStyles);
const { control } = useFormContext<RuleFormValues>();
return (
<div>
<label className={styles.annotationContainer}>
<Stack direction="column" gap={0}>
<label>
{
<InputControl
name={`annotations.${index}.key`}
defaultValue={annotationField.key}
render={({ field: { ref, ...field } }) => {
if (!annotationLabels[annotation]) {
return <CustomAnnotationHeaderField field={field} />;
}
let label;
switch (annotationField.key) {
case Annotation.dashboardUID:
return <div>Dashboard and panel</div>;
label = 'Dashboard and panel';
case Annotation.panelID:
return <span></span>;
label = '';
default:
return (
<div>
{annotationLabels[annotation] && (
<span className={styles.annotationTitle} data-testid={`annotation-key-${index}`}>
{annotationLabels[annotation]}
{' (optional)'}
</span>
)}
{!annotationLabels[annotation] && <CustomAnnotationHeaderField field={field} />}
</div>
);
label = annotationLabels[annotation] && annotationLabels[annotation] + ' (optional)';
}
return (
<span data-testid={`annotation-key-${index}`}>
<Text color="primary" variant="bodySmall">
{label}
</Text>
</span>
);
}}
control={control}
rules={{ required: { value: !!annotations[index]?.value, message: 'Required.' } }}
/>
}
</label>
<div className={styles.annotationDescription}>{annotationDescriptions[annotation]}</div>
</div>
<Text variant="bodySmall" color="secondary">
{annotationDescriptions[annotation]}
</Text>
</Stack>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
annotationTitle: css`
color: ${theme.colors.text.primary};
margin-bottom: 3px;
`,
annotationContainer: css`
margin-top: 5px;
`,
annotationDescription: css`
color: ${theme.colors.text.secondary};
`,
});
export default AnnotationHeaderField;

View File

@ -6,7 +6,7 @@ import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Button, Field, Input, TextArea, useStyles2 } from '@grafana/ui';
import { Button, Field, Input, Text, TextArea, useStyles2 } from '@grafana/ui';
import { DashboardDataDTO } from 'app/types';
import { dashboardApi } from '../../api/dashboardApi';
@ -97,8 +97,10 @@ const AnnotationsStep = () => {
'https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/variables-label-annotation';
return (
<Stack gap={0.5}>
<Stack direction="row" gap={0.5} alignItems="baseline">
<Text variant="bodySmall" color="secondary">
Add annotations to provide more context in your alert notifications.
</Text>
<NeedHelpInfo
contentText={`Annotations add metadata to provide more information on the alert in your alert notifications.
For example, add a Summary annotation to tell you which value caused the alert to fire or which server it happened on.
@ -112,8 +114,8 @@ const AnnotationsStep = () => {
}
return (
<RuleEditorSection stepNo={4} title="Add annotations" description={getAnnotationsSectionDescription()}>
<div className={styles.flexColumn}>
<RuleEditorSection stepNo={4} title="Add annotations" description={getAnnotationsSectionDescription()} fullWidth>
<Stack direction="column" gap={1}>
{fields.map((annotationField, index: number) => {
const isUrl = annotations[index]?.key?.toLocaleLowerCase().endsWith('url');
const ValueInputComponent = isUrl ? Input : TextArea;
@ -206,7 +208,7 @@ const AnnotationsStep = () => {
onDismiss={() => setShowPanelSelector(false)}
/>
)}
</div>
</Stack>
</RuleEditorSection>
);
};
@ -223,11 +225,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
gap: ${theme.spacing(1)};
display: flex;
`,
flexColumn: css`
display: flex;
flex-direction: column;
margin-top: ${theme.spacing(2)};
`,
field: css`
margin-bottom: ${theme.spacing(0.5)};
`,

View File

@ -25,7 +25,7 @@ export const CloudEvaluationBehavior = () => {
const dataSourceName = watch('dataSourceName');
return (
<RuleEditorSection stepNo={3} title="Set alert evaluation behavior">
<RuleEditorSection stepNo={3} title="Set evaluation behavior">
<Field
label="Pending period"
description="Period in which an alert rule can be in breach of the condition until the alert rule fires."

View File

@ -4,7 +4,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FormProvider, useForm, useFormContext } from 'react-hook-form';
import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { AsyncSelect, Button, Field, Input, InputControl, Label, Modal, useStyles2 } from '@grafana/ui';
import { Stack } from '@grafana/experimental';
import { AsyncSelect, Button, Field, Input, InputControl, Label, Modal, Text, useStyles2 } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/services/context_srv';
import { createFolder } from 'app/features/manage-dashboards/state/actions';
@ -122,7 +123,7 @@ export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGraf
return (
<div className={styles.container}>
<div className={styles.evaluationGroupsContainer}>
<div>
{
<Field
label={
@ -135,9 +136,12 @@ export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGraf
invalid={!!errors.folder?.message}
data-testid="folder-picker"
>
<Stack direction="row" alignItems="center">
{(!isCreatingFolder && (
<>
<InputControl
render={({ field: { ref, ...field } }) => (
<div style={{ width: 420 }}>
<RuleFolderPicker
inputId="folder"
{...field}
@ -147,6 +151,7 @@ export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGraf
resetGroup();
}}
/>
</div>
)}
name="folder"
rules={{
@ -156,12 +161,7 @@ export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGraf
},
}}
/>
)) || <div>Creating new folder...</div>}
</Field>
}
<div className={styles.addButton}>
<span>or</span>
<Text color="secondary">or</Text>
<Button
onClick={onOpenFolderCreationModal}
type="button"
@ -172,13 +172,17 @@ export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGraf
>
New folder
</Button>
</div>
</>
)) || <div>Creating new folder...</div>}
</Stack>
</Field>
}
{isCreatingFolder && (
<FolderCreationModal onCreate={handleFolderCreation} onClose={() => setIsCreatingFolder(false)} />
)}
</div>
<div className={styles.evaluationGroupsContainer}>
<div>
<Field
label="Evaluation group"
data-testid="group-picker"
@ -187,8 +191,10 @@ export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGraf
error={errors.group?.message}
invalid={!!errors.group?.message}
>
<Stack direction="row" alignItems="center">
<InputControl
render={({ field: { ref, ...field }, fieldState }) => (
<div style={{ width: 420 }}>
<AsyncSelect
disabled={!folder || loading}
inputId="group"
@ -218,6 +224,7 @@ export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGraf
)}
placeholder={'Select an evaluation group...'}
/>
</div>
)}
name="group"
control={control}
@ -228,10 +235,7 @@ export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGraf
},
}}
/>
</Field>
<div className={styles.addButton}>
<span>or</span>
<Text color="secondary">or</Text>
<Button
onClick={onOpenEvaluationGroupCreationModal}
type="button"
@ -242,7 +246,8 @@ export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGraf
>
New evaluation group
</Button>
</div>
</Stack>
</Field>
{isCreatingEvaluationGroup && (
<EvaluationGroupCreationModal
onCreate={handleEvalGroupCreation}
@ -407,40 +412,18 @@ function EvaluationGroupCreationModal({
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
margin-top: ${theme.spacing(1)};
display: flex;
flex-direction: column;
align-items: baseline;
max-width: ${theme.breakpoints.values.lg}px;
justify-content: space-between;
`,
evaluationGroupsContainer: css`
width: 100%;
display: flex;
flex-direction: row;
gap: ${theme.spacing(2)};
`,
addButton: css`
display: flex;
direction: row;
gap: ${theme.spacing(2)};
line-height: 2;
margin-top: 35px;
`,
formInput: css`
max-width: ${theme.breakpoints.values.sm}px;
flex-grow: 1;
label {
width: ${theme.breakpoints.values.sm}px;
}
`,
modal: css`
width: ${theme.breakpoints.values.sm}px;
`,
modalTitle: css`
color: ${theme.colors.text.secondary};
margin-bottom: ${theme.spacing(2)};

View File

@ -4,7 +4,7 @@ import { RegisterOptions, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Field, Icon, IconButton, Input, InputControl, Label, Switch, Tooltip, useStyles2 } from '@grafana/ui';
import { Field, Icon, IconButton, Input, InputControl, Label, Switch, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting';
import { logInfo, LogMessages } from '../../Analytics';
@ -185,11 +185,13 @@ function ForInput({ evaluateEvery }: { evaluateEvery: string }) {
}
function getDescription() {
const textToRender = 'Define how the alert rule is evaluated.';
const docsLink = 'https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/rule-evaluation/';
return (
<Stack gap={0.5}>
{`${textToRender}`}
<Stack direction="row" gap={0.5} alignItems="baseline">
<Text variant="bodySmall" color="secondary">
Define how the alert rule is evaluated.
</Text>
<NeedHelpInfo
contentText="Evaluation groups are containers for evaluating alert and recording rules. An evaluation group defines an evaluation interval - how often a rule is checked. Alert rules within the same evaluation group are evaluated sequentially"
externalLink={docsLink}
@ -218,7 +220,7 @@ export function GrafanaEvaluationBehavior({
return (
// TODO remove "and alert condition" for recording rules
<RuleEditorSection stepNo={3} title="Set alert evaluation behavior" description={getDescription()}>
<RuleEditorSection stepNo={3} title="Set evaluation behavior" description={getDescription()}>
<Stack direction="column" justify-content="flex-start" align-items="flex-start">
<FolderGroupAndEvaluationInterval setEvaluateEvery={setEvaluateEvery} evaluateEvery={evaluateEvery} />
<ForInput evaluateEvery={evaluateEvery} />
@ -252,7 +254,6 @@ export function GrafanaEvaluationBehavior({
isCollapsed={!showErrorHandling}
onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
text="Configure no data and error handling"
className={styles.collapseToggle}
/>
{showErrorHandling && (
<>
@ -296,9 +297,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
inlineField: css`
margin-bottom: 0;
`,
collapseToggle: css`
margin: ${theme.spacing(2, 0, 2, -1)};
`,
evaluateLabel: css`
margin-right: ${theme.spacing(1)};
`,

View File

@ -5,7 +5,18 @@ import { FieldArrayMethodProps, useFieldArray, useFormContext } from 'react-hook
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Button, Field, InlineLabel, Label, useStyles2, Tooltip, Icon, Input, LoadingPlaceholder } from '@grafana/ui';
import {
Button,
Field,
InlineLabel,
Label,
useStyles2,
Text,
Tooltip,
Icon,
Input,
LoadingPlaceholder,
} from '@grafana/ui';
import { useDispatch } from 'app/types';
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
@ -129,7 +140,7 @@ const LabelsWithSuggestions: FC<{ dataSourceName: string }> = ({ dataSourceName
<>
{loading && <LoadingPlaceholder text="Loading" />}
{!loading && (
<>
<Stack direction="column" gap={0.5}>
{fields.map((field, index) => {
return (
<div key={field.id}>
@ -182,7 +193,7 @@ const LabelsWithSuggestions: FC<{ dataSourceName: string }> = ({ dataSourceName
);
})}
<AddButton className={styles.addLabelButton} append={append} />
</>
</Stack>
)}
</>
);
@ -245,14 +256,16 @@ const LabelsWithoutSuggestions: FC = () => {
);
};
const LabelsField: FC<Props> = ({ className, dataSourceName }) => {
const LabelsField: FC<Props> = ({ dataSourceName }) => {
const styles = useStyles2(getStyles);
return (
<div className={cx(className, styles.wrapper)}>
<Label>
<Stack gap={0.5}>
<span>Custom Labels</span>
<div>
<Label description="A set of default labels is automatically added. Add additional labels as required.">
<Stack gap={0.5} alignItems="center">
<Text variant="bodySmall" color="primary">
Labels
</Text>
<Tooltip
content={
<div>
@ -265,15 +278,7 @@ const LabelsField: FC<Props> = ({ className, dataSourceName }) => {
</Tooltip>
</Stack>
</Label>
<>
<div className={styles.flexRow}>
<InlineLabel width={18}>Labels</InlineLabel>
<div className={styles.flexColumn}>
{dataSourceName && <LabelsWithSuggestions dataSourceName={dataSourceName} />}
{!dataSourceName && <LabelsWithoutSuggestions />}
</div>
</div>
</>
{dataSourceName ? <LabelsWithSuggestions dataSourceName={dataSourceName} /> : <LabelsWithoutSuggestions />}
</div>
);
};
@ -283,9 +288,6 @@ const getStyles = (theme: GrafanaTheme2) => {
icon: css`
margin-right: ${theme.spacing(0.5)};
`,
wrapper: css`
margin-bottom: ${theme.spacing(4)};
`,
flexColumn: css`
display: flex;
flex-direction: column;
@ -318,7 +320,8 @@ const getStyles = (theme: GrafanaTheme2) => {
`,
labelInput: css`
width: 175px;
margin-bottom: ${theme.spacing(1)};
margin-bottom: -${theme.spacing(1)};
& + & {
margin-left: ${theme.spacing(1)};
}

View File

@ -3,7 +3,7 @@ import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Icon, Toggletip, useStyles2 } from '@grafana/ui';
import { Icon, Text, Toggletip, useStyles2 } from '@grafana/ui';
interface NeedHelpInfoProps {
contentText: string | JSX.Element;
@ -25,9 +25,11 @@ export function NeedHelpInfo({ contentText, externalLink, linkText, title }: Nee
footer={
externalLink ? (
<a href={externalLink} target="_blank" rel="noreferrer">
<div className={styles.infoLink}>
{linkText} <Icon name="external-link-alt" />
</div>
<Stack direction="row" gap={0.5} alignItems="center">
<Text color="link">
{linkText} <Icon size="sm" name="external-link-alt" />
</Text>
</Stack>
</a>
) : undefined
}
@ -35,8 +37,12 @@ export function NeedHelpInfo({ contentText, externalLink, linkText, title }: Nee
placement="bottom-start"
>
<div className={styles.helpInfo}>
<Icon name="question-circle" />
<div className={styles.helpInfoText}>Need help?</div>
<Stack direction="row" alignItems="center" gap={0.5}>
<Icon name="question-circle" size="sm" />
<Text variant="bodySmall" color="primary">
Need help?
</Text>
</Stack>
</div>
</Toggletip>
);
@ -48,21 +54,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
font-size: ${theme.typography.size.sm};
`,
helpInfo: css`
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
font-weight: ${theme.typography.fontWeightMedium};
margin-left: ${theme.spacing(1)};
font-size: ${theme.typography.size.sm};
cursor: pointer;
color: ${theme.colors.text.primary};
`,
helpInfoText: css`
margin-left: ${theme.spacing(0.5)};
text-decoration: underline;
`,
infoLink: css`
color: ${theme.colors.text.link};
`,
});

View File

@ -1,10 +1,8 @@
import { css } from '@emotion/css';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Card, Icon, Link, useStyles2 } from '@grafana/ui';
import { Icon, Text } from '@grafana/ui';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
@ -18,8 +16,7 @@ type NotificationsStepProps = {
alertUid?: string;
};
export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
const styles = useStyles2(getStyles);
const { watch, getValues } = useFormContext<RuleFormValues & { location?: string }>();
const { watch } = useFormContext<RuleFormValues & { location?: string }>();
const [type, labels, queries, condition, folder, alertName] = watch([
'type',
@ -31,15 +28,15 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
]);
const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME;
const hasLabelsDefined = getNonEmptyLabels(getValues('labels')).length > 0;
const shouldRenderPreview = Boolean(condition) && Boolean(folder) && type === RuleFormType.grafana;
const shouldRenderPreview = type === RuleFormType.grafana;
const NotificationsStepDescription = () => {
return (
<div className={styles.stepDescription}>
<div>Add custom labels to change the way your notifications are routed.</div>
<Stack direction="row" gap={0.5} alignItems="baseline">
<Text variant="bodySmall" color="secondary">
Add custom labels to change the way your notifications are routed.
</Text>
<NeedHelpInfo
contentText={
<Stack gap={1}>
@ -55,9 +52,9 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
target="_blank"
rel="noreferrer"
>
<div className={styles.infoLink}>
<Text color="link">
Read about notification routing. <Icon name="external-link-alt" />
</div>
</Text>
</a>
</Stack>
<Stack direction="row" gap={0}>
@ -70,16 +67,16 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
target="_blank"
rel="noreferrer"
>
<div className={styles.infoLink}>
<Text color="link">
Read about Labels and annotations. <Icon name="external-link-alt" />
</div>
</Text>
</a>
</Stack>
</Stack>
}
title="Notification routing"
/>
</div>
</Stack>
);
};
@ -88,31 +85,20 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
stepNo={type === RuleFormType.cloudRecording ? 4 : 5}
title={type === RuleFormType.cloudRecording ? 'Add labels' : 'Configure notifications'}
description={
type === RuleFormType.cloudRecording ? (
'Add labels to help you better manage your recording rules'
<Stack direction="row" gap={0.5} alignItems="baseline">
{type === RuleFormType.cloudRecording ? (
<Text variant="bodySmall" color="secondary">
Add labels to help you better manage your recording rules
</Text>
) : (
<NotificationsStepDescription />
)
}
>
<div className={styles.contentWrapper}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{!hasLabelsDefined && type !== RuleFormType.cloudRecording && (
<Card className={styles.card}>
<Card.Heading>Default policy</Card.Heading>
<Card.Description>
All alert instances are handled by the default policy if no other matching policies are found. To view
and edit the default policy, go to <Link href="/alerting/routes">Notification Policies</Link>
&nbsp;or contact your Admin if you are using provisioning.
</Card.Description>
</Card>
)}
</Stack>
}
fullWidth
>
<LabelsField dataSourceName={dataSourceName} />
</div>
</div>
{shouldRenderPreview &&
condition &&
folder && ( // need to check for condition and folder again because of typescript
{shouldRenderPreview && (
<NotificationPreview
alertQueries={queries}
customLabels={labels}
@ -125,43 +111,3 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
</RuleEditorSection>
);
};
interface Label {
key: string;
value: string;
}
function getNonEmptyLabels(labels: Label[]) {
return labels.filter((label) => label.key && label.value);
}
const getStyles = (theme: GrafanaTheme2) => ({
contentWrapper: css`
display: flex;
align-items: center;
margin-top: ${theme.spacing(2)};
`,
hideButton: css`
color: ${theme.colors.text.secondary};
cursor: pointer;
margin-bottom: ${theme.spacing(1)};
`,
card: css`
max-width: 500px;
`,
flowChart: css`
margin-right: ${theme.spacing(3)};
`,
title: css`
margin-bottom: ${theme.spacing(2)};
`,
stepDescription: css`
margin-bottom: ${theme.spacing(2)};
display: flex;
gap: ${theme.spacing(1)};
)};
`,
infoLink: css`
color: ${theme.colors.text.link};
`,
});

View File

@ -1,70 +1,58 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import React, { ReactElement } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { FieldSet, useStyles2 } from '@grafana/ui';
import { Stack } from '@grafana/experimental';
import { FieldSet, Text, useStyles2 } from '@grafana/ui';
export interface RuleEditorSectionProps {
title: string;
stepNo: number;
description?: string | ReactElement;
fullWidth?: boolean;
}
export const RuleEditorSection = ({
title,
stepNo,
children,
fullWidth = false,
description,
}: React.PropsWithChildren<RuleEditorSectionProps>) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.parent}>
<div>
<span className={styles.stepNo}>{stepNo}</span>
</div>
<div className={styles.content}>
<FieldSet label={title} className={styles.fieldset}>
<FieldSet
className={cx(fullWidth && styles.fullWidth)}
label={
<Text variant="h3">
{stepNo}. {title}
</Text>
}
>
<Stack direction="column">
{description && <div className={styles.description}>{description}</div>}
{children}
</Stack>
</FieldSet>
</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
fieldset: css`
legend {
font-size: 16px;
padding-top: ${theme.spacing(0.5)};
}
`,
parent: css`
display: flex;
flex-direction: row;
max-width: ${theme.breakpoints.values.xl};
& + & {
margin-top: ${theme.spacing(4)};
}
max-width: ${theme.breakpoints.values.xl}px;
border: solid 1px ${theme.colors.border.weak};
border-radius: ${theme.shape.radius.default};
padding: ${theme.spacing(2)} ${theme.spacing(3)};
`,
description: css`
margin-top: -${theme.spacing(2)};
color: ${theme.colors.text.secondary};
`,
stepNo: css`
display: inline-block;
width: ${theme.spacing(4)};
height: ${theme.spacing(4)};
line-height: ${theme.spacing(4)};
border-radius: ${theme.shape.radius.circle};
text-align: center;
color: ${theme.colors.text.maxContrast};
background-color: ${theme.colors.background.canvas};
font-size: ${theme.typography.size.lg};
margin-right: ${theme.spacing(2)};
`,
content: css`
flex: 1;
fullWidth: css`
width: 100%;
`,
});

View File

@ -139,7 +139,7 @@ describe('NotificationPreview', () => {
mockOneAlertManager();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, {
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />, {
wrapper: TestProvider,
});
@ -147,20 +147,25 @@ describe('NotificationPreview', () => {
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
// we expect the alert manager label to be missing as there is only one alert manager configured to receive alerts
await waitFor(() => {
expect(ui.grafanaAlertManagerLabel.query()).not.toBeInTheDocument();
expect(ui.otherAlertManagerLabel.query()).not.toBeInTheDocument();
});
await waitFor(() => {
const matchingPoliciesElements = ui.route.queryAll();
expect(matchingPoliciesElements).toHaveLength(1);
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
});
});
it('should render notification preview with alert manager sections, when having more than one alert manager configured to receive alerts', async () => {
// two alert managers configured to receive alerts
mockTwoAlertManagers();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, {
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />, {
wrapper: TestProvider,
});
await waitFor(() => {
@ -171,14 +176,12 @@ describe('NotificationPreview', () => {
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
// we expect the alert manager label to be present as there is more than one alert manager configured to receive alerts
await waitFor(() => {
expect(ui.grafanaAlertManagerLabel.query()).toBeInTheDocument();
expect(ui.otherAlertManagerLabel.query()).toBeInTheDocument();
});
const matchingPoliciesElements = ui.route.queryAll();
expect(matchingPoliciesElements).toHaveLength(2);
@ -191,7 +194,7 @@ describe('NotificationPreview', () => {
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
mockHasEditPermission(true);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, {
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />, {
wrapper: TestProvider,
});
await waitFor(() => {
@ -222,7 +225,7 @@ describe('NotificationPreview', () => {
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
mockHasEditPermission(false);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, {
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />, {
wrapper: TestProvider,
});
await waitFor(() => {

View File

@ -20,12 +20,14 @@ interface NotificationPreviewProps {
value: string;
}>;
alertQueries: AlertQuery[];
condition: string;
folder: Folder;
condition: string | null;
folder: Folder | null;
alertName?: string;
alertUid?: string;
}
// TODO the scroll position keeps resetting when we preview
// this is to be expected because the list of routes dissapears as we start the request but is very annoying
export const NotificationPreview = ({
alertQueries,
customLabels,
@ -35,16 +37,21 @@ export const NotificationPreview = ({
alertUid,
}: NotificationPreviewProps) => {
const styles = useStyles2(getStyles);
const disabled = !condition || !folder;
const { usePreviewMutation } = alertRuleApi;
const previewEndpoint = alertRuleApi.endpoints.preview;
const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = usePreviewMutation();
const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = previewEndpoint.useMutation();
// potential instances are the instances that are going to be routed to the notification policies
// convert data to list of labels: are the representation of the potential instances
const potentialInstances = compact(data.flatMap((label) => label?.labels));
const onPreview = () => {
if (!folder || !condition) {
return;
}
// Get the potential labels given the alert queries, the condition and the custom labels (autogenerated labels are calculated on the BE side)
trigger({
alertQueries: alertQueries,
@ -60,32 +67,35 @@ export const NotificationPreview = ({
const alertManagerSourceNamesAndImage = useGetAlertManagersSourceNamesAndImage();
const onlyOneAM = alertManagerSourceNamesAndImage.length === 1;
const renderHowToPreview = !Boolean(data?.length) && !isLoading;
return (
<Stack direction="column" gap={2}>
<Stack direction="column">
<div className={styles.routePreviewHeaderRow}>
<div className={styles.previewHeader}>
<Text element="h4">Alert instance routing preview</Text>
{isLoading && previewUninitialized && (
<Text color="secondary" variant="bodySmall">
Loading...
</Text>
)}
{previewUninitialized ? (
<Text color="secondary" variant="bodySmall">
When you have your folder selected and your query and labels are configured, click &quot;Preview
routing&quot; to see the results here.
</Text>
) : (
<Text color="secondary" variant="bodySmall">
Based on the labels added, alert instances are routed to the following notification policies. Expand each
notification policy below to view more details.
</Text>
)}
</div>
<div className={styles.button}>
<Button icon="sync" variant="secondary" type="button" onClick={onPreview}>
<Button icon="sync" variant="secondary" type="button" onClick={onPreview} disabled={disabled}>
Preview routing
</Button>
</div>
</div>
{!renderHowToPreview && (
<div className={styles.textMuted}>
Based on the labels added, alert instances are routed to the following notification policies. Expand each
notification policy below to view more details.
</div>
)}
{isLoading && <div className={styles.textMuted}>Loading...</div>}
{renderHowToPreview && (
<div className={styles.previewHowToText}>
{`When your query and labels are configured, click "Preview routing" to see the results here.`}
</div>
)}
{!isLoading && !previewUninitialized && potentialInstances.length > 0 && (
<Suspense fallback={<LoadingPlaceholder text="Loading preview..." />}>
{alertManagerSourceNamesAndImage.map((alertManagerSource) => (
@ -107,15 +117,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
width: auto;
border: 0;
`,
textMuted: css`
color: ${theme.colors.text.secondary};
`,
previewHowToText: css`
display: flex;
color: ${theme.colors.text.secondary};
justify-content: center;
font-size: ${theme.typography.size.sm};
`,
previewHeader: css`
margin: 0;
`,
@ -123,14 +124,13 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
align-items: flex-start;
`,
collapseLabel: css`
flex: 1;
`,
button: css`
justify-content: flex-end;
display: flex;
`,
tagsInDetails: css`
display: flex;

View File

@ -88,7 +88,6 @@ export default withErrorBoundary(NotificationPreviewByAlertManager);
const getStyles = (theme: GrafanaTheme2) => ({
alertManagerRow: css`
margin-top: ${theme.spacing(2)};
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};

View File

@ -342,6 +342,22 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
<RuleEditorSection
stepNo={2}
title={type !== RuleFormType.cloudRecording ? 'Define query and alert condition' : 'Define query'}
description={
<Stack direction="row" gap={0.5} alignItems="baseline">
<Text variant="bodySmall" color="secondary">
Define queries and/or expressions and then choose one of them as the alert rule condition. This is the
threshold that an alert rule must meet or exceed in order to fire.
</Text>
<NeedHelpInfo
contentText={`An alert rule consists of one or more queries and expressions that select the data you want to measure.
Define queries and/or expressions and then choose one of them as the alert rule condition. This is the threshold that an alert rule must meet or exceed in order to fire.
For more information on queries and expressions, see Query and transform data.`}
externalLink={`https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/`}
linkText={`Read about query and condition`}
title="Define query and alert condition"
/>
</Stack>
}
>
{/* This is the cloud data source selector */}
{(type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) && (
@ -396,22 +412,6 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
{isGrafanaManagedType && (
<Stack direction="column">
{/* Data Queries */}
<Stack direction="row" gap={1} alignItems="baseline">
<div className={styles.mutedText}>
Define queries and/or expressions and then choose one of them as the alert rule condition. This is the
threshold that an alert rule must meet or exceed in order to fire.
</div>
<NeedHelpInfo
contentText={`An alert rule consists of one or more queries and expressions that select the data you want to measure.
Define queries and/or expressions and then choose one of them as the alert rule condition. This is the threshold that an alert rule must meet or exceed in order to fire.
For more information on queries and expressions, see Query and transform data.`}
externalLink={`https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/`}
linkText={`Read about query and condition`}
title="Define query and alert condition"
/>
</Stack>
<QueryEditor
queries={dataQueries}
expressions={expressionQueries}
@ -443,8 +443,13 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
onClickSwitch={onClickSwitch}
/>
{/* Expression Queries */}
<Stack direction="column" gap={0}>
<Text element="h5">Expressions</Text>
<div className={styles.mutedText}>Manipulate data returned from queries with math and other operations.</div>
<Text variant="bodySmall" color="secondary">
Manipulate data returned from queries with math and other operations.
</Text>
</Stack>
<ExpressionsEditor
queries={queries}
panelData={queryPreviewData}
@ -515,11 +520,6 @@ function TypeSelectorButton({ onClickType }: { onClickType: (type: ExpressionQue
}
const getStyles = (theme: GrafanaTheme2) => ({
mutedText: css`
color: ${theme.colors.text.secondary};
font-size: ${theme.typography.size.sm};
margin-top: ${theme.spacing(-1)};
`,
addQueryButton: css`
width: fit-content;
`,

View File

@ -1,11 +1,10 @@
import { css } from '@emotion/css';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { DataSourceInstanceSettings } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { DataSourceJsonData } from '@grafana/schema';
import { Alert, useStyles2 } from '@grafana/ui';
import { RadioButtonGroup, Text } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { ExpressionDatasourceUID } from 'app/features/expressions/types';
import { AccessControlAction } from 'app/types';
@ -39,13 +38,11 @@ const onlyOneDSInQueries = (queries: AlertQuery[]) => {
const getCanSwitch = ({
queries,
ruleFormType,
editingExistingRule,
rulesSourcesWithRuler,
}: {
rulesSourcesWithRuler: Array<DataSourceInstanceSettings<DataSourceJsonData>>;
queries: AlertQuery[];
ruleFormType: RuleFormType | undefined;
editingExistingRule: boolean;
}) => {
// get available rule types
const availableRuleTypes = getAvailableRuleTypes();
@ -57,12 +54,11 @@ const getCanSwitch = ({
//let's check if we switch to cloud type
const canSwitchToCloudRule =
!editingExistingRule &&
!isRecordingRuleType &&
onlyOneDS &&
rulesSourcesWithRuler.some((dsJsonData) => dsJsonData.uid === dataSourceIdFromQueries);
const canSwitchToGrafanaRule = !editingExistingRule && !isRecordingRuleType;
const canSwitchToGrafanaRule = !isRecordingRuleType;
// check for enabled types
const grafanaTypeEnabled = availableRuleTypes.enabledRuleTypes.includes(RuleFormType.grafana);
const cloudTypeEnabled = availableRuleTypes.enabledRuleTypes.includes(RuleFormType.cloudAlerting);
@ -83,43 +79,6 @@ export interface SmartAlertTypeDetectorProps {
onClickSwitch: () => void;
}
const getContentText = (ruleFormType: RuleFormType, isEditing: boolean, dataSourceName: string, canSwitch: boolean) => {
if (isEditing) {
if (ruleFormType === RuleFormType.grafana) {
return {
contentText: `Grafana-managed alert rules allow you to create alerts that can act on data from any of our supported data sources, including having multiple data sources in the same rule. You can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported. `,
title: `This alert rule is managed by Grafana.`,
};
} else {
return {
contentText: `Data source-managed alert rules can be used for Grafana Mimir or Grafana Loki data sources which have been configured to support rule creation. The use of expressions or multiple queries is not supported.`,
title: `This alert rule is managed by the data source ${dataSourceName}.`,
};
}
}
if (canSwitch) {
if (ruleFormType === RuleFormType.cloudAlerting) {
return {
contentText:
'Data source-managed alert rules can be used for Grafana Mimir or Grafana Loki data sources which have been configured to support rule creation. The use of expressions or multiple queries is not supported.',
title: `This alert rule is managed by the data source ${dataSourceName}. If you want to use expressions or have multiple queries, switch to a Grafana-managed alert rule.`,
};
} else {
return {
contentText:
'Grafana-managed alert rules allow you to create alerts that can act on data from any of our supported data sources, including having multiple data sources in the same rule. You can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported.',
title: `This alert rule will be managed by Grafana. The selected data source is configured to support rule creation.`,
};
}
} else {
// it can be only grafana rule
return {
contentText: `Grafana-managed alert rules allow you to create alerts that can act on data from any of our supported data sources, including having multiple data sources in the same rule. You can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported.`,
title: `Based on the data sources selected, this alert rule is managed by Grafana.`,
};
}
};
export function SmartAlertTypeDetector({
editingExistingRule,
rulesSourcesWithRuler,
@ -127,54 +86,77 @@ export function SmartAlertTypeDetector({
onClickSwitch,
}: SmartAlertTypeDetectorProps) {
const { getValues } = useFormContext<RuleFormValues>();
const [ruleFormType] = getValues(['type']);
const canSwitch = getCanSwitch({ queries, ruleFormType, rulesSourcesWithRuler });
const [ruleFormType, dataSourceName] = getValues(['type', 'dataSourceName']);
const styles = useStyles2(getStyles);
const options = [
{ label: 'Grafana-managed', value: RuleFormType.grafana },
{ label: 'Data source-managed', value: RuleFormType.cloudAlerting },
];
const canSwitch = getCanSwitch({ queries, ruleFormType, editingExistingRule, rulesSourcesWithRuler });
const typeTitle =
ruleFormType === RuleFormType.cloudAlerting ? 'Data source-managed alert rule' : 'Grafana-managed alert rule';
const switchToLabel = ruleFormType !== RuleFormType.cloudAlerting ? 'data source-managed' : 'Grafana-managed';
const content = ruleFormType
? getContentText(ruleFormType, editingExistingRule, dataSourceName ?? '', canSwitch)
: undefined;
// if we can't switch to data-source managed, disable it
// TODO figure out how to show a popover to the user to indicate _why_ it's disabled
const disabledOptions = canSwitch ? [] : [RuleFormType.cloudAlerting];
return (
<div className={styles.alert}>
<Alert
severity="info"
title={typeTitle}
onRemove={canSwitch ? onClickSwitch : undefined}
buttonContent={`Switch to ${switchToLabel} alert rule`}
>
<Stack gap={0.5} direction="row" alignItems={'baseline'}>
<div className={styles.alertText}>{content?.title}</div>
<div className={styles.needInfo}>
<Stack direction="column" gap={1} alignItems="flex-start">
<Stack direction="column" gap={0}>
<Text variant="h5">Rule type</Text>
<Stack direction="row" gap={0.5} alignItems="baseline">
<Text variant="bodySmall" color="secondary">
Select where the alert rule will be managed.
</Text>
<NeedHelpInfo
contentText={content?.contentText ?? ''}
externalLink={`https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/alert-rule-types/`}
linkText={`Read about alert rule types`}
title=" Alert rule types"
contentText={
<>
<Text color="primary" variant="h6">
Grafana-managed alert rules
</Text>
<p>
Grafana-managed alert rules allow you to create alerts that can act on data from any of our supported
data sources, including having multiple data sources in the same rule. You can also add expressions to
transform your data and set alert conditions. Using images in alert notifications is also supported.
</p>
<Text color="primary" variant="h6">
Data source-managed alert rules
</Text>
<p>
Data source-managed alert rules can be used for Grafana Mimir or Grafana Loki data sources which have
been configured to support rule creation. The use of expressions or multiple queries is not supported.
</p>
</>
}
externalLink="https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/alert-rule-types/"
linkText="Read about alert rule types"
title="Alert rule types"
/>
</div>
</Stack>
</Alert>
</div>
</Stack>
<RadioButtonGroup
options={options}
disabled={editingExistingRule}
disabledOptions={disabledOptions}
value={ruleFormType}
onChange={onClickSwitch}
/>
{/* editing an existing rule, we just show "cannot be changed" */}
{editingExistingRule && (
<Text color="secondary">The alert rule type cannot be changed for an existing rule.</Text>
)}
{/* in regular alert creation we tell the user what options they have when using a cloud data source */}
{!editingExistingRule && (
<>
{canSwitch ? (
<Text color="secondary">
{ruleFormType === RuleFormType.grafana
? 'The data source selected in your query supports alert rule management. Switch to data source-managed if you want the alert rule to be managed by the data source instead of Grafana.'
: 'Switch to Grafana-managed to use expressions, multiple queries, images in notifications and various other features.'}
</Text>
) : (
<Text color="secondary">Based on the selected data sources this alert rule will be Grafana-managed.</Text>
)}
</>
)}
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
alertText: css`
max-width: fit-content;
flex: 1;
`,
alert: css`
margin-top: ${theme.spacing(2)};
`,
needInfo: css`
flex: 1;
max-width: fit-content;
`,
});

View File

@ -2,16 +2,27 @@ import { rest } from 'msw';
import { SetupServer } from 'msw/node';
import {
AlertmanagerChoice,
AlertManagerCortexConfig,
ExternalAlertmanagersResponse,
} from '../../../../plugins/datasource/alertmanager/types';
import { AlertmanagersChoiceResponse } from '../api/alertmanagerApi';
import { getDatasourceAPIUid } from '../utils/datasource';
export const defaultAlertmanagerChoiceResponse: AlertmanagersChoiceResponse = {
alertmanagersChoice: AlertmanagerChoice.Internal,
numExternalAlertmanagers: 0,
};
export function mockAlertmanagerChoiceResponse(server: SetupServer, response: AlertmanagersChoiceResponse) {
server.use(rest.get('/api/v1/ngalert', (req, res, ctx) => res(ctx.status(200), ctx.json(response))));
}
export const emptyExternalAlertmanagersResponse: ExternalAlertmanagersResponse = {
data: {
droppedAlertManagers: [],
activeAlertManagers: [],
},
};
export function mockAlertmanagersResponse(server: SetupServer, response: ExternalAlertmanagersResponse) {
server.use(rest.get('/api/v1/ngalert/alertmanagers', (req, res, ctx) => res(ctx.status(200), ctx.json(response))));
}

View File

@ -11,7 +11,7 @@ import { TestProvider } from './TestProvider';
export const ui = {
inputs: {
name: byRole('textbox', { name: /rule name name for the alert rule\./i }),
name: byRole('textbox', { name: 'name' }),
alertType: byTestId('alert-type-picker'),
dataSource: byTestId('datasource-picker'),
folder: byTestId('folder-picker'),