Commit b53749e3 authored by Phil Hughes's avatar Phil Hughes

Merge branch '5158-frontend-metrics-alerting' into '5158-backend-metrics-alerting'

Frontend logic for MVC of SLI Alerts

See merge request gitlab-org/gitlab-ee!6656
parents 24bab47e 84f3f2dd
<script>
// ee-only
import DashboardMixin from 'ee/monitoring/components/dashboard_mixin';
import _ from 'underscore';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
......@@ -17,6 +20,10 @@ export default {
EmptyState,
Icon,
},
// ee-only
mixins: [DashboardMixin],
props: {
hasMetrics: {
type: Boolean,
......@@ -137,7 +144,7 @@ export default {
.catch(() => Flash(s__('Metrics|There was an error getting deployment information.'))),
this.service
.getEnvironmentsData()
.then((data) => this.store.storeEnvironmentsData(data))
.then(data => this.store.storeEnvironmentsData(data))
.catch(() => Flash(s__('Metrics|There was an error getting environments information.'))),
])
.then(() => {
......@@ -225,7 +232,13 @@ export default {
:small-graph="forceSmallGraph"
>
<!-- EE content -->
{{ null }}
<alert-widget
v-if="alertsEndpoint && graphData.id"
:alerts-endpoint="alertsEndpoint"
:label="getGraphLabel(graphData)"
:current-alerts="getQueryAlerts(graphData)"
:custom-metric-id="graphData.id"
/>
</graph>
</graph-group>
</div>
......
......@@ -267,7 +267,7 @@
border: 1px solid $white-light;
background-color: $orange-300;
border-radius: 50%;
content: "";
content: '';
}
}
}
......@@ -287,8 +287,6 @@
}
}
.gl-responsive-table-row {
.branch-commit {
max-width: 100%;
......@@ -653,3 +651,66 @@
}
}
}
.alert-dropdown-button {
margin-left: $btn-side-margin;
.dropdown.open & {
background: $white-normal;
outline: 0;
}
svg {
margin: 0;
+ svg {
margin-left: -$gl-padding-4;
}
&.chevron {
color: $gl-text-color-secondary;
}
}
}
.alert-dropdown-menu {
right: 0;
left: auto;
z-index: $zindex-popover + 5; // must be higher than graph flag popover
.dropdown-title {
margin: 0;
}
}
.alert-error-message {
color: $gl-danger;
vertical-align: middle;
}
.alert-current-setting {
color: $gl-text-color-disabled;
vertical-align: middle;
}
.alert-form {
padding: $gl-padding $gl-padding $gl-padding-8;
label {
font-weight: normal;
}
.btn-group,
.action-group {
display: flex;
.btn {
flex: 1 auto;
box-shadow: none;
}
}
.action-group .btn + .btn {
margin-left: $gl-padding-8;
}
}
......@@ -2,17 +2,11 @@
- page_title "Metrics for environment", @environment.name
.prometheus-container{ class: container_class }
#prometheus-graphs{ data: { "settings-path": edit_project_service_path(@project, 'prometheus'),
"clusters-path": project_clusters_path(@project),
"current-environment-name": @environment.name,
"documentation-path": help_page_path('administration/monitoring/prometheus/index.md'),
"empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'),
"empty-loading-svg-path": image_path('illustrations/monitoring/loading.svg'),
"empty-no-data-svg-path": image_path('illustrations/monitoring/no_data.svg'),
"empty-unable-to-connect-svg-path": image_path('illustrations/monitoring/unable_to_connect.svg'),
"metrics-endpoint": additional_metrics_project_environment_path(@project, @environment, format: :json),
"deployment-endpoint": project_environment_deployments_path(@project, @environment, format: :json),
"environments-endpoint": project_environments_path(@project, format: :json),
"project-path": project_path(@project),
"tags-path": project_tags_path(@project),
"has-metrics": "#{@environment.has_metrics?}" } }
.top-area
.row
.col-sm-6
%h3
Environment:
= link_to @environment.name, environment_path(@environment)
#prometheus-graphs{ data: metrics_data(@project, @environment) }
<script>
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import AlertWidgetForm from './alert_widget_form.vue';
import AlertsService from '../services/alerts_service';
export default {
components: {
Icon,
LoadingIcon,
AlertWidgetForm,
},
props: {
alertsEndpoint: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
currentAlerts: {
type: Array,
require: false,
default: () => [],
},
customMetricId: {
type: Number,
require: false,
default: null,
},
},
data() {
return {
service: null,
errorMessage: null,
isLoading: false,
isOpen: false,
alerts: this.currentAlerts,
alertData: {},
};
},
computed: {
alertSummary() {
const data = this.firstAlertData;
if (!data) return null;
return `${this.label} ${data.operator} ${data.threshold}`;
},
alertIcon() {
return this.hasAlerts ? 'notifications' : 'notifications-off';
},
alertStatus() {
return this.hasAlerts
? s__('PrometheusAlerts|Alert set')
: s__('PrometheusAlerts|No alert set');
},
dropdownTitle() {
return this.hasAlerts
? s__('PrometheusAlerts|Edit alert')
: s__('PrometheusAlerts|Add alert');
},
hasAlerts() {
return this.alerts.length > 0;
},
firstAlert() {
return this.hasAlerts ? this.alerts[0] : undefined;
},
firstAlertData() {
return this.hasAlerts ? this.alertData[this.alerts[0]] : undefined;
},
formDisabled() {
return !!(this.errorMessage || this.isLoading);
},
},
watch: {
isOpen(open) {
if (open) {
document.addEventListener('click', this.handleOutsideClick);
} else {
document.removeEventListener('click', this.handleOutsideClick);
}
},
},
created() {
this.service = new AlertsService({ alertsEndpoint: this.alertsEndpoint });
this.fetchAlertData();
},
beforeDestroy() {
// clean up external event listeners
document.removeEventListener('click', this.handleOutsideClick);
},
methods: {
fetchAlertData() {
this.isLoading = true;
return Promise.all(
this.alerts.map(alertPath =>
this.service
.readAlert(alertPath)
.then(alertData => this.$set(this.alertData, alertPath, alertData)),
),
)
.then(() => {
this.isLoading = false;
})
.catch(() => {
this.errorMessage = s__('PrometheusAlerts|Error fetching alert');
this.isLoading = false;
});
},
handleDropdownToggle() {
this.isOpen = !this.isOpen;
},
handleDropdownClose() {
this.isOpen = false;
},
handleOutsideClick(event) {
if (!this.$refs.dropdownMenu.contains(event.target)) {
this.isOpen = false;
}
},
handleCreate({ operator, threshold }) {
const newAlert = { operator, threshold, prometheus_metric_id: this.customMetricId };
this.isLoading = true;
this.service
.createAlert(newAlert)
.then(response => {
const alertPath = response.alert_path;
this.alerts.unshift(alertPath);
this.$set(this.alertData, alertPath, newAlert);
this.isLoading = false;
this.handleDropdownClose();
})
.catch(() => {
this.errorMessage = s__('PrometheusAlerts|Error creating alert');
this.isLoading = false;
});
},
handleUpdate({ alert, operator, threshold }) {
const updatedAlert = { operator, threshold };
this.isLoading = true;
this.service
.updateAlert(alert, updatedAlert)
.then(() => {
this.$set(this.alertData, alert, updatedAlert);
this.isLoading = false;
this.handleDropdownClose();
})
.catch(() => {
this.errorMessage = s__('PrometheusAlerts|Error saving alert');
this.isLoading = false;
});
},
handleDelete({ alert }) {
this.isLoading = true;
this.service
.deleteAlert(alert)
.then(() => {
this.$delete(this.alertData, alert);
this.alerts = this.alerts.filter(alertPath => alert !== alertPath);
this.isLoading = false;
this.handleDropdownClose();
})
.catch(() => {
this.errorMessage = s__('PrometheusAlerts|Error deleting alert');
this.isLoading = false;
});
},
},
};
</script>
<template>
<div
:class="{ show: isOpen }"
class="prometheus-alert-widget dropdown"
>
<span
v-if="errorMessage"
class="alert-error-message"
>
{{ errorMessage }}
</span>
<span
v-else
class="alert-current-setting"
>
<loading-icon
v-show="isLoading"
:inline="true"
/>
{{ alertSummary }}
</span>
<button
:aria-label="alertStatus"
class="btn btn-sm alert-dropdown-button"
type="button"
@click="handleDropdownToggle"
>
<icon
:name="alertIcon"
:size="16"
aria-hidden="true"
/>
<icon
:size="16"
name="arrow-down"
aria-hidden="true"
class="chevron"
/>
</button>
<div
ref="dropdownMenu"
class="dropdown-menu alert-dropdown-menu"
>
<div class="dropdown-title">
<span>{{ dropdownTitle }}</span>
<button
class="dropdown-title-button dropdown-menu-close"
type="button"
aria-label="Close"
@click="handleDropdownClose"
>
<icon
:size="12"
name="close"
aria-hidden="true"
/>
</button>
</div>
<div class="dropdown-content">
<alert-widget-form
ref="widgetForm"
:disabled="formDisabled"
:alert="firstAlert"
:alert-data="firstAlertData"
@create="handleCreate"
@update="handleUpdate"
@delete="handleDelete"
@cancel="handleDropdownClose"
/>
</div>
</div>
</div>
</template>
<script>
import { __ } from '~/locale';
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
Vue.use(Translate);
const SUBMIT_ACTION_TEXT = {
create: __('Add'),
update: __('Save'),
delete: __('Delete'),
};
const SUBMIT_BUTTON_CLASS = {
create: 'btn-create',
update: 'btn-save',
delete: 'btn-remove',
};
const OPERATORS = {
greaterThan: '>',
equalTo: '=',
lessThan: '<',
};
export default {
props: {
disabled: {
type: Boolean,
required: true,
},
alert: {
type: String,
required: false,
default: null,
},
alertData: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
operators: OPERATORS,
operator: this.alertData.operator,
threshold: this.alertData.threshold,
};
},
computed: {
haveValuesChanged() {
return (
this.operator &&
this.threshold === Number(this.threshold) &&
(this.operator !== this.alertData.operator || this.threshold !== this.alertData.threshold)
);
},
submitAction() {
if (!this.alert) return 'create';
if (this.haveValuesChanged) return 'update';
return 'delete';
},
submitActionText() {
return SUBMIT_ACTION_TEXT[this.submitAction];
},
submitButtonClass() {
return SUBMIT_BUTTON_CLASS[this.submitAction];
},
isSubmitDisabled() {
return this.disabled || (this.submitAction === 'create' && !this.haveValuesChanged);
},
},
watch: {
alertData() {
this.resetAlertData();
},
},
methods: {
handleCancel() {
this.resetAlertData();
this.$emit('cancel');
},
handleSubmit() {
this.$refs.submitButton.blur();
this.$emit(this.submitAction, {
alert: this.alert,
operator: this.operator,
threshold: this.threshold,
});
},
resetAlertData() {
this.operator = this.alertData.operator;
this.threshold = this.alertData.threshold;
},
},
};
</script>
<template>
<div class="alert-form">
<div
:aria-label="s__('PrometheusAlerts|Operator')"
class="form-group btn-group"
role="group"
>
<button
:class="{ active: operator === operators.greaterThan }"
:disabled="disabled"
type="button"
class="btn btn-default"
@click="operator = operators.greaterThan"
>
{{ operators.greaterThan }}
</button>
<button
:class="{ active: operator === operators.equalTo }"
:disabled="disabled"
type="button"
class="btn btn-default"
@click="operator = operators.equalTo"
>
{{ operators.equalTo }}
</button>
<button
:class="{ active: operator === operators.lessThan }"
:disabled="disabled"
type="button"
class="btn btn-default"
@click="operator = operators.lessThan"
>
{{ operators.lessThan }}
</button>
</div>
<div class="form-group">
<label>{{ s__('PrometheusAlerts|Threshold') }}</label>
<input
v-model.number="threshold"
:disabled="disabled"
type="number"
class="form-control"
/>
</div>
<div class="action-group">
<button
ref="cancelButton"
:disabled="disabled"
type="button"
class="btn btn-default"
@click="handleCancel"
>
{{ __('Cancel') }}
</button>
<button
ref="submitButton"
:class="submitButtonClass"
:disabled="isSubmitDisabled"
type="button"
class="btn btn-inverted"
@click="handleSubmit"
>
{{ submitActionText }}
</button>
</div>
</div>
</template>
import AlertWidget from './alert_widget.vue';
export default {
components: {
AlertWidget,
},
props: {
alertsEndpoint: {
type: String,
required: false,
default: null,
},
},
methods: {
getGraphLabel(graphData) {
if (!graphData.queries || !graphData.queries[0]) return undefined;
return graphData.queries[0].label || graphData.y_label || 'Average';
},
getQueryAlerts(graphData) {
if (!graphData.queries) return [];
return graphData.queries.map(query => query.alert_path).filter(Boolean);
},
},
};
import axios from '~/lib/utils/axios_utils';
export default class AlertsService {
constructor({ alertsEndpoint }) {
this.alertsEndpoint = alertsEndpoint;
}
getAlerts() {
return axios.get(this.alertsEndpoint).then(resp => resp.data);
}
createAlert({ prometheus_metric_id, operator, threshold }) {
return axios
.post(this.alertsEndpoint, { prometheus_metric_id, operator, threshold })
.then(resp => resp.data);
}
// eslint-disable-next-line class-methods-use-this
readAlert(alertPath) {
return axios.get(alertPath).then(resp => resp.data);
}
// eslint-disable-next-line class-methods-use-this
updateAlert(alertPath, { operator, threshold }) {
return axios.put(alertPath, { operator, threshold }).then(resp => resp.data);
}
// eslint-disable-next-line class-methods-use-this
deleteAlert(alertPath) {
return axios.delete(alertPath).then(resp => resp.data);
}
}
......@@ -8,8 +8,6 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-07-17 07:12+0000\n"
"PO-Revision-Date: 2018-07-17 07:12+0000\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
......@@ -4969,6 +4967,36 @@ msgstr ""
msgid "ProjectsDropdown|Sorry, no projects matched your search"
msgstr ""
msgid "PrometheusAlerts|Add alert"
msgstr ""
msgid "PrometheusAlerts|Alert set"
msgstr ""
msgid "PrometheusAlerts|Edit alert"
msgstr ""
msgid "PrometheusAlerts|Error creating alert"
msgstr ""
msgid "PrometheusAlerts|Error deleting alert"
msgstr ""
msgid "PrometheusAlerts|Error fetching alert"
msgstr ""
msgid "PrometheusAlerts|Error saving alert"
msgstr ""
msgid "PrometheusAlerts|No alert set"
msgstr ""
msgid "PrometheusAlerts|Operator"
msgstr ""
msgid "PrometheusAlerts|Threshold"
msgstr ""
msgid "PrometheusDashboard|Time"
msgstr ""
......
import Vue from 'vue';
import AlertWidgetForm from 'ee/monitoring/components/alert_widget_form.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('AlertWidgetForm', () => {
let AlertWidgetFormComponent;
let vm;
const props = {
disabled: false,
};
beforeAll(() => {
AlertWidgetFormComponent = Vue.extend(AlertWidgetForm);
});
afterEach(() => {
if (vm) vm.$destroy();
});
it('disables the input when disabled prop is set', () => {
vm = mountComponent(AlertWidgetFormComponent, { ...props, disabled: true });
expect(vm.$refs.cancelButton).toBeDisabled();
expect(vm.$refs.submitButton).toBeDisabled();
});
it('emits a "create" event when form submitted without existing alert', done => {
vm = mountComponent(AlertWidgetFormComponent, props);
expect(vm.$refs.submitButton.innerText).toContain('Add');
vm.$once('create', alert => {
expect(alert).toEqual({
alert: null,
operator: '<',
threshold: 5,
});
done();
});
// the button should be disabled until an operator and threshold are selected
expect(vm.$refs.submitButton).toBeDisabled();
vm.operator = '<';
vm.threshold = 5;
Vue.nextTick(() => {
vm.$refs.submitButton.click();
});
});
it('emits a "delete" event when form submitted with existing alert and no changes are made', done => {
vm = mountComponent(AlertWidgetFormComponent, {
...props,
alert: 'alert',
alertData: { operator: '<', threshold: 5 },
});
vm.$once('delete', alert => {
expect(alert).toEqual({
alert: 'alert',
operator: '<',
threshold: 5,
});
done();
});
expect(vm.$refs.submitButton.innerText).toContain('Delete');
vm.$refs.submitButton.click();
});
it('emits a "update" event when form submitted with existing alert', done => {
vm = mountComponent(AlertWidgetFormComponent, {
...props,
alert: 'alert',
alertData: { operator: '<', threshold: 5 },
});
expect(vm.$refs.submitButton.innerText).toContain('Delete');
vm.$once('update', alert => {
expect(alert).toEqual({
alert: 'alert',
operator: '=',
threshold: 5,
});
done();
});
// change operator to allow update
vm.operator = '=';
Vue.nextTick(() => {
expect(vm.$refs.submitButton.innerText).toContain('Save');
vm.$refs.submitButton.click();
});
});
});
import Vue from 'vue';
import AlertWidget from 'ee/monitoring/components/alert_widget.vue';
import AlertsService from 'ee/monitoring/services/alerts_service';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('AlertWidget', () => {
let AlertWidgetComponent;
let vm;
const props = {
alertsEndpoint: '',
customMetricId: 5,
label: 'alert-label',
currentAlerts: ['my/alert.json'],
};
beforeAll(() => {
AlertWidgetComponent = Vue.extend(AlertWidget);
});
beforeEach(() => {
setFixtures('<div id="alert-widget"></div>');
});
afterEach(() => {
if (vm) vm.$destroy();
});
it('displays a loading spinner when fetching alerts', done => {
let resolveReadAlert;
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(
new Promise(cb => {
resolveReadAlert = cb;
}),
);
vm = mountComponent(AlertWidgetComponent, props, '#alert-widget');
// expect loading spinner to exist during fetch
expect(vm.isLoading).toBeTruthy();
expect(vm.$el.querySelector('.loading-container')).toBeVisible();
resolveReadAlert({ operator: '=', threshold: 42 });
// expect loading spinner to go away after fetch
setTimeout(() =>
vm.$nextTick(() => {
expect(vm.isLoading).toEqual(false);
expect(vm.$el.querySelector('.loading-container')).toBeHidden();
done();
}),
);
});
it('displays an error message when fetch fails', done => {
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(Promise.reject());
vm = mountComponent(AlertWidgetComponent, props, '#alert-widget');
setTimeout(() =>
vm.$nextTick(() => {
expect(vm.errorMessage).toBe('Error fetching alert');
expect(vm.isLoading).toEqual(false);
expect(vm.$el.querySelector('.alert-error-message')).toBeVisible();
done();
}),
);
});
it('displays an alert summary when fetch succeeds', done => {
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(
Promise.resolve({ operator: '>', threshold: 42 }),
);
vm = mountComponent(AlertWidgetComponent, props, '#alert-widget');
setTimeout(() =>
vm.$nextTick(() => {
expect(vm.isLoading).toEqual(false);
expect(vm.alertSummary).toBe('alert-label > 42');
expect(vm.$el.querySelector('.alert-current-setting')).toBeVisible();
done();
}),
);
});
it('opens and closes a dropdown menu by clicking close button', done => {
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [] });
expect(vm.isOpen).toEqual(false);
expect(vm.$el.querySelector('.alert-dropdown-menu')).toBeHidden();
vm.$el.querySelector('.alert-dropdown-button').click();
Vue.nextTick(() => {
expect(vm.isOpen).toEqual(true);
expect(vm.$el).toHaveClass('show');
vm.$el.querySelector('.dropdown-menu-close').click();
Vue.nextTick(() => {
expect(vm.isOpen).toEqual(false);
expect(vm.$el).not.toHaveClass('show');
done();
});
});
});
it('opens and closes a dropdown menu by clicking outside the menu', done => {
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [] });
expect(vm.isOpen).toEqual(false);
expect(vm.$el.querySelector('.alert-dropdown-menu')).toBeHidden();
vm.$el.querySelector('.alert-dropdown-button').click();
Vue.nextTick(() => {
expect(vm.isOpen).toEqual(true);
expect(vm.$el).toHaveClass('show');
document.body.click();
Vue.nextTick(() => {
expect(vm.isOpen).toEqual(false);
expect(vm.$el).not.toHaveClass('show');
done();
});
});
});
it('creates an alert with an appropriate handler', done => {
const alertParams = {
operator: '<',
threshold: 4,
prometheus_metric_id: 5,
};
spyOn(AlertsService.prototype, 'createAlert').and.returnValue(
Promise.resolve({
alert_path: 'foo/bar',
...alertParams,
}),
);
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [] });
vm.$refs.widgetForm.$emit('create', alertParams);
expect(AlertsService.prototype.createAlert).toHaveBeenCalledWith(alertParams);
Vue.nextTick(() => {
expect(vm.isLoading).toEqual(false);
expect(vm.alertSummary).toBe('alert-label < 4');
done();
});
});
it('updates an alert with an appropriate handler', done => {
const alertPath = 'my/test/alert.json';
const alertParams = {
operator: '<',
threshold: 4,
};
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(Promise.resolve(alertParams));
spyOn(AlertsService.prototype, 'updateAlert').and.returnValue(Promise.resolve());
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [alertPath] });
vm.$refs.widgetForm.$emit('update', {
...alertParams,
alert: alertPath,
operator: '=',
threshold: 12,
});
expect(AlertsService.prototype.updateAlert).toHaveBeenCalledWith(alertPath, {
...alertParams,
operator: '=',
threshold: 12,
});
Vue.nextTick(() => {
expect(vm.isLoading).toEqual(false);
expect(vm.alertSummary).toBe('alert-label = 12');
done();
});
});
it('deletes an alert with an appropriate handler', done => {
const alertPath = 'my/test/alert.json';
const alertParams = {
operator: '<',
threshold: 4,
};
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(Promise.resolve(alertParams));
spyOn(AlertsService.prototype, 'deleteAlert').and.returnValue(Promise.resolve());
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [alertPath] });
vm.$refs.widgetForm.$emit('delete', { alert: alertPath });
expect(AlertsService.prototype.deleteAlert).toHaveBeenCalledWith(alertPath);
Vue.nextTick(() => {
expect(vm.isLoading).toEqual(false);
expect(vm.alertSummary).toBeFalsy();
done();
});
});
});
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