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