From f12b41287a2dffc4009937b5073288c4ba8d3e3d Mon Sep 17 00:00:00 2001 From: k-tomoyasu Date: Thu, 11 Jul 2019 19:23:06 +0900 Subject: [PATCH] Build custom alert message (#3137) * build custom alert message * fit button color tone * pass existing test * fix typos * follow code style * add webhook alert description and avoid key error * refactor: create alert template module * follow code style * use es6 class, fix template display * use alerts.options, use mustache * fix email description * alert custom subject * add alert state to template context, sanitized preview * remove console.log :bow: * chatwork custom_subject * add alert custom message. pagerduty, mattermost, hangoutschat * Pass custom subject in webhook destination * Add log message when checking alert. * Add feature flag for extra alert options. --- client/app/pages/alert/alert.html | 33 +++++++++++++++++++++ client/app/pages/alert/index.js | 26 ++++++++++++++++- client/app/services/alert-template.js | 41 +++++++++++++++++++++++++++ redash/destinations/chatwork.py | 10 +++++-- redash/destinations/email.py | 15 ++++++++-- redash/destinations/hangoutschat.py | 18 +++++++++++- redash/destinations/mattermost.py | 9 ++++++ redash/destinations/pagerduty.py | 7 ++++- redash/destinations/slack.py | 11 ++++++- redash/destinations/webhook.py | 6 +++- redash/handlers/alerts.py | 3 +- redash/handlers/authentication.py | 1 + redash/models/__init__.py | 17 ++++++++++- redash/settings/__init__.py | 1 + redash/tasks/alerts.py | 1 + 15 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 client/app/services/alert-template.js diff --git a/client/app/pages/alert/alert.html b/client/app/pages/alert/alert.html index 1091e993ba..cf8c86f474 100644 --- a/client/app/pages/alert/alert.html +++ b/client/app/pages/alert/alert.html @@ -47,6 +47,39 @@ +
+ + +
+
+
+ + +
+
+
+
+
+ + + +
+
+
+ + +
+
+
+
+
+
+
+
+
+ +
+
diff --git a/client/app/pages/alert/index.js b/client/app/pages/alert/index.js index 486629563c..1296468222 100644 --- a/client/app/pages/alert/index.js +++ b/client/app/pages/alert/index.js @@ -2,10 +2,15 @@ import { template as templateBuilder } from 'lodash'; import notification from '@/services/notification'; import Modal from 'antd/lib/modal'; import template from './alert.html'; +import AlertTemplate from '@/services/alert-template'; +import { clientConfig } from '@/services/auth'; import navigateTo from '@/services/navigateTo'; -function AlertCtrl($scope, $routeParams, $location, $sce, currentUser, Query, Events, Alert) { +function AlertCtrl($scope, $routeParams, $location, $sce, $sanitize, currentUser, Query, Events, Alert) { this.alertId = $routeParams.alertId; + this.hidePreview = false; + this.alertTemplate = new AlertTemplate(); + this.showExtendedOptions = clientConfig.extendedAlertOptions; if (this.alertId === 'new') { Events.record('view', 'page', 'alerts/new'); @@ -62,6 +67,9 @@ function AlertCtrl($scope, $routeParams, $location, $sce, currentUser, Query, Ev if (this.alert.rearm === '' || this.alert.rearm === 0) { this.alert.rearm = null; } + if (this.alert.template === undefined || this.alert.template === '') { + this.alert.template = null; + } this.alert.$save( (alert) => { notification.success('Saved.'); @@ -75,6 +83,22 @@ function AlertCtrl($scope, $routeParams, $location, $sce, currentUser, Query, Ev ); }; + this.preview = () => { + const notifyError = () => notification.error('Unable to render description. please confirm your template.'); + try { + const result = this.alertTemplate.render(this.alert, this.queryResult.query_result.data); + this.alert.preview = $sce.trustAsHtml(result.escaped); + this.alert.previewHTML = $sce.trustAsHtml($sanitize(result.raw)); + if (!result.raw) { + notifyError(); + } + } catch (e) { + notifyError(); + this.alert.preview = e.message; + this.alert.previewHTML = e.message; + } + }; + this.delete = () => { const doDelete = () => { this.alert.$delete(() => { diff --git a/client/app/services/alert-template.js b/client/app/services/alert-template.js new file mode 100644 index 0000000000..74da808326 --- /dev/null +++ b/client/app/services/alert-template.js @@ -0,0 +1,41 @@ +// 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/redash/destinations/chatwork.py b/redash/destinations/chatwork.py index 6d794b13ed..c4751f25d8 100644 --- a/redash/destinations/chatwork.py +++ b/redash/destinations/chatwork.py @@ -40,14 +40,18 @@ def notify(self, alert, query, user, new_state, app, host, options): 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( + message = '' + if alert.custom_subject: + message = alert.custom_subject + '\n' + 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 b66c9e9484..537ec6bc2b 100644 --- a/redash/destinations/email.py +++ b/redash/destinations/email.py @@ -35,21 +35,30 @@ def notify(self, alert, query, user, new_state, app, host, options): logging.warning("No emails given. Skipping send.") html = """ - Check alert / check query. + Check alert / check query
. """.format(host=host, alert_id=alert.id, query_id=query.id) + if alert.template: + description = alert.render_template() + html += "
" + description logging.debug("Notifying: %s", recipients) try: alert_name = alert.name.encode('utf-8', 'ignore') state = new_state.upper() - subject_template = options.get('subject_template', settings.ALERTS_DEFAULT_MAIL_SUBJECT_TEMPLATE) + if alert.custom_subject: + subject = alert.custom_subject + else: + subject_template = options.get('subject_template', settings.ALERTS_DEFAULT_MAIL_SUBJECT_TEMPLATE) + subject = subject_template.format(alert_name=alert_name, state=state) + message = Message( recipients=recipients, - subject=subject_template.format(alert_name=alert_name, state=state), + subject=subject, html=html ) mail.send(message) except Exception: logging.exception("Mail send error.") + register(Email) diff --git a/redash/destinations/hangoutschat.py b/redash/destinations/hangoutschat.py index 28951b6193..5db48e9cf7 100644 --- a/redash/destinations/hangoutschat.py +++ b/redash/destinations/hangoutschat.py @@ -44,11 +44,16 @@ def notify(self, alert, query, user, new_state, app, host, options): else: message = "Unable to determine status. Check Query and Alert configuration." + if alert.custom_subject: + title = alert.custom_subject + else: + title = alert.name + data = { "cards": [ { "header": { - "title": alert.name + "title": title }, "sections": [ { @@ -65,6 +70,17 @@ def notify(self, alert, query, user, new_state, app, host, options): ] } + if alert.template: + data["cards"][0]["sections"].append({ + "widgets": [ + { + "textParagraph": { + "text": alert.render_template() + } + } + ] + }) + if options.get("icon_url"): data["cards"][0]["header"]["imageUrl"] = options.get("icon_url") diff --git a/redash/destinations/mattermost.py b/redash/destinations/mattermost.py index ea0280954d..ca9e4ea161 100644 --- a/redash/destinations/mattermost.py +++ b/redash/destinations/mattermost.py @@ -40,7 +40,16 @@ def notify(self, alert, query, user, new_state, app, host, options): else: text = "####" + alert.name + " went back to normal" + if alert.custom_subject: + text += '\n' + alert.custom_subject payload = {'text': text} + + if alert.template: + payload['attachments'] = [{'fields': [{ + "title": "Description", + "value": alert.render_template() + }]}] + if options.get('username'): payload['username'] = options.get('username') if options.get('icon_url'): payload['icon_url'] = options.get('icon_url') if options.get('channel'): payload['channel'] = options.get('channel') diff --git a/redash/destinations/pagerduty.py b/redash/destinations/pagerduty.py index c0411a04ff..737d801e57 100644 --- a/redash/destinations/pagerduty.py +++ b/redash/destinations/pagerduty.py @@ -43,7 +43,9 @@ def notify(self, alert, query, user, new_state, app, host, options): default_desc = self.DESCRIPTION_STR.format(query_id=query.id, query_name=query.name) - if options.get('description'): + if alert.custom_subject: + default_desc = alert.custom_subject + elif options.get('description'): default_desc = options.get('description') incident_key = self.KEY_STRING.format(alert_id=alert.id, query_id=query.id) @@ -58,6 +60,9 @@ def notify(self, alert, query, user, new_state, app, host, options): } } + if alert.template: + data['payload']['custom_details'] = alert.render_template() + if new_state == 'triggered': data['event_action'] = 'trigger' elif new_state == "unknown": diff --git a/redash/destinations/slack.py b/redash/destinations/slack.py index e9e90ebf9c..9cbcfda2c5 100644 --- a/redash/destinations/slack.py +++ b/redash/destinations/slack.py @@ -52,8 +52,17 @@ def notify(self, alert, query, user, new_state, app, host, options): "short": True } ] + if alert.template: + description = alert.render_template() + fields.append({ + "title": "Description", + "value": description + }) if new_state == "triggered": - text = alert.name + " just triggered" + if alert.custom_subject: + text = alert.custom_subject + else: + text = alert.name + " just triggered" color = "#c0392b" else: text = alert.name + " went back to normal" diff --git a/redash/destinations/webhook.py b/redash/destinations/webhook.py index 5b572db080..eb0cd06e0b 100644 --- a/redash/destinations/webhook.py +++ b/redash/destinations/webhook.py @@ -36,8 +36,12 @@ def notify(self, alert, query, user, new_state, app, host, options): data = { 'event': 'alert_state_change', 'alert': serialize_alert(alert, full=False), - 'url_base': host + 'url_base': host, } + + data['alert']['description'] = alert.render_template() + data['alert']['title'] = alert.custom_subject + headers = {'Content-Type': 'application/json'} auth = HTTPBasicAuth(options.get('username'), options.get('password')) if options.get('username') else None resp = requests.post(options.get('url'), data=json_dumps(data), auth=auth, headers=headers, timeout=5.0) diff --git a/redash/handlers/alerts.py b/redash/handlers/alerts.py index 8dd3ec1c8e..74c47c0d48 100644 --- a/redash/handlers/alerts.py +++ b/redash/handlers/alerts.py @@ -9,6 +9,7 @@ require_fields) from redash.permissions import (require_access, require_admin_or_owner, require_permission, view_only) +from redash.utils import json_dumps class AlertResource(BaseResource): @@ -60,7 +61,7 @@ def post(self): query_rel=query, user=self.current_user, rearm=req.get('rearm'), - options=req['options'] + options=req['options'], ) models.db.session.add(alert) diff --git a/redash/handlers/authentication.py b/redash/handlers/authentication.py index 2ace2d3e26..461e033e5a 100644 --- a/redash/handlers/authentication.py +++ b/redash/handlers/authentication.py @@ -227,6 +227,7 @@ def client_config(): 'showPermissionsControl': current_org.get_setting("feature_show_permissions_control"), 'allowCustomJSVisualizations': settings.FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS, 'autoPublishNamedQueries': settings.FEATURE_AUTO_PUBLISH_NAMED_QUERIES, + 'extendedAlertOptions': settings.FEATURE_EXTENDED_ALERT_OPTIONS, 'mailSettingsMissing': not settings.email_server_is_configured(), 'dashboardRefreshIntervals': settings.DASHBOARD_REFRESH_INTERVALS, 'queryRefreshIntervals': settings.QUERY_REFRESH_INTERVALS, diff --git a/redash/models/__init__.py b/redash/models/__init__.py index 25f24180d9..5cecdbc530 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 +from redash.utils import generate_token, json_dumps, json_loads, mustache_render from redash.utils.configuration import ConfigurationContainer from redash.models.parameterized_query import ParameterizedQuery @@ -796,6 +796,21 @@ def evaluate(self): def subscribers(self): return User.query.join(AlertSubscription).filter(AlertSubscription.alert == self) + def render_template(self): + if not self.template: + 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) + + @property + def template(self): + return self.options.get('template', '') + + @property + def custom_subject(self): + return self.options.get('subject', '') + @property def groups(self): return self.query_rel.groups diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py index 64eae1e504..079de08796 100644 --- a/redash/settings/__init__.py +++ b/redash/settings/__init__.py @@ -334,6 +334,7 @@ def email_server_is_configured(): FEATURE_SHOW_QUERY_RESULTS_COUNT = parse_boolean(os.environ.get("REDASH_FEATURE_SHOW_QUERY_RESULTS_COUNT", "true")) FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS = parse_boolean(os.environ.get("REDASH_FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS", "false")) FEATURE_AUTO_PUBLISH_NAMED_QUERIES = parse_boolean(os.environ.get("REDASH_FEATURE_AUTO_PUBLISH_NAMED_QUERIES", "true")) +FEATURE_EXTENDED_ALERT_OPTIONS = parse_boolean(os.environ.get("REDASH_FEATURE_EXTENDED_ALERT_OPTIONS", "false")) # BigQuery BIGQUERY_HTTP_TIMEOUT = int(os.environ.get("REDASH_BIGQUERY_HTTP_TIMEOUT", "600")) diff --git a/redash/tasks/alerts.py b/redash/tasks/alerts.py index 7a239cf6e4..2346b9dee1 100644 --- a/redash/tasks/alerts.py +++ b/redash/tasks/alerts.py @@ -40,6 +40,7 @@ def check_alerts_for_query(query_id): query = models.Query.query.get(query_id) for alert in query.alerts: + logger.info("Checking alert (%d) of query %d.", alert.id, query_id) new_state = alert.evaluate() if should_notify(alert, new_state):