mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
513 lines
18 KiB
TypeScript
513 lines
18 KiB
TypeScript
import { css, cx } from '@emotion/css';
|
||
import { addMinutes, subDays, subHours } from 'date-fns';
|
||
import { Location } from 'history';
|
||
import { useRef, useState } from 'react';
|
||
import { FormProvider, useForm } from 'react-hook-form';
|
||
import { useToggle } from 'react-use';
|
||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||
|
||
import { GrafanaTheme2 } from '@grafana/data';
|
||
import { isFetchError, locationService } from '@grafana/runtime';
|
||
import {
|
||
Alert,
|
||
Box,
|
||
Button,
|
||
Drawer,
|
||
Dropdown,
|
||
FieldSet,
|
||
InlineField,
|
||
Input,
|
||
LinkButton,
|
||
Menu,
|
||
Stack,
|
||
Text,
|
||
useSplitter,
|
||
useStyles2,
|
||
} from '@grafana/ui';
|
||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||
import { useCleanup } from 'app/core/hooks/useCleanup';
|
||
import { Trans, t } from 'app/core/internationalization';
|
||
import { ActiveTab as ContactPointsActiveTabs } from 'app/features/alerting/unified/components/contact-points/ContactPoints';
|
||
import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types';
|
||
|
||
import { AppChromeUpdate } from '../../../../../core/components/AppChrome/AppChromeUpdate';
|
||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||
import { makeAMLink, stringifyErrorLike } from '../../utils/misc';
|
||
import { initialAsyncRequestState } from '../../utils/redux';
|
||
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
|
||
import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader';
|
||
import {
|
||
NotificationTemplate,
|
||
useCreateNotificationTemplate,
|
||
useNotificationTemplateMetadata,
|
||
useUpdateNotificationTemplate,
|
||
useValidateNotificationTemplate,
|
||
} from '../contact-points/useNotificationTemplates';
|
||
|
||
import { PayloadEditor } from './PayloadEditor';
|
||
import { TemplateDataDocs } from './TemplateDataDocs';
|
||
import { GlobalTemplateDataExamples } from './TemplateDataExamples';
|
||
import { TemplateEditor } from './TemplateEditor';
|
||
import { TemplatePreview } from './TemplatePreview';
|
||
import { snippets } from './editor/templateDataSuggestions';
|
||
|
||
export interface TemplateFormValues {
|
||
title: string;
|
||
content: string;
|
||
}
|
||
|
||
export const defaults: TemplateFormValues = Object.freeze({
|
||
title: '',
|
||
content: '',
|
||
});
|
||
|
||
interface Props {
|
||
originalTemplate?: NotificationTemplate;
|
||
prefill?: TemplateFormValues;
|
||
alertmanager: string;
|
||
}
|
||
|
||
export const isDuplicating = (location: Location) => location.pathname.endsWith('/duplicate');
|
||
|
||
/**
|
||
* We're going for this type of layout, but with the ability to resize the columns.
|
||
* To achieve this, we're using the useSplitter hook from Grafana UI twice.
|
||
* The first hook is for the vertical splitter between the template editor and the payload editor.
|
||
* The second hook is for the horizontal splitter between the template editor and the preview.
|
||
* If we're using a vanilla Alertmanager source, we don't show the payload editor nor the preview but we still use the splitter at 100/0.
|
||
*
|
||
* ┌───────────────────┐┌───────────┐
|
||
* │ Template ││ Preview │
|
||
* │ ││ │
|
||
* │ ││ │
|
||
* │ ││ │
|
||
* └───────────────────┘│ │
|
||
* ┌───────────────────┐│ │
|
||
* │ Payload ││ │
|
||
* │ ││ │
|
||
* │ ││ │
|
||
* │ ││ │
|
||
* └───────────────────┘└───────────┘
|
||
*/
|
||
export const TemplateForm = ({ originalTemplate, prefill, alertmanager }: Props) => {
|
||
const styles = useStyles2(getStyles);
|
||
|
||
const appNotification = useAppNotification();
|
||
|
||
const [createNewTemplate] = useCreateNotificationTemplate({ alertmanager });
|
||
const [updateTemplate] = useUpdateNotificationTemplate({ alertmanager });
|
||
const { titleIsUnique } = useValidateNotificationTemplate({ alertmanager, originalTemplate });
|
||
|
||
useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState));
|
||
const formRef = useRef<HTMLFormElement>(null);
|
||
const isGrafanaAlertManager = alertmanager === GRAFANA_RULES_SOURCE_NAME;
|
||
|
||
const { error } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
|
||
|
||
const [cheatsheetOpened, toggleCheatsheetOpened] = useToggle(false);
|
||
|
||
const [payload, setPayload] = useState(defaultPayloadString);
|
||
const [payloadFormatError, setPayloadFormatError] = useState<string | null>(null);
|
||
|
||
const { isProvisioned } = useNotificationTemplateMetadata(originalTemplate);
|
||
const originalTemplatePrefill: TemplateFormValues | undefined = originalTemplate
|
||
? { title: originalTemplate.title, content: originalTemplate.content }
|
||
: undefined;
|
||
|
||
// splitter for template and payload editor
|
||
const columnSplitter = useSplitter({
|
||
direction: 'column',
|
||
// if Grafana Alertmanager, split 50/50, otherwise 100/0 because there is no payload editor
|
||
initialSize: isGrafanaAlertManager ? 0.5 : 1,
|
||
dragPosition: 'middle',
|
||
});
|
||
|
||
// splitter for template editor and preview
|
||
const rowSplitter = useSplitter({
|
||
direction: 'row',
|
||
// if Grafana Alertmanager, split 60/40, otherwise 100/0 because there is no preview
|
||
initialSize: isGrafanaAlertManager ? 0.6 : 1,
|
||
dragPosition: 'middle',
|
||
});
|
||
|
||
const formApi = useForm<TemplateFormValues>({
|
||
mode: 'onSubmit',
|
||
defaultValues: prefill ?? originalTemplatePrefill ?? defaults,
|
||
});
|
||
const {
|
||
handleSubmit,
|
||
register,
|
||
formState: { errors, isSubmitting },
|
||
getValues,
|
||
setValue,
|
||
watch,
|
||
} = formApi;
|
||
|
||
const submit = async (values: TemplateFormValues) => {
|
||
const returnLink = makeAMLink('/alerting/notifications', alertmanager, {
|
||
tab: ContactPointsActiveTabs.NotificationTemplates,
|
||
});
|
||
|
||
try {
|
||
if (!originalTemplate) {
|
||
await createNewTemplate.execute({ templateValues: values });
|
||
} else {
|
||
await updateTemplate.execute({ template: originalTemplate, patch: values });
|
||
}
|
||
appNotification.success('Template saved', `Template ${values.title} has been saved`);
|
||
locationService.push(returnLink);
|
||
} catch (error) {
|
||
appNotification.error('Error saving template', stringifyErrorLike(error));
|
||
}
|
||
};
|
||
|
||
const appendExample = (example: string) => {
|
||
const content = getValues('content'),
|
||
newValue = !content ? example : `${content}\n${example}`;
|
||
setValue('content', newValue);
|
||
};
|
||
|
||
const actionButtons = (
|
||
<Stack>
|
||
<Button onClick={() => formRef.current?.requestSubmit()} variant="primary" size="sm" disabled={isSubmitting}>
|
||
<Trans i18nKey="common.save">Save</Trans>
|
||
</Button>
|
||
<LinkButton
|
||
disabled={isSubmitting}
|
||
href={makeAMLink('alerting/notifications', alertmanager, {
|
||
tab: ContactPointsActiveTabs.NotificationTemplates,
|
||
})}
|
||
variant="secondary"
|
||
size="sm"
|
||
>
|
||
<Trans i18nKey="common.cancel">Cancel</Trans>
|
||
</LinkButton>
|
||
</Stack>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<FormProvider {...formApi}>
|
||
<AppChromeUpdate actions={actionButtons} />
|
||
<form onSubmit={handleSubmit(submit)} ref={formRef} className={styles.form} aria-label="Template form">
|
||
{/* error message */}
|
||
{error && (
|
||
<Alert severity="error" title="Error saving template">
|
||
{error.message || (isFetchError(error) && error.data?.message) || String(error)}
|
||
</Alert>
|
||
)}
|
||
{/* warning about provisioned template */}
|
||
{isProvisioned && (
|
||
<Box grow={0}>
|
||
<ProvisioningAlert resource={ProvisionedResource.Template} />
|
||
</Box>
|
||
)}
|
||
|
||
{/* name field for the template */}
|
||
<FieldSet disabled={isProvisioned} className={styles.fieldset}>
|
||
<InlineField
|
||
label="Template group name"
|
||
error={errors?.title?.message}
|
||
invalid={!!errors.title?.message}
|
||
required
|
||
className={styles.nameField}
|
||
>
|
||
<Input
|
||
{...register('title', {
|
||
required: { value: true, message: 'Required.' },
|
||
validate: { titleIsUnique },
|
||
})}
|
||
placeholder="Give your template group a name"
|
||
width={42}
|
||
autoFocus={true}
|
||
id="new-template-name"
|
||
/>
|
||
</InlineField>
|
||
|
||
{/* editor layout */}
|
||
<div {...rowSplitter.containerProps} className={styles.contentContainer}>
|
||
<div {...rowSplitter.primaryProps}>
|
||
{/* template content and payload editor column – full height and half-width */}
|
||
<div {...columnSplitter.containerProps} className={styles.contentField}>
|
||
{/* template editor */}
|
||
<div {...columnSplitter.primaryProps}>
|
||
{/* primaryProps will set "minHeight: min-content;" so we have to make sure to apply minHeight to the child */}
|
||
<div className={cx(styles.flexColumn, styles.containerWithBorderAndRadius, styles.minEditorSize)}>
|
||
<div>
|
||
<EditorColumnHeader
|
||
label="Template group"
|
||
actions={
|
||
<>
|
||
{/* examples dropdown – only available for Grafana Alertmanager */}
|
||
{isGrafanaAlertManager && (
|
||
<Dropdown
|
||
overlay={
|
||
<Menu>
|
||
{GlobalTemplateDataExamples.map((item, index) => (
|
||
<Menu.Item
|
||
key={index}
|
||
label={item.description}
|
||
onClick={() => appendExample(item.example)}
|
||
/>
|
||
))}
|
||
<Menu.Divider />
|
||
<Menu.Item
|
||
label={'Examples documentation'}
|
||
url="https://grafana.com/docs/grafana/latest/alerting/configure-notifications/template-notifications/examples/"
|
||
target="_blank"
|
||
icon="external-link-alt"
|
||
/>
|
||
</Menu>
|
||
}
|
||
>
|
||
<Button variant="secondary" size="sm" icon="angle-down">
|
||
<Trans i18nKey="alerting.templates.editor.add-example">Add example</Trans>
|
||
</Button>
|
||
</Dropdown>
|
||
)}
|
||
<Button
|
||
icon="question-circle"
|
||
size="sm"
|
||
fill="outline"
|
||
variant="secondary"
|
||
onClick={toggleCheatsheetOpened}
|
||
>
|
||
<Trans i18nKey="common.help">Help</Trans>
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
</div>
|
||
<Box flex={1}>
|
||
<AutoSizer>
|
||
{({ width, height }) => (
|
||
<TemplateEditor
|
||
value={getValues('content')}
|
||
onBlur={(value) => setValue('content', value)}
|
||
containerStyles={styles.editorContainer}
|
||
width={width}
|
||
height={height}
|
||
/>
|
||
)}
|
||
</AutoSizer>
|
||
</Box>
|
||
</div>
|
||
</div>
|
||
{/* payload editor – only available for Grafana Alertmanager */}
|
||
{isGrafanaAlertManager && (
|
||
<>
|
||
<div {...columnSplitter.splitterProps} />
|
||
<div {...columnSplitter.secondaryProps}>
|
||
<div
|
||
className={cx(
|
||
styles.containerWithBorderAndRadius,
|
||
styles.minEditorSize,
|
||
styles.payloadEditor,
|
||
styles.flexFull
|
||
)}
|
||
>
|
||
<PayloadEditor
|
||
payload={payload}
|
||
defaultPayload={defaultPayloadString}
|
||
setPayload={setPayload}
|
||
setPayloadFormatError={setPayloadFormatError}
|
||
payloadFormatError={payloadFormatError}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* preview column – full height and half-width */}
|
||
{isGrafanaAlertManager && (
|
||
<>
|
||
<div {...rowSplitter.secondaryProps}>
|
||
<div {...rowSplitter.splitterProps} />
|
||
<TemplatePreview
|
||
payload={payload}
|
||
templateName={watch('title')}
|
||
setPayloadFormatError={setPayloadFormatError}
|
||
payloadFormatError={payloadFormatError}
|
||
className={cx(styles.templatePreview, styles.minEditorSize)}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</FieldSet>
|
||
</form>
|
||
</FormProvider>
|
||
{cheatsheetOpened && (
|
||
<Drawer title="Templating cheat sheet" onClose={toggleCheatsheetOpened} size="lg">
|
||
<TemplatingCheatSheet />
|
||
</Drawer>
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
function TemplatingBasics() {
|
||
const styles = useStyles2(getStyles);
|
||
|
||
const intro = t(
|
||
'alerting.templates.help.intro',
|
||
`Notification templates use Go templating language to create notification messages.
|
||
|
||
In Grafana, a template group can define multiple notification templates using {{ define "<NAME>" }}.
|
||
These templates can then be used in contact points and within other notification templates by calling {{ template "<NAME>" }}.
|
||
For detailed information about notification templates, refer to our documentation.`
|
||
);
|
||
|
||
return (
|
||
<Alert title="" severity="info">
|
||
<Stack direction="column" gap={2}>
|
||
<Stack direction="row">
|
||
<div style={{ whiteSpace: 'pre' }}>{intro}</div>
|
||
<div>
|
||
<LinkButton
|
||
href="https://grafana.com/docs/grafana/latest/alerting/manage-notifications/template-notifications/"
|
||
target="_blank"
|
||
icon="external-link-alt"
|
||
variant="secondary"
|
||
>
|
||
<Trans i18nKey="alerting.templates.editor.goto-docs">Notification templates documentation</Trans>
|
||
</LinkButton>
|
||
</div>
|
||
</Stack>
|
||
|
||
<Text variant="bodySmall">
|
||
<Trans i18nKey="alerting.templates.editor.auto-complete">
|
||
For auto-completion of common templating code, type the following keywords in the content editor:
|
||
</Trans>
|
||
<div className={styles.code}>
|
||
{Object.values(snippets)
|
||
.map((s) => s.label)
|
||
.join(', ')}
|
||
</div>
|
||
</Text>
|
||
</Stack>
|
||
</Alert>
|
||
);
|
||
}
|
||
|
||
function TemplatingCheatSheet() {
|
||
return (
|
||
<Stack direction="column" gap={1}>
|
||
<TemplatingBasics />
|
||
<TemplateDataDocs />
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
export const getStyles = (theme: GrafanaTheme2) => {
|
||
const narrowScreenQuery = theme.breakpoints.down('md');
|
||
|
||
return {
|
||
flexFull: css({
|
||
flex: 1,
|
||
}),
|
||
minEditorSize: css({
|
||
minHeight: 300,
|
||
minWidth: 300,
|
||
}),
|
||
payloadEditor: css({
|
||
minHeight: 0,
|
||
}),
|
||
containerWithBorderAndRadius: css({
|
||
borderRadius: theme.shape.radius.default,
|
||
border: `1px solid ${theme.colors.border.medium}`,
|
||
}),
|
||
flexColumn: css({
|
||
display: 'flex',
|
||
flex: 1,
|
||
flexDirection: 'column',
|
||
}),
|
||
form: css({
|
||
label: 'template-form',
|
||
height: '100%',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
}),
|
||
fieldset: css({
|
||
label: 'template-fieldset',
|
||
flex: 1,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
}),
|
||
label: css({
|
||
margin: 0,
|
||
}),
|
||
nameField: css({
|
||
marginBottom: theme.spacing(1),
|
||
}),
|
||
contentContainer: css({
|
||
flex: 1,
|
||
display: 'flex',
|
||
flexDirection: 'row',
|
||
}),
|
||
contentField: css({
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
flex: 1,
|
||
marginBottom: 0,
|
||
}),
|
||
templatePreview: css({
|
||
flex: 1,
|
||
display: 'flex',
|
||
}),
|
||
templatePayload: css({
|
||
flex: 1,
|
||
}),
|
||
editorContainer: css({
|
||
width: 'fit-content',
|
||
border: 'none',
|
||
}),
|
||
payloadCollapseButton: css({
|
||
backgroundColor: theme.colors.info.transparent,
|
||
margin: 0,
|
||
[narrowScreenQuery]: {
|
||
display: 'none',
|
||
},
|
||
}),
|
||
code: css({
|
||
color: theme.colors.text.secondary,
|
||
fontWeight: theme.typography.fontWeightBold,
|
||
}),
|
||
};
|
||
};
|
||
|
||
const defaultPayload: TestTemplateAlert[] = [
|
||
{
|
||
status: 'firing',
|
||
annotations: {
|
||
summary: 'Instance instance1 has been down for more than 5 minutes',
|
||
},
|
||
labels: {
|
||
alertname: 'InstanceDown',
|
||
instance: 'instance1',
|
||
},
|
||
startsAt: subDays(new Date(), 1).toISOString(),
|
||
endsAt: addMinutes(new Date(), 5).toISOString(),
|
||
fingerprint: 'a5331f0d5a9d81d4',
|
||
generatorURL: 'http://grafana.com/alerting/grafana/cdeqmlhvflz40f/view',
|
||
},
|
||
{
|
||
status: 'resolved',
|
||
annotations: {
|
||
summary: 'CPU usage above 90%',
|
||
},
|
||
labels: {
|
||
alertname: 'CpuUsage',
|
||
instance: 'instance1',
|
||
},
|
||
startsAt: subHours(new Date(), 4).toISOString(),
|
||
endsAt: new Date().toISOString(),
|
||
fingerprint: 'b77d941310f9d381',
|
||
generatorURL: 'http://grafana.com/alerting/grafana/oZSMdGj7z/view',
|
||
},
|
||
];
|
||
|
||
export const defaultPayloadString = JSON.stringify(defaultPayload, null, 2);
|