From 9b4f88c6f663bbc0e908e22ef44804b80dd1328d Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 4 Apr 2024 17:37:24 +0200 Subject: [PATCH] Alerting: Improve template preview (#84798) Co-authored-by: Gilles De Mey --- .betterer.results | 21 +- packages/grafana-data/src/types/icon.ts | 1 + public/app/features/alerting/routes.tsx | 10 + .../features/alerting/unified/Receivers.tsx | 21 +- .../features/alerting/unified/Templates.tsx | 34 + .../components/AlertingPageWrapper.tsx | 4 +- .../templates/EditorColumnHeader.tsx | 33 + .../receivers/AlertInstanceModalSelector.tsx | 3 + .../receivers/PayloadEditor.test.tsx | 21 +- .../components/receivers/PayloadEditor.tsx | 172 ++--- .../components/receivers/TemplateData.ts | 18 +- .../components/receivers/TemplateDataDocs.tsx | 4 +- .../components/receivers/TemplateEditor.tsx | 3 + .../components/receivers/TemplateForm.tsx | 611 +++++++++--------- .../receivers/TemplatePreview.test.tsx | 58 +- .../components/receivers/TemplatePreview.tsx | 175 +++++ .../receivers/form/GenerateAlertDataModal.tsx | 3 + .../alerting/unified/mocks/templatesApi.ts | 4 +- .../plugins/datasource/alertmanager/types.ts | 8 +- 19 files changed, 708 insertions(+), 496 deletions(-) create mode 100644 public/app/features/alerting/unified/Templates.tsx create mode 100644 public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx create mode 100644 public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx diff --git a/.betterer.results b/.betterer.results index bd1b51f525d..9184482f4f4 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1807,13 +1807,7 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"] + [0, 0, 0, "Styles should be written using objects.", "2"] ], "public/app/features/alerting/unified/components/receivers/ReceiversSection.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -1826,18 +1820,7 @@ exports[`better eslint`] = { ], "public/app/features/alerting/unified/components/receivers/TemplateForm.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"], - [0, 0, 0, "Styles should be written using objects.", "11"], - [0, 0, 0, "Styles should be written using objects.", "12"] + [0, 0, 0, "Styles should be written using objects.", "1"] ], "public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 4b6761b33b4..ab02b8790f1 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -14,6 +14,7 @@ export const availableIconsIndex = { 'adjust-circle': true, 'angle-double-down': true, 'angle-double-right': true, + 'angle-double-left': true, 'angle-double-up': true, 'angle-down': true, 'angle-left': true, diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index ddc9d112773..6272ab22e76 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -97,6 +97,16 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') ), }, + { + path: '/alerting/notifications/templates/*', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + ]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "Templates" */ 'app/features/alerting/unified/Templates') + ), + }, { path: '/alerting/notifications/:type/new', roles: evaluateAccess([ diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx index 49d69693365..fc37703fba1 100644 --- a/public/app/features/alerting/unified/Receivers.tsx +++ b/public/app/features/alerting/unified/Receivers.tsx @@ -2,33 +2,22 @@ import React from 'react'; import { Route, Switch } from 'react-router-dom'; import { withErrorBoundary } from '@grafana/ui'; -const ContactPointsV2 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints')); -const EditContactPoint = SafeDynamicImport(() => import('./components/contact-points/EditContactPoint')); -const NewContactPoint = SafeDynamicImport(() => import('./components/contact-points/NewContactPoint')); -const EditMessageTemplate = SafeDynamicImport(() => import('./components/contact-points/EditMessageTemplate')); -const NewMessageTemplate = SafeDynamicImport(() => import('./components/contact-points/NewMessageTemplate')); -const GlobalConfig = SafeDynamicImport(() => import('./components/contact-points/components/GlobalConfig')); -const DuplicateMessageTemplate = SafeDynamicImport( - () => import('./components/contact-points/DuplicateMessageTemplate') -); import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; +const ContactPointsV2 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints')); +const EditContactPoint = SafeDynamicImport(() => import('./components/contact-points/EditContactPoint')); +const NewContactPoint = SafeDynamicImport(() => import('./components/contact-points/NewContactPoint')); +const GlobalConfig = SafeDynamicImport(() => import('./components/contact-points/components/GlobalConfig')); + const ContactPoints = (_props: GrafanaRouteComponentProps): JSX.Element => ( - - - diff --git a/public/app/features/alerting/unified/Templates.tsx b/public/app/features/alerting/unified/Templates.tsx new file mode 100644 index 00000000000..e728d588812 --- /dev/null +++ b/public/app/features/alerting/unified/Templates.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { withErrorBoundary } from '@grafana/ui'; +import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; + +import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; + +const EditMessageTemplate = SafeDynamicImport(() => import('./components/contact-points/EditMessageTemplate')); +const NewMessageTemplate = SafeDynamicImport(() => import('./components/contact-points/NewMessageTemplate')); +const DuplicateMessageTemplate = SafeDynamicImport( + () => import('./components/contact-points/DuplicateMessageTemplate') +); + +const NotificationTemplates = (_props: GrafanaRouteComponentProps): JSX.Element => ( + + + + + + + +); + +export default withErrorBoundary(NotificationTemplates, { style: 'page' }); diff --git a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx index 44fe60d61e0..62f9069b8db 100644 --- a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx +++ b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx @@ -18,9 +18,7 @@ interface AlertingPageWrapperProps extends PageProps { export const AlertingPageWrapper = ({ children, isLoading, ...rest }: AlertingPageWrapperProps) => ( - -
{children}
-
+ {children}
); diff --git a/public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx b/public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx new file mode 100644 index 00000000000..8b6e37f0047 --- /dev/null +++ b/public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx @@ -0,0 +1,33 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, Stack, Label } from '@grafana/ui'; + +export function EditorColumnHeader({ label, actions }: { label: string; actions?: React.ReactNode }) { + const styles = useStyles2(editorColumnStyles); + + return ( +
+ + + {actions} + +
+ ); +} + +export const editorColumnStyles = (theme: GrafanaTheme2) => ({ + container: css({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: theme.spacing(1, 2), + backgroundColor: theme.colors.background.secondary, + borderBottom: `1px solid ${theme.colors.border.medium}`, + }), + label: css({ + margin: 0, + }), +}); diff --git a/public/app/features/alerting/unified/components/receivers/AlertInstanceModalSelector.tsx b/public/app/features/alerting/unified/components/receivers/AlertInstanceModalSelector.tsx index 3691f4b2a97..1c5876b7bdb 100644 --- a/public/app/features/alerting/unified/components/receivers/AlertInstanceModalSelector.tsx +++ b/public/app/features/alerting/unified/components/receivers/AlertInstanceModalSelector.tsx @@ -168,10 +168,13 @@ export function AlertInstanceModalSelector({ const instances: TestTemplateAlert[] = selectedInstances?.map((instance: AlertmanagerAlert) => { const alert: TestTemplateAlert = { + status: 'firing', annotations: instance.annotations, labels: instance.labels, startsAt: instance.startsAt, endsAt: instance.endsAt, + generatorURL: instance.generatorURL, + fingerprint: instance.fingerprint, }; return alert; }) || []; diff --git a/public/app/features/alerting/unified/components/receivers/PayloadEditor.test.tsx b/public/app/features/alerting/unified/components/receivers/PayloadEditor.test.tsx index cd8137f2391..aaced7dfded 100644 --- a/public/app/features/alerting/unified/components/receivers/PayloadEditor.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/PayloadEditor.test.tsx @@ -1,15 +1,13 @@ 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 { Props } from 'react-virtualized-auto-sizer'; - -import { configureStore } from 'app/store/configureStore'; - -import 'whatwg-fetch'; +import { TestProvider } from 'test/helpers/TestProvider'; import { PayloadEditor, RESET_TO_DEFAULT } from './PayloadEditor'; +import 'whatwg-fetch'; + const DEFAULT_PAYLOAD = `[ { "annotations": { @@ -48,17 +46,11 @@ const PayloadEditorWithState = () => { defaultPayload={DEFAULT_PAYLOAD} setPayloadFormatError={jest.fn()} payloadFormatError={null} - onPayloadError={jest.fn()} /> ); }; const renderWithProvider = () => { - const store = configureStore(); - render( - - - - ); + render(, { wrapper: TestProvider }); }; describe('Payload editor', () => { @@ -82,7 +74,10 @@ describe('Payload editor', () => { 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)); + + // click edit payload > reset to defaults + await userEvent.click(screen.getByRole('button', { name: 'Edit payload' })); + await userEvent.click(screen.getByRole('menuitem', { name: 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 index a306daf7b5f..95895b7ce58 100644 --- a/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx +++ b/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx @@ -1,17 +1,19 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React, { useState } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; -import { Badge, Button, CodeEditor, Icon, Tooltip, useStyles2 } from '@grafana/ui'; +import { Button, CodeEditor, Dropdown, Menu, Stack, Toggletip, useStyles2 } from '@grafana/ui'; import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types'; +import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader'; + import { AlertInstanceModalSelector } from './AlertInstanceModalSelector'; import { AlertTemplatePreviewData } from './TemplateData'; import { TemplateDataTable } from './TemplateDataDocs'; import { GenerateAlertDataModal } from './form/GenerateAlertDataModal'; -export const RESET_TO_DEFAULT = 'Reset to default'; +export const RESET_TO_DEFAULT = 'Reset to defaults'; export function PayloadEditor({ payload, @@ -19,14 +21,14 @@ export function PayloadEditor({ defaultPayload, setPayloadFormatError, payloadFormatError, - onPayloadError, + className, }: { payload: string; defaultPayload: string; setPayload: React.Dispatch>; setPayloadFormatError: (value: React.SetStateAction) => void; payloadFormatError: string | null; - onPayloadError: () => void; + className?: string; }) { const styles = useStyles2(getStyles); const onReset = () => { @@ -48,7 +50,6 @@ export function PayloadEditor({ setPayloadFormatError(null); } catch (e) { setPayloadFormatError(e instanceof Error ? e.message : 'Invalid JSON.'); - onPayloadError(); throw e; } }; @@ -79,67 +80,65 @@ export function PayloadEditor({ const [isAlertSelectorOpen, setIsAlertSelectorOpen] = useState(false); return ( -
-
-
- Payload data - } theme="info"> - - -
- - {({ width }) => ( -
+ <> +
+ + + + + + + + } + > + + + } placement="top" fitContent> + + + + } + /> + +
+ + {({ width, height }) => ( -
- )} - - -
- - - - - - {payloadFormatError !== null && ( - - )} + )} +
+ setIsAlertSelectorOpen(false)} /> -
+ ); } const AlertTemplateDataTable = () => { - const styles = useStyles2(getStyles); - return ( - - Alert template data This is the list of alert data fields used in the preview. - - } - dataItems={AlertTemplatePreviewData} - /> - ); + return ; }; const getStyles = (theme: GrafanaTheme2) => ({ - jsonEditor: css` - width: 100%; - height: 100%; - `, - buttonsWrapper: css` - margin-top: ${theme.spacing(1)}; - display: flex; - flex-wrap: wrap; - `, - button: css` - flex: none; - width: fit-content; - padding-right: ${theme.spacing(1)}; - margin-right: ${theme.spacing(1)}; - margin-bottom: ${theme.spacing(1)}; - `, - title: css` - font-weight: ${theme.typography.fontWeightBold}; - heigth: 41px; - padding-top: 10px; - padding-left: ${theme.spacing(2)}; - margin-top: 19px; - `, wrapper: css` - flex: 1; - min-width: 450px; + display: flex; + flex-direction: column; + height: 100%; `, tooltip: css` padding-left: ${theme.spacing(1)}; `, - editorWrapper: css` - width: min-content; - padding-top: 7px; - `, - editor: css` - display: flex; - flex-direction: column; - margin-top: ${theme.spacing(-1)}; - `, + label: css({ + margin: 0, + }), + editorWrapper: css({ + flex: 1, + }), + editorContainer: css({ + width: 'fit-content', + border: 'none', + }), templateDataDocsHeader: css` color: ${theme.colors.text.primary}; diff --git a/public/app/features/alerting/unified/components/receivers/TemplateData.ts b/public/app/features/alerting/unified/components/receivers/TemplateData.ts index a46eaaaa8e4..a928280f759 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateData.ts +++ b/public/app/features/alerting/unified/components/receivers/TemplateData.ts @@ -1,6 +1,6 @@ export interface TemplateDataItem { name: string; - type: 'string' | '[]Alert' | 'KeyValue' | 'time.Time'; + type: string; notes: string; } @@ -61,23 +61,23 @@ export const GlobalTemplateData: TemplateDataItem[] = [ export const AlertTemplatePreviewData: TemplateDataItem[] = [ { - name: 'Labels', - type: 'KeyValue', + name: 'labels', + type: 'Object{}', notes: 'Set of labels attached to the alert.', }, { - name: 'Annotations', - type: 'KeyValue', + name: 'annotations', + type: 'Object{}', notes: 'Set of annotations attached to the alert.', }, { - name: 'StartsAt', - type: 'time.Time', + name: 'startsAt', + type: 'string (ISO8601)', notes: 'Time the alert started firing.', }, { - name: 'EndsAt', - type: 'time.Time', + name: 'endsAt', + type: 'string (ISO8601)', notes: 'Time the alert ends firing.', }, ]; diff --git a/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx b/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx index bbf5b49dbc2..905c397e921 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateDataDocs.tsx @@ -67,7 +67,7 @@ const getTemplateDataDocsStyles = (theme: GrafanaTheme2) => ({ interface TemplateDataTableProps { dataItems: TemplateDataItem[]; - caption: JSX.Element | string; + caption?: JSX.Element | string; typeRenderer?: (type: TemplateDataItem['type']) => React.ReactNode; } @@ -76,7 +76,7 @@ export function TemplateDataTable({ dataItems, caption, typeRenderer }: Template return ( - + {caption && } diff --git a/public/app/features/alerting/unified/components/receivers/TemplateEditor.tsx b/public/app/features/alerting/unified/components/receivers/TemplateEditor.tsx index c2223ded6d5..e09b1f94efa 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateEditor.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateEditor.tsx @@ -44,6 +44,9 @@ const TemplateEditor = (props: TemplateEditorProps) => { showLineNumbers={true} showMiniMap={false} {...props} + monacoOptions={{ + scrollBeyondLastLine: false, + }} onEditorDidMount={onEditorDidMount} onBeforeEditorMount={(monaco) => { registerLanguage(monaco, goTemplateLanguageDefinition); diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index 6ed886fc853..4c0efa81fe3 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -1,9 +1,9 @@ -import { css } from '@emotion/css'; -import { subDays } from 'date-fns'; +import { css, cx } from '@emotion/css'; +import { addMinutes, subDays, subHours } 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 React, { useRef, useState } from 'react'; +import { FormProvider, useForm, Validate } from 'react-hook-form'; +import { useToggle } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; @@ -11,28 +11,21 @@ import { isFetchError } from '@grafana/runtime'; import { Alert, Button, - CollapsableSection, - Field, FieldSet, Input, LinkButton, - Spinner, - Tab, - TabsBar, useStyles2, Stack, + useSplitter, + Drawer, + InlineField, + Box, } from '@grafana/ui'; import { useCleanup } from 'app/core/hooks/useCleanup'; -import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; +import { AlertManagerCortexConfig, TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types'; -import { - AlertField, - TemplatePreviewErrors, - TemplatePreviewResponse, - TemplatePreviewResult, - usePreviewTemplateMutation, -} from '../../api/templateApi'; +import { AppChromeUpdate } from '../../../../../core/components/AppChrome/AppChromeUpdate'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { updateAlertManagerConfigAction } from '../../state/actions'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; @@ -40,10 +33,12 @@ import { makeAMLink } from '../../utils/misc'; import { initialAsyncRequestState } from '../../utils/redux'; import { ensureDefine } from '../../utils/templates'; import { ProvisionedResource, ProvisioningAlert } from '../Provisioning'; +import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader'; import { PayloadEditor } from './PayloadEditor'; import { TemplateDataDocs } from './TemplateDataDocs'; import { TemplateEditor } from './TemplateEditor'; +import { TemplatePreview } from './TemplatePreview'; import { snippets } from './editor/templateDataSuggestions'; export interface TemplateFormValues { @@ -64,35 +59,56 @@ interface Props { } 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()}" - }] -`; - +/** + * 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 = ({ existing, alertManagerSourceName, config, provenance }: Props) => { const styles = useStyles2(getStyles); const dispatch = useDispatch(); useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState)); + const formRef = useRef(null); + const isGrafanaAlertManager = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME; const { loading, error } = useUnifiedAlertingSelector((state) => state.saveAMConfig); - const location = useLocation(); - const isduplicating = isDuplicating(location); + const [cheatsheetOpened, toggleCheatsheetOpened] = useToggle(false); - const [payload, setPayload] = useState(DEFAULT_PAYLOAD); + const [payload, setPayload] = useState(defaultPayloadString); const [payloadFormatError, setPayloadFormatError] = useState(null); - const [view, setView] = useState<'content' | 'preview'>('content'); + // 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', + }); - const onPayloadError = () => setView('preview'); + // 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 submit = (values: TemplateFormValues) => { // wrap content in "define" if it's not already wrapped, in case user did not do it/ @@ -152,115 +168,154 @@ export const TemplateForm = ({ existing, alertManagerSourceName, config, provena ? true : 'Another template with this name already exists.'; }; - const isGrafanaAlertManager = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME; + + const actionButtons = ( + + + + Cancel + + + ); return ( - -
-

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

- {error && ( - - {error.message || (isFetchError(error) && error.data?.message) || String(error)} - - )} - {provenance && } -
- - - - -
-
- - setView('content')} /> - {isGrafanaAlertManager && ( - setView('preview')} /> - )} - -
- - {({ width }) => ( + <> + + + + {/* error message */} + {error && ( + + {error.message || (isFetchError(error) && error.data?.message) || String(error)} + + )} + {/* warning about provisioned template */} + {provenance && } + + {/* name field for the template */} +
+ + + + + {/* editor layout */} +
+
+ {/* template content and payload editor column – full height and half-width */} +
+ {/* template editor */} +
+ {/* primaryProps will set "minHeight: min-content;" so we have to make sure to apply minHeight to the child */} +
+ + Help + + } + /> + + + {({ width, height }) => ( + setValue('content', value)} + containerStyles={styles.editorContainer} + width={width} + height={height} + /> + )} + + +
+
+ {/* payload editor – only available for Grafana Alertmanager */} + {isGrafanaAlertManager && ( <> - {view === 'content' ? ( -
- -
- setValue('content', value)} - /> -
-
-
- {loading && ( - - )} - {!loading && ( - - )} - - Cancel - -
+
+
+
+
- ) : ( - - )} +
)} - +
+ {/* preview column – full height and half-width */} + {isGrafanaAlertManager && ( + <> +
+
+ +
+ + )}
- {isGrafanaAlertManager && ( - - )} -
-
- - - - -
+
+ +
+ {cheatsheetOpened && ( + + + + )} + ); }; -function TemplatingGuideline() { +function TemplatingBasics() { const styles = useStyles2(getStyles); return ( - +
Grafana uses Go templating language to create notification messages. @@ -291,191 +346,125 @@ 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, - width, -}: { - payload: string; - templateName: string; - payloadFormatError: string | null; - setPayloadFormatError: (value: React.SetStateAction) => void; - width: number; -}) { - 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]); - +function TemplatingCheatSheet() { return ( -
- {isLoading && ( - <> - Loading preview... - - )} -
-        {previewToRender}
-      
- -
+ + + + ); } -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; +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', + }, + }), + snippets: css` + margin-top: ${theme.spacing(2)}; + font-size: ${theme.typography.bodySmall.fontSize}; `, - button: css` - flex: none; - width: fit-content; - margin-top: -6px; + code: css` + color: ${theme.colors.text.secondary}; + font-weight: ${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', }, - collapsableSection: css` - width: fit-content; - `, - editorsWrapper: css` - display: flex; - flex: 1; - flex-wrap: wrap; - gap: ${theme.spacing(1)}; - `, -}); + { + 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', + }, +]; + +const defaultPayloadString = JSON.stringify(defaultPayload, null, 2); diff --git a/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx b/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx index 02b0bd6fba3..fbe9b77212f 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatePreview.test.tsx @@ -1,18 +1,32 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { screen, render, 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 { byRole } from 'testing-library-selector'; +import { Components } from '@grafana/e2e-selectors'; 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 { + mockPreviewTemplateResponse, + mockPreviewTemplateResponseRejected, + REJECTED_PREVIEW_RESPONSE, +} from '../../mocks/templatesApi'; -import { defaults, PREVIEW_NOT_AVAILABLE, TemplateFormValues, TemplatePreview } from './TemplateForm'; +import { defaults, TemplateFormValues } from './TemplateForm'; +import { TemplatePreview } from './TemplatePreview'; + +jest.mock( + 'react-virtualized-auto-sizer', + () => + ({ children }: { children: ({ height, width }: { height: number; width: number }) => JSX.Element }) => + children({ height: 500, width: 400 }) +); const getProviderWraper = () => { return function Wrapper({ children }: React.PropsWithChildren<{}>) { @@ -41,11 +55,15 @@ afterAll(() => { server.close(); }); +const ui = { + errorAlert: byRole('alert', { name: /error/i }), + resultItems: byRole('listitem'), +}; + 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'); + expect(ui.errorAlert.get()).toHaveTextContent('Unexpected token b in JSON at position 0'); }); }); @@ -62,7 +80,6 @@ describe('TemplatePreview component', () => { const setError = jest.fn(); render( { it('Should render error if payload has wrong format rendering the preview', async () => { render( { ); await waitFor(() => { - expect(screen.getByTestId('payloadJSON')).toHaveTextContent('Unexpected token b in JSON at position 0'); + expect(ui.errorAlert.get()).toHaveTextContent('Unexpected token b in JSON at position 0'); }); }); @@ -98,7 +114,6 @@ describe('TemplatePreview component', () => { mockPreviewTemplateResponseRejected(server); render( { ); await waitFor(() => { - expect(screen.getByTestId('payloadJSON')).toHaveTextContent(PREVIEW_NOT_AVAILABLE); + expect(ui.errorAlert.get()).toHaveTextContent(REJECTED_PREVIEW_RESPONSE); }); }); @@ -122,7 +137,6 @@ describe('TemplatePreview component', () => { mockPreviewTemplateResponse(server, response); render( { ); 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<======================' - ); + const previews = ui.resultItems.getAll(); + expect(previews).toHaveLength(2); + expect(previews[0]).toHaveTextContent('This is the template result bla bla bla'); + expect(previews[1]).toHaveTextContent('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' }], @@ -146,9 +162,9 @@ describe('TemplatePreview component', () => { ], }; 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' - ); + const alerts = screen.getAllByTestId(Components.Alert.alertV2('error')); + const previewContent = screen.getByRole('listitem'); + + expect(alerts).toHaveLength(2); + expect(alerts[0]).toHaveTextContent(/Unexpected "{" in operand/i); + expect(alerts[1]).toHaveTextContent(/Unexpected "{" in operand/i); + + expect(previewContent).toHaveTextContent('This is the template result bla bla bla'); }); }); }); diff --git a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx new file mode 100644 index 00000000000..cd80d764661 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx @@ -0,0 +1,175 @@ +import { css, cx } from '@emotion/css'; +import { compact, uniqueId } from 'lodash'; +import React, { useCallback, useEffect } from 'react'; +import { useFormContext } from 'react-hook-form'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, useStyles2, Alert, Box } from '@grafana/ui'; + +import { + AlertField, + TemplatePreviewErrors, + TemplatePreviewResponse, + TemplatePreviewResult, + usePreviewTemplateMutation, +} from '../../api/templateApi'; +import { stringifyErrorLike } from '../../utils/misc'; +import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader'; + +import type { TemplateFormValues } from './TemplateForm'; + +export function TemplatePreview({ + payload, + templateName, + payloadFormatError, + setPayloadFormatError, + className, +}: { + payload: string; + templateName: string; + payloadFormatError: string | null; + setPayloadFormatError: (value: React.SetStateAction) => void; + className?: string; +}) { + const styles = useStyles2(getStyles); + + const { watch } = useFormContext(); + + const templateContent = watch('content'); + + const [trigger, { data, error: previewError, isLoading }] = usePreviewTemplateMutation(); + + const previewToRender = getPreviewResults(previewError, 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 ( +
+ + Refresh + + } + /> + + + {({ height }) =>
{previewToRender}
} +
+
+
+ ); +} + +function PreviewResultViewer({ previews }: { previews: TemplatePreviewResult[] }) { + const styles = useStyles2(getStyles); + // If there is only one template, we don't need to show the name + const singleTemplate = previews.length === 1; + + return ( +
    + {previews.map((preview) => ( +
  • + {singleTemplate ? null :
    {preview.name}
    } +
    {preview.text ?? ''}
    +
  • + ))} +
+ ); +} + +function PreviewErrorViewer({ errors }: { errors: TemplatePreviewErrors[] }) { + return errors.map((error) => ( + + {error.message} + + )); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + label: 'template-preview-container', + display: 'flex', + flexDirection: 'column', + borderRadius: theme.shape.radius.default, + border: `1px solid ${theme.colors.border.medium}`, + }), + viewerContainer: ({ height }: { height: number }) => + css({ + height, + overflow: 'auto', + backgroundColor: theme.colors.background.primary, + }), + viewer: { + container: css({ + display: 'flex', + flexDirection: 'column', + }), + box: css({ + display: 'flex', + flexDirection: 'column', + borderBottom: `1px solid ${theme.colors.border.medium}`, + }), + header: css({ + fontSize: theme.typography.bodySmall.fontSize, + padding: theme.spacing(1, 2), + borderBottom: `1px solid ${theme.colors.border.medium}`, + backgroundColor: theme.colors.background.secondary, + }), + errorText: css({ + color: theme.colors.error.text, + }), + pre: css({ + backgroundColor: 'transparent', + margin: 0, + border: 'none', + padding: theme.spacing(2), + }), + }, +}); + +export function getPreviewResults( + previewError: unknown | undefined, + payloadFormatError: string | null, + data: TemplatePreviewResponse | undefined +): JSX.Element { + // ERRORS IN JSON OR IN REQUEST (endpoint not available, for example) + const previewErrorRequest = previewError ? stringifyErrorLike(previewError) : undefined; + const errorToRender = payloadFormatError || previewErrorRequest; + + //PREVIEW : RESULTS AND ERRORS + const previewResponseResults = data?.results ?? []; + const previewResponseErrors = data?.errors; + + return ( + <> + {errorToRender && ( + + {errorToRender} + + )} + {previewResponseErrors && } + {previewResponseResults && } + + ); +} diff --git a/public/app/features/alerting/unified/components/receivers/form/GenerateAlertDataModal.tsx b/public/app/features/alerting/unified/components/receivers/form/GenerateAlertDataModal.tsx index 4d4879cfb19..4fb7abb1bb3 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GenerateAlertDataModal.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GenerateAlertDataModal.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/css'; import { addDays, subDays } from 'date-fns'; +import { uniqueId } from 'lodash'; import React, { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; @@ -53,6 +54,8 @@ export const GenerateAlertDataModal = ({ isOpen, onDismiss, onAccept }: Props) = }, {}), startsAt: '2023-04-01T00:00:00Z', endsAt: status === 'firing' ? addDays(new Date(), 1).toISOString() : subDays(new Date(), 1).toISOString(), + status, + fingerprint: uniqueId('fingerprint_'), }; setAlerts((alerts) => [...alerts, alert]); formMethods.reset(); diff --git a/public/app/features/alerting/unified/mocks/templatesApi.ts b/public/app/features/alerting/unified/mocks/templatesApi.ts index d0e591fb3c5..71461f216e9 100644 --- a/public/app/features/alerting/unified/mocks/templatesApi.ts +++ b/public/app/features/alerting/unified/mocks/templatesApi.ts @@ -8,6 +8,8 @@ export function mockPreviewTemplateResponse(server: SetupServer, response: Templ server.use(http.post(previewTemplateUrl, () => HttpResponse.json(response))); } +export const REJECTED_PREVIEW_RESPONSE = 'error, something went wrong'; + export function mockPreviewTemplateResponseRejected(server: SetupServer) { - server.use(http.post(previewTemplateUrl, () => HttpResponse.json('error', { status: 500 }))); + server.use(http.post(previewTemplateUrl, () => HttpResponse.json(REJECTED_PREVIEW_RESPONSE, { status: 500 }))); } diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index 059773ac0d0..ba3f6594ffe 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -1,5 +1,4 @@ //DOCS: https://prometheus.io/docs/alerting/latest/configuration/ - import { DataSourceJsonData } from '@grafana/data'; export type AlertManagerCortexConfig = { @@ -255,7 +254,12 @@ export interface AlertmanagerStatus { } export type TestReceiversAlert = Pick; -export type TestTemplateAlert = Pick; +export type TestTemplateAlert = Pick< + AlertmanagerAlert, + 'annotations' | 'labels' | 'startsAt' | 'endsAt' | 'generatorURL' | 'fingerprint' +> & { + status: 'firing' | 'resolved'; +}; export interface TestReceiversPayload { receivers?: Receiver[];
{caption}{caption}
Name