mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Implement template preview for Grafana AlertManager (#65530)
* Add Preview template and payload editor to templates form * Add TemplatePreview test and update css * Preview errors for each template that is wrong * Enable preview templating only for Grafana Alert Manager * Use harcoded default payload instead of requesting it to the backend * Update error response in the api definition * Add spinner when loading result for preview * Update api request followind DD changes * Use pre instead of TextArea to render the preview * Fix tests * Add alert list editor * Add start and end time for alert generator * Add preview for data list added in the modal * Update copies and move submit button in alert generator to the bottom * Copy updates * Refactor * Use tab instead of button to preview * Move payload editor next to the content * Copy update * Refactor * Adress PR review comments * Fix wrong json format throwing an exception when adding more data * Use monaco editor for payload * Only show text 'Preview for...' when we have more than one define * Fix some errors * Update CollapseSection style * Add tooltip for the Payload info icon explaining the available list of alert data fields in preview * Set payload as invalid if it's not an array * Fix test * Update text in AlertTemplateDataTable * Add separators to distinguish lines that belong to the preview * Fix text * Use subDays instead of addDays for substracting days
This commit is contained in:
parent
b5a2c3c7f5
commit
3854be1fcb
@ -16,7 +16,6 @@ export const onCallApi = alertingApi.injectEndpoints({
|
|||||||
const integrations = await fetchOnCallIntegrations();
|
const integrations = await fetchOnCallIntegrations();
|
||||||
return { data: integrations };
|
return { data: integrations };
|
||||||
},
|
},
|
||||||
providesTags: ['AlertmanagerChoice'],
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
40
public/app/features/alerting/unified/api/templateApi.ts
Normal file
40
public/app/features/alerting/unified/api/templateApi.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { alertingApi } from './alertingApi';
|
||||||
|
|
||||||
|
export const previewTemplateUrl = `/api/alertmanager/grafana/config/api/v1/templates/test`;
|
||||||
|
|
||||||
|
export interface TemplatePreviewResult {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
export interface TemplatePreviewErrors {
|
||||||
|
name?: string;
|
||||||
|
message: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
export interface TemplatePreviewResponse {
|
||||||
|
results?: TemplatePreviewResult[];
|
||||||
|
errors?: TemplatePreviewErrors[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyValueField {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
export interface AlertField {
|
||||||
|
annotations: KeyValueField[];
|
||||||
|
labels: KeyValueField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const templatesApi = alertingApi.injectEndpoints({
|
||||||
|
endpoints: (build) => ({
|
||||||
|
previewTemplate: build.mutation<TemplatePreviewResponse, { template: string; alerts: AlertField[]; name: string }>({
|
||||||
|
query: ({ template, alerts, name }) => ({
|
||||||
|
url: previewTemplateUrl,
|
||||||
|
data: { template: template, alerts: alerts, name: name },
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { usePreviewTemplateMutation } = templatesApi;
|
@ -0,0 +1,81 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { default as React, useState } from 'react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
|
import { configureStore } from 'app/store/configureStore';
|
||||||
|
|
||||||
|
import 'whatwg-fetch';
|
||||||
|
|
||||||
|
import { PayloadEditor, RESET_TO_DEFAULT } from './PayloadEditor';
|
||||||
|
|
||||||
|
const DEFAULT_PAYLOAD = `[
|
||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"summary": "Instance instance1 has been down for more than 5 minutes"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"instance": "instance1"
|
||||||
|
},
|
||||||
|
"startsAt": "2023-04-25T15:28:56.440Z"
|
||||||
|
}]
|
||||||
|
`;
|
||||||
|
|
||||||
|
jest.mock('@grafana/ui', () => ({
|
||||||
|
...jest.requireActual('@grafana/ui'),
|
||||||
|
CodeEditor: function CodeEditor({ value, onBlur }: { value: string; onBlur: (newValue: string) => void }) {
|
||||||
|
return <input data-testid="mockeditor" value={value} onChange={(e) => onBlur(e.currentTarget.value)} />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const PayloadEditorWithState = () => {
|
||||||
|
const [payload, setPayload] = useState(DEFAULT_PAYLOAD);
|
||||||
|
return (
|
||||||
|
<PayloadEditor
|
||||||
|
payload={payload}
|
||||||
|
setPayload={setPayload}
|
||||||
|
defaultPayload={DEFAULT_PAYLOAD}
|
||||||
|
setPayloadFormatError={jest.fn()}
|
||||||
|
payloadFormatError={null}
|
||||||
|
onPayloadError={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const renderWithProvider = () => {
|
||||||
|
const store = configureStore();
|
||||||
|
render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<PayloadEditorWithState />
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Payload editor', () => {
|
||||||
|
it('Should render default payload by default', async () => {
|
||||||
|
renderWithProvider();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mockeditor')).toHaveValue(
|
||||||
|
`[ { "annotations": { "summary": "Instance instance1 has been down for more than 5 minutes" }, "labels": { "instance": "instance1" }, "startsAt": "2023-04-25T15:28:56.440Z" }]`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should render default payload after clicking reset to default button', async () => {
|
||||||
|
renderWithProvider();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mockeditor')).toHaveValue(
|
||||||
|
'[ { "annotations": { "summary": "Instance instance1 has been down for more than 5 minutes" }, "labels": { "instance": "instance1" }, "startsAt": "2023-04-25T15:28:56.440Z" }]'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await userEvent.type(screen.getByTestId('mockeditor'), 'this is the something');
|
||||||
|
expect(screen.getByTestId('mockeditor')).toHaveValue(
|
||||||
|
'[ { "annotations": { "summary": "Instance instance1 has been down for more than 5 minutes" }, "labels": { "instance": "instance1" }, "startsAt": "2023-04-25T15:28:56.440Z" }]this is the something'
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByText(RESET_TO_DEFAULT));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId('mockeditor')).toHaveValue(
|
||||||
|
'[ { "annotations": { "summary": "Instance instance1 has been down for more than 5 minutes" }, "labels": { "instance": "instance1" }, "startsAt": "2023-04-25T15:28:56.440Z" }]'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,160 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Badge, Button, CodeEditor, Icon, Tooltip, useStyles2 } from '@grafana/ui';
|
||||||
|
import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
|
import { AlertTemplatePreviewData } from './TemplateData';
|
||||||
|
import { TemplateDataTable } from './TemplateDataDocs';
|
||||||
|
import { GenerateAlertDataModal } from './form/GenerateAlertDataModal';
|
||||||
|
|
||||||
|
export const RESET_TO_DEFAULT = 'Reset to default';
|
||||||
|
|
||||||
|
export function PayloadEditor({
|
||||||
|
payload,
|
||||||
|
setPayload,
|
||||||
|
defaultPayload,
|
||||||
|
setPayloadFormatError,
|
||||||
|
payloadFormatError,
|
||||||
|
onPayloadError,
|
||||||
|
}: {
|
||||||
|
payload: string;
|
||||||
|
defaultPayload: string;
|
||||||
|
setPayload: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
setPayloadFormatError: (value: React.SetStateAction<string | null>) => void;
|
||||||
|
payloadFormatError: string | null;
|
||||||
|
onPayloadError: () => void;
|
||||||
|
}) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const onReset = () => {
|
||||||
|
setPayload(defaultPayload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [isEditingAlertData, setIsEditingAlertData] = useState(false);
|
||||||
|
|
||||||
|
const onCloseEditAlertModal = () => {
|
||||||
|
setIsEditingAlertData(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorInPayloadJson = payloadFormatError !== null;
|
||||||
|
|
||||||
|
const onOpenEditAlertModal = () => {
|
||||||
|
try {
|
||||||
|
const payloadObj = JSON.parse(payload);
|
||||||
|
JSON.stringify([...payloadObj]); // check if it's iterable, in order to be able to add more data
|
||||||
|
setIsEditingAlertData(true);
|
||||||
|
setPayloadFormatError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setPayloadFormatError(e instanceof Error ? e.message : 'Invalid JSON.');
|
||||||
|
onPayloadError();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onAddAlertList = (alerts: TestTemplateAlert[]) => {
|
||||||
|
onCloseEditAlertModal();
|
||||||
|
setPayload((payload) => {
|
||||||
|
const payloadObj = JSON.parse(payload);
|
||||||
|
return JSON.stringify([...payloadObj, ...alerts], undefined, 2);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<div className={styles.editor}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
Payload data
|
||||||
|
<Tooltip placement="top" content={<AlertTemplateDataTable />} theme="info">
|
||||||
|
<Icon name="info-circle" className={styles.tooltip} size="xl" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodeEditor
|
||||||
|
width={640}
|
||||||
|
height={363}
|
||||||
|
language={'json'}
|
||||||
|
showLineNumbers={true}
|
||||||
|
showMiniMap={false}
|
||||||
|
value={payload}
|
||||||
|
readOnly={false}
|
||||||
|
onBlur={setPayload}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.buttonsWrapper}>
|
||||||
|
<Button onClick={onReset} className={styles.button} icon="arrow-up" type="button" variant="secondary">
|
||||||
|
{RESET_TO_DEFAULT}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onOpenEditAlertModal}
|
||||||
|
className={styles.button}
|
||||||
|
icon="plus-circle"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={errorInPayloadJson}
|
||||||
|
>
|
||||||
|
Add alert data
|
||||||
|
</Button>
|
||||||
|
{payloadFormatError !== null && (
|
||||||
|
<Badge
|
||||||
|
color="orange"
|
||||||
|
icon="exclamation-triangle"
|
||||||
|
text={'There are some errors in payload JSON.'}
|
||||||
|
tooltip={'Fix errors in payload, and click Refresh preview button'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GenerateAlertDataModal isOpen={isEditingAlertData} onDismiss={onCloseEditAlertModal} onAccept={onAddAlertList} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const AlertTemplateDataTable = () => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
return (
|
||||||
|
<TemplateDataTable
|
||||||
|
caption={
|
||||||
|
<h4 className={styles.templateDataDocsHeader}>
|
||||||
|
Alert template data <span>This is the list of alert data fields used in the preview.</span>
|
||||||
|
</h4>
|
||||||
|
}
|
||||||
|
dataItems={AlertTemplatePreviewData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
jsonEditor: css`
|
||||||
|
width: 605px;
|
||||||
|
height: 363px;
|
||||||
|
`,
|
||||||
|
buttonsWrapper: css`
|
||||||
|
margin-top: ${theme.spacing(1)};
|
||||||
|
display: flex;
|
||||||
|
`,
|
||||||
|
button: css`
|
||||||
|
flex: none;
|
||||||
|
width: fit-content;
|
||||||
|
padding-right: ${theme.spacing(1)};
|
||||||
|
margin-right: ${theme.spacing(1)};
|
||||||
|
`,
|
||||||
|
title: css`
|
||||||
|
font-weight: ${theme.typography.fontWeightBold};
|
||||||
|
`,
|
||||||
|
wrapper: css`
|
||||||
|
padding-top: 38px;
|
||||||
|
`,
|
||||||
|
tooltip: css`
|
||||||
|
padding-left: ${theme.spacing(1)};
|
||||||
|
`,
|
||||||
|
editor: css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: ${theme.spacing(-1)};
|
||||||
|
`,
|
||||||
|
templateDataDocsHeader: css`
|
||||||
|
color: ${theme.colors.text.primary};
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: ${theme.colors.text.secondary};
|
||||||
|
font-size: ${theme.typography.bodySmall.fontSize};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
@ -59,6 +59,29 @@ export const GlobalTemplateData: TemplateDataItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const AlertTemplatePreviewData: TemplateDataItem[] = [
|
||||||
|
{
|
||||||
|
name: 'Labels',
|
||||||
|
type: 'KeyValue',
|
||||||
|
notes: 'Set of labels attached to the alert.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Annotations',
|
||||||
|
type: 'KeyValue',
|
||||||
|
notes: 'Set of annotations attached to the alert.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'StartsAt',
|
||||||
|
type: 'time.Time',
|
||||||
|
notes: 'Time the alert started firing.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'EndsAt',
|
||||||
|
type: 'time.Time',
|
||||||
|
notes: 'Time the alert ends firing.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const AlertTemplateData: TemplateDataItem[] = [
|
export const AlertTemplateData: TemplateDataItem[] = [
|
||||||
{
|
{
|
||||||
name: 'Status',
|
name: 'Status',
|
||||||
|
@ -72,7 +72,7 @@ interface TemplateDataTableProps {
|
|||||||
typeRenderer?: (type: TemplateDataItem['type']) => React.ReactNode;
|
typeRenderer?: (type: TemplateDataItem['type']) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateDataTable({ dataItems, caption, typeRenderer }: TemplateDataTableProps) {
|
export function TemplateDataTable({ dataItems, caption, typeRenderer }: TemplateDataTableProps) {
|
||||||
const styles = useStyles2(getTemplateDataTableStyles);
|
const styles = useStyles2(getTemplateDataTableStyles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,46 +1,79 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
|
import { subDays } from 'date-fns';
|
||||||
import { Location } from 'history';
|
import { Location } from 'history';
|
||||||
import React from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useForm, Validate } from 'react-hook-form';
|
import { FormProvider, useForm, useFormContext, Validate } from 'react-hook-form';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Stack } from '@grafana/experimental';
|
import { Stack } from '@grafana/experimental';
|
||||||
import { Alert, Button, Field, FieldSet, Input, LinkButton, useStyles2 } from '@grafana/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
CollapsableSection,
|
||||||
|
Field,
|
||||||
|
FieldSet,
|
||||||
|
Input,
|
||||||
|
LinkButton,
|
||||||
|
Spinner,
|
||||||
|
Tab,
|
||||||
|
TabsBar,
|
||||||
|
useStyles2,
|
||||||
|
} from '@grafana/ui';
|
||||||
import { useCleanup } from 'app/core/hooks/useCleanup';
|
import { useCleanup } from 'app/core/hooks/useCleanup';
|
||||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { useDispatch } from 'app/types';
|
import { useDispatch } from 'app/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertField,
|
||||||
|
TemplatePreviewErrors,
|
||||||
|
TemplatePreviewResponse,
|
||||||
|
TemplatePreviewResult,
|
||||||
|
usePreviewTemplateMutation,
|
||||||
|
} from '../../api/templateApi';
|
||||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||||
import { updateAlertManagerConfigAction } from '../../state/actions';
|
import { updateAlertManagerConfigAction } from '../../state/actions';
|
||||||
|
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||||
import { makeAMLink } from '../../utils/misc';
|
import { makeAMLink } from '../../utils/misc';
|
||||||
import { initialAsyncRequestState } from '../../utils/redux';
|
import { initialAsyncRequestState } from '../../utils/redux';
|
||||||
import { ensureDefine } from '../../utils/templates';
|
import { ensureDefine } from '../../utils/templates';
|
||||||
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
|
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
|
||||||
|
|
||||||
|
import { PayloadEditor } from './PayloadEditor';
|
||||||
import { TemplateDataDocs } from './TemplateDataDocs';
|
import { TemplateDataDocs } from './TemplateDataDocs';
|
||||||
import { TemplateEditor } from './TemplateEditor';
|
import { TemplateEditor } from './TemplateEditor';
|
||||||
import { snippets } from './editor/templateDataSuggestions';
|
import { snippets } from './editor/templateDataSuggestions';
|
||||||
|
|
||||||
interface Values {
|
export interface TemplateFormValues {
|
||||||
name: string;
|
name: string;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaults: Values = Object.freeze({
|
export const defaults: TemplateFormValues = Object.freeze({
|
||||||
name: '',
|
name: '',
|
||||||
content: '',
|
content: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
existing?: Values;
|
existing?: TemplateFormValues;
|
||||||
config: AlertManagerCortexConfig;
|
config: AlertManagerCortexConfig;
|
||||||
alertManagerSourceName: string;
|
alertManagerSourceName: string;
|
||||||
provenance?: string;
|
provenance?: string;
|
||||||
}
|
}
|
||||||
export const isDuplicating = (location: Location) => location.pathname.endsWith('/duplicate');
|
export const isDuplicating = (location: Location) => location.pathname.endsWith('/duplicate');
|
||||||
|
|
||||||
|
const DEFAULT_PAYLOAD = `[
|
||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"summary": "Instance instance1 has been down for more than 5 minutes"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"instance": "instance1"
|
||||||
|
},
|
||||||
|
"startsAt": "${subDays(new Date(), 1).toISOString()}"
|
||||||
|
}]
|
||||||
|
`;
|
||||||
|
|
||||||
export const TemplateForm = ({ existing, alertManagerSourceName, config, provenance }: Props) => {
|
export const TemplateForm = ({ existing, alertManagerSourceName, config, provenance }: Props) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@ -52,7 +85,14 @@ export const TemplateForm = ({ existing, alertManagerSourceName, config, provena
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isduplicating = isDuplicating(location);
|
const isduplicating = isDuplicating(location);
|
||||||
|
|
||||||
const submit = (values: Values) => {
|
const [payload, setPayload] = useState(DEFAULT_PAYLOAD);
|
||||||
|
const [payloadFormatError, setPayloadFormatError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [view, setView] = useState<'content' | 'preview'>('content');
|
||||||
|
|
||||||
|
const onPayloadError = () => setView('preview');
|
||||||
|
|
||||||
|
const submit = (values: TemplateFormValues) => {
|
||||||
// wrap content in "define" if it's not already wrapped, in case user did not do it/
|
// wrap content in "define" if it's not already wrapped, in case user did not do it/
|
||||||
// it's not obvious that this is needed for template to work
|
// it's not obvious that this is needed for template to work
|
||||||
const content = ensureDefine(values.name, values.content);
|
const content = ensureDefine(values.name, values.content);
|
||||||
@ -92,86 +132,119 @@ export const TemplateForm = ({ existing, alertManagerSourceName, config, provena
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formApi = useForm<TemplateFormValues>({
|
||||||
|
mode: 'onSubmit',
|
||||||
|
defaultValues: existing ?? defaults,
|
||||||
|
});
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
getValues,
|
getValues,
|
||||||
setValue,
|
setValue,
|
||||||
} = useForm<Values>({
|
watch,
|
||||||
mode: 'onSubmit',
|
} = formApi;
|
||||||
defaultValues: existing ?? defaults,
|
|
||||||
});
|
|
||||||
|
|
||||||
const validateNameIsUnique: Validate<string> = (name: string) => {
|
const validateNameIsUnique: Validate<string> = (name: string) => {
|
||||||
return !config.template_files[name] || existing?.name === name
|
return !config.template_files[name] || existing?.name === name
|
||||||
? true
|
? true
|
||||||
: 'Another template with this name already exists.';
|
: 'Another template with this name already exists.';
|
||||||
};
|
};
|
||||||
|
const isGrafanaAlertManager = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(submit)}>
|
<FormProvider {...formApi}>
|
||||||
<h4>{existing && !isduplicating ? 'Edit notification template' : 'Create notification template'}</h4>
|
<form onSubmit={handleSubmit(submit)}>
|
||||||
{error && (
|
<h4>{existing && !isduplicating ? 'Edit notification template' : 'Create notification template'}</h4>
|
||||||
<Alert severity="error" title="Error saving template">
|
{error && (
|
||||||
{error.message || (error as any)?.data?.message || String(error)}
|
<Alert severity="error" title="Error saving template">
|
||||||
</Alert>
|
{error.message || (error as any)?.data?.message || String(error)}
|
||||||
)}
|
</Alert>
|
||||||
{provenance && <ProvisioningAlert resource={ProvisionedResource.Template} />}
|
)}
|
||||||
<FieldSet disabled={Boolean(provenance)}>
|
{provenance && <ProvisioningAlert resource={ProvisionedResource.Template} />}
|
||||||
<Field label="Template name" error={errors?.name?.message} invalid={!!errors.name?.message} required>
|
<FieldSet disabled={Boolean(provenance)}>
|
||||||
<Input
|
<Field label="Template name" error={errors?.name?.message} invalid={!!errors.name?.message} required>
|
||||||
{...register('name', {
|
<Input
|
||||||
required: { value: true, message: 'Required.' },
|
{...register('name', {
|
||||||
validate: { nameIsUnique: validateNameIsUnique },
|
required: { value: true, message: 'Required.' },
|
||||||
})}
|
validate: { nameIsUnique: validateNameIsUnique },
|
||||||
placeholder="Give your template a name"
|
})}
|
||||||
width={42}
|
placeholder="Give your template a name"
|
||||||
autoFocus={true}
|
width={42}
|
||||||
/>
|
autoFocus={true}
|
||||||
</Field>
|
/>
|
||||||
<TemplatingGuideline />
|
</Field>
|
||||||
<div className={styles.contentContainer}>
|
<TemplatingGuideline />
|
||||||
<div>
|
<Stack direction="row" alignItems={'center'}>
|
||||||
<Field label="Content" error={errors?.content?.message} invalid={!!errors.content?.message} required>
|
<div>
|
||||||
<div className={styles.editWrapper}>
|
<TabsBar>
|
||||||
<AutoSizer>
|
<Tab label="Content" active={view === 'content'} onChangeTab={() => setView('content')} />
|
||||||
{({ width, height }) => (
|
{isGrafanaAlertManager && (
|
||||||
<TemplateEditor
|
<Tab label="Preview" active={view === 'preview'} onChangeTab={() => setView('preview')} />
|
||||||
value={getValues('content')}
|
)}
|
||||||
width={width}
|
</TabsBar>
|
||||||
height={height}
|
<div className={styles.contentContainer}>
|
||||||
onBlur={(value) => setValue('content', value)}
|
{view === 'content' ? (
|
||||||
/>
|
<div>
|
||||||
)}
|
<Field error={errors?.content?.message} invalid={!!errors.content?.message} required>
|
||||||
</AutoSizer>
|
<div className={styles.editWrapper}>
|
||||||
|
<TemplateEditor
|
||||||
|
value={getValues('content')}
|
||||||
|
width={640}
|
||||||
|
height={363}
|
||||||
|
onBlur={(value) => setValue('content', value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
{loading && (
|
||||||
|
<Button disabled={true} icon="fa fa-spinner" variant="primary">
|
||||||
|
Saving...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!loading && (
|
||||||
|
<Button type="submit" variant="primary">
|
||||||
|
Save template
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<LinkButton
|
||||||
|
disabled={loading}
|
||||||
|
href={makeAMLink('alerting/notifications', alertManagerSourceName)}
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
fill="outline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TemplatePreview
|
||||||
|
payload={payload}
|
||||||
|
templateName={watch('name')}
|
||||||
|
setPayloadFormatError={setPayloadFormatError}
|
||||||
|
payloadFormatError={payloadFormatError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
|
||||||
<div className={styles.buttons}>
|
|
||||||
{loading && (
|
|
||||||
<Button disabled={true} icon="fa fa-spinner" variant="primary">
|
|
||||||
Saving...
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!loading && (
|
|
||||||
<Button type="submit" variant="primary">
|
|
||||||
Save template
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<LinkButton
|
|
||||||
disabled={loading}
|
|
||||||
href={makeAMLink('alerting/notifications', alertManagerSourceName)}
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
fill="outline"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</LinkButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{isGrafanaAlertManager && (
|
||||||
|
<PayloadEditor
|
||||||
|
payload={payload}
|
||||||
|
setPayload={setPayload}
|
||||||
|
defaultPayload={DEFAULT_PAYLOAD}
|
||||||
|
setPayloadFormatError={setPayloadFormatError}
|
||||||
|
payloadFormatError={payloadFormatError}
|
||||||
|
onPayloadError={onPayloadError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</FieldSet>
|
||||||
|
<CollapsableSection label="Data cheat sheet" isOpen={false} className={styles.collapsableSection}>
|
||||||
<TemplateDataDocs />
|
<TemplateDataDocs />
|
||||||
</div>
|
</CollapsableSection>
|
||||||
</FieldSet>
|
</form>
|
||||||
</form>
|
</FormProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -209,9 +282,123 @@ function TemplatingGuideline() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getResultsToRender(results: TemplatePreviewResult[]) {
|
||||||
|
const filteredResults = results.filter((result) => result.text.trim().length > 0);
|
||||||
|
|
||||||
|
const moreThanOne = filteredResults.length > 1;
|
||||||
|
|
||||||
|
const preview = (result: TemplatePreviewResult) => {
|
||||||
|
const previewForLabel = `Preview for ${result.name}:`;
|
||||||
|
const separatorStart = '='.repeat(previewForLabel.length).concat('>');
|
||||||
|
const separatorEnd = '<'.concat('='.repeat(previewForLabel.length));
|
||||||
|
if (moreThanOne) {
|
||||||
|
return `${previewForLabel}\n${separatorStart}${result.text}${separatorEnd}\n`;
|
||||||
|
} else {
|
||||||
|
return `${separatorStart}${result.text}${separatorEnd}\n`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return filteredResults
|
||||||
|
.map((result: TemplatePreviewResult) => {
|
||||||
|
return preview(result);
|
||||||
|
})
|
||||||
|
.join(`\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorsToRender(results: TemplatePreviewErrors[]) {
|
||||||
|
return results
|
||||||
|
.map((result: TemplatePreviewErrors) => {
|
||||||
|
if (result.name) {
|
||||||
|
return `ERROR in ${result.name}:\n`.concat(`${result.kind}\n${result.message}\n`);
|
||||||
|
} else {
|
||||||
|
return `ERROR:\n${result.kind}\n${result.message}\n`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join(`\n`);
|
||||||
|
}
|
||||||
|
export const PREVIEW_NOT_AVAILABLE = 'Preview request failed. Check if the payload data has the correct structure.';
|
||||||
|
|
||||||
|
function getPreviewTorender(
|
||||||
|
isPreviewError: boolean,
|
||||||
|
payloadFormatError: string | null,
|
||||||
|
data: TemplatePreviewResponse | undefined
|
||||||
|
) {
|
||||||
|
// ERRORS IN JSON OR IN REQUEST (endpoint not available, for example)
|
||||||
|
const previewErrorRequest = isPreviewError ? PREVIEW_NOT_AVAILABLE : undefined;
|
||||||
|
const somethingWasWrong: boolean = isPreviewError || Boolean(payloadFormatError);
|
||||||
|
const errorToRender = payloadFormatError || previewErrorRequest;
|
||||||
|
|
||||||
|
//PREVIEW : RESULTS AND ERRORS
|
||||||
|
const previewResponseResults = data?.results;
|
||||||
|
const previewResponseErrors = data?.errors;
|
||||||
|
|
||||||
|
const previewResultsToRender = previewResponseResults ? getResultsToRender(previewResponseResults) : '';
|
||||||
|
const previewErrorsToRender = previewResponseErrors ? getErrorsToRender(previewResponseErrors) : '';
|
||||||
|
|
||||||
|
if (somethingWasWrong) {
|
||||||
|
return errorToRender;
|
||||||
|
} else {
|
||||||
|
return `${previewResultsToRender}\n${previewErrorsToRender}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplatePreview({
|
||||||
|
payload,
|
||||||
|
templateName,
|
||||||
|
payloadFormatError,
|
||||||
|
setPayloadFormatError,
|
||||||
|
}: {
|
||||||
|
payload: string;
|
||||||
|
templateName: string;
|
||||||
|
payloadFormatError: string | null;
|
||||||
|
setPayloadFormatError: (value: React.SetStateAction<string | null>) => void;
|
||||||
|
}) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const { watch } = useFormContext<TemplateFormValues>();
|
||||||
|
|
||||||
|
const templateContent = watch('content');
|
||||||
|
|
||||||
|
const [trigger, { data, isError: isPreviewError, isLoading }] = usePreviewTemplateMutation();
|
||||||
|
|
||||||
|
const previewToRender = getPreviewTorender(isPreviewError, payloadFormatError, data);
|
||||||
|
|
||||||
|
const onPreview = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const alertList: AlertField[] = JSON.parse(payload);
|
||||||
|
JSON.stringify([...alertList]); // check if it's iterable, in order to be able to add more data
|
||||||
|
trigger({ template: templateContent, alerts: alertList, name: templateName });
|
||||||
|
setPayloadFormatError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setPayloadFormatError(e instanceof Error ? e.message : 'Invalid JSON.');
|
||||||
|
}
|
||||||
|
}, [templateContent, templateName, payload, setPayloadFormatError, trigger]);
|
||||||
|
|
||||||
|
useEffect(() => onPreview(), [onPreview]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack direction="row" alignItems="center" gap={2}>
|
||||||
|
<Stack direction="column">
|
||||||
|
{isLoading && (
|
||||||
|
<>
|
||||||
|
<Spinner inline={true} /> Loading preview...
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<pre className={styles.preview.result} data-testid="payloadJSON">
|
||||||
|
{previewToRender}
|
||||||
|
</pre>
|
||||||
|
<Button onClick={onPreview} className={styles.preview.button} icon="arrow-up" type="button" variant="secondary">
|
||||||
|
Refresh preview
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
contentContainer: css`
|
contentContainer: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
padding-top: 10px;
|
||||||
gap: ${theme.spacing(2)};
|
gap: ${theme.spacing(2)};
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@ -232,6 +419,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
& > * + * {
|
& > * + * {
|
||||||
margin-left: ${theme.spacing(1)};
|
margin-left: ${theme.spacing(1)};
|
||||||
}
|
}
|
||||||
|
margin-top: -7px;
|
||||||
`,
|
`,
|
||||||
textarea: css`
|
textarea: css`
|
||||||
max-width: 758px;
|
max-width: 758px;
|
||||||
@ -240,6 +428,40 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 640px;
|
width: 640px;
|
||||||
height: 320px;
|
height: 363px;
|
||||||
|
`,
|
||||||
|
toggle: css({
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
marginRight: `${theme.spacing(1)}`,
|
||||||
|
}),
|
||||||
|
previewHeader: css({
|
||||||
|
display: 'flex',
|
||||||
|
cursor: 'pointer',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
color: theme.colors.text.primary,
|
||||||
|
'&:hover': {
|
||||||
|
background: theme.colors.emphasize(theme.colors.background.primary, 0.03),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
previewHeaderTitle: css({
|
||||||
|
flexGrow: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontSize: theme.typography.h4.fontSize,
|
||||||
|
fontWeight: theme.typography.fontWeightMedium,
|
||||||
|
margin: 0,
|
||||||
|
}),
|
||||||
|
preview: {
|
||||||
|
result: css`
|
||||||
|
width: 640px;
|
||||||
|
height: 363px;
|
||||||
|
`,
|
||||||
|
button: css`
|
||||||
|
flex: none;
|
||||||
|
width: fit-content;
|
||||||
|
margin-top: ${theme.spacing(-3)};
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
collapsableSection: css`
|
||||||
|
width: fit-content;
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
@ -0,0 +1,159 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { setupServer } from 'msw/node';
|
||||||
|
import { default as React } from 'react';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
|
import { setBackendSrv } from '@grafana/runtime';
|
||||||
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import { configureStore } from 'app/store/configureStore';
|
||||||
|
|
||||||
|
import 'whatwg-fetch';
|
||||||
|
import { TemplatePreviewResponse } from '../../api/templateApi';
|
||||||
|
import { mockPreviewTemplateResponse, mockPreviewTemplateResponseRejected } from '../../mocks/templatesApi';
|
||||||
|
|
||||||
|
import { defaults, PREVIEW_NOT_AVAILABLE, TemplateFormValues, TemplatePreview } from './TemplateForm';
|
||||||
|
|
||||||
|
const getProviderWraper = () => {
|
||||||
|
return function Wrapper({ children }: React.PropsWithChildren<{}>) {
|
||||||
|
const store = configureStore();
|
||||||
|
const formApi = useForm<TemplateFormValues>({ defaultValues: defaults });
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<FormProvider {...formApi}>{children}</FormProvider>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = setupServer();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
setBackendSrv(backendSrv);
|
||||||
|
server.listen({ onUnhandledRequest: 'error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TemplatePreview component', () => {
|
||||||
|
it('Should render error if payload has wrong format', async () => {
|
||||||
|
render(
|
||||||
|
<TemplatePreview
|
||||||
|
payload={'bla bla bla'}
|
||||||
|
templateName="potato"
|
||||||
|
payloadFormatError={'Unexpected token b in JSON at position 0'}
|
||||||
|
setPayloadFormatError={jest.fn()}
|
||||||
|
/>,
|
||||||
|
{ wrapper: getProviderWraper() }
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('payloadJSON')).toHaveTextContent('Unexpected token b in JSON at position 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should render error if payload is not an iterable', async () => {
|
||||||
|
const setError = jest.fn();
|
||||||
|
render(
|
||||||
|
<TemplatePreview
|
||||||
|
payload={'{"a":"b"}'}
|
||||||
|
templateName="potato"
|
||||||
|
payloadFormatError={'Unexpected token b in JSON at position 0'}
|
||||||
|
setPayloadFormatError={setError}
|
||||||
|
/>,
|
||||||
|
{ wrapper: getProviderWraper() }
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(setError).toHaveBeenCalledWith('alertList is not iterable');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should render error if payload has wrong format rendering the preview', async () => {
|
||||||
|
render(
|
||||||
|
<TemplatePreview
|
||||||
|
payload={'potatos and cherries'}
|
||||||
|
templateName="potato"
|
||||||
|
payloadFormatError={'Unexpected token b in JSON at position 0'}
|
||||||
|
setPayloadFormatError={jest.fn()}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
wrapper: getProviderWraper(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('payloadJSON')).toHaveTextContent('Unexpected token b in JSON at position 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should render error in preview response , if payload has correct format but preview request has been rejected', async () => {
|
||||||
|
mockPreviewTemplateResponseRejected(server);
|
||||||
|
render(
|
||||||
|
<TemplatePreview
|
||||||
|
payload={'[{"a":"b"}]'}
|
||||||
|
templateName="potato"
|
||||||
|
payloadFormatError={null}
|
||||||
|
setPayloadFormatError={jest.fn()}
|
||||||
|
/>,
|
||||||
|
{ wrapper: getProviderWraper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('payloadJSON')).toHaveTextContent(PREVIEW_NOT_AVAILABLE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should render preview response , if payload has correct ', async () => {
|
||||||
|
const response: TemplatePreviewResponse = {
|
||||||
|
results: [
|
||||||
|
{ name: 'template1', text: 'This is the template result bla bla bla' },
|
||||||
|
{ name: 'template2', text: 'This is the template2 result bla bla bla' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockPreviewTemplateResponse(server, response);
|
||||||
|
render(
|
||||||
|
<TemplatePreview
|
||||||
|
payload={'[{"a":"b"}]'}
|
||||||
|
templateName="potato"
|
||||||
|
payloadFormatError={null}
|
||||||
|
setPayloadFormatError={jest.fn()}
|
||||||
|
/>,
|
||||||
|
{ wrapper: getProviderWraper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('payloadJSON')).toHaveTextContent(
|
||||||
|
'Preview for template1: ======================>This is the template result bla bla bla<====================== Preview for template2: ======================>This is the template2 result bla bla bla<======================'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('Should render preview response with some errors, if payload has correct format ', async () => {
|
||||||
|
const response: TemplatePreviewResponse = {
|
||||||
|
results: [{ name: 'template1', text: 'This is the template result bla bla bla' }],
|
||||||
|
errors: [
|
||||||
|
{ name: 'template2', message: 'Unexpected "{" in operand', kind: 'kind_of_error' },
|
||||||
|
{ name: 'template3', kind: 'kind_of_error', message: 'Unexpected "{" in operand' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockPreviewTemplateResponse(server, response);
|
||||||
|
render(
|
||||||
|
<TemplatePreview
|
||||||
|
payload={'[{"a":"b"}]'}
|
||||||
|
templateName="potato"
|
||||||
|
payloadFormatError={null}
|
||||||
|
setPayloadFormatError={jest.fn()}
|
||||||
|
/>,
|
||||||
|
{ wrapper: getProviderWraper() }
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('payloadJSON')).toHaveTextContent(
|
||||||
|
'======================>This is the template result bla bla bla<====================== ERROR in template2: kind_of_error Unexpected "{" in operand ERROR in template3: kind_of_error Unexpected "{" in operand'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,173 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { addDays, subDays } from 'date-fns';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Stack } from '@grafana/experimental';
|
||||||
|
import { Button, Card, Modal, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||||
|
import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
|
import { KeyValueField } from '../../../api/templateApi';
|
||||||
|
import AnnotationsField from '../../rule-editor/AnnotationsField';
|
||||||
|
import LabelsField from '../../rule-editor/LabelsField';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onAccept: (alerts: TestTemplateAlert[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormFields {
|
||||||
|
annotations: KeyValueField[];
|
||||||
|
labels: KeyValueField[];
|
||||||
|
status: 'firing' | 'resolved';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValues: FormFields = {
|
||||||
|
annotations: [{ key: '', value: '' }],
|
||||||
|
labels: [{ key: '', value: '' }],
|
||||||
|
status: 'firing',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GenerateAlertDataModal = ({ isOpen, onDismiss, onAccept }: Props) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const [alerts, setAlerts] = useState<TestTemplateAlert[]>([]);
|
||||||
|
|
||||||
|
const formMethods = useForm<FormFields>({ defaultValues, mode: 'onBlur' });
|
||||||
|
const annotations = formMethods.watch('annotations');
|
||||||
|
const labels = formMethods.watch('labels');
|
||||||
|
const [status, setStatus] = useState<'firing' | 'resolved'>('firing');
|
||||||
|
|
||||||
|
const onAdd = () => {
|
||||||
|
const alert: TestTemplateAlert = {
|
||||||
|
annotations: annotations
|
||||||
|
.filter(({ key, value }) => !!key && !!value)
|
||||||
|
.reduce((acc, { key, value }) => {
|
||||||
|
return { ...acc, [key]: value };
|
||||||
|
}, {}),
|
||||||
|
labels: labels
|
||||||
|
.filter(({ key, value }) => !!key && !!value)
|
||||||
|
.reduce((acc, { key, value }) => {
|
||||||
|
return { ...acc, [key]: value };
|
||||||
|
}, {}),
|
||||||
|
startsAt: '2023-04-01T00:00:00Z',
|
||||||
|
endsAt: status === 'firing' ? addDays(new Date(), 1).toISOString() : subDays(new Date(), 1).toISOString(),
|
||||||
|
};
|
||||||
|
setAlerts((alerts) => [...alerts, alert]);
|
||||||
|
formMethods.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
onAccept(alerts);
|
||||||
|
setAlerts([]);
|
||||||
|
formMethods.reset();
|
||||||
|
setStatus('firing');
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelsOrAnnotationsAdded = () => {
|
||||||
|
const someLabels = labels.some((lb) => lb.key !== '' && lb.value !== '');
|
||||||
|
const someAnnotations = annotations.some((ann) => ann.key !== '' && ann.value !== '');
|
||||||
|
return someLabels || someAnnotations;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlertOption = {
|
||||||
|
label: string;
|
||||||
|
value: 'firing' | 'resolved';
|
||||||
|
};
|
||||||
|
const alertOptions: AlertOption[] = [
|
||||||
|
{
|
||||||
|
label: 'Firing',
|
||||||
|
value: 'firing',
|
||||||
|
},
|
||||||
|
{ label: 'Resolved', value: 'resolved' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onDismiss={onDismiss} isOpen={isOpen} title={'Add alert data'}>
|
||||||
|
<FormProvider {...formMethods}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
formMethods.reset();
|
||||||
|
setStatus('firing');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<Stack direction="column" gap={1}>
|
||||||
|
<div className={styles.section}>
|
||||||
|
<AnnotationsField />
|
||||||
|
</div>
|
||||||
|
<div className={styles.section}>
|
||||||
|
<LabelsField />
|
||||||
|
</div>
|
||||||
|
<div className={styles.flexWrapper}>
|
||||||
|
<RadioButtonGroup value={status} options={alertOptions} onChange={(value) => setStatus(value)} />
|
||||||
|
<Button
|
||||||
|
onClick={onAdd}
|
||||||
|
className={styles.onAddButton}
|
||||||
|
icon="plus-circle"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!labelsOrAnnotationsAdded()}
|
||||||
|
>
|
||||||
|
Add alert data
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
<div className={styles.onSubmitWrapper}></div>
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<Stack direction="column" gap={1}>
|
||||||
|
<h5> Review alert data to add to the payload:</h5>
|
||||||
|
<pre className={styles.result} data-testid="payloadJSON">
|
||||||
|
{JSON.stringify(alerts, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
<div className={styles.onSubmitWrapper}>
|
||||||
|
<Modal.ButtonRow>
|
||||||
|
<Button onClick={onSubmit} disabled={alerts.length === 0} className={styles.onSubmitButton}>
|
||||||
|
Add alert data to payload
|
||||||
|
</Button>
|
||||||
|
</Modal.ButtonRow>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
section: css`
|
||||||
|
margin-bottom: ${theme.spacing(2)};
|
||||||
|
`,
|
||||||
|
onAddButton: css`
|
||||||
|
flex: none;
|
||||||
|
width: fit-content;
|
||||||
|
padding-right: ${theme.spacing(1)};
|
||||||
|
margin-left: auto;
|
||||||
|
`,
|
||||||
|
flexWrapper: css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row,
|
||||||
|
justify-content: space-between;
|
||||||
|
`,
|
||||||
|
onSubmitWrapper: css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: flex-end;
|
||||||
|
`,
|
||||||
|
onSubmitButton: css`
|
||||||
|
margin-left: ${theme.spacing(2)};
|
||||||
|
`,
|
||||||
|
result: css`
|
||||||
|
width: 570px;
|
||||||
|
height: 363px;
|
||||||
|
`,
|
||||||
|
});
|
12
public/app/features/alerting/unified/mocks/templatesApi.ts
Normal file
12
public/app/features/alerting/unified/mocks/templatesApi.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { rest } from 'msw';
|
||||||
|
import { SetupServer } from 'msw/node';
|
||||||
|
|
||||||
|
import { previewTemplateUrl, TemplatePreviewResponse } from '../api/templateApi';
|
||||||
|
|
||||||
|
export function mockPreviewTemplateResponse(server: SetupServer, response: TemplatePreviewResponse) {
|
||||||
|
server.use(rest.post(previewTemplateUrl, (req, res, ctx) => res(ctx.status(200), ctx.json(response))));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockPreviewTemplateResponseRejected(server: SetupServer) {
|
||||||
|
server.use(rest.post(previewTemplateUrl, (req, res, ctx) => res(ctx.status(500), ctx.json('error'))));
|
||||||
|
}
|
@ -1,25 +1,25 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { isNumber } from 'lodash';
|
import { isNumber } from 'lodash';
|
||||||
import React, { PureComponent, ChangeEvent } from 'react';
|
import React, { ChangeEvent, PureComponent } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Threshold,
|
GrafanaTheme2,
|
||||||
|
SelectableValue,
|
||||||
sortThresholds,
|
sortThresholds,
|
||||||
|
Threshold,
|
||||||
ThresholdsConfig,
|
ThresholdsConfig,
|
||||||
ThresholdsMode,
|
ThresholdsMode,
|
||||||
SelectableValue,
|
|
||||||
GrafanaTheme2,
|
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import {
|
import {
|
||||||
Input,
|
|
||||||
colors,
|
|
||||||
ColorPicker,
|
|
||||||
ThemeContext,
|
|
||||||
Button,
|
Button,
|
||||||
|
ColorPicker,
|
||||||
|
colors,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
Label,
|
Label,
|
||||||
RadioButtonGroup,
|
RadioButtonGroup,
|
||||||
stylesFactory,
|
stylesFactory,
|
||||||
IconButton,
|
ThemeContext,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
|
|
||||||
const modes: Array<SelectableValue<ThresholdsMode>> = [
|
const modes: Array<SelectableValue<ThresholdsMode>> = [
|
||||||
|
@ -252,6 +252,7 @@ export interface AlertmanagerStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TestReceiversAlert = Pick<AlertmanagerAlert, 'annotations' | 'labels'>;
|
export type TestReceiversAlert = Pick<AlertmanagerAlert, 'annotations' | 'labels'>;
|
||||||
|
export type TestTemplateAlert = Pick<AlertmanagerAlert, 'annotations' | 'labels' | 'startsAt' | 'endsAt'>;
|
||||||
|
|
||||||
export interface TestReceiversPayload {
|
export interface TestReceiversPayload {
|
||||||
receivers?: Receiver[];
|
receivers?: Receiver[];
|
||||||
|
Loading…
Reference in New Issue
Block a user