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):