Skip to content
Snippets Groups Projects
Commit 0f780730 authored by Sarah GP's avatar Sarah GP
Browse files

Implement frontend for auto-stop envs

This is the commit for all changes, for merging

This change involves both HAML and Vue, plus refactoring
parent db3e5019
No related branches found
No related tags found
No related merge requests found
Showing
with 403 additions and 65 deletions
<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
Loading
Loading
@@ -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: {
Loading
Loading
@@ -52,7 +54,12 @@ export default {
model: {
type: Object,
required: true,
default: () => ({}),
},
shouldShowAutoStopDate: {
type: Boolean,
required: false,
default: false,
},
 
tableData: {
Loading
Loading
@@ -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)
Loading
Loading
@@ -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() {
Loading
Loading
@@ -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
Loading
Loading
@@ -353,7 +419,7 @@ export default {
*/
shouldRenderBuildName() {
return (
!this.model.isFolder &&
!this.isFolder &&
!_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.deployable)
);
Loading
Loading
@@ -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 || '';
},
 
/**
Loading
Loading
@@ -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() {
Loading
Loading
@@ -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"
Loading
Loading
@@ -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>
 
Loading
Loading
@@ -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"
Loading
Loading
@@ -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"
Loading
Loading
@@ -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
Loading
Loading
@@ -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"
Loading
Loading
@@ -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"
Loading
Loading
<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>
Loading
Loading
@@ -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 {
Loading
Loading
@@ -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,
Loading
Loading
@@ -42,6 +43,9 @@ export default {
: env,
);
},
shouldShowAutoStopDate() {
return this.glFeatures.autoStopEnvironments;
},
tableData() {
return {
// percent spacing for cols, should add up to 100
Loading
Loading
@@ -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',
},
};
},
Loading
Loading
@@ -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
Loading
Loading
@@ -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"
/>
 
Loading
Loading
Loading
Loading
@@ -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);
});
}
},
Loading
Loading
@@ -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: {
Loading
Loading
@@ -199,6 +209,8 @@ export default {
 
eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal);
eventHub.$on('rollbackEnvironment', this.rollbackEnvironment);
eventHub.$on('cancelAutoStop', this.cancelAutoStop);
},
 
beforeDestroy() {
Loading
Loading
@@ -208,5 +220,7 @@ export default {
 
eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal);
eventHub.$off('rollbackEnvironment', this.rollbackEnvironment);
eventHub.$off('cancelAutoStop', this.cancelAutoStop);
},
};
Loading
Loading
@@ -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
Loading
Loading
- 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')
Loading
Loading
@@ -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
Loading
Loading
---
title: Auto stop environments after a certain period
merge_request: 20372
author:
type: added
Loading
Loading
@@ -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 ""
 
Loading
Loading
@@ -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 ""
 
Loading
Loading
@@ -13326,6 +13335,9 @@ msgstr ""
msgid "Prevent approval of merge requests by merge request committers"
msgstr ""
 
msgid "Prevent environment from auto-stopping"
msgstr ""
msgid "Preview"
msgstr ""
 
Loading
Loading
Loading
Loading
@@ -12,6 +12,10 @@
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) { }
Loading
Loading
@@ -27,6 +31,40 @@
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.')
Loading
Loading
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', () => {
Loading
Loading
@@ -26,6 +28,8 @@ describe('Environment item', () => {
});
});
 
const findAutoStop = () => wrapper.find('.js-auto-stop');
afterEach(() => {
wrapper.destroy();
});
Loading
Loading
@@ -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', () => {
Loading
Loading
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);
});
});
Loading
Loading
@@ -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 = {
Loading
Loading
@@ -98,6 +99,10 @@ const tableData = {
title: 'Updated',
spacing: 'section-10',
},
autoStop: {
title: 'Auto stop in',
spacing: 'section-5',
},
actions: {
spacing: 'section-25',
},
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment