Commit 052a7e86 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '287755-add-alert-drawer' into 'master'

Create threat alert drawer

See merge request gitlab-org/gitlab!61183
parents 6fd64e5c df67ad13
......@@ -9,6 +9,7 @@ fragment AlertListItem on AlertManagementAlert {
iid
state
title
webUrl
}
assignees {
nodes {
......
......@@ -3,6 +3,7 @@ mutation createAlertIssue($projectPath: ID!, $iid: String!) {
errors
issue {
iid
webUrl
}
}
}
......@@ -227,6 +227,8 @@ By default, the list doesn't display resolved or 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, DRAWER_ERRORS, HIDDEN_VALUES } from './constants';
export default {
ALERT_DETAILS_LOADING_ROWS,
i18n: {
CREATE_ISSUE: __('Create incident'),
ERRORS: { ...DRAWER_ERRORS },
},
components: {
GlAlert,
GlButton,
GlDrawer,
GlLink,
GlSkeletonLoader,
},
inject: ['projectPath'],
apollo: {
alertDetails: {
query: getAlertDetailsQuery,
variables() {
return {
fullPath: this.projectPath,
alertId: this.selectedAlert.iid,
};
},
update(data) {
return data?.project?.alertManagementAlerts?.nodes?.[0] ?? null;
},
error(error) {
this.handleAlertError({ type: 'DETAILS', error });
},
},
},
props: {
isAlertDrawerOpen: {
type: Boolean,
required: false,
default: false,
},
projectId: {
type: String,
required: false,
default: '',
},
selectedAlert: {
type: Object,
required: true,
validator: (value) => ['iid', 'title'].every((prop) => value[prop]),
},
},
data() {
return {
alertDetails: {},
errorMessage: '',
creatingIssue: false,
};
},
computed: {
alertIssuePath() {
return this.selectedAlert.issue?.webUrl || '';
},
curatedAlertDetails() {
return Object.entries({ ...this.alertDetails, ...this.alertDetails?.details }).reduce(
(acc, [key, value]) => {
return HIDDEN_VALUES.includes(key) || !value ? acc : [...acc, [key, value]];
},
[],
);
},
errored() {
return Boolean(this.errorMessage);
},
hasIssue() {
return Boolean(this.selectedAlert.issue);
},
issueText() {
return `#${this.selectedAlert.issue?.iid}`;
},
isLoadingDetails() {
return this.$apollo.queries.alertDetails.loading;
},
},
methods: {
async createIssue() {
this.creatingIssue = true;
try {
const response = await this.$apollo.mutate({
mutation: createIssueMutation,
variables: {
iid: this.selectedAlert.iid,
projectPath: this.projectPath,
},
});
const { errors, issue } = response.data.createAlertIssue;
if (errors?.length) {
throw new Error(errors[0]);
}
visitUrl(issue.webUrl);
} catch (error) {
this.handleAlertError({ type: 'CREATE_ISSUE', error });
this.creatingIssue = false;
}
},
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));
},
},
};
</script>
<template>
<gl-drawer
:header-height="getDrawerHeaderHeight()"
:z-index="252"
class="threat-monitoring-alert-drawer gl-bg-gray-10"
:open="isAlertDrawerOpen"
@close="$emit('deselect-alert')"
>
<template #header>
<h5 class="gl-mt-2 gl-mb-5">{{ selectedAlert.title }}</h5>
<div>
<gl-link v-if="hasIssue" :href="alertIssuePath" data-testid="issue-link">
{{ issueText }}
</gl-link>
<gl-button
v-else
category="primary"
variant="confirm"
:loading="creatingIssue"
data-testid="create-issue-button"
@click="createIssue"
>
{{ $options.i18n.CREATE_ISSUE }}
</gl-button>
</div>
</template>
<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">
<gl-skeleton-loader :lines="2" :width="400" />
</div>
</div>
<div v-else data-testid="details-list">
<div v-for="[key, value] in curatedAlertDetails" :key="key" class="gl-mb-5">
<div class="gl-mb-2">{{ humanizeText(key) }}</div>
<b>{{ value }}</b>
</div>
</div>
</gl-drawer>
</template>
......@@ -17,6 +17,7 @@ import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { joinPaths } from '~/lib/utils/url_utility';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import AlertDrawer from './alert_drawer.vue';
import AlertFilters from './alert_filters.vue';
import AlertStatus from './alert_status.vue';
import {
......@@ -39,6 +40,7 @@ export default {
CLOSED,
},
components: {
AlertDrawer,
AlertStatus,
AlertFilters,
GlAlert,
......@@ -84,8 +86,10 @@ export default {
errored: false,
errorMsg: '',
filters: DEFAULT_FILTERS,
isAlertDrawerOpen: false,
isErrorAlertDismissed: false,
pageInfo: {},
selectedAlert: null,
sort: 'STARTED_AT_DESC',
sortBy: 'startedAt',
sortDesc: true,
......@@ -146,6 +150,10 @@ export default {
),
};
},
handleAlertDeselect() {
this.isAlertDrawerOpen = false;
this.selectedAlert = {};
},
handleAlertError(msg) {
this.errored = true;
this.errorMsg = msg;
......@@ -162,6 +170,10 @@ export default {
alertDetailsUrl({ iid }) {
return joinPaths(window.location.pathname, 'alerts', iid);
},
openAlertDrawer(data) {
this.isAlertDrawerOpen = true;
this.selectedAlert = data;
},
},
};
</script>
......@@ -201,6 +213,7 @@ export default {
sort-icon-left
responsive
show-empty
@row-clicked="openAlertDrawer"
@sort-changed="fetchSortedData"
>
<template #cell(startedAt)="{ item }">
......@@ -304,5 +317,11 @@ export default {
<gl-loading-icon v-if="isLoadingAlerts" size="md" />
<span v-else>&nbsp;</span>
</gl-intersection-observer>
<alert-drawer
v-if="selectedAlert"
:is-alert-drawer-open="isAlertDrawerOpen"
:selected-alert="selectedAlert"
@deselect-alert="handleAlertDeselect"
/>
</div>
</template>
......@@ -72,3 +72,22 @@ export const DEBOUNCE = 250;
export const ALL = { key: 'ALL', value: __('All') };
export const CLOSED = __('closed');
export const HIDDEN_VALUES = [
'__typename',
'assignees',
'details',
'iid',
'issue',
'notes',
'severity',
'status',
'todos',
];
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.'),
};
......@@ -5,3 +5,7 @@
background-color: $gray-50;
}
}
.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
......
---
title: Create threat alert drawer
merge_request: 61183
author:
type: added
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,
getAlertDetailsQueryErrorMessage,
getAlertDetailsQuerySpy,
} from '../../mocks/mock_apollo';
import { mockAlertDetails, mockAlerts } from '../../mocks/mock_data';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
}));
let localVue;
describe('Alert Drawer', () => {
let wrapper;
const DEFAULT_PROJECT_PATH = '#';
const mutateSpy = jest.fn().mockResolvedValue({
data: { createAlertIssue: { errors: [], issue: { webUrl: '/#/-/issues/03' } } },
});
let querySpy;
const createMockApolloProvider = (query) => {
localVue.use(VueApollo);
return createMockApollo([[getAlertDetailsQuery, query]]);
};
const shallowApolloMock = ({ loading = false, mutate = mutateSpy }) => ({
mutate,
queries: { alertDetails: { loading } },
});
const findAlert = () => wrapper.findComponent(GlAlert);
const findCreateIssueButton = () => wrapper.findByTestId('create-issue-button');
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findIssueLink = () => wrapper.findByTestId('issue-link');
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findDetails = () => wrapper.findByTestId('details-list');
const createWrapper = ({
$apollo,
apolloSpy = getAlertDetailsQuerySpy,
mount = shallowMountExtended,
props = {},
} = {}) => {
let apolloOptions;
if ($apollo) {
apolloOptions = {
mocks: {
$apollo,
},
};
} else {
localVue = createLocalVue();
querySpy = apolloSpy;
const mockApollo = createMockApolloProvider(querySpy);
apolloOptions = {
localVue,
apolloProvider: mockApollo,
};
}
wrapper = mount(AlertDrawer, {
propsData: {
isAlertDrawerOpen: true,
projectId: '1',
selectedAlert: mockAlertDetails,
...props,
},
provide: {
projectPath: DEFAULT_PROJECT_PATH,
},
stubs: { GlDrawer },
...apolloOptions,
});
};
describe('default', () => {
it.each`
component | status | findComponent | state | mount
${'alert'} | ${'does not display'} | ${findAlert} | ${false} | ${undefined}
${'"Create Issue" button'} | ${'does not display'} | ${findCreateIssueButton} | ${false} | ${undefined}
${'details list'} | ${'does display'} | ${findDetails} | ${true} | ${undefined}
${'drawer'} | ${'does display'} | ${findDrawer} | ${true} | ${undefined}
${'issue link'} | ${'does display'} | ${findIssueLink} | ${true} | ${undefined}
${'skeleton loader'} | ${'does not display'} | ${findSkeletonLoader} | ${false} | ${mountExtended}
`('$status the $component', async ({ findComponent, state, mount }) => {
createWrapper({ $apollo: shallowApolloMock({}), mount });
await wrapper.vm.$nextTick();
expect(findComponent().exists()).toBe(state);
});
});
it('displays the issue link if an alert already has an issue associated with it', () => {
createWrapper();
expect(findIssueLink().exists()).toBe(true);
expect(findIssueLink().attributes('href')).toBe('/#/-/issues/02');
});
it('displays the loading icon when retrieving the alert details', () => {
createWrapper({ $apollo: shallowApolloMock({ loading: true }) });
expect(findSkeletonLoader().exists()).toBe(true);
expect(findDetails().exists()).toBe(false);
});
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 () => {
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: [errorMessage] } } });
createWrapper({
$apollo: shallowApolloMock({ mutate: erroredMutateSpy }),
props: { selectedAlert: mockAlerts[2] },
});
expect(findCreateIssueButton().exists()).toBe(true);
findCreateIssueButton().vm.$emit('click');
await waitForPromises();
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);
});
});
});
......@@ -8,7 +8,11 @@ import { DEFAULT_FILTERS } from 'ee/threat_monitoring/components/alerts/constant
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql';
import { defaultQuerySpy, emptyQuerySpy, loadingQuerySpy } from '../../mocks/mock_apollo';
import {
getAlertsQuerySpy,
emptyGetAlertsQuerySpy,
loadingQuerySpy,
} from '../../mocks/mock_apollo';
import { mockAlerts, mockPageInfo } from '../../mocks/mock_data';
let localVue;
......@@ -54,7 +58,7 @@ describe('AlertsList component', () => {
const findGlIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoading);
const createWrapper = ({ $apollo, apolloSpy = defaultQuerySpy, data, stubs = {} } = {}) => {
const createWrapper = ({ $apollo, apolloSpy = getAlertsQuerySpy, data, stubs = {} } = {}) => {
let apolloOptions;
if ($apollo) {
apolloOptions = {
......@@ -80,6 +84,7 @@ describe('AlertsList component', () => {
projectPath: DEFAULT_PROJECT_PATH,
},
stubs: {
AlertDrawer: true,
AlertStatus: true,
AlertFilters: true,
GlAlert: true,
......@@ -235,7 +240,7 @@ describe('AlertsList component', () => {
describe('empty state', () => {
beforeEach(() => {
createWrapper({ apolloSpy: emptyQuerySpy });
createWrapper({ apolloSpy: emptyGetAlertsQuerySpy });
});
it('does show the empty state', () => {
......
import { mockAlerts, mockPageInfo } from './mock_data';
import { mockAlertDetails, mockAlerts, mockPageInfo } from './mock_data';
export const defaultQuerySpy = jest.fn().mockResolvedValue({
export const getAlertsQuerySpy = jest.fn().mockResolvedValue({
data: { project: { alertManagementAlerts: { nodes: mockAlerts, pageInfo: mockPageInfo } } },
});
export const emptyQuerySpy = jest.fn().mockResolvedValue({
export const emptyGetAlertsQuerySpy = jest.fn().mockResolvedValue({
data: {
project: {
alertManagementAlerts: {
......@@ -16,3 +16,14 @@ export const emptyQuerySpy = jest.fn().mockResolvedValue({
});
export const loadingQuerySpy = jest.fn().mockReturnValue(new Promise(() => {}));
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: getAlertDetailsQueryErrorMessage }],
});
......@@ -106,7 +106,7 @@ export const mockAlerts = [
},
eventCount: '1',
issueIid: null,
issue: { iid: '5', state: 'opened', title: 'Issue 01' },
issue: { iid: '5', state: 'opened', title: 'Issue 01', webUrl: 'http://test.com/05' },
title: 'Issue 01',
severity: 'HIGH',
status: 'TRIGGERED',
......@@ -117,7 +117,7 @@ export const mockAlerts = [
eventCount: '2',
assignees: { nodes: [] },
issueIid: null,
issue: { iid: '6', state: 'closed', title: 'Issue 02' },
issue: { iid: '6', state: 'closed', title: 'Issue 02', webUrl: 'http://test.com/06' },
severity: 'CRITICAL',
title: 'Issue 02',
status: 'ACKNOWLEDGED',
......@@ -153,3 +153,10 @@ export const mockPageInfo = {
hasPreviousPage: false,
startCursor: 'eyJpZCI6IjM5Iiwic3RhcnRlZF9hdCI6IjIwMjAtMTItMDQgMTg6MDE6MDcuNzY1ODgyMDAwIFVUQyJ9',
};
export const mockAlertDetails = {
iid: '01',
issue: { webUrl: '/#/-/issues/02' },
title: 'dropingress',
monitorTool: 'Cilium',
};
......@@ -9351,6 +9351,9 @@ msgstr ""
msgid "Create group label"
msgstr ""
msgid "Create incident"
msgstr ""
msgid "Create issue"
msgstr ""
......@@ -32722,6 +32725,9 @@ msgstr ""
msgid "There was an error fetching configuration for charts"
msgstr ""
msgid "There was an error fetching content, please refresh the page"
msgstr ""
msgid "There was an error fetching data for the selected stage"
msgstr ""
......@@ -33463,6 +33469,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