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

Use iris dynamic plans to escalate

Reduce plan spam in Iris from every Oncall team making a plan,
instead designate medium and urgent escalation plans and
dynamically determine targets.
This commit is contained in:
Daniel Wang
2017-08-15 14:31:06 -07:00
committed by Daniel Wang
parent 8e5d760306
commit ee6f4bdc9c
11 changed files with 135 additions and 33 deletions

View File

@@ -85,6 +85,18 @@ iris_plan_integration:
api_key: magic
api_host: http://localhost:16649
plan_url: '/v0/applications/oncall/plans'
urgent_plan:
name: 'Oncall Urgent'
dynamic_targets:
- role: 'oncall-primary'
- role: 'team'
- role: 'manager'
medium_plan:
name: 'Oncall Medium'
dynamic_targets:
- role: 'oncall-primary'
- role: 'team'
- role: 'manager'
# slack:

View File

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

View File

@@ -58,7 +58,7 @@ def test_api_v0_get_team(team, role, roster, schedule):
assert re.status_code == 200
team = re.json()
assert isinstance(team, dict)
expected_set = {'users', 'admins', 'services', 'rosters', 'name', 'id', 'slack_channel', 'email', 'scheduling_timezone', 'iris_plan'}
expected_set = {'users', 'admins', 'services', 'rosters', 'name', 'id', 'slack_channel', 'email', 'scheduling_timezone', 'iris_plan', 'iris_enabled'}
assert expected_set == set(team.keys())
# it should also support filter by fields
@@ -66,7 +66,7 @@ def test_api_v0_get_team(team, role, roster, schedule):
assert re.status_code == 200
team = re.json()
assert isinstance(team, dict)
expected_set = {'users', 'admins', 'services', 'name', 'id', 'slack_channel', 'email', 'scheduling_timezone', 'iris_plan'}
expected_set = {'users', 'admins', 'services', 'name', 'id', 'slack_channel', 'email', 'scheduling_timezone', 'iris_plan', 'iris_enabled'}
assert expected_set == set(team.keys())

View File

