1
0
mirror of https://github.com/linkedin/oncall.git synced 2025-11-29 23:38:17 +02:00

Merge pull request #20 from dwang159/iris

Add Iris plan support for team escalation
This commit is contained in:
Daniel Wang
2017-05-12 17:23:35 -07:00
committed by GitHub
14 changed files with 273 additions and 36 deletions

View File

@@ -20,7 +20,7 @@ session:
encrypt_key: 'abc'
sign_key: '123'
auth:
debug: True
debug: False
module: 'oncall.auth.modules.debug'
notifier:
skipsend: True
@@ -65,3 +65,9 @@ user_validator:
slack_instance: foobar
header_color: '#3a3a3a'
iris_plan_integration:
activated: True
app: oncall
api_key: magic
api_host: http://localhost:16649
plan_url: '/v0/applications/oncall/plans'

View File

@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS `team` (
`email` VARCHAR(255),
`scheduling_timezone` VARCHAR(255),
`active` BOOLEAN NOT NULL DEFAULT TRUE,
`iris_plan` VARCHAR(255),
PRIMARY KEY (`id`),
UNIQUE INDEX `name_unique` (`name` ASC));

View File

@@ -71,3 +71,11 @@ def init(application, config):
from . import upcoming_shifts
application.add_route('/api/v0/users/{user_name}/upcoming', upcoming_shifts)
# Optional Iris integration
from . import iris_settings
application.add_route('/api/v0/iris_settings', iris_settings)
from ... import iris
if iris.client and config.get('iris_plan_integration', {}).get('activated'):
from . import team_iris_escalate
application.add_route('/api/v0/teams/{team}/iris_escalate', team_iris_escalate)

View File

@@ -0,0 +1,9 @@
from ... import iris
from ujson import dumps as json_dumps
def on_get(req, resp):
if iris.settings is None:
resp.body = json_dumps({'activated': False})
else:
resp.body = json_dumps(iris.settings)

View File

