mirror of
https://github.com/valitydev/redash.git
synced 2024-11-07 09:28:51 +00:00
Alert page - migrate to React and redesign (#4153)
This commit is contained in:
parent
2f42b8154c
commit
69dc761c60
Binary file not shown.
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 16 KiB |
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
.alert-inverse {
|
||||
.alert-variant(@alert-inverse-bg; @alert-inverse-border; @alert-inverse-text);
|
||||
.btn-create-alert[disabled] {
|
||||
display: block;
|
||||
margin-top: -20px;
|
||||
}
|
||||
|
||||
.alert-link {
|
||||
color: #fff !important;
|
||||
font-weight: normal !important;
|
||||
text-decoration: underline;
|
||||
.alert-state {
|
||||
border-bottom: 1px solid @input-border;
|
||||
padding-bottom: 30px;
|
||||
|
||||
.alert-state-indicator {
|
||||
text-transform: uppercase;
|
||||
font-size: 14px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.alert-last-triggered {
|
||||
color: @headings-color;
|
||||
}
|
||||
}
|
||||
|
||||
.growl-animated {
|
||||
&.alert-inverse {
|
||||
box-shadow: 0 0 5px fade(@alert-inverse-bg, 50%);
|
||||
}
|
||||
|
||||
&.alert-info {
|
||||
box-shadow: 0 0 5px fade(@alert-info-bg, 50%);
|
||||
}
|
||||
.alert-query-selector {
|
||||
min-width: 250px;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
&.alert-success {
|
||||
box-shadow: 0 0 5px fade(@alert-success-bg, 50%);
|
||||
}
|
||||
// allow form item labels to gracefully break line
|
||||
.alert-form-item label {
|
||||
white-space: initial;
|
||||
padding-right: 8px;
|
||||
line-height: 21px;
|
||||
|
||||
&.alert-warning {
|
||||
box-shadow: 0 0 5px fade(@alert-warning-bg, 50%);
|
||||
}
|
||||
|
||||
&.alert-danger {
|
||||
box-shadow: 0 0 5px fade(@alert-danger-bg, 50%);
|
||||
&::after {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-right: -15px;
|
||||
}
|
@ -36,6 +36,7 @@
|
||||
-----------------------------------------------------------*/
|
||||
@input-height-base: 35px;
|
||||
@input-color: #595959;
|
||||
@input-color-placeholder: #b4b4b4;
|
||||
@border-radius-base: 2px;
|
||||
@border-color-base: #E8E8E8;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -76,6 +76,8 @@
|
||||
|
||||
.font-size(20, 8px, 8);
|
||||
|
||||
.f-inherit { font-size: inherit !important; }
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Font Weight
|
||||
|
@ -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'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,
|
||||
};
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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">
|
||||
<h5 className="m-0">{selectedItemsTitle}</h5>
|
||||
</div>
|
||||
{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,15 +181,17 @@ class SelectItemsDialog extends React.Component {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-50 m-l-10 scrollbox">
|
||||
{(selected.length > 0) && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={selected}
|
||||
renderItem={item => this.renderItem(item, true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{renderStagedItem && (
|
||||
<div className="w-50 m-l-20 scrollbox">
|
||||
{(selected.length > 0) && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={selected}
|
||||
renderItem={item => this.renderItem(item, true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
@ -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>
|
@ -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> ${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;
|
@ -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',
|
||||
};
|
||||
|
@ -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);
|
||||
|
247
client/app/pages/alert/Alert.jsx
Normal file
247
client/app/pages/alert/Alert.jsx
Normal 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;
|
147
client/app/pages/alert/AlertEdit.jsx
Normal file
147
client/app/pages/alert/AlertEdit.jsx
Normal 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,
|
||||
};
|
109
client/app/pages/alert/AlertNew.jsx
Normal file
109
client/app/pages/alert/AlertNew.jsx
Normal 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,
|
||||
};
|
135
client/app/pages/alert/AlertView.jsx
Normal file
135
client/app/pages/alert/AlertView.jsx
Normal 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,
|
||||
};
|
@ -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>
|
212
client/app/pages/alert/components/AlertDestinations.jsx
Normal file
212
client/app/pages/alert/components/AlertDestinations.jsx
Normal 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 "Alert Destination"" 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>
|
||||
);
|
||||
}
|
||||
}
|
71
client/app/pages/alert/components/AlertDestinations.less
Normal file
71
client/app/pages/alert/components/AlertDestinations.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
128
client/app/pages/alert/components/Criteria.jsx
Normal file
128
client/app/pages/alert/components/Criteria.jsx
Normal 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} {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,
|
||||
};
|
52
client/app/pages/alert/components/Criteria.less
Normal file
52
client/app/pages/alert/components/Criteria.less
Normal 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;
|
||||
}
|
32
client/app/pages/alert/components/HorizontalFormItem.jsx
Normal file
32
client/app/pages/alert/components/HorizontalFormItem.jsx
Normal 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,
|
||||
};
|
122
client/app/pages/alert/components/NotificationTemplate.jsx
Normal file
122
client/app/pages/alert/components/NotificationTemplate.jsx
Normal 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;
|
36
client/app/pages/alert/components/NotificationTemplate.less
Normal file
36
client/app/pages/alert/components/NotificationTemplate.less
Normal 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;
|
||||
}
|
||||
}
|
66
client/app/pages/alert/components/Query.jsx
Normal file
66
client/app/pages/alert/components/Query.jsx
Normal 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'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,
|
||||
};
|
17
client/app/pages/alert/components/Query.less
Normal file
17
client/app/pages/alert/components/Query.less
Normal 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;
|
||||
}
|
138
client/app/pages/alert/components/Rearm.jsx
Normal file
138
client/app/pages/alert/components/Rearm.jsx
Normal 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,
|
||||
};
|
8
client/app/pages/alert/components/Rearm.less
Normal file
8
client/app/pages/alert/components/Rearm.less
Normal file
@ -0,0 +1,8 @@
|
||||
.alert-rearm > * {
|
||||
vertical-align: top;
|
||||
margin-right: 8px !important;
|
||||
|
||||
&.ant-select {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
38
client/app/pages/alert/components/Title.jsx
Normal file
38
client/app/pages/alert/components/Title.jsx
Normal 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,
|
||||
};
|
@ -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;
|
@ -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',
|
||||
|
@ -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>
|
||||
|
@ -1,41 +0,0 @@
|
||||
// import { $http } from '@/services/ng';
|
||||
import Mustache from 'mustache';
|
||||
|
||||
export default class AlertTemplate {
|
||||
render(alert, queryResult) {
|
||||
const view = {
|
||||
state: alert.state,
|
||||
rows: queryResult.rows,
|
||||
cols: queryResult.columns,
|
||||
};
|
||||
const result = Mustache.render(alert.options.template, view);
|
||||
const escaped = result
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
23
client/cypress/integration/alert/create_alert_spec.js
Normal file
23
client/cypress/integration/alert/create_alert_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
45
client/cypress/integration/alert/edit_alert_spec.js
Normal file
45
client/cypress/integration/alert/edit_alert_spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
107
client/cypress/integration/alert/view_alert_spec.js
Normal file
107
client/cypress/integration/alert/view_alert_spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -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'
|
||||
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.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}
|
||||
|
||||
|
@ -35,12 +35,13 @@ class Email(BaseDestination):
|
||||
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:
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -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)
|
||||
|
@ -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'
|
||||
|
@ -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:
|
||||
|
@ -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'}
|
||||
|
@ -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):
|
||||
|
Loading…
Reference in New Issue
Block a user