Commit 838a74c3 authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Olena Horal-Koretska

Add ability to cancel on-demand scans

parent 01dd30cc
...@@ -970,6 +970,11 @@ To view running completed and scheduled on-demand DAST scans for a project, go t ...@@ -970,6 +970,11 @@ To view running completed and scheduled on-demand DAST scans for a project, go t
- To view scheduled scans, select **Scheduled**. It shows on-demand scans that have a schedule - To view scheduled scans, select **Scheduled**. It shows on-demand scans that have a schedule
set up. Those are _not_ included in the **All** tab. set up. Those are _not_ included in the **All** tab.
#### Cancel an on-demand scan
To cancel a pending or running on-demand scan, select **Cancel** (**{cancel}**) in the
on-demand scans list.
### Run an on-demand DAST scan ### Run an on-demand DAST scan
Prerequisites: Prerequisites:
......
<script>
import { GlButton, GlTooltip } from '@gitlab/ui';
import pipelineCancelMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
import { __, s__ } from '~/locale';
import { PIPELINES_GROUP_RUNNING, PIPELINES_GROUP_PENDING } from '../constants';
const CANCELLING_PROPERTY = 'isCancelling';
export const cancelError = s__('OnDemandScans|The scan could not be canceled.');
export default {
components: {
GlButton,
GlTooltip,
},
props: {
scan: {
type: Object,
required: true,
},
},
data() {
return {
[CANCELLING_PROPERTY]: false,
};
},
computed: {
isCancellable() {
return [PIPELINES_GROUP_RUNNING, PIPELINES_GROUP_PENDING].includes(
this.scan?.detailedStatus?.group,
);
},
},
methods: {
cancelPipeline() {
this.$emit('action');
this[CANCELLING_PROPERTY] = true;
this.$apollo
.mutate({
mutation: pipelineCancelMutation,
variables: {
id: this.scan.id,
},
update: (_store, { data = {} }) => {
const [errorMessage] = data.pipelineCancel?.errors ?? [];
if (errorMessage) {
this.triggerError(CANCELLING_PROPERTY, errorMessage);
}
},
})
.catch((exception) => {
this.triggerError(CANCELLING_PROPERTY, this.$options.i18n.cancelError, exception);
});
},
triggerError(loadingProperty, message, exception) {
this[loadingProperty] = false;
this.$emit('error', message, exception);
},
},
i18n: {
cancel: __('Cancel'),
cancelError,
},
};
</script>
<template>
<div class="gl-text-right">
<template v-if="isCancellable">
<gl-button
:id="`cancel-button-${scan.id}`"
:aria-label="$options.i18n.cancel"
:loading="isCancelling"
icon="cancel"
data-testid="cancel-scan-button"
@click="cancelPipeline"
/>
<gl-tooltip
:target="`cancel-button-${scan.id}`"
placement="top"
triggers="hover"
noninteractive
>
{{ $options.i18n.cancel }}
</gl-tooltip>
</template>
</div>
</template>
<script> <script>
import * as Sentry from '@sentry/browser';
import { import {
GlTab, GlTab,
GlBadge, GlBadge,
...@@ -15,6 +16,7 @@ import { DAST_SHORT_NAME } from '~/security_configuration/components/constants'; ...@@ -15,6 +16,7 @@ import { DAST_SHORT_NAME } from '~/security_configuration/components/constants';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { scrollToElement } from '~/lib/utils/common_utils'; import { scrollToElement } from '~/lib/utils/common_utils';
import Actions from '../actions.vue';
import EmptyState from '../empty_state.vue'; import EmptyState from '../empty_state.vue';
import { PIPELINES_PER_PAGE, PIPELINES_POLL_INTERVAL } from '../../constants'; import { PIPELINES_PER_PAGE, PIPELINES_POLL_INTERVAL } from '../../constants';
...@@ -40,6 +42,7 @@ export default { ...@@ -40,6 +42,7 @@ export default {
GlTruncate, GlTruncate,
CiBadgeLink, CiBadgeLink,
TimeAgoTooltip, TimeAgoTooltip,
Actions,
EmptyState, EmptyState,
}, },
inject: ['projectPath'], inject: ['projectPath'],
...@@ -124,6 +127,7 @@ export default { ...@@ -124,6 +127,7 @@ export default {
return { return {
cursor, cursor,
hasError: false, hasError: false,
actionErrorMessage: '',
}; };
}, },
computed: { computed: {
...@@ -137,7 +141,14 @@ export default { ...@@ -137,7 +141,14 @@ export default {
return this.pipelines?.pageInfo; return this.pipelines?.pageInfo;
}, },
tableFields() { tableFields() {
return this.fields.map((field) => ({ return [
...this.fields,
{
label: '',
key: 'actions',
columnClass: 'gl-w-13',
},
].map((field) => ({
...field, ...field,
class: ['gl-text-primary'], class: ['gl-text-primary'],
thClass: ['gl-bg-transparent!', 'gl-white-space-nowrap'], thClass: ['gl-bg-transparent!', 'gl-white-space-nowrap'],
...@@ -148,6 +159,7 @@ export default { ...@@ -148,6 +159,7 @@ export default {
isActive(isActive) { isActive(isActive) {
if (isActive) { if (isActive) {
this.resetCursor(); this.resetCursor();
this.resetActionError();
} }
}, },
hasPipelines(hasPipelines) { hasPipelines(hasPipelines) {
...@@ -177,11 +189,25 @@ export default { ...@@ -177,11 +189,25 @@ export default {
this.updateRoute({ before }); this.updateRoute({ before });
}, },
updateRoute(query = {}) { updateRoute(query = {}) {
scrollToElement(this.$el); this.scrollToTop();
this.$router.push({ this.$router.push({
path: this.$route.path, path: this.$route.path,
query, query,
}); });
this.resetActionError();
},
handleActionError(message, exception = null) {
this.actionErrorMessage = message;
this.scrollToTop();
if (exception !== null) {
Sentry.captureException(exception);
}
},
resetActionError() {
this.actionErrorMessage = '';
},
scrollToTop() {
scrollToElement(this.$el);
}, },
}, },
i18n: { i18n: {
...@@ -224,6 +250,14 @@ export default { ...@@ -224,6 +250,14 @@ export default {
</gl-skeleton-loader> </gl-skeleton-loader>
</template> </template>
<template v-if="actionErrorMessage" #top-row>
<td :colspan="tableFields.length">
<gl-alert class="gl-my-4" variant="danger" :dismissible="false">
{{ actionErrorMessage }}
</gl-alert>
</td>
</template>
<template #cell(status)="{ value }"> <template #cell(status)="{ value }">
<div class="gl-my-3"> <div class="gl-my-3">
<ci-badge-link :status="value" /> <ci-badge-link :status="value" />
...@@ -254,6 +288,10 @@ export default { ...@@ -254,6 +288,10 @@ export default {
<gl-link :href="item.path">#{{ $options.getIdFromGraphQLId(item.id) }}</gl-link> <gl-link :href="item.path">#{{ $options.getIdFromGraphQLId(item.id) }}</gl-link>
</template> </template>
<template #cell(actions)="{ item }">
<actions :scan="item" @action="resetActionError" @error="handleActionError" />
</template>
<template v-for="slot in Object.keys($scopedSlots)" #[slot]="scope"> <template v-for="slot in Object.keys($scopedSlots)" #[slot]="scope">
<slot :name="slot" v-bind="scope"></slot> <slot :name="slot" v-bind="scope"></slot>
</template> </template>
......
...@@ -13,9 +13,15 @@ export const PIPELINE_TABS_KEYS = ['all', 'running', 'finished', 'scheduled']; ...@@ -13,9 +13,15 @@ export const PIPELINE_TABS_KEYS = ['all', 'running', 'finished', 'scheduled'];
export const PIPELINES_PER_PAGE = 20; export const PIPELINES_PER_PAGE = 20;
export const PIPELINES_POLL_INTERVAL = 1000; export const PIPELINES_POLL_INTERVAL = 1000;
export const PIPELINES_COUNT_POLL_INTERVAL = 1000; export const PIPELINES_COUNT_POLL_INTERVAL = 1000;
// Pipeline scopes
export const PIPELINES_SCOPE_RUNNING = 'RUNNING'; export const PIPELINES_SCOPE_RUNNING = 'RUNNING';
export const PIPELINES_SCOPE_FINISHED = 'FINISHED'; export const PIPELINES_SCOPE_FINISHED = 'FINISHED';
// Pipeline statuses
export const PIPELINES_GROUP_RUNNING = 'running';
export const PIPELINES_GROUP_PENDING = 'pending';
const STATUS_COLUMN = { const STATUS_COLUMN = {
label: __('Status'), label: __('Status'),
key: 'status', key: 'status',
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import Actions, { cancelError } from 'ee/on_demand_scans/components/actions.vue';
import { PIPELINES_GROUP_RUNNING, PIPELINES_GROUP_PENDING } from 'ee/on_demand_scans/constants';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import pipelineCancelMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
Vue.use(VueApollo);
// Dummy scans
const mockPipelineId = 'gid://gitlab/Ci::Pipeline/1';
const runningScan = {
id: mockPipelineId,
detailedStatus: {
group: PIPELINES_GROUP_RUNNING,
},
};
const pendingScan = {
id: mockPipelineId,
detailedStatus: {
group: PIPELINES_GROUP_PENDING,
},
};
describe('Actions', () => {
let wrapper;
let requestHandler;
let apolloProvider;
// Finders
const findCancelScanButton = () => wrapper.findByTestId('cancel-scan-button');
// Helpers
const createMockApolloProvider = (mutation, handler) => {
requestHandler = handler;
apolloProvider = createMockApollo([[mutation, handler]]);
};
const createComponent = (scan) => {
wrapper = shallowMountExtended(Actions, {
apolloProvider,
propsData: {
scan,
},
});
};
afterEach(() => {
wrapper.destroy();
requestHandler = null;
apolloProvider = null;
});
it("doesn't render anything if the scan status is not supported", () => {
createComponent({
id: mockPipelineId,
detailedStatus: {
group: 'foo',
},
});
expect(wrapper.element.childNodes).toHaveLength(1);
expect(wrapper.element.childNodes[0].tagName).toBeUndefined();
});
describe.each`
scanStatus | scan
${'running'} | ${runningScan}
${'pending'} | ${pendingScan}
`('$scanStatus scan', ({ scan }) => {
it('renders a cancel button', () => {
createComponent(scan);
expect(findCancelScanButton().exists()).toBe(true);
});
describe('when clicking on the cancel button', () => {
let cancelButton;
beforeEach(() => {
createMockApolloProvider(
pipelineCancelMutation,
jest.fn().mockResolvedValue({ data: { pipelineCancel: { errors: [] } } }),
);
createComponent(scan);
cancelButton = findCancelScanButton();
cancelButton.vm.$emit('click');
});
afterEach(() => {
cancelButton = null;
});
it('trigger the pipelineCancel mutation on click', () => {
expect(requestHandler).toHaveBeenCalled();
});
it('emits the action event and puts the button in the loading state on click', async () => {
expect(wrapper.emitted('action')).toHaveLength(1);
expect(cancelButton.props('loading')).toBe(true);
});
});
const errorAsDataMessage = 'Error as data';
describe.each`
errorType | eventPayload | handler
${'top-level error'} | ${[cancelError, expect.any(Error)]} | ${jest.fn().mockRejectedValue()}
${'error as data'} | ${[errorAsDataMessage, undefined]} | ${jest.fn().mockResolvedValue({ data: { pipelineCancel: { errors: [errorAsDataMessage] } } })}
`('on $errorType', ({ eventPayload, handler }) => {
let cancelButton;
beforeEach(() => {
createMockApolloProvider(pipelineCancelMutation, handler);
createComponent(scan);
cancelButton = findCancelScanButton();
cancelButton.vm.$emit('click');
return waitForPromises();
});
afterEach(() => {
cancelButton = null;
});
it('removes the loading state once the mutation errors out', async () => {
expect(cancelButton.props('loading')).toBe(false);
});
it('emits the error', async () => {
expect(wrapper.emitted('error')).toEqual([eventPayload]);
});
});
});
});
import { nextTick } from 'vue';
import { GlTab, GlTable, GlAlert } from '@gitlab/ui'; import { GlTab, GlTable, GlAlert } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils'; import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
...@@ -7,6 +8,7 @@ import allPipelinesWithoutPipelinesMock from 'test_fixtures/graphql/on_demand_sc ...@@ -7,6 +8,7 @@ import allPipelinesWithoutPipelinesMock from 'test_fixtures/graphql/on_demand_sc
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BaseTab from 'ee/on_demand_scans/components/tabs/base_tab.vue'; import BaseTab from 'ee/on_demand_scans/components/tabs/base_tab.vue';
import Actions from 'ee/on_demand_scans/components/actions.vue';
import EmptyState from 'ee/on_demand_scans/components/empty_state.vue'; import EmptyState from 'ee/on_demand_scans/components/empty_state.vue';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import onDemandScansQuery from 'ee/on_demand_scans/graphql/on_demand_scans.query.graphql'; import onDemandScansQuery from 'ee/on_demand_scans/graphql/on_demand_scans.query.graphql';
...@@ -35,6 +37,7 @@ describe('BaseTab', () => { ...@@ -35,6 +37,7 @@ describe('BaseTab', () => {
// Finders // Finders
const findTitle = () => wrapper.findByTestId('tab-title'); const findTitle = () => wrapper.findByTestId('tab-title');
const findTable = () => wrapper.findComponent(GlTable); const findTable = () => wrapper.findComponent(GlTable);
const findActions = () => wrapper.findComponent(Actions);
const findEmptyState = () => wrapper.findComponent(EmptyState); const findEmptyState = () => wrapper.findComponent(EmptyState);
const findPagination = () => wrapper.findByTestId('pagination'); const findPagination = () => wrapper.findByTestId('pagination');
const findErrorAlert = () => wrapper.findComponent(GlAlert); const findErrorAlert = () => wrapper.findComponent(GlAlert);
...@@ -48,18 +51,23 @@ describe('BaseTab', () => { ...@@ -48,18 +51,23 @@ describe('BaseTab', () => {
const navigateToPage = (direction, cursor = '') => { const navigateToPage = (direction, cursor = '') => {
findPagination().vm.$emit(direction, cursor); findPagination().vm.$emit(direction, cursor);
return wrapper.vm.$nextTick(); return nextTick();
}; };
const setActiveState = (isActive) => { const setActiveState = (isActive) => {
wrapper.setProps({ isActive }); wrapper.setProps({ isActive });
return wrapper.vm.$nextTick(); return nextTick();
}; };
const advanceToNextFetch = () => { const advanceToNextFetch = () => {
jest.advanceTimersByTime(PIPELINES_POLL_INTERVAL); jest.advanceTimersByTime(PIPELINES_POLL_INTERVAL);
}; };
const triggerActionError = (errorMessage) => {
findActions().vm.$emit('error', errorMessage);
return nextTick();
};
const createComponentFactory = (mountFn = shallowMountExtended) => (options = {}) => { const createComponentFactory = (mountFn = shallowMountExtended) => (options = {}) => {
router = createRouter(); router = createRouter();
wrapper = mountFn( wrapper = mountFn(
...@@ -131,7 +139,7 @@ describe('BaseTab', () => { ...@@ -131,7 +139,7 @@ describe('BaseTab', () => {
expect(requestHandler).toHaveBeenCalledTimes(1); expect(requestHandler).toHaveBeenCalledTimes(1);
await wrapper.vm.$nextTick(); await nextTick();
advanceToNextFetch(); advanceToNextFetch();
expect(requestHandler).toHaveBeenCalledTimes(2); expect(requestHandler).toHaveBeenCalledTimes(2);
...@@ -361,4 +369,60 @@ describe('BaseTab', () => { ...@@ -361,4 +369,60 @@ describe('BaseTab', () => {
expect(findErrorAlert().exists()).toBe(false); expect(findErrorAlert().exists()).toBe(false);
}); });
}); });
describe('actions', () => {
const errorMessage = 'An error occurred.';
beforeEach(() => {
createFullComponent({
stubs: {
GlTable: false,
},
});
return waitForPromises();
});
it('renders action cell', () => {
expect(findActions().exists()).toBe(true);
});
it('shows action error message and scrolls back to the top on error', async () => {
await triggerActionError(errorMessage);
expect(wrapper.text()).toContain(errorMessage);
expect(scrollToElement).toHaveBeenCalledWith(wrapper.vm.$el);
});
it('resets action error message on action', async () => {
await triggerActionError(errorMessage);
expect(wrapper.text()).toContain(errorMessage);
findActions().vm.$emit('action');
await nextTick();
expect(wrapper.text()).not.toContain(errorMessage);
});
it('reset action error message when tab becomes active', async () => {
await triggerActionError(errorMessage);
expect(wrapper.text()).toContain(errorMessage);
await setActiveState(false);
await setActiveState(true);
expect(wrapper.text()).not.toContain(errorMessage);
});
it('reset action error message on navigation', async () => {
await triggerActionError(errorMessage);
expect(wrapper.text()).toContain(errorMessage);
await navigateToPage('next');
expect(wrapper.text()).not.toContain(errorMessage);
});
});
}); });
...@@ -24448,6 +24448,9 @@ msgstr "" ...@@ -24448,6 +24448,9 @@ msgstr ""
msgid "OnDemandScans|Target" msgid "OnDemandScans|Target"
msgstr "" msgstr ""
msgid "OnDemandScans|The scan could not be canceled."
msgstr ""
msgid "OnDemandScans|There are no finished scans." msgid "OnDemandScans|There are no finished scans."
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