@@ -5,7 +5,7 @@ from urllib import unquote
from falcon import HTTPNotFound, HTTPBadRequest, HTTPError
from ujson import dumps as json_dumps
from ... import db
from ... import db, iris
from .users import get_user_data
from .rosters import get_roster_by_team_id
from ...auth import login_required, check_team_auth
@@ -13,7 +13,7 @@ from ...utils import load_json_body, invalid_char_reg, create_audit
from ...constants import TEAM_DELETED, TEAM_EDITED
cols = set(['name', 'slack_channel', 'email', 'scheduling_timezone'])
cols = set(['name', 'slack_channel', 'email', 'scheduling_timezone', 'iris_plan'])
def populate_team_users(cursor, team_dict):
@@ -60,7 +60,7 @@ def on_get(req, resp, team):
connection = db.connect()
cursor = connection.cursor(db.DictCursor)
cursor.execute('SELECT `id`, `name`, `email`, `slack_channel`, `scheduling_timezone` '
cursor.execute('SELECT `id`, `name`, `email`, `slack_channel`, `scheduling_timezone`, `iris_plan` '
'FROM `team` WHERE `name`=%s AND `active` = %s', (team, active))
results = cursor.fetchall()
if not results:
@@ -96,6 +96,13 @@ def on_put(req, resp, team):
if invalid_char:
raise HTTPBadRequest('invalid team name',
'team name contains invalid character "%s"' % invalid_char.group())
if 'iris_plan' in data:
iris_plan = data['iris_plan']
plan_resp = iris.client.get(iris.client.url + 'plans?name=%s&active=1' % iris_plan)
if plan_resp.status_code != 200 or plan_resp.json() == []:
raise HTTPBadRequest('invalid iris escalation plan', 'no iris plan named %s exists' % iris_plan)
set_clause = ', '.join(['`{0}`=%s'.format(d) for d in data_cols if d in cols])
query_params = tuple(data[d] for d in data_cols) + (team,)
try:

View File

@@ -0,0 +1,34 @@
from ... import db, iris
from ...utils import load_json_body
from ...auth import login_required
from falcon import HTTPBadRequest
from requests import ConnectionError
@login_required
def on_post(req, resp, team):
'''
Escalate to a team using the team's configured Iris plan. Configured in the
'iris_plan_integration' section of the configuration file.
'''
data = load_json_body(req)
connection = db.connect()
cursor = connection.cursor()
cursor.execute('SELECT iris_plan FROM team WHERE name = %s', team)
plan_name = cursor.fetchone()[0]
cursor.close()
connection.close()
requester = req.context.get('user')
if not requester:
requester = req.context['app']
data['requester'] = requester
if 'description' not in data or data['description'] == '':
raise HTTPBadRequest('Iris escalation failed', 'Escalation cannot have an empty description')
if plan_name is None:
raise HTTPBadRequest('Iris escalation failed', 'No escalation plan specified for team: %s' % team)
try:
iris.client.incident(plan_name, context=data)
except (ValueError, ConnectionError) as e:
raise HTTPBadRequest('Iris escalation failed', 'Iris client error: %s' % e)

View File

@@ -7,7 +7,7 @@ from ujson import dumps as json_dumps
from ...utils import load_json_body, invalid_char_reg, subscribe_notifications, create_audit
from ...constants import TEAM_CREATED
from ... import db
from ... import db, iris
from ...auth import login_required
constraints = {
@@ -23,7 +23,8 @@ constraints = {
def on_get(req, resp):
query = 'SELECT `name`, `email`, `slack_channel`, `scheduling_timezone` FROM `team`'
query = 'SELECT `name`, `email`, `slack_channel`, `scheduling_timezone`, `iris_plan` FROM `team`'
if 'active' not in req.params:
req.params['active'] = True
@@ -70,13 +71,21 @@ def on_post(req, resp):
raise HTTPBadRequest('invalid slack channel',
'slack channel name needs to start with #')
email = data.get('email')
iris_plan = data.get('iris_plan')
# validate Iris plan if provided and Iris is configured
if iris_plan is not None and iris.client is not None:
plan_resp = iris.client.get(iris.client.url + 'plans?name=%s&active=1' % iris_plan)
if plan_resp.status_code != 200 or plan_resp.json() == []:
raise HTTPBadRequest('invalid iris escalation plan', 'no iris plan named %s exists' % iris_plan)
connection = db.connect()
cursor = connection.cursor()
try:
cursor.execute('''
INSERT INTO `team` (`name`, `slack_channel`, `email`, `scheduling_timezone`)
VALUES (%s, %s, %s, %s)''', (team_name, slack, email, scheduling_timezone))
INSERT INTO `team` (`name`, `slack_channel`, `email`, `scheduling_timezone`, `iris_plan`)
VALUES (%s, %s, %s, %s, %s)''', (team_name, slack, email, scheduling_timezone, iris_plan))
team_id = cursor.lastrowid
query = '''
INSERT INTO `team_user` (`team_id`, `user_id`)

View File

@@ -11,7 +11,7 @@ import re
from beaker.middleware import SessionMiddleware
from falcon_cors import CORS
from . import db, constants
from . import db, constants, iris
import logging
logger = logging.getLogger('oncall.app')
@@ -116,6 +116,8 @@ def init(config):
db.init(config['db'])
constants.init(config)
if 'iris_plan_integration' in config:
iris.init(config['iris_plan_integration'])
init_falcon_api(config)
global application

14
src/oncall/iris.py Normal file
View File

@@ -0,0 +1,14 @@
# Copyright (c) LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license.
# See LICENSE in the project root for license information.
from irisclient import IrisClient
client = None
settings = None
def init(config):
global client
global settings
settings = {key: config[key] for key in config if key != 'api_key'}
client = IrisClient(config['app'], config['api_key'], config['api_host'])

View File

@@ -61,6 +61,7 @@ INDEX_CONTENT_SETTING = {
SLACK_INSTANCE = None
HEADER_COLOR = None
IRIS_PLAN_SETTINGS = None
def index(req, resp):
@@ -71,6 +72,7 @@ def index(req, resp):
slack_instance=SLACK_INSTANCE,
user_setting_note=INDEX_CONTENT_SETTING['user_setting_note'],
header_color=HEADER_COLOR,
iris_plan_settings=IRIS_PLAN_SETTINGS,
footer=INDEX_CONTENT_SETTING['footer']
)
@@ -112,8 +114,10 @@ def init(application, config):
global SLACK_INSTANCE
global HEADER_COLOR
global IRIS_PLAN_SETTINGS
SLACK_INSTANCE = config.get('slack_instance')
HEADER_COLOR = config.get('header_color', '#3a3a3a')
IRIS_PLAN_SETTINGS = config.get('iris_plan_integration')
application.add_sink(index, '/')
application.add_route('/static/bundles/{filename}',

View File

@@ -818,6 +818,34 @@ nav.subnav li.active {
font-style: italic;
}
#oncall-now-heading h4 {
display: inline-block;
}
body[data-authenticated="false"] #escalate-btn {
display: none;
}
#escalate-description {
height: 100px;
resize: vertical;
}
.text-actions > span {
cursor: pointer;
color: #000;
opacity: .3;
font-weight: bold;
}
.text-actions > span .grey-icon {
opacity: 1;
}
.text-actions > span:hover {
opacity: .5;
}
/* Teams.info */
.module-card .badge.toggle-rotation {
@@ -992,21 +1020,6 @@ div[data-admin="false"] .team-info .toggle-rotation {
margin-top: 10px;
}
.module-schedule .schedule-actions > span {
cursor: pointer;
color: #000;
opacity: .3;
font-weight: bold;
}
.module-schedule .schedule-actions > span .grey-icon {
opacity: 1;
}
.module-schedule .schedule-actions > span:hover {
opacity: .5;
}
.module-schedule[data-role="secondary"] .schedule-actions {
border-color: #E6E6FF;
}

View File

@@ -13,8 +13,10 @@ var oncall = {
logoutUrl: '/logout',
user: $('body').attr('data-user'),
userUrl: '/api/v0/users/',
irisSettingsUrl: '/api/v0/iris_settings',
rolesUrl: '/api/v0/roles/',
roles: null, // will be fetched from API
irisSettings: null,
modes: [
'email',
'sms',
@@ -34,6 +36,7 @@ var oncall = {
userInfo: null,
csrfKey: 'csrf-key',
userInfoPromise: $.Deferred(),
irisSettingsPromise: $.Deferred(),
rolesPromise: $.Deferred()
},
callbacks: {
@@ -48,8 +51,12 @@ var oncall = {
var self = this;
$.ajaxSetup({
cache: 'true',
headers: {'X-CSRF-TOKEN': localStorage.getItem(this.data.csrfKey)}
cache: 'true'
});
$.ajaxPrefilter(function(options, originalOptions, jqXHR) {
if (! options.crossDomain) {
jqXHR.setRequestHeader('X-CSRF-TOKEN', localStorage.getItem(oncall.data.csrfKey));
}
});
$(document).ajaxError(function(event, jqxhr, settings, thrownError){
@@ -63,7 +70,10 @@ var oncall = {
this.defineRoutes();
this.events.call(this);
this.registerHandlebarHelpers();
this.modal.init(this);
this.getIrisSettings();
this.data.irisSettingsPromise.done(function() {
self.modal.init(self);
});
if (this.data.user && this.data.user !== 'None') {
this.getUserInfo().done(this.getUpcomingShifts.bind(this));
} else {
@@ -87,7 +97,6 @@ var oncall = {
var data = JSON.parse(data),
token = data.csrf_token;
$.ajaxSetup({headers: {'X-CSRF-TOKEN': token}});
localStorage.setItem(self.data.csrfKey, token);
self.data.userInfo = data;
@@ -131,6 +140,13 @@ var oncall = {
self.renderUserInfo.call(self, data);
});
},
getIrisSettings: function (){
var self = this;
return $.get(this.data.irisSettingsUrl).done(function(data){
self.data.irisSettings = data;
self.data.irisSettingsPromise.resolve();
});
},
renderUserInfo: function(data){
var $body = this.data.$body,
$nav = $body.find('#navbar'),
@@ -733,11 +749,13 @@ var oncall = {
$calendar: null,
url: '/api/v0/teams/',
pageSource: $('#team-calendar-template').html(),
escalateModalTemplate: $('#team-escalate-modal'),
cardColumnTemplate: $('#card-column-template').html(),
cardInnerTemplate: $('#card-inner-slim-template').html(),
cardOncallTemplate: $('#card-oncall-template').html(),
addCardTemplate: $('#add-card-item-template').html(),
calendarTypesTemplate: $('#calendar-types-template').html(),
escalateModal: '#team-escalate-modal',
cardExtra: '.card-inner[data-collapsed]',
cardExtraChevron: '.card-inner[data-collapsed] .svg-icon-chevron',
timezoneDisplay: '.timezone-display',
@@ -754,7 +772,7 @@ var oncall = {
this.getData(name);
oncall.callbacks.onLogin = function(){
self.init(name);
}
};
oncall.callbacks.onLogout = function(){
self.checkIfAdmin();
self.data.$calendar.incalendar('updateCalendarOption', 'user', null);
@@ -851,7 +869,6 @@ var oncall = {
}
);
});
},
checkIfAdmin: function(){
var data = this.data.teamData;
@@ -869,8 +886,16 @@ var oncall = {
renderTeamSummary: function(data){
var template = Handlebars.compile(this.data.cardOncallTemplate),
$container = this.data.$page.find('#oncall-now-container');
$container.html(template(data));
self = this;
data.showEscalate = oncall.data.user && this.data.teamData.iris_plan;
oncall.data.irisSettingsPromise.done(function(){
data.showEscalate = data.showEscalate && oncall.data.irisSettings.activated;
if (data.showEscalate) {
data.iris_plan = self.data.teamData.iris_plan;
}
$container.html(template(data));
self.setupEscalateModal();
});
},
renderCalendarTypes: function(incalendar){
var template = Handlebars.compile(this.data.calendarTypesTemplate),
@@ -930,6 +955,46 @@ var oncall = {
.append('<label class="label-col">Slack</label>')
.append('<span class="data-col">' + userData.contacts.im + '</span>')
)
},
setupEscalateModal: function(){
var $modal = $(this.data.escalateModal),
$modalForm = $modal.find('.modal-form'),
$modalInput = $modalForm.find('.create-input'),
$modalBtn = $modal.find('#escalate-btn'),
$cta = $modal.find('.modal-cta'),
self = this;
$modal.on('shown.bs.modal', function(e){
$modalInput.trigger('focus');
});
$modalBtn.on('click', function(e){
$cta.addClass('loading disabled').prop('disabled', true);
e.preventDefault();
$modal.find('.alert').remove();
$.ajax({
type: 'POST',
url: self.data.url + self.data.teamName + '/iris_escalate',
contentType: 'application/json',
dataType: 'html',
data: JSON.stringify({description:$modalForm.find('#escalate-description').val()})
}).done(function(data){
$modal.modal('hide');
oncall.alerts.removeAlerts();
oncall.alerts.createAlert('Escalated incident to ' + self.data.teamName + ' successfully, using the ' + self.data.teamData.iris_plan + ' Iris plan.', 'success');
}).fail(function(data){
var error = oncall.isJson(data.responseText) ? JSON.parse(data.responseText).description : data.responseText || 'Escalation failed.';
oncall.alerts.createAlert(error, 'danger');
}).always(function(){
$cta.removeClass('loading disabled').prop('disabled', false);
});
});
$modal.on('hide.bs.modal', function(){
$modal.find('.alert').remove();
$modalForm[0].reset();
});
}
},
info: {
@@ -2140,6 +2205,7 @@ var oncall = {
$teamEmail = $modalForm.find('#team-email'),
$teamSlack = $modalForm.find('#team-slack'),
$teamTimezone = $modalForm.find('#team-timezone'),
$teamIrisPlan = $modalForm.find('#team-irisplan'),
self = this,
$btn,
action;
@@ -2151,6 +2217,30 @@ var oncall = {
$teamName.val($btn.attr('data-modal-name'));
$teamEmail.val($btn.attr('data-modal-email'));
$teamSlack.val($btn.attr('data-modal-slack'));
$teamIrisPlan.val($btn.attr('data-modal-irisplan'));
results = new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: oncall.data.irisSettings.api_host + oncall.data.irisSettings.plan_url
+ '?name__startswith=%QUERY&fields=name',
wildcard: '%QUERY'
}
});
$('#team-irisplan').typeahead(null, {
hint: true,
async: true,
highlight: true,
source: results,
display: 'name',
templates: {
empty: '<div>&nbsp; No plans found. </div>'
}
}).on('typeahead:select', function(){
$(this).attr('value', $(this).val());
});
if ($btn.attr('data-modal-timezone')) {
$teamTimezone.val($btn.attr('data-modal-timezone'));
}

View File

@@ -144,6 +144,12 @@
<option value="UTC">UTC</option>
</select>
</p>
{% if iris_plan_settings.activated %}
<p>
<label for="team-irisplan"> Team Iris Escalation Plan:</label>
<input type="text" data-type="plans" name="iris_plan" class="form-control" id="team-irisplan" placeholder="Plan Name" />
</p>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>

View File

@@ -235,7 +235,7 @@
<h3>
{{name}}
<span class="edit-team-name">
<i class="svg-icon svg-icon-pencil" data-toggle="modal" data-target="#team-edit-modal" data-modal-action="oncall.team.updateTeamName" data-modal-title="Update Team Info" data-modal-name="{{#if name}}{{name}}{{else}}{{@key}}{{/if}}" data-modal-email="{{email}}" data-modal-slack="{{slack_channel}}" data-modal-timezone="{{scheduling_timezone}}" data-admin-action="true">
<i class="svg-icon svg-icon-pencil" data-toggle="modal" data-target="#team-edit-modal" data-modal-action="oncall.team.updateTeamName" data-modal-title="Update Team Info" data-modal-name="{{#if name}}{{name}}{{else}}{{@key}}{{/if}}" data-modal-email="{{email}}" data-modal-slack="{{slack_channel}}" data-modal-timezone="{{scheduling_timezone}}" data-modal-irisplan="{{iris_plan}}" data-admin-action="true">
<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 8 8" style="fill: currentColor">
<path d="M6 0l-1 1 2 2 1-1-2-2zm-2 2l-4 4v2h2l4-4-2-2z" />
</svg>
@@ -449,8 +449,16 @@
<!--// On call now partial -->
<script id="card-oncall-template" type="text/x-handlebars-template">
<div class="module module-card module-top-edge">
<div class="module-heading module-card-heading border-bottom">
<h4>On Call Now</h4>
<div class="module-heading module-card-heading border-bottom text-actions" id="oncall-now-heading">
<h4>On Call Now </h4>
{{#if showEscalate}}
<span class="pull-right" data-toggle="modal" data-target="#team-escalate-modal" id="escalate-btn">
<svg class="svg-icon grey-icon magic" xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 10 10">
<path d="M4 0c-1.1 0-2 .9-2 2 0 1.04-.52 1.98-1.34 2.66-.41.34-.66.82-.66 1.34h8c0-.52-.24-1-.66-1.34-.82-.68-1.34-1.62-1.34-2.66 0-1.1-.89-2-2-2zm-1 7c0 .55.45 1 1 1s1-.45 1-1h-2z" />
</svg>
Escalate
</span>
{{/if}}
</div>
<ul>
{{#ifNotEmpty current.primary}}
@@ -518,6 +526,32 @@
{{/ifNotEmpty}}
</ul>
</div>
<!-- Team Escalation modal -->
<div class="modal fade" id="team-escalate-modal" tabindex="-1" role="dialog" aria-labelledby="team-escalate">
<div class="modal-dialog" role="document">
<form class="modal-form modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
<h4 class="modal-title">Escalate</h4>
</div>
<div class="modal-body">
<p>
Escalating to the team will trigger an Iris incident with the provided description below, with your name
associated as the requester. This incident will trigger an escalation following the Iris plan configured
for this team, which is currently: <b>{{ iris_plan }}.</b>
</p>
<p>
<label class="light" for="escalate-description"> Description: </label>
<textarea name="escalate_description" id="escalate-description" class="form-control" placeholder="Description"></textarea>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary modal-cta" id="escalate-btn" data-dismiss="modal"><span class="btn-text">Escalate</span> <i class="loader loader-small"></i></button>
</div>
</form>
</div>
</div>
</script>
<!--// **********************
@@ -684,7 +718,7 @@
</ul>
</li>
</ul>
<div class="schedule-actions">
<div class="schedule-actions text-actions">
<span class="populate-schedule pull-left" data-toggle="modal" data-target="#populate-schedule-modal">
<i class="svg-icon svg-icon-cal grey-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 8 8">