Alerting: Implement template preview for Grafana AlertManager (#65530)

* Add Preview template and payload editor to templates form

* Add TemplatePreview test and update css

* Preview errors for each template that is wrong

* Enable preview templating only for Grafana Alert Manager

* Use harcoded default payload instead of requesting it to the backend

* Update error response in the api definition

* Add spinner when loading result for preview

* Update api request followind DD changes

* Use pre instead of TextArea to render the preview

* Fix tests

* Add alert list editor

* Add start and end time for alert generator

* Add preview for data list added in the modal

* Update copies and move submit button in alert generator to the bottom

* Copy updates

* Refactor

* Use tab instead of button to preview

* Move payload editor next to the content

* Copy update

* Refactor

* Adress PR review comments

* Fix wrong json format throwing an exception when adding more data

* Use monaco editor for payload

* Only show text 'Preview for...'  when we have more than one define

* Fix some errors

* Update CollapseSection style

* Add tooltip for the Payload info icon explaining the available list of alert data fields in preview

* Set payload as invalid if it's not an array

* Fix test

* Update text in AlertTemplateDataTable

* Add separators to distinguish lines that belong to the preview

* Fix text

* Use subDays instead of addDays for substracting days
This commit is contained in:
Sonia Aguilar 2023-04-28 17:05:45 +02:00 committed by GitHub
parent b5a2c3c7f5
commit 3854be1fcb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 954 additions and 84 deletions

View File

@ -16,7 +16,6 @@ export const onCallApi = alertingApi.injectEndpoints({
const integrations = await fetchOnCallIntegrations();
return { data: integrations };
},
providesTags: ['AlertmanagerChoice'],
}),
}),
});

View File

@ -0,0 +1,40 @@
import { alertingApi } from './alertingApi';
export const previewTemplateUrl = `/api/alertmanager/grafana/config/api/v1/templates/test`;
export interface TemplatePreviewResult {
name: string;
text: string;
}
export interface TemplatePreviewErrors {
name?: string;
message: string;
kind: string;
}
export interface TemplatePreviewResponse {
results?: TemplatePreviewResult[];
errors?: TemplatePreviewErrors[];
}
export interface KeyValueField {
key: string;
value: string;
}
export interface AlertField {
annotations: KeyValueField[];
labels: KeyValueField[];
}
export const templatesApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
previewTemplate: build.mutation<TemplatePreviewResponse, { template: string; alerts: AlertField[]; name: string }>({
query: ({ template, alerts, name }) => ({
url: previewTemplateUrl,
data: { template: template, alerts: alerts, name: name },
method: 'POST',
}),
}),
}),
});
export const { usePreviewTemplateMutation } = templatesApi;

View File

@ -0,0 +1,81 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { default as React, useState } from 'react';
import { Provider } from 'react-redux';
import { configureStore } from 'app/store/configureStore';
import 'whatwg-fetch';
import { PayloadEditor, RESET_TO_DEFAULT } from './PayloadEditor';
const DEFAULT_PAYLOAD = `[
{
"annotations": {
"summary": "Instance instance1 has been down for more than 5 minutes"
},
"labels": {
"instance": "instance1"
},
"startsAt": "2023-04-25T15:28:56.440Z"
}]
`;
jest.mock('@grafana/ui', () => ({
...jest.requireActual('@grafana/ui'),
CodeEditor: function CodeEditor({ value, onBlur }: { value: string; onBlur: (newValue: string) => void }) {
return <input data-testid="mockeditor" value={value} onChange={(e) => onBlur(e.currentTarget.value)} />;
},
}));
const PayloadEditorWithState = () => {
const [payload, setPayload] = useState(DEFAULT_PAYLOAD);
return (
<PayloadEditor
payload={payload}
setPayload={setPayload}
defaultPayload={DEFAULT_PAYLOAD}
setPayloadFormatError={jest.fn()}
payloadFormatError={null}
onPayloadError={jest.fn()}
/>
);
};
const renderWithProvider = () => {
const store = configureStore();
render(
<Provider store={store}>
<PayloadEditorWithState />
</Provider>
);
};
describe('Payload editor', () => {
it('Should render default payload by default', async () => {
renderWithProvider();
await waitFor(() => {
expect(screen.getByTestId('mockeditor')).toHaveValue(
`[ { "annotations": { "summary": "Instance instance1 has been down for more than 5 minutes" }, "labels": { "instance": "instance1" }, "startsAt": "2023-04-25T15:28:56.440Z" }]`
);
});
});
it('Should render default payload after clicking reset to default button', async () => {
renderWithProvider();
await waitFor(() => {
expect(screen.getByTestId('mockeditor')).toHaveValue(
'[ { "annotations": { "summary": "Instance instance1 has been down for more than 5 minutes" }, "labels": { "instance": "instance1" }, "startsAt": "2023-04-25T15:28:56.440Z" }]'
);
});
await userEvent.type(screen.getByTestId('mockeditor'), 'this is the something');
expect(screen.getByTestId('mockeditor')).toHaveValue(
'[ { "annotations": { "summary": "Instance instance1 has been down for more than 5 minutes" }, "labels": { "instance": "instance1" }, "startsAt": "2023-04-25T15:28:56.440Z" }]this is the something'
);
await userEvent.click(screen.getByText(RESET_TO_DEFAULT));
await waitFor(() =>
expect(screen.queryByTestId('mockeditor')).toHaveValue(
'[ { "annotations": { "summary": "Instance instance1 has been down for more than 5 minutes" }, "labels": { "instance": "instance1" }, "startsAt": "2023-04-25T15:28:56.440Z" }]'
)
);
});
});

