mirror of
https://github.com/grafana/grafana.git
synced 2025-09-22 20:19:05 +08:00

Make two boxes in template form adaptative to the screen width to be align in the same line when possible
481 lines
15 KiB
TypeScript
481 lines
15 KiB
TypeScript
import { css } from '@emotion/css';
|
|
import { subDays } from 'date-fns';
|
|
import { Location } from 'history';
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
|
import { FormProvider, useForm, useFormContext, Validate } from 'react-hook-form';
|
|
import { useLocation } from 'react-router-dom';
|
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
|
|
|
import { GrafanaTheme2 } from '@grafana/data';
|
|
import { Stack } from '@grafana/experimental';
|
|
import {
|
|
Alert,
|
|
Button,
|
|
CollapsableSection,
|
|
Field,
|
|
FieldSet,
|
|
Input,
|
|
LinkButton,
|
|
Spinner,
|
|
Tab,
|
|
TabsBar,
|
|
useStyles2,
|
|
} from '@grafana/ui';
|
|
import { useCleanup } from 'app/core/hooks/useCleanup';
|
|
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
|
import { useDispatch } from 'app/types';
|
|
|
|
import {
|
|
AlertField,
|
|
TemplatePreviewErrors,
|
|
TemplatePreviewResponse,
|
|
TemplatePreviewResult,
|
|
usePreviewTemplateMutation,
|
|
} from '../../api/templateApi';
|
|
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
|
import { updateAlertManagerConfigAction } from '../../state/actions';
|
|
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
|
import { makeAMLink } from '../../utils/misc';
|
|
import { initialAsyncRequestState } from '../../utils/redux';
|
|
import { ensureDefine } from '../../utils/templates';
|
|
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
|
|
|
|
import { PayloadEditor } from './PayloadEditor';
|
|
import { TemplateDataDocs } from './TemplateDataDocs';
|
|
import { TemplateEditor } from './TemplateEditor';
|
|
import { snippets } from './editor/templateDataSuggestions';
|
|
|
|
export interface TemplateFormValues {
|
|
name: string;
|
|
content: string;
|
|
}
|
|
|
|
export const defaults: TemplateFormValues = Object.freeze({
|
|
name: '',
|
|
content: '',
|
|
});
|
|
|
|
interface Props {
|
|
existing?: TemplateFormValues;
|
|
config: AlertManagerCortexConfig;
|
|
alertManagerSourceName: string;
|
|
provenance?: string;
|
|
}
|
|
export const isDuplicating = (location: Location) => location.pathname.endsWith('/duplicate');
|
|
|
|
const DEFAULT_PAYLOAD = `[
|
|
{
|
|
"annotations": {
|
|
"summary": "Instance instance1 has been down for more than 5 minutes"
|
|
},
|
|
"labels": {
|
|
"instance": "instance1"
|
|
},
|
|
"startsAt": "${subDays(new Date(), 1).toISOString()}"
|
|
}]
|
|
`;
|
|
|
|
export const TemplateForm = ({ existing, alertManagerSourceName, config, provenance }: Props) => {
|
|
const styles = useStyles2(getStyles);
|
|
const dispatch = useDispatch();
|
|
|
|
useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState));
|
|
|
|
const { loading, error } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
|
|
|
|
const location = useLocation();
|
|
const isduplicating = isDuplicating(location);
|
|
|
|
const [payload, setPayload] = useState(DEFAULT_PAYLOAD);
|
|
const [payloadFormatError, setPayloadFormatError] = useState<string | null>(null);
|
|
|
|
const [view, setView] = useState<'content' | 'preview'>('content');
|
|
|
|
const onPayloadError = () => setView('preview');
|
|
|
|
const submit = (values: TemplateFormValues) => {
|
|
// wrap content in "define" if it's not already wrapped, in case user did not do it/
|
|
// it's not obvious that this is needed for template to work
|
|
const content = ensureDefine(values.name, values.content);
|
|
|
|
// add new template to template map
|
|
const template_files = {
|
|
...config.template_files,
|
|
[values.name]: content,
|
|
};
|
|
|
|
// delete existing one (if name changed, otherwise it was overwritten in previous step)
|
|
if (existing && existing.name !== values.name) {
|
|
delete template_files[existing.name];
|
|
}
|
|
|
|
// make sure name for the template is configured on the alertmanager config object
|
|
const templates = [
|
|
...(config.alertmanager_config.templates ?? []).filter((name) => name !== existing?.name),
|
|
values.name,
|
|
];
|
|
|
|
const newConfig: AlertManagerCortexConfig = {
|
|
template_files,
|
|
alertmanager_config: {
|
|
...config.alertmanager_config,
|
|
templates,
|
|
},
|
|
};
|
|
dispatch(
|
|
updateAlertManagerConfigAction({
|
|
alertManagerSourceName,
|
|
newConfig,
|
|
oldConfig: config,
|
|
successMessage: 'Template saved.',
|
|
redirectPath: '/alerting/notifications',
|
|
})
|
|
);
|
|
};
|
|
|
|
const formApi = useForm<TemplateFormValues>({
|
|
mode: 'onSubmit',
|
|
defaultValues: existing ?? defaults,
|
|
});
|
|
const {
|
|
handleSubmit,
|
|
register,
|
|
formState: { errors },
|
|
getValues,
|
|
setValue,
|
|
watch,
|
|
} = formApi;
|
|
|
|
const validateNameIsUnique: Validate<string> = (name: string) => {
|
|
return !config.template_files[name] || existing?.name === name
|
|
? true
|
|
: 'Another template with this name already exists.';
|
|
};
|
|
const isGrafanaAlertManager = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;
|
|
|
|
return (
|
|
<FormProvider {...formApi}>
|
|
<form onSubmit={handleSubmit(submit)}>
|
|
<h4>{existing && !isduplicating ? 'Edit notification template' : 'Create notification template'}</h4>
|
|
{error && (
|
|
<Alert severity="error" title="Error saving template">
|
|
{error.message || (error as any)?.data?.message || String(error)}
|
|
</Alert>
|
|
)}
|
|
{provenance && <ProvisioningAlert resource={ProvisionedResource.Template} />}
|
|
<FieldSet disabled={Boolean(provenance)}>
|
|
<Field label="Template name" error={errors?.name?.message} invalid={!!errors.name?.message} required>
|
|
<Input
|
|
{...register('name', {
|
|
required: { value: true, message: 'Required.' },
|
|
validate: { nameIsUnique: validateNameIsUnique },
|
|
})}
|
|
placeholder="Give your template a name"
|
|
width={42}
|
|
autoFocus={true}
|
|
/>
|
|
</Field>
|
|
<TemplatingGuideline />
|
|
<div className={styles.editorsWrapper}>
|
|
<div className={styles.contentContainer}>
|
|
<TabsBar>
|
|
<Tab label="Content" active={view === 'content'} onChangeTab={() => setView('content')} />
|
|
{isGrafanaAlertManager && (
|
|
<Tab label="Preview" active={view === 'preview'} onChangeTab={() => setView('preview')} />
|
|
)}
|
|
</TabsBar>
|
|
<div className={styles.contentContainerEditor}>
|
|
<AutoSizer>
|
|
{({ width }) => (
|
|
<>
|
|
{view === 'content' ? (
|
|
<div>
|
|
<Field error={errors?.content?.message} invalid={!!errors.content?.message} required>
|
|
<div className={styles.editWrapper}>
|
|
<TemplateEditor
|
|
value={getValues('content')}
|
|
width={width}
|
|
height={363}
|
|
onBlur={(value) => setValue('content', value)}
|
|
/>
|
|
</div>
|
|
</Field>
|
|
<div className={styles.buttons}>
|
|
{loading && (
|
|
<Button disabled={true} icon="fa fa-spinner" variant="primary">
|
|
Saving...
|
|
</Button>
|
|
)}
|
|
{!loading && (
|
|
<Button type="submit" variant="primary">
|
|
Save template
|
|
</Button>
|
|
)}
|
|
<LinkButton
|
|
disabled={loading}
|
|
href={makeAMLink('alerting/notifications', alertManagerSourceName)}
|
|
variant="secondary"
|
|
type="button"
|
|
>
|
|
Cancel
|
|
</LinkButton>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<TemplatePreview
|
|
width={width}
|
|
payload={payload}
|
|
templateName={watch('name')}
|
|
setPayloadFormatError={setPayloadFormatError}
|
|
payloadFormatError={payloadFormatError}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</AutoSizer>
|
|
</div>
|
|
</div>
|
|
{isGrafanaAlertManager && (
|
|
<PayloadEditor
|
|
payload={payload}
|
|
setPayload={setPayload}
|
|
defaultPayload={DEFAULT_PAYLOAD}
|
|
setPayloadFormatError={setPayloadFormatError}
|
|
payloadFormatError={payloadFormatError}
|
|
onPayloadError={onPayloadError}
|
|
/>
|
|
)}
|
|
</div>
|
|
</FieldSet>
|
|
<CollapsableSection label="Data cheat sheet" isOpen={false} className={styles.collapsableSection}>
|
|
<TemplateDataDocs />
|
|
</CollapsableSection>
|
|
</form>
|
|
</FormProvider>
|
|
);
|
|
};
|
|
|
|
function TemplatingGuideline() {
|
|
const styles = useStyles2(getStyles);
|
|
|
|
return (
|
|
<Alert title="Templating guideline" severity="info">
|
|
<Stack direction="row">
|
|
<div>
|
|
Grafana uses Go templating language to create notification messages.
|
|
<br />
|
|
To find out more about templating please visit our documentation.
|
|
</div>
|
|
<div>
|
|
<LinkButton
|
|
href="https://grafana.com/docs/grafana/latest/alerting/manage-notifications/template-notifications/"
|
|
target="_blank"
|
|
icon="external-link-alt"
|
|
variant="secondary"
|
|
>
|
|
Templating documentation
|
|
</LinkButton>
|
|
</div>
|
|
</Stack>
|
|
|
|
<div className={styles.snippets}>
|
|
To make templating easier, we provide a few snippets in the content editor to help you speed up your workflow.
|
|
<div className={styles.code}>
|
|
{Object.values(snippets)
|
|
.map((s) => s.label)
|
|
.join(', ')}
|
|
</div>
|
|
</div>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
function getResultsToRender(results: TemplatePreviewResult[]) {
|
|
const filteredResults = results.filter((result) => result.text.trim().length > 0);
|
|
|
|
const moreThanOne = filteredResults.length > 1;
|
|
|
|
const preview = (result: TemplatePreviewResult) => {
|
|
const previewForLabel = `Preview for ${result.name}:`;
|
|
const separatorStart = '='.repeat(previewForLabel.length).concat('>');
|
|
const separatorEnd = '<'.concat('='.repeat(previewForLabel.length));
|
|
if (moreThanOne) {
|
|
return `${previewForLabel}\n${separatorStart}${result.text}${separatorEnd}\n`;
|
|
} else {
|
|
return `${separatorStart}${result.text}${separatorEnd}\n`;
|
|
}
|
|
};
|
|
|
|
return filteredResults
|
|
.map((result: TemplatePreviewResult) => {
|
|
return preview(result);
|
|
})
|
|
.join(`\n`);
|
|
}
|
|
|
|
function getErrorsToRender(results: TemplatePreviewErrors[]) {
|
|
return results
|
|
.map((result: TemplatePreviewErrors) => {
|
|
if (result.name) {
|
|
return `ERROR in ${result.name}:\n`.concat(`${result.kind}\n${result.message}\n`);
|
|
} else {
|
|
return `ERROR:\n${result.kind}\n${result.message}\n`;
|
|
}
|
|
})
|
|
.join(`\n`);
|
|
}
|
|
|
|
export const PREVIEW_NOT_AVAILABLE = 'Preview request failed. Check if the payload data has the correct structure.';
|
|
|
|
function getPreviewTorender(
|
|
isPreviewError: boolean,
|
|
payloadFormatError: string | null,
|
|
data: TemplatePreviewResponse | undefined
|
|
) {
|
|
// ERRORS IN JSON OR IN REQUEST (endpoint not available, for example)
|
|
const previewErrorRequest = isPreviewError ? PREVIEW_NOT_AVAILABLE : undefined;
|
|
const somethingWasWrong: boolean = isPreviewError || Boolean(payloadFormatError);
|
|
const errorToRender = payloadFormatError || previewErrorRequest;
|
|
|
|
//PREVIEW : RESULTS AND ERRORS
|
|
const previewResponseResults = data?.results;
|
|
const previewResponseErrors = data?.errors;
|
|
|
|
const previewResultsToRender = previewResponseResults ? getResultsToRender(previewResponseResults) : '';
|
|
const previewErrorsToRender = previewResponseErrors ? getErrorsToRender(previewResponseErrors) : '';
|
|
|
|
if (somethingWasWrong) {
|
|
return errorToRender;
|
|
} else {
|
|
return `${previewResultsToRender}\n${previewErrorsToRender}`;
|
|
}
|
|
}
|
|
|
|
export function TemplatePreview({
|
|
payload,
|
|
templateName,
|
|
payloadFormatError,
|
|
setPayloadFormatError,
|
|
width,
|
|
}: {
|
|
payload: string;
|
|
templateName: string;
|
|
payloadFormatError: string | null;
|
|
setPayloadFormatError: (value: React.SetStateAction<string | null>) => void;
|
|
width: number;
|
|
}) {
|
|
const styles = useStyles2(getStyles);
|
|
|
|
const { watch } = useFormContext<TemplateFormValues>();
|
|
|
|
const templateContent = watch('content');
|
|
|
|
const [trigger, { data, isError: isPreviewError, isLoading }] = usePreviewTemplateMutation();
|
|
|
|
const previewToRender = getPreviewTorender(isPreviewError, payloadFormatError, data);
|
|
|
|
const onPreview = useCallback(() => {
|
|
try {
|
|
const alertList: AlertField[] = JSON.parse(payload);
|
|
JSON.stringify([...alertList]); // check if it's iterable, in order to be able to add more data
|
|
trigger({ template: templateContent, alerts: alertList, name: templateName });
|
|
setPayloadFormatError(null);
|
|
} catch (e) {
|
|
setPayloadFormatError(e instanceof Error ? e.message : 'Invalid JSON.');
|
|
}
|
|
}, [templateContent, templateName, payload, setPayloadFormatError, trigger]);
|
|
|
|
useEffect(() => onPreview(), [onPreview]);
|
|
|
|
return (
|
|
<div style={{ width: `${width}px` }} className={styles.preview.wrapper}>
|
|
{isLoading && (
|
|
<>
|
|
<Spinner inline={true} /> Loading preview...
|
|
</>
|
|
)}
|
|
<pre className={styles.preview.result} data-testid="payloadJSON">
|
|
{previewToRender}
|
|
</pre>
|
|
<Button onClick={onPreview} className={styles.preview.button} icon="arrow-up" type="button" variant="secondary">
|
|
Refresh preview
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const getStyles = (theme: GrafanaTheme2) => ({
|
|
contentContainer: css`
|
|
flex: 1;
|
|
margin-bottom: ${theme.spacing(6)};
|
|
`,
|
|
contentContainerEditor: css`
|
|
flex:1;
|
|
display: flex;
|
|
padding-top: 10px;
|
|
gap: ${theme.spacing(2)};
|
|
flex-direction: row;
|
|
align-items: flex-start;
|
|
flex-wrap: wrap;
|
|
${theme.breakpoints.up('xxl')} {
|
|
flex - wrap: nowrap;
|
|
}
|
|
min-width: 450px;
|
|
height: 363px;
|
|
`,
|
|
snippets: css`
|
|
margin-top: ${theme.spacing(2)};
|
|
font-size: ${theme.typography.bodySmall.fontSize};
|
|
`,
|
|
code: css`
|
|
color: ${theme.colors.text.secondary};
|
|
font-weight: ${theme.typography.fontWeightBold};
|
|
`,
|
|
buttons: css`
|
|
display: flex;
|
|
& > * + * {
|
|
margin-left: ${theme.spacing(1)};
|
|
}
|
|
margin-top: -7px;
|
|
`,
|
|
textarea: css`
|
|
max-width: 758px;
|
|
`,
|
|
editWrapper: css`
|
|
display: flex;
|
|
width: 100%
|
|
heigth:100%;
|
|
position: relative;
|
|
`,
|
|
toggle: css`
|
|
color: theme.colors.text.secondary,
|
|
marginRight: ${theme.spacing(1)}`,
|
|
preview: {
|
|
wrapper: css`
|
|
display: flex;
|
|
width: 100%
|
|
heigth:100%;
|
|
position: relative;
|
|
flex-direction: column;
|
|
`,
|
|
result: css`
|
|
width: 100%;
|
|
height: 363px;
|
|
`,
|
|
button: css`
|
|
flex: none;
|
|
width: fit-content;
|
|
margin-top: -6px;
|
|
`,
|
|
},
|
|
collapsableSection: css`
|
|
width: fit-content;
|
|
`,
|
|
editorsWrapper: css`
|
|
display: flex;
|
|
flex: 1;
|
|
flex-wrap: wrap;
|
|
gap: ${theme.spacing(1)};
|
|
`,
|
|
});
|