mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Template selector in contact points form (#87689)
* WIP * Refactor and update how we display these fields in the form * Add test for getTemplateOptions and udpate parseTemplates to handle minus simbol * fix betterer * Fix wrapper * Create new usePreviewTemplate to be reused from TemplatePreview and TemplateContentAndPreview * remove unnecessary check * track interactions * Include the whole content of the template in the preview * Update parseTemplates function to return default templates * handle nested templates in parseTemplates function * Missing border fixed, whitespaces preserved and no empty space at the bottom * remove unused styles and add a comment in test * Add missing error in getPreviewResults * fix styles for template selector containers * Alerting: PR feedback to move default templates into RTKQ (#88172) Move default templates to RTKQ API + constant * move parseTemplates to a utils file and refactor last part of this function * Keep selected options when loading exising input and when switching between tabs * Update descritpion in tabs * Fix not previewing when loading existing values * Update text addressing Brenda's feedback * Add test for matchesOnlyOneTemplate function * Add minheight to viewer container and fix getContentFromOptions function --------- Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
parent
6775bcb0a3
commit
caeb9bcea2
@ -28,7 +28,7 @@ export const LogMessages = {
|
||||
|
||||
const { logInfo, logError, logMeasurement } = createMonitoringLogger('features.alerting', { module: 'Alerting' });
|
||||
|
||||
export { logInfo, logError, logMeasurement };
|
||||
export { logError, logInfo, logMeasurement };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function withPerformanceLogging<TFunc extends (...args: any[]) => Promise<any>>(
|
||||
@ -245,6 +245,16 @@ export function trackSwitchToPoliciesRouting() {
|
||||
reportInteraction('grafana_alerting_switch_to_policies_routing');
|
||||
}
|
||||
|
||||
export function trackEditInputWithTemplate() {
|
||||
reportInteraction('grafana_alerting_contact_point_form_edit_input_with_template');
|
||||
}
|
||||
export function trackUseCustomInputInTemplate() {
|
||||
reportInteraction('grafana_alerting_contact_point_form_use_custom_input_in_template');
|
||||
}
|
||||
export function trackUseSingleTemplateInInput() {
|
||||
reportInteraction('grafana_alerting_contact_point_form_use_single_template_in_input');
|
||||
}
|
||||
|
||||
export type AlertRuleTrackingProps = {
|
||||
user_id: number;
|
||||
grafana_version?: string;
|
||||
|
@ -1,3 +1,8 @@
|
||||
import { Template } from 'app/features/alerting/unified/components/receivers/form/fields/TemplateSelector';
|
||||
import { DEFAULT_TEMPLATES } from 'app/features/alerting/unified/utils/template-constants';
|
||||
|
||||
import { parseTemplates } from '../components/receivers/form/fields/utils';
|
||||
|
||||
import { alertingApi } from './alertingApi';
|
||||
|
||||
export const previewTemplateUrl = `/api/alertmanager/grafana/config/api/v1/templates/test`;
|
||||
@ -34,6 +39,12 @@ export const templatesApi = alertingApi.injectEndpoints({
|
||||
method: 'POST',
|
||||
}),
|
||||
}),
|
||||
getDefaultTemplates: build.query<Template[], void>({
|
||||
queryFn: async () => {
|
||||
const data = parseTemplates(DEFAULT_TEMPLATES);
|
||||
return { data };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Stack, Label } from '@grafana/ui';
|
||||
import { Label, Stack, useStyles2 } from '@grafana/ui';
|
||||
|
||||
export function EditorColumnHeader({ label, actions }: { label: string; actions?: React.ReactNode }) {
|
||||
const styles = useStyles2(editorColumnStyles);
|
||||
|
@ -471,4 +471,4 @@ const defaultPayload: TestTemplateAlert[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const defaultPayloadString = JSON.stringify(defaultPayload, null, 2);
|
||||
export const defaultPayloadString = JSON.stringify(defaultPayload, null, 2);
|
||||
|
@ -1,23 +1,18 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { compact, uniqueId } from 'lodash';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React 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 { Alert, Box, Button, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import {
|
||||
AlertField,
|
||||
TemplatePreviewErrors,
|
||||
TemplatePreviewResponse,
|
||||
TemplatePreviewResult,
|
||||
usePreviewTemplateMutation,
|
||||
} from '../../api/templateApi';
|
||||
import { TemplatePreviewErrors, TemplatePreviewResponse, TemplatePreviewResult } from '../../api/templateApi';
|
||||
import { stringifyErrorLike } from '../../utils/misc';
|
||||
import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader';
|
||||
|
||||
import type { TemplateFormValues } from './TemplateForm';
|
||||
import { usePreviewTemplate } from './usePreviewTemplate';
|
||||
|
||||
export function TemplatePreview({
|
||||
payload,
|
||||
@ -38,23 +33,14 @@ export function TemplatePreview({
|
||||
|
||||
const templateContent = watch('content');
|
||||
|
||||
const [trigger, { data, error: previewError, isLoading }] = usePreviewTemplateMutation();
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
onPreview,
|
||||
error: previewError,
|
||||
} = usePreviewTemplate(templateContent, templateName, payload, setPayloadFormatError);
|
||||
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
|
||||
@ -124,11 +110,13 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'inherit',
|
||||
}),
|
||||
box: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderBottom: `1px solid ${theme.colors.border.medium}`,
|
||||
height: 'inherit',
|
||||
}),
|
||||
header: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { isEmpty } from 'lodash';
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { useFormContext, FieldError, DeepMap, Controller } from 'react-hook-form';
|
||||
import { Controller, DeepMap, FieldError, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Checkbox, Field, Input, RadioButtonList, Select, TextArea, useStyles2 } from '@grafana/ui';
|
||||
@ -11,6 +11,7 @@ import { KeyValueMapInput } from './KeyValueMapInput';
|
||||
import { StringArrayInput } from './StringArrayInput';
|
||||
import { SubformArrayField } from './SubformArrayField';
|
||||
import { SubformField } from './SubformField';
|
||||
import { WrapWithTemplateSelection } from './TemplateSelector';
|
||||
|
||||
interface Props {
|
||||
defaultValue: any;
|
||||
@ -89,7 +90,7 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
||||
customValidator,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { control, register, unregister, getValues } = useFormContext();
|
||||
const { control, register, unregister, getValues, setValue } = useFormContext();
|
||||
const name = `${pathPrefix}${option.propertyName}`;
|
||||
|
||||
// workaround for https://github.com/react-hook-form/react-hook-form/issues/4993#issuecomment-829012506
|
||||
@ -99,6 +100,13 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
||||
},
|
||||
[unregister, name]
|
||||
);
|
||||
|
||||
const useTemplates = option.placeholder.includes('{{ template');
|
||||
|
||||
function onSelectTemplate(template: string) {
|
||||
setValue(name, template);
|
||||
}
|
||||
|
||||
switch (option.element) {
|
||||
case 'checkbox':
|
||||
return (
|
||||
@ -114,22 +122,29 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
||||
);
|
||||
case 'input':
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
readOnly={readOnly || determineReadOnly(option, getValues, pathIndex)}
|
||||
invalid={invalid}
|
||||
type={option.inputType}
|
||||
{...register(name, {
|
||||
required: determineRequired(option, getValues, pathIndex),
|
||||
validate: {
|
||||
validationRule: (v) =>
|
||||
option.validationRule ? validateOption(v, option.validationRule, option.required) : true,
|
||||
customValidator: (v) => (customValidator ? customValidator(v) : true),
|
||||
},
|
||||
setValueAs: option.setValueAs,
|
||||
})}
|
||||
placeholder={option.placeholder}
|
||||
/>
|
||||
<WrapWithTemplateSelection
|
||||
useTemplates={useTemplates}
|
||||
option={option}
|
||||
name={name}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
>
|
||||
<Input
|
||||
id={id}
|
||||
readOnly={readOnly || useTemplates || determineReadOnly(option, getValues, pathIndex)}
|
||||
invalid={invalid}
|
||||
type={option.inputType}
|
||||
{...register(name, {
|
||||
required: determineRequired(option, getValues, pathIndex),
|
||||
validate: {
|
||||
validationRule: (v) =>
|
||||
option.validationRule ? validateOption(v, option.validationRule, option.required) : true,
|
||||
customValidator: (v) => (customValidator ? customValidator(v) : true),
|
||||
},
|
||||
setValueAs: option.setValueAs,
|
||||
})}
|
||||
placeholder={option.placeholder}
|
||||
/>
|
||||
</WrapWithTemplateSelection>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
@ -178,17 +193,24 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
||||
);
|
||||
case 'textarea':
|
||||
return (
|
||||
<TextArea
|
||||
id={id}
|
||||
readOnly={readOnly}
|
||||
invalid={invalid}
|
||||
placeholder={option.placeholder}
|
||||
{...register(name, {
|
||||
required: option.required ? 'Required' : false,
|
||||
validate: (v) =>
|
||||
option.validationRule !== '' ? validateOption(v, option.validationRule, option.required) : true,
|
||||
})}
|
||||
/>
|
||||
<WrapWithTemplateSelection
|
||||
useTemplates={useTemplates}
|
||||
option={option}
|
||||
name={name}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
>
|
||||
<TextArea
|
||||
id={id}
|
||||
readOnly={readOnly || useTemplates}
|
||||
invalid={invalid}
|
||||
placeholder={option.placeholder}
|
||||
{...register(name, {
|
||||
required: option.required ? 'Required' : false,
|
||||
validate: (v) =>
|
||||
option.validationRule !== '' ? validateOption(v, option.validationRule, option.required) : true,
|
||||
})}
|
||||
/>
|
||||
</WrapWithTemplateSelection>
|
||||
);
|
||||
case 'string_array':
|
||||
return (
|
||||
|
@ -0,0 +1,94 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Box, useStyles2 } from '@grafana/ui';
|
||||
import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
|
||||
import { EditorColumnHeader } from '../../../contact-points/templates/EditorColumnHeader';
|
||||
import { TemplateEditor } from '../../TemplateEditor';
|
||||
import { getPreviewResults } from '../../TemplatePreview';
|
||||
import { usePreviewTemplate } from '../../usePreviewTemplate';
|
||||
|
||||
export function TemplateContentAndPreview({
|
||||
payload,
|
||||
templateContent,
|
||||
templateName,
|
||||
payloadFormatError,
|
||||
setPayloadFormatError,
|
||||
className,
|
||||
}: {
|
||||
payload: string;
|
||||
templateName: string;
|
||||
payloadFormatError: string | null;
|
||||
setPayloadFormatError: (value: React.SetStateAction<string | null>) => void;
|
||||
className?: string;
|
||||
templateContent: string;
|
||||
}) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const isGrafanaAlertManager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
|
||||
|
||||
const { data, error } = usePreviewTemplate(templateContent, templateName, payload, setPayloadFormatError);
|
||||
const previewToRender = getPreviewResults(error, payloadFormatError, data);
|
||||
|
||||
return (
|
||||
<div className={cx(className, styles.mainContainer)}>
|
||||
<div className={styles.container}>
|
||||
<EditorColumnHeader label="Template content" />
|
||||
<Box flex={1}>
|
||||
<div className={styles.viewerContainer({ height: 400 })}>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<TemplateEditor
|
||||
value={templateContent}
|
||||
containerStyles={styles.editorContainer}
|
||||
width={width}
|
||||
height={height}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{isGrafanaAlertManager && (
|
||||
<div className={styles.container}>
|
||||
<EditorColumnHeader label="Preview with the default payload" />
|
||||
<Box flex={1}>
|
||||
<div className={styles.viewerContainer({ height: 'minHeight' })}>{previewToRender}</div>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
mainContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
}),
|
||||
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 | string }) =>
|
||||
css({
|
||||
height,
|
||||
overflow: 'auto',
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
}),
|
||||
editorContainer: css({
|
||||
width: 'fit-content',
|
||||
border: 'none',
|
||||
}),
|
||||
});
|
@ -0,0 +1,46 @@
|
||||
import { DEFAULT_TEMPLATES } from 'app/features/alerting/unified/utils/template-constants';
|
||||
|
||||
import { getTemplateOptions } from './TemplateSelector';
|
||||
import { parseTemplates } from './utils';
|
||||
|
||||
describe('getTemplateOptions function', () => {
|
||||
it('should return the last template when there are duplicates', () => {
|
||||
const templateFiles = {
|
||||
file1:
|
||||
'{{ define "template1" }}{{ len .Alerts.Firing }} firing alert(s), {{ len .Alerts.Resolved }} resolved alert(s){{ end }}',
|
||||
// duplicated define, the last one should be returned
|
||||
file2:
|
||||
'{{ define "template1" }}{{ len .Alerts.Firing }} firing alert(s), {{ len .Alerts.Resolved }} resolved alert(s) this is the last one{{ end }}',
|
||||
file3:
|
||||
'{{ define "email.subject" }}{{ len .Alerts.Firing }} firing alert(s), {{ len .Alerts.Resolved }} resolved alert(s){{ end }}',
|
||||
// define with a minus sign
|
||||
file4: '{{ define "template_with_minus" -}}{{ .Annotations.summary }}{{- end }}',
|
||||
file5: '',
|
||||
//nested templates
|
||||
file6: `{{ define "nested" }}
|
||||
Main Template Content
|
||||
{{ template "sub1" }}
|
||||
{{ template "sub2" }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "sub1" }}
|
||||
Sub Template 1 Content
|
||||
{{ end }}
|
||||
|
||||
{{ define "sub2" }}
|
||||
Sub Template 2 Content
|
||||
{{ end }}`,
|
||||
};
|
||||
const defaultTemplates = parseTemplates(DEFAULT_TEMPLATES);
|
||||
const result = getTemplateOptions(templateFiles, defaultTemplates);
|
||||
|
||||
const template1Matches = result.filter((option) => option.label === 'template1');
|
||||
expect(template1Matches).toHaveLength(1);
|
||||
expect(template1Matches[0].value?.content).toMatch(/this is the last one/i);
|
||||
|
||||
const file5Matches = result.filter((option) => option.label === 'file5');
|
||||
expect(file5Matches).toHaveLength(0);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
});
|
@ -0,0 +1,376 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { PropsWithChildren, useEffect, useMemo } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Input,
|
||||
RadioButtonGroup,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextArea,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import {
|
||||
trackEditInputWithTemplate,
|
||||
trackUseCustomInputInTemplate,
|
||||
trackUseSingleTemplateInInput,
|
||||
} from 'app/features/alerting/unified/Analytics';
|
||||
import { templatesApi } from 'app/features/alerting/unified/api/templateApi';
|
||||
import { useAlertmanagerConfig } from 'app/features/alerting/unified/hooks/useAlertmanagerConfig';
|
||||
import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
|
||||
import { NotificationChannelOption } from 'app/types';
|
||||
|
||||
import { defaultPayloadString } from '../../TemplateForm';
|
||||
|
||||
import { TemplateContentAndPreview } from './TemplateContentAndPreview';
|
||||
import { getTemplateName, getUseTemplateText, matchesOnlyOneTemplate, parseTemplates } from './utils';
|
||||
|
||||
interface TemplatesPickerProps {
|
||||
onSelect: (temnplate: string) => void;
|
||||
option: NotificationChannelOption;
|
||||
valueInForm: string;
|
||||
}
|
||||
export function TemplatesPicker({ onSelect, option, valueInForm }: TemplatesPickerProps) {
|
||||
const [showTemplates, setShowTemplates] = React.useState(false);
|
||||
const onClick = () => {
|
||||
setShowTemplates(true);
|
||||
trackEditInputWithTemplate();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon="edit"
|
||||
tooltip={'Edit using existing templates.'}
|
||||
onClick={onClick}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
aria-label={'Select available template from the list of available templates.'}
|
||||
>
|
||||
{`Edit ${option.label}`}
|
||||
</Button>
|
||||
|
||||
{showTemplates && (
|
||||
<Drawer title={`Edit ${option.label}`} size="md" onClose={() => setShowTemplates(false)}>
|
||||
<TemplateSelector
|
||||
onSelect={onSelect}
|
||||
onClose={() => setShowTemplates(false)}
|
||||
option={option}
|
||||
valueInForm={valueInForm}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type TemplateFieldOption = 'Existing' | 'Custom';
|
||||
|
||||
export function getTemplateOptions(templateFiles: Record<string, string>, defaultTemplates: Template[] = []) {
|
||||
// Add default templates
|
||||
const templateMap = new Map<string, SelectableValue<Template>>();
|
||||
Object.entries(templateFiles).forEach(([_, content]) => {
|
||||
const templates: Template[] = parseTemplates(content);
|
||||
templates.forEach((template) => {
|
||||
templateMap.set(template.name, {
|
||||
label: template.name,
|
||||
value: {
|
||||
name: template.name,
|
||||
content: template.content,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
// Add default templates to the map
|
||||
defaultTemplates.forEach((template) => {
|
||||
templateMap.set(template.name, {
|
||||
label: template.name,
|
||||
value: {
|
||||
name: template.name,
|
||||
content: template.content,
|
||||
},
|
||||
});
|
||||
});
|
||||
// return the sum of default and custom templates
|
||||
return Array.from(templateMap.values());
|
||||
}
|
||||
function getContentFromOptions(name: string, options: Array<SelectableValue<Template>>) {
|
||||
const template = options.find((option) => option.label === name);
|
||||
return template?.value?.content ?? '';
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
interface TemplateSelectorProps {
|
||||
onSelect: (template: string) => void;
|
||||
onClose: () => void;
|
||||
option: NotificationChannelOption;
|
||||
valueInForm: string;
|
||||
}
|
||||
function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSelectorProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const useGetDefaultTemplatesQuery = templatesApi.endpoints.getDefaultTemplates.useQuery;
|
||||
const [template, setTemplate] = React.useState<Template | undefined>(undefined);
|
||||
const [inputToUpdate, setInputToUpdate] = React.useState<string>('');
|
||||
const [inputToUpdateCustom, setInputToUpdateCustom] = React.useState<string>(valueInForm);
|
||||
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const { data, error } = useAlertmanagerConfig(selectedAlertmanager);
|
||||
const { data: defaultTemplates } = useGetDefaultTemplatesQuery();
|
||||
const [templateOption, setTemplateOption] = React.useState<TemplateFieldOption>('Existing');
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const templateOptions: Array<SelectableValue<TemplateFieldOption>> = [
|
||||
{
|
||||
label: 'Selecting existing template',
|
||||
value: 'Existing',
|
||||
description: `Select a single template and preview it, or copy it to paste it in the custom tab. ${templateOption === 'Existing' ? 'Clicking Save will save your changes to the selected template.' : ''}`,
|
||||
},
|
||||
{
|
||||
label: `Enter custom ${option.label.toLowerCase()}`,
|
||||
value: 'Custom',
|
||||
description: `Enter custom ${option.label.toLowerCase()}. ${templateOption === 'Custom' ? 'Clicking Save will save the custom value only.' : ''}`,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setInputToUpdate(getUseTemplateText(template.name));
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
function onCustomTemplateChange(customInput: string) {
|
||||
setInputToUpdateCustom(customInput);
|
||||
}
|
||||
|
||||
const onTemplateOptionChange = (option: TemplateFieldOption) => {
|
||||
setTemplateOption(option);
|
||||
};
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!defaultTemplates) {
|
||||
return [];
|
||||
}
|
||||
return getTemplateOptions(data?.template_files ?? {}, defaultTemplates);
|
||||
}, [data, defaultTemplates]);
|
||||
|
||||
// if we are using only one template, we should settemplate to that template
|
||||
useEffect(() => {
|
||||
if (matchesOnlyOneTemplate(valueInForm)) {
|
||||
const name = getTemplateName(valueInForm);
|
||||
setTemplate({
|
||||
name,
|
||||
content: getContentFromOptions(name, options),
|
||||
});
|
||||
} else {
|
||||
if (Boolean(valueInForm)) {
|
||||
// if it's empty we default to select existing template
|
||||
setTemplateOption('Custom');
|
||||
}
|
||||
}
|
||||
}, [valueInForm, setTemplate, setTemplateOption, options]);
|
||||
|
||||
if (error) {
|
||||
return <div>Error loading templates</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={1} justifyContent="space-between" height="100%">
|
||||
<Stack direction="column" gap={1}>
|
||||
<RadioButtonGroup
|
||||
options={templateOptions}
|
||||
value={templateOption}
|
||||
onChange={onTemplateOptionChange}
|
||||
className={styles.templateTabOption}
|
||||
/>
|
||||
|
||||
{templateOption === 'Existing' ? (
|
||||
<Stack direction="column" gap={1}>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Select<Template>
|
||||
aria-label="Template"
|
||||
onChange={(value: SelectableValue<Template>, _) => {
|
||||
setTemplate(value?.value);
|
||||
}}
|
||||
options={options}
|
||||
width={50}
|
||||
value={template ? { label: template.name, value: template } : undefined}
|
||||
/>
|
||||
<IconButton
|
||||
tooltip="Copy selected template to clipboard. You can use it in the custom tab."
|
||||
onClick={() => copyToClipboard(getUseTemplateText(template?.name ?? ''))}
|
||||
name="copy"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<TemplateContentAndPreview
|
||||
templateContent={template?.content ?? ''}
|
||||
payload={defaultPayloadString}
|
||||
templateName={template?.name ?? ''}
|
||||
setPayloadFormatError={() => {}}
|
||||
className={cx(styles.templatePreview, styles.minEditorSize)}
|
||||
payloadFormatError={null}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<OptionCustomfield
|
||||
option={option}
|
||||
onCustomTemplateChange={onCustomTemplateChange}
|
||||
initialValue={inputToUpdateCustom}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
onSelect(templateOption === 'Custom' ? inputToUpdateCustom : inputToUpdate);
|
||||
onClose();
|
||||
if (templateOption === 'Custom') {
|
||||
trackUseCustomInputInTemplate();
|
||||
} else {
|
||||
trackUseSingleTemplateInInput();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionCustomfield({
|
||||
option,
|
||||
onCustomTemplateChange,
|
||||
initialValue,
|
||||
}: {
|
||||
option: NotificationChannelOption;
|
||||
onCustomTemplateChange(customInput: string): void;
|
||||
initialValue: string;
|
||||
}) {
|
||||
switch (option.element) {
|
||||
case 'textarea':
|
||||
return (
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<TextArea
|
||||
placeholder={option.placeholder}
|
||||
onChange={(e) => onCustomTemplateChange(e.currentTarget.value)}
|
||||
defaultValue={initialValue}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
case 'input':
|
||||
return (
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Input
|
||||
type={option.inputType}
|
||||
placeholder={option.placeholder}
|
||||
onChange={(e) => onCustomTemplateChange(e.currentTarget.value)}
|
||||
defaultValue={initialValue}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface WrapWithTemplateSelectionProps extends PropsWithChildren {
|
||||
useTemplates: boolean;
|
||||
onSelectTemplate: (template: string) => void;
|
||||
option: NotificationChannelOption;
|
||||
name: string;
|
||||
}
|
||||
export function WrapWithTemplateSelection({
|
||||
useTemplates,
|
||||
onSelectTemplate,
|
||||
option,
|
||||
name,
|
||||
children,
|
||||
}: WrapWithTemplateSelectionProps) {
|
||||
const { getValues } = useFormContext();
|
||||
const value: string = getValues(name) ?? '';
|
||||
const emptyValue = value === '' || value === undefined;
|
||||
const onlyOneTemplate = value ? matchesOnlyOneTemplate(value) : false;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// if the placeholder does not contain a template, we don't need to show the template picker
|
||||
if (!option.placeholder.includes('{{ template ')) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
// Otherwise, we can use templates on this field
|
||||
|
||||
// if the value is empty, we only show the template picker
|
||||
if (emptyValue) {
|
||||
return (
|
||||
<div className={styles.inputContainer}>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
{useTemplates && (
|
||||
<TemplatesPicker onSelect={onSelectTemplate} option={option} valueInForm={getValues(name) ?? ''} />
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (onlyOneTemplate) {
|
||||
return (
|
||||
<div className={styles.inputContainer}>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Text variant="bodySmall">{`Template: ${getTemplateName(value)}`}</Text>
|
||||
{useTemplates && (
|
||||
<TemplatesPicker onSelect={onSelectTemplate} option={option} valueInForm={getValues(name) ?? ''} />
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// custom template field
|
||||
return (
|
||||
<div className={styles.inputContainer}>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
{children}
|
||||
{useTemplates && (
|
||||
<TemplatesPicker onSelect={onSelectTemplate} option={option} valueInForm={getValues(name) ?? ''} />
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
actions: css({
|
||||
flex: 0,
|
||||
justifyContent: 'flex-end',
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
templatePreview: css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
}),
|
||||
templateTabOption: css({
|
||||
width: 'fit-content',
|
||||
}),
|
||||
minEditorSize: css({
|
||||
minHeight: 300,
|
||||
minWidth: 300,
|
||||
}),
|
||||
inputContainer: css({
|
||||
marginTop: theme.spacing(1.5),
|
||||
}),
|
||||
});
|
@ -0,0 +1,73 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`getTemplateOptions function should return the last template when there are duplicates 1`] = `
|
||||
[
|
||||
{
|
||||
"label": "template1",
|
||||
"value": {
|
||||
"content": "{{ define "template1" }}{{ len .Alerts.Firing }} firing alert(s), {{ len .Alerts.Resolved }} resolved alert(s) this is the last one{{ end }}",
|
||||
"name": "template1",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "email.subject",
|
||||
"value": {
|
||||
"content": "{{ define "email.subject" }}{{ len .Alerts.Firing }} firing alert(s), {{ len .Alerts.Resolved }} resolved alert(s){{ end }}",
|
||||
"name": "email.subject",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "template_with_minus",
|
||||
"value": {
|
||||
"content": "{{ define "template_with_minus" -}}{{ .Annotations.summary }}{{- end }}",
|
||||
"name": "template_with_minus",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "nested",
|
||||
"value": {
|
||||
"content": "{{ define "nested" }}
|
||||
Main Template Content
|
||||
{{ template "sub1" }}
|
||||
{{ template "sub2" }}
|
||||
{{ end }}
|
||||
{{ define "sub1" }}
|
||||
Sub Template 1 Content
|
||||
{{ end }}
|
||||
{{ define "sub2" }}
|
||||
Sub Template 2 Content
|
||||
{{ end }}",
|
||||
"name": "nested",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "default.title",
|
||||
"value": {
|
||||
"content": "{{ define "default.title" }}{{ template "__subject" . }}{{ end }}",
|
||||
"name": "default.title",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "default.message",
|
||||
"value": {
|
||||
"content": "{{ define "default.message" }}{{ if gt (len .Alerts.Firing) 0 }}**Firing**
|
||||
{{ template "__text_alert_list" .Alerts.Firing }}{{ if gt (len .Alerts.Resolved) 0 }}
|
||||
|
||||
{{ end }}{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}**Resolved**
|
||||
{{ template "__text_alert_list" .Alerts.Resolved }}{{ end }}{{ end }}",
|
||||
"name": "default.message",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "teams.default.message",
|
||||
"value": {
|
||||
"content": "{{ define "teams.default.message" }}{{ if gt (len .Alerts.Firing) 0 }}**Firing**
|
||||
{{ template "__teams_text_alert_list" .Alerts.Firing }}{{ if gt (len .Alerts.Resolved) 0 }}
|
||||
|
||||
{{ end }}{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}**Resolved**
|
||||
{{ template "__teams_text_alert_list" .Alerts.Resolved }}{{ end }}{{ end }}",
|
||||
"name": "teams.default.message",
|
||||
},
|
||||
},
|
||||
]
|
||||
`;
|
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* This function parses the template content and returns an array of Template objects.
|
||||
* Each Template object represents a single template definition found in the content.
|
||||
*
|
||||
* There are several rules for parsing the template content:
|
||||
* - The template content may use the "-" symbol for whitespace trimming. If a template's left delimiter ("{{")
|
||||
* is immediately followed by a "-" and whitespace, all trailing whitespace is removed from the preceding text.
|
||||
* - Similarly, if the right delimiter ("}}") is immediately preceded by whitespace and a "-", all leading whitespace
|
||||
* is removed from the following text. In these cases, the whitespace must be present for the trimming to occur.
|
||||
* - The template content may contain nested templates. The nested templates are appended to the main template content,
|
||||
* and the nested templates are removed from the list of templates.
|
||||
* - We don't return templates with names starting with "__" as they are considered internal templates.
|
||||
*
|
||||
* @param templatesString is a string containing the template content. Each template is defined within
|
||||
* "{{ define "templateName" }}" and "{{ end }}" delimiters.But it may also contain nested templates.
|
||||
*/
|
||||
|
||||
import { Template } from './TemplateSelector';
|
||||
|
||||
export function parseTemplates(templatesString: string): Template[] {
|
||||
const templates: Record<string, Template> = {};
|
||||
const stack: Array<{ type: string; startIndex: number; name?: string }> = [];
|
||||
const regex = /{{(-?\s*)(define|end|if|range|else|with|template)(\s*.*?)?(-?\s*)}}/gs;
|
||||
|
||||
let match;
|
||||
let currentIndex = 0;
|
||||
|
||||
while ((match = regex.exec(templatesString)) !== null) {
|
||||
const [, , keyword, middleContent] = match;
|
||||
currentIndex = match.index;
|
||||
|
||||
if (keyword === 'define') {
|
||||
const nameMatch = middleContent?.match(/"([^"]+)"/);
|
||||
if (nameMatch) {
|
||||
stack.push({ type: 'define', startIndex: currentIndex, name: nameMatch[1] });
|
||||
}
|
||||
} else if (keyword === 'end') {
|
||||
let top = stack.pop();
|
||||
while (top && top.type !== 'define' && top.type !== 'if' && top.type !== 'range' && top.type !== 'with') {
|
||||
top = stack.pop();
|
||||
}
|
||||
if (top) {
|
||||
const endIndex = regex.lastIndex;
|
||||
if (top.type === 'define' && !top.name?.startsWith('__')) {
|
||||
templates[top.name!] = {
|
||||
name: top.name!,
|
||||
content: templatesString.slice(top.startIndex, endIndex),
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (keyword === 'if' || keyword === 'range' || keyword === 'else' || keyword === 'with') {
|
||||
stack.push({ type: keyword, startIndex: currentIndex });
|
||||
}
|
||||
}
|
||||
// Append sub-template content to the end of the main template and remove sub-templates from the list
|
||||
for (const template of Object.values(templates)) {
|
||||
const regex = /{{ template "([^"]+)" }}/g;
|
||||
let match;
|
||||
while ((match = regex.exec(template.content)) !== null) {
|
||||
const name = match[1];
|
||||
if (templates[name]?.content) {
|
||||
template.content += '\n' + templates[name]?.content;
|
||||
delete templates[name]; // Remove the sub-template from the list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(templates);
|
||||
}
|
||||
|
||||
export function getUseTemplateText(templateName: string) {
|
||||
return `{{ template "${templateName}" . }}`;
|
||||
}
|
||||
|
||||
export function getTemplateName(useTemplateText: string) {
|
||||
const match = useTemplateText.match(/\{\{\s*template\s*"(.*)"\s*\.\s*\}\}/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
/* This function checks if the a field value contains only one template usage
|
||||
for example:
|
||||
"{{ template "templateName" . }}"" returns true
|
||||
but "{{ template "templateName" . }} some text {{ template "templateName" . }}"" returns false
|
||||
and "{{ template "templateName" . }} some text" some text returns false
|
||||
**/
|
||||
|
||||
export function matchesOnlyOneTemplate(fieldValue: string) {
|
||||
const pattern = /\{\{\s*template\s*".*?"\s*\.\s*\}\}/g;
|
||||
const matches = fieldValue.match(pattern);
|
||||
|
||||
if (matches?.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split the content by the template pattern
|
||||
const parts = fieldValue.split(pattern);
|
||||
|
||||
// Check if there is any non-whitespace text outside the template pattern
|
||||
for (const part of parts) {
|
||||
if (part.trim() !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import { ChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
|
||||
|
||||
import { matchesOnlyOneTemplate } from './fields/utils';
|
||||
import { DeprecatedAuthHTTPConfig, HTTPAuthConfig, normalizeFormValues } from './util';
|
||||
|
||||
describe('normalizeFormValues', () => {
|
||||
@ -61,3 +62,25 @@ function createContactPoint(httpConfig: DeprecatedAuthHTTPConfig | HTTPAuthConfi
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
describe('matchesOnlyOneTemplate', () => {
|
||||
it('should return true when there is only one template and no other text', () => {
|
||||
const fieldValue = '{{ template "nested" . }}';
|
||||
expect(matchesOnlyOneTemplate(fieldValue)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when there is more than one template', () => {
|
||||
const fieldValue = '{{ template "nested" . }}{{ template "nested2" . }}';
|
||||
expect(matchesOnlyOneTemplate(fieldValue)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when there is other text outside the template', () => {
|
||||
const fieldValue = '{{ template "nested" . }} some other text';
|
||||
expect(matchesOnlyOneTemplate(fieldValue)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when there is no template', () => {
|
||||
const fieldValue = 'some other text';
|
||||
expect(matchesOnlyOneTemplate(fieldValue)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,27 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { AlertField, usePreviewTemplateMutation } from '../../api/templateApi';
|
||||
|
||||
export function usePreviewTemplate(
|
||||
templateContent: string,
|
||||
templateName: string,
|
||||
payload: string,
|
||||
setPayloadFormatError: (value: React.SetStateAction<string | null>) => void
|
||||
) {
|
||||
const [trigger, { data, error, isLoading }] = usePreviewTemplateMutation();
|
||||
|
||||
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 { data, error, isLoading, onPreview };
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
// We need to use this constat until we have an API to get the default templates
|
||||
export const DEFAULT_TEMPLATES = `{{ define "__subject" }}[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ if gt (.Alerts.Resolved | len) 0 }}, RESOLVED:{{ .Alerts.Resolved | len }}{{ end }}{{ end }}] {{ .GroupLabels.SortedPairs.Values | join " " }} {{ if gt (len .CommonLabels) (len .GroupLabels) }}({{ with .CommonLabels.Remove .GroupLabels.Names }}{{ .Values | join " " }}{{ end }}){{ end }}{{ end }}
|
||||
|
||||
{{ define "__text_values_list" }}{{ if len .Values }}{{ $first := true }}{{ range $refID, $value := .Values -}}
|
||||
{{ if $first }}{{ $first = false }}{{ else }}, {{ end }}{{ $refID }}={{ $value }}{{ end -}}
|
||||
{{ else }}[no value]{{ end }}{{ end }}
|
||||
|
||||
{{ define "__text_alert_list" }}{{ range . }}
|
||||
Value: {{ template "__text_values_list" . }}
|
||||
Labels:
|
||||
{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }}
|
||||
{{ end }}Annotations:
|
||||
{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}
|
||||
{{ end }}{{ if gt (len .GeneratorURL) 0 }}Source: {{ .GeneratorURL }}
|
||||
{{ end }}{{ if gt (len .SilenceURL) 0 }}Silence: {{ .SilenceURL }}
|
||||
{{ end }}{{ if gt (len .DashboardURL) 0 }}Dashboard: {{ .DashboardURL }}
|
||||
{{ end }}{{ if gt (len .PanelURL) 0 }}Panel: {{ .PanelURL }}
|
||||
{{ end }}{{ end }}{{ end }}
|
||||
|
||||
{{ define "default.title" }}{{ template "__subject" . }}{{ end }}
|
||||
|
||||
{{ define "default.message" }}{{ if gt (len .Alerts.Firing) 0 }}**Firing**
|
||||
{{ template "__text_alert_list" .Alerts.Firing }}{{ if gt (len .Alerts.Resolved) 0 }}
|
||||
|
||||
{{ end }}{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}**Resolved**
|
||||
{{ template "__text_alert_list" .Alerts.Resolved }}{{ end }}{{ end }}
|
||||
|
||||
{{ define "__teams_text_alert_list" }}{{ range . }}
|
||||
Value: {{ template "__text_values_list" . }}
|
||||
Labels:
|
||||
{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }}
|
||||
{{ end }}
|
||||
Annotations:
|
||||
{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}
|
||||
{{ end }}
|
||||
{{ if gt (len .GeneratorURL) 0 }}Source: [{{ .GeneratorURL }}]({{ .GeneratorURL }})
|
||||
|
||||
{{ end }}{{ if gt (len .SilenceURL) 0 }}Silence: [{{ .SilenceURL }}]({{ .SilenceURL }})
|
||||
|
||||
{{ end }}{{ if gt (len .DashboardURL) 0 }}Dashboard: [{{ .DashboardURL }}]({{ .DashboardURL }})
|
||||
|
||||
{{ end }}{{ if gt (len .PanelURL) 0 }}Panel: [{{ .PanelURL }}]({{ .PanelURL }})
|
||||
|
||||
{{ end }}
|
||||
{{ end }}{{ end }}
|
||||
|
||||
{{ define "teams.default.message" }}{{ if gt (len .Alerts.Firing) 0 }}**Firing**
|
||||
{{ template "__teams_text_alert_list" .Alerts.Firing }}{{ if gt (len .Alerts.Resolved) 0 }}
|
||||
|
||||
{{ end }}{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}**Resolved**
|
||||
{{ template "__teams_text_alert_list" .Alerts.Resolved }}{{ end }}{{ end }}`;
|
Loading…
Reference in New Issue
Block a user