Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alert redesign #4 - custom notification template #4170

Merged
merged 12 commits into from
Oct 5, 2019
4 changes: 4 additions & 0 deletions client/app/components/HelpTrigger.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions client/app/components/proptypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
ranbena marked this conversation as resolved.
Show resolved Hide resolved
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) {
Expand Down
15 changes: 15 additions & 0 deletions client/app/pages/alert/Alert.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -349,10 +350,24 @@ class AlertPage extends React.Component {
<HorizontalFormItem label="When triggered, send notification">
<Rearm value={pendingRearm || 0} onChange={this.onRearmChange} editMode />
</HorizontalFormItem>
<HorizontalFormItem label="Template">
<NotificationTemplate
alert={alert}
query={query}
columnNames={queryResult.getColumnNames()}
resultValues={queryResult.getData()}
subject={options.custom_subject}
setSubject={subject => this.setAlertOptions({ custom_subject: subject })}
body={options.custom_body}
setBody={body => this.setAlertOptions({ custom_body: body })}
/>
</HorizontalFormItem>
</>
) : (
<HorizontalFormItem label="Notifications" className="form-item-line-height-normal">
<Rearm value={pendingRearm || 0} />
<br />
Set to {options.custom_subject || options.custom_body ? 'custom' : 'default'} notification template.
</HorizontalFormItem>
)}
</>
Expand Down
122 changes: 122 additions & 0 deletions client/app/pages/alert/components/NotificationTemplate.jsx
Original file line number Diff line number Diff line change
@@ -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,
};
}
ranbena marked this conversation as resolved.
Show resolved Hide resolved

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 (
<div className="alert-template">
<Select
value={enabled}
onChange={onEnabledChange}
optionLabelProp="label"
dropdownMatchSelectWidth={false}
style={{ width: 'fit-content' }}
>
<Select.Option value={0} label="Use default template">
Default template
</Select.Option>
<Select.Option value={1} label="Use custom template">
Custom template
</Select.Option>
</Select>
{!!enabled && (
<div className="alert-custom-template" data-test="AlertCustomTemplate">
<div className="d-flex align-items-center">
<h5 className="flex-fill">Subject / Body</h5>
Preview <Switch size="small" className="alert-template-preview" value={showPreview} onChange={setShowPreview} />
</div>
<Input
value={showPreview ? render(subject) : subject}
onChange={e => setSubject(e.target.value)}
disabled={showPreview}
data-test="CustomSubject"
/>
<Input.TextArea
value={showPreview ? render(body) : body}
autosize={{ minRows: 9 }}
onChange={e => setBody(e.target.value)}
disabled={showPreview}
data-test="CustomBody"
/>
<HelpTrigger type="ALERT_NOTIF_TEMPLATE_GUIDE" className="f-13">
<i className="fa fa-question-circle" /> Formatting guide
</HelpTrigger>
</div>
)}
</div>
);
}

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;
36 changes: 36 additions & 0 deletions client/app/pages/alert/components/NotificationTemplate.less
Original file line number Diff line number Diff line change
@@ -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;
}
}
41 changes: 0 additions & 41 deletions client/app/services/alert-template.js

This file was deleted.

28 changes: 28 additions & 0 deletions client/cypress/integration/alert/edit_alert_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
15 changes: 8 additions & 7 deletions redash/destinations/chatwork.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
12 changes: 6 additions & 6 deletions redash/destinations/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href="{host}/alerts/{alert_id}">alert</a> / check <a href="{host}/queries/{query_id}">query</a> </br>.
""".format(host=host, alert_id=alert.id, query_id=query.id)
if alert.template:
description = alert.render_template()
html += "<br>" + description
if alert.custom_body:
html = alert.custom_body
else:
html = """
Check <a href="{host}/alerts/{alert_id}">alert</a> / check <a href="{host}/queries/{query_id}">query</a> </br>.
""".format(host=host, alert_id=alert.id, query_id=query.id)
logging.debug("Notifying: %s", recipients)

try:
Expand Down
4 changes: 2 additions & 2 deletions redash/destinations/hangoutschat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
]
Expand Down
Loading