Alerting: Template preview enhancements (#103817)

API Changes:

- Fixes validation in template CRUD API to be closer to how the running 
  alertmanager will use the template. Should remove some incorrect 
  validation errors.

- Adds some missing default placeholder labels to receiver testing that 
  are used during template testing but missing during receiver testing


Template Preview:

- Replaced basic preview with a readonly CodeEditor for better whitespace 
  and alignment clarity (also adds support for future syntax highlighting 
  in template previews for upcoming webhook payload templates)

Template Selector (Receiver Form):

- Refactored to use same components as Template editor for preview.
- Fixed preview to work with multi-definition templates
- Fixed copy to correctly copy the template contents instead of 
  {{ template "<name>" . }}.

Template Editor:

- Fixed detection of when to display functions vs snippets in multi-line 
  expressions
This commit is contained in:
Matthew Jacobson
2025-04-11 09:27:19 -04:00
committed by GitHub
parent 2c3422fc5c
commit 9f9c4b3da3
15 changed files with 172 additions and 102 deletions
@@ -2,10 +2,8 @@ package definitions
import (
"fmt"
tmplhtml "html/template"
"regexp"
"strings"
tmpltext "text/template"
"github.com/prometheus/alertmanager/template"
"gopkg.in/yaml.v3"
@@ -35,17 +33,12 @@ func (t *NotificationTemplate) Validate() error {
t.Template = content
// Validate template contents. We try to stick as close to what will actually happen when the templates are parsed
// by the alertmanager as possible. That means parsing with both the text and html parsers and making sure we set
// the template name and options.
ttext := tmpltext.New(t.Name).Option("missingkey=zero")
ttext.Funcs(tmpltext.FuncMap(template.DefaultFuncs))
if _, err := ttext.Parse(t.Template); err != nil {
return fmt.Errorf("invalid template: %w", err)
// by the alertmanager as possible.
tmpl, err := template.New()
if err != nil {
return fmt.Errorf("failed to create template: %w", err)
}
thtml := tmplhtml.New(t.Name).Option("missingkey=zero")
thtml.Funcs(tmplhtml.FuncMap(template.DefaultFuncs))
if _, err := thtml.Parse(t.Template); err != nil {
if err := tmpl.Parse(strings.NewReader(t.Template)); err != nil {
return fmt.Errorf("invalid template: %w", err)
}
@@ -442,7 +442,6 @@ func TestValidateNotificationTemplates(t *testing.T) {
expError: errors.New("invalid template: template: Different name than definition:1: template: multiple definition of template \"Alert Instance Template\""),
},
{
// This is fine as long as the template name is different from the definition, it just ignores the extra text.
name: "Extra text outside definition block - different template name and definition",
template: NotificationTemplate{
Name: "Different name than definition",
@@ -452,16 +451,16 @@ func TestValidateNotificationTemplates(t *testing.T) {
expContent: `{{ define "Alert Instance Template" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}[what is this?]`,
expError: nil,
},
// This test used to error because our template code parsed the template with the filename as template name.
// However, we have since moved away from this. We keep this test to ensure we don't regress.
{
// This is NOT fine as the template name is the same as the definition.
// GO template parser will treat it as if it's wrapped in {{ define "Alert Instance Template" }}, thus creating a duplicate definition.
name: "Extra text outside definition block - same template name and definition",
template: NotificationTemplate{
Name: "Alert Instance Template",
Template: `{{ define "Alert Instance Template" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}[what is this?]`,
Provenance: "test",
},
expError: errors.New("invalid template: template: Alert Instance Template:1: template: multiple definition of template \"Alert Instance Template\""),
expContent: `{{ define "Alert Instance Template" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}[what is this?]`,
},
}
+2 -2
View File
@@ -15,8 +15,8 @@ type TestTemplatesResults = alertingNotify.TestTemplatesResults
var (
DefaultLabels = map[string]string{
prometheusModel.AlertNameLabel: `alert title`,
alertingModels.FolderTitleLabel: `folder title`,
prometheusModel.AlertNameLabel: `TestAlert`,
alertingModels.FolderTitleLabel: `Test Folder`,
}
DefaultAnnotations = map[string]string{
alertingModels.ValuesAnnotation: `{"B":22,"C":1}`,
@@ -84,7 +84,7 @@ CommonAnnotations: {{ range .CommonAnnotations.SortedPairs }}{{ .Name }}={{ .Val
expected: TestTemplatesResults{
Results: []alertingNotify.TestTemplatesResult{{
Name: "slack.title",
Text: "\nReceiver: TestReceiver\nStatus: firing\nExternalURL: http://localhost:9093\nAlerts: 1\nFiring Alerts: 1\nResolved Alerts: 0\nGroupLabels: group_label=group_label_value \nCommonLabels: alertname=alert1 grafana_folder=folder title lbl1=val1 \nCommonAnnotations: ann1=annv1 \n",
Text: "\nReceiver: TestReceiver\nStatus: firing\nExternalURL: http://localhost:9093\nAlerts: 1\nFiring Alerts: 1\nResolved Alerts: 0\nGroupLabels: group_label=group_label_value \nCommonLabels: alertname=alert1 grafana_folder=Test Folder lbl1=val1 \nCommonAnnotations: ann1=annv1 \n",
Scope: alertingNotify.TemplateScope(apimodels.RootScope),
}},
Errors: nil,
@@ -5,6 +5,7 @@ import (
"encoding/json"
alertingNotify "github.com/grafana/alerting/notify"
v2 "github.com/prometheus/alertmanager/api/v2"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
)
@@ -30,13 +31,17 @@ func (am *alertmanager) TestReceivers(ctx context.Context, c apimodels.TestRecei
},
})
}
var alert *alertingNotify.TestReceiversConfigAlertParams
a := &alertingNotify.PostableAlert{}
if c.Alert != nil {
alert = &alertingNotify.TestReceiversConfigAlertParams{Annotations: c.Alert.Annotations, Labels: c.Alert.Labels}
a.Annotations = v2.ModelLabelSetToAPILabelSet(c.Alert.Annotations)
a.Labels = v2.ModelLabelSetToAPILabelSet(c.Alert.Labels)
}
AddDefaultLabelsAndAnnotations(a)
return am.Base.TestReceivers(ctx, alertingNotify.TestReceiversConfigBodyParams{
Alert: alert,
Alert: &alertingNotify.TestReceiversConfigAlertParams{
Annotations: v2.APILabelSetToModelLabelSet(a.Annotations),
Labels: v2.APILabelSetToModelLabelSet(a.Labels),
},
Receivers: receivers,
})
}
@@ -131,10 +131,15 @@ func TestIntegrationTestReceivers(t *testing.T) {
"alert": {
"annotations": {
"summary": "Notification test",
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
"alertname": "TestAlert",
"grafana_folder": "Test Folder",
"instance": "Grafana"
}
},
@@ -214,10 +219,15 @@ func TestIntegrationTestReceivers(t *testing.T) {
"alert": {
"annotations": {
"summary": "Notification test",
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
"alertname": "TestAlert",
"grafana_folder": "Test Folder",
"instance": "Grafana"
}
},
@@ -315,10 +325,15 @@ func TestIntegrationTestReceivers(t *testing.T) {
"alert": {
"annotations": {
"summary": "Notification test",
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
"alertname": "TestAlert",
"grafana_folder": "Test Folder",
"instance": "Grafana"
}
},
@@ -392,10 +407,15 @@ func TestIntegrationTestReceivers(t *testing.T) {
"alert": {
"annotations": {
"summary": "Notification test",
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
"alertname": "TestAlert",
"grafana_folder": "Test Folder",
"instance": "Grafana"
}
},
@@ -480,10 +500,15 @@ func TestIntegrationTestReceivers(t *testing.T) {
"alert": {
"annotations": {
"summary": "Notification test",
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
"alertname": "TestAlert",
"grafana_folder": "Test Folder",
"instance": "Grafana"
}
},
@@ -581,10 +606,15 @@ func TestIntegrationTestReceivers(t *testing.T) {
"alert": {
"annotations": {
"summary": "Notification test",
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
"alertname": "TestAlert",
"grafana_folder": "Test Folder",
"instance": "Grafana"
}
},
@@ -687,10 +717,15 @@ func TestIntegrationTestReceiversAlertCustomization(t *testing.T) {
"annotations": {
"annotation1": "value1",
"summary": "Notification test",
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
"alertname": "TestAlert",
"grafana_folder": "Test Folder",
"instance": "Grafana",
"label1": "value1"
}
@@ -776,10 +811,15 @@ func TestIntegrationTestReceiversAlertCustomization(t *testing.T) {
"alert": {
"annotations": {
"summary": "This is a custom annotation",
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
"alertname": "TestAlert",
"grafana_folder": "Test Folder",
"instance": "Grafana"
}
},
@@ -863,10 +903,15 @@ func TestIntegrationTestReceiversAlertCustomization(t *testing.T) {
"alert": {
"annotations": {
"summary": "Notification test",
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
"__dashboardUid__": "dashboard_uid",
"__orgId__": "1",
"__panelId__": "1",
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
"__values__": "{\"B\":22,\"C\":1}"
},
"labels": {
"alertname": "This is a custom label",
"grafana_folder": "Test Folder",
"instance": "Grafana"
}
},
@@ -1,7 +1,7 @@
import 'core-js/stable/structured-clone';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { render, screen } from 'test/test-utils';
import { render, screen, within } from 'test/test-utils';
import EditContactPoint from 'app/features/alerting/unified/components/contact-points/EditContactPoint';
import { AccessControlAction } from 'app/types';
@@ -15,6 +15,20 @@ const Index = () => {
return <div>redirected</div>;
};
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)} />;
},
}));
jest.mock(
'react-virtualized-auto-sizer',
() =>
({ children }: { children: ({ height, width }: { height: number; width: number }) => JSX.Element }) =>
children({ height: 500, width: 400 })
);
const renderEditContactPoint = (contactPointUid: string) =>
render(
<Routes>
@@ -30,8 +44,7 @@ beforeEach(() => {
grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]);
});
const getTemplatePreviewContent = async () =>
await screen.findByRole('presentation', { description: /Preview with the default payload/i });
const getTemplatePreviewContent = async () => within(screen.getByTestId('template-preview')).getByTestId('mockeditor');
const templatesSelectorTestId = 'existing-templates-selector';
@@ -44,11 +57,11 @@ describe('Edit contact point', () => {
await user.click(await screen.findByText(/optional email settings/i));
await user.click(await screen.findByRole('button', { name: /edit message/i }));
expect(await screen.findByRole('dialog', { name: /edit message/i })).toBeInTheDocument();
expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for slack-template/i);
expect(await getTemplatePreviewContent()).toHaveValue(`some example preview for {{ template "slack-template" . }}`);
// Change the preset template and check that the preview updates correctly
await clickSelectOption(screen.getByTestId(templatesSelectorTestId), 'custom-email');
expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for custom-email/i);
expect(await getTemplatePreviewContent()).toHaveValue(`some example preview for {{ template "custom-email" . }}`);
// Close the drawer
await user.click(screen.getByRole('button', { name: /^save$/i }));
@@ -62,7 +75,7 @@ describe('Edit contact point', () => {
await user.click(screen.getByRole('radio', { name: /select notification template/i }));
await clickSelectOption(screen.getByTestId(templatesSelectorTestId), 'slack-template');
expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for slack-template/i);
expect(await getTemplatePreviewContent()).toHaveValue(`some example preview for {{ template "slack-template" . }}`);
// Close the drawer
await user.click(screen.getByRole('button', { name: /^save$/i }));
@@ -336,6 +336,7 @@ export const TemplateForm = ({ originalTemplate, prefill, alertmanager }: Props)
<TemplatePreview
payload={payload}
templateName={watch('title')}
templateContent={watch('content')}
setPayloadFormatError={setPayloadFormatError}
payloadFormatError={payloadFormatError}
className={cx(styles.templatePreview, styles.minEditorSize)}
@@ -1,7 +1,7 @@
import { default as React } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { Provider } from 'react-redux';
import { render, screen, waitFor } from 'test/test-utils';
import { render, screen, waitFor, within } from 'test/test-utils';
import { byRole } from 'testing-library-selector';
import { Components } from '@grafana/e2e-selectors';
@@ -18,6 +18,13 @@ import {
import { TemplateFormValues, defaults } from './TemplateForm';
import { TemplatePreview } from './TemplatePreview';
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)} />;
},
}));
jest.mock(
'react-virtualized-auto-sizer',
() =>
@@ -50,6 +57,7 @@ describe('TemplatePreview component', () => {
<TemplatePreview
payload={'bla bla bla'}
templateName="potato"
templateContent={`{{ define "potato" }}{{ . }}{{ end }}`}
payloadFormatError={'Unexpected token b in JSON at position 0'}
setPayloadFormatError={jest.fn()}
/>,
@@ -66,6 +74,7 @@ describe('TemplatePreview component', () => {
<TemplatePreview
payload={'{"a":"b"}'}
templateName="potato"
templateContent={`{{ define "potato" }}{{ . }}{{ end }}`}
payloadFormatError={'Unexpected token b in JSON at position 0'}
setPayloadFormatError={setError}
/>,
@@ -81,6 +90,7 @@ describe('TemplatePreview component', () => {
<TemplatePreview
payload={'potatos and cherries'}
templateName="potato"
templateContent={`{{ define "potato" }}{{ . }}{{ end }}`}
payloadFormatError={'Unexpected token b in JSON at position 0'}
setPayloadFormatError={jest.fn()}
/>,
@@ -100,6 +110,7 @@ describe('TemplatePreview component', () => {
<TemplatePreview
payload={'[{"a":"b"}]'}
templateName="potato"
templateContent={`{{ define "potato" }}{{ . }}{{ end }}`}
payloadFormatError={null}
setPayloadFormatError={jest.fn()}
/>,
@@ -123,6 +134,7 @@ describe('TemplatePreview component', () => {
<TemplatePreview
payload={'[{"a":"b"}]'}
templateName="potato"
templateContent={`{{ define "potato" }}{{ . }}{{ end }}`}
payloadFormatError={null}
setPayloadFormatError={jest.fn()}
/>,
@@ -133,8 +145,11 @@ describe('TemplatePreview component', () => {
await waitFor(() => {
expect(previews()).toHaveLength(2);
});
expect(previews()[0]).toHaveTextContent('This is the template result bla bla bla');
expect(previews()[1]).toHaveTextContent('This is the template2 result bla bla bla');
const previewItems = previews();
expect(within(previewItems[0]).getByRole('banner')).toHaveTextContent('template1');
expect(within(previewItems[0]).getByTestId('mockeditor')).toHaveValue('This is the template result bla bla bla');
expect(within(previewItems[1]).getByRole('banner')).toHaveTextContent('template2');
expect(within(previewItems[1]).getByTestId('mockeditor')).toHaveValue('This is the template2 result bla bla bla');
});
it('Should render preview response with some errors, if payload has correct format ', async () => {
@@ -151,6 +166,7 @@ describe('TemplatePreview component', () => {
<TemplatePreview
payload={'[{"a":"b"}]'}
templateName="potato"
templateContent={`{{ define "potato" }}{{ . }}{{ end }}`}
payloadFormatError={null}
setPayloadFormatError={jest.fn()}
/>,
@@ -165,6 +181,6 @@ describe('TemplatePreview component', () => {
expect(alerts()[1]).toHaveTextContent(/Unexpected "{" in operand/i);
const previewContent = screen.getByRole('listitem');
expect(previewContent).toHaveTextContent('This is the template result bla bla bla');
expect(within(previewContent).getByTestId('mockeditor')).toHaveValue('This is the template result bla bla bla');
});
});
@@ -1,39 +1,35 @@
import { css, cx } from '@emotion/css';
import { compact, uniqueId } from 'lodash';
import * as React from 'react';
import { useFormContext } from 'react-hook-form';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Box, Button, useStyles2 } from '@grafana/ui';
import { Alert, Box, Button, CodeEditor, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
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,
templateName,
templateContent,
payloadFormatError,
setPayloadFormatError,
className,
}: {
payload: string;
templateName: string;
templateContent: string;
payloadFormatError: string | null;
setPayloadFormatError: (value: React.SetStateAction<string | null>) => void;
className?: string;
}) {
const styles = useStyles2(getStyles);
const { watch } = useFormContext<TemplateFormValues>();
const templateContent = watch('content');
const {
data,
isLoading,
@@ -74,13 +70,25 @@ function PreviewResultViewer({ previews }: { previews: TemplatePreviewResult[] }
const singleTemplate = previews.length === 1;
return (
<ul className={styles.viewer.container}>
{previews.map((preview) => (
<li className={styles.viewer.box} key={preview.name}>
{singleTemplate ? null : <header className={styles.viewer.header}>{preview.name}</header>}
<pre className={styles.viewer.pre}>{preview.text ?? '<Empty>'}</pre>
</li>
))}
<ul className={styles.viewer.container} data-testid="template-preview">
{previews.map((preview) => {
return (
<li className={styles.viewer.box} key={preview.name}>
{singleTemplate ? null : <header className={styles.viewer.header}>{preview.name}</header>}
<CodeEditor
containerStyles={styles.editorContainer}
language={'plaintext'}
showLineNumbers={false}
showMiniMap={false}
value={preview.text}
readOnly={true}
monacoOptions={{
scrollBeyondLastLine: false,
}}
/>
</li>
);
})}
</ul>
);
}
@@ -101,6 +109,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
borderRadius: theme.shape.radius.default,
border: `1px solid ${theme.colors.border.medium}`,
}),
editorContainer: css({
width: '100%',
height: '100%',
border: 'none',
}),
viewerContainer: ({ height }: { height: number }) =>
css({
height,
@@ -128,12 +141,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
errorText: css({
color: theme.colors.error.text,
}),
pre: css({
backgroundColor: 'transparent',
margin: 0,
border: 'none',
padding: theme.spacing(2),
}),
},
});
@@ -49,17 +49,16 @@ export function registerGoTemplateAutocomplete(monaco: Monaco): IDisposable {
}
function isInsideGoExpression(model: editor.ITextModel, position: Position) {
const searchRange = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: model.getLineMinColumn(position.lineNumber),
endColumn: model.getLineMaxColumn(position.lineNumber),
};
// Need to trick findMatches into enabling multiline matches. One way to do this is to have \n in the regex.
const goSyntaxRegex = '\\{\\{(?:.|\\n)+?\\}\\}';
const matches = model.findMatches(goSyntaxRegex, model.getFullModelRange(), true, false, null, false);
const goSyntaxRegex = '\\{\\{[a-zA-Z0-9._() "]+\\}\\}';
const matches = model.findMatches(goSyntaxRegex, searchRange, true, false, null, true);
return matches.some((match) => match.range.containsPosition(position));
return matches.some((match) =>
match.range.containsPosition({
lineNumber: position.lineNumber,
column: position.column + 1, // Stricter check to avoid matching on the closing bracket.
})
);
}
export class CompletionProvider {
@@ -10,8 +10,9 @@ import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/d
import { EditorColumnHeader } from '../../../contact-points/templates/EditorColumnHeader';
import { TemplateEditor } from '../../TemplateEditor';
import { getPreviewResults } from '../../TemplatePreview';
import { usePreviewTemplate } from '../../usePreviewTemplate';
import { TemplatePreview } from '../../TemplatePreview';
import { getUseTemplateText } from './utils';
export function TemplateContentAndPreview({
payload,
@@ -33,11 +34,6 @@ export function TemplateContentAndPreview({
const { selectedAlertmanager } = useAlertmanager();
const isGrafanaAlertManager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
const { data, error } = usePreviewTemplate(templateContent, templateName, payload, setPayloadFormatError);
const previewToRender = getPreviewResults(error, payloadFormatError, data);
const templatePreviewId = 'template-preview';
return (
<div className={cx(className, styles.mainContainer)}>
<div className={styles.container}>
@@ -62,24 +58,15 @@ export function TemplateContentAndPreview({
</div>
{isGrafanaAlertManager && (
<div className={styles.container}>
<EditorColumnHeader
id={templatePreviewId}
label={t(
'alerting.template-content-and-preview.label-preview-with-the-default-payload',
'Preview with the default payload'
)}
/>
<Box flex={1}>
<div
role="presentation"
aria-describedby={templatePreviewId}
className={styles.viewerContainer({ height: 'minHeight' })}
>
{previewToRender}
</div>
</Box>
</div>
<TemplatePreview
payload={payload}
// This should be an empty template name so that the test API treats it as a new unnamed template.
templateName={''}
templateContent={getUseTemplateText(templateName)}
setPayloadFormatError={setPayloadFormatError}
payloadFormatError={payloadFormatError}
className={cx(styles.templatePreview, styles.minEditorSize)}
/>
)}
</div>
);
@@ -98,6 +85,14 @@ const getStyles = (theme: GrafanaTheme2) => ({
borderRadius: theme.shape.radius.default,
border: `1px solid ${theme.colors.border.medium}`,
}),
templatePreview: css({
flex: 1,
display: 'flex',
}),
minEditorSize: css({
minHeight: 300,
minWidth: 300,
}),
viewerContainer: ({ height }: { height: number | string }) =>
css({
height,
@@ -220,9 +220,7 @@ function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSe
/>
<IconButton
tooltip="Copy selected notification template to clipboard. You can use it in the custom tab."
onClick={() =>
copyToClipboard(getUseTemplateText(template?.value?.name ?? defaultTemplateValue?.value?.name ?? ''))
}
onClick={() => copyToClipboard(template?.value?.content ?? defaultTemplateValue?.value?.content ?? '')}
name="copy"
/>
</Stack>
@@ -142,7 +142,7 @@ const getGrafanaAlertmanagerTemplatePreview = () =>
const body = await request.json();
if (body?.template.startsWith('{{')) {
return HttpResponse.json({ results: [{ name: 'asdasd', text: `some example preview for ${body.name}` }] });
return HttpResponse.json({ results: [{ name: 'asdasd', text: `some example preview for ${body.template}` }] });
}
return HttpResponse.json({});
-1
View File
@@ -1963,7 +1963,6 @@
"tooltip-delete": "delete"
},
"template-content-and-preview": {
"label-preview-with-the-default-payload": "Preview with the default payload",
"label-template-content": "Template content"
},
"template-data-table": {