mirror of
https://github.com/grafana/grafana.git
synced 2026-07-29 15:59:50 -05:00
Alerting: Improve template preview (#84798)
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
co-authored by
Gilles De Mey
parent
b08285962d
commit
9b4f88c6f6
+2
-19
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
+33
@@ -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,
|
||||
}),
|
||||
});
|
||||
+3
@@ -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);
|
||||
|
||||
+40
-18
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+3
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user