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();
|
||||
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[] = [
|
||||
{
|
||||
name: 'Status',
|
||||
|
@ -72,7 +72,7 @@ interface TemplateDataTableProps {
|
||||
typeRenderer?: (type: TemplateDataItem['type']) => React.ReactNode;
|
||||
}
|
||||
|
||||
function TemplateDataTable({ dataItems, caption, typeRenderer }: TemplateDataTableProps) {
|
||||
export function TemplateDataTable({ dataItems, caption, typeRenderer }: TemplateDataTableProps) {
|
||||
const styles = useStyles2(getTemplateDataTableStyles);
|
||||
|
||||
return (
|
||||
|
@ -1,46 +1,79 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { subDays } from 'date-fns';
|
||||
import { Location } from 'history';
|
||||
import React from 'react';
|
||||
import { useForm, Validate } from 'react-hook-form';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm, useFormContext, Validate } from 'react-hook-form';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Stack } from '@grafana/experimental';
|
||||
import { Alert, Button, Field, FieldSet, Input, LinkButton, useStyles2 } from '@grafana/ui';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
CollapsableSection,
|
||||
Field,
|
||||
FieldSet,
|
||||
Input,
|
||||
LinkButton,
|
||||
Spinner,
|
||||
Tab,
|
||||
TabsBar,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { useCleanup } from 'app/core/hooks/useCleanup';
|
||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { useDispatch } from 'app/types';
|
||||
|
||||
import {
|
||||
AlertField,
|
||||
TemplatePreviewErrors,
|
||||
TemplatePreviewResponse,
|
||||
TemplatePreviewResult,
|
||||
usePreviewTemplateMutation,
|
||||
} from '../../api/templateApi';
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { updateAlertManagerConfigAction } from '../../state/actions';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
import { initialAsyncRequestState } from '../../utils/redux';
|
||||
import { ensureDefine } from '../../utils/templates';
|
||||
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
|
||||
|
||||
import { PayloadEditor } from './PayloadEditor';
|
||||
import { TemplateDataDocs } from './TemplateDataDocs';
|
||||
import { TemplateEditor } from './TemplateEditor';
|
||||
import { snippets } from './editor/templateDataSuggestions';
|
||||
|
||||
interface Values {
|
||||
export interface TemplateFormValues {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const defaults: Values = Object.freeze({
|
||||
export const defaults: TemplateFormValues = Object.freeze({
|
||||
name: '',
|
||||
content: '',
|
||||
});
|
||||
|
||||
interface Props {
|
||||
existing?: Values;
|
||||
existing?: TemplateFormValues;
|
||||
config: AlertManagerCortexConfig;
|
||||
alertManagerSourceName: string;
|
||||
provenance?: string;
|
||||
}
|
||||
export const isDuplicating = (location: Location) => location.pathname.endsWith('/duplicate');
|
||||
|
||||
const DEFAULT_PAYLOAD = `[
|
||||
{
|
||||
"annotations": {
|
||||
"summary": "Instance instance1 has been down for more than 5 minutes"
|
||||
},
|
||||
"labels": {
|
||||
"instance": "instance1"
|
||||
},
|
||||
"startsAt": "${subDays(new Date(), 1).toISOString()}"
|
||||
}]
|
||||
`;
|
||||
|
||||
export const TemplateForm = ({ existing, alertManagerSourceName, config, provenance }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const dispatch = useDispatch();
|
||||
@ -52,7 +85,14 @@ export const TemplateForm = ({ existing, alertManagerSourceName, config, provena
|
||||
const location = useLocation();
|
||||
const isduplicating = isDuplicating(location);
|
||||
|
||||
const submit = (values: Values) => {
|
||||
const [payload, setPayload] = useState(DEFAULT_PAYLOAD);
|
||||
const [payloadFormatError, setPayloadFormatError] = useState<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/
|
||||
// it's not obvious that this is needed for template to work
|
||||
const content = ensureDefine(values.name, values.content);
|
||||
@ -92,86 +132,119 @@ export const TemplateForm = ({ existing, alertManagerSourceName, config, provena
|
||||
);
|
||||
};
|
||||
|
||||
const formApi = useForm<TemplateFormValues>({
|
||||
mode: 'onSubmit',
|
||||
defaultValues: existing ?? defaults,
|
||||
});
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
getValues,
|
||||
setValue,
|
||||
} = useForm<Values>({
|
||||
mode: 'onSubmit',
|
||||
defaultValues: existing ?? defaults,
|
||||
});
|
||||
watch,
|
||||
} = formApi;
|
||||
|
||||
const validateNameIsUnique: Validate<string> = (name: string) => {
|
||||
return !config.template_files[name] || existing?.name === name
|
||||
? true
|
||||
: 'Another template with this name already exists.';
|
||||
};
|
||||
const isGrafanaAlertManager = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(submit)}>
|
||||
<h4>{existing && !isduplicating ? 'Edit notification template' : 'Create notification template'}</h4>
|
||||
{error && (
|
||||
<Alert severity="error" title="Error saving template">
|
||||
{error.message || (error as any)?.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.contentContainer}>
|
||||
<div>
|
||||
<Field label="Content" error={errors?.content?.message} invalid={!!errors.content?.message} required>
|
||||
<div className={styles.editWrapper}>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<TemplateEditor
|
||||
value={getValues('content')}
|
||||
width={width}
|
||||
height={height}
|
||||
onBlur={(value) => setValue('content', value)}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
<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 || (error as any)?.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 />
|
||||
<Stack direction="row" alignItems={'center'}>
|
||||
<div>
|
||||
<TabsBar>
|
||||
<Tab label="Content" active={view === 'content'} onChangeTab={() => setView('content')} />
|
||||
{isGrafanaAlertManager && (
|
||||
<Tab label="Preview" active={view === 'preview'} onChangeTab={() => setView('preview')} />
|
||||
)}
|
||||
</TabsBar>
|
||||
<div className={styles.contentContainer}>
|
||||
{view === 'content' ? (
|
||||
<div>
|
||||
<Field error={errors?.content?.message} invalid={!!errors.content?.message} required>
|
||||
<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>
|
||||
</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>
|
||||
{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 />
|
||||
</div>
|
||||
</FieldSet>
|
||||
</form>
|
||||
</CollapsableSection>
|
||||
</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) => ({
|
||||
contentContainer: css`
|
||||
display: flex;
|
||||
padding-top: 10px;
|
||||
gap: ${theme.spacing(2)};
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
@ -232,6 +419,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
& > * + * {
|
||||
margin-left: ${theme.spacing(1)};
|
||||
}
|
||||
margin-top: -7px;
|
||||
`,
|
||||
textarea: css`
|
||||
max-width: 758px;
|
||||
@ -240,6 +428,40 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 640px;
|
||||
height: 320px;
|
||||
height: 363px;
|
||||
`,
|
||||
toggle: css({
|
||||
color: theme.colors.text.secondary,
|
||||
marginRight: `${theme.spacing(1)}`,
|
||||
}),
|
||||
previewHeader: css({
|
||||
display: 'flex',
|
||||
cursor: 'pointer',
|
||||
alignItems: 'baseline',
|
||||
color: theme.colors.text.primary,
|
||||
'&:hover': {
|
||||
background: theme.colors.emphasize(theme.colors.background.primary, 0.03),
|
||||
},
|
||||
}),
|
||||
previewHeaderTitle: css({
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
fontSize: theme.typography.h4.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
margin: 0,
|
||||
}),
|
||||
preview: {
|
||||
result: css`
|
||||
width: 640px;
|
||||
height: 363px;
|
||||
`,
|
||||
button: css`
|
||||
flex: none;
|
||||
width: fit-content;
|
||||
margin-top: ${theme.spacing(-3)};
|
||||
`,
|
||||
},
|
||||
collapsableSection: css`
|
||||
width: fit-content;
|
||||
`,
|
||||
});
|
||||
|
@ -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 { isNumber } from 'lodash';
|
||||
import React, { PureComponent, ChangeEvent } from 'react';
|
||||
import React, { ChangeEvent, PureComponent } from 'react';
|
||||
|
||||
import {
|
||||
Threshold,
|
||||
GrafanaTheme2,
|
||||
SelectableValue,
|
||||
sortThresholds,
|
||||
Threshold,
|
||||
ThresholdsConfig,
|
||||
ThresholdsMode,
|
||||
SelectableValue,
|
||||
GrafanaTheme2,
|
||||
} from '@grafana/data';
|
||||
import {
|
||||
Input,
|
||||
colors,
|
||||
ColorPicker,
|
||||
ThemeContext,
|
||||
Button,
|
||||
ColorPicker,
|
||||
colors,
|
||||
IconButton,
|
||||
Input,
|
||||
Label,
|
||||
RadioButtonGroup,
|
||||
stylesFactory,
|
||||
IconButton,
|
||||
ThemeContext,
|
||||
} from '@grafana/ui';
|
||||
|
||||
const modes: Array<SelectableValue<ThresholdsMode>> = [
|
||||
|
@ -252,6 +252,7 @@ export interface AlertmanagerStatus {
|
||||
}
|
||||
|
||||
export type TestReceiversAlert = Pick<AlertmanagerAlert, 'annotations' | 'labels'>;
|
||||
export type TestTemplateAlert = Pick<AlertmanagerAlert, 'annotations' | 'labels' | 'startsAt' | 'endsAt'>;
|
||||
|
||||
export interface TestReceiversPayload {
|
||||
receivers?: Receiver[];
|
||||
|
Loading…
Reference in New Issue
Block a user