Alert page - migrate to React and redesign (#4153)

This commit is contained in:
Ran Byron 2019-10-07 19:15:06 +03:00 committed by GitHub
parent 2f42b8154c
commit 69dc761c60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 2104 additions and 538 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -60,6 +60,11 @@
font-weight: normal;
}
.ant-select-dropdown-menu-item em {
color: @input-color-placeholder;
font-size: 11px;
}
// Fix for disabled button styles inside Tooltip component.
// Tooltip wraps disabled buttons with `<span>` and moves all styles
// and classes to that `<span>`. This resets all button styles and
@ -362,4 +367,10 @@
border-radius: 50%;
}
}
// for form items that contain text
&.form-item-line-height-normal .@{form-prefix-cls}-item-control {
line-height: 20px;
margin-top: 9px;
}
}

View File

@ -1,44 +1,53 @@
.alert {
padding: 15px;
.alert-page h3 {
flex-grow: 1;
span {
cursor: pointer;
input {
margin: -0.2em 0;
width: 100%;
min-width: 170px;
}
}
.alert-dismissable,
.alert-dismissible {
padding-right: 44px;
.btn-create-alert[disabled] {
display: block;
margin-top: -20px;
}
.alert-inverse {
.alert-variant(@alert-inverse-bg; @alert-inverse-border; @alert-inverse-text);
.alert-state {
border-bottom: 1px solid @input-border;
padding-bottom: 30px;
.alert-state-indicator {
text-transform: uppercase;
font-size: 14px;
padding: 5px 8px;
}
.alert-link {
color: #fff !important;
font-weight: normal !important;
text-decoration: underline;
.alert-last-triggered {
color: @headings-color;
}
}
.growl-animated {
&.alert-inverse {
box-shadow: 0 0 5px fade(@alert-inverse-bg, 50%);
.alert-query-selector {
min-width: 250px;
width: auto !important;
}
&.alert-info {
box-shadow: 0 0 5px fade(@alert-info-bg, 50%);
// allow form item labels to gracefully break line
.alert-form-item label {
white-space: initial;
padding-right: 8px;
line-height: 21px;
&::after {
margin-right: 0 !important;
}
}
&.alert-success {
box-shadow: 0 0 5px fade(@alert-success-bg, 50%);
}
&.alert-warning {
box-shadow: 0 0 5px fade(@alert-warning-bg, 50%);
}
&.alert-danger {
box-shadow: 0 0 5px fade(@alert-danger-bg, 50%);
}
.alert-actions {
flex-grow: 1;
display: flex;
justify-content: flex-end;
align-items: center;
margin-right: -15px;
}

View File

@ -36,6 +36,7 @@
-----------------------------------------------------------*/
@input-height-base: 35px;
@input-color: #595959;
@input-color-placeholder: #b4b4b4;
@border-radius-base: 2px;
@border-color-base: #E8E8E8;

View File

@ -222,6 +222,10 @@ text.slicetext {
}
}
.warning-icon-danger {
color: @red !important;
}
// page
.page-header--new .btn-favourite, .page-header--new .btn-archive {
font-size: 19px;

View File

@ -76,6 +76,8 @@
.font-size(20, 8px, 8);
.f-inherit { font-size: inherit !important; }
/* --------------------------------------------------------
Font Weight

View File

@ -1,22 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import { react2angular } from 'react2angular';
import { currentUser, clientConfig } from '@/services/auth';
import cx from 'classnames';
import { clientConfig, currentUser } from '@/services/auth';
import Tooltip from 'antd/lib/tooltip';
import Alert from 'antd/lib/alert';
import { HelpTrigger } from '@/components/HelpTrigger';
export function EmailSettingsWarning({ featureName }) {
return (clientConfig.mailSettingsMissing && currentUser.isAdmin) ? (
<p className="alert alert-danger">
{`It looks like your mail server isn't configured. Make sure to configure it for the ${featureName} to work.`}
</p>
) : null;
export default function EmailSettingsWarning({ featureName, className, mode, adminOnly }) {
if (!clientConfig.mailSettingsMissing) {
return null;
}
if (adminOnly && !currentUser.isAdmin) {
return null;
}
const message = (
<span>
Your mail server isn&apos;t configured correctly, and is needed for {featureName} to work.{' '}
<HelpTrigger type="MAIL_CONFIG" className="f-inherit" />
</span>
);
if (mode === 'icon') {
return (
<Tooltip title={message}>
<i className={cx('fa fa-exclamation-triangle', className)} />
</Tooltip>
);
}
return (
<Alert message={message} type="error" className={className} />
);
}
EmailSettingsWarning.propTypes = {
featureName: PropTypes.string.isRequired,
className: PropTypes.string,
mode: PropTypes.oneOf(['alert', 'icon']),
adminOnly: PropTypes.bool,
};
export default function init(ngModule) {
ngModule.component('emailSettingsWarning', react2angular(EmailSettingsWarning));
}
init.init = true;
EmailSettingsWarning.defaultProps = {
className: null,
mode: 'alert',
adminOnly: false,
};

View File

@ -68,6 +68,18 @@ export const TYPES = {
'/user-guide/querying/query-results-data-source',
'Guide: Help Setting up Query Results',
],
ALERT_SETUP: [
'/user-guide/alerts/setting-up-an-alert',
'Guide: Setting Up a New Alert',
],
MAIL_CONFIG: [
'/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 {

View File

@ -146,11 +146,13 @@ export function QuerySelector(props) {
notFoundContent={null}
filterOption={false}
defaultActiveFirstOption={false}
className={props.className}
data-test="QuerySelector"
>
{searchResults && searchResults.map((q) => {
const disabled = q.is_draft;
return (
<Option value={q.id} key={q.id} disabled={disabled}>
<Option value={q.id} key={q.id} disabled={disabled} className="query-selector-result" data-test={`QueryId${q.id}`}>
{q.name}{' '}
<QueryTagsControl isDraft={q.is_draft} tags={q.tags} className={cx('inline-tags-control', { disabled })} />
</Option>
@ -161,7 +163,7 @@ export function QuerySelector(props) {
}
return (
<React.Fragment>
<span data-test="QuerySelector">
{selectedQuery ? (
<Input value={selectedQuery.name} suffix={clearIcon} readOnly />
) : (
@ -175,7 +177,7 @@ export function QuerySelector(props) {
<div className="scrollbox" style={{ maxHeight: '50vh', marginTop: 15 }}>
{searchResults && renderResults()}
</div>
</React.Fragment>
</span>
);
}
@ -183,12 +185,14 @@ QuerySelector.propTypes = {
onChange: PropTypes.func.isRequired,
selectedQuery: PropTypes.object, // eslint-disable-line react/forbid-prop-types
type: PropTypes.oneOf(['select', 'default']),
className: PropTypes.string,
disabled: PropTypes.bool,
};
QuerySelector.defaultProps = {
selectedQuery: null,
type: 'default',
className: null,
disabled: false,
};

View File

@ -1,10 +1,11 @@
import { filter, debounce, find } from 'lodash';
import { filter, debounce, find, isEmpty, size } from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Modal from 'antd/lib/modal';
import Input from 'antd/lib/input';
import List from 'antd/lib/list';
import Button from 'antd/lib/button';
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
import { BigMessage } from '@/components/BigMessage';
@ -29,6 +30,9 @@ class SelectItemsDialog extends React.Component {
// right list; args/results save as for `renderItem`. if not specified - `renderItem` will be used
renderStagedItem: PropTypes.func,
save: PropTypes.func, // (selectedItems[]) => Promise<any>
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
extraFooterContent: PropTypes.node,
showCount: PropTypes.bool,
};
static defaultProps = {
@ -37,8 +41,11 @@ class SelectItemsDialog extends React.Component {
selectedItemsTitle: 'Selected items',
itemKey: item => item.id,
renderItem: () => '',
renderStagedItem: null, // use `renderItem` by default
renderStagedItem: null, // hidden by default
save: items => items,
width: '80%',
extraFooterContent: null,
showCount: false,
};
state = {
@ -108,7 +115,7 @@ class SelectItemsDialog extends React.Component {
renderItem(item, isStagedList) {
const { renderItem, renderStagedItem } = this.props;
const isSelected = this.isSelected(item);
const render = isStagedList ? (renderStagedItem || renderItem) : renderItem;
const render = isStagedList ? renderStagedItem : renderItem;
const { content, className, isDisabled } = render(item, { isSelected });
@ -123,23 +130,29 @@ class SelectItemsDialog extends React.Component {
}
render() {
const { dialog, dialogTitle, inputPlaceholder, selectedItemsTitle } = this.props;
const { dialog, dialogTitle, inputPlaceholder } = this.props;
const { selectedItemsTitle, renderStagedItem, width, showCount } = this.props;
const { loading, saveInProgress, items, selected } = this.state;
const hasResults = items.length > 0;
return (
<Modal
{...dialog.props}
width="80%"
className="select-items-dialog"
width={width}
title={dialogTitle}
okText="Save"
okButtonProps={{
loading: saveInProgress,
disabled: selected.length === 0,
}}
onOk={() => this.save()}
footer={(
<div className="d-flex align-items-center">
<span className="flex-fill m-r-5" style={{ textAlign: 'left', color: 'rgba(0, 0, 0, 0.5)' }}>{this.props.extraFooterContent}</span>
<Button onClick={dialog.dismiss}>Cancel</Button>
<Button onClick={() => this.save()} loading={saveInProgress} disabled={selected.length === 0} type="primary">
Save
{showCount && !isEmpty(selected) ? ` (${size(selected)})` : null}
</Button>
</div>
)}
>
<div className="d-flex align-items-center m-b-10">
<div className="w-50 m-r-10">
<div className="flex-fill">
<Input.Search
defaultValue={this.state.searchTerm}
onChange={event => this.search(event.target.value)}
@ -147,13 +160,15 @@ class SelectItemsDialog extends React.Component {
autoFocus
/>
</div>
<div className="w-50 m-l-10">
{renderStagedItem && (
<div className="w-50 m-l-20">
<h5 className="m-0">{selectedItemsTitle}</h5>
</div>
)}
</div>
<div className="d-flex align-items-stretch" style={{ minHeight: '30vh', maxHeight: '50vh' }}>
<div className="w-50 m-r-10 scrollbox">
<div className="flex-fill scrollbox">
{loading && <LoadingState className="" />}
{!loading && !hasResults && (
<BigMessage icon="fa-search" message="No items match your search." className="" />
@ -166,7 +181,8 @@ class SelectItemsDialog extends React.Component {
/>
)}
</div>
<div className="w-50 m-l-10 scrollbox">
{renderStagedItem && (
<div className="w-50 m-l-20 scrollbox">
{(selected.length > 0) && (
<List
size="small"
@ -175,6 +191,7 @@ class SelectItemsDialog extends React.Component {
/>
)}
</div>
)}
</div>
</Modal>
);

View File

@ -1,27 +0,0 @@
<div class="p-5">
<h4>Notifications</h4>
<div>
<ui-select ng-model="newSubscription.destination" ng-disabled="destinations.length == 0">
<ui-select-match><span ng-bind-html="destinationsDisplay($select.selected)"></span></ui-select-match>
<ui-select-choices repeat="d in destinations">
<span ng-bind-html="destinationsDisplay(d)"></span>
</ui-select-choices>
</ui-select>
</div>
<div class="m-t-5">
<button class="btn btn-default" ng-click="saveSubscriber()" ng-disabled="destinations.length == 0" style="width:50%;">Add</button>
<span class="pull-right m-t-5">
<a href="destinations/new" ng-if="currentUser.isAdmin">Create New Destination</a>
</span>
</div>
<hr/>
<div>
<div class="list-group-item" ng-repeat="subscriber in subscribers">
<span ng-bind-html="destinationsDisplay(subscriber)"></span>
<button class="btn btn-xs btn-danger pull-right" ng-click="unsubscribe(subscriber)" ng-if="currentUser.isAdmin || currentUser.id == subscriber.user.id">Remove</button>
</div>
</div>
</div>

View File

@ -1,116 +0,0 @@
import { includes, without, compact } from 'lodash';
import notification from '@/services/notification';
import template from './alert-subscriptions.html';
function controller($scope, $q, $sce, currentUser, AlertSubscription, Destination) {
'ngInject';
$scope.newSubscription = {};
$scope.subscribers = [];
$scope.destinations = [];
$scope.currentUser = currentUser;
$q
.all([
Destination.query().$promise,
AlertSubscription.query({ alertId: $scope.alertId }).$promise,
])
.then((responses) => {
const destinations = responses[0];
const subscribers = responses[1];
const mapF = s => s.destination && s.destination.id;
const subscribedDestinations = compact(subscribers.map(mapF));
const subscribedUsers = compact(subscribers.map(s => !s.destination && s.user.id));
$scope.destinations = destinations.filter(d => !includes(subscribedDestinations, d.id));
if (!includes(subscribedUsers, currentUser.id)) {
$scope.destinations.unshift({ user: { name: currentUser.name } });
}
$scope.newSubscription.destination = $scope.destinations[0];
$scope.subscribers = subscribers;
});
$scope.destinationsDisplay = (d) => {
if (!d) {
return '';
}
let destination = d;
if (d.destination) {
destination = destination.destination;
} else if (destination.user) {
destination = {
name: `${d.user.name} (Email)`,
icon: 'fa-envelope',
type: 'user',
};
}
return $sce.trustAsHtml(`<i class="fa ${destination.icon}"></i>&nbsp;${destination.name}`);
};
$scope.saveSubscriber = () => {
const sub = new AlertSubscription({ alert_id: $scope.alertId });
if ($scope.newSubscription.destination.id) {
sub.destination_id = $scope.newSubscription.destination.id;
}
sub.$save(
() => {
notification.success('Subscribed.');
$scope.subscribers.push(sub);
$scope.destinations = without($scope.destinations, $scope.newSubscription.destination);
if ($scope.destinations.length > 0) {
$scope.newSubscription.destination = $scope.destinations[0];
} else {
$scope.newSubscription.destination = undefined;
}
},
() => {
notification.error('Failed saving subscription.');
},
);
};
$scope.unsubscribe = (subscriber) => {
const destination = subscriber.destination;
const user = subscriber.user;
subscriber.$delete(
() => {
notification.success('Unsubscribed');
$scope.subscribers = without($scope.subscribers, subscriber);
if (destination) {
$scope.destinations.push(destination);
} else if (user.id === currentUser.id) {
$scope.destinations.push({ user: { name: currentUser.name } });
}
if ($scope.destinations.length === 1) {
$scope.newSubscription.destination = $scope.destinations[0];
}
},
() => {
notification.error('Failed unsubscribing.');
},
);
};
}
export default function init(ngModule) {
ngModule.directive('alertSubscriptions', () => ({
restrict: 'E',
replace: true,
scope: {
alertId: '=',
},
template,
controller,
}));
}
init.init = true;

View File

@ -2,24 +2,26 @@ import React from 'react';
import PropTypes from 'prop-types';
import Tooltip from 'antd/lib/tooltip';
export default function ListItemAddon({ isSelected, isStaged, alreadyInGroup }) {
export default function ListItemAddon({ isSelected, isStaged, alreadyInGroup, deselectedIcon }) {
if (isStaged) {
return <i className="fa fa-remove" />;
}
if (alreadyInGroup) {
return <Tooltip title="Already in this group"><i className="fa fa-check" /></Tooltip>;
return <Tooltip title="Already selected"><i className="fa fa-check" /></Tooltip>;
}
return isSelected ? <i className="fa fa-check" /> : <i className="fa fa-angle-double-right" />;
return isSelected ? <i className="fa fa-check" /> : <i className={`fa ${deselectedIcon}`} />;
}
ListItemAddon.propTypes = {
isSelected: PropTypes.bool,
isStaged: PropTypes.bool,
alreadyInGroup: PropTypes.bool,
deselectedIcon: PropTypes.string,
};
ListItemAddon.defaultProps = {
isSelected: false,
isStaged: false,
alreadyInGroup: false,
deselectedIcon: 'fa-angle-double-right',
};

View File

@ -87,6 +87,54 @@ export const UserProfile = PropTypes.shape({
isDisabled: PropTypes.bool,
});
export const Destination = PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
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,
options: PropTypes.shape({
column: PropTypes.string,
op: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}).isRequired,
});
function checkMoment(isRequired, props, propName, componentName) {
const value = props[propName];
const isRequiredValid = isRequired && (value !== null && value !== undefined) && moment.isMoment(value);

View File

@ -0,0 +1,247 @@
import React from 'react';
import { react2angular } from 'react2angular';
import { head, includes, trim, template } from 'lodash';
import { $route } from '@/services/ng';
import { currentUser } from '@/services/auth';
import navigateTo from '@/services/navigateTo';
import notification from '@/services/notification';
import { Alert as AlertService } from '@/services/alert';
import { Query as QueryService } from '@/services/query';
import LoadingState from '@/components/items-list/components/LoadingState';
import AlertView from './AlertView';
import AlertEdit from './AlertEdit';
import AlertNew from './AlertNew';
import Modal from 'antd/lib/modal';
import { routesToAngularRoutes } from '@/lib/utils';
import PromiseRejectionError from '@/lib/promise-rejection-error';
const MODES = {
NEW: 0,
VIEW: 1,
EDIT: 2,
};
const defaultNameBuilder = template('<%= query.name %>: <%= options.column %> <%= options.op %> <%= options.value %>');
export function getDefaultName(alert) {
if (!alert.query) {
return 'New Alert';
}
return defaultNameBuilder(alert);
}
class AlertPage extends React.Component {
_isMounted = false;
state = {
alert: null,
queryResult: null,
pendingRearm: null,
canEdit: false,
mode: null,
}
componentDidMount() {
this._isMounted = true;
const { mode } = $route.current.locals;
this.setState({ mode });
if (mode === MODES.NEW) {
this.setState({
alert: new AlertService({
options: {
op: 'greater than',
value: 1,
},
}),
pendingRearm: 0,
canEdit: true,
});
} else {
const { alertId } = $route.current.params;
AlertService.get({ id: alertId }).$promise.then((alert) => {
if (this._isMounted) {
const canEdit = currentUser.canEdit(alert);
// force view mode if can't edit
if (!canEdit) {
this.setState({ mode: MODES.VIEW });
notification.warn(
'You cannot edit this alert',
'You do not have sufficient permissions to edit this alert, and have been redirected to the view-only page.',
{ duration: 0 },
);
}
this.setState({ alert, canEdit, pendingRearm: alert.rearm });
this.onQuerySelected(alert.query);
}
}).catch((err) => {
if (this._isMounted) {
throw new PromiseRejectionError(err);
}
});
}
}
componentWillUnmount() {
this._isMounted = false;
}
save = () => {
const { alert, pendingRearm } = this.state;
alert.name = trim(alert.name) || getDefaultName(alert);
alert.rearm = pendingRearm || null;
return alert.$save().then(() => {
notification.success('Saved.');
navigateTo(`/alerts/${alert.id}`, true, false);
this.setState({ mode: MODES.VIEW });
}).catch(() => {
notification.error('Failed saving alert.');
});
};
onQuerySelected = (query) => {
this.setState(({ alert }) => ({
alert: Object.assign(alert, { query }),
queryResult: null,
}));
if (query) {
// get cached result for column names and values
new QueryService(query).getQueryResultPromise().then((queryResult) => {
if (this._isMounted) {
this.setState({ queryResult });
let { column } = this.state.alert.options;
const columns = queryResult.getColumnNames();
// default to first column name if none chosen, or irrelevant in current query
if (!column || !includes(columns, column)) {
column = head(queryResult.getColumnNames());
}
this.setAlertOptions({ column });
}
});
}
}
onNameChange = (name) => {
const { alert } = this.state;
this.setState({
alert: Object.assign(alert, { name }),
});
}
onRearmChange = (pendingRearm) => {
this.setState({ pendingRearm });
}
setAlertOptions = (obj) => {
const { alert } = this.state;
const options = { ...alert.options, ...obj };
this.setState({
alert: Object.assign(alert, { options }),
});
}
delete = () => {
const { alert } = this.state;
const doDelete = () => {
alert.$delete(() => {
notification.success('Alert deleted successfully.');
navigateTo('/alerts');
}, () => {
notification.error('Failed deleting alert.');
});
};
Modal.confirm({
title: 'Delete Alert',
content: 'Are you sure you want to delete this alert?',
okText: 'Delete',
okType: 'danger',
onOk: doDelete,
maskClosable: true,
autoFocusButton: null,
});
}
edit = () => {
const { id } = this.state.alert;
navigateTo(`/alerts/${id}/edit`, true, false);
this.setState({ mode: MODES.EDIT });
}
cancel = () => {
const { id } = this.state.alert;
navigateTo(`/alerts/${id}`, true, false);
this.setState({ mode: MODES.VIEW });
}
render() {
const { alert } = this.state;
if (!alert) {
return <LoadingState className="m-t-30" />;
}
const { queryResult, mode, canEdit, pendingRearm } = this.state;
const commonProps = {
alert,
queryResult,
pendingRearm,
delete: this.delete,
save: this.save,
onQuerySelected: this.onQuerySelected,
onRearmChange: this.onRearmChange,
onNameChange: this.onNameChange,
onCriteriaChange: this.setAlertOptions,
onNotificationTemplateChange: this.setAlertOptions,
};
return (
<div className="container alert-page">
{mode === MODES.NEW && <AlertNew {...commonProps} />}
{mode === MODES.VIEW && <AlertView canEdit={canEdit} onEdit={this.edit} {...commonProps} />}
{mode === MODES.EDIT && <AlertEdit cancel={this.cancel} {...commonProps} />}
</div>
);
}
}
export default function init(ngModule) {
ngModule.component('alertPage', react2angular(AlertPage));
return routesToAngularRoutes([
{
path: '/alerts/new',
title: 'New Alert',
mode: MODES.NEW,
},
{
path: '/alerts/:alertId',
title: 'Alert',
mode: MODES.VIEW,
},
{
path: '/alerts/:alertId/edit',
title: 'Alert',
mode: MODES.EDIT,
},
], {
template: '<alert-page></alert-page>',
controller($scope, $exceptionHandler) {
'ngInject';
$scope.handleError = $exceptionHandler;
},
});
}
init.init = true;

View File

@ -0,0 +1,147 @@
import React from 'react';
import PropTypes from 'prop-types';
import { HelpTrigger } from '@/components/HelpTrigger';
import { Alert as AlertType } from '@/components/proptypes';
import Form from 'antd/lib/form';
import Button from 'antd/lib/button';
import Icon from 'antd/lib/icon';
import Dropdown from 'antd/lib/dropdown';
import Menu from 'antd/lib/menu';
import Title from './components/Title';
import Criteria from './components/Criteria';
import NotificationTemplate from './components/NotificationTemplate';
import Rearm from './components/Rearm';
import Query from './components/Query';
import HorizontalFormItem from './components/HorizontalFormItem';
const spinnerIcon = <i className="fa fa-spinner fa-pulse m-r-5" />;
export default class AlertEdit extends React.Component {
_isMounted = false;
state = {
saving: false,
canceling: false,
}
componentDidMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
}
save = () => {
this.setState({ saving: true });
this.props.save().catch(() => {
if (this._isMounted) {
this.setState({ saving: false });
}
});
}
cancel = () => {
this.setState({ canceling: true });
this.props.cancel();
};
render() {
const { alert, queryResult, pendingRearm, onNotificationTemplateChange } = this.props;
const { onQuerySelected, onNameChange, onRearmChange, onCriteriaChange } = this.props;
const { query, name, options } = alert;
const { saving, canceling } = this.state;
return (
<>
<Title name={name} alert={alert} onChange={onNameChange} editMode>
<Button className="m-r-5" onClick={() => this.cancel()}>
{canceling ? spinnerIcon : <i className="fa fa-times m-r-5" />}
Cancel
</Button>
<Button type="primary" onClick={() => this.save()}>
{saving ? spinnerIcon : <i className="fa fa-check m-r-5" />}
Save Changes
</Button>
<Dropdown
className="m-l-5"
trigger={['click']}
placement="bottomRight"
overlay={(
<Menu>
<Menu.Item>
<a onClick={this.props.delete}>Delete Alert</a>
</Menu.Item>
</Menu>
)}
>
<Button><Icon type="ellipsis" rotate={90} /></Button>
</Dropdown>
</Title>
<div className="row bg-white tiled p-20">
<div className="d-flex">
<Form className="flex-fill">
<HorizontalFormItem label="Query">
<Query query={query} queryResult={queryResult} onChange={onQuerySelected} editMode />
</HorizontalFormItem>
{queryResult && options && (
<>
<HorizontalFormItem label="Trigger when" className="alert-criteria">
<Criteria
columnNames={queryResult.getColumnNames()}
resultValues={queryResult.getData()}
alertOptions={options}
onChange={onCriteriaChange}
editMode
/>
</HorizontalFormItem>
<HorizontalFormItem label="When triggered, send notification">
<Rearm value={pendingRearm || 0} onChange={onRearmChange} editMode />
</HorizontalFormItem>
<HorizontalFormItem label="Template">
<NotificationTemplate
alert={alert}
query={query}
columnNames={queryResult.getColumnNames()}
resultValues={queryResult.getData()}
subject={options.custom_subject}
setSubject={subject => onNotificationTemplateChange({ custom_subject: subject })}
body={options.custom_body}
setBody={body => onNotificationTemplateChange({ custom_body: body })}
/>
</HorizontalFormItem>
</>
)}
</Form>
<HelpTrigger className="f-13" type="ALERT_SETUP">
Setup Instructions <i className="fa fa-question-circle" />
</HelpTrigger>
</div>
</div>
</>
);
}
}
AlertEdit.propTypes = {
alert: AlertType.isRequired,
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types,
pendingRearm: PropTypes.number,
delete: PropTypes.func.isRequired,
save: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired,
onQuerySelected: PropTypes.func.isRequired,
onNameChange: PropTypes.func.isRequired,
onCriteriaChange: PropTypes.func.isRequired,
onRearmChange: PropTypes.func.isRequired,
onNotificationTemplateChange: PropTypes.func.isRequired,
};
AlertEdit.defaultProps = {
queryResult: null,
pendingRearm: null,
};

View File

@ -0,0 +1,109 @@
import React from 'react';
import PropTypes from 'prop-types';
import { HelpTrigger } from '@/components/HelpTrigger';
import { Alert as AlertType } from '@/components/proptypes';
import Form from 'antd/lib/form';
import Button from 'antd/lib/button';
import Title from './components/Title';
import Criteria from './components/Criteria';
import NotificationTemplate from './components/NotificationTemplate';
import Rearm from './components/Rearm';
import Query from './components/Query';
import HorizontalFormItem from './components/HorizontalFormItem';
export default class AlertNew extends React.Component {
state = {
saving: false,
};
save = () => {
this.setState({ saving: true });
this.props.save().catch(() => {
this.setState({ saving: false });
});
}
render() {
const { alert, queryResult, pendingRearm, onNotificationTemplateChange } = this.props;
const { onQuerySelected, onNameChange, onRearmChange, onCriteriaChange } = this.props;
const { query, name, options } = alert;
const { saving } = this.state;
return (
<>
<Title alert={alert} name={name} onChange={onNameChange} editMode />
<div className="row bg-white tiled p-20">
<div className="d-flex">
<Form className="flex-fill">
<div className="m-b-30">
Start by selecting the query that you would like to monitor using the search bar.
<br />
Keep in mind that Alerts do not work with queries that use parameters.
</div>
<HorizontalFormItem label="Query">
<Query query={query} queryResult={queryResult} onChange={onQuerySelected} editMode />
</HorizontalFormItem>
{queryResult && options && (
<>
<HorizontalFormItem label="Trigger when" className="alert-criteria">
<Criteria
columnNames={queryResult.getColumnNames()}
resultValues={queryResult.getData()}
alertOptions={options}
onChange={onCriteriaChange}
editMode
/>
</HorizontalFormItem>
<HorizontalFormItem label="When triggered, send notification">
<Rearm value={pendingRearm || 0} onChange={onRearmChange} editMode />
</HorizontalFormItem>
<HorizontalFormItem label="Template">
<NotificationTemplate
alert={alert}
query={query}
columnNames={queryResult.getColumnNames()}
resultValues={queryResult.getData()}
subject={options.custom_subject}
setSubject={subject => onNotificationTemplateChange({ custom_subject: subject })}
body={options.custom_body}
setBody={body => onNotificationTemplateChange({ custom_body: body })}
/>
</HorizontalFormItem>
</>
)}
<HorizontalFormItem>
<Button type="primary" onClick={this.save} disabled={!query} className="btn-create-alert">
{saving && <i className="fa fa-spinner fa-pulse m-r-5" />}
Create Alert
</Button>
</HorizontalFormItem>
</Form>
<HelpTrigger className="f-13" type="ALERT_SETUP">
Setup Instructions <i className="fa fa-question-circle" />
</HelpTrigger>
</div>
</div>
</>
);
}
}
AlertNew.propTypes = {
alert: AlertType.isRequired,
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types,
pendingRearm: PropTypes.number,
onQuerySelected: PropTypes.func.isRequired,
save: PropTypes.func.isRequired,
onNameChange: PropTypes.func.isRequired,
onRearmChange: PropTypes.func.isRequired,
onCriteriaChange: PropTypes.func.isRequired,
onNotificationTemplateChange: PropTypes.func.isRequired,
};
AlertNew.defaultProps = {
queryResult: null,
pendingRearm: null,
};

View File

@ -0,0 +1,135 @@
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { TimeAgo } from '@/components/TimeAgo';
import { Alert as AlertType } from '@/components/proptypes';
import Form from 'antd/lib/form';
import Button from 'antd/lib/button';
import Icon from 'antd/lib/icon';
import Dropdown from 'antd/lib/dropdown';
import Menu from 'antd/lib/menu';
import Tooltip from 'antd/lib/tooltip';
import Title from './components/Title';
import Criteria from './components/Criteria';
import Rearm from './components/Rearm';
import Query from './components/Query';
import AlertDestinations from './components/AlertDestinations';
import HorizontalFormItem from './components/HorizontalFormItem';
import { STATE_CLASS } from '../alerts/AlertsList';
function AlertState({ state, lastTriggered }) {
return (
<div className="alert-state">
<span className={`alert-state-indicator label ${STATE_CLASS[state]}`}>Status: {state}</span>
{state === 'unknown' && (
<div className="ant-form-explain">
Alert condition has not been evaluated.
</div>
)}
{lastTriggered && (
<div className="ant-form-explain">
Last triggered <span className="alert-last-triggered"><TimeAgo date={lastTriggered} /></span>
</div>
)}
</div>
);
}
AlertState.propTypes = {
state: PropTypes.string.isRequired,
lastTriggered: PropTypes.string,
};
AlertState.defaultProps = {
lastTriggered: null,
};
export default class AlertView extends React.Component {
render() {
const { alert, queryResult, canEdit, onEdit } = this.props;
const { query, name, options, rearm } = alert;
return (
<>
<Title name={name} alert={alert}>
<Tooltip title={canEdit ? '' : 'You do not have sufficient permissions to edit this alert'}>
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}><i className="fa fa-edit m-r-5" />Edit</Button>
<Dropdown
className={cx('m-l-5', { disabled: !canEdit })}
trigger={[canEdit ? 'click' : undefined]}
placement="bottomRight"
overlay={(
<Menu>
<Menu.Item>
<a onClick={this.props.delete}>Delete Alert</a>
</Menu.Item>
</Menu>
)}
>
<Button><Icon type="ellipsis" rotate={90} /></Button>
</Dropdown>
</Tooltip>
</Title>
<div className="row bg-white tiled p-20">
<div className="d-flex col-md-8">
<Form className="flex-fill">
<HorizontalFormItem>
<AlertState state={alert.state} lastTriggered={alert.last_triggered_at} />
</HorizontalFormItem>
<HorizontalFormItem label="Query">
<Query query={query} queryResult={queryResult} onChange={this.onQuerySelected} />
</HorizontalFormItem>
{query && !queryResult && (
<HorizontalFormItem className="m-t-30">
<Icon type="loading" className="m-r-5" /> Loading query data
</HorizontalFormItem>
)}
{queryResult && options && (
<>
<HorizontalFormItem label="Trigger when" className="alert-criteria">
<Criteria
columnNames={queryResult.getColumnNames()}
resultValues={queryResult.getData()}
alertOptions={options}
/>
</HorizontalFormItem>
<HorizontalFormItem label="Notifications" className="form-item-line-height-normal">
<Rearm value={rearm || 0} />
<br />
Set to {options.custom_subject || options.custom_body ? 'custom' : 'default'} notification template.
</HorizontalFormItem>
</>
)}
</Form>
</div>
<div className="col-md-4">
<h4>Destinations{' '}
<Tooltip title="Open Alert Destinations page in a new tab.">
<a href="/destinations" target="_blank">
<i className="fa fa-external-link f-13" />
</a>
</Tooltip>
</h4>
<AlertDestinations alertId={alert.id} />
</div>
</div>
</>
);
}
}
AlertView.propTypes = {
alert: AlertType.isRequired,
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types,
canEdit: PropTypes.bool.isRequired,
delete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
};
AlertView.defaultProps = {
queryResult: null,
};

View File

@ -1,95 +0,0 @@
<div class="container">
<page-header title="$ctrl.alert.name || $ctrl.getDefaultName() || 'New Alert'"></page-header>
<email-settings-warning feature-name="'alert emails'"></email-settings-warning>
<div class="container">
<div class="row bg-white tiled p-10">
<div class="col-md-8">
<form name="alertForm" class="form">
<div class="form-group">
<label>Query</label>
<query-selector type="'select'" selected-query="$ctrl.alert.query" on-change="$ctrl.onQuerySelected" disabled="!$ctrl.canEdit" />
</div>
<div class="form-group" ng-show="$ctrl.selectedQuery">
<label>Name</label>
<input type="string" placeholder="{{$ctrl.getDefaultName()}}" class="form-control" ng-model="$ctrl.alert.name" ng-disabled="!$ctrl.canEdit">
</div>
<div ng-show="$ctrl.queryResult" class="form-horizontal">
<div class="form-group">
<label class="control-label col-md-2">Value column</label>
<div class="col-md-4">
<select ng-options="name for name in $ctrl.queryResult.getColumnNames()" ng-model="$ctrl.alert.options.column"
class="form-control" ng-disabled="!$ctrl.canEdit"></select>
</div>
<label class="control-label col-md-2">Value</label>
<div class="col-md-4">
<p class="form-control-static">{{$ctrl.queryResult.getData()[0][$ctrl.alert.options.column]}}</p>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-2">Op</label>
<div class="col-md-4">
<select ng-options="name for name in $ctrl.ops" ng-model="$ctrl.alert.options.op" class="form-control" ng-disabled="!$ctrl.canEdit"></select>
</div>
<label class="control-label col-md-2">Reference</label>
<div class="col-md-4">
<input type="number" step="any" class="form-control" ng-model="$ctrl.alert.options.value" placeholder="reference value" ng-disabled="!$ctrl.canEdit"
required/>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-2">Rearm seconds</label>
<div class="col-md-4">
<input type="number" class="form-control" ng-model="$ctrl.alert.rearm" ng-disabled="!$ctrl.canEdit"/>
</div>
</div>
</div>
<div class="form-group" ng-show="$ctrl.selectedQuery && $ctrl.showExtendedOptions">
<label>Custom subject</label>
<input type="string" class="form-control" ng-model="$ctrl.alert.options.subject" ng-disabled="!$ctrl.canEdit">
</div>
<div ng-show="$ctrl.selectedQuery && $ctrl.showExtendedOptions">
<div class="form-group" ng-show="$ctrl.selectedQuery">
<label>Description template</label>
<i class="fa fa-question-circle" uib-tooltip="{{$ctrl.alertTemplate.helpMessage}}"></i>
<div class="row bg-white p-b-5" ng-if="$ctrl.canEdit" resizable r-directions="['bottom']" r-height="300" style="min-height:100px;">
<div ui-ace="$ctrl.alertTemplate.editorOptions" ng-model="$ctrl.alert.options.template"></div>
</div>
</div>
<div class="form-group" ng-if="$ctrl.canEdit">
<button class="btn btn-default" ng-click="$ctrl.preview()">Preview</button>
<label for="show-as-html">Show As HTML</label>
<input type="checkbox" name="show-as-html" ng-model="$ctrl.showAsHTML">
</div>
<div class="panel panel-default" ng-if="$ctrl.alert.preview">
<div class="panel-heading">
<label for="hide-preview">Hide Preview</label>
<input type="checkbox" name="hide-preview" ng-model="$ctrl.hidePreview">
</div>
<div class="panel-body" ng-if="$ctrl.hidePreview == false">
<div ng-if="!$ctrl.showAsHTML">
<div ng-bind-html="$ctrl.alert.preview"></div>
</div>
<div ng-if="$ctrl.showAsHTML">
<div ng-bind-html="$ctrl.alert.previewHTML"></div>
</div>
</div>
<div class="panel-footer"></div>
</div>
</div>
<div class="form-group" ng-if="$ctrl.canEdit">
<button class="btn btn-primary" ng-disabled="!alertForm.$valid" ng-click="$ctrl.saveChanges()">Save</button>
<button class="btn btn-danger" ng-if="$ctrl.alert.id" ng-click="$ctrl.delete()">Delete</button>
</div>
</form>
</div>
<div class="col-md-4" ng-if="$ctrl.alert.id">
<alert-subscriptions alert-id="$ctrl.alert.id"></alert-subscriptions>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,212 @@
import React from 'react';
import PropTypes from 'prop-types';
import { without, find, isEmpty, includes, map } from 'lodash';
import SelectItemsDialog from '@/components/SelectItemsDialog';
import { Destination as DestinationType, UserProfile as UserType } from '@/components/proptypes';
import { Destination as DestinationService, IMG_ROOT } from '@/services/destination';
import { AlertSubscription } from '@/services/alert-subscription';
import { $q } from '@/services/ng';
import { clientConfig, currentUser } from '@/services/auth';
import notification from '@/services/notification';
import ListItemAddon from '@/components/groups/ListItemAddon';
import EmailSettingsWarning from '@/components/EmailSettingsWarning';
import Icon from 'antd/lib/icon';
import Tooltip from 'antd/lib/tooltip';
import Switch from 'antd/lib/switch';
import Button from 'antd/lib/button';
import './AlertDestinations.less';
const USER_EMAIL_DEST_ID = -1;
function normalizeSub(sub) {
if (!sub.destination) {
sub.destination = {
id: USER_EMAIL_DEST_ID,
name: sub.user.email,
icon: 'DEPRECATED',
type: 'email',
};
}
return sub;
}
function ListItem({ destination: { name, type }, user, unsubscribe }) {
const canUnsubscribe = currentUser.isAdmin || currentUser.id === user.id;
return (
<li className="destination-wrapper">
<img src={`${IMG_ROOT}/${type}.png`} className="destination-icon" alt={name} />
<span className="flex-fill">{name}</span>
{type === 'email' && <EmailSettingsWarning className="destination-warning" featureName="alert emails" mode="icon" />}
{canUnsubscribe && (
<Tooltip title="Remove" mouseEnterDelay={0.5}>
<Icon type="close" className="remove-button" onClick={unsubscribe} />
</Tooltip>
)}
</li>
);
}
ListItem.propTypes = {
destination: DestinationType.isRequired,
user: UserType.isRequired,
unsubscribe: PropTypes.func.isRequired,
};
export default class AlertDestinations extends React.Component {
static propTypes = {
alertId: PropTypes.number.isRequired,
}
state = {
dests: [],
subs: null,
}
componentDidMount() {
const { alertId } = this.props;
$q
.all([
DestinationService.query().$promise, // get all destinations
AlertSubscription.query({ alertId }).$promise, // get subcriptions per alert
])
.then(([dests, subs]) => {
subs = subs.map(normalizeSub);
this.setState({ dests, subs });
});
}
showAddAlertSubDialog = () => {
const { dests, subs } = this.state;
SelectItemsDialog.showModal({
width: 570,
showCount: true,
extraFooterContent: (
<>
<i className="fa fa-info-circle" />{' '}
Create new destinations in{' '}
<Tooltip title="Opens page in a new tab.">
<a href="/destinations/new" target="_blank">Alert Destinations</a>
</Tooltip>
</>
),
dialogTitle: 'Add Existing Alert Destinations',
inputPlaceholder: 'Search destinations...',
searchItems: (searchTerm) => {
searchTerm = searchTerm.toLowerCase();
const filtered = dests.filter(d => isEmpty(searchTerm) || includes(d.name.toLowerCase(), searchTerm));
return Promise.resolve(filtered);
},
renderItem: (item, { isSelected }) => {
const alreadyInGroup = !!find(subs, s => s.destination.id === item.id);
return {
content: (
<div className="destination-wrapper">
<img src={`${IMG_ROOT}/${item.type}.png`} className="destination-icon" alt={name} />
<span className="flex-fill">{item.name}</span>
<ListItemAddon isSelected={isSelected} alreadyInGroup={alreadyInGroup} deselectedIcon="fa-plus" />
</div>
),
isDisabled: alreadyInGroup,
className: isSelected || alreadyInGroup ? 'selected' : '',
};
},
save: (items) => {
const promises = map(items, item => this.subscribe(item));
return Promise.all(promises).then(() => {
notification.success('Subscribed.');
}).catch(() => {
notification.error('Failed saving subscription.');
});
},
});
}
onUserEmailToggle = (sub) => {
if (sub) {
this.unsubscribe(sub);
} else {
this.subscribe();
}
}
subscribe = (dest) => {
const { alertId } = this.props;
const sub = new AlertSubscription({ alert_id: alertId });
if (dest) {
sub.destination_id = dest.id;
}
return sub.$save(() => {
const { subs } = this.state;
this.setState({
subs: [...subs, normalizeSub(sub)],
});
});
}
unsubscribe = (sub) => {
sub.$delete(
() => {
// not showing subscribe notification cause it's redundant here
const { subs } = this.state;
this.setState({
subs: without(subs, sub),
});
},
() => {
notification.error('Failed unsubscribing.');
},
);
};
render() {
if (!this.props.alertId) {
return null;
}
const { subs } = this.state;
const currentUserEmailSub = find(subs, {
destination: { id: USER_EMAIL_DEST_ID },
user: { id: currentUser.id },
});
const filteredSubs = without(subs, currentUserEmailSub);
const { mailSettingsMissing } = clientConfig;
return (
<div className="alert-destinations" data-test="AlertDestinations">
<Tooltip title="Click to add an existing &quot;Alert Destination&quot;" mouseEnterDelay={0.5}>
<Button data-test="ShowAddAlertSubDialog" type="primary" size="small" className="add-button" onClick={this.showAddAlertSubDialog}>
<i className="fa fa-plus f-12 m-r-5" /> Add
</Button>
</Tooltip>
<ul>
<li className="destination-wrapper">
<i className="destination-icon fa fa-envelope" />
<span className="flex-fill">{ currentUser.email }</span>
<EmailSettingsWarning className="destination-warning" featureName="alert emails" mode="icon" />
{!mailSettingsMissing && (
<Switch
size="small"
className="toggle-button"
checked={!!currentUserEmailSub}
loading={!subs}
onChange={() => this.onUserEmailToggle(currentUserEmailSub)}
data-test="UserEmailToggle"
/>
)}
</li>
{filteredSubs.map(s => <ListItem key={s.id} unsubscribe={() => this.unsubscribe(s)} {...s} />)}
</ul>
</div>
);
}
}

View File

@ -0,0 +1,71 @@
.alert-destinations {
ul {
list-style: none;
padding: 0;
margin-top: 15px;
li {
color: rgba(0, 0, 0, 0.85);
height: 46px;
border-bottom: 1px solid #e8e8e8;
.remove-button {
cursor: pointer;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.toggle-button {
margin: 0 7px;
}
.destination-warning {
color: #f5222d;
&:last-child {
margin-right: 14px;
}
}
}
}
.add-button {
position: absolute;
right: 14px;
top: 9px;
}
}
.destination-wrapper {
padding-left: 8px;
display: flex;
align-items: center;
min-height: 38px;
width: 100%;
.select-items-dialog & {
padding: 0;
}
.destination-icon {
height: 25px;
width: 25px;
margin: 2px 5px 0 0;
filter: grayscale(1);
&.fa {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.select-items-dialog & {
width: 35px;
height: 35px;
}
}
}

View File

@ -0,0 +1,128 @@
import React from 'react';
import PropTypes from 'prop-types';
import { head, includes, toString, map } from 'lodash';
import Input from 'antd/lib/input';
import Icon from 'antd/lib/icon';
import Select from 'antd/lib/select';
import { AlertOptions as AlertOptionsType } from '@/components/proptypes';
import './Criteria.less';
const CONDITIONS = {
'greater than': '>',
'less than': '<',
equals: '=',
};
const VALID_STRING_CONDITIONS = ['equals'];
function DisabledInput({ children, minWidth }) {
return (
<div className="criteria-disabled-input" style={{ minWidth }}>{children}</div>
);
}
DisabledInput.propTypes = {
children: PropTypes.node.isRequired,
minWidth: PropTypes.number.isRequired,
};
export default function Criteria({ columnNames, resultValues, alertOptions, onChange, editMode }) {
const columnValue = resultValues && head(resultValues)[alertOptions.column];
const invalidMessage = (() => {
// bail if condition is valid for strings
if (includes(VALID_STRING_CONDITIONS, alertOptions.op)) {
return null;
}
if (isNaN(alertOptions.value)) {
return 'Value column type doesn\'t match threshold type.';
}
if (isNaN(columnValue)) {
return 'Value column isn\'t supported by condition type.';
}
return null;
})();
const columnHint = (
<small className="alert-criteria-hint">
Top row value is <code className="p-0">{toString(columnValue) || 'unknown'}</code>
</small>
);
return (
<div data-test="Criteria">
<div className="input-title">
<span>Value column</span>
{editMode ? (
<Select
value={alertOptions.column}
onChange={column => onChange({ column })}
dropdownMatchSelectWidth={false}
style={{ minWidth: 100 }}
>
{columnNames.map(name => (
<Select.Option key={name}>{name}</Select.Option>
))}
</Select>
) : (
<DisabledInput minWidth={70}>{alertOptions.column}</DisabledInput>
)}
</div>
<div className="input-title">
<span>Condition</span>
{editMode ? (
<Select
value={alertOptions.op}
onChange={op => onChange({ op })}
optionLabelProp="label"
dropdownMatchSelectWidth={false}
style={{ width: 55 }}
>
{map(CONDITIONS, (v, k) => (
<Select.Option value={k} label={v} key={k}>
{v} &nbsp;{k}
</Select.Option>
))}
</Select>
) : (
<DisabledInput minWidth={50}>{CONDITIONS[alertOptions.op]}</DisabledInput>
)}
</div>
<div className="input-title">
<span>Threshold</span>
{editMode ? (
<Input style={{ width: 90 }} value={alertOptions.value} onChange={e => onChange({ value: e.target.value })} />
) : (
<DisabledInput minWidth={50}>{alertOptions.value}</DisabledInput>
)}
</div>
<div className="ant-form-explain">
{columnHint}
<br />
{invalidMessage && (
<small>
<Icon type="warning" theme="filled" className="warning-icon-danger" /> {invalidMessage}
</small>
)}
</div>
</div>
);
}
Criteria.propTypes = {
columnNames: PropTypes.arrayOf(PropTypes.string).isRequired,
resultValues: PropTypes.arrayOf(PropTypes.object).isRequired,
alertOptions: AlertOptionsType.isRequired,
onChange: PropTypes.func,
editMode: PropTypes.bool,
};
Criteria.defaultProps = {
onChange: () => {},
editMode: false,
};

View File

@ -0,0 +1,52 @@
.alert-criteria {
margin-top: 40px !important;
.input-title {
display: inline-block;
width: auto;
margin-right: 8px;
margin-bottom: 17px; // assure no collision when not enough room for horizontal layout
position: relative;
vertical-align: middle;
& > span {
position: absolute;
top: -16px;
left: 0;
line-height: normal;
font-size: 10px;
& + * {
vertical-align: top;
}
}
}
.ant-form-explain {
margin-top: -17px; // compensation for .input-title bottom margin
}
.alert-criteria-hint code {
overflow: hidden;
max-width: 100px;
display: inline-block;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
}
.criteria-disabled-input {
text-align: center;
line-height: 35px;
height: 35px;
max-width: 200px;
background: #ececec;
border: 1px solid #d9d9d9;
border-radius: 2px;
margin-bottom: 5px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding: 0 8px;
}

View File

@ -0,0 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import Form from 'antd/lib/form';
export default function HorizontalFormItem({ children, label, className, ...props }) {
const labelCol = { span: 4 };
const wrapperCol = { span: 16 };
if (!label) {
wrapperCol.offset = 4;
}
className = cx('alert-form-item', className);
return (
<Form.Item labelCol={labelCol} wrapperCol={wrapperCol} label={label} className={className} {...props}>
{ children }
</Form.Item>
);
}
HorizontalFormItem.propTypes = {
children: PropTypes.node,
label: PropTypes.string,
className: PropTypes.string,
};
HorizontalFormItem.defaultProps = {
children: null,
label: null,
className: null,
};

View File

@ -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 (
<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;

View File

@ -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;
}
}

View File

@ -0,0 +1,66 @@
import React from 'react';
import PropTypes from 'prop-types';
import { QuerySelector } from '@/components/QuerySelector';
import { SchedulePhrase } from '@/components/queries/SchedulePhrase';
import { Query as QueryType } from '@/components/proptypes';
import Tooltip from 'antd/lib/tooltip';
import Icon from 'antd/lib/icon';
import './Query.less';
export default function QueryFormItem({ query, queryResult, onChange, editMode }) {
const queryHint = query && query.schedule ? (
<small>
Scheduled to refresh <i className="alert-query-schedule"><SchedulePhrase schedule={query.schedule} isNew={false} /></i>
</small>
) : (
<small>
<Icon type="warning" theme="filled" className="warning-icon-danger" />{' '}
This query has no <i>refresh schedule</i>.{' '}
<Tooltip title="A query schedule is not necessary but is highly recommended for alerts. An Alert without a query schedule will only send notifications if a user in your organization manually executes this query."><a>Why it&apos;s recommended <Icon type="question-circle" theme="twoTone" /></a></Tooltip>
</small>
);
return (
<>
{editMode ? (
<QuerySelector
onChange={onChange}
selectedQuery={query}
className="alert-query-selector"
type="select"
/>
) : (
<Tooltip title="Open query in a new tab.">
<a href={`/queries/${query.id}`} target="_blank" rel="noopener noreferrer" className="alert-query-link">
{query.name}<i className="fa fa-external-link" />
</a>
</Tooltip>
)}
<div className="ant-form-explain">
{query && queryHint}
</div>
{query && !queryResult && (
<div className="m-t-30">
<Icon type="loading" className="m-r-5" /> Loading query data
</div>
)}
</>
);
}
QueryFormItem.propTypes = {
query: QueryType,
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onChange: PropTypes.func,
editMode: PropTypes.bool,
};
QueryFormItem.defaultProps = {
query: null,
queryResult: null,
onChange: () => {},
editMode: false,
};

View File

@ -0,0 +1,17 @@
.alert-query-link {
font-weight: 600;
text-decoration: underline;
display: inline-block;
position: relative;
top: -1px;
.fa-external-link {
vertical-align: text-bottom;
margin-left: 4px;
}
}
.alert-query-schedule {
font-style: italic;
text-transform: lowercase;
}

View File

@ -0,0 +1,138 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { toLower, isNumber } from 'lodash';
import InputNumber from 'antd/lib/input-number';
import Select from 'antd/lib/select';
import './Rearm.less';
const DURATIONS = [
['Second', 1],
['Minute', 60],
['Hour', 3600],
['Day', 86400],
['Week', 604800],
];
function RearmByDuration({ value, onChange, editMode }) {
const [durationIdx, setDurationIdx] = useState();
const [count, setCount] = useState();
useEffect(() => {
for (let i = DURATIONS.length - 1; i >= 0; i -= 1) {
const [, durValue] = DURATIONS[i];
if (value % durValue === 0) {
setDurationIdx(i);
setCount(value / durValue);
break;
}
}
}, []);
if (!isNumber(count) || !isNumber(durationIdx)) {
return null;
}
const onChangeCount = (newCount) => {
setCount(newCount);
onChange(newCount * DURATIONS[durationIdx][1]);
};
const onChangeIdx = (newIdx) => {
setDurationIdx(newIdx);
onChange(count * DURATIONS[newIdx][1]);
};
const plural = count !== 1 ? 's' : '';
if (editMode) {
return (
<>
<InputNumber value={count} onChange={onChangeCount} min={1} precision={0} />
<Select value={durationIdx} onChange={onChangeIdx}>
{DURATIONS.map(([name], idx) => (
<Select.Option value={idx} key={name}>{name}{plural}</Select.Option>
))}
</Select>
</>
);
}
const [name] = DURATIONS[durationIdx];
return count + ' ' + toLower(name) + plural;
}
RearmByDuration.propTypes = {
onChange: PropTypes.func,
value: PropTypes.number.isRequired,
editMode: PropTypes.bool.isRequired,
};
RearmByDuration.defaultProps = {
onChange: () => {},
};
function RearmEditor({ value, onChange }) {
const [selected, setSelected] = useState(value < 2 ? value : 2);
const _onChange = (newSelected) => {
setSelected(newSelected);
onChange(newSelected < 2 ? newSelected : 3600);
};
return (
<div className="alert-rearm">
<Select optionLabelProp="label" defaultValue={selected || 0} dropdownMatchSelectWidth={false} onChange={_onChange}>
<Select.Option value={0} label="Just once">Just once <em>until back to normal</em></Select.Option>
<Select.Option value={1} label="Each time alert is evaluated">
Each time alert is evaluated <em>until back to normal</em>
</Select.Option>
<Select.Option value={2} label="At most every">At most every ... <em>when alert is evaluated</em></Select.Option>
</Select>
{selected === 2 && value && <RearmByDuration value={value} onChange={onChange} editMode />}
</div>
);
}
RearmEditor.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.number.isRequired,
};
function RearmViewer({ value }) {
let phrase = '';
switch (value) {
case 0:
phrase = 'just once, until back to normal';
break;
case 1:
phrase = 'each time alert is evaluated, until back to normal';
break;
default:
phrase = (
<>at most every <RearmByDuration value={value} editMode={false} />, when alert is evaluated</>
);
}
return <span>Notifications are sent {phrase}.</span>;
}
RearmViewer.propTypes = {
value: PropTypes.number.isRequired,
};
export default function Rearm({ editMode, ...props }) {
return editMode ? <RearmEditor {...props} /> : <RearmViewer {...props} />;
}
Rearm.propTypes = {
onChange: PropTypes.func,
value: PropTypes.number.isRequired,
editMode: PropTypes.bool,
};
Rearm.defaultProps = {
onChange: null,
editMode: false,
};

View File

@ -0,0 +1,8 @@
.alert-rearm > * {
vertical-align: top;
margin-right: 8px !important;
&.ant-select {
width: auto !important;
}
}

View File

@ -0,0 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import Input from 'antd/lib/input';
import { getDefaultName } from '../Alert';
import { Alert as AlertType } from '@/components/proptypes';
export default function Title({ alert, editMode, name, onChange, children }) {
const defaultName = getDefaultName(alert);
return (
<div className="p-b-10 m-l-0 m-r-0 page-header--new">
<div className="d-flex">
<h3>
{editMode && alert.query ? (
<Input className="f-inherit" placeholder={defaultName} value={name} onChange={e => onChange(e.target.value)} />
) : name || defaultName }
</h3>
<span className="alert-actions">{ children }</span>
</div>
</div>
);
}
Title.propTypes = {
alert: AlertType.isRequired,
name: PropTypes.string,
children: PropTypes.node,
onChange: PropTypes.func,
editMode: PropTypes.bool,
};
Title.defaultProps = {
name: null,
children: null,
onChange: null,
editMode: false,
};

View File

@ -1,138 +0,0 @@
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, $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');
}
this.trustAsHtml = html => $sce.trustAsHtml(html);
this.onQuerySelected = (item) => {
this.alert.query = item;
this.selectedQuery = new Query(item);
this.selectedQuery.getQueryResultPromise().then((result) => {
this.queryResult = result;
this.alert.options.column = this.alert.options.column || result.getColumnNames()[0];
});
$scope.$applyAsync();
};
if (this.alertId === 'new') {
this.alert = new Alert({ options: {} });
this.canEdit = true;
} else {
this.alert = Alert.get({ id: this.alertId }, (alert) => {
this.onQuerySelected(alert.query);
this.canEdit = currentUser.canEdit(this.alert);
});
}
this.ops = ['greater than', 'less than', 'equals'];
this.selectedQuery = null;
const defaultNameBuilder = templateBuilder('<%= query.name %>: <%= options.column %> <%= options.op %> <%= options.value %>');
this.getDefaultName = () => {
if (!this.alert.query) {
return undefined;
}
return defaultNameBuilder(this.alert);
};
this.searchQueries = (term) => {
if (!term || term.length < 3) {
return;
}
Query.query({ q: term }, (results) => {
this.queries = results.results;
});
};
this.saveChanges = () => {
if (this.alert.name === undefined || this.alert.name === '') {
this.alert.name = this.getDefaultName();
}
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.');
if (this.alertId === 'new') {
$location.path(`/alerts/${alert.id}`).replace();
}
},
() => {
notification.error('Failed saving alert.');
},
);
};
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(() => {
notification.success('Alert destination deleted successfully.');
navigateTo('/alerts', true);
}, () => {
notification.error('Failed deleting alert.');
});
};
Modal.confirm({
title: 'Delete Alert',
content: 'Are you sure you want to delete this alert?',
okText: 'Delete',
okType: 'danger',
onOk: doDelete,
maskClosable: true,
autoFocusButton: null,
});
};
}
export default function init(ngModule) {
ngModule.component('alertPage', {
template,
controller: AlertCtrl,
});
return {
'/alerts/:alertId': {
template: '<alert-page></alert-page>',
title: 'Alerts',
},
};
}
init.init = true;

View File

@ -16,7 +16,7 @@ import ItemsTable, { Columns } from '@/components/items-list/components/ItemsTab
import { Alert } from '@/services/alert';
import { routesToAngularRoutes } from '@/lib/utils';
const STATE_CLASS = {
export const STATE_CLASS = {
unknown: 'label-warning',
ok: 'label-success',
triggered: 'label-danger',

View File

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { react2angular } from 'react2angular';
import { EmailSettingsWarning } from '@/components/EmailSettingsWarning';
import EmailSettingsWarning from '@/components/EmailSettingsWarning';
import UserEdit from '@/components/users/UserEdit';
import UserShow from '@/components/users/UserShow';
import LoadingState from '@/components/items-list/components/LoadingState';
@ -47,7 +47,7 @@ class UserProfile extends React.Component {
const UserComponent = canEdit ? UserEdit : UserShow;
return (
<React.Fragment>
<EmailSettingsWarning featureName="invite emails" />
<EmailSettingsWarning featureName="invite emails" className="m-b-20" adminOnly />
<div className="row">
{user ? <UserComponent user={user} /> : <LoadingState className="" />}
</div>

View File

@ -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, '&quot;')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n|\r/g, '<br>');
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;
},
};
}
}

View File

@ -0,0 +1,23 @@
import { createQuery } from '../../support/redash-api';
describe('Create Alert', () => {
beforeEach(() => {
cy.login();
});
it('renders the initial page and takes a screenshot', () => {
cy.visit('/alerts/new');
cy.getByTestId('QuerySelector').should('exist');
cy.percySnapshot('Create Alert initial screen');
});
it('selects query and takes a screenshot', () => {
createQuery({ name: 'Create Alert Query' }).then(({ id: queryId }) => {
cy.visit('/alerts/new');
cy.getByTestId('QuerySelector').click().type('Create Alert Query');
cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
cy.getByTestId('Criteria').should('exist');
cy.percySnapshot('Create Alert second screen');
});
});
});

View File

@ -0,0 +1,45 @@
import { createAlert, createQuery } from '../../support/redash-api';
describe('Edit Alert', () => {
beforeEach(() => {
cy.login();
});
it('renders the page and takes a screenshot', () => {
createQuery({ query: 'select 1 as col_name' })
.then(({ id: queryId }) => createAlert(queryId, { column: 'col_name' }))
.then(({ id: alertId }) => {
cy.visit(`/alerts/${alertId}/edit`);
cy.getByTestId('Criteria').should('exist');
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);
});
});
});

View File

@ -0,0 +1,107 @@
import { createAlert, createQuery, createUser, addDestinationSubscription } from '../../support/redash-api';
describe('View Alert', () => {
beforeEach(function () {
cy.login();
createQuery({ query: 'select 1 as col_name' })
.then(({ id: queryId }) => createAlert(queryId, { column: 'col_name' }))
.then(({ id: alertId }) => {
this.alertId = alertId;
this.alertUrl = `/alerts/${alertId}`;
});
});
it('renders the page and takes a screenshot', function () {
cy.visit(this.alertUrl);
cy.getByTestId('Criteria').should('exist');
cy.percySnapshot('View Alert screen');
});
it('allows adding new destinations', function () {
cy.visit(this.alertUrl);
cy.getByTestId('AlertDestinations').contains('Test Email Destination').should('not.exist');
cy.server();
cy.route('GET', 'api/destinations').as('Destinations');
cy.route('GET', 'api/alerts/*/subscriptions').as('Subscriptions');
cy.visit(this.alertUrl);
cy.wait(['@Destinations', '@Subscriptions']);
cy.getByTestId('ShowAddAlertSubDialog').click();
cy.contains('Test Email Destination').click();
cy.contains('Save').click();
cy.getByTestId('AlertDestinations').contains('Test Email Destination').should('exist');
});
describe('Alert Destination permissions', () => {
before(() => {
cy.login();
createUser({
name: 'Example User',
email: 'user@redash.io',
password: 'password',
});
});
it('hides remove button from non-author', function () {
cy.server();
cy.route('GET', 'api/alerts/*/subscriptions').as('Subscriptions');
cy.logout()
.then(() => cy.login()) // as admin
.then(() => addDestinationSubscription(this.alertId, 'Test Email Destination'))
.then(() => {
cy.visit(this.alertUrl);
// verify remove button appears for author
cy.wait(['@Subscriptions']);
cy.getByTestId('AlertDestinations')
.contains('Test Email Destination')
.parent()
.within(() => {
cy.get('.remove-button').as('RemoveButton').should('exist');
});
return cy.logout().then(() => cy.login('user@redash.io', 'password'));
})
.then(() => {
cy.visit(this.alertUrl);
// verify remove button not shown for non-author
cy.wait(['@Subscriptions']);
cy.get('@RemoveButton').should('not.exist');
});
});
it('shows remove button for non-author admin', function () {
cy.server();
cy.route('GET', 'api/alerts/*/subscriptions').as('Subscriptions');
cy.logout()
.then(() => cy.login('user@redash.io', 'password'))
.then(() => addDestinationSubscription(this.alertId, 'Test Email Destination'))
.then(() => {
cy.visit(this.alertUrl);
// verify remove button appears for author
cy.wait(['@Subscriptions']);
cy.getByTestId('AlertDestinations')
.contains('Test Email Destination')
.parent().within(() => {
cy.get('.remove-button').as('RemoveButton').should('exist');
});
return cy.logout().then(() => cy.login()); // as admin
})
.then(() => {
cy.visit(this.alertUrl);
// verify remove button also appears for admin
cy.wait(['@Subscriptions']);
cy.get('@RemoveButton').should('exist');
});
});
});
});

View File

@ -32,4 +32,15 @@ exports.seedData = [
type: 'pg',
},
},
{
route: '/api/destinations',
type: 'json',
data: {
name: 'Test Email Destination',
options: {
addresses: 'test@example.com',
},
type: 'email',
},
},
];

View File

@ -1,6 +1,6 @@
/* global cy, Cypress */
const { extend, get, merge } = Cypress._;
const { extend, get, merge, find } = Cypress._;
export function createDashboard(name) {
return cy.request('POST', 'api/dashboards', { name })
@ -78,3 +78,79 @@ export function addWidget(dashboardId, visualizationId, options = {}) {
return body;
});
}
export function createAlert(queryId, options = {}, name) {
const defaultOptions = {
column: '?column?',
op: 'greater than',
rearm: 0,
value: 1,
};
const data = {
query_id: queryId,
name: name || 'Alert for query ' + queryId,
options: merge(defaultOptions, options),
};
return cy.request('POST', 'api/alerts', data)
.then(({ body }) => {
const id = get(body, 'id');
assert.isDefined(id, 'Alert api call returns alert id');
return body;
});
}
export function createUser({ name, email, password }) {
return cy.request({
method: 'POST',
url: 'api/users',
body: { name, email },
failOnStatusCode: false,
})
.then((xhr) => {
const { status, body } = xhr;
if (status < 200 || status > 400) {
throw new Error(xhr);
}
if (status === 400 && body.message === 'Email already taken.') {
// all is good, do nothing
return;
}
const id = get(body, 'id');
assert.isDefined(id, 'User api call returns user id');
return cy.request({
url: body.invite_link,
method: 'POST',
form: true,
body: { password },
});
});
}
export function getDestinations() {
return cy.request('GET', 'api/destinations')
.then(({ body }) => body);
}
export function addDestinationSubscription(alertId, destinationName) {
return getDestinations()
.then((destinations) => {
const destination = find(destinations, { name: destinationName });
if (!destination) {
throw new Error('Destination not found');
}
return cy.request('POST', `api/alerts/${alertId}/subscriptions`, {
alert_id: alertId,
destination_id: destination.id,
});
})
.then(({ body }) => {
const id = get(body, 'id');
assert.isDefined(id, 'Subscription api call returns subscription id');
return body;
});
}

View File

@ -38,20 +38,22 @@ class ChatWork(BaseDestination):
# 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'
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_name=alert.name,
new_state=new_state.upper(),
alert_url=alert_url,
query_url=query_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}

View File

@ -35,12 +35,13 @@ class Email(BaseDestination):
if not recipients:
logging.warning("No emails given. Skipping send.")
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>.
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
logging.debug("Notifying: %s", recipients)
try:

View File

@ -70,12 +70,12 @@ class HangoutsChat(BaseDestination):
]
}
if alert.template:
if alert.custom_body:
data["cards"][0]["sections"].append({
"widgets": [
{
"textParagraph": {
"text": alert.render_template()
"text": alert.custom_body
}
}
]

View File

@ -35,24 +35,26 @@ class Mattermost(BaseDestination):
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')
if options.get('icon_url'): payload['icon_url'] = options.get('icon_url')
if options.get('channel'): payload['channel'] = options.get('channel')
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')
try:
resp = requests.post(options.get('url'), data=json_dumps(payload), timeout=5.0)

View File

@ -60,8 +60,8 @@ class PagerDuty(BaseDestination):
}
}
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'

View File

@ -52,11 +52,10 @@ class Slack(BaseDestination):
"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:

View File

@ -39,7 +39,7 @@ class Webhook(BaseDestination):
'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'}

View File

@ -23,7 +23,7 @@ from redash.destinations import (get_configuration_schema_for_destination_type,
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 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
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):