Commit 2befec1a authored by Denys Mishunov's avatar Denys Mishunov

Merge branch '208496_Firing_alerts' into 'master'

Indicate whether the alert is firing

See merge request gitlab-org/gitlab!27825
parents c4d43725 4d96a216
......@@ -93,6 +93,11 @@
.alert-current-setting {
max-width: 240px;
.badge.badge-danger {
color: $red-500;
background-color: $red-100;
}
}
.prometheus-graph-cursor {
......
<script>
import { GlBadge, GlLoadingIcon, GlModalDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import Icon from '~/vue_shared/components/icon.vue';
import AlertWidgetForm from './alert_widget_form.vue';
import AlertsService from '../services/alerts_service';
import { alertsValidator, queriesValidator } from '../validators';
import { OPERATORS } from '../constants';
import { values, get } from 'lodash';
export default {
components: {
AlertWidgetForm,
GlBadge,
GlLoadingIcon,
Icon,
GlIcon,
GlTooltip,
GlSprintf,
},
directives: {
GlModal: GlModalDirective,
......@@ -50,16 +53,48 @@ export default {
apiAction: 'create',
};
},
i18n: {
alertsCountMsg: s__('PrometheusAlerts|%{count} alerts applied'),
singleFiringMsg: s__('PrometheusAlerts|Firing: %{alert}'),
multipleFiringMsg: s__('PrometheusAlerts|%{firingCount} firing'),
firingAlertsTooltip: s__('PrometheusAlerts|Firing: %{alerts}'),
},
computed: {
alertSummary() {
singleAlertSummary() {
return {
message: this.isFiring ? this.$options.i18n.singleFiringMsg : this.thresholds[0],
alert: this.thresholds[0],
};
},
multipleAlertsSummary() {
return {
message: this.isFiring
? `${this.$options.i18n.alertsCountMsg}, ${this.$options.i18n.multipleFiringMsg}`
: this.$options.i18n.alertsCountMsg,
count: this.thresholds.length,
firingCount: this.firingAlerts.length,
};
},
thresholds() {
const alertsToManage = Object.keys(this.alertsToManage);
const alertCountMsg = sprintf(s__('PrometheusAlerts|%{count} alerts applied'), {
count: alertsToManage.length,
});
return alertsToManage.length > 1
? alertCountMsg
: alertsToManage.map(this.formatAlertSummary)[0];
return alertsToManage.map(this.formatAlertSummary);
},
hasAlerts() {
return Boolean(Object.keys(this.alertsToManage).length);
},
hasMultipleAlerts() {
return this.thresholds.length > 1;
},
isFiring() {
return Boolean(this.firingAlerts.length);
},
firingAlerts() {
return values(this.alertsToManage).filter(alert =>
this.passedAlertThreshold(this.getQueryData(alert), alert),
);
},
formattedFiringAlerts() {
return this.firingAlerts.map(alert => this.formatAlertSummary(alert.alert_path));
},
},
created() {
......@@ -99,6 +134,25 @@ export default {
return `${alertQuery.label} ${alert.operator} ${alert.threshold}`;
},
passedAlertThreshold(data, alert) {
const { threshold, operator } = alert;
switch (operator) {
case OPERATORS.greaterThan:
return data.some(value => value > threshold);
case OPERATORS.lessThan:
return data.some(value => value < threshold);
case OPERATORS.equalTo:
return data.some(value => value === threshold);
default:
return false;
}
},
getQueryData(alert) {
const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId);
return get(alertQuery, 'result[0].values', []).map(value => get(value, '[1]', null));
},
showModal() {
this.$root.$emit('bv::show::modal', this.modalId);
},
......@@ -159,24 +213,49 @@ export default {
<template>
<div class="prometheus-alert-widget dropdown flex-grow-2 overflow-hidden">
<span v-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{
<gl-loading-icon v-if="isLoading" :inline="true" />
<span v-else-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{
errorMessage
}}</span>
<span
v-else
v-else-if="hasAlerts"
ref="alertCurrentSetting"
class="alert-current-setting text-secondary cursor-pointer d-flex align-items-end"
class="alert-current-setting cursor-pointer d-flex"
@click="showModal"
>
<gl-badge
v-if="alertSummary"
variant="secondary"
class="d-flex-center text-secondary text-truncate"
:variant="isFiring ? 'danger' : 'secondary'"
pill
class="d-flex-center text-truncate"
>
<gl-icon name="warning" :size="16" class="flex-shrink-0" />
<span class="text-truncate gl-pl-1">
<gl-sprintf
:message="
hasMultipleAlerts ? multipleAlertsSummary.message : singleAlertSummary.message
"
>
<icon name="warning" class="s18 append-right-4" :size="16" />
<span class="text-truncate">{{ alertSummary }}</span>
<template #alert>
{{ singleAlertSummary.alert }}
</template>
<template #count>
{{ multipleAlertsSummary.count }}
</template>
<template #firingCount>
{{ multipleAlertsSummary.firingCount }}
</template>
</gl-sprintf>
</span>
</gl-badge>
<gl-loading-icon v-show="isLoading" :inline="true" />
<gl-tooltip v-if="hasMultipleAlerts && isFiring" :target="() => $refs.alertCurrentSetting">
<gl-sprintf :message="$options.i18n.firingAlertsTooltip">
<template #alerts>
<div v-for="alert in formattedFiringAlerts" :key="alert.alert_path">
{{ alert }}
</div>
</template>
</gl-sprintf>
</gl-tooltip>
</span>
<alert-widget-form
ref="widgetForm"
......
......@@ -18,6 +18,7 @@ import TrackEventDirective from '~/vue_shared/directives/track_event';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Icon from '~/vue_shared/components/icon.vue';
import { alertsValidator, queriesValidator } from '../validators';
import { OPERATORS } from '../constants';
Vue.use(Translate);
......@@ -33,12 +34,6 @@ const SUBMIT_BUTTON_CLASS = {
delete: 'btn-remove',
};
const OPERATORS = {
greaterThan: '>',
equalTo: '==',
lessThan: '<',
};
export default {
components: {
GlButton,
......
// eslint-disable-next-line import/prefer-default-export
export const OPERATORS = {
greaterThan: '>',
equalTo: '==',
lessThan: '<',
};
---
title: Indicate whether the alert is firing
merge_request: 27825
author:
type: added
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AlertWidget displays a warning icon and matches snapshopt 1`] = `
exports[`AlertWidget Alert firing displays a warning icon and matches snapshot 1`] = `
<gl-badge-stub
class="d-flex-center text-secondary text-truncate"
class="d-flex-center text-truncate"
pill=""
variant="danger"
>
<gl-icon-stub
class="flex-shrink-0"
name="warning"
size="16"
/>
<span
class="text-truncate gl-pl-1"
>
Firing:
alert-label &gt; 42
</span>
</gl-badge-stub>
`;
exports[`AlertWidget Alert not firing displays a warning icon and matches snapshot 1`] = `
<gl-badge-stub
class="d-flex-center text-truncate"
pill=""
variant="secondary"
>
<icon-stub
class="s18 append-right-4"
<gl-icon-stub
class="flex-shrink-0"
name="warning"
size="16"
/>
<span
class="text-truncate"
class="text-truncate gl-pl-1"
>
alert-label &gt; 42
</span>
......
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon, GlBadge } from '@gitlab/ui';
import { GlLoadingIcon, GlTooltip, GlSprintf, GlBadge } from '@gitlab/ui';
import AlertWidget from 'ee/monitoring/components/alert_widget.vue';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
......@@ -26,9 +26,36 @@ jest.mock(
describe('AlertWidget', () => {
let wrapper;
const nonFiringAlertResult = [
{
values: [[0, 1], [1, 42], [2, 41]],
},
];
const firingAlertResult = [
{
values: [[0, 42], [1, 43], [2, 44]],
},
];
const metricId = '5';
const alertPath = 'my/alert.json';
const relevantQueries = [{ metricId, label: 'alert-label', alert_path: alertPath }];
const relevantQueries = [
{
metricId,
label: 'alert-label',
alert_path: alertPath,
result: nonFiringAlertResult,
},
];
const firingRelevantQueries = [
{
metricId,
label: 'alert-label',
alert_path: alertPath,
result: firingAlertResult,
},
];
const defaultProps = {
alertsEndpoint: '',
......@@ -50,16 +77,23 @@ describe('AlertWidget', () => {
const createComponent = propsData => {
wrapper = shallowMount(AlertWidget, {
stubs: { GlTooltip, GlSprintf },
propsData: {
...defaultProps,
...propsData,
},
});
};
const hasLoadingIcon = () => wrapper.contains(GlLoadingIcon);
const findWidgetForm = () => wrapper.find({ ref: 'widgetForm' });
const findAlertErrorMessage = () => wrapper.find({ ref: 'alertErrorMessage' });
const findCurrentSettings = () => wrapper.find({ ref: 'alertCurrentSetting' });
const findCurrentSettingsText = () =>
wrapper
.find({ ref: 'alertCurrentSetting' })
.text()
.replace(/\s\s+/g, ' ');
const findBadge = () => wrapper.find(GlBadge);
const findTooltip = () => wrapper.find(GlTooltip);
afterEach(() => {
wrapper.destroy();
......@@ -77,14 +111,14 @@ describe('AlertWidget', () => {
return wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
expect(hasLoadingIcon()).toBe(true);
expect(findWidgetForm().props('disabled')).toBe(true);
resolveReadAlert({ operator: '==', threshold: 42 });
})
.then(() => waitForPromises())
.then(() => {
expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(false);
expect(hasLoadingIcon()).toBe(false);
expect(findWidgetForm().props('disabled')).toBe(false);
});
});
......@@ -92,35 +126,138 @@ describe('AlertWidget', () => {
it('displays an error message when fetch fails', () => {
mockReadAlert.mockRejectedValue();
createComponent(propsWithAlert);
expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
expect(hasLoadingIcon()).toBe(true);
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalled();
expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(false);
expect(hasLoadingIcon()).toBe(false);
});
});
describe('Alert not firing', () => {
it('displays a warning icon and matches snapshot', () => {
mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
createComponent(propsWithAlertData);
return waitForPromises().then(() => {
expect(findBadge().element).toMatchSnapshot();
});
});
it('displays an alert summary when there is a single alert', () => {
mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
createComponent(propsWithAlertData);
return waitForPromises().then(() => {
expect(findCurrentSettingsText()).toEqual('alert-label > 42');
});
});
expect(wrapper.text()).toContain('alert-label > 42');
it('displays a combined alert summary when there are multiple alerts', () => {
mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
const propsWithManyAlerts = {
relevantQueries: [
...relevantQueries,
...[
{
metricId: '6',
alert_path: 'my/alert2.json',
label: 'alert-label2',
result: [{ values: [] }],
},
],
],
alertsToManage: {
'my/alert.json': {
operator: '>',
threshold: 42,
alert_path: alertPath,
metricId,
},
'my/alert2.json': {
operator: '==',
threshold: 900,
alert_path: 'my/alert2.json',
metricId: '6',
},
},
};
createComponent(propsWithManyAlerts);
return waitForPromises().then(() => {
expect(findCurrentSettingsText()).toContain('2 alerts applied');
});
});
});
it('displays a warning icon and matches snapshopt', () => {
describe('Alert firing', () => {
it('displays a warning icon and matches snapshot', () => {
mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
propsWithAlertData.relevantQueries = firingRelevantQueries;
createComponent(propsWithAlertData);
return waitForPromises().then(() => {
expect(findBadge().element).toMatchSnapshot();
});
});
it('displays an alert summary when there is a single alert', () => {
mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
propsWithAlertData.relevantQueries = firingRelevantQueries;
createComponent(propsWithAlertData);
return waitForPromises().then(() => {
expect(findCurrentSettingsText()).toEqual('Firing: alert-label > 42');
});
});
it('displays a combined alert summary when there are multiple alerts', () => {
mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
const propsWithManyAlerts = {
relevantQueries: relevantQueries.concat([
{ metricId: '6', alert_path: 'my/alert2.json', label: 'alert-label2' },
]),
relevantQueries: [
...firingRelevantQueries,
...[
{
metricId: '6',
alert_path: 'my/alert2.json',
label: 'alert-label2',
result: [{ values: [] }],
},
],
],
alertsToManage: {
'my/alert.json': {
operator: '>',
threshold: 42,
alert_path: alertPath,
metricId,
},
'my/alert2.json': {
operator: '==',
threshold: 900,
alert_path: 'my/alert2.json',
metricId: '6',
},
},
};
createComponent(propsWithManyAlerts);
return waitForPromises().then(() => {
expect(findCurrentSettingsText()).toContain('2 alerts applied, 1 firing');
});
});
it('should display tooltip with thresholds summary', () => {
mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 });
const propsWithManyAlerts = {
relevantQueries: [
...firingRelevantQueries,
...[
{
metricId: '6',
alert_path: 'my/alert2.json',
label: 'alert-label2',
result: [{ values: [] }],
},
],
],
alertsToManage: {
'my/alert.json': {
operator: '>',
......@@ -138,7 +275,14 @@ describe('AlertWidget', () => {
};
createComponent(propsWithManyAlerts);
expect(findCurrentSettings().text()).toEqual('2 alerts applied');
return waitForPromises().then(() => {
expect(
findTooltip()
.text()
.replace(/\s\s+/g, ' '),
).toEqual('Firing: alert-label > 42');
});
});
});
it('creates an alert with an appropriate handler', () => {
......
......@@ -15900,6 +15900,9 @@ msgstr ""
msgid "PrometheusAlerts|%{count} alerts applied"
msgstr ""
msgid "PrometheusAlerts|%{firingCount} firing"
msgstr ""
msgid "PrometheusAlerts|Add alert"
msgstr ""
......@@ -15918,6 +15921,12 @@ msgstr ""
msgid "PrometheusAlerts|Error saving alert"
msgstr ""
msgid "PrometheusAlerts|Firing: %{alerts}"
msgstr ""
msgid "PrometheusAlerts|Firing: %{alert}"
msgstr ""
msgid "PrometheusAlerts|Operator"
msgstr ""
......
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