You've already forked oncall
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:
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = '''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user