Commit d79cef3a authored by Winnie Hellmann's avatar Winnie Hellmann Committed by Phil Hughes

Support manually stopping any environment from the UI

parent ca1deb9e
<script> <script>
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
export default { export default {
directives: { directives: {
tooltip, tooltip,
},
components: {
loadingIcon,
Icon,
},
props: {
actions: {
type: Array,
required: false,
default: () => [],
}, },
components: { },
loadingIcon, data() {
Icon, return {
isLoading: false,
};
},
computed: {
title() {
return 'Deploy to...';
}, },
props: { },
actions: { methods: {
type: Array, onClickAction(endpoint) {
required: false, this.isLoading = true;
default: () => [],
},
},
data() {
return {
isLoading: false,
};
},
computed: {
title() {
return 'Deploy to...';
},
},
methods: {
onClickAction(endpoint) {
this.isLoading = true;
eventHub.$emit('postAction', endpoint); eventHub.$emit('postAction', { endpoint });
}, },
isActionDisabled(action) { isActionDisabled(action) {
if (action.playable === undefined) { if (action.playable === undefined) {
return false; return false;
} }
return !action.playable; return !action.playable;
},
}, },
}; },
};
</script> </script>
<template> <template>
<div <div
...@@ -61,10 +61,7 @@ ...@@ -61,10 +61,7 @@
data-toggle="dropdown" data-toggle="dropdown"
> >
<span> <span>
<icon <icon name="play" />
:size="12"
name="play"
/>
<i <i
class="fa fa-caret-down" class="fa fa-caret-down"
aria-hidden="true" aria-hidden="true"
...@@ -85,10 +82,6 @@ ...@@ -85,10 +82,6 @@
class="js-manual-action-link no-btn btn" class="js-manual-action-link no-btn btn"
@click="onClickAction(action.play_path)" @click="onClickAction(action.play_path)"
> >
<icon
:size="12"
name="play"
/>
<span> <span>
{{ action.name }} {{ action.name }}
</span> </span>
......
<script> <script>
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
import { s__ } from '../../locale'; import { s__ } from '../../locale';
/** /**
* Renders the external url link in environments table. * Renders the external url link in environments table.
*/ */
export default { export default {
components: { components: {
Icon, Icon,
},
directives: {
tooltip,
},
props: {
externalUrl: {
type: String,
required: true,
}, },
directives: { },
tooltip, computed: {
title() {
return s__('Environments|Open live environment');
}, },
props: { },
externalUrl: { };
type: String,
required: true,
},
},
computed: {
title() {
return s__('Environments|Open');
},
},
};
</script> </script>
<template> <template>
<a <a
...@@ -37,9 +37,6 @@ ...@@ -37,9 +37,6 @@
target="_blank" target="_blank"
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
> >
<icon <icon name="external-link" />
:size="12"
name="external-link"
/>
</a> </a>
</template> </template>
<script> <script>
/** /**
* Renders the Monitoring (Metrics) link in environments table. * Renders the Monitoring (Metrics) link in environments table.
*/ */
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
export default { export default {
components: { components: {
Icon, Icon,
},
directives: {
tooltip,
},
props: {
monitoringUrl: {
type: String,
required: true,
}, },
directives: { },
tooltip, computed: {
title() {
return 'Monitoring';
}, },
props: { },
monitoringUrl: { };
type: String,
required: true,
},
},
computed: {
title() {
return 'Monitoring';
},
},
};
</script> </script>
<template> <template>
<a <a
...@@ -35,9 +35,6 @@ ...@@ -35,9 +35,6 @@
data-container="body" data-container="body"
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
> >
<icon <icon name="chart" />
:size="12"
name="chart"
/>
</a> </a>
</template> </template>
<script> <script>
/** /**
* Renders Rollback or Re deploy button in environments table depending * Renders Rollback or Re deploy button in environments table depending
* of the provided property `isLastDeployment`. * of the provided property `isLastDeployment`.
* *
* Makes a post request when the button is clicked. * Makes a post request when the button is clicked.
*/ */
import eventHub from '../event_hub'; import { s__ } from '~/locale';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
export default { import eventHub from '../event_hub';
components: { import LoadingIcon from '../../vue_shared/components/loading_icon.vue';
loadingIcon,
export default {
components: {
Icon,
LoadingIcon,
},
directives: {
tooltip,
},
props: {
retryUrl: {
type: String,
default: '',
}, },
props: {
retryUrl: { isLastDeployment: {
type: String, type: Boolean,
default: '', default: true,
},
isLastDeployment: {
type: Boolean,
default: true,
},
}, },
data() { },
return { data() {
isLoading: false, return {
}; isLoading: false,
};
},
computed: {
title() {
return this.isLastDeployment ? s__('Environments|Re-deploy to environment') : s__('Environments|Rollback environment');
}, },
methods: { },
onClick() {
this.isLoading = true; methods: {
onClick() {
this.isLoading = true;
eventHub.$emit('postAction', this.retryUrl); eventHub.$emit('postAction', { endpoint: this.retryUrl });
},
}, },
}; },
};
</script> </script>
<template> <template>
<button <button
v-tooltip
:disabled="isLoading" :disabled="isLoading"
:title="title"
type="button" type="button"
class="btn d-none d-sm-none d-md-block" class="btn d-none d-sm-none d-md-block"
@click="onClick" @click="onClick"
> >
<span v-if="isLastDeployment"> <icon
{{ s__("Environments|Re-deploy") }} v-if="isLastDeployment"
</span> name="repeat" />
<span v-else> <icon
{{ s__("Environments|Rollback") }} v-else
</span> name="redo"/>
<loading-icon v-if="isLoading" /> <loading-icon v-if="isLoading" />
</button> </button>
......
<script> <script>
/** /**
* Renders the stop "button" that allows stop an environment. * Renders the stop "button" that allows stop an environment.
* Used in environments table. * Used in environments table.
*/ */
import $ from 'jquery'; import $ from 'jquery';
import eventHub from '../event_hub'; import Icon from '~/vue_shared/components/icon.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import { s__ } from '~/locale';
import tooltip from '../../vue_shared/directives/tooltip'; import eventHub from '../event_hub';
import LoadingButton from '../../vue_shared/components/loading_button.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default { export default {
components: { components: {
loadingIcon, Icon,
}, LoadingButton,
},
directives: { directives: {
tooltip, tooltip,
}, },
props: { props: {
stopUrl: { environment: {
type: String, type: Object,
default: '', required: true,
},
}, },
},
data() { data() {
return { return {
isLoading: false, isLoading: false,
}; };
}, },
computed: { computed: {
title() { title() {
return 'Stop'; return s__('Environments|Stop environment');
},
}, },
},
methods: { mounted() {
onClick() { eventHub.$on('stopEnvironment', this.onStopEnvironment);
// eslint-disable-next-line no-alert },
if (window.confirm('Are you sure you want to stop this environment?')) {
this.isLoading = true;
$(this.$el).tooltip('dispose'); beforeDestroy() {
eventHub.$off('stopEnvironment', this.onStopEnvironment);
},
eventHub.$emit('postAction', this.stopUrl); methods: {
} onClick() {
}, $(this.$el).tooltip('dispose');
eventHub.$emit('requestStopEnvironment', this.environment);
},
onStopEnvironment(environment) {
if (this.environment.id === environment.id) {
this.isLoading = true;
}
}, },
}; },
};
</script> </script>
<template> <template>
<button <loading-button
v-tooltip v-tooltip
:disabled="isLoading" :loading="isLoading"
:title="title" :title="title"
:aria-label="title" :aria-label="title"
type="button" container-class="btn btn-danger d-none d-sm-none d-md-block"
class="btn stop-env-link d-none d-sm-none d-md-block"
data-container="body" data-container="body"
data-toggle="modal"
data-target="#stop-environment-modal"
@click="onClick" @click="onClick"
> >
<i <icon name="stop"/>
class="fa fa-stop stop-env-icon" </loading-button>
aria-hidden="true"
>
</i>
<loading-icon v-if="isLoading" />
</button>
</template> </template>
<script> <script>
/** /**
* Renders a terminal button to open a web terminal. * Renders a terminal button to open a web terminal.
* Used in environments table. * Used in environments table.
*/ */
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
export default { export default {
components: { components: {
Icon, Icon,
},
directives: {
tooltip,
},
props: {
terminalPath: {
type: String,
required: false,
default: '',
}, },
directives: { },
tooltip, computed: {
title() {
return 'Terminal';
}, },
props: { },
terminalPath: { };
type: String,
required: false,
default: '',
},
},
computed: {
title() {
return 'Terminal';
},
},
};
</script> </script>
<template> <template>
<a <a
...@@ -36,9 +36,6 @@ ...@@ -36,9 +36,6 @@
class="btn terminal-button d-none d-sm-none d-md-block" class="btn terminal-button d-none d-sm-none d-md-block"
data-container="body" data-container="body"
> >
<icon <icon name="terminal" />
:size="12"
name="terminal"
/>
</a> </a>
</template> </template>
...@@ -5,10 +5,12 @@ ...@@ -5,10 +5,12 @@
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import environmentsMixin from '../mixins/environments_mixin'; import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from './stop_environment_modal.vue';
export default { export default {
components: { components: {
emptyState, emptyState,
StopEnvironmentModal,
}, },
mixins: [ mixins: [
...@@ -90,6 +92,8 @@ ...@@ -90,6 +92,8 @@
</script> </script>
<template> <template>
<div :class="cssContainerClass"> <div :class="cssContainerClass">
<stop-environment-modal :environment="environmentInStopModal" />
<div class="top-area"> <div class="top-area">
<tabs <tabs
:tabs="tabs" :tabs="tabs"
......
<script>
import GlModal from '~/vue_shared/components/gl_modal.vue';
import { s__, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '../event_hub';
export default {
id: 'stop-environment-modal',
name: 'StopEnvironmentModal',
components: {
GlModal,
LoadingButton,
},
directives: {
tooltip,
},
props: {
environment: {
type: Object,
required: true,
},
},
computed: {
noStopActionMessage() {
return sprintf(
s__(
`Environments|Note that this action will stop the environment,
but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment
due to no “stop environment action” being defined
in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file.`,
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
ciConfigLinkStart:
'<a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">',
ciConfigLinkEnd: '</a>',
},
false,
);
},
},
methods: {
onSubmit() {
eventHub.$emit('stopEnvironment', this.environment);
},
},
};
</script>
<template>
<gl-modal
:id="$options.id"
:footer-primary-button-text="s__('Environments|Stop environment')"
footer-primary-button-variant="danger"
@submit="onSubmit"
>
<template slot="header">
<h4
class="modal-title d-flex mw-100"
>
Stopping
<span
v-tooltip
:title="environment.name"
class="text-truncate ml-1 mr-1 flex-fill"
>{{ environment.name }}</span>
?
</h4>
</template>
<p>{{ s__('Environments|Are you sure you want to stop this environment?') }}</p>
<div
v-if="!environment.has_stop_action"
class="warning_message"
>
<p v-html="noStopActionMessage"></p>
<a
href="https://docs.gitlab.com/ee/ci/environments.html#stopping-an-environment"
target="_blank"
rel="noopener noreferrer"
>{{ s__('Environments|Learn more about stopping environments') }}</a>
</div>
</gl-modal>
</template>
<script> <script>
import environmentsMixin from '../mixins/environments_mixin'; import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from '../components/stop_environment_modal.vue';
export default { export default {
components: {
StopEnvironmentModal,
},
mixins: [ mixins: [
environmentsMixin, environmentsMixin,
CIPaginationMixin, CIPaginationMixin,
], ],
props: { props: {
endpoint: { endpoint: {
type: String, type: String,
...@@ -38,6 +44,8 @@ ...@@ -38,6 +44,8 @@
</script> </script>
<template> <template>
<div :class="cssContainerClass"> <div :class="cssContainerClass">
<stop-environment-modal :environment="environmentInStopModal" />
<div <div
v-if="!isLoading" v-if="!isLoading"
class="top-area" class="top-area"
......
...@@ -40,6 +40,7 @@ export default { ...@@ -40,6 +40,7 @@ export default {
scope: getParameterByName('scope') || 'available', scope: getParameterByName('scope') || 'available',
page: getParameterByName('page') || '1', page: getParameterByName('page') || '1',
requestData: {}, requestData: {},
environmentInStopModal: {},
}; };
}, },
...@@ -85,7 +86,7 @@ export default { ...@@ -85,7 +86,7 @@ export default {
Flash(s__('Environments|An error occurred while fetching the environments.')); Flash(s__('Environments|An error occurred while fetching the environments.'));
}, },
postAction(endpoint) { postAction({ endpoint, errorMessage }) {
if (!this.isMakingRequest) { if (!this.isMakingRequest) {
this.isLoading = true; this.isLoading = true;
...@@ -93,7 +94,7 @@ export default { ...@@ -93,7 +94,7 @@ export default {
.then(() => this.fetchEnvironments()) .then(() => this.fetchEnvironments())
.catch(() => { .catch(() => {
this.isLoading = false; this.isLoading = false;
Flash(s__('Environments|An error occurred while making the request.')); Flash(errorMessage || s__('Environments|An error occurred while making the request.'));
}); });
} }
}, },
...@@ -106,6 +107,15 @@ export default { ...@@ -106,6 +107,15 @@ export default {
.catch(this.errorCallback); .catch(this.errorCallback);
}, },
updateStopModal(environment) {
this.environmentInStopModal = environment;
},
stopEnvironment(environment) {
const endpoint = environment.stop_path;
const errorMessage = s__('Environments|An error occurred while stopping the environment, please try again');
this.postAction({ endpoint, errorMessage });
},
}, },
computed: { computed: {
...@@ -162,9 +172,13 @@ export default { ...@@ -162,9 +172,13 @@ export default {
}); });
eventHub.$on('postAction', this.postAction); eventHub.$on('postAction', this.postAction);
eventHub.$on('requestStopEnvironment', this.updateStopModal);
eventHub.$on('stopEnvironment', this.stopEnvironment);
}, },
beforeDestroyed() { beforeDestroy() {
eventHub.$off('postAction'); eventHub.$off('postAction', this.postAction);
eventHub.$off('requestStopEnvironment', this.updateStopModal);
eventHub.$off('stopEnvironment', this.stopEnvironment);
}, },
}; };
...@@ -13,7 +13,7 @@ export default class EnvironmentsService { ...@@ -13,7 +13,7 @@ export default class EnvironmentsService {
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
postAction(endpoint) { postAction(endpoint) {
return axios.post(endpoint, {}, { emulateJSON: true }); return axios.post(endpoint, {});
} }
getFolderContent(folderUrl) { getFolderContent(folderUrl) {
......
...@@ -23,7 +23,7 @@ ...@@ -23,7 +23,7 @@
} }
.btn-group { .btn-group {
> a { > .btn:not(.btn-danger) {
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
} }
......
...@@ -2,7 +2,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -2,7 +2,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
layout 'project' layout 'project'
before_action :authorize_read_environment! before_action :authorize_read_environment!
before_action :authorize_create_environment!, only: [:new, :create] before_action :authorize_create_environment!, only: [:new, :create]
before_action :authorize_create_deployment!, only: [:stop] before_action :authorize_stop_environment!, only: [:stop]
before_action :authorize_update_environment!, only: [:edit, :update] before_action :authorize_update_environment!, only: [:edit, :update]
before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize] before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize]
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics] before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics]
...@@ -175,4 +175,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -175,4 +175,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def environment def environment
@environment ||= project.environments.find(params[:id]) @environment ||= project.environments.find(params[:id])
end end
def authorize_stop_environment!
access_denied! unless can?(current_user, :stop_environment, environment)
end
end end
...@@ -192,7 +192,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -192,7 +192,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
deployment = environment.first_deployment_for(@merge_request.diff_head_sha) deployment = environment.first_deployment_for(@merge_request.diff_head_sha)
stop_url = stop_url =
if environment.stop_action? && can?(current_user, :create_deployment, environment) if can?(current_user, :stop_environment, environment)
stop_project_environment_path(project, environment) stop_project_environment_path(project, environment)
end end
......
class EnvironmentPolicy < BasePolicy class EnvironmentPolicy < BasePolicy
delegate { @subject.project } delegate { @subject.project }
condition(:stop_action_allowed) do condition(:stop_with_deployment_allowed) do
@subject.stop_action? && can?(:update_build, @subject.stop_action) @subject.stop_action? && can?(:create_deployment) && can?(:update_build, @subject.stop_action)
end end
rule { can?(:create_deployment) & stop_action_allowed }.enable :stop_environment condition(:stop_with_update_allowed) do
!@subject.stop_action? && can?(:update_environment, @subject)
end
rule { stop_with_deployment_allowed | stop_with_update_allowed }.enable :stop_environment
end end
...@@ -7,7 +7,7 @@ class EnvironmentEntity < Grape::Entity ...@@ -7,7 +7,7 @@ class EnvironmentEntity < Grape::Entity
expose :external_url expose :external_url
expose :environment_type expose :environment_type
expose :last_deployment, using: DeploymentEntity expose :last_deployment, using: DeploymentEntity
expose :stop_action? expose :stop_action?, as: :has_stop_action
expose :metrics_path, if: -> (environment, _) { environment.has_metrics? } do |environment| expose :metrics_path, if: -> (environment, _) { environment.has_metrics? } do |environment|
metrics_project_environment_path(environment.project, environment) metrics_project_environment_path(environment.project, environment)
...@@ -31,4 +31,14 @@ class EnvironmentEntity < Grape::Entity ...@@ -31,4 +31,14 @@ class EnvironmentEntity < Grape::Entity
end end
expose :created_at, :updated_at expose :created_at, :updated_at
expose :can_stop do |environment|
environment.available? && can?(current_user, :stop_environment, environment)
end
private
def current_user
request.current_user
end
end end
...@@ -3,13 +3,12 @@ ...@@ -3,13 +3,12 @@
- if actions.present? - if actions.present?
.btn-group .btn-group
.dropdown .dropdown
%button.dropdown.dropdown-new.btn.btn-default{ type: 'button', 'data-toggle' => 'dropdown' } %button.dropdown.dropdown-new.btn.btn-default.has-tooltip{ type: 'button', 'data-toggle' => 'dropdown', title: s_('Environments|Deploy to...') }
= custom_icon('icon_play') = sprite_icon('play')
= icon('caret-down') = icon('caret-down')
%ul.dropdown-menu.dropdown-menu-right %ul.dropdown-menu.dropdown-menu-right
- actions.each do |action| - actions.each do |action|
- next unless can?(current_user, :update_build, action) - next unless can?(current_user, :update_build, action)
%li %li
= link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow' do = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow', class: 'btn' do
= custom_icon('icon_play')
%span= action.name.humanize %span= action.name.humanize
- if can?(current_user, :create_deployment, deployment) && deployment.deployable - if can?(current_user, :create_deployment, deployment) && deployment.deployable
= link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-build' do - tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment')
= link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-build has-tooltip', title: tooltip do
- if deployment.last? - if deployment.last?
= _("Re-deploy") = sprite_icon('repeat')
- else - else
= _("Rollback") = sprite_icon('redo')
- if environment.external_url && can?(current_user, :read_environment, environment) - if environment.external_url && can?(current_user, :read_environment, environment)
= link_to environment.external_url, target: '_blank', rel: 'noopener noreferrer', class: 'btn external-url' do = link_to environment.external_url, target: '_blank', rel: 'noopener noreferrer', class: 'btn external-url has-tooltip', title: s_('Environments|Open live environment') do
= sprite_icon('external-link') = sprite_icon('external-link')
View deployment View deployment
- if can?(current_user, :create_deployment, environment) && environment.stop_action?
.inline
= link_to stop_project_environment_path(@project, environment), method: :post,
class: 'btn stop-env-link', rel: 'nofollow', data: { confirm: 'Are you sure you want to stop this environment?' } do
= icon('stop', class: 'stop-env-icon')
...@@ -4,6 +4,33 @@ ...@@ -4,6 +4,33 @@
- page_title "Environments" - page_title "Environments"
%div{ class: container_class } %div{ class: container_class }
- if can?(current_user, :stop_environment, @environment)
#stop-environment-modal.modal.fade{ tabindex: -1 }
.modal-dialog
.modal-content
.modal-header
%h4.modal-title.d-flex.mw-100
Stopping
%span.has-tooltip.text-truncate.ml-1.mr-1.flex-fill{ title: @environment.name, data: { container: '#stop-environment-modal' } }
= @environment.name
?
.modal-body
%p= s_('Environments|Are you sure you want to stop this environment?')
- unless @environment.stop_action?
.warning_message
%p= s_('Environments|Note that this action will stop the environment, but it will %{emphasis_start}not%{emphasis_end} have an effect on any existing deployment due to no “stop environment action” being defined in the %{ci_config_link_start}.gitlab-ci.yml%{ci_config_link_end} file.').html_safe % { emphasis_start: '<strong>'.html_safe,
emphasis_end: '</strong>'.html_safe,
ci_config_link_start: '<a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">'.html_safe,
ci_config_link_end: '</a>'.html_safe }
%a{ href: 'https://docs.gitlab.com/ee/ci/environments.html#stopping-an-environment',
target: '_blank',
rel: 'noopener noreferrer' }
= s_('Environments|Learn more about stopping environments')
.modal-footer
= button_tag _('Cancel'), type: 'button', class: 'btn btn-cancel', data: { dismiss: 'modal' }
= button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do
= s_('Environments|Stop environment')
.row.top-area.adjust .row.top-area.adjust
.col-md-7 .col-md-7
%h3.page-title= @environment.name %h3.page-title= @environment.name
...@@ -15,7 +42,10 @@ ...@@ -15,7 +42,10 @@
- if can?(current_user, :update_environment, @environment) - if can?(current_user, :update_environment, @environment)
= link_to 'Edit', edit_project_environment_path(@project, @environment), class: 'btn' = link_to 'Edit', edit_project_environment_path(@project, @environment), class: 'btn'
- if can?(current_user, :stop_environment, @environment) - if can?(current_user, :stop_environment, @environment)
= link_to 'Stop', stop_project_environment_path(@project, @environment), data: { confirm: 'Are you sure you want to stop this environment?' }, class: 'btn btn-danger', method: :post = button_tag class: 'btn btn-danger', type: 'button', data: { toggle: 'modal',
target: '#stop-environment-modal' } do
= sprite_icon('stop')
= s_('Environments|Stop')
.environments-container .environments-container
- if @deployments.blank? - if @deployments.blank?
......
---
title: Support manually stopping any environment from the UI
merge_request: 20077
author:
type: changed
...@@ -89,9 +89,10 @@ module API ...@@ -89,9 +89,10 @@ module API
requires :environment_id, type: Integer, desc: 'The environment ID' requires :environment_id, type: Integer, desc: 'The environment ID'
end end
post ':id/environments/:environment_id/stop' do post ':id/environments/:environment_id/stop' do
authorize! :create_deployment, user_project authorize! :read_environment, user_project
environment = user_project.environments.find(params[:environment_id]) environment = user_project.environments.find(params[:environment_id])
authorize! :stop_environment, environment
environment.stop_with_action!(current_user) environment.stop_with_action!(current_user)
......
...@@ -8,8 +8,8 @@ msgid "" ...@@ -8,8 +8,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: gitlab 1.0.0\n" "Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-07-09 08:28+0200\n" "POT-Creation-Date: 2018-07-09 19:16+0200\n"
"PO-Revision-Date: 2018-07-09 08:28+0200\n" "PO-Revision-Date: 2018-07-09 19:16+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n" "Language: \n"
...@@ -2099,9 +2099,18 @@ msgstr "" ...@@ -2099,9 +2099,18 @@ msgstr ""
msgid "Environments|An error occurred while making the request." msgid "Environments|An error occurred while making the request."
msgstr "" msgstr ""
msgid "Environments|An error occurred while stopping the environment, please try again"
msgstr ""
msgid "Environments|Are you sure you want to stop this environment?"
msgstr ""
msgid "Environments|Commit" msgid "Environments|Commit"
msgstr "" msgstr ""
msgid "Environments|Deploy to..."
msgstr ""
msgid "Environments|Deployment" msgid "Environments|Deployment"
msgstr "" msgstr ""
...@@ -2114,27 +2123,39 @@ msgstr "" ...@@ -2114,27 +2123,39 @@ msgstr ""
msgid "Environments|Job" msgid "Environments|Job"
msgstr "" msgstr ""
msgid "Environments|Learn more about stopping environments"
msgstr ""
msgid "Environments|New environment" msgid "Environments|New environment"
msgstr "" msgstr ""
msgid "Environments|No deployments yet" msgid "Environments|No deployments yet"
msgstr "" msgstr ""
msgid "Environments|Open" msgid "Environments|Note that this action will stop the environment, but it will %{emphasis_start}not%{emphasis_end} have an effect on any existing deployment due to no “stop environment action” being defined in the %{ci_config_link_start}.gitlab-ci.yml%{ci_config_link_end} file."
msgstr ""
msgid "Environments|Open live environment"
msgstr "" msgstr ""
msgid "Environments|Re-deploy" msgid "Environments|Re-deploy to environment"
msgstr "" msgstr ""
msgid "Environments|Read more about environments" msgid "Environments|Read more about environments"
msgstr "" msgstr ""
msgid "Environments|Rollback" msgid "Environments|Rollback environment"
msgstr "" msgstr ""
msgid "Environments|Show all" msgid "Environments|Show all"
msgstr "" msgstr ""
msgid "Environments|Stop"
msgstr ""
msgid "Environments|Stop environment"
msgstr ""
msgid "Environments|Updated" msgid "Environments|Updated"
msgstr "" msgstr ""
...@@ -3799,9 +3820,6 @@ msgstr "" ...@@ -3799,9 +3820,6 @@ msgstr ""
msgid "Quick actions can be used in the issues description and comment boxes." msgid "Quick actions can be used in the issues description and comment boxes."
msgstr "" msgstr ""
msgid "Re-deploy"
msgstr ""
msgid "Read more" msgid "Read more"
msgstr "" msgstr ""
...@@ -3936,9 +3954,6 @@ msgstr "" ...@@ -3936,9 +3954,6 @@ msgstr ""
msgid "Reviewing (merge request !%{mergeRequestId})" msgid "Reviewing (merge request !%{mergeRequestId})"
msgstr "" msgstr ""
msgid "Rollback"
msgstr ""
msgid "Runner token" msgid "Runner token"
msgstr "" msgstr ""
......
...@@ -166,7 +166,8 @@ describe 'Environment' do ...@@ -166,7 +166,8 @@ describe 'Environment' do
end end
it 'allows to stop environment' do it 'allows to stop environment' do
click_link('Stop') click_button('Stop')
click_button('Stop environment') # confirm modal
expect(page).to have_content('close_app') expect(page).to have_content('close_app')
end end
...@@ -174,7 +175,7 @@ describe 'Environment' do ...@@ -174,7 +175,7 @@ describe 'Environment' do
context 'when user has no ability to stop environment' do context 'when user has no ability to stop environment' do
it 'does not allow to stop environment' do it 'does not allow to stop environment' do
expect(page).to have_no_link('Stop') expect(page).not_to have_button('Stop')
end end
end end
...@@ -182,7 +183,7 @@ describe 'Environment' do ...@@ -182,7 +183,7 @@ describe 'Environment' do
let(:role) { :reporter } let(:role) { :reporter }
it 'does not show stop button' do it 'does not show stop button' do
expect(page).not_to have_link('Stop') expect(page).not_to have_button('Stop')
end end
end end
end end
...@@ -192,7 +193,7 @@ describe 'Environment' do ...@@ -192,7 +193,7 @@ describe 'Environment' do
let(:environment) { create(:environment, project: project, state: :stopped) } let(:environment) { create(:environment, project: project, state: :stopped) }
it 'does not show stop button' do it 'does not show stop button' do
expect(page).not_to have_link('Stop') expect(page).not_to have_button('Stop')
end end
end end
end end
...@@ -230,7 +231,7 @@ describe 'Environment' do ...@@ -230,7 +231,7 @@ describe 'Environment' do
it 'user visits environment page' do it 'user visits environment page' do
visit_environment(environment) visit_environment(environment)
expect(page).to have_link('Stop') expect(page).to have_button('Stop')
end end
it 'user deletes the branch with running environment' do it 'user deletes the branch with running environment' do
...@@ -242,7 +243,7 @@ describe 'Environment' do ...@@ -242,7 +243,7 @@ describe 'Environment' do
visit_environment(environment) visit_environment(environment)
expect(page).to have_no_link('Stop') expect(page).not_to have_button('Stop')
end end
## ##
......
...@@ -10,6 +10,10 @@ describe 'Environments page', :js do ...@@ -10,6 +10,10 @@ describe 'Environments page', :js do
sign_in(user) sign_in(user)
end end
def stop_button_selector
%q{button[data-original-title="Stop environment"]}
end
describe 'page tabs' do describe 'page tabs' do
it 'shows "Available" and "Stopped" tab with links' do it 'shows "Available" and "Stopped" tab with links' do
visit_environments(project) visit_environments(project)
...@@ -120,7 +124,7 @@ describe 'Environments page', :js do ...@@ -120,7 +124,7 @@ describe 'Environments page', :js do
end end
it 'does not show stip button when environment is not stoppable' do it 'does not show stip button when environment is not stoppable' do
expect(page).not_to have_selector('.stop-env-link') expect(page).not_to have_selector(stop_button_selector)
end end
end end
...@@ -178,7 +182,7 @@ describe 'Environments page', :js do ...@@ -178,7 +182,7 @@ describe 'Environments page', :js do
end end
it 'shows a stop button' do it 'shows a stop button' do
expect(page).not_to have_selector('.stop-env-link') expect(page).not_to have_selector(stop_button_selector)
end end
it 'does not show external link button' do it 'does not show external link button' do
...@@ -211,14 +215,14 @@ describe 'Environments page', :js do ...@@ -211,14 +215,14 @@ describe 'Environments page', :js do
end end
it 'shows a stop button' do it 'shows a stop button' do
expect(page).to have_selector('.stop-env-link') expect(page).to have_selector(stop_button_selector)
end end
context 'when user is a reporter' do context 'when user is a reporter' do
let(:role) { :reporter } let(:role) { :reporter }
it 'does not show stop button' do it 'does not show stop button' do
expect(page).not_to have_selector('.stop-env-link') expect(page).not_to have_selector(stop_button_selector)
end end
end end
end end
......
...@@ -18,7 +18,7 @@ describe('Rollback Component', () => { ...@@ -18,7 +18,7 @@ describe('Rollback Component', () => {
}, },
}).$mount(); }).$mount();
expect(component.$el.querySelector('span').textContent).toContain('Re-deploy'); expect(component.$el).toHaveSpriteIcon('repeat');
}); });
it('Should render Rollback label when isLastDeployment is false', () => { it('Should render Rollback label when isLastDeployment is false', () => {
...@@ -30,6 +30,6 @@ describe('Rollback Component', () => { ...@@ -30,6 +30,6 @@ describe('Rollback Component', () => {
}, },
}).$mount(); }).$mount();
expect(component.$el.querySelector('span').textContent).toContain('Rollback'); expect(component.$el).toHaveSpriteIcon('redo');
}); });
}); });
...@@ -4,7 +4,6 @@ import stopComp from '~/environments/components/environment_stop.vue'; ...@@ -4,7 +4,6 @@ import stopComp from '~/environments/components/environment_stop.vue';
describe('Stop Component', () => { describe('Stop Component', () => {
let StopComponent; let StopComponent;
let component; let component;
const stopURL = '/stop';
beforeEach(() => { beforeEach(() => {
StopComponent = Vue.extend(stopComp); StopComponent = Vue.extend(stopComp);
...@@ -12,20 +11,13 @@ describe('Stop Component', () => { ...@@ -12,20 +11,13 @@ describe('Stop Component', () => {
component = new StopComponent({ component = new StopComponent({
propsData: { propsData: {
stopUrl: stopURL, environment: {},
}, },
}).$mount(); }).$mount();
}); });
describe('computed', () => {
it('title', () => {
expect(component.title).toEqual('Stop');
});
});
it('should render a button to stop the environment', () => { it('should render a button to stop the environment', () => {
expect(component.$el.tagName).toEqual('BUTTON'); expect(component.$el.tagName).toEqual('BUTTON');
expect(component.$el.getAttribute('data-original-title')).toEqual('Stop'); expect(component.$el.getAttribute('data-original-title')).toEqual('Stop environment');
expect(component.$el.getAttribute('aria-label')).toEqual('Stop');
}); });
}); });
require 'spec_helper' require 'spec_helper'
describe EnvironmentPolicy do describe EnvironmentPolicy do
let(:user) { create(:user) } using RSpec::Parameterized::TableSyntax
let(:project) { create(:project, :repository) }
let(:environment) do let(:user) { create(:user) }
create(:environment, :with_review_app, project: project)
end
let(:policy) do let(:policy) do
described_class.new(user, environment) described_class.new(user, environment)
end end
describe '#rules' do describe '#rules' do
context 'when user does not have access to the project' do shared_examples 'project permissions' do
let(:project) { create(:project, :private, :repository) } context 'with stop action' do
let(:environment) do
create(:environment, :with_review_app, project: project)
end
it 'does not include ability to stop environment' do where(:access_level, :allowed?) do
expect(policy).to be_disallowed :stop_environment nil | false
end :guest | false
end :reporter | false
:developer | true
:master | true
end
context 'when anonymous user has access to the project' do with_them do
let(:project) { create(:project, :public, :repository) } before do
project.add_user(user, access_level) unless access_level.nil?
end
it 'does not include ability to stop environment' do it { expect(policy.allowed?(:stop_environment)).to be allowed? }
expect(policy).to be_disallowed :stop_environment end
end
end
context 'when team member has access to the project' do context 'when an admin user' do
let(:project) { create(:project, :public, :repository) } let(:user) { create(:user, :admin) }
before do it { expect(policy).to be_allowed :stop_environment }
project.add_developer(user) end
end
context 'with protected branch' do
with_them do
before do
project.add_user(user, access_level) unless access_level.nil?
create(:protected_branch, :no_one_can_push,
name: 'master', project: project)
end
context 'when team member has ability to stop environment' do it { expect(policy).to be_disallowed :stop_environment }
it 'does includes ability to stop environment' do end
expect(policy).to be_allowed :stop_environment
context 'when an admin user' do
let(:user) { create(:user, :admin) }
it { expect(policy).to be_allowed :stop_environment }
end
end end
end end
context 'when team member has no ability to stop environment' do context 'without stop action' do
before do let(:environment) do
create(:protected_branch, :no_one_can_push, create(:environment, project: project)
name: 'master', project: project) end
where(:access_level, :allowed?) do
nil | false
:guest | false
:reporter | false
:developer | false
:master | true
end end
it 'does not include ability to stop environment' do with_them do
expect(policy).to be_disallowed :stop_environment before do
project.add_user(user, access_level) unless access_level.nil?
end
it { expect(policy.allowed?(:stop_environment)).to be allowed? }
end
context 'when an admin user' do
let(:user) { create(:user, :admin) }
it { expect(policy).to be_allowed :stop_environment }
end end
end end
end end
context 'when project is public' do
let(:project) { create(:project, :public, :repository) }
include_examples 'project permissions'
end
context 'when project is private' do
let(:project) { create(:project, :private, :repository) }
include_examples 'project permissions'
end
end end
end end
require 'spec_helper' require 'spec_helper'
describe EnvironmentEntity do describe EnvironmentEntity do
let(:request) { double('request') }
let(:entity) do let(:entity) do
described_class.new(environment, request: double) described_class.new(environment, request: spy('request'))
end end
let(:environment) { create(:environment) } let(:environment) { create(:environment) }
......
...@@ -54,7 +54,9 @@ describe EnvironmentSerializer do ...@@ -54,7 +54,9 @@ describe EnvironmentSerializer do
context 'when representing environments within folders' do context 'when representing environments within folders' do
let(:serializer) do let(:serializer) do
described_class.new(project: project).within_folders described_class
.new(current_user: user, project: project)
.within_folders
end end
let(:resource) { Environment.all } let(:resource) { Environment.all }
...@@ -123,7 +125,8 @@ describe EnvironmentSerializer do ...@@ -123,7 +125,8 @@ describe EnvironmentSerializer do
let(:pagination) { { page: 1, per_page: 2 } } let(:pagination) { { page: 1, per_page: 2 } }
let(:serializer) do let(:serializer) do
described_class.new(project: project) described_class
.new(current_user: user, project: project)
.with_pagination(request, response) .with_pagination(request, response)
end end
...@@ -169,7 +172,8 @@ describe EnvironmentSerializer do ...@@ -169,7 +172,8 @@ describe EnvironmentSerializer do
context 'when grouping environments within folders' do context 'when grouping environments within folders' do
let(:serializer) do let(:serializer) do
described_class.new(project: project) described_class
.new(current_user: user, project: project)
.with_pagination(request, response) .with_pagination(request, response)
.within_folders .within_folders
end end
......
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