View File

@ -0,0 +1,160 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, Button, CodeEditor, Icon, Tooltip, useStyles2 } from '@grafana/ui';
import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types';
import { AlertTemplatePreviewData } from './TemplateData';
import { TemplateDataTable } from './TemplateDataDocs';
import { GenerateAlertDataModal } from './form/GenerateAlertDataModal';
export const RESET_TO_DEFAULT = 'Reset to default';
export function PayloadEditor({
payload,
setPayload,
defaultPayload,
setPayloadFormatError,
payloadFormatError,
onPayloadError,
}: {
payload: string;
defaultPayload: string;
setPayload: React.Dispatch<React.SetStateAction<string>>;
setPayloadFormatError: (value: React.SetStateAction<string | null>) => void;
payloadFormatError: string | null;
onPayloadError: () => void;
}) {
const styles = useStyles2(getStyles);
const onReset = () => {
setPayload(defaultPayload);
};
const [isEditingAlertData, setIsEditingAlertData] = useState(false);
const onCloseEditAlertModal = () => {
setIsEditingAlertData(false);
};
const errorInPayloadJson = payloadFormatError !== null;
const onOpenEditAlertModal = () => {
try {
const payloadObj = JSON.parse(payload);
JSON.stringify([...payloadObj]); // check if it's iterable, in order to be able to add more data
setIsEditingAlertData(true);
setPayloadFormatError(null);
} catch (e) {
setPayloadFormatError(e instanceof Error ? e.message : 'Invalid JSON.');
onPayloadError();
}
};
const onAddAlertList = (alerts: TestTemplateAlert[]) => {
onCloseEditAlertModal();
setPayload((payload) => {
const payloadObj = JSON.parse(payload);
return JSON.stringify([...payloadObj, ...alerts], undefined, 2);
});
};
return (
<div className={styles.wrapper}>
<div className={styles.editor}>
<div className={styles.title}>
Payload data
<Tooltip placement="top" content={<AlertTemplateDataTable />} theme="info">
<Icon name="info-circle" className={styles.tooltip} size="xl" />
</Tooltip>
</div>
<CodeEditor
width={640}
height={363}
language={'json'}
showLineNumbers={true}
showMiniMap={false}
value={payload}
readOnly={false}
onBlur={setPayload}
/>
<div className={styles.buttonsWrapper}>
<Button onClick={onReset} className={styles.button} icon="arrow-up" type="button" variant="secondary">
{RESET_TO_DEFAULT}
</Button>
<Button
onClick={onOpenEditAlertModal}
className={styles.button}
icon="plus-circle"
type="button"
variant="secondary"
disabled={errorInPayloadJson}
>
Add alert data
</Button>
{payloadFormatError !== null && (
<Badge
color="orange"
icon="exclamation-triangle"
text={'There are some errors in payload JSON.'}
tooltip={'Fix errors in payload, and click Refresh preview button'}
/>
)}
</div>
</div>
<GenerateAlertDataModal isOpen={isEditingAlertData} onDismiss={onCloseEditAlertModal} onAccept={onAddAlertList} />
</div>
);
}
const AlertTemplateDataTable = () => {
const styles = useStyles2(getStyles);
return (
<TemplateDataTable
caption={
<h4 className={styles.templateDataDocsHeader}>
Alert template data <span>This is the list of alert data fields used in the preview.</span>
</h4>
}
dataItems={AlertTemplatePreviewData}
/>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
jsonEditor: css`
width: 605px;
height: 363px;
`,
buttonsWrapper: css`
margin-top: ${theme.spacing(1)};
display: flex;
`,
button: css`
flex: none;
width: fit-content;
padding-right: ${theme.spacing(1)};
margin-right: ${theme.spacing(1)};
`,
title: css`
font-weight: ${theme.typography.fontWeightBold};
`,
wrapper: css`
padding-top: 38px;
`,
tooltip: css`
padding-left: ${theme.spacing(1)};
`,
editor: css`
display: flex;
flex-direction: column;
margin-top: ${theme.spacing(-1)};
`,
templateDataDocsHeader: css`
color: ${theme.colors.text.primary};
span {
color: ${theme.colors.text.secondary};
font-size: ${theme.typography.bodySmall.fontSize};
}
`,
});

