Commit dfefd434 authored by Tristan Read's avatar Tristan Read Committed by Miguel Rincon

Add runbooks to metric dashboard alerts - frontend

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