From 3854be1fcb35c1340953bae0d5f022d7cf88089f Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri, 28 Apr 2023 17:05:45 +0200 Subject: [PATCH] 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 --- .../alerting/unified/api/onCallApi.ts | 1 - .../alerting/unified/api/templateApi.ts | 40 ++ .../receivers/PayloadEditor.test.tsx | 81 ++++ .../components/receivers/PayloadEditor.tsx | 160 ++++++++ .../components/receivers/TemplateData.ts | 23 ++ .../components/receivers/TemplateDataDocs.tsx | 2 +- .../components/receivers/TemplateForm.tsx | 368 ++++++++++++++---- .../receivers/TemplatePreview.test.tsx | 159 ++++++++ .../receivers/form/GenerateAlertDataModal.tsx | 173 ++++++++ .../alerting/unified/mocks/templatesApi.ts | 12 + .../ThresholdsEditor/ThresholdsEditor.tsx | 18 +- .../plugins/datasource/alertmanager/types.ts | 1 + 12 files changed, 954 insertions(+), 84 deletions(-) create mode 100644 public/app/features/alerting/unified/api/templateApi.ts create mode 100644 public/app/features/alerting/unified/components/receivers/PayloadEditor.test.tsx create mode 100644 public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx create mode 100644 public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx create mode 100644 public/app/features/alerting/unified/components/receivers/form/GenerateAlertDataModal.tsx create mode 100644 public/app/features/alerting/unified/mocks/templatesApi.ts diff --git a/public/app/features/alerting/unified/api/onCallApi.ts b/public/app/features/alerting/unified/api/onCallApi.ts index 259adfdb517..a7272211950 100644 --- a/public/app/features/alerting/unified/api/onCallApi.ts +++ b/public/app/features/alerting/unified/api/onCallApi.ts @@ -16,7 +16,6 @@ export const onCallApi = alertingApi.injectEndpoints({ const integrations = await fetchOnCallIntegrations(); return { data: integrations }; }, - providesTags: ['AlertmanagerChoice'], }), }), }); diff --git a/public/app/features/alerting/unified/api/templateApi.ts b/public/app/features/alerting/unified/api/templateApi.ts new file mode 100644 index 00000000000..41e5f23703a --- /dev/null +++ b/public/app/features/alerting/unified/api/templateApi.ts @@ -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({ + query: ({ template, alerts, name }) => ({ + url: previewTemplateUrl, + data: { template: template, alerts: alerts, name: name }, + method: 'POST', + }), + }), + }), +}); + +export const { usePreviewTemplateMutation } = templatesApi; diff --git a/public/app/features/alerting/unified/components/receivers/PayloadEditor.test.tsx b/public/app/features/alerting/unified/components/receivers/PayloadEditor.test.tsx new file mode 100644 index 00000000000..6c38193111b --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/PayloadEditor.test.tsx @@ -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 onBlur(e.currentTarget.value)} />; + }, +})); + +const PayloadEditorWithState = () => { + const [payload, setPayload] = useState(DEFAULT_PAYLOAD); + return ( + + ); +}; +const renderWithProvider = () => { + const store = configureStore(); + render( + + + + ); +}; + +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" }]' + ) + ); + }); +}); diff --git a/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx b/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx new file mode 100644 index 00000000000..20ab3d80e31 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx @@ -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>; + setPayloadFormatError: (value: React.SetStateAction) => 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 ( +
+
+
+ Payload data + } theme="info"> + + +
+ + + +
+ + + {payloadFormatError !== null && ( + + )} +
+
+ +
+ ); +} +const AlertTemplateDataTable = () => { + const styles = useStyles2(getStyles); + return ( + + Alert template data This is the list of alert data fields used in the preview. + + } + 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}; + } + `, +}); diff --git a/public/app/features/alerting/unified/components/receivers/TemplateData.ts b/public/app/features/alerting/unified/components/receivers/TemplateData.ts index 7580bda6fba..a46eaaaa8e4 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateData.ts +++ b/public/app/features/alerting/unified/components/receivers/TemplateData.ts @@ -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', diff --git a/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx b/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx index 99e5e4fb3d9..3e1dbe0e85d 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx @@ -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 ( diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index 225c0d49746..52299c0a047 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -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(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({ + mode: 'onSubmit', + defaultValues: existing ?? defaults, + }); const { handleSubmit, register, formState: { errors }, getValues, setValue, - } = useForm({ - mode: 'onSubmit', - defaultValues: existing ?? defaults, - }); + watch, + } = formApi; const validateNameIsUnique: Validate = (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 ( -
-

{existing && !isduplicating ? 'Edit notification template' : 'Create notification template'}

- {error && ( - - {error.message || (error as any)?.data?.message || String(error)} - - )} - {provenance && } -
- - - - -
-
- -
- - {({ width, height }) => ( - setValue('content', value)} - /> - )} - + + +

{existing && !isduplicating ? 'Edit notification template' : 'Create notification template'}

+ {error && ( + + {error.message || (error as any)?.data?.message || String(error)} + + )} + {provenance && } +
+ + + + + +
+ + setView('content')} /> + {isGrafanaAlertManager && ( + setView('preview')} /> + )} + +
+ {view === 'content' ? ( +
+ +
+ setValue('content', value)} + /> +
+
+
+ {loading && ( + + )} + {!loading && ( + + )} + + Cancel + +
+
+ ) : ( + + )}
- -
- {loading && ( - - )} - {!loading && ( - - )} - - Cancel -
-
+ {isGrafanaAlertManager && ( + + )} +
+
+ -
-
- + + + ); }; @@ -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) => void; +}) { + const styles = useStyles2(getStyles); + + const { watch } = useFormContext(); + + 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 ( + + + {isLoading && ( + <> + Loading preview... + + )} +
+          {previewToRender}
+        
+ +
+
+ ); +} + 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; `, }); diff --git a/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx b/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx new file mode 100644 index 00000000000..def4b0edaa3 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx @@ -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({ defaultValues: defaults }); + return ( + + {children} + + ); + }; +}; + +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( + , + { 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( + , + { 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( + , + { + 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( + , + { 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( + , + { 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( + , + { 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' + ); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/receivers/form/GenerateAlertDataModal.tsx b/public/app/features/alerting/unified/components/receivers/form/GenerateAlertDataModal.tsx new file mode 100644 index 00000000000..706eacf3348 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/form/GenerateAlertDataModal.tsx @@ -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([]); + + const formMethods = useForm({ 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 ( + + +
{ + e.preventDefault(); + e.stopPropagation(); + formMethods.reset(); + setStatus('firing'); + }} + > + <> + + +
+ +
+
+ +
+
+ setStatus(value)} /> + +
+
+
+ +
+ {alerts.length > 0 && ( + +
Review alert data to add to the payload:
+
+                {JSON.stringify(alerts, null, 2)}
+              
+
+ )} +
+ + + +
+
+
+
+ ); +}; + +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; + `, +}); diff --git a/public/app/features/alerting/unified/mocks/templatesApi.ts b/public/app/features/alerting/unified/mocks/templatesApi.ts new file mode 100644 index 00000000000..cb6d18d4cf3 --- /dev/null +++ b/public/app/features/alerting/unified/mocks/templatesApi.ts @@ -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')))); +} diff --git a/public/app/features/dimensions/editors/ThresholdsEditor/ThresholdsEditor.tsx b/public/app/features/dimensions/editors/ThresholdsEditor/ThresholdsEditor.tsx index 1ba5234e31b..e5f8df86343 100644 --- a/public/app/features/dimensions/editors/ThresholdsEditor/ThresholdsEditor.tsx +++ b/public/app/features/dimensions/editors/ThresholdsEditor/ThresholdsEditor.tsx @@ -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> = [ diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index ba4aeed19e0..17a3c192427 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -252,6 +252,7 @@ export interface AlertmanagerStatus { } export type TestReceiversAlert = Pick; +export type TestTemplateAlert = Pick; export interface TestReceiversPayload { receivers?: Receiver[];