Commit 75293bff authored by Tristan Read's avatar Tristan Read Committed by Kushal Pandya

Add sla columns to project incident settings table

- Update schema
- Update helper specs
parent 6285814e
......@@ -11,6 +11,8 @@ export default {
GlTab,
AlertsSettingsForm,
PagerDutySettingsForm,
ServiceLevelAgreementForm: () =>
import('ee_component/incidents_settings/components/service_level_agreement_form.vue'),
},
tabs: INTEGRATION_TABS_CONFIG,
i18n: I18N_INTEGRATION_TABS,
......@@ -45,6 +47,7 @@ export default {
>
<component :is="tab.component" class="gl-pt-3" :data-testid="`${tab.component}-tab`" />
</gl-tab>
<service-level-agreement-form />
</gl-tabs>
</div>
</section>
......
......@@ -21,6 +21,9 @@ export default () => {
pagerdutyWebhookUrl,
pagerdutyResetKeyPath,
autoCloseIncident,
slaActive,
slaMinutes,
slaFeatureAvailable,
},
} = el;
......@@ -40,6 +43,11 @@ export default () => {
active: parseBoolean(pagerdutyActive),
webhookUrl: pagerdutyWebhookUrl,
},
serviceLevelAgreementSettings: {
active: parseBoolean(slaActive),
minutes: slaMinutes,
available: parseBoolean(slaFeatureAvailable),
},
},
render(createElement) {
return createElement(SettingsTabs);
......
......@@ -51,3 +51,5 @@ module IncidentManagement
end
end
end
IncidentManagement::ProjectIncidentManagementSetting.prepend_if_ee('EE::IncidentManagement::ProjectIncidentManagementSetting')
---
title: Add Incident Sla timer columns to DB
merge_request: 44099
author:
type: added
---
name: incident_sla_dev
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43648
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/266931
group: group::health
type: development
default_enabled: false
---
name: incident_sla
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43648
rollout_issue_url:
group: group::health
type: licensed
default_enabled: true
# frozen_string_literal: true
class AddSlaMinutesToProjectIncidentManagementSettings < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :project_incident_management_settings, :sla_timer, :boolean, default: false
add_column :project_incident_management_settings, :sla_timer_minutes, :integer
end
end
77fa26f97216c1fa3d0b046dfabac92a5afa2a0eaf33439117b29ac81e740e4a
\ No newline at end of file
......@@ -14800,6 +14800,8 @@ CREATE TABLE project_incident_management_settings (
encrypted_pagerduty_token bytea,
encrypted_pagerduty_token_iv bytea,
auto_close_incident boolean DEFAULT true NOT NULL,
sla_timer boolean DEFAULT false,
sla_timer_minutes integer,
CONSTRAINT pagerduty_token_iv_length_constraint CHECK ((octet_length(encrypted_pagerduty_token_iv) <= 12)),
CONSTRAINT pagerduty_token_length_constraint CHECK ((octet_length(encrypted_pagerduty_token) <= 255))
);
......
<script>
import {
GlButton,
GlForm,
GlFormCheckbox,
GlFormGroup,
GlFormInput,
GlFormSelect,
GlFormText,
GlTab,
} from '@gitlab/ui';
import { s__ } from '~/locale';
const units = {
minutes: {
value: 'minutes',
text: s__('IncidentSettings|minutes'),
multiplier: 1,
step: 15,
},
hours: {
value: 'hours',
text: s__('IncidentSettings|hours'),
multiplier: 60,
step: 1,
},
};
export default {
i18n: {
description: s__(
'IncidentSettings|You may choose to introduce a countdown timer in incident issues to better track Service Level Agreements (SLAs). The timer is automatically started when the incident is created, and sets a time limit for the incident to be resolved in. When activated, "time to SLA" countdown will appear on all new incidents.',
),
checkboxDetail: s__(
'IncidentSettings|When activated, this will apply to all new incidents within the project',
),
validFeedback: s__('IncidentSettings|Time limit must be a multiple of 15 minutes'),
},
selectOptions: Object.values(units),
units,
components: {
GlButton,
GlForm,
GlFormCheckbox,
GlFormGroup,
GlFormInput,
GlFormSelect,
GlFormText,
GlTab,
},
inject: ['service', 'serviceLevelAgreementSettings'],
data() {
return {
available: this.serviceLevelAgreementSettings.available,
duration: this.serviceLevelAgreementSettings.minutes ?? '',
enabled: this.serviceLevelAgreementSettings.active,
loading: false,
unit: this.$options.units.minutes.value,
};
},
computed: {
disableSubmit() {
return this.loading || !this.showValidFeedback;
},
invalidFeedback() {
// Don't validate when checkbox is disabled
if (!this.enabled) {
return '';
}
// This checks for empty and non-number values, because input elements of
// type 'number' automatically convert a non-number input to an empty string.
if (this.duration === '') {
return s__('IncidentSettings|Time limit must be a valid number');
}
if (this.duration <= 0) {
return s__('IncidentSettings|Time limit must be greater than 0');
}
// We're looking for a minutes value provided in multiples of 15
const minutes = this.duration * this.$options.units[this.unit].multiplier;
if (minutes % 15 !== 0) {
return s__('IncidentSettings|Time limit must be a multiple of 15 minutes');
}
return '';
},
showValidFeedback() {
return !this.invalidFeedback;
},
},
methods: {
updateServiceLevelAgreementSettings() {
this.loading = true;
return this.service
.updateSettings({
sla_timer: this.enabled,
sla_timer_minutes: this.duration * this.$options.units[this.unit].multiplier,
})
.catch(() => {
this.loading = false;
});
},
},
};
</script>
<template>
<gl-tab
v-if="available"
key="service-level-agreement"
:title="s__('IncidentSettings|Incident settings')"
>
<gl-form class="gl-pt-3" @submit.prevent="updateServiceLevelAgreementSettings">
<p class="gl-line-height-20">
{{ $options.i18n.description }}
</p>
<gl-form-checkbox v-model="enabled" class="gl-my-4">
<span>{{ s__('IncidentSettings|Activate "time to SLA" countdown timer') }}</span>
<gl-form-text class="gl-font-base gl-text-gray-400">
{{ $options.i18n.checkboxDetail }}
</gl-form-text>
</gl-form-checkbox>
<gl-form-group
:invalid-feedback="invalidFeedback"
:label="s__('IncidentSettings|Time limit')"
label-for="sla-duration"
:state="showValidFeedback"
>
<div class="gl-display-flex gl-flex-direction-row">
<gl-form-input
id="sla-duration"
v-model="duration"
number
size="xs"
:step="$options.units[unit].step"
type="number"
/>
<gl-form-select
v-model="unit"
class="gl-w-auto gl-ml-3 gl-line-height-normal gl-border-gray-400"
:options="$options.selectOptions"
/>
</div>
<template name="valid-feedback">
<gl-form-text v-show="showValidFeedback" class="gl-font-base gl-text-gray-400!">
{{ $options.i18n.validFeedback }}
</gl-form-text>
</template>
</gl-form-group>
<gl-button variant="success" type="submit" :disabled="disableSubmit" :loading="loading">
{{ __('Save changes') }}
</gl-button>
</gl-form>
</gl-tab>
</template>
......@@ -19,6 +19,18 @@ module EE
def has_status_page_license?
project.feature_available?(:status_page, current_user)
end
def sla_feature_available?
::Feature.enabled?(:incident_sla_dev, @project) && @project.feature_available?(:incident_sla, current_user)
end
def track_tracing_external_url
external_url_previous_change = project&.tracing_setting&.external_url_previous_change
return unless external_url_previous_change
return unless external_url_previous_change[0].blank? && external_url_previous_change[1].present?
::Gitlab::Tracking.event('project:operations:tracing', "external_url_populated")
end
end
override :permitted_project_params
......@@ -29,12 +41,28 @@ module EE
permitted_params.merge!(status_page_setting_params)
end
if sla_feature_available?
incident_params = Array(permitted_params[:incident_management_setting_attributes])
permitted_params[:incident_management_setting_attributes] = incident_params.push(*sla_timer_params)
end
permitted_params
end
def status_page_setting_params
{ status_page_setting_attributes: [:status_page_url, :aws_s3_bucket_name, :aws_region, :aws_access_key, :aws_secret_key, :enabled] }
end
def sla_timer_params
[:sla_timer, :sla_timer_minutes]
end
override :track_events
def track_events(result)
super
track_tracing_external_url if result[:status] == :success
end
end
end
end
......
......@@ -40,8 +40,27 @@ module EE
super.merge(opsgenie_mvc_data)
end
override :operations_settings_data
def operations_settings_data
super.merge(incident_sla_data)
end
private
def incident_sla_data
setting = project_incident_management_setting
{
sla_feature_available: sla_feature_available?.to_s,
sla_active: setting.sla_timer.to_s,
sla_minutes: setting.sla_timer_minutes
}
end
def sla_feature_available?
::Feature.enabled?(:incident_sla_dev, @project) && @project.feature_available?(:incident_sla, current_user)
end
def opsgenie_mvc_data
return {} unless alerts_service.opsgenie_mvc_available?
......
# frozen_string_literal: true
module EE
module IncidentManagement
module ProjectIncidentManagementSetting
extend ActiveSupport::Concern
ONE_YEAR_IN_MINUTES = 1.year / 1.minute
prepended do
validates :sla_timer_minutes,
presence: true,
numericality: { greater_than_or_equal_to: 15, less_than_or_equal_to: ONE_YEAR_IN_MINUTES },
if: :sla_timer
end
end
end
end
......@@ -87,6 +87,7 @@ class License < ApplicationRecord
group_repository_analytics
group_saml
group_wikis
incident_sla
ide_schema_config
issues_analytics
jira_issues_integration
......
---
title: Add incident SLA to operations settings
merge_request: 44099
author:
type: added
......@@ -56,7 +56,7 @@ RSpec.describe Projects::Settings::OperationsController do
context 'with a license' do
before do
stub_licensed_features(status_page: true)
stub_licensed_features(status_page: true, incident_sla: true)
end
context 'with maintainer role' do
......@@ -87,7 +87,7 @@ RSpec.describe Projects::Settings::OperationsController do
context 'without license' do
before do
stub_licensed_features(status_page: false)
stub_licensed_features(status_page: false, incident_sla: false)
end
it_behaves_like 'user with read access', :public
......@@ -115,7 +115,7 @@ RSpec.describe Projects::Settings::OperationsController do
context 'with a license' do
before do
stub_licensed_features(status_page: true)
stub_licensed_features(status_page: true, incident_sla: true)
end
context 'with non maintainer roles' do
......@@ -181,24 +181,96 @@ RSpec.describe Projects::Settings::OperationsController do
expect(project.status_page_setting).to be_nil
end
end
context 'indident management settings' do
let(:project) { create(:project) }
let(:params) { attributes_for(:project_incident_management_setting) }
subject(:incident_management_setting) do
update_project(project, incident_management_params: params)
project.incident_management_setting
end
before do
project.add_maintainer(user)
end
shared_examples 'can set the sla timer settings' do
let(:sla_settings) do
{
sla_timer: 'true',
sla_timer_minutes: 60
}
end
before do
params.merge!(sla_settings)
end
it 'updates the sla settings' do
setting = incident_management_setting
expect(setting.sla_timer).to eq(true)
expect(setting.sla_timer_minutes).to eq(60)
end
end
context 'without existing incident management setting' do
it { is_expected.to be_a(IncidentManagement::ProjectIncidentManagementSetting) }
it_behaves_like 'can set the sla timer settings'
end
context 'with existing incident management setting' do
before do
create(:project_incident_management_setting, project: project)
end
it { is_expected.to be_a(IncidentManagement::ProjectIncidentManagementSetting) }
it_behaves_like 'can set the sla timer settings'
end
end
end
context 'without a license' do
let(:project) { create(:project) }
before do
stub_licensed_features(status_page: false)
project.add_maintainer(user)
stub_licensed_features(status_page: false, incident_sla: false)
end
it_behaves_like 'user without write access', :public, :maintainer
it_behaves_like 'user without write access', :private, :maintainer
it_behaves_like 'user without write access', :internal, :maintainer
it 'cannot update sla timer settings', :aggregate_failures do
default_attributes = attributes_for(:project_incident_management_setting)
sla_settings = {
sla_timer: 'true',
sla_timer_minutes: 60
}
update_project(project, incident_management_params: default_attributes.merge(sla_settings) )
setting = project.incident_management_setting
expect(setting.sla_timer).to eq(default_attributes[:sla_timer])
expect(setting.sla_timer_minutes).to eq(default_attributes[:sla_timer_minutes])
end
end
private
def update_project(project, status_page_params: nil)
def update_project(project, incident_management_params: nil, status_page_params: nil)
patch :update, params: project_params(
project,
status_page_params: status_page_params
status_page_params: status_page_params,
incident_management_params: incident_management_params
)
project.reload
......@@ -207,12 +279,13 @@ RSpec.describe Projects::Settings::OperationsController do
private
def project_params(project, status_page_params: nil)
def project_params(project, incident_management_params: nil, status_page_params: nil)
{
namespace_id: project.namespace,
project_id: project,
project: {
status_page_setting_attributes: status_page_params
status_page_setting_attributes: status_page_params,
incident_management_setting_attributes: incident_management_params
}
}
end
......
# frozen_string_literal: true
FactoryBot.modify do
factory :project_incident_management_setting, class: 'IncidentManagement::ProjectIncidentManagementSetting' do
sla_timer { false }
sla_timer_minutes { nil }
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Alert integration settings form should match the default snapshot 1`] = `
<gl-tab-stub
title="Incident settings"
>
<gl-form-stub
class="gl-pt-3"
>
<p
class="gl-line-height-20"
>
You may choose to introduce a countdown timer in incident issues to better track Service Level Agreements (SLAs). The timer is automatically started when the incident is created, and sets a time limit for the incident to be resolved in. When activated, "time to SLA" countdown will appear on all new incidents.
</p>
<gl-form-checkbox-stub
checked="true"
class="gl-my-4"
>
<span>
Activate "time to SLA" countdown timer
</span>
<gl-form-text-stub
class="gl-font-base gl-text-gray-400"
tag="small"
textvariant="muted"
>
When activated, this will apply to all new incidents within the project
</gl-form-text-stub>
</gl-form-checkbox-stub>
<gl-form-group-stub
invalid-feedback=""
label="Time limit"
label-for="sla-duration"
state="true"
>
<div
class="gl-display-flex gl-flex-direction-row"
>
<gl-form-input-stub
id="sla-duration"
number=""
size="xs"
step="15"
type="number"
value="15"
/>
<gl-form-select-stub
class="gl-w-auto gl-ml-3 gl-line-height-normal gl-border-gray-400"
options="[object Object],[object Object]"
value="minutes"
/>
</div>
<gl-form-text-stub
class="gl-font-base gl-text-gray-400!"
tag="small"
textvariant="muted"
>
Time limit must be a multiple of 15 minutes
</gl-form-text-stub>
</gl-form-group-stub>
<gl-button-stub
buttontextclasses=""
category="primary"
icon=""
size="medium"
type="submit"
variant="success"
>
Save changes
</gl-button-stub>
</gl-form-stub>
</gl-tab-stub>
`;
import { mount, shallowMount } from '@vue/test-utils';
import { GlButton, GlForm, GlFormGroup } from '@gitlab/ui';
import ServiceLevelAgreementForm from 'ee/incidents_settings/components/service_level_agreement_form.vue';
import merge from 'lodash/merge';
const defaultData = { enabled: true, duration: 15, units: 'minutes' };
describe('Alert integration settings form', () => {
let wrapper;
const service = { updateSettings: jest.fn().mockResolvedValue() };
const mountComponent = (options, mountMethod = shallowMount) => {
wrapper = mountMethod(
ServiceLevelAgreementForm,
merge(
{
provide: {
service,
serviceLevelAgreementSettings: { available: true },
},
data() {
return { ...defaultData };
},
},
options,
),
);
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
const findForm = () => wrapper.find(GlForm);
const findFormGroup = () => wrapper.find(GlFormGroup);
const findSubmitButton = () => wrapper.find(GlButton);
it('renders an empty component when feature not available', () => {
mountComponent({ provide: { serviceLevelAgreementSettings: { available: false } } });
expect(wrapper.html()).toBe('');
});
it('should match the default snapshot', () => {
mountComponent();
expect(wrapper.element).toMatchSnapshot();
});
describe('Form fields', () => {
it('should call service `updateSettings` on submit', async () => {
mountComponent({}, mount);
findForm().trigger('submit');
expect(service.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
sla_timer: true,
sla_timer_minutes: 15,
}),
);
});
it('should allow submit when form is valid', () => {
mountComponent();
expect(findFormGroup().attributes('state')).toBe('true');
});
describe.each`
enabled | duration | unit | isValid | expectedMessage
${true} | ${''} | ${'minutes'} | ${false} | ${'Time limit must be a valid number'}
${true} | ${-15} | ${'minutes'} | ${false} | ${'Time limit must be greater than 0'}
${true} | ${0} | ${'minutes'} | ${false} | ${'Time limit must be greater than 0'}
${true} | ${0.5} | ${'minutes'} | ${false} | ${'Time limit must be a multiple of 15 minutes'}
${true} | ${5} | ${'minutes'} | ${false} | ${'Time limit must be a multiple of 15 minutes'}
${true} | ${15} | ${'minutes'} | ${true} | ${''}
${true} | ${0} | ${'hours'} | ${false} | ${'Time limit must be greater than 0'}
${true} | ${0.15} | ${'hours'} | ${false} | ${'Time limit must be a multiple of 15 minutes'}
${true} | ${0.5} | ${'hours'} | ${true} | ${''}
${true} | ${1} | ${'hours'} | ${true} | ${''}
${true} | ${24} | ${'hours'} | ${true} | ${''}
`(
'Inputs enabled "$enabled", "$duration" $unit',
({ enabled, duration, unit, isValid, expectedMessage }) => {
beforeEach(() => {
mountComponent(
{
data() {
return { ...defaultData, enabled, duration, unit };
},
},
mount,
);
});
it(`should${isValid ? '' : ' not'} allow submit`, () => {
expect(findSubmitButton().attributes('disabled')).toBe(isValid ? undefined : 'disabled');
});
it(`should show ${isValid ? 'no' : 'the correct'} error message`, () => {
expect(findFormGroup().text()).toContain(expectedMessage);
});
},
);
});
});
......@@ -3,21 +3,21 @@
require 'spec_helper'
RSpec.describe OperationsHelper, :routing do
let_it_be(:project) { create(:project, :private) }
let_it_be_with_refind(:project) { create(:project, :private) }
let_it_be(:user) { create(:user) }
before do
helper.instance_variable_set(:@project, project)
allow(helper).to receive(:current_user) { user }
end
describe '#status_page_settings_data' do
let_it_be(:user) { create(:user) }
let_it_be(:status_page_setting) { project.build_status_page_setting }
subject { helper.status_page_settings_data }
before do
allow(helper).to receive(:status_page_setting) { status_page_setting }
allow(helper).to receive(:current_user) { user }
allow(helper)
.to receive(:can?).with(user, :admin_operations, project) { true }
end
......@@ -116,4 +116,46 @@ RSpec.describe OperationsHelper, :routing do
end
end
end
describe '#operations_settings_data' do
let_it_be(:operations_settings) do
create(
:project_incident_management_setting,
project: project,
issue_template_key: 'template-key',
pagerduty_active: true,
auto_close_incident: false
)
end
subject { helper.operations_settings_data }
it 'returns the correct set of data' do
is_expected.to eq(
operations_settings_endpoint: project_settings_operations_path(project),
templates: '[]',
create_issue: 'false',
issue_template_key: 'template-key',
send_email: 'false',
auto_close_incident: 'false',
pagerduty_active: 'true',
pagerduty_token: operations_settings.pagerduty_token,
pagerduty_webhook_url: project_incidents_integrations_pagerduty_url(project, token: operations_settings.pagerduty_token),
pagerduty_reset_key_path: reset_pagerduty_token_project_settings_operations_path(project),
sla_feature_available: 'false',
sla_active: operations_settings.sla_timer.to_s,
sla_minutes: operations_settings.sla_timer_minutes
)
end
context 'incident_sla feature enabled' do
before do
stub_licensed_features(incident_sla: true)
end
it 'returns the feature as enabled' do
expect(subject[:sla_feature_available]).to eq('true')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe EE::IncidentManagement::ProjectIncidentManagementSetting do
let_it_be(:project) { create(:project, :repository, create_templates: :issue) }
describe 'Validations' do
describe 'validate incident SLA settings' do
subject { build(:project_incident_management_setting, sla_timer: sla_timer) }
describe '#sla_timer_minutes' do
context 'sla_timer is disabled' do
let(:sla_timer) { false }
it { is_expected.not_to validate_presence_of(:sla_timer_minutes) }
end
context 'sla_timer is enabled' do
let(:sla_timer) { true }
it { is_expected.to validate_numericality_of(:sla_timer_minutes).is_greater_than_or_equal_to(15) }
it { is_expected.to validate_numericality_of(:sla_timer_minutes).is_less_than_or_equal_to(1.year / 1.minute) } # 1 year
end
end
end
end
end
......@@ -13728,12 +13728,18 @@ msgstr ""
msgid "IncidentManagement|Unpublished"
msgstr ""
msgid "IncidentSettings|Activate \"time to SLA\" countdown timer"
msgstr ""
msgid "IncidentSettings|Alert integration"
msgstr ""
msgid "IncidentSettings|Grafana integration"
msgstr ""
msgid "IncidentSettings|Incident settings"
msgstr ""
msgid "IncidentSettings|Incidents"
msgstr ""
......@@ -13743,6 +13749,30 @@ msgstr ""
msgid "IncidentSettings|Set up integrations with external tools to help better manage incidents."
msgstr ""
msgid "IncidentSettings|Time limit"
msgstr ""
msgid "IncidentSettings|Time limit must be a multiple of 15 minutes"
msgstr ""
msgid "IncidentSettings|Time limit must be a valid number"
msgstr ""
msgid "IncidentSettings|Time limit must be greater than 0"
msgstr ""
msgid "IncidentSettings|When activated, this will apply to all new incidents within the project"
msgstr ""
msgid "IncidentSettings|You may choose to introduce a countdown timer in incident issues to better track Service Level Agreements (SLAs). The timer is automatically started when the incident is created, and sets a time limit for the incident to be resolved in. When activated, \"time to SLA\" countdown will appear on all new incidents."
msgstr ""
msgid "IncidentSettings|hours"
msgstr ""
msgid "IncidentSettings|minutes"
msgstr ""
msgid "Incidents"
msgstr ""
......
......@@ -58,6 +58,8 @@ exports[`IncidentsSettingTabs should render the component 1`] = `
/>
</gl-tab-stub>
<!---->
<!---->
</gl-tabs-stub>
</div>
</section>
......
......@@ -6,7 +6,12 @@ describe('IncidentsSettingTabs', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(IncidentsSettingTabs);
wrapper = shallowMount(IncidentsSettingTabs, {
provide: {
service: {},
serviceLevelAgreementSettings: {},
},
});
});
afterEach(() => {
......
......@@ -145,7 +145,7 @@ RSpec.describe OperationsHelper do
subject { helper.operations_settings_data }
it 'returns the correct set of data' do
is_expected.to eq(
is_expected.to include(
operations_settings_endpoint: project_settings_operations_path(project),
templates: '[]',
create_issue: 'false',
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment