Commit df67ad13 authored by Alexander Turinske's avatar Alexander Turinske

Update alert drawer with Sentry error handling

- add more robust CSS styling for the header height
- add specific errors for different failures
- add sentry calls on error
- update documentation
- update tests
parent 4d769b70
......@@ -223,6 +223,8 @@ checkbox **Hide dismissed alerts**.
![Policy Alert List](img/threat_monitoring_policy_alert_list_v13_12.png)
Clicking an alert's row will open the alert drawer that shows more details and allows a user to create an incident from the alert.
Clicking an alert's name takes the user to the [alert details page](../../../operations/incident_management/alerts.md#alert-details-page).
For information on work in progress for the alerts dashboard, see [this epic](https://gitlab.com/groups/gitlab-org/-/epics/5041).
<script>
import { GlAlert, GlButton, GlDrawer, GlLink, GlSkeletonLoader } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { capitalizeFirstCharacter, splitCamelCase } from '~/lib/utils/text_utility';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import createIssueMutation from '~/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql';
import getAlertDetailsQuery from '~/vue_shared/alert_details/graphql/queries/alert_details.query.graphql';
import { ALERT_DETAILS_LOADING_ROWS, HIDDEN_VALUES } from './constants';
import { ALERT_DETAILS_LOADING_ROWS, DRAWER_ERRORS, HIDDEN_VALUES } from './constants';
export default {
ALERT_DETAILS_LOADING_ROWS,
i18n: {
CREATE_ISSUE: __('Create incident'),
ERROR: __('There was an error fetching content, please refresh the page'),
ERRORS: { ...DRAWER_ERRORS },
},
components: {
GlAlert,
......@@ -33,8 +34,8 @@ export default {
update(data) {
return data?.project?.alertManagementAlerts?.nodes?.[0] ?? null;
},
error() {
this.errored = true;
error(error) {
this.handleAlertError({ type: 'DETAILS', error });
},
},
},
......@@ -52,12 +53,13 @@ export default {
selectedAlert: {
type: Object,
required: true,
validator: (value) => ['iid', 'title'].every((prop) => value[prop]),
},
},
data() {
return {
alertDetails: {},
errored: false,
errorMessage: '',
creatingIssue: false,
};
},
......@@ -73,6 +75,9 @@ export default {
[],
);
},
errored() {
return Boolean(this.errorMessage);
},
hasIssue() {
return Boolean(this.selectedAlert.issue);
},
......@@ -98,16 +103,26 @@ export default {
const { errors, issue } = response.data.createAlertIssue;
if (errors?.length) {
throw new Error();
throw new Error(errors[0]);
}
visitUrl(issue.webUrl);
} catch {
this.handleAlertError();
} catch (error) {
this.handleAlertError({ type: 'CREATE_ISSUE', error });
this.creatingIssue = false;
}
},
handleAlertError() {
this.errored = true;
getDrawerHeaderHeight() {
const wrapperEl = document.querySelector('.js-threat-monitoring-container-wrapper');
if (wrapperEl) {
return `${wrapperEl.offsetTop}px`;
}
return '';
},
handleAlertError({ type, error }) {
this.errorMessage = this.$options.i18n.ERRORS[type];
Sentry.captureException(error);
},
humanizeText(text) {
return capitalizeFirstCharacter(splitCamelCase(text));
......@@ -117,6 +132,7 @@ export default {
</script>
<template>
<gl-drawer
:header-height="getDrawerHeaderHeight()"
:z-index="252"
class="threat-monitoring-alert-drawer gl-bg-gray-10"
:open="isAlertDrawerOpen"
......@@ -132,7 +148,6 @@ export default {
v-else
category="primary"
variant="confirm"
:disabled="errored"
:loading="creatingIssue"
data-testid="create-issue-button"
@click="createIssue"
......@@ -141,8 +156,8 @@ export default {
</gl-button>
</div>
</template>
<gl-alert v-if="errored" variant="danger" :dismissable="false" contained>
{{ $options.i18n.ERROR }}
<gl-alert v-if="errored" variant="danger" :dismissible="false" contained>
{{ errorMessage }}
</gl-alert>
<div v-if="isLoadingDetails">
<div v-for="row in $options.ALERT_DETAILS_LOADING_ROWS" :key="row" class="gl-mb-5">
......
......@@ -89,7 +89,7 @@ export default {
isAlertDrawerOpen: false,
isErrorAlertDismissed: false,
pageInfo: {},
selectedAlert: {},
selectedAlert: null,
sort: 'STARTED_AT_DESC',
sortBy: 'startedAt',
sortDesc: true,
......@@ -318,6 +318,7 @@ export default {
<span v-else>&nbsp;</span>
</gl-intersection-observer>
<alert-drawer
v-if="selectedAlert"
:is-alert-drawer-open="isAlertDrawerOpen"
:selected-alert="selectedAlert"
@deselect-alert="handleAlertDeselect"
......
......@@ -86,3 +86,8 @@ export const HIDDEN_VALUES = [
];
export const ALERT_DETAILS_LOADING_ROWS = 20;
export const DRAWER_ERRORS = {
DETAILS: __('There was an error fetching content, please refresh the page'),
CREATE_ISSUE: s__('ThreatMonitoring|Failed to create incident, please try again.'),
};
......@@ -6,15 +6,6 @@
}
}
.threat-monitoring-alert-drawer {
// Override gl-drawer inline styles
top: $header-height !important;
.gl-drawer-header {
align-items: flex-start;
}
}
.with-performance-bar .threat-monitoring-alert-drawer {
top: $performance-bar-height + $header-height !important;
.threat-monitoring-alert-drawer .gl-drawer-header {
align-items: flex-start;
}
- breadcrumb_title s_("ThreatMonitoring|Threat Monitoring")
- page_title s_("ThreatMonitoring|Threat Monitoring")
- @content_wrapper_class = 'js-threat-monitoring-container-wrapper'
- default_environment_id = @project.default_environment&.id || -1
......
import { GlAlert, GlDrawer, GlSkeletonLoader } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import AlertDrawer from 'ee/threat_monitoring/components/alerts/alert_drawer.vue';
import { DRAWER_ERRORS } from 'ee/threat_monitoring/components/alerts/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { visitUrl } from '~/lib/utils/url_utility';
import getAlertDetailsQuery from '~/vue_shared/alert_details/graphql/queries/alert_details.query.graphql';
import { erroredGetAlertDetailsQuerySpy, getAlertDetailsQuerySpy } from '../../mocks/mock_apollo';
import { mockAlertDetails } from '../../mocks/mock_data';
import {
erroredGetAlertDetailsQuerySpy,
getAlertDetailsQueryErrorMessage,
getAlertDetailsQuerySpy,
} from '../../mocks/mock_apollo';
import { mockAlertDetails, mockAlerts } from '../../mocks/mock_data';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
......@@ -80,6 +86,7 @@ describe('Alert Drawer', () => {
...apolloOptions,
});
};
describe('default', () => {
it.each`
component | status | findComponent | state | mount
......@@ -109,32 +116,38 @@ describe('Alert Drawer', () => {
});
it('displays the alert when there was an error retrieving alert details', async () => {
const errorMessage = `GraphQL error: ${getAlertDetailsQueryErrorMessage}`;
const captureExceptionSpy = jest.spyOn(Sentry, 'captureException');
createWrapper({ apolloSpy: erroredGetAlertDetailsQuerySpy });
await wrapper.vm.$nextTick();
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(DRAWER_ERRORS.DETAILS);
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
expect(captureExceptionSpy.mock.calls[0][0].message).toBe(errorMessage);
});
describe('creating an issue', () => {
it('navigates to the created issue when the "Create Issue" button is clicked', async () => {
createWrapper({
$apollo: shallowApolloMock({}),
props: { selectedAlert: {} },
});
const captureExceptionSpy = jest.spyOn(Sentry, 'captureException');
createWrapper({ $apollo: shallowApolloMock({}), props: { selectedAlert: mockAlerts[2] } });
expect(findCreateIssueButton().exists()).toBe(true);
findCreateIssueButton().vm.$emit('click');
await waitForPromises();
expect(mutateSpy).toHaveBeenCalledTimes(1);
expect(captureExceptionSpy).not.toHaveBeenCalled();
expect(visitUrl).toHaveBeenCalledWith('/#/-/issues/03');
});
it('displays the alert when there was an error creating an issue', async () => {
const errorMessage = 'GraphQL error';
const captureExceptionSpy = jest.spyOn(Sentry, 'captureException');
const erroredMutateSpy = jest
.fn()
.mockResolvedValue({ data: { createAlertIssue: { errors: ['test'] } } });
.mockResolvedValue({ data: { createAlertIssue: { errors: [errorMessage] } } });
createWrapper({
$apollo: shallowApolloMock({ mutate: erroredMutateSpy }),
props: { selectedAlert: {} },
props: { selectedAlert: mockAlerts[2] },
});
expect(findCreateIssueButton().exists()).toBe(true);
findCreateIssueButton().vm.$emit('click');
......@@ -142,6 +155,9 @@ describe('Alert Drawer', () => {
expect(erroredMutateSpy).toHaveBeenCalledTimes(1);
expect(visitUrl).not.toHaveBeenCalled();
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(DRAWER_ERRORS.CREATE_ISSUE);
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
expect(captureExceptionSpy.mock.calls[0][0].message).toBe(errorMessage);
});
});
});
......@@ -21,6 +21,9 @@ export const getAlertDetailsQuerySpy = jest.fn().mockResolvedValue({
data: { project: { alertManagementAlerts: { nodes: [mockAlertDetails] } } },
});
export const getAlertDetailsQueryErrorMessage =
'Variable $fullPath of type ID! was provided invalid value';
export const erroredGetAlertDetailsQuerySpy = jest.fn().mockResolvedValue({
errors: [{ message: 'Variable $fullPath of type ID! was provided invalid value' }],
errors: [{ message: getAlertDetailsQueryErrorMessage }],
});
......@@ -33425,6 +33425,9 @@ msgstr ""
msgid "ThreatMonitoring|Events"
msgstr ""
msgid "ThreatMonitoring|Failed to create incident, please try again."
msgstr ""
msgid "ThreatMonitoring|Hide dismissed alerts"
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