Commit 15bdf6cd authored by Miguel Rincon's avatar Miguel Rincon

Merge branch 'tr-alert-runbook-frontend' into 'master'

Add runbooks to metric dashboard alerts - frontend

See merge request gitlab-org/gitlab!38449
parents 54ce3a2e dfefd434
...@@ -174,8 +174,8 @@ export default { ...@@ -174,8 +174,8 @@ export default {
handleSetApiAction(apiAction) { handleSetApiAction(apiAction) {
this.apiAction = apiAction; this.apiAction = apiAction;
}, },
handleCreate({ operator, threshold, prometheus_metric_id }) { handleCreate({ operator, threshold, prometheus_metric_id, runbookUrl }) {
const newAlert = { operator, threshold, prometheus_metric_id }; const newAlert = { operator, threshold, prometheus_metric_id, runbookUrl };
this.isLoading = true; this.isLoading = true;
this.service this.service
.createAlert(newAlert) .createAlert(newAlert)
...@@ -189,8 +189,8 @@ export default { ...@@ -189,8 +189,8 @@ export default {
this.isLoading = false; this.isLoading = false;
}); });
}, },
handleUpdate({ alert, operator, threshold }) { handleUpdate({ alert, operator, threshold, runbookUrl }) {
const updatedAlert = { operator, threshold }; const updatedAlert = { operator, threshold, runbookUrl };
this.isLoading = true; this.isLoading = true;
this.service this.service
.updateAlert(alert, updatedAlert) .updateAlert(alert, updatedAlert)
......
...@@ -88,6 +88,7 @@ export default { ...@@ -88,6 +88,7 @@ export default {
operator: null, operator: null,
threshold: null, threshold: null,
prometheusMetricId: null, prometheusMetricId: null,
runbookUrl: null,
selectedAlert: {}, selectedAlert: {},
alertQuery: '', alertQuery: '',
}; };
...@@ -116,7 +117,8 @@ export default { ...@@ -116,7 +117,8 @@ export default {
this.operator && this.operator &&
this.threshold === Number(this.threshold) && this.threshold === Number(this.threshold) &&
(this.operator !== this.selectedAlert.operator || (this.operator !== this.selectedAlert.operator ||
this.threshold !== this.selectedAlert.threshold) this.threshold !== this.selectedAlert.threshold ||
this.runbookUrl !== this.selectedAlert.runbookUrl)
); );
}, },
submitAction() { submitAction() {
...@@ -153,13 +155,17 @@ export default { ...@@ -153,13 +155,17 @@ export default {
const existingAlert = this.alertsToManage[existingAlertPath]; const existingAlert = this.alertsToManage[existingAlertPath];
if (existingAlert) { if (existingAlert) {
const { operator, threshold, runbookUrl } = existingAlert;
this.selectedAlert = existingAlert; this.selectedAlert = existingAlert;
this.operator = existingAlert.operator; this.operator = operator;
this.threshold = existingAlert.threshold; this.threshold = threshold;
this.runbookUrl = runbookUrl;
} else { } else {
this.selectedAlert = {}; this.selectedAlert = {};
this.operator = this.operators.greaterThan; this.operator = this.operators.greaterThan;
this.threshold = null; this.threshold = null;
this.runbookUrl = null;
} }
this.prometheusMetricId = queryId; this.prometheusMetricId = queryId;
...@@ -168,13 +174,13 @@ export default { ...@@ -168,13 +174,13 @@ export default {
this.resetAlertData(); this.resetAlertData();
this.$emit('cancel'); this.$emit('cancel');
}, },
handleSubmit(e) { handleSubmit() {
e.preventDefault();
this.$emit(this.submitAction, { this.$emit(this.submitAction, {
alert: this.selectedAlert.alert_path, alert: this.selectedAlert.alert_path,
operator: this.operator, operator: this.operator,
threshold: this.threshold, threshold: this.threshold,
prometheus_metric_id: this.prometheusMetricId, prometheus_metric_id: this.prometheusMetricId,
runbookUrl: this.runbookUrl,
}); });
}, },
handleShown() { handleShown() {
...@@ -189,6 +195,7 @@ export default { ...@@ -189,6 +195,7 @@ export default {
this.threshold = null; this.threshold = null;
this.prometheusMetricId = null; this.prometheusMetricId = null;
this.selectedAlert = {}; this.selectedAlert = {};
this.runbookUrl = null;
}, },
getAlertFormActionTrackingOption() { getAlertFormActionTrackingOption() {
const label = `${this.submitAction}_alert`; const label = `${this.submitAction}_alert`;
...@@ -217,7 +224,7 @@ export default { ...@@ -217,7 +224,7 @@ export default {
:modal-id="modalId" :modal-id="modalId"
:ok-variant="submitAction === 'delete' ? 'danger' : 'success'" :ok-variant="submitAction === 'delete' ? 'danger' : 'success'"
:ok-disabled="formDisabled" :ok-disabled="formDisabled"
@ok="handleSubmit" @ok.prevent="handleSubmit"
@hidden="handleHidden" @hidden="handleHidden"
@shown="handleShown" @shown="handleShown"
> >
...@@ -259,7 +266,7 @@ export default { ...@@ -259,7 +266,7 @@ export default {
</gl-deprecated-dropdown-item> </gl-deprecated-dropdown-item>
</gl-deprecated-dropdown> </gl-deprecated-dropdown>
</gl-form-group> </gl-form-group>
<gl-button-group class="mb-2" :label="s__('PrometheusAlerts|Operator')"> <gl-button-group class="mb-3" :label="s__('PrometheusAlerts|Operator')">
<gl-deprecated-button <gl-deprecated-button
:class="{ active: operator === operators.greaterThan }" :class="{ active: operator === operators.greaterThan }"
:disabled="formDisabled" :disabled="formDisabled"
...@@ -296,11 +303,16 @@ export default { ...@@ -296,11 +303,16 @@ export default {
</gl-form-group> </gl-form-group>
<gl-form-group <gl-form-group
v-if="glFeatures.alertRunbooks" v-if="glFeatures.alertRunbooks"
:label="s__('PrometheusAlerts|Runbook')" :label="s__('PrometheusAlerts|Runbook URL (optional)')"
label-for="alert-runbook" label-for="alert-runbook"
data-testid="alertRunbookField"
> >
<gl-form-input id="alert-runbook" :disabled="formDisabled" type="text" /> <gl-form-input
id="alert-runbook"
v-model="runbookUrl"
:disabled="formDisabled"
data-testid="alertRunbookField"
type="text"
/>
</gl-form-group> </gl-form-group>
</div> </div>
<template #modal-ok> <template #modal-ok>
......
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
const mapAlert = ({ runbook_url, ...alert }) => {
return { runbookUrl: runbook_url, ...alert };
};
export default class AlertsService { export default class AlertsService {
constructor({ alertsEndpoint }) { constructor({ alertsEndpoint }) {
this.alertsEndpoint = alertsEndpoint; this.alertsEndpoint = alertsEndpoint;
} }
getAlerts() { getAlerts() {
return axios.get(this.alertsEndpoint).then(resp => resp.data); return axios.get(this.alertsEndpoint).then(resp => mapAlert(resp.data));
} }
createAlert({ prometheus_metric_id, operator, threshold }) { createAlert({ prometheus_metric_id, operator, threshold, runbookUrl }) {
return axios return axios
.post(this.alertsEndpoint, { prometheus_metric_id, operator, threshold }) .post(this.alertsEndpoint, {
.then(resp => resp.data); prometheus_metric_id,
operator,
threshold,
runbook_url: runbookUrl,
})
.then(resp => mapAlert(resp.data));
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
readAlert(alertPath) { readAlert(alertPath) {
return axios.get(alertPath).then(resp => resp.data); return axios.get(alertPath).then(resp => mapAlert(resp.data));
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
updateAlert(alertPath, { operator, threshold }) { updateAlert(alertPath, { operator, threshold, runbookUrl }) {
return axios.put(alertPath, { operator, threshold }).then(resp => resp.data); return axios
.put(alertPath, { operator, threshold, runbook_url: runbookUrl })
.then(resp => mapAlert(resp.data));
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
......
import { isSafeURL } from '~/lib/utils/url_utility';
const isRunbookUrlValid = runbookUrl => {
if (!runbookUrl) {
return true;
}
return isSafeURL(runbookUrl);
};
// Prop validator for alert information, expecting an object like the example below. // Prop validator for alert information, expecting an object like the example below.
// //
// { // {
...@@ -8,6 +17,7 @@ ...@@ -8,6 +17,7 @@
// query: "rate(http_requests_total[5m])[30m:1m]", // query: "rate(http_requests_total[5m])[30m:1m]",
// threshold: 0.002, // threshold: 0.002,
// title: "Core Usage (Total)", // title: "Core Usage (Total)",
// runbookUrl: "https://www.gitlab.com/my-project/-/wikis/runbook"
// } // }
// } // }
export function alertsValidator(value) { export function alertsValidator(value) {
...@@ -19,7 +29,8 @@ export function alertsValidator(value) { ...@@ -19,7 +29,8 @@ export function alertsValidator(value) {
alert.metricId && alert.metricId &&
typeof alert.metricId === 'string' && typeof alert.metricId === 'string' &&
alert.operator && alert.operator &&
typeof alert.threshold === 'number' typeof alert.threshold === 'number' &&
isRunbookUrlValid(alert.runbookUrl)
); );
}); });
} }
......
...@@ -14,6 +14,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -14,6 +14,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
push_frontend_feature_flag(:prometheus_computed_alerts) push_frontend_feature_flag(:prometheus_computed_alerts)
push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate) push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate)
push_frontend_feature_flag(:alert_runbooks)
end end
before_action :authorize_read_environment!, except: [:metrics, :additional_metrics, :metrics_dashboard, :metrics_redirect] before_action :authorize_read_environment!, except: [:metrics, :additional_metrics, :metrics_dashboard, :metrics_redirect]
before_action :authorize_create_environment!, only: [:new, :create] before_action :authorize_create_environment!, only: [:new, :create]
......
...@@ -19429,7 +19429,7 @@ msgstr "" ...@@ -19429,7 +19429,7 @@ msgstr ""
msgid "PrometheusAlerts|Operator" msgid "PrometheusAlerts|Operator"
msgstr "" msgstr ""
msgid "PrometheusAlerts|Runbook" msgid "PrometheusAlerts|Runbook URL (optional)"
msgstr "" msgstr ""
msgid "PrometheusAlerts|Select query" msgid "PrometheusAlerts|Select query"
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui'; import { GlLink } from '@gitlab/ui';
import INVALID_URL from '~/lib/utils/invalid_url';
import AlertWidgetForm from '~/monitoring/components/alert_widget_form.vue'; import AlertWidgetForm from '~/monitoring/components/alert_widget_form.vue';
import ModalStub from '../stubs/modal_stub'; import ModalStub from '../stubs/modal_stub';
...@@ -24,7 +25,13 @@ describe('AlertWidgetForm', () => { ...@@ -24,7 +25,13 @@ describe('AlertWidgetForm', () => {
const propsWithAlertData = { const propsWithAlertData = {
...defaultProps, ...defaultProps,
alertsToManage: { alertsToManage: {
alert: { alert_path: alertPath, operator: '<', threshold: 5, metricId }, alert: {
alert_path: alertPath,
operator: '<',
threshold: 5,
metricId,
runbookUrl: INVALID_URL,
},
}, },
configuredAlert: metricId, configuredAlert: metricId,
}; };
...@@ -49,16 +56,11 @@ describe('AlertWidgetForm', () => { ...@@ -49,16 +56,11 @@ describe('AlertWidgetForm', () => {
const modal = () => wrapper.find(ModalStub); const modal = () => wrapper.find(ModalStub);
const modalTitle = () => modal().attributes('title'); const modalTitle = () => modal().attributes('title');
const submitButton = () => modal().find(GlLink); const submitButton = () => modal().find(GlLink);
const alertRunbookField = () => wrapper.find('[data-testid="alertRunbookField"]'); const findRunbookField = () => modal().find('[data-testid="alertRunbookField"]');
const findThresholdField = () => modal().find('[data-qa-selector="alert_threshold_field"]');
const submitButtonTrackingOpts = () => const submitButtonTrackingOpts = () =>
JSON.parse(submitButton().attributes('data-tracking-options')); JSON.parse(submitButton().attributes('data-tracking-options'));
const e = { const stubEvent = { preventDefault: jest.fn() };
preventDefault: jest.fn(),
};
beforeEach(() => {
e.preventDefault.mockReset();
});
afterEach(() => { afterEach(() => {
if (wrapper) wrapper.destroy(); if (wrapper) wrapper.destroy();
...@@ -85,35 +87,34 @@ describe('AlertWidgetForm', () => { ...@@ -85,35 +87,34 @@ describe('AlertWidgetForm', () => {
expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.create); expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.create);
}); });
it('emits a "create" event when form submitted without existing alert', () => { it('emits a "create" event when form submitted without existing alert', async () => {
createComponent(); createComponent(defaultProps, { alertRunbooks: true });
wrapper.vm.selectQuery('9'); modal().vm.$emit('shown');
wrapper.setData({
threshold: 900,
});
wrapper.vm.handleSubmit(e); findThresholdField().vm.$emit('input', 900);
findRunbookField().vm.$emit('input', INVALID_URL);
modal().vm.$emit('ok', stubEvent);
expect(wrapper.emitted().create[0]).toEqual([ expect(wrapper.emitted().create[0]).toEqual([
{ {
alert: undefined, alert: undefined,
operator: '>', operator: '>',
threshold: 900, threshold: 900,
prometheus_metric_id: '9', prometheus_metric_id: '8',
runbookUrl: INVALID_URL,
}, },
]); ]);
expect(e.preventDefault).toHaveBeenCalledTimes(1);
}); });
it('resets form when modal is dismissed (hidden)', () => { it('resets form when modal is dismissed (hidden)', () => {
createComponent(); createComponent(defaultProps, { alertRunbooks: true });
wrapper.vm.selectQuery('9'); modal().vm.$emit('shown');
wrapper.vm.selectQuery('>');
wrapper.setData({ findThresholdField().vm.$emit('input', 800);
threshold: 800, findRunbookField().vm.$emit('input', INVALID_URL);
});
modal().vm.$emit('hidden'); modal().vm.$emit('hidden');
...@@ -121,6 +122,7 @@ describe('AlertWidgetForm', () => { ...@@ -121,6 +122,7 @@ describe('AlertWidgetForm', () => {
expect(wrapper.vm.operator).toBe(null); expect(wrapper.vm.operator).toBe(null);
expect(wrapper.vm.threshold).toBe(null); expect(wrapper.vm.threshold).toBe(null);
expect(wrapper.vm.prometheusMetricId).toBe(null); expect(wrapper.vm.prometheusMetricId).toBe(null);
expect(wrapper.vm.runbookUrl).toBe(null);
}); });
it('sets selectedAlert to the provided configuredAlert on modal show', () => { it('sets selectedAlert to the provided configuredAlert on modal show', () => {
...@@ -167,7 +169,7 @@ describe('AlertWidgetForm', () => { ...@@ -167,7 +169,7 @@ describe('AlertWidgetForm', () => {
beforeEach(() => { beforeEach(() => {
createComponent(propsWithAlertData); createComponent(propsWithAlertData);
wrapper.vm.selectQuery(metricId); modal().vm.$emit('shown');
}); });
it('sets tracking options for delete alert', () => { it('sets tracking options for delete alert', () => {
...@@ -180,7 +182,7 @@ describe('AlertWidgetForm', () => { ...@@ -180,7 +182,7 @@ describe('AlertWidgetForm', () => {
}); });
it('emits "delete" event when form values unchanged', () => { it('emits "delete" event when form values unchanged', () => {
wrapper.vm.handleSubmit(e); modal().vm.$emit('ok', stubEvent);
expect(wrapper.emitted().delete[0]).toEqual([ expect(wrapper.emitted().delete[0]).toEqual([
{ {
...@@ -188,51 +190,58 @@ describe('AlertWidgetForm', () => { ...@@ -188,51 +190,58 @@ describe('AlertWidgetForm', () => {
operator: '<', operator: '<',
threshold: 5, threshold: 5,
prometheus_metric_id: '8', prometheus_metric_id: '8',
runbookUrl: INVALID_URL,
}, },
]); ]);
expect(e.preventDefault).toHaveBeenCalledTimes(1);
}); });
});
it('emits "update" event when form changed', () => { it('emits "update" event when form changed', () => {
wrapper.setData({ const updatedRunbookUrl = `${INVALID_URL}/test`;
threshold: 11,
});
wrapper.vm.handleSubmit(e); createComponent(propsWithAlertData, { alertRunbooks: true });
expect(wrapper.emitted().update[0]).toEqual([ modal().vm.$emit('shown');
{
alert: 'alert', findRunbookField().vm.$emit('input', updatedRunbookUrl);
operator: '<', findThresholdField().vm.$emit('input', 11);
threshold: 11,
prometheus_metric_id: '8',
},
]);
expect(e.preventDefault).toHaveBeenCalledTimes(1);
});
it('sets tracking options for update alert', () => { modal().vm.$emit('ok', stubEvent);
wrapper.setData({
expect(wrapper.emitted().update[0]).toEqual([
{
alert: 'alert',
operator: '<',
threshold: 11, threshold: 11,
}); prometheus_metric_id: '8',
runbookUrl: updatedRunbookUrl,
},
]);
});
return wrapper.vm.$nextTick(() => { it('sets tracking options for update alert', async () => {
expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.update); createComponent(propsWithAlertData);
});
}); modal().vm.$emit('shown');
findThresholdField().vm.$emit('input', 11);
await wrapper.vm.$nextTick();
expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.update);
}); });
describe('alert runbooks feature flag', () => { describe('alert runbooks feature flag', () => {
it('hides the runbook field when the flag is disabled', () => { it('hides the runbook field when the flag is disabled', () => {
createComponent(undefined, { alertRunbooks: false }); createComponent(undefined, { alertRunbooks: false });
expect(alertRunbookField().exists()).toBe(false); expect(findRunbookField().exists()).toBe(false);
}); });
it('shows the runbook field when the flag is enabled', () => { it('shows the runbook field when the flag is enabled', () => {
createComponent(undefined, { alertRunbooks: true }); createComponent(undefined, { alertRunbooks: true });
expect(alertRunbookField().exists()).toBe(true); expect(findRunbookField().exists()).toBe(true);
}); });
}); });
}); });
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