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>
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
export default {
directives: {
tooltip,
},
......@@ -33,7 +33,7 @@
onClickAction(endpoint) {
this.isLoading = true;
eventHub.$emit('postAction', endpoint);
eventHub.$emit('postAction', { endpoint });
},
isActionDisabled(action) {
......@@ -44,7 +44,7 @@
return !action.playable;
},
},
};
};
</script>
<template>
<div
......@@ -61,10 +61,7 @@
data-toggle="dropdown"
>
<span>
<icon
:size="12"
name="play"
/>
<icon name="play" />
<i
class="fa fa-caret-down"
aria-hidden="true"
......@@ -85,10 +82,6 @@
class="js-manual-action-link no-btn btn"
@click="onClickAction(action.play_path)"
>
<icon
:size="12"
name="play"
/>
<span>
{{ action.name }}
</span>
......
<script>
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import { s__ } from '../../locale';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import { s__ } from '../../locale';
/**
/**
* Renders the external url link in environments table.
*/
export default {
export default {
components: {
Icon,
},
......@@ -21,10 +21,10 @@
},
computed: {
title() {
return s__('Environments|Open');
return s__('Environments|Open live environment');
},
},
};
};
</script>
<template>
<a
......@@ -37,9 +37,6 @@
target="_blank"
rel="noopener noreferrer nofollow"
>
<icon
:size="12"
name="external-link"
/>
<icon name="external-link" />
</a>
</template>
<script>
import Timeago from 'timeago.js';
import _ from 'underscore';
import tooltip from '~/vue_shared/directives/tooltip';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { humanize } from '~/lib/utils/text_utility';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue';
import RollbackComponent from './environment_rollback.vue';
import TerminalButtonComponent from './environment_terminal_button.vue';
import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub';
/**
import Timeago from 'timeago.js';
import _ from 'underscore';
import tooltip from '~/vue_shared/directives/tooltip';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { humanize } from '~/lib/utils/text_utility';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue';
import RollbackComponent from './environment_rollback.vue';
import TerminalButtonComponent from './environment_terminal_button.vue';
import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub';
/**
* Envrionment Item Component
*
* Renders a table row for each environment.
*/
const timeagoInstance = new Timeago();
const timeagoInstance = new Timeago();
export default {
export default {
components: {
UserAvatarLink,
CommitComponent,
......@@ -65,9 +65,7 @@
* @returns {Boolean}
*/
hasLastDeploymentKey() {
if (this.model &&
this.model.last_deployment &&
!_.isEmpty(this.model.last_deployment)) {
if (this.model && this.model.last_deployment && !_.isEmpty(this.model.last_deployment)) {
return true;
}
return false;
......@@ -80,19 +78,21 @@
* @returns {Boolean|Undefined}
*/
hasManualActions() {
return this.model &&
return (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.manual_actions &&
this.model.last_deployment.manual_actions.length > 0;
this.model.last_deployment.manual_actions.length > 0
);
},
/**
* Returns the value of the `stop_action?` key provided in the response.
* Returns whether the environment can be stopped.
*
* @returns {Boolean}
*/
hasStopAction() {
return this.model && this.model['stop_action?'];
canStopEnvironment() {
return this.model && this.model.can_stop;
},
/**
......@@ -102,10 +102,12 @@
* @returns {Boolean|Undefined}
*/
canRetry() {
return this.model &&
return (
this.model &&
this.hasLastDeploymentKey &&
this.model.last_deployment &&
this.model.last_deployment.deployable;
this.model.last_deployment.deployable
);
},
/**
......@@ -114,10 +116,12 @@
* @returns {Boolean|Undefined}
*/
canShowDate() {
return this.model &&
return (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable &&
this.model.last_deployment.deployable !== undefined;
this.model.last_deployment.deployable !== undefined
);
},
/**
......@@ -126,10 +130,12 @@
* @returns {String}
*/
createdDate() {
if (this.model &&
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable &&
this.model.last_deployment.deployable.created_at) {
this.model.last_deployment.deployable.created_at
) {
return timeagoInstance.format(this.model.last_deployment.deployable.created_at);
}
return '';
......@@ -142,7 +148,7 @@
*/
manualActions() {
if (this.hasManualActions) {
return this.model.last_deployment.manual_actions.map((action) => {
return this.model.last_deployment.manual_actions.map(action => {
const parsedAction = {
name: humanize(action.name),
play_path: action.play_path,
......@@ -160,10 +166,12 @@
* @returns {String}
*/
userImageAltDescription() {
if (this.model &&
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.user &&
this.model.last_deployment.user.username) {
this.model.last_deployment.user.username
) {
return `${this.model.last_deployment.user.username}'s avatar'`;
}
return '';
......@@ -175,9 +183,7 @@
* @returns {String|Undefined}
*/
commitTag() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.tag) {
if (this.model && this.model.last_deployment && this.model.last_deployment.tag) {
return this.model.last_deployment.tag;
}
return undefined;
......@@ -189,9 +195,7 @@
* @returns {Object|Undefined}
*/
commitRef() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.ref) {
if (this.model && this.model.last_deployment && this.model.last_deployment.ref) {
return this.model.last_deployment.ref;
}
return undefined;
......@@ -203,10 +207,12 @@
* @returns {String|Undefined}
*/
commitUrl() {
if (this.model &&
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.commit &&
this.model.last_deployment.commit.commit_path) {
this.model.last_deployment.commit.commit_path
) {
return this.model.last_deployment.commit.commit_path;
}
return undefined;
......@@ -218,10 +224,12 @@
* @returns {String|Undefined}
*/
commitShortSha() {
if (this.model &&
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.commit &&
this.model.last_deployment.commit.short_id) {
this.model.last_deployment.commit.short_id
) {
return this.model.last_deployment.commit.short_id;
}
return undefined;
......@@ -233,10 +241,12 @@
* @returns {String|Undefined}
*/
commitTitle() {
if (this.model &&
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.commit &&
this.model.last_deployment.commit.title) {
this.model.last_deployment.commit.title
) {
return this.model.last_deployment.commit.title;
}
return undefined;
......@@ -248,10 +258,12 @@
* @returns {Object|Undefined}
*/
commitAuthor() {
if (this.model &&
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.commit &&
this.model.last_deployment.commit.author) {
this.model.last_deployment.commit.author
) {
return this.model.last_deployment.commit.author;
}
......@@ -264,10 +276,12 @@
* @returns {String|Undefined}
*/
retryUrl() {
if (this.model &&
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable &&
this.model.last_deployment.deployable.retry_path) {
this.model.last_deployment.deployable.retry_path
) {
return this.model.last_deployment.deployable.retry_path;
}
return undefined;
......@@ -279,8 +293,7 @@
* @returns {Boolean|Undefined}
*/
isLastDeployment() {
return this.model && this.model.last_deployment &&
this.model.last_deployment['last?'];
return this.model && this.model.last_deployment && this.model.last_deployment['last?'];
},
/**
......@@ -289,9 +302,7 @@
* @returns {String}
*/
buildName() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable) {
if (this.model && this.model.last_deployment && this.model.last_deployment.deployable) {
const { deployable } = this.model.last_deployment;
return `${deployable.name} #${deployable.id}`;
}
......@@ -304,9 +315,7 @@
* @returns {String}
*/
deploymentInternalId() {
if (this.model &&
this.model.last_deployment &&
this.model.last_deployment.iid) {
if (this.model && this.model.last_deployment && this.model.last_deployment.iid) {
return `#${this.model.last_deployment.iid}`;
}
return '';
......@@ -318,9 +327,11 @@
* @returns {Boolean}
*/
deploymentHasUser() {
return this.model &&
return (
this.model &&
!_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.user);
!_.isEmpty(this.model.last_deployment.user)
);
},
/**
......@@ -330,9 +341,11 @@
* @returns {Object}
*/
deploymentUser() {
if (this.model &&
if (
this.model &&
!_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.user)) {
!_.isEmpty(this.model.last_deployment.user)
) {
return this.model.last_deployment.user;
}
return {};
......@@ -346,9 +359,11 @@
* @returns {Boolean}
*/
shouldRenderBuildName() {
return !this.model.isFolder &&
return (
!this.model.isFolder &&
!_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.deployable);
!_.isEmpty(this.model.last_deployment.deployable)
);
},
/**
......@@ -357,10 +372,12 @@
* @return {String}
*/
buildPath() {
if (this.model &&
if (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.deployable &&
this.model.last_deployment.deployable.build_path) {
this.model.last_deployment.deployable.build_path
) {
return this.model.last_deployment.deployable.build_path;
}
......@@ -388,9 +405,11 @@
* @returns {Boolean}
*/
shouldRenderDeploymentID() {
return !this.model.isFolder &&
return (
!this.model.isFolder &&
!_.isEmpty(this.model.last_deployment) &&
this.model.last_deployment.iid !== undefined;
this.model.last_deployment.iid !== undefined
);
},
environmentPath() {
......@@ -410,11 +429,13 @@
},
displayEnvironmentActions() {
return this.hasManualActions ||
return (
this.hasManualActions ||
this.externalURL ||
this.monitoringUrl ||
this.hasStopAction ||
this.canRetry;
this.canStopEnvironment ||
this.canRetry
);
},
},
......@@ -423,7 +444,7 @@
eventHub.$emit('toggleFolder', this.model);
},
},
};
};
</script>
<template>
<div
......@@ -580,11 +601,6 @@
class="btn-group table-action-buttons"
role="group">
<actions-component
v-if="hasManualActions && canCreateDeployment"
:actions="manualActions"
/>
<external-url-component
v-if="externalURL && canReadEnvironment"
:external-url="externalURL"
......@@ -595,21 +611,26 @@
:monitoring-url="monitoringUrl"
/>
<actions-component
v-if="hasManualActions && canCreateDeployment"
:actions="manualActions"
/>
<terminal-button-component
v-if="model && model.terminal_path"
:terminal-path="model.terminal_path"
/>
<stop-component
v-if="hasStopAction && canCreateDeployment"
:stop-url="model.stop_path"
/>
<rollback-component
v-if="canRetry && canCreateDeployment"
:is-last-deployment="isLastDeployment"
:retry-url="retryUrl"
/>
<stop-component
v-if="canStopEnvironment"
:environment="model"
/>
</div>
</div>
</div>
......
<script>
/**
/**
* Renders the Monitoring (Metrics) link in environments table.
*/
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
export default {
components: {
Icon,
},
......@@ -23,7 +23,7 @@
return 'Monitoring';
},
},
};
};
</script>
<template>
<a
......@@ -35,9 +35,6 @@
data-container="body"
rel="noopener noreferrer nofollow"
>
<icon
:size="12"
name="chart"
/>
<icon name="chart" />
</a>
</template>
<script>
/**
/**
* Renders Rollback or Re deploy button in environments table depending
* of the provided property `isLastDeployment`.
*
* Makes a post request when the button is clicked.
*/
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '../event_hub';
import LoadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
export default {
components: {
loadingIcon,
Icon,
LoadingIcon,
},
directives: {
tooltip,
},
props: {
retryUrl: {
type: String,
......@@ -28,29 +37,38 @@
isLoading: false,
};
},
computed: {
title() {
return this.isLastDeployment ? s__('Environments|Re-deploy to environment') : s__('Environments|Rollback environment');
},
},
methods: {
onClick() {
this.isLoading = true;
eventHub.$emit('postAction', this.retryUrl);
eventHub.$emit('postAction', { endpoint: this.retryUrl });
},
},
};
};
</script>
<template>
<button
v-tooltip
:disabled="isLoading"
:title="title"
type="button"
class="btn d-none d-sm-none d-md-block"
@click="onClick"
>
<span v-if="isLastDeployment">
{{ s__("Environments|Re-deploy") }}
</span>
<span v-else>
{{ s__("Environments|Rollback") }}
</span>
<icon
v-if="isLastDeployment"
name="repeat" />
<icon
v-else
name="redo"/>
<loading-icon v-if="isLoading" />
</button>
......
<script>
/**
/**
* Renders the stop "button" that allows stop an environment.
* Used in environments table.
*/
import $ from 'jquery';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import $ from 'jquery';
import Icon from '~/vue_shared/components/icon.vue';
import { s__ } from '~/locale';
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: {
loadingIcon,
Icon,
LoadingButton,
},
directives: {
......@@ -19,9 +22,9 @@
},
props: {
stopUrl: {
type: String,
default: '',
environment: {
type: Object,
required: true,
},
},
......@@ -33,40 +36,43 @@
computed: {
title() {
return 'Stop';
return s__('Environments|Stop environment');
},
},
mounted() {
eventHub.$on('stopEnvironment', this.onStopEnvironment);
},
beforeDestroy() {
eventHub.$off('stopEnvironment', this.onStopEnvironment);
},
methods: {
onClick() {
// 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');
eventHub.$emit('postAction', this.stopUrl);
eventHub.$emit('requestStopEnvironment', this.environment);
},
onStopEnvironment(environment) {
if (this.environment.id === environment.id) {
this.isLoading = true;
}
},
},
};
};
</script>
<template>
<button
<loading-button
v-tooltip
:disabled="isLoading"
:loading="isLoading"
:title="title"
:aria-label="title"
type="button"
class="btn stop-env-link d-none d-sm-none d-md-block"
container-class="btn btn-danger d-none d-sm-none d-md-block"
data-container="body"
data-toggle="modal"
data-target="#stop-environment-modal"
@click="onClick"
>
<i
class="fa fa-stop stop-env-icon"
aria-hidden="true"
>
</i>
<loading-icon v-if="isLoading" />
</button>
<icon name="stop"/>
</loading-button>
</template>
<script>
/**
/**
* Renders a terminal button to open a web terminal.
* Used in environments table.
*/
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
export default {
components: {
Icon,
},
......@@ -25,7 +25,7 @@
return 'Terminal';
},
},
};
};
</script>
<template>
<a
......@@ -36,9 +36,6 @@
class="btn terminal-button d-none d-sm-none d-md-block"
data-container="body"
>
<icon
:size="12"
name="terminal"
/>
<icon name="terminal" />
</a>
</template>
......@@ -5,10 +5,12 @@
import eventHub from '../event_hub';
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from './stop_environment_modal.vue';
export default {
components: {
emptyState,
StopEnvironmentModal,
},
mixins: [
......@@ -90,6 +92,8 @@
</script>
<template>
<div :class="cssContainerClass">
<stop-environment-modal :environment="environmentInStopModal" />
<div class="top-area">
<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>
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from '../components/stop_environment_modal.vue';
export default {
components: {
StopEnvironmentModal,
},
mixins: [
environmentsMixin,
CIPaginationMixin,
],
props: {
endpoint: {
type: String,
......@@ -38,6 +44,8 @@
</script>
<template>
<div :class="cssContainerClass">
<stop-environment-modal :environment="environmentInStopModal" />
<div
v-if="!isLoading"
class="top-area"
......
......@@ -40,6 +40,7 @@ export default {
scope: getParameterByName('scope') || 'available',
page: getParameterByName('page') || '1',
requestData: {},
environmentInStopModal: {},
};
},
......@@ -85,7 +86,7 @@ export default {
Flash(s__('Environments|An error occurred while fetching the environments.'));
},
postAction(endpoint) {
postAction({ endpoint, errorMessage }) {
if (!this.isMakingRequest) {
this.isLoading = true;
......@@ -93,7 +94,7 @@ export default {
.then(() => this.fetchEnvironments())
.catch(() => {
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 {
.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: {
......@@ -162,9 +172,13 @@ export default {
});
eventHub.$on('postAction', this.postAction);
eventHub.$on('requestStopEnvironment', this.updateStopModal);
eventHub.$on('stopEnvironment', this.stopEnvironment);
},
beforeDestroyed() {
eventHub.$off('postAction');
beforeDestroy() {
eventHub.$off('postAction', this.postAction);
eventHub.$off('requestStopEnvironment', this.updateStopModal);
eventHub.$off('stopEnvironment', this.stopEnvironment);
},
};
......@@ -13,7 +13,7 @@ export default class EnvironmentsService {
// eslint-disable-next-line class-methods-use-this
postAction(endpoint) {
return axios.post(endpoint, {}, { emulateJSON: true });
return axios.post(endpoint, {});
}
getFolderContent(folderUrl) {
......
......@@ -23,7 +23,7 @@
}
.btn-group {
> a {
> .btn:not(.btn-danger) {
color: $gl-text-color-secondary;
}
......
......@@ -2,7 +2,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
layout 'project'
before_action :authorize_read_environment!
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_admin_environment!, only: [:terminal, :terminal_websocket_authorize]
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics]
......@@ -175,4 +175,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def environment
@environment ||= project.environments.find(params[:id])
end
def authorize_stop_environment!
access_denied! unless can?(current_user, :stop_environment, environment)
end
end
......@@ -192,7 +192,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
deployment = environment.first_deployment_for(@merge_request.diff_head_sha)
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)
end
......
class EnvironmentPolicy < BasePolicy
delegate { @subject.project }
condition(:stop_action_allowed) do
@subject.stop_action? && can?(:update_build, @subject.stop_action)
condition(:stop_with_deployment_allowed) do
@subject.stop_action? && can?(:create_deployment) && can?(:update_build, @subject.stop_action)
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
......@@ -7,7 +7,7 @@ class EnvironmentEntity < Grape::Entity
expose :external_url
expose :environment_type
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|
metrics_project_environment_path(environment.project, environment)
......@@ -31,4 +31,14 @@ class EnvironmentEntity < Grape::Entity
end
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
......@@ -3,13 +3,12 @@
- if actions.present?
.btn-group
.dropdown
%button.dropdown.dropdown-new.btn.btn-default{ type: 'button', 'data-toggle' => 'dropdown' }
= custom_icon('icon_play')
%button.dropdown.dropdown-new.btn.btn-default.has-tooltip{ type: 'button', 'data-toggle' => 'dropdown', title: s_('Environments|Deploy to...') }
= sprite_icon('play')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-right
- actions.each do |action|
- next unless can?(current_user, :update_build, action)
%li
= link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow' do
= custom_icon('icon_play')
= link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow', class: 'btn' do
%span= action.name.humanize
- 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?
= _("Re-deploy")
= sprite_icon('repeat')
- else
= _("Rollback")
= sprite_icon('redo')
- 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')
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 @@
- page_title "Environments"
%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
.col-md-7
%h3.page-title= @environment.name
......@@ -15,7 +42,10 @@
- if can?(current_user, :update_environment, @environment)
= link_to 'Edit', edit_project_environment_path(@project, @environment), class: 'btn'
- 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
- if @deployments.blank?
......
---
title: Support manually stopping any environment from the UI
merge_request: 20077
author:
type: changed
......@@ -89,9 +89,10 @@ module API
requires :environment_id, type: Integer, desc: 'The environment ID'
end
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])
authorize! :stop_environment, environment
environment.stop_with_action!(current_user)
......
......@@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-07-09 08:28+0200\n"
"PO-Revision-Date: 2018-07-09 08:28+0200\n"
"POT-Creation-Date: 2018-07-09 19:16+0200\n"
"PO-Revision-Date: 2018-07-09 19:16+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
......@@ -2099,9 +2099,18 @@ msgstr ""
msgid "Environments|An error occurred while making the request."
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"
msgstr ""
msgid "Environments|Deploy to..."
msgstr ""
msgid "Environments|Deployment"
msgstr ""
......@@ -2114,27 +2123,39 @@ msgstr ""
msgid "Environments|Job"
msgstr ""
msgid "Environments|Learn more about stopping environments"
msgstr ""
msgid "Environments|New environment"
msgstr ""
msgid "Environments|No deployments yet"
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 ""
msgid "Environments|Re-deploy"
msgid "Environments|Re-deploy to environment"
msgstr ""
msgid "Environments|Read more about environments"
msgstr ""
msgid "Environments|Rollback"
msgid "Environments|Rollback environment"
msgstr ""
msgid "Environments|Show all"
msgstr ""
msgid "Environments|Stop"
msgstr ""
msgid "Environments|Stop environment"
msgstr ""
msgid "Environments|Updated"
msgstr ""
......@@ -3799,9 +3820,6 @@ msgstr ""
msgid "Quick actions can be used in the issues description and comment boxes."
msgstr ""
msgid "Re-deploy"
msgstr ""
msgid "Read more"
msgstr ""
......@@ -3936,9 +3954,6 @@ msgstr ""
msgid "Reviewing (merge request !%{mergeRequestId})"
msgstr ""
msgid "Rollback"
msgstr ""
msgid "Runner token"
msgstr ""
......
......@@ -166,7 +166,8 @@ describe 'Environment' do
end
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')
end
......@@ -174,7 +175,7 @@ describe 'Environment' do
context 'when user has no ability 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
......@@ -182,7 +183,7 @@ describe 'Environment' do
let(:role) { :reporter }
it 'does not show stop button' do
expect(page).not_to have_link('Stop')
expect(page).not_to have_button('Stop')
end
end
end
......@@ -192,7 +193,7 @@ describe 'Environment' do
let(:environment) { create(:environment, project: project, state: :stopped) }
it 'does not show stop button' do
expect(page).not_to have_link('Stop')
expect(page).not_to have_button('Stop')
end
end
end
......@@ -230,7 +231,7 @@ describe 'Environment' do
it 'user visits environment page' do
visit_environment(environment)
expect(page).to have_link('Stop')
expect(page).to have_button('Stop')
end
it 'user deletes the branch with running environment' do
......@@ -242,7 +243,7 @@ describe 'Environment' do
visit_environment(environment)
expect(page).to have_no_link('Stop')
expect(page).not_to have_button('Stop')
end
##
......
......@@ -10,6 +10,10 @@ describe 'Environments page', :js do
sign_in(user)
end
def stop_button_selector
%q{button[data-original-title="Stop environment"]}
end
describe 'page tabs' do
it 'shows "Available" and "Stopped" tab with links' do
visit_environments(project)
......@@ -120,7 +124,7 @@ describe 'Environments page', :js do
end
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
......@@ -178,7 +182,7 @@ describe 'Environments page', :js do
end
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
it 'does not show external link button' do
......@@ -211,14 +215,14 @@ describe 'Environments page', :js do
end
it 'shows a stop button' do
expect(page).to have_selector('.stop-env-link')
expect(page).to have_selector(stop_button_selector)
end
context 'when user is a reporter' do
let(:role) { :reporter }
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
......
......@@ -18,7 +18,7 @@ describe('Rollback Component', () => {
},
}).$mount();
expect(component.$el.querySelector('span').textContent).toContain('Re-deploy');
expect(component.$el).toHaveSpriteIcon('repeat');
});
it('Should render Rollback label when isLastDeployment is false', () => {
......@@ -30,6 +30,6 @@ describe('Rollback Component', () => {
},
}).$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';
describe('Stop Component', () => {
let StopComponent;
let component;
const stopURL = '/stop';
beforeEach(() => {
StopComponent = Vue.extend(stopComp);
......@@ -12,20 +11,13 @@ describe('Stop Component', () => {
component = new StopComponent({
propsData: {
stopUrl: stopURL,
environment: {},
},
}).$mount();
});
describe('computed', () => {
it('title', () => {
expect(component.title).toEqual('Stop');
});
});
it('should render a button to stop the environment', () => {
expect(component.$el.tagName).toEqual('BUTTON');
expect(component.$el.getAttribute('data-original-title')).toEqual('Stop');
expect(component.$el.getAttribute('aria-label')).toEqual('Stop');
expect(component.$el.getAttribute('data-original-title')).toEqual('Stop environment');
});
});
require 'spec_helper'
describe EnvironmentPolicy do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
using RSpec::Parameterized::TableSyntax
let(:environment) do
create(:environment, :with_review_app, project: project)
end
let(:user) { create(:user) }
let(:policy) do
described_class.new(user, environment)
end
describe '#rules' do
context 'when user does not have access to the project' do
let(:project) { create(:project, :private, :repository) }
shared_examples 'project permissions' do
context 'with stop action' do
let(:environment) do
create(:environment, :with_review_app, project: project)
end
where(:access_level, :allowed?) do
nil | false
:guest | false
:reporter | false
:developer | true
:master | true
end
it 'does not include ability to stop environment' do
expect(policy).to be_disallowed :stop_environment
with_them do
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 anonymous user has access to the project' do
let(:project) { create(:project, :public, :repository) }
context 'when an admin user' do
let(:user) { create(:user, :admin) }
it 'does not include ability to stop environment' do
expect(policy).to be_disallowed :stop_environment
it { expect(policy).to be_allowed :stop_environment }
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 access to the project' do
let(:project) { create(:project, :public, :repository) }
it { expect(policy).to be_disallowed :stop_environment }
end
before do
project.add_developer(user)
context 'when an admin user' do
let(:user) { create(:user, :admin) }
it { expect(policy).to be_allowed :stop_environment }
end
end
end
context 'when team member has ability to stop environment' do
it 'does includes ability to stop environment' do
expect(policy).to be_allowed :stop_environment
context 'without stop action' do
let(:environment) do
create(:environment, project: project)
end
where(:access_level, :allowed?) do
nil | false
:guest | false
:reporter | false
:developer | false
:master | true
end
context 'when team member has no ability to stop environment' do
with_them do
before do
create(:protected_branch, :no_one_can_push,
name: 'master', project: project)
project.add_user(user, access_level) unless access_level.nil?
end
it 'does not include ability to stop environment' do
expect(policy).to be_disallowed :stop_environment
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
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
require 'spec_helper'
describe EnvironmentEntity do
let(:request) { double('request') }
let(:entity) do
described_class.new(environment, request: double)
described_class.new(environment, request: spy('request'))
end
let(:environment) { create(:environment) }
......
......@@ -54,7 +54,9 @@ describe EnvironmentSerializer do
context 'when representing environments within folders' do
let(:serializer) do
described_class.new(project: project).within_folders
described_class
.new(current_user: user, project: project)
.within_folders
end
let(:resource) { Environment.all }
......@@ -123,7 +125,8 @@ describe EnvironmentSerializer do
let(:pagination) { { page: 1, per_page: 2 } }
let(:serializer) do
described_class.new(project: project)
described_class
.new(current_user: user, project: project)
.with_pagination(request, response)
end
......@@ -169,7 +172,8 @@ describe EnvironmentSerializer do
context 'when grouping environments within folders' do
let(:serializer) do
described_class.new(project: project)
described_class
.new(current_user: user, project: project)
.with_pagination(request, response)
.within_folders
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