summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPhil Hughes <me@iamphill.com>2018-07-10 08:11:04 +0000
committerPhil Hughes <me@iamphill.com>2018-07-10 08:11:04 +0000
commit2f96930d5cea171cf8339925a53a71bca63f85cc (patch)
treefad8dce6f89102fda75f511dee80b7fae7675994
parentca1deb9e5ec1429a65d73b3352d1207301f9fc6f (diff)
parentd79cef3a9a5577765d975326fbf4bc1b8c5634de (diff)
downloadgitlab-ce-2f96930d5cea171cf8339925a53a71bca63f85cc.tar.gz
Merge branch 'winh-stop-all-environments' into 'master'
Support manually stopping any environment from the UI Closes #25388 See merge request gitlab-org/gitlab-ce!20077
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue87
-rw-r--r--app/assets/javascripts/environments/components/environment_external_url.vue51
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue873
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue49
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue94
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue106
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue53
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue4
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue92
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue8
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js22
-rw-r--r--app/assets/javascripts/environments/services/environments_service.js2
-rw-r--r--app/assets/stylesheets/pages/environments.scss2
-rw-r--r--app/controllers/projects/environments_controller.rb6
-rw-r--r--app/controllers/projects/merge_requests_controller.rb2
-rw-r--r--app/policies/environment_policy.rb10
-rw-r--r--app/serializers/environment_entity.rb12
-rw-r--r--app/views/projects/deployments/_actions.haml7
-rw-r--r--app/views/projects/deployments/_rollback.haml7
-rw-r--r--app/views/projects/environments/_external_url.html.haml2
-rw-r--r--app/views/projects/environments/_stop.html.haml5
-rw-r--r--app/views/projects/environments/show.html.haml32
-rw-r--r--changelogs/unreleased/winh-stop-all-environments.yml5
-rw-r--r--lib/api/environments.rb3
-rw-r--r--locale/gitlab.pot37
-rw-r--r--spec/features/projects/environments/environment_spec.rb13
-rw-r--r--spec/features/projects/environments/environments_spec.rb12
-rw-r--r--spec/javascripts/environments/environment_rollback_spec.js4
-rw-r--r--spec/javascripts/environments/environment_stop_spec.js12
-rw-r--r--spec/policies/environment_policy_spec.rb106
-rw-r--r--spec/serializers/environment_entity_spec.rb3
-rw-r--r--spec/serializers/environment_serializer_spec.rb10
32 files changed, 994 insertions, 737 deletions
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index e3652fe739e..63d83e307ee 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,50 +1,50 @@
<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 {
- directives: {
- tooltip,
+export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ loadingIcon,
+ Icon,
+ },
+ props: {
+ actions: {
+ type: Array,
+ required: false,
+ default: () => [],
},
- components: {
- loadingIcon,
- Icon,
+ },
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
+ computed: {
+ title() {
+ return 'Deploy to...';
},
- props: {
- actions: {
- type: Array,
- required: false,
- default: () => [],
- },
- },
- data() {
- return {
- isLoading: false,
- };
- },
- computed: {
- title() {
- return 'Deploy to...';
- },
- },
- methods: {
- onClickAction(endpoint) {
- this.isLoading = true;
+ },
+ methods: {
+ onClickAction(endpoint) {
+ this.isLoading = true;
- eventHub.$emit('postAction', endpoint);
- },
+ eventHub.$emit('postAction', { endpoint });
+ },
- isActionDisabled(action) {
- if (action.playable === undefined) {
- return false;
- }
+ isActionDisabled(action) {
+ if (action.playable === undefined) {
+ return false;
+ }
- return !action.playable;
- },
+ 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>
diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue
index 68195225d50..7446196de13 100644
--- a/app/assets/javascripts/environments/components/environment_external_url.vue
+++ b/app/assets/javascripts/environments/components/environment_external_url.vue
@@ -1,30 +1,30 @@
<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 {
- components: {
- Icon,
+/**
+ * Renders the external url link in environments table.
+ */
+export default {
+ components: {
+ 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>
<template>
<a
@@ -37,9 +37,6 @@
target="_blank"
rel="noopener noreferrer nofollow"
>
- <icon
- :size="12"
- name="external-link"
- />
+ <icon name="external-link" />
</a>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 5ecdccf63ad..39f3790a286 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,429 +1,450 @@
<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';
-
- /**
- * Envrionment Item Component
- *
- * Renders a table row for each environment.
- */
- const timeagoInstance = new Timeago();
-
- export default {
- components: {
- UserAvatarLink,
- CommitComponent,
- ActionsComponent,
- ExternalUrlComponent,
- StopComponent,
- RollbackComponent,
- TerminalButtonComponent,
- MonitoringButtonComponent,
+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();
+
+export default {
+ components: {
+ UserAvatarLink,
+ CommitComponent,
+ ActionsComponent,
+ ExternalUrlComponent,
+ StopComponent,
+ RollbackComponent,
+ TerminalButtonComponent,
+ MonitoringButtonComponent,
+ },
+
+ directives: {
+ tooltip,
+ },
+
+ props: {
+ model: {
+ type: Object,
+ required: true,
+ default: () => ({}),
},
- directives: {
- tooltip,
+ canCreateDeployment: {
+ type: Boolean,
+ required: false,
+ default: false,
},
- props: {
- model: {
- type: Object,
- required: true,
- default: () => ({}),
- },
-
- canCreateDeployment: {
- type: Boolean,
- required: false,
- default: false,
- },
-
- canReadEnvironment: {
- type: Boolean,
- required: false,
- default: false,
- },
+ canReadEnvironment: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ computed: {
+ /**
+ * Verifies if `last_deployment` key exists in the current Envrionment.
+ * This key is required to render most of the html - this method works has
+ * an helper.
+ *
+ * @returns {Boolean}
+ */
+ hasLastDeploymentKey() {
+ if (this.model && this.model.last_deployment && !_.isEmpty(this.model.last_deployment)) {
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Verifies is the given environment has manual actions.
+ * Used to verify if we should render them or nor.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ hasManualActions() {
+ return (
+ this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.manual_actions &&
+ this.model.last_deployment.manual_actions.length > 0
+ );
+ },
+
+ /**
+ * Returns whether the environment can be stopped.
+ *
+ * @returns {Boolean}
+ */
+ canStopEnvironment() {
+ return this.model && this.model.can_stop;
+ },
+
+ /**
+ * Verifies if the `deployable` key is present in `last_deployment` key.
+ * Used to verify whether we should or not render the rollback partial.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ canRetry() {
+ return (
+ this.model &&
+ this.hasLastDeploymentKey &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable
+ );
+ },
+
+ /**
+ * Verifies if the date to be shown is present.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ canShowDate() {
+ return (
+ this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable !== undefined
+ );
+ },
+
+ /**
+ * Human readable date.
+ *
+ * @returns {String}
+ */
+ createdDate() {
+ if (
+ this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable.created_at
+ ) {
+ return timeagoInstance.format(this.model.last_deployment.deployable.created_at);
+ }
+ return '';
+ },
+
+ /**
+ * Returns the manual actions with the name parsed.
+ *
+ * @returns {Array.<Object>|Undefined}
+ */
+ manualActions() {
+ if (this.hasManualActions) {
+ return this.model.last_deployment.manual_actions.map(action => {
+ const parsedAction = {
+ name: humanize(action.name),
+ play_path: action.play_path,
+ playable: action.playable,
+ };
+ return parsedAction;
+ });
+ }
+ return [];
+ },
+
+ /**
+ * Builds the string used in the user image alt attribute.
+ *
+ * @returns {String}
+ */
+ userImageAltDescription() {
+ if (
+ this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.user &&
+ this.model.last_deployment.user.username
+ ) {
+ return `${this.model.last_deployment.user.username}'s avatar'`;
+ }
+ return '';
+ },
+
+ /**
+ * If provided, returns the commit tag.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTag() {
+ if (this.model && this.model.last_deployment && this.model.last_deployment.tag) {
+ return this.model.last_deployment.tag;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit ref.
+ *
+ * @returns {Object|Undefined}
+ */
+ commitRef() {
+ if (this.model && this.model.last_deployment && this.model.last_deployment.ref) {
+ return this.model.last_deployment.ref;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit url.
+ *
+ * @returns {String|Undefined}
+ */
+ commitUrl() {
+ if (
+ this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.commit_path
+ ) {
+ return this.model.last_deployment.commit.commit_path;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit short sha.
+ *
+ * @returns {String|Undefined}
+ */
+ commitShortSha() {
+ if (
+ this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.short_id
+ ) {
+ return this.model.last_deployment.commit.short_id;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit title.
+ *
+ * @returns {String|Undefined}
+ */
+ commitTitle() {
+ if (
+ this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.title
+ ) {
+ return this.model.last_deployment.commit.title;
+ }
+ return undefined;
+ },
+
+ /**
+ * If provided, returns the commit tag.
+ *
+ * @returns {Object|Undefined}
+ */
+ commitAuthor() {
+ if (
+ this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.commit &&
+ this.model.last_deployment.commit.author
+ ) {
+ return this.model.last_deployment.commit.author;
+ }
+
+ return undefined;
+ },
+
+ /**
+ * Verifies if the `retry_path` key is present and returns its value.
+ *
+ * @returns {String|Undefined}
+ */
+ retryUrl() {
+ if (
+ this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable.retry_path
+ ) {
+ return this.model.last_deployment.deployable.retry_path;
+ }
+ return undefined;
+ },
+
+ /**
+ * Verifies if the `last?` key is present and returns its value.
+ *
+ * @returns {Boolean|Undefined}
+ */
+ isLastDeployment() {
+ return this.model && this.model.last_deployment && this.model.last_deployment['last?'];
+ },
+
+ /**
+ * Builds the name of the builds needed to display both the name and the id.
+ *
+ * @returns {String}
+ */
+ buildName() {
+ if (this.model && this.model.last_deployment && this.model.last_deployment.deployable) {
+ const { deployable } = this.model.last_deployment;
+ return `${deployable.name} #${deployable.id}`;
+ }
+ return '';
+ },
+
+ /**
+ * Builds the needed string to show the internal id.
+ *
+ * @returns {String}
+ */
+ deploymentInternalId() {
+ if (this.model && this.model.last_deployment && this.model.last_deployment.iid) {
+ return `#${this.model.last_deployment.iid}`;
+ }
+ return '';
},
- computed: {
- /**
- * Verifies if `last_deployment` key exists in the current Envrionment.
- * This key is required to render most of the html - this method works has
- * an helper.
- *
- * @returns {Boolean}
- */
- hasLastDeploymentKey() {
- if (this.model &&
- this.model.last_deployment &&
- !_.isEmpty(this.model.last_deployment)) {
- return true;
- }
- return false;
- },
-
- /**
- * Verifies is the given environment has manual actions.
- * Used to verify if we should render them or nor.
- *
- * @returns {Boolean|Undefined}
- */
- hasManualActions() {
- return this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.manual_actions &&
- this.model.last_deployment.manual_actions.length > 0;
- },
-
- /**
- * Returns the value of the `stop_action?` key provided in the response.
- *
- * @returns {Boolean}
- */
- hasStopAction() {
- return this.model && this.model['stop_action?'];
- },
-
- /**
- * Verifies if the `deployable` key is present in `last_deployment` key.
- * Used to verify whether we should or not render the rollback partial.
- *
- * @returns {Boolean|Undefined}
- */
- canRetry() {
- return this.model &&
- this.hasLastDeploymentKey &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable;
- },
-
- /**
- * Verifies if the date to be shown is present.
- *
- * @returns {Boolean|Undefined}
- */
- canShowDate() {
- return this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable &&
- this.model.last_deployment.deployable !== undefined;
- },
-
- /**
- * Human readable date.
- *
- * @returns {String}
- */
- createdDate() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable &&
- this.model.last_deployment.deployable.created_at) {
- return timeagoInstance.format(this.model.last_deployment.deployable.created_at);
- }
- return '';
- },
-
- /**
- * Returns the manual actions with the name parsed.
- *
- * @returns {Array.<Object>|Undefined}
- */
- manualActions() {
- if (this.hasManualActions) {
- return this.model.last_deployment.manual_actions.map((action) => {
- const parsedAction = {
- name: humanize(action.name),
- play_path: action.play_path,
- playable: action.playable,
- };
- return parsedAction;
- });
- }
- return [];
- },
-
- /**
- * Builds the string used in the user image alt attribute.
- *
- * @returns {String}
- */
- userImageAltDescription() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.user &&
- this.model.last_deployment.user.username) {
- return `${this.model.last_deployment.user.username}'s avatar'`;
- }
- return '';
- },
-
- /**
- * If provided, returns the commit tag.
- *
- * @returns {String|Undefined}
- */
- commitTag() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.tag) {
- return this.model.last_deployment.tag;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit ref.
- *
- * @returns {Object|Undefined}
- */
- commitRef() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.ref) {
- return this.model.last_deployment.ref;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit url.
- *
- * @returns {String|Undefined}
- */
- commitUrl() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.commit &&
- this.model.last_deployment.commit.commit_path) {
- return this.model.last_deployment.commit.commit_path;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit short sha.
- *
- * @returns {String|Undefined}
- */
- commitShortSha() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.commit &&
- this.model.last_deployment.commit.short_id) {
- return this.model.last_deployment.commit.short_id;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit title.
- *
- * @returns {String|Undefined}
- */
- commitTitle() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.commit &&
- this.model.last_deployment.commit.title) {
- return this.model.last_deployment.commit.title;
- }
- return undefined;
- },
-
- /**
- * If provided, returns the commit tag.
- *
- * @returns {Object|Undefined}
- */
- commitAuthor() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.commit &&
- this.model.last_deployment.commit.author) {
- return this.model.last_deployment.commit.author;
- }
-
- return undefined;
- },
-
- /**
- * Verifies if the `retry_path` key is present and returns its value.
- *
- * @returns {String|Undefined}
- */
- retryUrl() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable &&
- this.model.last_deployment.deployable.retry_path) {
- return this.model.last_deployment.deployable.retry_path;
- }
- return undefined;
- },
-
- /**
- * Verifies if the `last?` key is present and returns its value.
- *
- * @returns {Boolean|Undefined}
- */
- isLastDeployment() {
- return this.model && this.model.last_deployment &&
- this.model.last_deployment['last?'];
- },
-
- /**
- * Builds the name of the builds needed to display both the name and the id.
- *
- * @returns {String}
- */
- buildName() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable) {
- const { deployable } = this.model.last_deployment;
- return `${deployable.name} #${deployable.id}`;
- }
- return '';
- },
-
- /**
- * Builds the needed string to show the internal id.
- *
- * @returns {String}
- */
- deploymentInternalId() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.iid) {
- return `#${this.model.last_deployment.iid}`;
- }
- return '';
- },
-
- /**
- * Verifies if the user object is present under last_deployment object.
- *
- * @returns {Boolean}
- */
- deploymentHasUser() {
- return this.model &&
- !_.isEmpty(this.model.last_deployment) &&
- !_.isEmpty(this.model.last_deployment.user);
- },
-
- /**
- * Returns the user object nested with the last_deployment object.
- * Used to render the template.
- *
- * @returns {Object}
- */
- deploymentUser() {
- if (this.model &&
- !_.isEmpty(this.model.last_deployment) &&
- !_.isEmpty(this.model.last_deployment.user)) {
- return this.model.last_deployment.user;
- }
- return {};
- },
-
- /**
- * Verifies if the build name column should be rendered by verifing
- * if all the information needed is present
- * and if the environment is not a folder.
- *
- * @returns {Boolean}
- */
- shouldRenderBuildName() {
- return !this.model.isFolder &&
- !_.isEmpty(this.model.last_deployment) &&
- !_.isEmpty(this.model.last_deployment.deployable);
- },
-
- /**
- * Verifies the presence of all the keys needed to render the buil_path.
- *
- * @return {String}
- */
- buildPath() {
- if (this.model &&
- this.model.last_deployment &&
- this.model.last_deployment.deployable &&
- this.model.last_deployment.deployable.build_path) {
- return this.model.last_deployment.deployable.build_path;
- }
-
- return '';
- },
-
- /**
- * Verifies the presence of all the keys needed to render the external_url.
- *
- * @return {String}
- */
- externalURL() {
- if (this.model && this.model.external_url) {
- return this.model.external_url;
- }
-
- return '';
- },
-
- /**
- * Verifies if deplyment internal ID should be rendered by verifing
- * if all the information needed is present
- * and if the environment is not a folder.
- *
- * @returns {Boolean}
- */
- shouldRenderDeploymentID() {
- return !this.model.isFolder &&
- !_.isEmpty(this.model.last_deployment) &&
- this.model.last_deployment.iid !== undefined;
- },
-
- environmentPath() {
- if (this.model && this.model.environment_path) {
- return this.model.environment_path;
- }
-
- return '';
- },
-
- monitoringUrl() {
- if (this.model && this.model.metrics_path) {
- return this.model.metrics_path;
- }
-
- return '';
- },
-
- displayEnvironmentActions() {
- return this.hasManualActions ||
- this.externalURL ||
- this.monitoringUrl ||
- this.hasStopAction ||
- this.canRetry;
- },
+ /**
+ * Verifies if the user object is present under last_deployment object.
+ *
+ * @returns {Boolean}
+ */
+ deploymentHasUser() {
+ return (
+ this.model &&
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.user)
+ );
},
- methods: {
- onClickFolder() {
- eventHub.$emit('toggleFolder', this.model);
- },
+ /**
+ * Returns the user object nested with the last_deployment object.
+ * Used to render the template.
+ *
+ * @returns {Object}
+ */
+ deploymentUser() {
+ if (
+ this.model &&
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.user)
+ ) {
+ return this.model.last_deployment.user;
+ }
+ return {};
},
- };
+
+ /**
+ * Verifies if the build name column should be rendered by verifing
+ * if all the information needed is present
+ * and if the environment is not a folder.
+ *
+ * @returns {Boolean}
+ */
+ shouldRenderBuildName() {
+ return (
+ !this.model.isFolder &&
+ !_.isEmpty(this.model.last_deployment) &&
+ !_.isEmpty(this.model.last_deployment.deployable)
+ );
+ },
+
+ /**
+ * Verifies the presence of all the keys needed to render the buil_path.
+ *
+ * @return {String}
+ */
+ buildPath() {
+ if (
+ this.model &&
+ this.model.last_deployment &&
+ this.model.last_deployment.deployable &&
+ this.model.last_deployment.deployable.build_path
+ ) {
+ return this.model.last_deployment.deployable.build_path;
+ }
+
+ return '';
+ },
+
+ /**
+ * Verifies the presence of all the keys needed to render the external_url.
+ *
+ * @return {String}
+ */
+ externalURL() {
+ if (this.model && this.model.external_url) {
+ return this.model.external_url;
+ }
+
+ return '';
+ },
+
+ /**
+ * Verifies if deplyment internal ID should be rendered by verifing
+ * if all the information needed is present
+ * and if the environment is not a folder.
+ *
+ * @returns {Boolean}
+ */
+ shouldRenderDeploymentID() {
+ return (
+ !this.model.isFolder &&
+ !_.isEmpty(this.model.last_deployment) &&
+ this.model.last_deployment.iid !== undefined
+ );
+ },
+
+ environmentPath() {
+ if (this.model && this.model.environment_path) {
+ return this.model.environment_path;
+ }
+
+ return '';
+ },
+
+ monitoringUrl() {
+ if (this.model && this.model.metrics_path) {
+ return this.model.metrics_path;
+ }
+
+ return '';
+ },
+
+ displayEnvironmentActions() {
+ return (
+ this.hasManualActions ||
+ this.externalURL ||
+ this.monitoringUrl ||
+ this.canStopEnvironment ||
+ this.canRetry
+ );
+ },
+ },
+
+ methods: {
+ onClickFolder() {
+ 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>
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index 947e8c901e9..ccc8419ca6d 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -1,29 +1,29 @@
<script>
- /**
- * Renders the Monitoring (Metrics) link in environments table.
- */
- import Icon from '~/vue_shared/components/icon.vue';
- import tooltip from '../../vue_shared/directives/tooltip';
+/**
+ * Renders the Monitoring (Metrics) link in environments table.
+ */
+import Icon from '~/vue_shared/components/icon.vue';
+import tooltip from '../../vue_shared/directives/tooltip';
- export default {
- components: {
- Icon,
+export default {
+ components: {
+ 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>
<template>
<a
@@ -35,9 +35,6 @@
data-container="body"
rel="noopener noreferrer nofollow"
>
- <icon
- :size="12"
- name="chart"
- />
+ <icon name="chart" />
</a>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 310835c5ea9..4deeef4beb9 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -1,56 +1,74 @@
<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';
-
- export default {
- components: {
- loadingIcon,
+/**
+ * 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 { 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 {
+ components: {
+ Icon,
+ LoadingIcon,
+ },
+
+ directives: {
+ tooltip,
+ },
+
+ props: {
+ retryUrl: {
+ type: String,
+ default: '',
},
- props: {
- retryUrl: {
- type: String,
- default: '',
- },
-
- isLastDeployment: {
- type: Boolean,
- default: true,
- },
+
+ isLastDeployment: {
+ type: Boolean,
+ default: true,
},
- data() {
- return {
- isLoading: false,
- };
+ },
+ data() {
+ 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>
<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>
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index eba58bedd6d..a814b3405f5 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -1,72 +1,78 @@
<script>
- /**
- * Renders the stop "button" that allows stop an environment.
- * Used in environments table.
- */
+/**
+ * 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 {
- components: {
- loadingIcon,
- },
+export default {
+ components: {
+ Icon,
+ LoadingButton,
+ },
- directives: {
- tooltip,
- },
+ directives: {
+ tooltip,
+ },
- props: {
- stopUrl: {
- type: String,
- default: '',
- },
+ props: {
+ environment: {
+ type: Object,
+ required: true,
},
+ },
- data() {
- return {
- isLoading: false,
- };
- },
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
- computed: {
- title() {
- return 'Stop';
- },
+ computed: {
+ title() {
+ return s__('Environments|Stop environment');
},
+ },
- methods: {
- onClick() {
- // eslint-disable-next-line no-alert
- if (window.confirm('Are you sure you want to stop this environment?')) {
- this.isLoading = true;
+ mounted() {
+ eventHub.$on('stopEnvironment', this.onStopEnvironment);
+ },
- $(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>
<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>
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index f8e3165f8cd..350417e5ad0 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -1,31 +1,31 @@
<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';
+/**
+ * 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';
- export default {
- components: {
- Icon,
+export default {
+ components: {
+ 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>
<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>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index b18f02343d6..8efdfb8abe0 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -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"
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
new file mode 100644
index 00000000000..657cc8cd1aa
--- /dev/null
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -0,0 +1,92 @@
+<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>
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index 5f72a39c5cb..e69bfa0b2cc 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,12 +1,18 @@
<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"
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index a7a79dbca70..d88624f7f8d 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -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);
},
};
diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js
index 3b121551aca..4e07ccba91a 100644
--- a/app/assets/javascripts/environments/services/environments_service.js
+++ b/app/assets/javascripts/environments/services/environments_service.js
@@ -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) {
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 199039f38f7..3144dcc4dc0 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -23,7 +23,7 @@
}
.btn-group {
- > a {
+ > .btn:not(.btn-danger) {
color: $gl-text-color-secondary;
}
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 395c5336ad5..68353e6a210 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -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
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 1ad2e93c85f..dc6551fc761 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -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
diff --git a/app/policies/environment_policy.rb b/app/policies/environment_policy.rb
index 375a5535359..978dc3a7c81 100644
--- a/app/policies/environment_policy.rb
+++ b/app/policies/environment_policy.rb
@@ -1,9 +1,13 @@
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
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index ba0ae6ba8a0..0fc3f92b151 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -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
diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml
index e0ecf56525a..f4c91377ecb 100644
--- a/app/views/projects/deployments/_actions.haml
+++ b/app/views/projects/deployments/_actions.haml
@@ -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
diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml
index 95f950948ab..281e042c915 100644
--- a/app/views/projects/deployments/_rollback.haml
+++ b/app/views/projects/deployments/_rollback.haml
@@ -1,6 +1,7 @@
- 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')
diff --git a/app/views/projects/environments/_external_url.html.haml b/app/views/projects/environments/_external_url.html.haml
index a264252e095..4694bc39d54 100644
--- a/app/views/projects/environments/_external_url.html.haml
+++ b/app/views/projects/environments/_external_url.html.haml
@@ -1,4 +1,4 @@
- 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
diff --git a/app/views/projects/environments/_stop.html.haml b/app/views/projects/environments/_stop.html.haml
deleted file mode 100644
index c35f9af2873..00000000000
--- a/app/views/projects/environments/_stop.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- 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')
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index add394a6356..a33bc9d4ce6 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -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?
diff --git a/changelogs/unreleased/winh-stop-all-environments.yml b/changelogs/unreleased/winh-stop-all-environments.yml
new file mode 100644
index 00000000000..6e5f2f506d9
--- /dev/null
+++ b/changelogs/unreleased/winh-stop-all-environments.yml
@@ -0,0 +1,5 @@
+---
+title: Support manually stopping any environment from the UI
+merge_request: 20077
+author:
+type: changed
diff --git a/lib/api/environments.rb b/lib/api/environments.rb
index 5c63ec028d9..fa828f43001 100644
--- a/lib/api/environments.rb
+++ b/lib/api/environments.rb
@@ -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)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ca488053a1f..b6fd7ccc1bb 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb
index 0c34309c1f4..624f7139605 100644
--- a/spec/features/projects/environments/environment_spec.rb
+++ b/spec/features/projects/environments/environment_spec.rb
@@ -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
##
diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb
index 9900c13095e..c2ed753c409 100644
--- a/spec/features/projects/environments/environments_spec.rb
+++ b/spec/features/projects/environments/environments_spec.rb
@@ -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
diff --git a/spec/javascripts/environments/environment_rollback_spec.js b/spec/javascripts/environments/environment_rollback_spec.js
index eb8e49d81fe..79f33c5bc8a 100644
--- a/spec/javascripts/environments/environment_rollback_spec.js
+++ b/spec/javascripts/environments/environment_rollback_spec.js
@@ -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');
});
});
diff --git a/spec/javascripts/environments/environment_stop_spec.js b/spec/javascripts/environments/environment_stop_spec.js
index 3f95faf466a..4d9caa57566 100644
--- a/spec/javascripts/environments/environment_stop_spec.js
+++ b/spec/javascripts/environments/environment_stop_spec.js
@@ -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');
});
});
diff --git a/spec/policies/environment_policy_spec.rb b/spec/policies/environment_policy_spec.rb
index de4cb5b30c5..3728218accc 100644
--- a/spec/policies/environment_policy_spec.rb
+++ b/spec/policies/environment_policy_spec.rb
@@ -1,57 +1,101 @@
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
- it 'does not include ability to stop environment' do
- expect(policy).to be_disallowed :stop_environment
- end
- end
+ where(:access_level, :allowed?) do
+ nil | false
+ :guest | false
+ :reporter | false
+ :developer | true
+ :master | true
+ end
- context 'when anonymous user has access to the project' do
- let(:project) { create(:project, :public, :repository) }
+ with_them do
+ before do
+ 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
- end
- end
+ it { expect(policy.allowed?(:stop_environment)).to be allowed? }
+ end
- context 'when team member has access to the project' do
- let(:project) { create(:project, :public, :repository) }
+ context 'when an admin user' do
+ let(:user) { create(:user, :admin) }
- before do
- project.add_developer(user)
- end
+ 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 ability to stop environment' do
- it 'does includes ability to stop environment' do
- expect(policy).to be_allowed :stop_environment
+ it { expect(policy).to be_disallowed :stop_environment }
+ 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 team member has no ability to stop environment' do
- before do
- create(:protected_branch, :no_one_can_push,
- name: 'master', project: project)
+ 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
- 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 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
diff --git a/spec/serializers/environment_entity_spec.rb b/spec/serializers/environment_entity_spec.rb
index 8f32c5639a1..b7324a26ed2 100644
--- a/spec/serializers/environment_entity_spec.rb
+++ b/spec/serializers/environment_entity_spec.rb
@@ -1,8 +1,9 @@
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) }
diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb
index ca9b520fb38..0f0ab5ac796 100644
--- a/spec/serializers/environment_serializer_spec.rb
+++ b/spec/serializers/environment_serializer_spec.rb
@@ -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