Alerting: Improve template preview (#84798)

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Konrad Lalik
2024-04-04 18:37:24 +03:00
committed by GitHub
co-authored by Gilles De Mey
parent b08285962d
commit 9b4f88c6f6
19 changed files with 708 additions and 496 deletions
+2 -19
View File
@@ -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"],
+1
View File
@@ -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,
+10
View File
@@ -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([
@@ -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 => (
<AlertmanagerPageWrapper navId="receivers" accessType="notification">
<Switch>
<Route exact={true} path="/alerting/notifications" component={ContactPointsV2} />
<Route exact={true} path="/alerting/notifications/receivers/new" component={NewContactPoint} />
<Route exact={true} path="/alerting/notifications/receivers/:name/edit" component={EditContactPoint} />
<Route exact={true} path="/alerting/notifications/templates/:name/edit" component={EditMessageTemplate} />
<Route exact={true} path="/alerting/notifications/templates/new" component={NewMessageTemplate} />
<Route
exact={true}
path="/alerting/notifications/templates/:name/duplicate"
component={DuplicateMessageTemplate}
/>
<Route exact={true} path="/alerting/notifications/global-config" component={GlobalConfig} />
</Switch>
</AlertmanagerPageWrapper>
@@ -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 => (
<AlertmanagerPageWrapper
navId="receivers"
accessType="notification"
pageNav={{ id: 'templates', text: 'Notification templates', subTitle: 'Create and edit notification templates' }}
>
<Switch>
<Route exact={true} path="/alerting/notifications/templates/:name/edit" component={EditMessageTemplate} />
<Route exact={true} path="/alerting/notifications/templates/new" component={NewMessageTemplate} />
<Route
exact={true}
path="/alerting/notifications/templates/:name/duplicate"
component={DuplicateMessageTemplate}
/>
</Switch>
</AlertmanagerPageWrapper>
);
export default withErrorBoundary(NotificationTemplates, { style: 'page' });
@@ -18,9 +18,7 @@ interface AlertingPageWrapperProps extends PageProps {
export const AlertingPageWrapper = ({ children, isLoading, ...rest }: AlertingPageWrapperProps) => (
<Page {...rest}>
<Page.Contents isLoading={isLoading}>
<div>{children}</div>
</Page.Contents>
<Page.Contents isLoading={isLoading}>{children}</Page.Contents>
</Page>
);
@@ -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 (
<div className={styles.container}>
<Label className={styles.label}>{label}</Label>
<Stack direction="row" gap={1}>
{actions}
</Stack>
</div>
);
}
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,
}),
});
@@ -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;
}) || [];
@@ -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(
<Provider store={store}>
<PayloadEditorWithState />
</Provider>
);
render(<PayloadEditorWithState />, { 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" }]'
@@ -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<React.SetStateAction<string>>;
setPayloadFormatError: (value: React.SetStateAction<string | null>) => 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 (
<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>
<AutoSizer disableHeight>
{({ width }) => (
<div className={styles.editorWrapper}>
<>
<div className={cx(styles.wrapper, className)}>
<EditorColumnHeader
label="Payload"
actions={
<Stack direction="row" alignItems="center" gap={0.5}>
<Dropdown
overlay={
<Menu>
<Menu.Item
label="Use existing alert instances"
disabled={errorInPayloadJson}
onClick={onOpenAlertSelectorModal}
/>
<Menu.Item
label="Add custom alert instance"
disabled={errorInPayloadJson}
onClick={onOpenEditAlertModal}
/>
<Menu.Divider />
<Menu.Item label={RESET_TO_DEFAULT} onClick={onReset} destructive />
</Menu>
}
>
<Button variant="secondary" size="sm" icon="angle-down">
Edit payload
</Button>
</Dropdown>
<Toggletip content={<AlertTemplateDataTable />} placement="top" fitContent>
<Button variant="secondary" fill="outline" size="sm" icon="question-circle">
Help
</Button>
</Toggletip>
</Stack>
}
/>
<div className={styles.editorWrapper}>
<AutoSizer>
{({ width, height }) => (
<CodeEditor
containerStyles={styles.editorContainer}
width={width}
height={362}
height={height}
language={'json'}
showLineNumbers={true}
showMiniMap={false}
value={payload}
readOnly={false}
onBlur={setPayload}
monacoOptions={{
scrollBeyondLastLine: false,
}}
/>
</div>
)}
</AutoSizer>
<div className={styles.buttonsWrapper}>
<Button
type="button"
variant="secondary"
className={styles.button}
icon="bell"
disabled={errorInPayloadJson}
onClick={onOpenAlertSelectorModal}
>
Select alert instances
</Button>
<Button
onClick={onOpenEditAlertModal}
className={styles.button}
icon="plus-circle"
type="button"
variant="secondary"
disabled={errorInPayloadJson}
>
Add custom alerts
</Button>
<Button onClick={onReset} className={styles.button} icon="arrow-up" type="button" variant="destructive">
{RESET_TO_DEFAULT}
</Button>
{payloadFormatError !== null && (
<Badge
color="orange"
icon="exclamation-triangle"
text={'JSON Error'}
tooltip={'Fix errors in payload, and click Refresh preview button'}
/>
)}
)}
</AutoSizer>
</div>
</div>
<GenerateAlertDataModal isOpen={isEditingAlertData} onDismiss={onCloseEditAlertModal} onAccept={onAddAlertList} />
<AlertInstanceModalSelector
@@ -147,62 +146,31 @@ export function PayloadEditor({
isOpen={isAlertSelectorOpen}
onClose={() => setIsAlertSelectorOpen(false)}
/>
</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}
/>
);
return <TemplateDataTable dataItems={AlertTemplatePreviewData} />;
};
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};
@@ -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.',
},
];
@@ -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 (
<table className={styles.table}>
<caption>{caption}</caption>
{caption && <caption>{caption}</caption>}
<thead>
<tr>
<th>Name</th>
@@ -44,6 +44,9 @@ const TemplateEditor = (props: TemplateEditorProps) => {
showLineNumbers={true}
showMiniMap={false}
{...props}
monacoOptions={{
scrollBeyondLastLine: false,
}}
onEditorDidMount={onEditorDidMount}
onBeforeEditorMount={(monaco) => {
registerLanguage(monaco, goTemplateLanguageDefinition);
@@ -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<HTMLFormElement>(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<string | null>(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 = (
<Stack>
<Button onClick={() => formRef.current?.requestSubmit()} variant="primary" size="sm" disabled={loading}>
Save
</Button>
<LinkButton
disabled={loading}
href={makeAMLink('alerting/notifications', alertManagerSourceName)}
variant="secondary"
size="sm"
>
Cancel
</LinkButton>
</Stack>
);
return (
<FormProvider {...formApi}>
<form onSubmit={handleSubmit(submit)}>
<h4>{existing && !isduplicating ? 'Edit notification template' : 'Create notification template'}</h4>
{error && (
<Alert severity="error" title="Error saving template">
{error.message || (isFetchError(error) && error.data?.message) || String(error)}
</Alert>
)}
{provenance && <ProvisioningAlert resource={ProvisionedResource.Template} />}
<FieldSet disabled={Boolean(provenance)}>
<Field label="Template name" error={errors?.name?.message} invalid={!!errors.name?.message} required>
<Input
{...register('name', {
required: { value: true, message: 'Required.' },
validate: { nameIsUnique: validateNameIsUnique },
})}
placeholder="Give your template a name"
width={42}
autoFocus={true}
/>
</Field>
<TemplatingGuideline />
<div className={styles.editorsWrapper}>
<div className={styles.contentContainer}>
<TabsBar>
<Tab label="Content" active={view === 'content'} onChangeTab={() => setView('content')} />
{isGrafanaAlertManager && (
<Tab label="Preview" active={view === 'preview'} onChangeTab={() => setView('preview')} />
)}
</TabsBar>
<div className={styles.contentContainerEditor}>
<AutoSizer>
{({ width }) => (
<>
<FormProvider {...formApi}>
<AppChromeUpdate actions={actionButtons} />
<form onSubmit={handleSubmit(submit)} ref={formRef} className={styles.form}>
{/* error message */}
{error && (
<Alert severity="error" title="Error saving template">
{error.message || (isFetchError(error) && error.data?.message) || String(error)}
</Alert>
)}
{/* warning about provisioned template */}
{provenance && <ProvisioningAlert resource={ProvisionedResource.Template} />}
{/* name field for the template */}
<FieldSet disabled={Boolean(provenance)} className={styles.fieldset}>
<InlineField
label="Template name"
error={errors?.name?.message}
invalid={!!errors.name?.message}
required
className={styles.nameField}
>
<Input
{...register('name', {
required: { value: true, message: 'Required.' },
validate: { nameIsUnique: validateNameIsUnique },
})}
placeholder="Give your template a name"
width={42}
autoFocus={true}
/>
</InlineField>
{/* editor layout */}
<div {...rowSplitter.containerProps} className={styles.contentContainer}>
<div {...rowSplitter.primaryProps}>
{/* template content and payload editor column full height and half-width */}
<div {...columnSplitter.containerProps} className={styles.contentField}>
{/* template editor */}
<div {...columnSplitter.primaryProps}>
{/* primaryProps will set "minHeight: min-content;" so we have to make sure to apply minHeight to the child */}
<div className={cx(styles.flexColumn, styles.containerWithBorderAndRadius, styles.minEditorSize)}>
<EditorColumnHeader
label="Template"
actions={
<Button
icon="question-circle"
size="sm"
fill="outline"
variant="secondary"
onClick={toggleCheatsheetOpened}
>
Help
</Button>
}
/>
<Box flex={1}>
<AutoSizer>
{({ width, height }) => (
<TemplateEditor
value={getValues('content')}
onBlur={(value) => setValue('content', value)}
containerStyles={styles.editorContainer}
width={width}
height={height}
/>
)}
</AutoSizer>
</Box>
</div>
</div>
{/* payload editor only available for Grafana Alertmanager */}
{isGrafanaAlertManager && (
<>
{view === 'content' ? (
<div>
<Field error={errors?.content?.message} invalid={!!errors.content?.message} required>
<div className={styles.editWrapper}>
<TemplateEditor
value={getValues('content')}
width={width}
height={363}
onBlur={(value) => setValue('content', value)}
/>
</div>
</Field>
<div className={styles.buttons}>
{loading && (
<Button disabled={true} icon="spinner" variant="primary">
Saving...
</Button>
)}
{!loading && (
<Button type="submit" variant="primary">
Save template
</Button>
)}
<LinkButton
disabled={loading}
href={makeAMLink('alerting/notifications', alertManagerSourceName)}
variant="secondary"
type="button"
>
Cancel
</LinkButton>
</div>
<div {...columnSplitter.splitterProps} />
<div {...columnSplitter.secondaryProps}>
<div
className={cx(
styles.containerWithBorderAndRadius,
styles.minEditorSize,
styles.payloadEditor,
styles.flexFull
)}
>
<PayloadEditor
payload={payload}
defaultPayload={defaultPayloadString}
setPayload={setPayload}
setPayloadFormatError={setPayloadFormatError}
payloadFormatError={payloadFormatError}
/>
</div>
) : (
<TemplatePreview
width={width}
payload={payload}
templateName={watch('name')}
setPayloadFormatError={setPayloadFormatError}
payloadFormatError={payloadFormatError}
/>
)}
</div>
</>
)}
</AutoSizer>
</div>
</div>
{/* preview column full height and half-width */}
{isGrafanaAlertManager && (
<>
<div {...rowSplitter.secondaryProps}>
<div {...rowSplitter.splitterProps}></div>
<TemplatePreview
payload={payload}
templateName={watch('name')}
setPayloadFormatError={setPayloadFormatError}
payloadFormatError={payloadFormatError}
className={cx(styles.templatePreview, styles.minEditorSize)}
/>
</div>
</>
)}
</div>
{isGrafanaAlertManager && (
<PayloadEditor
payload={payload}
setPayload={setPayload}
defaultPayload={DEFAULT_PAYLOAD}
setPayloadFormatError={setPayloadFormatError}
payloadFormatError={payloadFormatError}
onPayloadError={onPayloadError}
/>
)}
</div>
</FieldSet>
<CollapsableSection label="Data cheat sheet" isOpen={false} className={styles.collapsableSection}>
<TemplateDataDocs />
</CollapsableSection>
</form>
</FormProvider>
</FieldSet>
</form>
</FormProvider>
{cheatsheetOpened && (
<Drawer title="Templating cheat sheet" onClose={toggleCheatsheetOpened} size="lg">
<TemplatingCheatSheet />
</Drawer>
)}
</>
);
};
function TemplatingGuideline() {
function TemplatingBasics() {
const styles = useStyles2(getStyles);
return (
<Alert title="Templating guideline" severity="info">
<Alert title="How to" severity="info">
<Stack direction="row">
<div>
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<string | null>) => void;
width: number;
}) {
const styles = useStyles2(getStyles);
const { watch } = useFormContext<TemplateFormValues>();
const templateContent = watch('content');
const [trigger, { data, isError: isPreviewError, isLoading }] = usePreviewTemplateMutation();
const previewToRender = getPreviewTorender(isPreviewError, payloadFormatError, data);
const onPreview = useCallback(() => {
try {
const alertList: AlertField[] = JSON.parse(payload);
JSON.stringify([...alertList]); // check if it's iterable, in order to be able to add more data
trigger({ template: templateContent, alerts: alertList, name: templateName });
setPayloadFormatError(null);
} catch (e) {
setPayloadFormatError(e instanceof Error ? e.message : 'Invalid JSON.');
}
}, [templateContent, templateName, payload, setPayloadFormatError, trigger]);
useEffect(() => onPreview(), [onPreview]);
function TemplatingCheatSheet() {
return (
<div style={{ width: `${width}px` }} className={styles.preview.wrapper}>
{isLoading && (
<>
<Spinner inline={true} /> Loading preview...
</>
)}
<pre className={styles.preview.result} data-testid="payloadJSON">
{previewToRender}
</pre>
<Button onClick={onPreview} className={styles.preview.button} icon="arrow-up" type="button" variant="secondary">
Refresh preview
</Button>
</div>
<Stack direction="column" gap={1}>
<TemplatingBasics />
<TemplateDataDocs />
</Stack>
);
}
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);
@@ -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(
<TemplatePreview
width={50}
payload={'bla bla bla'}
templateName="potato"
payloadFormatError={'Unexpected token b in JSON at position 0'}
@@ -54,7 +72,7 @@ describe('TemplatePreview component', () => {
{ 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(
<TemplatePreview
width={50}
payload={'{"a":"b"}'}
templateName="potato"
payloadFormatError={'Unexpected token b in JSON at position 0'}
@@ -78,7 +95,6 @@ describe('TemplatePreview component', () => {
it('Should render error if payload has wrong format rendering the preview', async () => {
render(
<TemplatePreview
width={50}
payload={'potatos and cherries'}
templateName="potato"
payloadFormatError={'Unexpected token b in JSON at position 0'}
@@ -90,7 +106,7 @@ describe('TemplatePreview component', () => {
);
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(
<TemplatePreview
width={50}
payload={'[{"a":"b"}]'}
templateName="potato"
payloadFormatError={null}
@@ -108,7 +123,7 @@ describe('TemplatePreview component', () => {
);
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(
<TemplatePreview
width={50}
payload={'[{"a":"b"}]'}
templateName="potato"
payloadFormatError={null}
@@ -132,11 +146,13 @@ describe('TemplatePreview component', () => {
);
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(
<TemplatePreview
width={50}
payload={'[{"a":"b"}]'}
templateName="potato"
payloadFormatError={null}
@@ -156,10 +172,16 @@ describe('TemplatePreview component', () => {
/>,
{ 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');
});
});
});
@@ -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<string | null>) => void;
className?: string;
}) {
const styles = useStyles2(getStyles);
const { watch } = useFormContext<TemplateFormValues>();
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 (
<div className={cx(styles.container, className)}>
<EditorColumnHeader
label="Preview"
actions={
<Button
disabled={isLoading}
icon="sync"
aria-label="Refresh preview"
onClick={onPreview}
size="sm"
variant="secondary"
>
Refresh
</Button>
}
/>
<Box flex={1}>
<AutoSizer disableWidth>
{({ height }) => <div className={styles.viewerContainer({ height })}>{previewToRender}</div>}
</AutoSizer>
</Box>
</div>
);
}
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 (
<ul className={styles.viewer.container}>
{previews.map((preview) => (
<li className={styles.viewer.box} key={preview.name}>
{singleTemplate ? null : <header className={styles.viewer.header}>{preview.name}</header>}
<pre className={styles.viewer.pre}>{preview.text ?? '<Empty>'}</pre>
</li>
))}
</ul>
);
}
function PreviewErrorViewer({ errors }: { errors: TemplatePreviewErrors[] }) {
return errors.map((error) => (
<Alert key={uniqueId('errors-list')} title={compact([error.name, error.kind]).join(' ')}>
{error.message}
</Alert>
));
}
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 && (
<Alert severity="error" title="Error">
{errorToRender}
</Alert>
)}
{previewResponseErrors && <PreviewErrorViewer errors={previewResponseErrors} />}
{previewResponseResults && <PreviewResultViewer previews={previewResponseResults} />}
</>
);
}
@@ -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();
@@ -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 })));
}
@@ -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<AlertmanagerAlert, 'annotations' | 'labels'>;
export type TestTemplateAlert = Pick<AlertmanagerAlert, 'annotations' | 'labels' | 'startsAt' | 'endsAt'>;
export type TestTemplateAlert = Pick<
AlertmanagerAlert,
'annotations' | 'labels' | 'startsAt' | 'endsAt' | 'generatorURL' | 'fingerprint'
> & {
status: 'firing' | 'resolved';
};
export interface TestReceiversPayload {
receivers?: Receiver[];