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