diff --git a/client/app/pages/alert/Alert.jsx b/client/app/pages/alert/Alert.jsx index 82352fd4e6..8bcc4d19f1 100644 --- a/client/app/pages/alert/Alert.jsx +++ b/client/app/pages/alert/Alert.jsx @@ -55,6 +55,7 @@ class AlertPage extends React.Component { options: { op: '>', value: 1, + muted: false, }, }), pendingRearm: 0, @@ -159,6 +160,30 @@ class AlertPage extends React.Component { }); }; + mute = () => { + const { alert } = this.state; + return alert.$mute() + .then(() => { + this.setAlertOptions({ muted: true }); + notification.warn('Notifications have been muted.'); + }) + .catch(() => { + notification.error('Failed muting notifications.'); + }); + } + + unmute = () => { + const { alert } = this.state; + return alert.$unmute() + .then(() => { + this.setAlertOptions({ muted: false }); + notification.success('Notifications have been restored.'); + }) + .catch(() => { + notification.error('Failed restoring notifications.'); + }); + } + edit = () => { const { id } = this.state.alert; navigateTo(`/alerts/${id}/edit`, true, false); @@ -177,11 +202,15 @@ class AlertPage extends React.Component { return ; } + const muted = !!alert.options.muted; const { queryResult, mode, canEdit, pendingRearm } = this.state; const menuButton = ( ); @@ -202,7 +231,15 @@ class AlertPage extends React.Component { return (
{mode === MODES.NEW && } - {mode === MODES.VIEW && } + {mode === MODES.VIEW && ( + + )} {mode === MODES.EDIT && }
); diff --git a/client/app/pages/alert/AlertView.jsx b/client/app/pages/alert/AlertView.jsx index d9248b5835..90460a4551 100644 --- a/client/app/pages/alert/AlertView.jsx +++ b/client/app/pages/alert/AlertView.jsx @@ -8,6 +8,7 @@ import { Alert as AlertType } from '@/components/proptypes'; import Form from 'antd/lib/form'; import Button from 'antd/lib/button'; import Tooltip from 'antd/lib/tooltip'; +import AntAlert from 'antd/lib/alert'; import Title from './components/Title'; import Criteria from './components/Criteria'; @@ -47,6 +48,17 @@ AlertState.defaultProps = { // eslint-disable-next-line react/prefer-stateless-function export default class AlertView extends React.Component { + state = { + unmuting: false, + } + + unmute = () => { + this.setState({ unmuting: true }); + this.props.unmute().finally(() => { + this.setState({ unmuting: false }); + }); + } + render() { const { alert, queryResult, canEdit, onEdit, menuButton } = this.props; const { query, name, options, rearm } = alert; @@ -87,6 +99,24 @@ export default class AlertView extends React.Component {
+ {options.muted && ( + Notifications are muted} + description={( + <> + Notifications for this alert will not be sent.
+ {canEdit && ( + <> + To restore notifications click + + + )} + + )} + type="warning" + /> + )}

Destinations{' '} @@ -108,8 +138,10 @@ AlertView.propTypes = { canEdit: PropTypes.bool.isRequired, onEdit: PropTypes.func.isRequired, menuButton: PropTypes.node.isRequired, + unmute: PropTypes.func, }; AlertView.defaultProps = { queryResult: null, + unmute: null, }; diff --git a/client/app/pages/alert/components/AlertDestinations.less b/client/app/pages/alert/components/AlertDestinations.less index 54e0b4f6a0..9fbd8cfecb 100644 --- a/client/app/pages/alert/components/AlertDestinations.less +++ b/client/app/pages/alert/components/AlertDestinations.less @@ -1,5 +1,7 @@ .alert-destinations { - ul { + position: relative; + + ul { list-style: none; padding: 0; margin-top: 15px; @@ -34,8 +36,8 @@ .add-button { position: absolute; - right: 14px; - top: 9px; + right: 0; + top:-33px; } } diff --git a/client/app/pages/alert/components/MenuButton.jsx b/client/app/pages/alert/components/MenuButton.jsx index a6494fb1b6..540314dfbd 100644 --- a/client/app/pages/alert/components/MenuButton.jsx +++ b/client/app/pages/alert/components/MenuButton.jsx @@ -9,9 +9,16 @@ import Button from 'antd/lib/button'; import Icon from 'antd/lib/icon'; -export default function MenuButton({ doDelete, canEdit }) { +export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) { const [loading, setLoading] = useState(false); + const execute = useCallback((action) => { + setLoading(true); + action().finally(() => { + setLoading(false); + }); + }, []); + const confirmDelete = useCallback(() => { Modal.confirm({ title: 'Delete Alert', @@ -36,6 +43,13 @@ export default function MenuButton({ doDelete, canEdit }) { placement="bottomRight" overlay={( + + {muted ? ( + execute(unmute)}>Unmute Notifications + ) : ( + execute(mute)}>Mute Notifications + )} + Delete Alert @@ -52,4 +66,11 @@ export default function MenuButton({ doDelete, canEdit }) { MenuButton.propTypes = { doDelete: PropTypes.func.isRequired, canEdit: PropTypes.bool.isRequired, + mute: PropTypes.func.isRequired, + unmute: PropTypes.func.isRequired, + muted: PropTypes.bool, +}; + +MenuButton.defaultProps = { + muted: false, }; diff --git a/client/app/pages/alerts/AlertsList.jsx b/client/app/pages/alerts/AlertsList.jsx index 8eac176a94..34fef9ebc7 100644 --- a/client/app/pages/alerts/AlertsList.jsx +++ b/client/app/pages/alerts/AlertsList.jsx @@ -28,6 +28,13 @@ class AlertsList extends React.Component { }; listColumns = [ + Columns.custom.sortable((text, alert) => ( + + ), { + title: , + field: 'muted', + width: '1%', + }), Columns.custom.sortable((text, alert) => (
{alert.name} diff --git a/client/app/services/alert.js b/client/app/services/alert.js index 7913d29c5c..e340d2c072 100644 --- a/client/app/services/alert.js +++ b/client/app/services/alert.js @@ -35,6 +35,8 @@ function AlertService($resource, $http) { return newData; }].concat($http.defaults.transformRequest), }, + mute: { method: 'POST', url: 'api/alerts/:id/mute' }, + unmute: { method: 'DELETE', url: 'api/alerts/:id/mute' }, }; return $resource('api/alerts/:id', { id: '@id' }, actions); } diff --git a/redash/handlers/alerts.py b/redash/handlers/alerts.py index 74c47c0d48..03a0d73f04 100644 --- a/redash/handlers/alerts.py +++ b/redash/handlers/alerts.py @@ -47,6 +47,34 @@ def delete(self, alert_id): models.db.session.commit() +class AlertMuteResource(BaseResource): + def post(self, alert_id): + alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org) + require_admin_or_owner(alert.user.id) + + alert.options['muted'] = True + models.db.session.commit() + + self.record_event({ + 'action': 'mute', + 'object_id': alert.id, + 'object_type': 'alert' + }) + + def delete(self, alert_id): + alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org) + require_admin_or_owner(alert.user.id) + + alert.options['muted'] = False + models.db.session.commit() + + self.record_event({ + 'action': 'unmute', + 'object_id': alert.id, + 'object_type': 'alert' + }) + + class AlertListResource(BaseResource): def post(self): req = request.get_json(True) diff --git a/redash/handlers/api.py b/redash/handlers/api.py index 4479ca12ea..bca2c527d5 100644 --- a/redash/handlers/api.py +++ b/redash/handlers/api.py @@ -2,7 +2,8 @@ from flask_restful import Api from werkzeug.wrappers import Response -from redash.handlers.alerts import (AlertListResource, AlertResource, +from redash.handlers.alerts import (AlertListResource, + AlertResource, AlertMuteResource, AlertSubscriptionListResource, AlertSubscriptionResource) from redash.handlers.base import org_scoped_rule @@ -75,6 +76,7 @@ def json_representation(data, code, headers=None): api.add_org_resource(AlertResource, '/api/alerts/', endpoint='alert') +api.add_org_resource(AlertMuteResource, '/api/alerts//mute', endpoint='alert_mute') api.add_org_resource(AlertSubscriptionListResource, '/api/alerts//subscriptions', endpoint='alert_subscriptions') api.add_org_resource(AlertSubscriptionResource, '/api/alerts//subscriptions/', endpoint='alert_subscription') api.add_org_resource(AlertListResource, '/api/alerts', endpoint='alerts') diff --git a/redash/models/__init__.py b/redash/models/__init__.py index 60aaecdb9a..9f17a47d68 100644 --- a/redash/models/__init__.py +++ b/redash/models/__init__.py @@ -890,6 +890,10 @@ def custom_subject(self): def groups(self): return self.query_rel.groups + @property + def muted(self): + return self.options.get('muted', False) + def generate_slug(ctx): slug = utils.slugify(ctx.current_parameters['name']) diff --git a/redash/tasks/alerts.py b/redash/tasks/alerts.py index 37dce53126..ea2d26fb48 100644 --- a/redash/tasks/alerts.py +++ b/redash/tasks/alerts.py @@ -46,4 +46,8 @@ def check_alerts_for_query(query_id): logger.debug("Skipping notification (previous state was unknown and now it's ok).") continue + if alert.muted: + logger.debug("Skipping notification (alert muted).") + continue + notify_subscriptions(alert, new_state) diff --git a/tests/tasks/test_alerts.py b/tests/tasks/test_alerts.py index 38b160da25..14a330d2d1 100644 --- a/tests/tasks/test_alerts.py +++ b/tests/tasks/test_alerts.py @@ -25,6 +25,15 @@ def test_doesnt_notify_when_nothing_changed(self): self.assertFalse(redash.tasks.alerts.notify_subscriptions.called) + def test_doesnt_notify_when_muted(self): + redash.tasks.alerts.notify_subscriptions = MagicMock() + Alert.evaluate = MagicMock(return_value=Alert.TRIGGERED_STATE) + + alert = self.factory.create_alert(options={"muted": True}) + check_alerts_for_query(alert.query_id) + + self.assertFalse(redash.tasks.alerts.notify_subscriptions.called) + class TestNotifySubscriptions(BaseTestCase): def test_calls_notify_for_subscribers(self):