Commit 40b9bf8f authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '20956-autostop-frontend' into 'master'

Resolve "Auto stop environments after a certain period"

See merge request gitlab-org/gitlab!20372
parents acb38fa0 0f780730
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { format } from 'timeago.js';
import _ from 'underscore';
import { GlTooltipDirective } from '@gitlab/ui';
import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin';
import { __, sprintf } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import { __, sprintf } from '~/locale';
import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin';
import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue';
import MonitoringButtonComponent from './environment_monitoring.vue';
import PinComponent from './environment_pin.vue';
import RollbackComponent from './environment_rollback.vue';
import StopComponent from './environment_stop.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 { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
/**
* Environment Item Component
......@@ -26,21 +27,22 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default {
components: {
CommitComponent,
Icon,
ActionsComponent,
CommitComponent,
ExternalUrlComponent,
StopComponent,
Icon,
MonitoringButtonComponent,
PinComponent,
RollbackComponent,
StopComponent,
TerminalButtonComponent,
MonitoringButtonComponent,
TooltipOnTruncate,
UserAvatarLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [environmentItemMixin],
mixins: [environmentItemMixin, timeagoMixin],
props: {
canReadEnvironment: {
......@@ -52,7 +54,12 @@ export default {
model: {
type: Object,
required: true,
default: () => ({}),
},
shouldShowAutoStopDate: {
type: Boolean,
required: false,
default: false,
},
tableData: {
......@@ -76,6 +83,16 @@ export default {
return false;
},
/**
* Checkes whether the row displayed is a folder.
*
* @returns {Boolean}
*/
isFolder() {
return this.model.isFolder;
},
/**
* Checkes whether the environment is protected.
* (`is_protected` currently only set in EE)
......@@ -112,24 +129,64 @@ export default {
},
/**
* Verifies if the date to be shown is present.
* Verifies if the autostop date is present.
*
* @returns {Boolean}
*/
canShowAutoStopDate() {
if (!this.model.auto_stop_at) {
return false;
}
const autoStopDate = new Date(this.model.auto_stop_at);
const now = new Date();
return now < autoStopDate;
},
/**
* Human readable deployment date.
*
* @returns {String}
*/
autoStopDate() {
if (this.canShowAutoStopDate) {
return {
formatted: this.timeFormatted(this.model.auto_stop_at),
tooltip: this.tooltipTitle(this.model.auto_stop_at),
};
}
return {
formatted: '',
tooltip: '',
};
},
/**
* Verifies if the deployment date is present.
*
* @returns {Boolean|Undefined}
*/
canShowDate() {
canShowDeploymentDate() {
return this.model && this.model.last_deployment && this.model.last_deployment.deployed_at;
},
/**
* Human readable date.
* Human readable deployment date.
*
* @returns {String}
*/
deployedDate() {
if (this.canShowDate) {
return format(this.model.last_deployment.deployed_at);
if (this.canShowDeploymentDate) {
return {
formatted: this.timeFormatted(this.model.last_deployment.deployed_at),
tooltip: this.tooltipTitle(this.model.last_deployment.deployed_at),
};
}
return '';
return {
formatted: '',
tooltip: '',
};
},
actions() {
......@@ -344,6 +401,15 @@ export default {
return {};
},
/**
* Checkes whether to display no deployment text.
*
* @returns {Boolean}
*/
showNoDeployments() {
return !this.hasLastDeploymentKey && !this.isFolder;
},
/**
* Verifies if the build name column should be rendered by verifing
* if all the information needed is present
......@@ -353,7 +419,7 @@ export default {
*/
shouldRenderBuildName() {
return (
!this.model.isFolder &&
!this.isFolder &&
!_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.deployable)
);
......@@ -383,11 +449,7 @@ export default {
* @return {String}
*/
externalURL() {
if (this.model && this.model.external_url) {
return this.model.external_url;
}
return '';
return this.model.external_url || '';
},
/**
......@@ -399,26 +461,22 @@ export default {
*/
shouldRenderDeploymentID() {
return (
!this.model.isFolder &&
!this.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 '';
return this.model.environment_path || '';
},
monitoringUrl() {
if (this.model && this.model.metrics_path) {
return this.model.metrics_path;
}
return this.model.metrics_path || '';
},
return '';
autoStopUrl() {
return this.model.cancel_auto_stop_path || '';
},
displayEnvironmentActions() {
......@@ -447,7 +505,7 @@ export default {
<div
:class="{
'js-child-row environment-child-row': model.isChildren,
'folder-row': model.isFolder,
'folder-row': isFolder,
}"
class="gl-responsive-table-row"
role="row"
......@@ -457,7 +515,7 @@ export default {
:class="tableData.name.spacing"
role="gridcell"
>
<div v-if="!model.isFolder" class="table-mobile-header" role="rowheader">
<div v-if="!isFolder" class="table-mobile-header" role="rowheader">
{{ tableData.name.title }}
</div>
......@@ -466,7 +524,7 @@ export default {
</span>
<span
v-if="!model.isFolder"
v-if="!isFolder"
v-gl-tooltip
:title="model.name"
class="environment-name table-mobile-content"
......@@ -506,7 +564,7 @@ export default {
{{ deploymentInternalId }}
</span>
<span v-if="!model.isFolder && deploymentHasUser" class="text-break-word">
<span v-if="!isFolder && deploymentHasUser" class="text-break-word">
by
<user-avatar-link
:link-href="deploymentUser.web_url"
......@@ -516,6 +574,10 @@ export default {
class="js-deploy-user-container float-none"
/>
</span>
<div v-if="showNoDeployments" class="commit-title table-mobile-content">
{{ s__('Environments|No deployments yet') }}
</div>
</div>
<div
......@@ -536,14 +598,8 @@ export default {
</a>
</div>
<div
v-if="!model.isFolder"
class="table-section"
:class="tableData.commit.spacing"
role="gridcell"
>
<div v-if="!isFolder" class="table-section" :class="tableData.commit.spacing" role="gridcell">
<div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div>
<div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content">
<commit-component
:tag="commitTag"
......@@ -554,31 +610,51 @@ export default {
:author="commitAuthor"
/>
</div>
<div v-if="!hasLastDeploymentKey" class="commit-title table-mobile-content">
{{ s__('Environments|No deployments yet') }}
</div>
</div>
<div v-if="!isFolder" class="table-section" :class="tableData.date.spacing" role="gridcell">
<div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div>
<span
v-if="canShowDeploymentDate"
v-gl-tooltip
:title="deployedDate.tooltip"
class="environment-created-date-timeago table-mobile-content flex-truncate-parent"
>
<span class="flex-truncate-child">
{{ deployedDate.formatted }}
</span>
</span>
</div>
<div
v-if="!model.isFolder"
v-if="!isFolder && shouldShowAutoStopDate"
class="table-section"
:class="tableData.date.spacing"
:class="tableData.autoStop.spacing"
role="gridcell"
>
<div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div>
<span v-if="canShowDate" class="environment-created-date-timeago table-mobile-content">
{{ deployedDate }}
<div role="rowheader" class="table-mobile-header">{{ tableData.autoStop.title }}</div>
<span
v-if="canShowAutoStopDate"
v-gl-tooltip
:title="autoStopDate.tooltip"
class="table-mobile-content flex-truncate-parent"
>
<span class="flex-truncate-child js-auto-stop">{{ autoStopDate.formatted }}</span>
</span>
</div>
<div
v-if="!model.isFolder && displayEnvironmentActions"
v-if="!isFolder && displayEnvironmentActions"
class="table-section table-button-footer"
:class="tableData.actions.spacing"
role="gridcell"
>
<div class="btn-group table-action-buttons" role="group">
<pin-component
v-if="canShowAutoStopDate && shouldShowAutoStopDate"
:auto-stop-url="autoStopUrl"
/>
<external-url-component
v-if="externalURL && canReadEnvironment"
:external-url="externalURL"
......
<script>
/**
* Renders a prevent auto-stop button.
* Used in environments table.
*/
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { __ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
Icon,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
autoStopUrl: {
type: String,
required: true,
},
},
methods: {
onPinClick() {
eventHub.$emit('cancelAutoStop', this.autoStopUrl);
},
},
title: __('Prevent environment from auto-stopping'),
};
</script>
<template>
<gl-button v-gl-tooltip :title="$options.title" :aria-label="$options.title" @click="onPinClick">
<icon name="thumbtack" />
</gl-button>
</template>
......@@ -6,6 +6,7 @@ import { GlLoadingIcon } from '@gitlab/ui';
import _ from 'underscore';
import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin';
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import EnvironmentItem from './environment_item.vue';
export default {
......@@ -16,7 +17,7 @@ export default {
CanaryDeploymentCallout: () =>
import('ee_component/environments/components/canary_deployment_callout.vue'),
},
mixins: [environmentTableMixin],
mixins: [environmentTableMixin, glFeatureFlagsMixin()],
props: {
environments: {
type: Array,
......@@ -42,6 +43,9 @@ export default {
: env,
);
},
shouldShowAutoStopDate() {
return this.glFeatures.autoStopEnvironments;
},
tableData() {
return {
// percent spacing for cols, should add up to 100
......@@ -65,8 +69,12 @@ export default {
title: s__('Environments|Updated'),
spacing: 'section-10',
},
autoStop: {
title: s__('Environments|Auto stop in'),
spacing: 'section-5',
},
actions: {
spacing: 'section-30',
spacing: this.shouldShowAutoStopDate ? 'section-25' : 'section-30',
},
};
},
......@@ -123,6 +131,14 @@ export default {
<div class="table-section" :class="tableData.date.spacing" role="columnheader">
{{ tableData.date.title }}
</div>
<div
v-if="shouldShowAutoStopDate"
class="table-section"
:class="tableData.autoStop.spacing"
role="columnheader"
>
{{ tableData.autoStop.title }}
</div>
</div>
<template v-for="(model, i) in sortedEnvironments" :model="model">
<div
......@@ -130,6 +146,7 @@ export default {
:key="`environment-item-${i}`"
:model="model"
:can-read-environment="canReadEnvironment"
:should-show-auto-stop-date="shouldShowAutoStopDate"
:table-data="tableData"
/>
......
......@@ -90,16 +90,19 @@ export default {
Flash(s__('Environments|An error occurred while fetching the environments.'));
},
postAction({ endpoint, errorMessage }) {
postAction({
endpoint,
errorMessage = s__('Environments|An error occurred while making the request.'),
}) {
if (!this.isMakingRequest) {
this.isLoading = true;
this.service
.postAction(endpoint)
.then(() => this.fetchEnvironments())
.catch(() => {
.catch(err => {
this.isLoading = false;
Flash(errorMessage || s__('Environments|An error occurred while making the request.'));
Flash(_.isFunction(errorMessage) ? errorMessage(err.response.data) : errorMessage);
});
}
},
......@@ -138,6 +141,13 @@ export default {
);
this.postAction({ endpoint: retryUrl, errorMessage });
},
cancelAutoStop(autoStopPath) {
const errorMessage = ({ message }) =>
message ||
s__('Environments|An error occurred while canceling the auto stop, please try again');
this.postAction({ endpoint: autoStopPath, errorMessage });
},
},
computed: {
......@@ -199,6 +209,8 @@ export default {
eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal);
eventHub.$on('rollbackEnvironment', this.rollbackEnvironment);
eventHub.$on('cancelAutoStop', this.cancelAutoStop);
},
beforeDestroy() {
......@@ -208,5 +220,7 @@ export default {
eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal);
eventHub.$off('rollbackEnvironment', this.rollbackEnvironment);
eventHub.$off('cancelAutoStop', this.cancelAutoStop);
},
};
......@@ -15,6 +15,9 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do
push_frontend_feature_flag(:prometheus_computed_alerts)
end
before_action do
push_frontend_feature_flag(:auto_stop_environments)
end
after_action :expire_etag_cache, only: [:cancel_auto_stop]
def index
......
- if environment.auto_stop_at? && environment.available?
= button_to cancel_auto_stop_project_environment_path(environment.project, environment), class: 'btn btn-secondary has-tooltip', title: _('Prevent environment from auto-stopping') do
= sprite_icon('thumbtack')
......@@ -32,9 +32,14 @@
= button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do
= s_('Environments|Stop environment')
.top-area
%h3.page-title= @environment.name
.nav-controls.ml-auto.my-2
.top-area.justify-content-between
.d-flex
%h3.page-title= @environment.name
- if @environment.auto_stop_at?
%p.align-self-end.prepend-left-8
= s_('Environments|Auto stops %{auto_stop_time}').html_safe % {auto_stop_time: time_ago_with_tooltip(@environment.auto_stop_at)}
.nav-controls.my-2
= render 'projects/environments/pin_button', environment: @environment
= render 'projects/environments/terminal_button', environment: @environment
= render 'projects/environments/external_url', environment: @environment
= render 'projects/environments/metrics_button', environment: @environment
......
---
title: Auto stop environments after a certain period
merge_request: 20372
author:
type: added
......@@ -6816,6 +6816,9 @@ msgstr ""
msgid "EnvironmentsDashboard|The environments dashboard provides a summary of each project's environments' status, including pipeline and alert statuses."
msgstr ""
msgid "Environments|An error occurred while canceling the auto stop, please try again"
msgstr ""
msgid "Environments|An error occurred while fetching the environments."
msgstr ""
......@@ -6834,6 +6837,12 @@ msgstr ""
msgid "Environments|Are you sure you want to stop this environment?"
msgstr ""
msgid "Environments|Auto stop in"
msgstr ""
msgid "Environments|Auto stops %{auto_stop_time}"
msgstr ""
msgid "Environments|Commit"
msgstr ""
......@@ -13329,6 +13338,9 @@ msgstr ""
msgid "Prevent approval of merge requests by merge request committers"
msgstr ""
msgid "Prevent environment from auto-stopping"
msgstr ""
msgid "Preview"
msgstr ""
......
......@@ -12,6 +12,10 @@ describe 'Environment' do
project.add_role(user, role)
end
def auto_stop_button_selector
%q{button[title="Prevent environment from auto-stopping"]}
end
describe 'environment details page' do
let!(:environment) { create(:environment, project: project) }
let!(:permissions) { }
......@@ -27,6 +31,40 @@ describe 'Environment' do
expect(page).to have_content(environment.name)
end
context 'without auto-stop' do
it 'does not show auto-stop text' do
expect(page).not_to have_content('Auto stops')
end
it 'does not show auto-stop button' do
expect(page).not_to have_selector(auto_stop_button_selector)
end
end
context 'with auto-stop' do
let!(:environment) { create(:environment, :will_auto_stop, name: 'staging', project: project) }
before do
visit_environment(environment)
end
it 'shows auto stop info' do
expect(page).to have_content('Auto stops')
end
it 'shows auto stop button' do
expect(page).to have_selector(auto_stop_button_selector)
expect(page.find(auto_stop_button_selector).find(:xpath, '..')['action']).to have_content(cancel_auto_stop_project_environment_path(environment.project, environment))
end
it 'allows user to cancel auto stop', :js do
page.find(auto_stop_button_selector).click
wait_for_all_requests
expect(page).to have_content('Auto stop successfully canceled.')
expect(page).not_to have_selector(auto_stop_button_selector)
end
end
context 'without deployments' do
it 'does not show deployments' do
expect(page).to have_content('You don\'t have any deployments right now.')
......
import { mount } from '@vue/test-utils';
import { format } from 'timeago.js';
import EnvironmentItem from '~/environments/components/environment_item.vue';
import PinComponent from '~/environments/components/environment_pin.vue';
import { environment, folder, tableData } from './mock_data';
describe('Environment item', () => {
......@@ -26,6 +28,8 @@ describe('Environment item', () => {
});
});
const findAutoStop = () => wrapper.find('.js-auto-stop');
afterEach(() => {
wrapper.destroy();
});
......@@ -77,6 +81,79 @@ describe('Environment item', () => {
expect(wrapper.find('.js-commit-component')).toBeDefined();
});
});
describe('Without auto-stop date', () => {
beforeEach(() => {
factory({
propsData: {
model: environment,
canReadEnvironment: true,
tableData,
shouldShowAutoStopDate: true,
},
});
});
it('should not render a date', () => {
expect(findAutoStop().exists()).toBe(false);
});
it('should not render the suto-stop button', () => {
expect(wrapper.find(PinComponent).exists()).toBe(false);
});
});
describe('With auto-stop date', () => {
describe('in the future', () => {
const futureDate = new Date(Date.now() + 100000);
beforeEach(() => {
factory({
propsData: {
model: {
...environment,
auto_stop_at: futureDate,
},
canReadEnvironment: true,
tableData,
shouldShowAutoStopDate: true,
},
});
});
it('renders the date', () => {
expect(findAutoStop().text()).toContain(format(futureDate));
});
it('should render the auto-stop button', () => {
expect(wrapper.find(PinComponent).exists()).toBe(true);
});
});
describe('in the past', () => {
const pastDate = new Date(Date.now() - 100000);
beforeEach(() => {
factory({
propsData: {
model: {
...environment,
auto_stop_at: pastDate,
},
canReadEnvironment: true,
tableData,
shouldShowAutoStopDate: true,
},
});
});
it('should not render a date', () => {
expect(findAutoStop().exists()).toBe(false);
});
it('should not render the suto-stop button', () => {
expect(wrapper.find(PinComponent).exists()).toBe(false);
});
});
});
});
describe('With manual actions', () => {
......
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/environments/event_hub';
import PinComponent from '~/environments/components/environment_pin.vue';
describe('Pin Component', () => {
let wrapper;
const factory = (options = {}) => {
// This destroys any wrappers created before a nested call to factory reassigns it
if (wrapper && wrapper.destroy) {
wrapper.destroy();
}
wrapper = shallowMount(PinComponent, {
...options,
});
};
const autoStopUrl = '/root/auto-stop-env-test/-/environments/38/cancel_auto_stop';
beforeEach(() => {
factory({
propsData: {
autoStopUrl,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('should render the component with thumbtack icon', () => {
expect(wrapper.find(Icon).props('name')).toBe('thumbtack');
});
it('should emit onPinClick when clicked', () => {
const eventHubSpy = jest.spyOn(eventHub, '$emit');
const button = wrapper.find(GlButton);
button.vm.$emit('click');
expect(eventHubSpy).toHaveBeenCalledWith('cancelAutoStop', autoStopUrl);
});
});
......@@ -63,6 +63,7 @@ const environment = {
log_path: 'root/ci-folders/environments/31/logs',
created_at: '2016-11-07T11:11:16.525Z',
updated_at: '2016-11-10T15:55:58.778Z',
auto_stop_at: null,
};
const folder = {
......@@ -98,6 +99,10 @@ const tableData = {
title: 'Updated',
spacing: 'section-10',
},
autoStop: {
title: 'Auto stop in',
spacing: 'section-5',
},
actions: {
spacing: 'section-25',
},
......
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