diff --git a/client/app/components/HelpTrigger.jsx b/client/app/components/HelpTrigger.jsx
index deddf4285d..ae57b250da 100644
--- a/client/app/components/HelpTrigger.jsx
+++ b/client/app/components/HelpTrigger.jsx
@@ -76,6 +76,10 @@ export const TYPES = {
'/open-source/setup/#Mail-Configuration',
'Guide: Mail Configuration',
],
+ ALERT_NOTIF_TEMPLATE_GUIDE: [
+ '/user-guide/alerts/custom-alert-notifications',
+ 'Guide: Custom Alerts Notifications',
+ ],
};
export class HelpTrigger extends React.Component {
diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js
index bb8a314f2e..41151d388e 100644
--- a/client/app/components/proptypes.js
+++ b/client/app/components/proptypes.js
@@ -93,10 +93,45 @@ export const Destination = PropTypes.shape({
type: PropTypes.string.isRequired,
});
+export const Query = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ description: PropTypes.string,
+ data_source_id: PropTypes.number.isRequired,
+ created_at: PropTypes.string.isRequired,
+ updated_at: PropTypes.string,
+ user: UserProfile,
+ query: PropTypes.string,
+ queryHash: PropTypes.string,
+ is_safe: PropTypes.bool.isRequired,
+ is_draft: PropTypes.bool.isRequired,
+ is_archived: PropTypes.bool.isRequired,
+ api_key: PropTypes.string.isRequired,
+});
+
export const AlertOptions = PropTypes.shape({
column: PropTypes.string,
op: PropTypes.oneOf(['greater than', 'less than', 'equals']),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ custom_subject: PropTypes.string,
+ custom_body: PropTypes.string,
+});
+
+export const Alert = PropTypes.shape({
+ id: PropTypes.number,
+ name: PropTypes.string,
+ created_at: PropTypes.string,
+ last_triggered_at: PropTypes.string,
+ updated_at: PropTypes.string,
+ rearm: PropTypes.number,
+ state: PropTypes.oneOf(['ok', 'triggered', 'unknown']),
+ user: UserProfile,
+ query: Query.isRequired,
+ options: PropTypes.shape({
+ column: PropTypes.string,
+ op: PropTypes.string,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ }).isRequired,
});
function checkMoment(isRequired, props, propName, componentName) {
diff --git a/client/app/pages/alert/Alert.jsx b/client/app/pages/alert/Alert.jsx
index 935f476348..3fded8d79e 100644
--- a/client/app/pages/alert/Alert.jsx
+++ b/client/app/pages/alert/Alert.jsx
@@ -25,6 +25,7 @@ import Dropdown from 'antd/lib/dropdown';
import Menu from 'antd/lib/menu';
import Criteria from './components/Criteria';
+import NotificationTemplate from './components/NotificationTemplate';
import Rearm from './components/Rearm';
import Query from './components/Query';
import AlertDestinations from './components/AlertDestinations';
@@ -349,10 +350,24 @@ class AlertPage extends React.Component {
+
+ this.setAlertOptions({ custom_subject: subject })}
+ body={options.custom_body}
+ setBody={body => this.setAlertOptions({ custom_body: body })}
+ />
+
>
) : (
+
+ Set to {options.custom_subject || options.custom_body ? 'custom' : 'default'} notification template.
)}
>
diff --git a/client/app/pages/alert/components/NotificationTemplate.jsx b/client/app/pages/alert/components/NotificationTemplate.jsx
new file mode 100644
index 0000000000..d3c1178436
--- /dev/null
+++ b/client/app/pages/alert/components/NotificationTemplate.jsx
@@ -0,0 +1,122 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { head } from 'lodash';
+import Mustache from 'mustache';
+
+import { HelpTrigger } from '@/components/HelpTrigger';
+import { Alert as AlertType, Query as QueryType } from '@/components/proptypes';
+
+import Input from 'antd/lib/input';
+import Select from 'antd/lib/select';
+import Modal from 'antd/lib/modal';
+import Switch from 'antd/lib/switch';
+
+import './NotificationTemplate.less';
+
+
+function normalizeCustomTemplateData(alert, query, columnNames, resultValues) {
+ const topValue = resultValues && head(resultValues)[alert.options.column];
+
+ return {
+ ALERT_STATUS: 'TRIGGERED',
+ ALERT_CONDITION: alert.options.op,
+ ALERT_THRESHOLD: alert.options.value,
+ ALERT_NAME: alert.name,
+ ALERT_URL: `${window.location.origin}/alerts/${alert.id}`,
+ QUERY_NAME: query.name,
+ QUERY_URL: `${window.location.origin}/queries/${query.id}`,
+ QUERY_RESULT_VALUE: topValue,
+ QUERY_RESULT_ROWS: resultValues,
+ QUERY_RESULT_COLS: columnNames,
+ };
+}
+
+function NotificationTemplate({ alert, query, columnNames, resultValues, subject, setSubject, body, setBody }) {
+ const hasContent = !!(subject || body);
+ const [enabled, setEnabled] = useState(hasContent ? 1 : 0);
+ const [showPreview, setShowPreview] = useState(false);
+
+ const renderData = normalizeCustomTemplateData(alert, query, columnNames, resultValues);
+
+ const render = tmpl => Mustache.render(tmpl || '', renderData);
+ const onEnabledChange = (value) => {
+ if (value || !hasContent) {
+ setEnabled(value);
+ setShowPreview(false);
+ } else {
+ Modal.confirm({
+ title: 'Are you sure?',
+ content: 'Switching to default template will discard your custom template.',
+ onOk: () => {
+ setSubject(null);
+ setBody(null);
+ setEnabled(value);
+ setShowPreview(false);
+ },
+ maskClosable: true,
+ autoFocusButton: null,
+ });
+ }
+ };
+
+ return (
+
+
+ {!!enabled && (
+
+
+
Subject / Body
+ Preview
+
+
setSubject(e.target.value)}
+ disabled={showPreview}
+ data-test="CustomSubject"
+ />
+
setBody(e.target.value)}
+ disabled={showPreview}
+ data-test="CustomBody"
+ />
+
+ Formatting guide
+
+
+ )}
+
+ );
+}
+
+NotificationTemplate.propTypes = {
+ alert: AlertType.isRequired,
+ query: QueryType.isRequired,
+ columnNames: PropTypes.arrayOf(PropTypes.string).isRequired,
+ resultValues: PropTypes.arrayOf(PropTypes.any).isRequired,
+ subject: PropTypes.string,
+ setSubject: PropTypes.func.isRequired,
+ body: PropTypes.string,
+ setBody: PropTypes.func.isRequired,
+};
+
+NotificationTemplate.defaultProps = {
+ subject: '',
+ body: '',
+};
+
+export default NotificationTemplate;
diff --git a/client/app/pages/alert/components/NotificationTemplate.less b/client/app/pages/alert/components/NotificationTemplate.less
new file mode 100644
index 0000000000..15a4907c34
--- /dev/null
+++ b/client/app/pages/alert/components/NotificationTemplate.less
@@ -0,0 +1,36 @@
+.alert-template {
+ display: flex;
+ flex-direction: column;
+
+ input {
+ margin-bottom: 10px;
+ }
+
+ textarea {
+ margin-bottom: 0 !important;
+ }
+
+ input, textarea {
+ font-family: "Roboto Mono", monospace;
+ font-size: 12px;
+ letter-spacing: -0.4px ;
+
+ &[disabled] {
+ color: inherit;
+ cursor: auto;
+ }
+ }
+
+ .alert-custom-template {
+ margin-top: 10px;
+ padding: 4px 10px 2px;
+ background: #fbfbfb;
+ border: 1px dashed #d9d9d9;
+ border-radius: 3px;
+ max-width: 500px;
+ }
+
+ .alert-template-preview {
+ margin: 0 0 0 5px !important;
+ }
+}
\ No newline at end of file
diff --git a/client/app/services/alert-template.js b/client/app/services/alert-template.js
deleted file mode 100644
index 74da808326..0000000000
--- a/client/app/services/alert-template.js
+++ /dev/null
@@ -1,41 +0,0 @@
-// import { $http } from '@/services/ng';
-import Mustache from 'mustache';
-
-export default class AlertTemplate {
- render(alert, queryResult) {
- const view = {
- state: alert.state,
- rows: queryResult.rows,
- cols: queryResult.columns,
- };
- const result = Mustache.render(alert.options.template, view);
- const escaped = result
- .replace(/"/g, '"')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/\n|\r/g, '
');
-
- return { escaped, raw: result };
- }
-
- constructor() {
- this.helpMessage = `using template engine "mustache".
- you can build message with latest query result.
- variable name "rows" is assigned as result rows. "cols" as result columns, "state" as alert state.`;
-
- this.editorOptions = {
- useWrapMode: true,
- showPrintMargin: false,
- advanced: {
- behavioursEnabled: true,
- enableBasicAutocompletion: true,
- enableLiveAutocompletion: true,
- autoScrollEditorIntoView: true,
- },
- onLoad(editor) {
- editor.$blockScrolling = Infinity;
- },
- };
- }
-}
diff --git a/client/cypress/integration/alert/edit_alert_spec.js b/client/cypress/integration/alert/edit_alert_spec.js
index cde513a6f2..31c8062011 100644
--- a/client/cypress/integration/alert/edit_alert_spec.js
+++ b/client/cypress/integration/alert/edit_alert_spec.js
@@ -14,4 +14,32 @@ describe('Edit Alert', () => {
cy.percySnapshot('Edit Alert screen');
});
});
+
+ it('edits the notification template and takes a screenshot', () => {
+ createQuery()
+ .then(({ id: queryId }) => createAlert(queryId, { custom_subject: 'FOO', custom_body: 'BAR' }))
+ .then(({ id: alertId }) => {
+ cy.visit(`/alerts/${alertId}/edit`);
+ cy.getByTestId('AlertCustomTemplate').should('exist');
+ cy.percySnapshot('Alert Custom Template screen');
+ });
+ });
+
+ it('previews rendered template correctly', () => {
+ const options = {
+ value: '123',
+ op: 'equals',
+ custom_subject: '{{ ALERT_CONDITION }}',
+ custom_body: '{{ ALERT_THRESHOLD }}',
+ };
+
+ createQuery()
+ .then(({ id: queryId }) => createAlert(queryId, options))
+ .then(({ id: alertId }) => {
+ cy.visit(`/alerts/${alertId}/edit`);
+ cy.get('.alert-template-preview').click();
+ cy.getByTestId('CustomSubject').should('have.value', options.op);
+ cy.getByTestId('CustomBody').should('have.value', options.value);
+ });
+ });
});
diff --git a/redash/destinations/chatwork.py b/redash/destinations/chatwork.py
index aea6855a3e..4ea13a20d0 100644
--- a/redash/destinations/chatwork.py
+++ b/redash/destinations/chatwork.py
@@ -38,20 +38,21 @@ def notify(self, alert, query, user, new_state, app, host, options):
# Documentation: http://developer.chatwork.com/ja/endpoint_rooms.html#POST-rooms-room_id-messages
url = 'https://api.chatwork.com/v2/rooms/{room_id}/messages'.format(room_id=options.get('room_id'))
- alert_url = '{host}/alerts/{alert_id}'.format(host=host, alert_id=alert.id)
- query_url = '{host}/queries/{query_id}'.format(host=host, query_id=query.id)
- message_template = options.get('message_template', ChatWork.ALERTS_DEFAULT_MESSAGE_TEMPLATE)
message = ''
if alert.custom_subject:
message = alert.custom_subject + '\n'
- message += message_template.replace('\\n', '\n').format(
+
+ if alert.custom_body:
+ message += alert.custom_body
+ else:
+ alert_url = '{host}/alerts/{alert_id}'.format(host=host, alert_id=alert.id)
+ query_url = '{host}/queries/{query_id}'.format(host=host, query_id=query.id)
+ message_template = options.get('message_template', ChatWork.ALERTS_DEFAULT_MESSAGE_TEMPLATE)
+ message += message_template.replace('\\n', '\n').format(
alert_name=alert.name, new_state=new_state.upper(),
alert_url=alert_url,
query_url=query_url)
- if alert.template:
- description = alert.render_template()
- message = message + "\n" + description
headers = {'X-ChatWorkToken': options.get('api_token')}
payload = {'body': message}
diff --git a/redash/destinations/email.py b/redash/destinations/email.py
index 537ec6bc2b..985014c415 100644
--- a/redash/destinations/email.py
+++ b/redash/destinations/email.py
@@ -34,12 +34,12 @@ def notify(self, alert, query, user, new_state, app, host, options):
if not recipients:
logging.warning("No emails given. Skipping send.")
- html = """
- Check alert / check query .
- """.format(host=host, alert_id=alert.id, query_id=query.id)
- if alert.template:
- description = alert.render_template()
- html += "
" + description
+ if alert.custom_body:
+ html = alert.custom_body
+ else:
+ html = """
+ Check alert / check query .
+ """.format(host=host, alert_id=alert.id, query_id=query.id)
logging.debug("Notifying: %s", recipients)
try:
diff --git a/redash/destinations/hangoutschat.py b/redash/destinations/hangoutschat.py
index 5db48e9cf7..0a3063a435 100644
--- a/redash/destinations/hangoutschat.py
+++ b/redash/destinations/hangoutschat.py
@@ -70,12 +70,12 @@ def notify(self, alert, query, user, new_state, app, host, options):
]
}
- if alert.template:
+ if alert.custom_body:
data["cards"][0]["sections"].append({
"widgets": [
{
"textParagraph": {
- "text": alert.render_template()
+ "text": alert.custom_body
}
}
]
diff --git a/redash/destinations/mattermost.py b/redash/destinations/mattermost.py
index 6528032e92..573cb7cf21 100644
--- a/redash/destinations/mattermost.py
+++ b/redash/destinations/mattermost.py
@@ -35,19 +35,20 @@ def icon(cls):
return 'fa-bolt'
def notify(self, alert, query, user, new_state, app, host, options):
- if new_state == "triggered":
+
+
+ if alert.custom_subject:
+ text = alert.custom_subject
+ elif new_state == "triggered":
text = "#### " + alert.name + " just triggered"
else:
text = "#### " + alert.name + " went back to normal"
-
- if alert.custom_subject:
- text += '\n' + alert.custom_subject
payload = {'text': text}
- if alert.template:
+ if alert.custom_body:
payload['attachments'] = [{'fields': [{
"title": "Description",
- "value": alert.render_template()
+ "value": alert.custom_body
}]}]
if options.get('username'): payload['username'] = options.get('username')
diff --git a/redash/destinations/pagerduty.py b/redash/destinations/pagerduty.py
index 403901e252..09410a7183 100644
--- a/redash/destinations/pagerduty.py
+++ b/redash/destinations/pagerduty.py
@@ -60,8 +60,8 @@ def notify(self, alert, query, user, new_state, app, host, options):
}
}
- if alert.template:
- data['payload']['custom_details'] = alert.render_template()
+ if alert.custom_body:
+ data['payload']['custom_details'] = alert.custom_body
if new_state == 'triggered':
data['event_action'] = 'trigger'
diff --git a/redash/destinations/slack.py b/redash/destinations/slack.py
index 18c998cf89..4c10c8e73b 100644
--- a/redash/destinations/slack.py
+++ b/redash/destinations/slack.py
@@ -52,11 +52,10 @@ def notify(self, alert, query, user, new_state, app, host, options):
"short": True
}
]
- if alert.template:
- description = alert.render_template()
+ if alert.custom_body:
fields.append({
"title": "Description",
- "value": description
+ "value": alert.custom_body
})
if new_state == "triggered":
if alert.custom_subject:
diff --git a/redash/destinations/webhook.py b/redash/destinations/webhook.py
index eb0cd06e0b..42144ff3fa 100644
--- a/redash/destinations/webhook.py
+++ b/redash/destinations/webhook.py
@@ -39,7 +39,7 @@ def notify(self, alert, query, user, new_state, app, host, options):
'url_base': host,
}
- data['alert']['description'] = alert.render_template()
+ data['alert']['description'] = alert.custom_body
data['alert']['title'] = alert.custom_subject
headers = {'Content-Type': 'application/json'}
diff --git a/redash/models/__init__.py b/redash/models/__init__.py
index cc160884c8..f49101fa7e 100644
--- a/redash/models/__init__.py
+++ b/redash/models/__init__.py
@@ -23,7 +23,7 @@
from redash.metrics import database # noqa: F401
from redash.query_runner import (get_configuration_schema_for_query_runner_type,
get_query_runner, TYPE_BOOLEAN, TYPE_DATE, TYPE_DATETIME)
-from redash.utils import generate_token, json_dumps, json_loads, mustache_render
+from redash.utils import generate_token, json_dumps, json_loads, mustache_render, base_url
from redash.utils.configuration import ConfigurationContainer
from redash.models.parameterized_query import ParameterizedQuery
@@ -821,20 +821,42 @@ def evaluate(self):
def subscribers(self):
return User.query.join(AlertSubscription).filter(AlertSubscription.alert == self)
- def render_template(self):
- if not self.template:
+ def render_template(self, template):
+ if template is None:
return ''
+
data = json_loads(self.query_rel.latest_query_data.data)
- context = {'rows': data['rows'], 'cols': data['columns'], 'state': self.state}
- return mustache_render(self.template, context)
+ host = base_url(self.query_rel.org)
+
+ col_name = self.options['column']
+ if data['rows'] and col_name in data['rows'][0]:
+ result_value = data['rows'][0][col_name]
+ else:
+ result_value = None
+
+ context = {
+ 'ALERT_NAME': self.name,
+ 'ALERT_URL': '{host}/alerts/{alert_id}'.format(host=host, alert_id=self.id),
+ 'ALERT_STATUS': self.state.upper(),
+ 'ALERT_CONDITION': self.options['op'],
+ 'ALERT_THRESHOLD': self.options['value'],
+ 'QUERY_NAME': self.query_rel.name,
+ 'QUERY_URL': '{host}/queries/{query_id}'.format(host=host, query_id=self.query_rel.id),
+ 'QUERY_RESULT_VALUE': result_value,
+ 'QUERY_RESULT_ROWS': data['rows'],
+ 'QUERY_RESULT_COLS': data['columns'],
+ }
+ return mustache_render(template, context)
@property
- def template(self):
- return self.options.get('template', '')
+ def custom_body(self):
+ template = self.options.get('custom_body', self.options.get('template'))
+ return self.render_template(template)
@property
def custom_subject(self):
- return self.options.get('subject', '')
+ template = self.options.get('custom_subject')
+ return self.render_template(template)
@property
def groups(self):