View File

@ -59,6 +59,29 @@ export const GlobalTemplateData: TemplateDataItem[] = [
},
];
export const AlertTemplatePreviewData: TemplateDataItem[] = [
{
name: 'Labels',
type: 'KeyValue',
notes: 'Set of labels attached to the alert.',
},
{
name: 'Annotations',
type: 'KeyValue',
notes: 'Set of annotations attached to the alert.',
},
{
name: 'StartsAt',
type: 'time.Time',
notes: 'Time the alert started firing.',
},
{
name: 'EndsAt',
type: 'time.Time',
notes: 'Time the alert ends firing.',
},
];
export const AlertTemplateData: TemplateDataItem[] = [
{
name: 'Status',

View File

@ -72,7 +72,7 @@ interface TemplateDataTableProps {
typeRenderer?: (type: TemplateDataItem['type']) => React.ReactNode;
}
function TemplateDataTable({ dataItems, caption, typeRenderer }: TemplateDataTableProps) {
export function TemplateDataTable({ dataItems, caption, typeRenderer }: TemplateDataTableProps) {
const styles = useStyles2(getTemplateDataTableStyles);
return (

View File

@ -1,46 +1,79 @@
import { css } from '@emotion/css';
import { subDays } from 'date-fns';
import { Location } from 'history';
import React from 'react';
import { useForm, Validate } from 'react-hook-form';
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, Field, FieldSet, Input, LinkButton, useStyles2 } from '@grafana/ui';
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';
interface Values {
export interface TemplateFormValues {
name: string;
content: string;
}
const defaults: Values = Object.freeze({
export const defaults: TemplateFormValues = Object.freeze({
name: '',
content: '',
});
interface Props {
existing?: Values;
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();
@ -52,7 +85,14 @@ export const TemplateForm = ({ existing, alertManagerSourceName, config, provena
const location = useLocation();
const isduplicating = isDuplicating(location);
const submit = (values: Values) => {
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);
@ -92,86 +132,119 @@ export const TemplateForm = ({ existing, alertManagerSourceName, config, provena
);
};
const formApi = useForm<TemplateFormValues>({
mode: 'onSubmit',
defaultValues: existing ?? defaults,
});
const {
handleSubmit,
register,
formState: { errors },
getValues,
setValue,
} = useForm<Values>({
mode: 'onSubmit',
defaultValues: existing ?? defaults,
});
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 (
<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.contentContainer}>
<div>
<Field label="Content" error={errors?.content?.message} invalid={!!errors.content?.message} required>
<div className={styles.editWrapper}>
<AutoSizer>
{({ width, height }) => (
<TemplateEditor
value={getValues('content')}
width={width}
height={height}
onBlur={(value) => setValue('content', value)}
/>
)}
</AutoSizer>
<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 />
<Stack direction="row" alignItems={'center'}>
<div>
<TabsBar>
<Tab label="Content" active={view === 'content'} onChangeTab={() => setView('content')} />
{isGrafanaAlertManager && (
<Tab label="Preview" active={view === 'preview'} onChangeTab={() => setView('preview')} />
)}
</TabsBar>
<div className={styles.contentContainer}>
{view === 'content' ? (
<div>
<Field error={errors?.content?.message} invalid={!!errors.content?.message} required>
<div className={styles.editWrapper}>
<TemplateEditor
value={getValues('content')}
width={640}
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"
fill="outline"
>
Cancel
</LinkButton>
</div>
</div>
) : (
<TemplatePreview
payload={payload}
templateName={watch('name')}
setPayloadFormatError={setPayloadFormatError}
payloadFormatError={payloadFormatError}
/>
)}
</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"
fill="outline"
>
Cancel
</LinkButton>
</div>
</div>
{isGrafanaAlertManager && (
<PayloadEditor
payload={payload}
setPayload={setPayload}
defaultPayload={DEFAULT_PAYLOAD}
setPayloadFormatError={setPayloadFormatError}
payloadFormatError={payloadFormatError}
onPayloadError={onPayloadError}
/>
)}
</Stack>
</FieldSet>
<CollapsableSection label="Data cheat sheet" isOpen={false} className={styles.collapsableSection}>
<TemplateDataDocs />
</div>
</FieldSet>
</form>
</CollapsableSection>
</form>
</FormProvider>
);
};
@ -209,9 +282,123 @@ function TemplatingGuideline() {
);
}
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,
}: {
payload: string;
templateName: string;
payloadFormatError: string | null;
setPayloadFormatError: (value: React.SetStateAction<string | null>) => void;
}) {
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 (
<Stack direction="row" alignItems="center" gap={2}>
<Stack direction="column">
{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>
</Stack>
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
contentContainer: css`
display: flex;
padding-top: 10px;
gap: ${theme.spacing(2)};
flex-direction: row;
align-items: flex-start;
@ -232,6 +419,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
& > * + * {
margin-left: ${theme.spacing(1)};
}
margin-top: -7px;
`,
textarea: css`
max-width: 758px;
@ -240,6 +428,40 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: block;
position: relative;
width: 640px;
height: 320px;
height: 363px;
`,
toggle: css({
color: theme.colors.text.secondary,
marginRight: `${theme.spacing(1)}`,
}),
previewHeader: css({
display: 'flex',
cursor: 'pointer',
alignItems: 'baseline',
color: theme.colors.text.primary,
'&:hover': {
background: theme.colors.emphasize(theme.colors.background.primary, 0.03),
},
}),
previewHeaderTitle: css({
flexGrow: 1,
overflow: 'hidden',
fontSize: theme.typography.h4.fontSize,
fontWeight: theme.typography.fontWeightMedium,
margin: 0,
}),
preview: {
result: css`
width: 640px;
height: 363px;
`,
button: css`
flex: none;
width: fit-content;
margin-top: ${theme.spacing(-3)};
`,
},
collapsableSection: css`
width: fit-content;
`,
});

View File

@ -0,0 +1,159 @@
import { render, screen, waitFor } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { default as React } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { Provider } from 'react-redux';
import { setBackendSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { configureStore } from 'app/store/configureStore';
import 'whatwg-fetch';
import { TemplatePreviewResponse } from '../../api/templateApi';
import { mockPreviewTemplateResponse, mockPreviewTemplateResponseRejected } from '../../mocks/templatesApi';
import { defaults, PREVIEW_NOT_AVAILABLE, TemplateFormValues, TemplatePreview } from './TemplateForm';
const getProviderWraper = () => {
return function Wrapper({ children }: React.PropsWithChildren<{}>) {
const store = configureStore();
const formApi = useForm<TemplateFormValues>({ defaultValues: defaults });
return (
<Provider store={store}>
<FormProvider {...formApi}>{children}</FormProvider>
</Provider>
);
};
};
const server = setupServer();
beforeAll(() => {
setBackendSrv(backendSrv);
server.listen({ onUnhandledRequest: 'error' });
});
beforeEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
describe('TemplatePreview component', () => {
it('Should render error if payload has wrong format', async () => {
render(
<TemplatePreview
payload={'bla bla bla'}
templateName="potato"
payloadFormatError={'Unexpected token b in JSON at position 0'}
setPayloadFormatError={jest.fn()}
/>,
{ wrapper: getProviderWraper() }
);
await waitFor(() => {
expect(screen.getByTestId('payloadJSON')).toHaveTextContent('Unexpected token b in JSON at position 0');
});
});
it('Should render error if payload is not an iterable', async () => {
const setError = jest.fn();
render(
<TemplatePreview
payload={'{"a":"b"}'}
templateName="potato"
payloadFormatError={'Unexpected token b in JSON at position 0'}
setPayloadFormatError={setError}
/>,
{ wrapper: getProviderWraper() }
);
await waitFor(() => {
expect(setError).toHaveBeenCalledWith('alertList is not iterable');
});
});
it('Should render error if payload has wrong format rendering the preview', async () => {
render(
<TemplatePreview
payload={'potatos and cherries'}
templateName="potato"
payloadFormatError={'Unexpected token b in JSON at position 0'}
setPayloadFormatError={jest.fn()}
/>,
{
wrapper: getProviderWraper(),
}
);
await waitFor(() => {
expect(screen.getByTestId('payloadJSON')).toHaveTextContent('Unexpected token b in JSON at position 0');
});
});
it('Should render error in preview response , if payload has correct format but preview request has been rejected', async () => {
mockPreviewTemplateResponseRejected(server);
render(
<TemplatePreview
payload={'[{"a":"b"}]'}
templateName="potato"
payloadFormatError={null}
setPayloadFormatError={jest.fn()}
/>,
{ wrapper: getProviderWraper() }
);
await waitFor(() => {
expect(screen.getByTestId('payloadJSON')).toHaveTextContent(PREVIEW_NOT_AVAILABLE);
});
});
it('Should render preview response , if payload has correct ', async () => {
const response: TemplatePreviewResponse = {
results: [
{ name: 'template1', text: 'This is the template result bla bla bla' },
{ name: 'template2', text: 'This is the template2 result bla bla bla' },
],
};
mockPreviewTemplateResponse(server, response);
render(
<TemplatePreview
payload={'[{"a":"b"}]'}
templateName="potato"
payloadFormatError={null}
setPayloadFormatError={jest.fn()}
/>,
{ wrapper: getProviderWraper() }
);
await waitFor(() => {
expect(screen.getByTestId('payloadJSON')).toHaveTextContent(
'Preview for template1: ======================>This is the template result bla bla bla<====================== Preview for template2: ======================>This is the template2 result bla bla bla<======================'
);
});
});
it('Should render preview response with some errors, if payload has correct format ', async () => {
const response: TemplatePreviewResponse = {
results: [{ name: 'template1', text: 'This is the template result bla bla bla' }],
errors: [
{ name: 'template2', message: 'Unexpected "{" in operand', kind: 'kind_of_error' },
{ name: 'template3', kind: 'kind_of_error', message: 'Unexpected "{" in operand' },
],
};
mockPreviewTemplateResponse(server, response);
render(
<TemplatePreview
payload={'[{"a":"b"}]'}
templateName="potato"
payloadFormatError={null}
setPayloadFormatError={jest.fn()}
/>,
{ wrapper: getProviderWraper() }
);
await waitFor(() => {
expect(screen.getByTestId('payloadJSON')).toHaveTextContent(
'======================>This is the template result bla bla bla<====================== ERROR in template2: kind_of_error Unexpected "{" in operand ERROR in template3: kind_of_error Unexpected "{" in operand'
);
});
});
});

View File

@ -0,0 +1,173 @@
import { css } from '@emotion/css';
import { addDays, subDays } from 'date-fns';
import React, { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Button, Card, Modal, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types';
import { KeyValueField } from '../../../api/templateApi';
import AnnotationsField from '../../rule-editor/AnnotationsField';
import LabelsField from '../../rule-editor/LabelsField';
interface Props {
isOpen: boolean;
onDismiss: () => void;
onAccept: (alerts: TestTemplateAlert[]) => void;
}
interface FormFields {
annotations: KeyValueField[];
labels: KeyValueField[];
status: 'firing' | 'resolved';
}
const defaultValues: FormFields = {
annotations: [{ key: '', value: '' }],
labels: [{ key: '', value: '' }],
status: 'firing',
};
export const GenerateAlertDataModal = ({ isOpen, onDismiss, onAccept }: Props) => {
const styles = useStyles2(getStyles);
const [alerts, setAlerts] = useState<TestTemplateAlert[]>([]);
const formMethods = useForm<FormFields>({ defaultValues, mode: 'onBlur' });
const annotations = formMethods.watch('annotations');
const labels = formMethods.watch('labels');
const [status, setStatus] = useState<'firing' | 'resolved'>('firing');
const onAdd = () => {
const alert: TestTemplateAlert = {
annotations: annotations
.filter(({ key, value }) => !!key && !!value)
.reduce((acc, { key, value }) => {
return { ...acc, [key]: value };
}, {}),
labels: labels
.filter(({ key, value }) => !!key && !!value)
.reduce((acc, { key, value }) => {
return { ...acc, [key]: value };
}, {}),
startsAt: '2023-04-01T00:00:00Z',
endsAt: status === 'firing' ? addDays(new Date(), 1).toISOString() : subDays(new Date(), 1).toISOString(),
};
setAlerts((alerts) => [...alerts, alert]);
formMethods.reset();
};
const onSubmit = () => {
onAccept(alerts);
setAlerts([]);
formMethods.reset();
setStatus('firing');
};
const labelsOrAnnotationsAdded = () => {
const someLabels = labels.some((lb) => lb.key !== '' && lb.value !== '');
const someAnnotations = annotations.some((ann) => ann.key !== '' && ann.value !== '');
return someLabels || someAnnotations;
};
type AlertOption = {
label: string;
value: 'firing' | 'resolved';
};
const alertOptions: AlertOption[] = [
{
label: 'Firing',
value: 'firing',
},
{ label: 'Resolved', value: 'resolved' },
];
return (
<Modal onDismiss={onDismiss} isOpen={isOpen} title={'Add alert data'}>
<FormProvider {...formMethods}>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
formMethods.reset();
setStatus('firing');
}}
>
<>
<Card>
<Stack direction="column" gap={1}>
<div className={styles.section}>
<AnnotationsField />
</div>
<div className={styles.section}>
<LabelsField />
</div>
<div className={styles.flexWrapper}>
<RadioButtonGroup value={status} options={alertOptions} onChange={(value) => setStatus(value)} />
<Button
onClick={onAdd}
className={styles.onAddButton}
icon="plus-circle"
type="button"
variant="secondary"
disabled={!labelsOrAnnotationsAdded()}
>
Add alert data
</Button>
</div>
</Stack>
</Card>
</>
<div className={styles.onSubmitWrapper}></div>
{alerts.length > 0 && (
<Stack direction="column" gap={1}>
<h5> Review alert data to add to the payload:</h5>
<pre className={styles.result} data-testid="payloadJSON">
{JSON.stringify(alerts, null, 2)}
</pre>
</Stack>
)}
<div className={styles.onSubmitWrapper}>
<Modal.ButtonRow>
<Button onClick={onSubmit} disabled={alerts.length === 0} className={styles.onSubmitButton}>
Add alert data to payload
</Button>
</Modal.ButtonRow>
</div>
</form>
</FormProvider>
</Modal>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
section: css`
margin-bottom: ${theme.spacing(2)};
`,
onAddButton: css`
flex: none;
width: fit-content;
padding-right: ${theme.spacing(1)};
margin-left: auto;
`,
flexWrapper: css`
display: flex;
flex-direction: row,
justify-content: space-between;
`,
onSubmitWrapper: css`
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: flex-end;
`,
onSubmitButton: css`
margin-left: ${theme.spacing(2)};
`,
result: css`
width: 570px;
height: 363px;
`,
});

View File

@ -0,0 +1,12 @@
import { rest } from 'msw';
import { SetupServer } from 'msw/node';
import { previewTemplateUrl, TemplatePreviewResponse } from '../api/templateApi';
export function mockPreviewTemplateResponse(server: SetupServer, response: TemplatePreviewResponse) {
server.use(rest.post(previewTemplateUrl, (req, res, ctx) => res(ctx.status(200), ctx.json(response))));
}
export function mockPreviewTemplateResponseRejected(server: SetupServer) {
server.use(rest.post(previewTemplateUrl, (req, res, ctx) => res(ctx.status(500), ctx.json('error'))));
}

View File

@ -1,25 +1,25 @@
import { css } from '@emotion/css';
import { isNumber } from 'lodash';
import React, { PureComponent, ChangeEvent } from 'react';
import React, { ChangeEvent, PureComponent } from 'react';
import {
Threshold,
GrafanaTheme2,
SelectableValue,
sortThresholds,
Threshold,
ThresholdsConfig,
ThresholdsMode,
SelectableValue,
GrafanaTheme2,
} from '@grafana/data';
import {
Input,
colors,
ColorPicker,
ThemeContext,
Button,
ColorPicker,
colors,
IconButton,
Input,
Label,
RadioButtonGroup,
stylesFactory,
IconButton,
ThemeContext,
} from '@grafana/ui';
const modes: Array<SelectableValue<ThresholdsMode>> = [

View File

@ -252,6 +252,7 @@ export interface AlertmanagerStatus {
}
export type TestReceiversAlert = Pick<AlertmanagerAlert, 'annotations' | 'labels'>;
export type TestTemplateAlert = Pick<AlertmanagerAlert, 'annotations' | 'labels' | 'startsAt' | 'endsAt'>;
export interface TestReceiversPayload {
receivers?: Receiver[];