mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 09:27:23 -05:00
Merge pull request #1098 from getredash/flexible_notifications
Feature: UI for alert destinations & new destination types
This commit is contained in:
67
migrations/0023_add_notification_destination.py
Normal file
67
migrations/0023_add_notification_destination.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from redash import settings
|
||||
from redash.models import db, NotificationDestination, AlertSubscription, Alert
|
||||
from redash.destinations import get_configuration_schema_for_destination_type
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
with db.database.transaction():
|
||||
|
||||
if not NotificationDestination.table_exists():
|
||||
NotificationDestination.create_table()
|
||||
|
||||
# Update alert subscription fields
|
||||
migrate(
|
||||
migrator.add_column('alert_subscriptions', 'destination_id', AlertSubscription.destination)
|
||||
)
|
||||
|
||||
if settings.WEBHOOK_ENDPOINT:
|
||||
# Have all existing alerts send to webhook if already configured
|
||||
schema = get_configuration_schema_for_destination_type('webhook')
|
||||
conf = {'url': settings.WEBHOOK_ENDPOINT}
|
||||
if settings.WEBHOOK_USERNAME:
|
||||
conf['username'] = settings.WEBHOOK_USERNAME
|
||||
conf['password'] = settings.WEBHOOK_PASSWORD
|
||||
options = ConfigurationContainer(conf, schema)
|
||||
|
||||
webhook = NotificationDestination.create(
|
||||
org=1,
|
||||
user=1,
|
||||
name="Webhook",
|
||||
type="webhook",
|
||||
options=options
|
||||
)
|
||||
|
||||
for alert in Alert.select():
|
||||
AlertSubscription.create(
|
||||
user=1,
|
||||
destination=webhook,
|
||||
alert=alert
|
||||
)
|
||||
|
||||
if settings.HIPCHAT_API_TOKEN:
|
||||
# Have all existing alerts send to HipChat if already configured
|
||||
schema = get_configuration_schema_for_destination_type('hipchat')
|
||||
conf = {'token': settings.HIPCHAT_API_TOKEN,
|
||||
'room_id': settings.HIPCHAT_ROOM_ID}
|
||||
if settings.HIPCHAT_API_URL:
|
||||
conf['url'] = settings.HIPCHAT_API_URL
|
||||
options = ConfigurationContainer(conf, schema)
|
||||
|
||||
hipchat = NotificationDestination.create(
|
||||
org=1,
|
||||
user=1,
|
||||
name="HipChat",
|
||||
type="hipchat",
|
||||
options=options
|
||||
)
|
||||
|
||||
for alert in Alert.select():
|
||||
AlertSubscription.create(
|
||||
user=1,
|
||||
destination=hipchat,
|
||||
alert=alert
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
@@ -74,6 +74,7 @@
|
||||
<script src="/scripts/controllers/dashboard.js"></script>
|
||||
<script src="/scripts/controllers/admin_controllers.js"></script>
|
||||
<script src="/scripts/controllers/data_sources.js"></script>
|
||||
<script src="/scripts/controllers/destinations.js"></script>
|
||||
<script src="/scripts/controllers/query_view.js"></script>
|
||||
<script src="/scripts/controllers/query_source.js"></script>
|
||||
<script src="/scripts/controllers/users.js"></script>
|
||||
@@ -89,7 +90,6 @@
|
||||
<script src="/scripts/visualizations/date_range_selector.js"></script>
|
||||
<script src="/scripts/directives/directives.js"></script>
|
||||
<script src="/scripts/directives/query_directives.js"></script>
|
||||
<script src="/scripts/directives/data_source_directives.js"></script>
|
||||
<script src="/scripts/directives/dashboard_directives.js"></script>
|
||||
<script src="/scripts/filters.js"></script>
|
||||
<script src="/scripts/controllers/alerts.js"></script>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{% extends 'app_layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
<app-header></app-header>
|
||||
<edit-dashboard-form dashboard="newDashboard" id="new_dashboard_dialog"></edit-dashboard-form>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -115,6 +115,15 @@ angular.module('redash', [
|
||||
controller: 'DataSourcesCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/destinations/:destinationId', {
|
||||
templateUrl: '/views/destinations/edit.html',
|
||||
controller: 'DestinationCtrl'
|
||||
});
|
||||
$routeProvider.when('/destinations', {
|
||||
templateUrl: '/views/destinations/list.html',
|
||||
controller: 'DestinationsCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/users/new', {
|
||||
templateUrl: '/views/users/new.html',
|
||||
controller: 'NewUserCtrl'
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
];
|
||||
};
|
||||
|
||||
var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert) {
|
||||
var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert, Destination) {
|
||||
$scope.selectedTab = 'users';
|
||||
$scope.$parent.pageTitle = "Alerts";
|
||||
|
||||
$scope.alertId = $routeParams.alertId;
|
||||
@@ -108,69 +109,109 @@
|
||||
growl.addErrorMessage("Failed saving alert.");
|
||||
});
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
angular.module('redash.directives').directive('alertSubscribers', ['AlertSubscription', function (AlertSubscription) {
|
||||
angular.module('redash.directives').directive('alertSubscriptions', ['$q', '$sce', 'AlertSubscription', 'Destination', 'growl', function ($q, $sce, AlertSubscription, Destination, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
templateUrl: '/views/alerts/subscribers.html',
|
||||
templateUrl: '/views/alerts/alert_subscriptions.html',
|
||||
scope: {
|
||||
'alertId': '='
|
||||
},
|
||||
controller: function ($scope) {
|
||||
$scope.subscribers = AlertSubscription.query({alertId: $scope.alertId});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
$scope.newSubscription = {};
|
||||
$scope.subscribers = [];
|
||||
$scope.destinations = [];
|
||||
$scope.currentUser = currentUser;
|
||||
|
||||
angular.module('redash.directives').directive('subscribeButton', ['AlertSubscription', 'growl', function (AlertSubscription, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
template: '<button class="btn btn-default btn-xs" ng-click="toggleSubscription()"><i ng-class="class"></i></button>',
|
||||
controller: function ($scope) {
|
||||
var updateClass = function() {
|
||||
if ($scope.subscription) {
|
||||
$scope.class = "fa fa-eye-slash";
|
||||
} else {
|
||||
$scope.class = "fa fa-eye";
|
||||
var destinations = Destination.query().$promise;
|
||||
var subscribers = AlertSubscription.query({alertId: $scope.alertId}).$promise;
|
||||
|
||||
$q.all([destinations, subscribers]).then(function(responses) {
|
||||
var destinations = responses[0];
|
||||
var subscribers = responses[1];
|
||||
|
||||
var subscribedDestinations = _.compact(_.map(subscribers, function(s) { return s.destination && s.destination.id }));
|
||||
var subscribedUsers = _.compact(_.map(subscribers, function(s) { if (!s.destination) { return s.user.id } }));
|
||||
|
||||
$scope.destinations = _.filter(destinations, function(d) { return !_.contains(subscribedDestinations, d.id); });
|
||||
|
||||
if (!_.contains(subscribedUsers, currentUser.id)) {
|
||||
$scope.destinations.unshift({user: {name: currentUser.name}});
|
||||
}
|
||||
}
|
||||
|
||||
$scope.subscribers.$promise.then(function() {
|
||||
$scope.subscription = _.find($scope.subscribers, function(subscription) {
|
||||
return (subscription.user.email == currentUser.email);
|
||||
});
|
||||
|
||||
updateClass();
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
$scope.subscribers = subscribers;
|
||||
});
|
||||
|
||||
$scope.toggleSubscription = function() {
|
||||
if ($scope.subscription) {
|
||||
$scope.subscription.$delete(function() {
|
||||
$scope.subscribers = _.without($scope.subscribers, $scope.subscription);
|
||||
$scope.subscription = undefined;
|
||||
updateClass();
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving subscription.");
|
||||
});
|
||||
} else {
|
||||
$scope.subscription = new AlertSubscription({alert_id: $scope.alertId});
|
||||
$scope.subscription.$save(function() {
|
||||
$scope.subscribers.push($scope.subscription);
|
||||
updateClass();
|
||||
}, function() {
|
||||
growl.addErrorMessage("Unsubscription failed.");
|
||||
});
|
||||
$scope.destinationsDisplay = function(destination) {
|
||||
if (!destination) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
if (destination.destination) {
|
||||
destination = destination.destination;
|
||||
} else if (destination.user) {
|
||||
destination = {
|
||||
name: destination.user.name + ' (Email)',
|
||||
icon: 'fa-envelope',
|
||||
type: 'user'
|
||||
};
|
||||
}
|
||||
|
||||
return $sce.trustAsHtml('<i class="fa ' + destination.icon + '"></i> ' + destination.name);
|
||||
};
|
||||
|
||||
$scope.saveSubscriber = function() {
|
||||
var sub = new AlertSubscription({alert_id: $scope.alertId});
|
||||
if ($scope.newSubscription.destination.id) {
|
||||
sub.destination_id = $scope.newSubscription.destination.id;
|
||||
}
|
||||
|
||||
sub.$save(function () {
|
||||
growl.addSuccessMessage("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;
|
||||
}
|
||||
console.log("dests: ", $scope.destinations);
|
||||
}, function (response) {
|
||||
growl.addErrorMessage("Failed saving subscription.");
|
||||
});
|
||||
};
|
||||
|
||||
$scope.unsubscribe = function(subscriber) {
|
||||
var destination = subscriber.destination;
|
||||
var user = subscriber.user;
|
||||
|
||||
subscriber.$delete(function () {
|
||||
growl.addSuccessMessage("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];
|
||||
}
|
||||
|
||||
}, function () {
|
||||
growl.addErrorMessage("Failed unsubscribing.");
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('AlertsCtrl', ['$scope', 'Events', 'Alert', AlertsCtrl])
|
||||
.controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', AlertCtrl])
|
||||
.controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', 'Destination', AlertCtrl])
|
||||
|
||||
})();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
};
|
||||
|
||||
var DataSourceCtrl = function ($scope, $routeParams, $http, $location, Events, DataSource) {
|
||||
var DataSourceCtrl = function ($scope, $routeParams, $http, $location, growl, Events, DataSource) {
|
||||
Events.record(currentUser, "view", "page", "admin/data_source");
|
||||
$scope.$parent.pageTitle = "Data Sources";
|
||||
|
||||
@@ -24,9 +24,21 @@
|
||||
$location.path('/data_sources/' + id).replace();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.delete = function () {
|
||||
Events.record(currentUser, "delete", "datasource", $scope.dataSource.id);
|
||||
|
||||
$scope.dataSource.$delete(function (resource) {
|
||||
growl.addSuccessMessage("Data source deleted successfully.");
|
||||
$location.path('/data_sources/');
|
||||
}.bind(this), function (httpResponse) {
|
||||
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
|
||||
growl.addErrorMessage("Failed to delete data source.");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('DataSourcesCtrl', ['$scope', '$location', 'growl', 'Events', 'DataSource', DataSourcesCtrl])
|
||||
.controller('DataSourceCtrl', ['$scope', '$routeParams', '$http', '$location', 'Events', 'DataSource', DataSourceCtrl])
|
||||
.controller('DataSourceCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'DataSource', DataSourceCtrl])
|
||||
})();
|
||||
|
||||
44
rd_ui/app/scripts/controllers/destinations.js
Normal file
44
rd_ui/app/scripts/controllers/destinations.js
Normal file
@@ -0,0 +1,44 @@
|
||||
(function () {
|
||||
var DestinationsCtrl = function ($scope, $location, growl, Events, Destination) {
|
||||
Events.record(currentUser, "view", "page", "admin/destinations");
|
||||
$scope.$parent.pageTitle = "Destinations";
|
||||
|
||||
$scope.destinations = Destination.query();
|
||||
|
||||
};
|
||||
|
||||
var DestinationCtrl = function ($scope, $routeParams, $http, $location, growl, Events, Destination) {
|
||||
Events.record(currentUser, "view", "page", "admin/destination");
|
||||
$scope.$parent.pageTitle = "Destinations";
|
||||
|
||||
$scope.destinationId = $routeParams.destinationId;
|
||||
|
||||
if ($scope.destinationId == "new") {
|
||||
$scope.destination = new Destination({options: {}});
|
||||
} else {
|
||||
$scope.destination = Destination.get({id: $routeParams.destinationId});
|
||||
}
|
||||
|
||||
$scope.$watch('destination.id', function(id) {
|
||||
if (id != $scope.destinationId && id !== undefined) {
|
||||
$location.path('/destinations/' + id).replace();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.delete = function() {
|
||||
Events.record(currentUser, "delete", "destination", $scope.destination.id);
|
||||
|
||||
$scope.destination.$delete(function(resource) {
|
||||
growl.addSuccessMessage("Destination deleted successfully.");
|
||||
$location.path('/destinations/');
|
||||
}.bind(this), function(httpResponse) {
|
||||
console.log("Failed to delete destination: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
|
||||
growl.addErrorMessage("Failed to delete destination.");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('DestinationsCtrl', ['$scope', '$location', 'growl', 'Events', 'Destination', DestinationsCtrl])
|
||||
.controller('DestinationCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'Destination', DestinationCtrl])
|
||||
})();
|
||||
@@ -1,95 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var directives = angular.module('redash.directives');
|
||||
|
||||
// Angular strips data- from the directive, so data-source-form becomes sourceForm...
|
||||
directives.directive('sourceForm', ['$http', 'growl', '$q', '$location', 'Events', function ($http, growl, $q, $location, Events) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
templateUrl: '/views/data_sources/form.html',
|
||||
scope: {
|
||||
'dataSource': '='
|
||||
},
|
||||
link: function ($scope) {
|
||||
var setType = function(types) {
|
||||
if ($scope.dataSource.type === undefined) {
|
||||
$scope.dataSource.type = types[0].type;
|
||||
return types[0];
|
||||
}
|
||||
|
||||
$scope.type = _.find(types, function (t) {
|
||||
return t.type == $scope.dataSource.type;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.files = {};
|
||||
|
||||
$scope.$watchCollection('files', function() {
|
||||
_.each($scope.files, function(v, k) {
|
||||
if (v) {
|
||||
$scope.dataSource.options[k] = v.base64;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var typesPromise = $http.get('api/data_sources/types');
|
||||
|
||||
$q.all([typesPromise, $scope.dataSource.$promise]).then(function(responses) {
|
||||
var types = responses[0].data;
|
||||
setType(types);
|
||||
|
||||
$scope.dataSourceTypes = types;
|
||||
|
||||
_.each(types, function (type) {
|
||||
_.each(type.configuration_schema.properties, function (prop, name) {
|
||||
if (name == 'password' || name == 'passwd') {
|
||||
prop.type = 'password';
|
||||
}
|
||||
|
||||
if (_.string.endsWith(name, "File")) {
|
||||
prop.type = 'file';
|
||||
}
|
||||
|
||||
if (prop.type == 'boolean') {
|
||||
prop.type = 'checkbox';
|
||||
}
|
||||
|
||||
prop.required = _.contains(type.configuration_schema.required, name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$scope.$watch('dataSource.type', function(current, prev) {
|
||||
if (prev !== current) {
|
||||
if (prev !== undefined) {
|
||||
$scope.dataSource.options = {};
|
||||
}
|
||||
setType($scope.dataSourceTypes);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.saveChanges = function() {
|
||||
$scope.dataSource.$save(function() {
|
||||
growl.addSuccessMessage("Saved.");
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving.");
|
||||
});
|
||||
}
|
||||
|
||||
$scope.deleteDataSource = function() {
|
||||
Events.record(currentUser, "delete", "datasource", $scope.dataSource.id);
|
||||
|
||||
$scope.dataSource.$delete(function(resource) {
|
||||
growl.addSuccessMessage("Data source deleted successfully.");
|
||||
$location.path('/data_sources/');
|
||||
}.bind(this), function(httpResponse) {
|
||||
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
|
||||
growl.addErrorMessage("Failed to delete data source.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
})();
|
||||
@@ -383,7 +383,86 @@
|
||||
'</div>' +
|
||||
'</div>'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
directives.directive('dynamicForm', ['$http', 'growl', '$q', function ($http, growl, $q) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: 'true',
|
||||
transclude: true,
|
||||
templateUrl: '/views/directives/dynamic_form.html',
|
||||
scope: {
|
||||
'target': '=',
|
||||
'type': '@type'
|
||||
},
|
||||
link: function ($scope) {
|
||||
var setType = function(types) {
|
||||
if ($scope.target.type === undefined) {
|
||||
$scope.target.type = types[0].type;
|
||||
return types[0];
|
||||
}
|
||||
|
||||
$scope.type = _.find(types, function (t) {
|
||||
return t.type == $scope.target.type;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.files = {};
|
||||
|
||||
$scope.$watchCollection('files', function() {
|
||||
_.each($scope.files, function(v, k) {
|
||||
if (v) {
|
||||
$scope.target.options[k] = v.base64;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var typesPromise = $http.get('api/' + $scope.type + '/types');
|
||||
|
||||
$q.all([typesPromise, $scope.target.$promise]).then(function(responses) {
|
||||
var types = responses[0].data;
|
||||
setType(types);
|
||||
|
||||
$scope.types = types;
|
||||
|
||||
_.each(types, function (type) {
|
||||
_.each(type.configuration_schema.properties, function (prop, name) {
|
||||
if (name == 'password' || name == 'passwd') {
|
||||
prop.type = 'password';
|
||||
}
|
||||
|
||||
if (_.string.endsWith(name, "File")) {
|
||||
prop.type = 'file';
|
||||
}
|
||||
|
||||
if (prop.type == 'boolean') {
|
||||
prop.type = 'checkbox';
|
||||
}
|
||||
|
||||
prop.required = _.contains(type.configuration_schema.required, name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$scope.$watch('target.type', function(current, prev) {
|
||||
if (prev !== current) {
|
||||
if (prev !== undefined) {
|
||||
$scope.target.options = {};
|
||||
}
|
||||
setType($scope.types);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.saveChanges = function() {
|
||||
$scope.target.$save(function() {
|
||||
growl.addSuccessMessage("Saved.");
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('pageHeader', function() {
|
||||
return {
|
||||
@@ -407,10 +486,12 @@
|
||||
scope.usersPage = _.string.startsWith($location.path(), '/users');
|
||||
scope.groupsPage = _.string.startsWith($location.path(), '/groups');
|
||||
scope.dsPage = _.string.startsWith($location.path(), '/data_sources');
|
||||
scope.destinationsPage = _.string.startsWith($location.path(), '/destinations');
|
||||
|
||||
scope.showGroupsLink = currentUser.hasPermission('list_users');
|
||||
scope.showUsersLink = currentUser.hasPermission('list_users');
|
||||
scope.showDsLink = currentUser.hasPermission('admin');
|
||||
scope.showDestinationsLink = currentUser.hasPermission('admin');
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
@@ -567,6 +567,17 @@
|
||||
return DataSourceResource;
|
||||
};
|
||||
|
||||
var Destination = function ($resource) {
|
||||
var actions = {
|
||||
'get': {'method': 'GET', 'cache': false, 'isArray': false},
|
||||
'query': {'method': 'GET', 'cache': false, 'isArray': true}
|
||||
};
|
||||
|
||||
var DestinationResource = $resource('api/destinations/:id', {id: '@id'}, actions);
|
||||
|
||||
return DestinationResource;
|
||||
};
|
||||
|
||||
var User = function ($resource, $http) {
|
||||
var transformSingle = function(user) {
|
||||
if (user.groups !== undefined) {
|
||||
@@ -607,7 +618,7 @@
|
||||
};
|
||||
|
||||
var AlertSubscription = function ($resource) {
|
||||
var resource = $resource('api/alerts/:alertId/subscriptions/:userId', {alertId: '@alert_id', userId: '@user.id'});
|
||||
var resource = $resource('api/alerts/:alertId/subscriptions/:subscriberId', {alertId: '@alert_id', subscriberId: '@id'});
|
||||
return resource;
|
||||
};
|
||||
|
||||
@@ -619,7 +630,9 @@
|
||||
var newData = _.extend({}, data);
|
||||
if (newData.query_id === undefined) {
|
||||
newData.query_id = newData.query.id;
|
||||
newData.destination_id = newData.destinations;
|
||||
delete newData.query;
|
||||
delete newData.destinations;
|
||||
}
|
||||
|
||||
return newData;
|
||||
@@ -656,6 +669,7 @@
|
||||
.factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult])
|
||||
.factory('Query', ['$resource', 'QueryResult', 'DataSource', Query])
|
||||
.factory('DataSource', ['$resource', DataSource])
|
||||
.factory('Destination', ['$resource', Destination])
|
||||
.factory('Alert', ['$resource', '$http', Alert])
|
||||
.factory('AlertSubscription', ['$resource', AlertSubscription])
|
||||
.factory('Widget', ['$resource', 'Query', Widget])
|
||||
|
||||
@@ -657,3 +657,8 @@ div.table-name:hover {
|
||||
.t-body a.actions.open > a {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
hr.subscription {
|
||||
height: 2px;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
27
rd_ui/app/views/alerts/alert_subscriptions.html
Normal file
27
rd_ui/app/views/alerts/alert_subscriptions.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<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>
|
||||
@@ -62,7 +62,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-4" ng-if="alert.id">
|
||||
<alert-subscribers alert-id="alert.id"></alert-subscribers>
|
||||
<alert-subscriptions alert-id="alertId"></alert-subscriptions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<div>
|
||||
<strong>Subscribers</strong> <subscribe-button alert-id="alertId" subscribers="subscribers"></subscribe-button><br/>
|
||||
<img ng-src="{{s.user.gravatar_url}}" class="img-circle" alt="{{s.user.name}}" ng-repeat="s in subscribers"/>
|
||||
</div>
|
||||
@@ -1,7 +1,9 @@
|
||||
<settings-screen>
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<data-source-form data-data-source="dataSource" />
|
||||
<dynamic-form target="dataSource" type="data_sources">
|
||||
<button class="btn btn-danger" ng-if="dataSource.id" ng-click="delete()">Delete</button>
|
||||
</dynamic-form>
|
||||
</div>
|
||||
</div>
|
||||
</settings-screen>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<div>
|
||||
|
||||
<form name="dataSourceForm" ng-submit="saveChanges()">
|
||||
<div class="form-group">
|
||||
<label for="dataSourceName">Name</label>
|
||||
<input type="string" class="form-control" name="dataSourceName" ng-model="dataSource.name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="type">Type</label>
|
||||
<select name="type" class="form-control" ng-options="type.type as type.name for type in dataSourceTypes"
|
||||
ng-model="dataSource.type"></select>
|
||||
</div>
|
||||
<div class="form-group" ng-class='{"has-error": !inner.input.$valid}' ng-form="inner"
|
||||
ng-repeat="(name, input) in type.configuration_schema.properties">
|
||||
<label>{{input.title || name | capitalize}}</label>
|
||||
<input name="input" type="{{input.type}}" class="form-control" ng-model="dataSource.options[name]"
|
||||
ng-required="input.required"
|
||||
ng-if="input.type !== 'file'" accesskey="tab" placeholder="{{input.default}}">
|
||||
|
||||
<input name="input" type="file" class="form-control" ng-model="files[name]"
|
||||
ng-required="input.required && !dataSource.options[name]"
|
||||
base-sixty-four-input
|
||||
ng-if="input.type === 'file'">
|
||||
</div>
|
||||
</form>
|
||||
<button class="btn btn-primary" ng-disabled="!dataSourceForm.$valid" ng-click="saveChanges()">Save</button>
|
||||
<button class="btn btn-danger" ng-click="deleteDataSource()" ng-if="dataSource.id">Delete</button>
|
||||
</div>
|
||||
11
rd_ui/app/views/destinations/edit.html
Normal file
11
rd_ui/app/views/destinations/edit.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<settings-screen>
|
||||
<div class="row voffset1">
|
||||
<div class="col-md-6">
|
||||
<dynamic-form target="destination" type="destinations">
|
||||
<button class="btn btn-danger" ng-if="destination.id" ng-click="delete()">Delete</button>
|
||||
</dynamic-form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</settings-screen>
|
||||
12
rd_ui/app/views/destinations/list.html
Normal file
12
rd_ui/app/views/destinations/list.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<settings-screen>
|
||||
<div class="row voffset1">
|
||||
<div class="col-md-4">
|
||||
<p>
|
||||
<a href="destinations/new" class="btn btn-default"><i class="fa fa-plus"></i> New Alert Destination</a>
|
||||
</p>
|
||||
<div class="list-group">
|
||||
<a ng-href="destinations/{{destination.id}}" class="list-group-item" ng-repeat="destination in destinations"><i class="fa {{destination.icon}}"></i> {{destination.name}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</settings-screen>
|
||||
23
rd_ui/app/views/directives/dynamic_form.html
Normal file
23
rd_ui/app/views/directives/dynamic_form.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<form name="dataSourceForm">
|
||||
<div class="form-group">
|
||||
<label for="dataSourceName">Name</label>
|
||||
<input type="string" class="form-control" name="dataSourceName" ng-model="target.name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="type">Type</label>
|
||||
<select name="type" class="form-control" ng-options="type.type as type.name for type in types" ng-model="target.type"></select>
|
||||
</div>
|
||||
<div class="form-group" ng-class='{"has-error": !inner.input.$valid}' ng-form="inner" ng-repeat="(name, input) in type.configuration_schema.properties">
|
||||
<label>{{input.title || name | capitalize}}</label>
|
||||
<input name="input" type="{{input.type}}" class="form-control" ng-model="target.options[name]" ng-required="input.required"
|
||||
ng-if="input.type !== 'file'" accesskey="tab" placeholder="{{input.default}}">
|
||||
|
||||
<input name="input" type="file" class="form-control" ng-model="files[name]" ng-required="input.required && !target.options[name]"
|
||||
base-sixty-four-input
|
||||
ng-if="input.type === 'file'">
|
||||
</div>
|
||||
<button class="btn btn-primary" ng-disabled="!dataSourceForm.$valid" ng-click="saveChanges()">Save</button>
|
||||
<span ng-transclude>
|
||||
|
||||
</span>
|
||||
</form>
|
||||
@@ -7,6 +7,7 @@
|
||||
<li ng-class="{'active': dsPage }" ng-if="showDsLink"><a href="data_sources">Data Sources</a></li>
|
||||
<li ng-class="{'active': usersPage }" ng-if="showUsersLink"><a href="users">Users</a></li>
|
||||
<li ng-class="{'active': groupsPage }" ng-if="showGroupsLink"><a href="groups">Groups</a></li>
|
||||
<li ng-class="{'active': destinationsPage }" ng-if="showDestinationsLink"><a href="destinations">Alert Destinations</a></li>
|
||||
</ul>
|
||||
|
||||
<div ng-transclude>
|
||||
|
||||
@@ -10,6 +10,7 @@ from flask_mail import Mail
|
||||
|
||||
from redash import settings
|
||||
from redash.query_runner import import_query_runners
|
||||
from redash.destinations import import_destinations
|
||||
|
||||
|
||||
__version__ = '0.11.0'
|
||||
@@ -61,6 +62,7 @@ mail.init_mail(settings.all_settings())
|
||||
statsd_client = StatsClient(host=settings.STATSD_HOST, port=settings.STATSD_PORT, prefix=settings.STATSD_PREFIX)
|
||||
|
||||
import_query_runners(settings.QUERY_RUNNERS)
|
||||
import_destinations(settings.DESTINATIONS)
|
||||
|
||||
from redash.version_check import reset_new_version_status
|
||||
reset_new_version_status()
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import click
|
||||
from flask_script import Manager
|
||||
from redash import models
|
||||
from redash.query_runner import query_runners, get_configuration_schema_for_type
|
||||
from redash.query_runner import query_runners, get_configuration_schema_for_query_runner_type
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
|
||||
manager = Manager(help="Data sources management commands.")
|
||||
@@ -119,7 +119,7 @@ def edit(name, new_name=None, options=None, type=None):
|
||||
data_source = models.DataSource.get(models.DataSource.name==name)
|
||||
|
||||
if options is not None:
|
||||
schema = get_configuration_schema_for_type(data_source.type)
|
||||
schema = get_configuration_schema_for_query_runner_type(data_source.type)
|
||||
options = json.loads(options)
|
||||
data_source.options.set_schema(schema)
|
||||
data_source.options.update(options)
|
||||
|
||||
82
redash/destinations/__init__.py
Normal file
82
redash/destinations/__init__.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import logging
|
||||
import json
|
||||
|
||||
from redash import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
'BaseDestination',
|
||||
'register',
|
||||
'get_destination',
|
||||
'import_destinations'
|
||||
]
|
||||
|
||||
|
||||
class BaseDestination(object):
|
||||
def __init__(self, configuration):
|
||||
self.configuration = configuration
|
||||
|
||||
@classmethod
|
||||
def name(cls):
|
||||
return cls.__name__
|
||||
|
||||
@classmethod
|
||||
def type(cls):
|
||||
return cls.__name__.lower()
|
||||
|
||||
@classmethod
|
||||
def icon(cls):
|
||||
return 'fa-bullseye'
|
||||
|
||||
@classmethod
|
||||
def enabled(cls):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {}
|
||||
|
||||
def notify(self, alert, query, user, new_state, app, host, options):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def to_dict(cls):
|
||||
return {
|
||||
'name': cls.name(),
|
||||
'type': cls.type(),
|
||||
'icon': cls.icon(),
|
||||
'configuration_schema': cls.configuration_schema()
|
||||
}
|
||||
|
||||
|
||||
destinations = {}
|
||||
|
||||
|
||||
def register(destination_class):
|
||||
global destinations
|
||||
if destination_class.enabled():
|
||||
logger.debug("Registering %s (%s) destinations.", destination_class.name(), destination_class.type())
|
||||
destinations[destination_class.type()] = destination_class
|
||||
else:
|
||||
logger.warning("%s destination enabled but not supported, not registering. Either disable or install missing dependencies.", destination_class.name())
|
||||
|
||||
|
||||
def get_destination(destination_type, configuration):
|
||||
destination_class = destinations.get(destination_type, None)
|
||||
if destination_class is None:
|
||||
return None
|
||||
return destination_class(configuration)
|
||||
|
||||
|
||||
def get_configuration_schema_for_destination_type(destination_type):
|
||||
destination_class = destinations.get(destination_type, None)
|
||||
if destination_class is None:
|
||||
return None
|
||||
|
||||
return destination_class.configuration_schema()
|
||||
|
||||
|
||||
def import_destinations(destination_imports):
|
||||
for destination_import in destination_imports:
|
||||
__import__(destination_import)
|
||||
44
redash/destinations/email.py
Normal file
44
redash/destinations/email.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import logging
|
||||
|
||||
from flask_mail import Message
|
||||
from redash import models, mail
|
||||
from redash.destinations import *
|
||||
|
||||
|
||||
class Email(BaseDestination):
|
||||
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"addresses": {
|
||||
"type": "string"
|
||||
},
|
||||
},
|
||||
"required": ["addresses"]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def icon(cls):
|
||||
return 'fa-envelope'
|
||||
|
||||
def notify(self, alert, query, user, new_state, app, host, options):
|
||||
recipients = [email for email in options.get('addresses').split(',') if email]
|
||||
html = """
|
||||
Check <a href="{host}/alerts/{alert_id}">alert</a> / check <a href="{host}/queries/{query_id}">query</a>.
|
||||
""".format(host=host, alert_id=alert.id, query_id=query.id)
|
||||
logging.debug("Notifying: %s", recipients)
|
||||
|
||||
try:
|
||||
with app.app_context():
|
||||
message = Message(
|
||||
recipients=recipients,
|
||||
subject="[{1}] {0}".format(alert.name.encode('utf-8', 'ignore'), new_state.upper()),
|
||||
html=html
|
||||
)
|
||||
mail.send(message)
|
||||
except Exception:
|
||||
logging.exception("mail send ERROR.")
|
||||
|
||||
register(Email)
|
||||
47
redash/destinations/hipchat.py
Normal file
47
redash/destinations/hipchat.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import logging
|
||||
import hipchat
|
||||
|
||||
from redash import settings
|
||||
from redash.destinations import *
|
||||
|
||||
|
||||
class HipChat(BaseDestination):
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"room_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["token", "room_id"],
|
||||
"secret": ["token"]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def icon(cls):
|
||||
return 'fa-comment-o'
|
||||
|
||||
def notify(self, alert, query, user, new_state, app, host, options):
|
||||
try:
|
||||
if options.url:
|
||||
hipchat_client = hipchat.HipChat(token=options.token, url=options.url)
|
||||
else:
|
||||
hipchat_client = hipchat.HipChat(token=options.token)
|
||||
html = """
|
||||
Check <a href="{host}/alerts/{alert_id}">alert</a> / check <a href="{host}/queries/{query_id}">query</a>.
|
||||
""".format(host=host, alert_id=alert.id, query_id=query.id)
|
||||
message = '[' + new_state.upper() + '] ' + alert.name + '<br />' + html
|
||||
hipchat_client.message_room(options.room_id, settings.NAME, message.encode('utf-8', 'ignore'), message_format='html')
|
||||
except Exception:
|
||||
logging.exception("hipchat send ERROR.")
|
||||
|
||||
|
||||
register(HipChat)
|
||||
38
redash/destinations/slack.py
Normal file
38
redash/destinations/slack.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from redash.destinations import *
|
||||
|
||||
|
||||
class Slack(BaseDestination):
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'url': {
|
||||
'type': 'string',
|
||||
'title': 'Slack Webhook URL'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def icon(cls):
|
||||
return 'fa-slack'
|
||||
|
||||
def notify(self, alert, query, user, new_state, app, host, options):
|
||||
msg = "Check <{host}/alerts/{alert_id}|alert> / check <{host}/queries/{query_id}|query>".format(
|
||||
host=host, alert_id=alert.id, query_id=query.id)
|
||||
payload = {'text': msg}
|
||||
try:
|
||||
resp = requests.post(options.get('url'), data=json.dumps(payload))
|
||||
logging.warning(resp.text)
|
||||
if resp.status_code != 200:
|
||||
logging.error("Slack send ERROR. status_code => {status}".format(status=resp.status_code))
|
||||
except Exception:
|
||||
logging.exception("Slack send ERROR.")
|
||||
|
||||
|
||||
register(Slack)
|
||||
49
redash/destinations/webhook.py
Normal file
49
redash/destinations/webhook.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import logging
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from redash.destinations import *
|
||||
from redash.utils import json_dumps
|
||||
|
||||
|
||||
class Webhook(BaseDestination):
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["url"],
|
||||
"secret": ["password"]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def icon(cls):
|
||||
return 'fa-bolt'
|
||||
|
||||
def notify(self, alert, query, user, new_state, app, host, options):
|
||||
try:
|
||||
data = {
|
||||
'event': 'alert_state_change',
|
||||
'alert': alert.to_dict(full=False),
|
||||
'url_base': host
|
||||
}
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
auth = HTTPBasicAuth(options.get('username'), options.get('password')) if options.get('username') else None
|
||||
resp = requests.post(options.get('url'), data=json_dumps(data), auth=auth, headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logging.error("webhook send ERROR. status_code => {status}".format(status=resp.status_code))
|
||||
except Exception:
|
||||
logging.exception("webhook send ERROR.")
|
||||
|
||||
|
||||
register(Webhook)
|
||||
@@ -57,16 +57,6 @@ class AlertListResource(BaseResource):
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
# TODO: should be in model?
|
||||
models.AlertSubscription.create(alert=alert, user=self.current_user)
|
||||
|
||||
self.record_event({
|
||||
'action': 'subscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert.id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
return alert.to_dict()
|
||||
|
||||
@require_permission('list_alerts')
|
||||
@@ -76,15 +66,24 @@ class AlertListResource(BaseResource):
|
||||
|
||||
class AlertSubscriptionListResource(BaseResource):
|
||||
def post(self, alert_id):
|
||||
req = request.get_json(True)
|
||||
|
||||
alert = models.Alert.get_by_id_and_org(alert_id, self.current_org)
|
||||
require_access(alert.groups, self.current_user, view_only)
|
||||
kwargs = {'alert': alert, 'user': self.current_user}
|
||||
|
||||
if 'destination_id' in req:
|
||||
destination = models.NotificationDestination.get_by_id_and_org(req['destination_id'], self.current_org)
|
||||
kwargs['destination'] = destination
|
||||
|
||||
subscription = models.AlertSubscription.create(**kwargs)
|
||||
|
||||
subscription = models.AlertSubscription.create(alert=alert_id, user=self.current_user)
|
||||
self.record_event({
|
||||
'action': 'subscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert_id,
|
||||
'object_type': 'alert'
|
||||
'object_type': 'alert',
|
||||
'destination': req.get('destination_id')
|
||||
})
|
||||
|
||||
return subscription.to_dict()
|
||||
@@ -99,8 +98,10 @@ class AlertSubscriptionListResource(BaseResource):
|
||||
|
||||
class AlertSubscriptionResource(BaseResource):
|
||||
def delete(self, alert_id, subscriber_id):
|
||||
models.AlertSubscription.unsubscribe(alert_id, subscriber_id)
|
||||
require_admin_or_owner(subscriber_id)
|
||||
|
||||
subscription = get_object_or_404(models.AlertSubscription.get_by_id, subscriber_id)
|
||||
require_admin_or_owner(subscription.user.id)
|
||||
subscription.delete_instance()
|
||||
|
||||
self.record_event({
|
||||
'action': 'unsubscribe',
|
||||
|
||||
@@ -16,6 +16,7 @@ from redash.handlers.visualizations import VisualizationResource
|
||||
from redash.handlers.widgets import WidgetResource, WidgetListResource
|
||||
from redash.handlers.groups import GroupListResource, GroupResource, GroupMemberListResource, GroupMemberResource, \
|
||||
GroupDataSourceListResource, GroupDataSourceResource
|
||||
from redash.handlers.destinations import DestinationTypeListResource, DestinationResource, DestinationListResource
|
||||
|
||||
|
||||
class ApiExt(Api):
|
||||
@@ -86,4 +87,6 @@ api.add_org_resource(VisualizationResource, '/api/visualizations/<visualization_
|
||||
api.add_org_resource(WidgetListResource, '/api/widgets', endpoint='widgets')
|
||||
api.add_org_resource(WidgetResource, '/api/widgets/<int:widget_id>', endpoint='widget')
|
||||
|
||||
|
||||
api.add_org_resource(DestinationTypeListResource, '/api/destinations/types', endpoint='destination_types')
|
||||
api.add_org_resource(DestinationResource, '/api/destinations/<destination_id>', endpoint='destination')
|
||||
api.add_org_resource(DestinationListResource, '/api/destinations', endpoint='destinations')
|
||||
|
||||
@@ -5,7 +5,7 @@ from funcy import project
|
||||
from redash import models
|
||||
from redash.utils.configuration import ConfigurationContainer, ValidationError
|
||||
from redash.permissions import require_admin, require_permission, require_access, view_only
|
||||
from redash.query_runner import query_runners, get_configuration_schema_for_type
|
||||
from redash.query_runner import query_runners, get_configuration_schema_for_query_runner_type
|
||||
from redash.handlers.base import BaseResource, get_object_or_404
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class DataSourceResource(BaseResource):
|
||||
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
|
||||
req = request.get_json(True)
|
||||
|
||||
schema = get_configuration_schema_for_type(req['type'])
|
||||
schema = get_configuration_schema_for_query_runner_type(req['type'])
|
||||
if schema is None:
|
||||
abort(400)
|
||||
|
||||
@@ -35,7 +35,7 @@ class DataSourceResource(BaseResource):
|
||||
data_source.options.update(req['options'])
|
||||
except ValidationError:
|
||||
abort(400)
|
||||
|
||||
|
||||
data_source.type = req['type']
|
||||
data_source.name = req['name']
|
||||
data_source.save()
|
||||
@@ -77,7 +77,7 @@ class DataSourceListResource(BaseResource):
|
||||
if f not in req:
|
||||
abort(400)
|
||||
|
||||
schema = get_configuration_schema_for_type(req['type'])
|
||||
schema = get_configuration_schema_for_query_runner_type(req['type'])
|
||||
if schema is None:
|
||||
abort(400)
|
||||
|
||||
|
||||
93
redash/handlers/destinations.py
Normal file
93
redash/handlers/destinations.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import json
|
||||
|
||||
from flask import make_response, request
|
||||
from flask.ext.restful import abort
|
||||
from funcy import project
|
||||
|
||||
from redash import models
|
||||
from redash.permissions import require_admin
|
||||
from redash.destinations import destinations, get_configuration_schema_for_destination_type
|
||||
from redash.utils.configuration import ConfigurationContainer, ValidationError
|
||||
from redash.handlers.base import BaseResource, get_object_or_404
|
||||
|
||||
|
||||
class DestinationTypeListResource(BaseResource):
|
||||
@require_admin
|
||||
def get(self):
|
||||
return [q.to_dict() for q in destinations.values()]
|
||||
|
||||
|
||||
class DestinationResource(BaseResource):
|
||||
@require_admin
|
||||
def get(self, destination_id):
|
||||
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
|
||||
return destination.to_dict(all=True)
|
||||
|
||||
@require_admin
|
||||
def post(self, destination_id):
|
||||
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
|
||||
req = request.get_json(True)
|
||||
|
||||
schema = get_configuration_schema_for_destination_type(req['type'])
|
||||
if schema is None:
|
||||
abort(400)
|
||||
|
||||
try:
|
||||
destination.options.set_schema(schema)
|
||||
destination.options.update(req['options'])
|
||||
except ValidationError:
|
||||
abort(400)
|
||||
|
||||
destination.type = req['type']
|
||||
destination.name = req['name']
|
||||
|
||||
destination.save()
|
||||
|
||||
return destination.to_dict(all=True)
|
||||
|
||||
@require_admin
|
||||
def delete(self, destination_id):
|
||||
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
|
||||
destination.delete_instance(recursive=True)
|
||||
|
||||
return make_response('', 204)
|
||||
|
||||
|
||||
class DestinationListResource(BaseResource):
|
||||
def get(self):
|
||||
destinations = models.NotificationDestination.all(self.current_org)
|
||||
|
||||
response = {}
|
||||
for ds in destinations:
|
||||
if ds.id in response:
|
||||
continue
|
||||
|
||||
d = ds.to_dict()
|
||||
response[ds.id] = d
|
||||
|
||||
return response.values()
|
||||
|
||||
@require_admin
|
||||
def post(self):
|
||||
req = request.get_json(True)
|
||||
required_fields = ('options', 'name', 'type')
|
||||
for f in required_fields:
|
||||
if f not in req:
|
||||
abort(400)
|
||||
|
||||
schema = get_configuration_schema_for_destination_type(req['type'])
|
||||
if schema is None:
|
||||
abort(400)
|
||||
|
||||
config = ConfigurationContainer(req['options'], schema)
|
||||
if not config.is_valid():
|
||||
abort(400)
|
||||
|
||||
destination = models.NotificationDestination(org=self.current_org,
|
||||
name=req['name'],
|
||||
type=req['type'],
|
||||
options=config,
|
||||
user=self.current_user)
|
||||
destination.save()
|
||||
|
||||
return destination.to_dict(all=True)
|
||||
@@ -176,5 +176,3 @@ class GroupDataSourceResource(BaseResource):
|
||||
'object_type': 'group',
|
||||
'member_id': data_source.id
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ def send_static(filename):
|
||||
|
||||
# The following is copied from send_from_directory, and extended to support multiple directories
|
||||
for path in settings.STATIC_ASSETS_PATHS:
|
||||
print path
|
||||
full_path = safe_join(path, filename)
|
||||
if os.path.isfile(full_path):
|
||||
return send_file(full_path, **dict(cache_timeout=cache_timeout, conditional=True))
|
||||
@@ -80,6 +79,8 @@ rules = ['/admin/<anything>/<whatever>',
|
||||
'/data_sources/<pk>',
|
||||
'/users',
|
||||
'/users/<pk>',
|
||||
'/destinations',
|
||||
'/destinations/<pk>',
|
||||
'/groups',
|
||||
'/groups/<pk>',
|
||||
'/groups/<pk>/data_sources',
|
||||
|
||||
124
redash/models.py
124
redash/models.py
@@ -16,7 +16,8 @@ from playhouse.postgres_ext import ArrayField, DateTimeTZField
|
||||
from permissions import has_access, view_only
|
||||
|
||||
from redash import utils, settings, redis_connection
|
||||
from redash.query_runner import get_query_runner, get_configuration_schema_for_type
|
||||
from redash.query_runner import get_query_runner, get_configuration_schema_for_query_runner_type
|
||||
from redash.destinations import get_destination, get_configuration_schema_for_destination_type
|
||||
from redash.metrics.database import MeteredPostgresqlExtDatabase, MeteredModel
|
||||
from redash.utils import generate_token
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
@@ -378,7 +379,7 @@ class DataSource(BelongsToOrgMixin, BaseModel):
|
||||
}
|
||||
|
||||
if all:
|
||||
schema = get_configuration_schema_for_type(self.type)
|
||||
schema = get_configuration_schema_for_query_runner_type(self.type)
|
||||
self.options.set_schema(schema)
|
||||
d['options'] = self.options.to_dict(mask_secrets=True)
|
||||
d['queue_name'] = self.queue_name
|
||||
@@ -827,29 +828,6 @@ class Alert(ModelTimestampsMixin, BaseModel):
|
||||
return self.query.groups
|
||||
|
||||
|
||||
class AlertSubscription(ModelTimestampsMixin, BaseModel):
|
||||
user = peewee.ForeignKeyField(User)
|
||||
alert = peewee.ForeignKeyField(Alert)
|
||||
|
||||
class Meta:
|
||||
db_table = 'alert_subscriptions'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'user': self.user.to_dict(),
|
||||
'alert_id': self.alert_id
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def all(cls, alert_id):
|
||||
return AlertSubscription.select(AlertSubscription, User).join(User).where(AlertSubscription.alert==alert_id)
|
||||
|
||||
@classmethod
|
||||
def unsubscribe(cls, alert_id, user_id):
|
||||
query = AlertSubscription.delete().where(AlertSubscription.alert==alert_id).where(AlertSubscription.user==user_id)
|
||||
return query.execute()
|
||||
|
||||
|
||||
class Dashboard(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
|
||||
id = peewee.PrimaryKeyField()
|
||||
org = peewee.ForeignKeyField(Organization, related_name="dashboards")
|
||||
@@ -1120,7 +1098,101 @@ class ApiKey(ModelTimestampsMixin, BaseModel):
|
||||
return cls.create(org=user.org, object=object, created_by=user)
|
||||
|
||||
|
||||
all_models = (Organization, Group, DataSource, DataSourceGroup, User, QueryResult, Query, Alert, AlertSubscription, Dashboard, Visualization, Widget, Event, ApiKey)
|
||||
class NotificationDestination(BelongsToOrgMixin, BaseModel):
|
||||
|
||||
id = peewee.PrimaryKeyField()
|
||||
org = peewee.ForeignKeyField(Organization, related_name="notification_destinations")
|
||||
user = peewee.ForeignKeyField(User, related_name="notification_destinations")
|
||||
name = peewee.CharField()
|
||||
type = peewee.CharField()
|
||||
options = ConfigurationField()
|
||||
created_at = DateTimeTZField(default=datetime.datetime.now)
|
||||
|
||||
class Meta:
|
||||
db_table = 'notification_destinations'
|
||||
|
||||
indexes = (
|
||||
(('org', 'name'), True),
|
||||
)
|
||||
|
||||
def to_dict(self, all=False):
|
||||
d = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'icon': self.destination.icon()
|
||||
}
|
||||
|
||||
if all:
|
||||
schema = get_configuration_schema_for_destination_type(self.type)
|
||||
self.options.set_schema(schema)
|
||||
d['options'] = self.options.to_dict(mask_secrets=True)
|
||||
|
||||
return d
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def destination(self):
|
||||
return get_destination(self.type, self.options)
|
||||
|
||||
@classmethod
|
||||
def all(cls, org):
|
||||
notification_destinations = cls.select().where(cls.org==org).order_by(cls.id.asc())
|
||||
|
||||
return notification_destinations
|
||||
|
||||
def notify(self, alert, query, user, new_state, app, host):
|
||||
schema = get_configuration_schema_for_destination_type(self.type)
|
||||
self.options.set_schema(schema)
|
||||
return self.destination.notify(alert, query, user, new_state,
|
||||
app, host, self.options)
|
||||
|
||||
|
||||
class AlertSubscription(ModelTimestampsMixin, BaseModel):
|
||||
user = peewee.ForeignKeyField(User)
|
||||
destination = peewee.ForeignKeyField(NotificationDestination, null=True)
|
||||
alert = peewee.ForeignKeyField(Alert, related_name="subscriptions")
|
||||
|
||||
class Meta:
|
||||
db_table = 'alert_subscriptions'
|
||||
|
||||
indexes = (
|
||||
(('destination', 'alert'), True),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
d = {
|
||||
'id': self.id,
|
||||
'user': self.user.to_dict(),
|
||||
'alert_id': self.alert_id
|
||||
}
|
||||
|
||||
if self.destination:
|
||||
d['destination'] = self.destination.to_dict()
|
||||
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def all(cls, alert_id):
|
||||
return AlertSubscription.select(AlertSubscription, User).join(User).where(AlertSubscription.alert==alert_id)
|
||||
|
||||
def notify(self, alert, query, user, new_state, app, host):
|
||||
if self.destination:
|
||||
return self.destination.notify(alert, query, user, new_state,
|
||||
app, host)
|
||||
else:
|
||||
# User email subscription, so create an email destination object
|
||||
config = {'email': self.user.email}
|
||||
schema = get_configuration_schema_for_destination_type('email')
|
||||
options = ConfigurationContainer(json.dumps(config), schema)
|
||||
destination = get_destination('email', options)
|
||||
return destination.notify(alert, query, user, new_state,
|
||||
app, host, options)
|
||||
|
||||
|
||||
all_models = (Organization, Group, DataSource, DataSourceGroup, User, QueryResult, Query, Alert, Dashboard, Visualization, Widget, Event, NotificationDestination, AlertSubscription, ApiKey)
|
||||
|
||||
|
||||
def init_db():
|
||||
|
||||
@@ -148,7 +148,7 @@ def get_query_runner(query_runner_type, configuration):
|
||||
return query_runner_class(configuration)
|
||||
|
||||
|
||||
def get_configuration_schema_for_type(query_runner_type):
|
||||
def get_configuration_schema_for_query_runner_type(query_runner_type):
|
||||
query_runner_class = query_runners.get(query_runner_type, None)
|
||||
if query_runner_class is None:
|
||||
return None
|
||||
|
||||
@@ -183,6 +183,19 @@ disabled_query_runners = array_from_string(os.environ.get("REDASH_DISABLED_QUERY
|
||||
|
||||
QUERY_RUNNERS = remove(set(disabled_query_runners), distinct(enabled_query_runners + additional_query_runners))
|
||||
|
||||
# Destinations
|
||||
default_destinations = [
|
||||
'redash.destinations.email',
|
||||
'redash.destinations.slack',
|
||||
'redash.destinations.webhook',
|
||||
'redash.destinations.hipchat',
|
||||
]
|
||||
|
||||
enabled_destinations = array_from_string(os.environ.get("REDASH_ENABLED_DESTINATIONS", ",".join(default_destinations)))
|
||||
additional_destinations = array_from_string(os.environ.get("REDASH_ADDITIONAL_DESTINATIONS", ""))
|
||||
|
||||
DESTINATIONS = distinct(enabled_destinations + additional_destinations)
|
||||
|
||||
EVENT_REPORTING_WEBHOOKS = array_from_string(os.environ.get("REDASH_EVENT_REPORTING_WEBHOOKS", ""))
|
||||
|
||||
# Support for Sentry (http://getsentry.com/). Just set your Sentry DSN to enable it:
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
from celery.utils.log import get_task_logger
|
||||
import datetime
|
||||
from flask.ext.mail import Message
|
||||
import hipchat
|
||||
import requests
|
||||
from redash.utils import json_dumps
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from redash.worker import celery
|
||||
from redash import utils, mail
|
||||
from redash import utils
|
||||
from redash import models, settings
|
||||
from .base import BaseTask
|
||||
|
||||
@@ -21,8 +16,8 @@ def base_url(org):
|
||||
return settings.HOST
|
||||
|
||||
|
||||
@celery.task(name="redash.tasks.check_alerts_for_query", bind=True, base=BaseTask)
|
||||
def check_alerts_for_query(self, query_id):
|
||||
@celery.task(name="redash.tasks.check_alerts_for_query", base=BaseTask)
|
||||
def check_alerts_for_query(query_id):
|
||||
from redash.wsgi import app
|
||||
|
||||
logger.debug("Checking query %d for alerts", query_id)
|
||||
@@ -42,56 +37,10 @@ def check_alerts_for_query(self, query_id):
|
||||
logger.debug("Skipping notification (previous state was unknown and now it's ok).")
|
||||
continue
|
||||
|
||||
# message = Message
|
||||
html = """
|
||||
Check <a href="{host}/alerts/{alert_id}">alert</a> / check <a href="{host}/queries/{query_id}">query</a>.
|
||||
""".format(host=base_url(alert.query.org), alert_id=alert.id, query_id=query.id)
|
||||
host = base_url(alert.query.org)
|
||||
for subscription in alert.subscriptions:
|
||||
try:
|
||||
subscription.notify(alert, query, subscription.user, new_state, app, host)
|
||||
except Exception as e:
|
||||
logger.warn("Exception: {}".format(e))
|
||||
|
||||
notify_mail(alert, html, new_state, app)
|
||||
|
||||
if settings.HIPCHAT_API_TOKEN:
|
||||
notify_hipchat(alert, html, new_state)
|
||||
|
||||
if settings.WEBHOOK_ENDPOINT:
|
||||
notify_webhook(alert, query, html, new_state)
|
||||
|
||||
|
||||
def notify_hipchat(alert, html, new_state):
|
||||
try:
|
||||
if settings.HIPCHAT_API_URL:
|
||||
hipchat_client = hipchat.HipChat(token=settings.HIPCHAT_API_TOKEN, url=settings.HIPCHAT_API_URL)
|
||||
else:
|
||||
hipchat_client = hipchat.HipChat(token=settings.HIPCHAT_API_TOKEN)
|
||||
message = '[' + new_state.upper() + '] ' + alert.name + '<br />' + html
|
||||
hipchat_client.message_room(settings.HIPCHAT_ROOM_ID, settings.NAME, message.encode('utf-8', 'ignore'), message_format='html')
|
||||
except Exception:
|
||||
logger.exception("hipchat send ERROR.")
|
||||
|
||||
|
||||
def notify_mail(alert, html, new_state, app):
|
||||
recipients = [s.email for s in alert.subscribers()]
|
||||
logger.debug("Notifying: %s", recipients)
|
||||
try:
|
||||
with app.app_context():
|
||||
message = Message(recipients=recipients,
|
||||
subject="[{1}] {0}".format(alert.name.encode('utf-8', 'ignore'), new_state.upper()),
|
||||
html=html)
|
||||
mail.send(message)
|
||||
except Exception:
|
||||
logger.exception("mail send ERROR.")
|
||||
|
||||
|
||||
def notify_webhook(alert, query, html, new_state):
|
||||
try:
|
||||
data = {
|
||||
'event': 'alert_state_change',
|
||||
'alert': alert.to_dict(full=False),
|
||||
'url_base': base_url(query.org)
|
||||
}
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
auth = HTTPBasicAuth(settings.WEBHOOK_USERNAME, settings.WEBHOOK_PASSWORD) if settings.WEBHOOK_USERNAME else None
|
||||
resp = requests.post(settings.WEBHOOK_ENDPOINT, data=json_dumps(data), auth=auth, headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logger.error("webhook send ERROR. status_code => {status}".format(status=resp.status_code))
|
||||
except Exception:
|
||||
logger.exception("webhook send ERROR.")
|
||||
|
||||
@@ -111,6 +111,18 @@ widget_factory = ModelFactory(redash.models.Widget,
|
||||
dashboard=dashboard_factory.create,
|
||||
visualization=visualization_factory.create)
|
||||
|
||||
destination_factory = ModelFactory(redash.models.NotificationDestination,
|
||||
org=1,
|
||||
user=user_factory.create,
|
||||
name='Destination',
|
||||
type='slack',
|
||||
options=ConfigurationContainer.from_json('{"url": "https://www.slack.com"}'))
|
||||
|
||||
alert_subscription_factory = ModelFactory(redash.models.AlertSubscription,
|
||||
user=user_factory.create,
|
||||
destination=destination_factory.create,
|
||||
alert=alert_factory.create)
|
||||
|
||||
|
||||
class Factory(object):
|
||||
def __init__(self):
|
||||
@@ -259,3 +271,9 @@ class Factory(object):
|
||||
}
|
||||
args.update(kwargs)
|
||||
return api_key_factory.create(**args)
|
||||
|
||||
def create_destination(self, **kwargs):
|
||||
return destination_factory.create(**kwargs)
|
||||
|
||||
def create_alert_subscription(self, **kwargs):
|
||||
return alert_subscription_factory.create(**kwargs)
|
||||
|
||||
@@ -33,23 +33,28 @@ class TestAlertResourceGet(BaseTestCase):
|
||||
class TestAlertListPost(BaseTestCase):
|
||||
def test_returns_200_if_has_access_to_query(self):
|
||||
query = self.factory.create_query()
|
||||
destination = self.factory.create_destination()
|
||||
|
||||
rv = self.make_request('post', "/api/alerts", data=dict(name='Alert', query_id=query.id, options={}))
|
||||
rv = self.make_request('post', "/api/alerts", data=dict(name='Alert', query_id=query.id,
|
||||
destination_id=destination.id, options={}))
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
|
||||
def test_fails_if_doesnt_have_access_to_query(self):
|
||||
data_source = self.factory.create_data_source(group=self.factory.create_group())
|
||||
query = self.factory.create_query(data_source=data_source)
|
||||
destination = self.factory.create_destination()
|
||||
|
||||
rv = self.make_request('post', "/api/alerts", data=dict(name='Alert', query_id=query.id, options={}))
|
||||
rv = self.make_request('post', "/api/alerts", data=dict(name='Alert', query_id=query.id,
|
||||
destination_id=destination.id, options={}))
|
||||
self.assertEqual(rv.status_code, 403)
|
||||
|
||||
|
||||
class TestAlertSubscriptionListResourcePost(BaseTestCase):
|
||||
def test_subscribers_user_to_alert(self):
|
||||
alert = self.factory.create_alert()
|
||||
destination = self.factory.create_destination()
|
||||
|
||||
rv = self.make_request('post', "/api/alerts/{}/subscriptions".format(alert.id))
|
||||
rv = self.make_request('post', "/api/alerts/{}/subscriptions".format(alert.id), data=dict(destination_id=destination.id))
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertIn(self.factory.user, alert.subscribers())
|
||||
|
||||
@@ -57,8 +62,9 @@ class TestAlertSubscriptionListResourcePost(BaseTestCase):
|
||||
data_source = self.factory.create_data_source(group=self.factory.create_group())
|
||||
query = self.factory.create_query(data_source=data_source)
|
||||
alert = self.factory.create_alert(query=query)
|
||||
destination = self.factory.create_destination()
|
||||
|
||||
rv = self.make_request('post', "/api/alerts/{}/subscriptions".format(alert.id))
|
||||
rv = self.make_request('post', "/api/alerts/{}/subscriptions".format(alert.id), data=dict(destination_id=destination.id))
|
||||
self.assertEqual(rv.status_code, 403)
|
||||
self.assertNotIn(self.factory.user, alert.subscribers())
|
||||
|
||||
@@ -81,18 +87,20 @@ class TestAlertSubscriptionListResourceGet(BaseTestCase):
|
||||
|
||||
class TestAlertSubscriptionresourceDelete(BaseTestCase):
|
||||
def test_only_subscriber_or_admin_can_unsubscribe(self):
|
||||
alert = self.factory.create_alert()
|
||||
subscription = self.factory.create_alert_subscription()
|
||||
alert = subscription.alert
|
||||
user = subscription.user
|
||||
path = '/api/alerts/{}/subscriptions/{}'.format(alert.id, subscription.id)
|
||||
|
||||
other_user = self.factory.create_user()
|
||||
path = '/api/alerts/{}/subscriptions/{}'.format(alert.id, other_user.id)
|
||||
|
||||
AlertSubscription.create(alert=alert, user=other_user)
|
||||
|
||||
response = self.make_request('delete', path)
|
||||
response = self.make_request('delete', path, user=other_user)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = self.make_request('delete', path, user=self.factory.create_admin())
|
||||
response = self.make_request('delete', path, user=user)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
AlertSubscription.create(alert=alert, user=other_user)
|
||||
response = self.make_request('delete', path, user=other_user)
|
||||
subscription_two = AlertSubscription.create(alert=alert, user=other_user)
|
||||
path = '/api/alerts/{}/subscriptions/{}'.format(alert.id, subscription_two.id)
|
||||
response = self.make_request('delete', path, user=self.factory.create_admin())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
Reference in New Issue
Block a user