@@ -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', 'iris_plan'])
cols = set(['name', 'slack_channel', 'email', 'scheduling_timezone', 'iris_plan', 'iris_enabled'])
def populate_team_users(cursor, team_dict):
@@ -145,7 +145,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`, `iris_plan` '
cursor.execute('SELECT `id`, `name`, `email`, `slack_channel`, `scheduling_timezone`, `iris_plan`, `iris_enabled` '
'FROM `team` WHERE `name`=%s AND `active` = %s', (team, active))
results = cursor.fetchall()
if not results:

View File

@@ -1,18 +1,22 @@
from ... import db, iris
from ...utils import load_json_body
from ...auth import login_required
from ...constants import URGENT, MEDIUM, CUSTOM
from falcon import HTTPBadRequest
from requests import ConnectionError
from requests import ConnectionError, HTTPError
@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. If iris plan integration
is not activated, this endpoint will be disabled.
Escalate to a team using Iris. Configured in the 'iris_plan_integration' section of
the configuration file. Escalation plan is specified via keyword, currently: 'urgent',
'medium', or 'custom'. These keywords correspond to the plan specified in the
iris_plan_integration urgent_plan key, the iris integration medium_plan key, and the team's
iris plan defined in the DB, respectively. If no plan is specified, the team's custom plan will be
used. If iris plan integration is not activated, this endpoint will be disabled.
**Example request:**
**Example request:**
.. sourcecode:: http
@@ -21,6 +25,7 @@ def on_post(req, resp, team):
{
"description": "Something bad happened!",
"plan": "urgent"
}
:statuscode 200: Incident created
@@ -29,12 +34,29 @@ def on_post(req, resp, team):
'''
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()
plan = data.get('plan')
dynamic = False
if plan == URGENT:
plan_settings = iris.settings['urgent_plan']
dynamic = True
elif plan == MEDIUM:
plan_settings = iris.settings['medium_plan']
dynamic = True
elif plan == CUSTOM or plan is None:
# Default to team's custom plan for backwards compatibility
connection = db.connect()
cursor = connection.cursor()
cursor.execute('SELECT iris_plan FROM team WHERE name = %s', team)
if cursor.rowcount == 0:
cursor.close()
connection.close()
raise HTTPBadRequest('Iris escalation failed', 'No escalation plan specified '
'and team has no custom escalation plan defined')
plan_name = cursor.fetchone()[0]
cursor.close()
connection.close()
else:
raise HTTPBadRequest('Iris escalation failed', 'Invalid escalation plan')
requester = req.context.get('user')
if not requester:
@@ -42,9 +64,21 @@ def on_post(req, resp, team):
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:
if dynamic:
plan_name = plan_settings['name']
targets = plan_settings['dynamic_targets']
for t in targets:
# Set target to team name if not overridden in settings
if 'target' not in t:
t['target'] = team
re = iris.client.post(iris.client.url + 'incidents',
json={'plan': plan_name, 'context': data, 'dynamic_targets': targets})
re.raise_for_status()
incident_id = re.json()
else:
incident_id = iris.client.incident(plan_name, context=data)
except (ValueError, ConnectionError, HTTPError) as e:
raise HTTPBadRequest('Iris escalation failed', 'Iris client error: %s' % e)
resp.body = str(incident_id)

View File

@@ -148,6 +148,7 @@ def on_post(req, resp):
'slack channel name needs to start with #')
email = data.get('email')
iris_plan = data.get('iris_plan')
iris_enabled = data.get('iris_enabled', False)
# validate Iris plan if provided and Iris is configured
if iris_plan is not None and iris.client is not None:
@@ -159,8 +160,8 @@ def on_post(req, resp):
cursor = connection.cursor()
try:
cursor.execute('''
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))
INSERT INTO `team` (`name`, `slack_channel`, `email`, `scheduling_timezone`, `iris_plan`, `iris_enabled`)
VALUES (%s, %s, %s, %s, %s, %s)''', (team_name, slack, email, scheduling_timezone, iris_plan, iris_enabled))
team_id = cursor.lastrowid
query = '''

View File

@@ -26,6 +26,10 @@ ROSTER_DELETED = 'roster_deleted'
ADMIN_CREATED = 'admin_created'
ADMIN_DELETED = 'admin_deleted'
URGENT = 'urgent'
MEDIUM = 'medium'
CUSTOM = 'custom'
DEFAULT_ROLES = None
DEFAULT_MODES = None
DEFAULT_TIMES = None

View File

@@ -859,6 +859,10 @@ body[data-authenticated="false"] #escalate-btn {
display: none;
}
#escalate-plan {
width: 35%;
}
#escalate-description {
height: 100px;
resize: vertical;
@@ -1855,6 +1859,7 @@ select.form-control {
transition: box-shadow .15s;
color: rgba(0,0,0,.85);
padding: 4px 12px;
padding-right: 25px;
width: 100%;
background: url('../images/chevron-bottom.svg') right 9px top 13px no-repeat transparent;
}

View File

@@ -695,6 +695,7 @@ var oncall = {
email = $form.find('#team-email').val(),
slack = $form.find('#team-slack').val(),
timezone = $form.find('#team-timezone').val(),
$irisEnabled = $form.find('#team-iris-enabled'),
model = {};
$form.find(':input[type="text"]').each(function(){
@@ -704,6 +705,7 @@ var oncall = {
}
});
model[$form.find('#team-timezone').attr('name')] = timezone;
model[$irisEnabled.attr('name')] = $irisEnabled.prop('checked');
$cta.addClass('loading disabled').prop('disabled', true);
$.ajax({
@@ -732,15 +734,15 @@ var oncall = {
slack = $form.find('#team-slack').val(),
timezone = $form.find('#team-timezone').val(),
irisPlan = $form.find('#team-irisplan').val(),
irisEnabled = $form.find('#team-iris-enabled').prop('checked'),
model = {};
$form.find(':input[type="text"]').each(function(){
$form.find(':input[type="text"]').not('.tt-hint').each(function(){
var $this = $(this);
if ($this.val().length) {
model[$this.attr('name')] = $this.val();
}
model[$this.attr('name')] = $this.val();
});
model[$form.find('#team-timezone').attr('name')] = timezone;
model[$form.find('#team-iris-enabled').attr('name')] = irisEnabled;
$cta.addClass('loading disabled').prop('disabled', true);
$.ajax({
@@ -757,6 +759,7 @@ var oncall = {
slack_channel: slack,
scheduling_timezone: timezone,
iris_plan: irisPlan,
iris_enabled: irisEnabled ? '1' : '0',
page: self.data.route
},
state = (self.data.route === 'calendar') ? name : '/team/' + name + '/' + self.data.route;
@@ -835,6 +838,7 @@ var oncall = {
addCardTemplate: $('#add-card-item-template').html(),
calendarTypesTemplate: $('#calendar-types-template').html(),
escalateModal: '#team-escalate-modal',
escalatePlanSelect: '#escalate-plan',
cardExtra: '.card-inner[data-collapsed]',
cardExtraChevron: '.card-inner[data-collapsed] .svg-icon-chevron',
oncallNowDisplayRoles: ['primary', 'secondary', 'manager'],
@@ -861,6 +865,7 @@ var oncall = {
},
events: function(){
this.data.$page.on('click', this.data.cardExtraChevron, this.toggleCardExtra.bind(this));
this.data.$page.on('change', this.data.escalatePlanSelect, this.updatePlanDescription.bind(this));
router.updatePageLinks();
},
getData: function(name){
@@ -964,12 +969,12 @@ var oncall = {
},
renderTeamSummary: function(data){
var template = Handlebars.compile(this.data.cardOncallTemplate),
$container = this.data.$page.find('#oncall-now-container');
$container = this.data.$page.find('#oncall-now-container'),
self = this,
roles = oncall.data.roles;
data.oncallNow = [];
data.showEscalate = oncall.data.user && this.data.teamData.iris_plan;
data.showEscalate = oncall.data.user && this.data.teamData.iris_enabled;
// Sort data for oncall now module by display_order
@@ -1002,7 +1007,9 @@ var oncall = {
oncall.data.irisSettingsPromise.done(function(){
data.showEscalate = data.showEscalate && oncall.data.irisSettings.activated;
if (data.showEscalate) {
data.iris_plan = self.data.teamData.iris_plan;
data.custom_plan = self.data.teamData.iris_plan;
data.urgent_plan = oncall.data.irisSettings.urgent_plan.name;
data.medium_plan = oncall.data.irisSettings.medium_plan.name;
}
$container.html(template(data));
self.setupEscalateModal();
@@ -1072,14 +1079,33 @@ var oncall = {
);
}
},
updatePlanDescription: function() {
var $modal = $(this.data.escalateModal),
plan = $modal.find('#escalate-plan').find('option:selected').text(),
$description = $modal.find('#escalate-plan-description');
switch (plan){
case oncall.data.irisSettings.urgent_plan.name:
$description.html('<i>For urgent escalations. <b>WARNING: This will call the current on-call</b></i>');
break;
case oncall.data.irisSettings.medium_plan.name:
$description.html('<i>For medium-priority escalations.</i>');
break;
case this.data.teamData.iris_plan:
$description.html('<i>Escalate using this team\'s custom plan</i>');
break;
}
},
setupEscalateModal: function(){
var $modal = $(this.data.escalateModal),
$modalForm = $modal.find('.modal-form'),
$modalBody = $modal.find('.modal-body'),
$modalInput = $modalForm.find('.create-input'),
$modalBtn = $modal.find('#escalate-btn'),
$cta = $modal.find('.modal-cta'),
self = this;
this.updatePlanDescription();
$modal.on('shown.bs.modal', function(){
$modalInput.trigger('focus');
});
@@ -1094,14 +1120,16 @@ var oncall = {
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: JSON.stringify({description: $modalForm.find('#escalate-description').val(),
plan: $modalForm.find('#escalate-plan').val()})
}).done(function(response){
$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');
oncall.alerts.createAlert('Escalated incident to ' + self.data.teamName + ' successfully. <a href="'
+ oncall.data.irisSettings.api_host + '/incidents/' + response + '">See incident details.</a>', 'success');
}).fail(function(data){
var error = oncall.isJson(data.responseText) ? JSON.parse(data.responseText).description : data.responseText || 'Escalation failed.';
oncall.alerts.createAlert(error, 'danger');
oncall.alerts.createAlert(error, 'danger', $modalBody);
}).always(function(){
$cta.removeClass('loading disabled').prop('disabled', false);
});
@@ -2324,6 +2352,7 @@ var oncall = {
$teamSlack = $modalForm.find('#team-slack'),
$teamTimezone = $modalForm.find('#team-timezone'),
$teamIrisPlan = $modalForm.find('#team-irisplan'),
$teamIrisEnabled = $modalForm.find('#team-iris-enabled'),
self = this,
$btn,
action;
@@ -2336,6 +2365,7 @@ var oncall = {
$teamEmail.val($btn.attr('data-modal-email'));
$teamSlack.val($btn.attr('data-modal-slack'));
$teamIrisPlan.val($btn.attr('data-modal-irisplan'));
$teamIrisEnabled.prop('checked', $btn.attr('data-modal-iris-enabled') === '1');
$planInput = $('#team-irisplan');
results = new Bloodhound({

View File

@@ -144,6 +144,8 @@
<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>
<input type="checkbox" id="team-iris-enabled" name="iris_enabled">
<label for="team-iris-enabled">Enable Iris Escalation for this team</label>
{% endif %}
</div>
<div class="modal-footer">

View File

@@ -274,7 +274,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-modal-irisplan="{{iris_plan}}" 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-modal-iris-enabled="{{iris_enabled}}" 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>
@@ -557,6 +557,19 @@
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-plan"> Escalation Plan: </label>
<select name="from" class="form-control" id="escalate-plan">
{{#if custom_plan}}
<option value="custom">{{custom_plan}}</option>
{{/if}}
<option value="medium">{{medium_plan}}</option>
<option value="urgent">{{urgent_plan}}</option>
</select>
<p id="escalate-plan-description">
</p>
</p>
<p>
<label class="light" for="escalate-description"> Description: </label>
<textarea name="escalate_description" id="escalate-description" class="form-control" placeholder="Description"></textarea>