Merge branch 'zj-realtime-env-list' into 'master'
Realtime env list Closes #31701 See merge request !11333
This commit is contained in:
commit
8d131eb85d
15 changed files with 255 additions and 53 deletions
|
@ -1,5 +1,6 @@
|
|||
<script>
|
||||
/* global Flash */
|
||||
import Visibility from 'visibilityjs';
|
||||
import EnvironmentsService from '../services/environments_service';
|
||||
import environmentTable from './environments_table.vue';
|
||||
import EnvironmentsStore from '../stores/environments_store';
|
||||
|
@ -7,6 +8,8 @@ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
|
|||
import tablePagination from '../../vue_shared/components/table_pagination.vue';
|
||||
import '../../lib/utils/common_utils';
|
||||
import eventHub from '../event_hub';
|
||||
import Poll from '../../lib/utils/poll';
|
||||
import environmentsMixin from '../mixins/environments_mixin';
|
||||
|
||||
export default {
|
||||
|
||||
|
@ -16,6 +19,10 @@ export default {
|
|||
loadingIcon,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
environmentsMixin,
|
||||
],
|
||||
|
||||
data() {
|
||||
const environmentsData = document.querySelector('#environments-list-view').dataset;
|
||||
const store = new EnvironmentsStore();
|
||||
|
@ -35,6 +42,7 @@ export default {
|
|||
projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath,
|
||||
newEnvironmentPath: environmentsData.newEnvironmentPath,
|
||||
helpPagePath: environmentsData.helpPagePath,
|
||||
isMakingRequest: false,
|
||||
|
||||
// Pagination Properties,
|
||||
paginationInformation: {},
|
||||
|
@ -65,17 +73,43 @@ export default {
|
|||
* Toggles loading property.
|
||||
*/
|
||||
created() {
|
||||
const scope = gl.utils.getParameterByName('scope') || this.visibility;
|
||||
const page = gl.utils.getParameterByName('page') || this.pageNumber;
|
||||
|
||||
this.service = new EnvironmentsService(this.endpoint);
|
||||
|
||||
this.fetchEnvironments();
|
||||
const poll = new Poll({
|
||||
resource: this.service,
|
||||
method: 'get',
|
||||
data: { scope, page },
|
||||
successCallback: this.successCallback,
|
||||
errorCallback: this.errorCallback,
|
||||
notificationCallback: (isMakingRequest) => {
|
||||
this.isMakingRequest = isMakingRequest;
|
||||
|
||||
// We need to verify if any folder is open to also fecth it
|
||||
this.openFolders = this.store.getOpenFolders();
|
||||
},
|
||||
});
|
||||
|
||||
if (!Visibility.hidden()) {
|
||||
this.isLoading = true;
|
||||
poll.makeRequest();
|
||||
}
|
||||
|
||||
Visibility.change(() => {
|
||||
if (!Visibility.hidden()) {
|
||||
poll.restart();
|
||||
} else {
|
||||
poll.stop();
|
||||
}
|
||||
});
|
||||
|
||||
eventHub.$on('refreshEnvironments', this.fetchEnvironments);
|
||||
eventHub.$on('toggleFolder', this.toggleFolder);
|
||||
eventHub.$on('postAction', this.postAction);
|
||||
},
|
||||
|
||||
beforeDestroyed() {
|
||||
eventHub.$off('refreshEnvironments');
|
||||
eventHub.$off('toggleFolder');
|
||||
eventHub.$off('postAction');
|
||||
},
|
||||
|
@ -104,29 +138,13 @@ export default {
|
|||
|
||||
fetchEnvironments() {
|
||||
const scope = gl.utils.getParameterByName('scope') || this.visibility;
|
||||
const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber;
|
||||
const page = gl.utils.getParameterByName('page') || this.pageNumber;
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
return this.service.get(scope, pageNumber)
|
||||
.then(resp => ({
|
||||
headers: resp.headers,
|
||||
body: resp.json(),
|
||||
}))
|
||||
.then((response) => {
|
||||
this.store.storeAvailableCount(response.body.available_count);
|
||||
this.store.storeStoppedCount(response.body.stopped_count);
|
||||
this.store.storeEnvironments(response.body.environments);
|
||||
this.store.setPagination(response.headers);
|
||||
})
|
||||
.then(() => {
|
||||
this.isLoading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.isLoading = false;
|
||||
// eslint-disable-next-line no-new
|
||||
new Flash('An error occurred while fetching the environments.');
|
||||
});
|
||||
return this.service.get({ scope, page })
|
||||
.then(this.successCallback)
|
||||
.catch(this.errorCallback);
|
||||
},
|
||||
|
||||
fetchChildEnvironments(folder, folderUrl) {
|
||||
|
@ -146,9 +164,34 @@ export default {
|
|||
},
|
||||
|
||||
postAction(endpoint) {
|
||||
this.service.postAction(endpoint)
|
||||
.then(() => this.fetchEnvironments())
|
||||
.catch(() => new Flash('An error occured while making the request.'));
|
||||
if (!this.isMakingRequest) {
|
||||
this.isLoading = true;
|
||||
|
||||
this.service.postAction(endpoint)
|
||||
.then(() => this.fetchEnvironments())
|
||||
.catch(() => new Flash('An error occured while making the request.'));
|
||||
}
|
||||
},
|
||||
|
||||
successCallback(resp) {
|
||||
this.saveData(resp);
|
||||
|
||||
// If folders are open while polling we need to open them again
|
||||
if (this.openFolders.length) {
|
||||
this.openFolders.map((folder) => {
|
||||
// TODO - Move this to the backend
|
||||
const folderUrl = `${window.location.pathname}/folders/${folder.folderName}`;
|
||||
|
||||
this.store.updateFolder(folder, 'isOpen', true);
|
||||
return this.fetchChildEnvironments(folder, folderUrl);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
errorCallback() {
|
||||
this.isLoading = false;
|
||||
// eslint-disable-next-line no-new
|
||||
new Flash('An error occurred while fetching the environments.');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
<script>
|
||||
/* global Flash */
|
||||
import Visibility from 'visibilityjs';
|
||||
import EnvironmentsService from '../services/environments_service';
|
||||
import environmentTable from '../components/environments_table.vue';
|
||||
import EnvironmentsStore from '../stores/environments_store';
|
||||
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
|
||||
import tablePagination from '../../vue_shared/components/table_pagination.vue';
|
||||
import Poll from '../../lib/utils/poll';
|
||||
import eventHub from '../event_hub';
|
||||
import environmentsMixin from '../mixins/environments_mixin';
|
||||
import '../../lib/utils/common_utils';
|
||||
import '../../vue_shared/vue_resource_interceptor';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -15,6 +18,10 @@ export default {
|
|||
loadingIcon,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
environmentsMixin,
|
||||
],
|
||||
|
||||
data() {
|
||||
const environmentsData = document.querySelector('#environments-folder-list-view').dataset;
|
||||
const store = new EnvironmentsStore();
|
||||
|
@ -76,33 +83,39 @@ export default {
|
|||
*/
|
||||
created() {
|
||||
const scope = gl.utils.getParameterByName('scope') || this.visibility;
|
||||
const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber;
|
||||
const page = gl.utils.getParameterByName('page') || this.pageNumber;
|
||||
|
||||
const endpoint = `${this.endpoint}?scope=${scope}&page=${pageNumber}`;
|
||||
this.service = new EnvironmentsService(this.endpoint);
|
||||
|
||||
this.service = new EnvironmentsService(endpoint);
|
||||
const poll = new Poll({
|
||||
resource: this.service,
|
||||
method: 'get',
|
||||
data: { scope, page },
|
||||
successCallback: this.successCallback,
|
||||
errorCallback: this.errorCallback,
|
||||
notificationCallback: (isMakingRequest) => {
|
||||
this.isMakingRequest = isMakingRequest;
|
||||
},
|
||||
});
|
||||
|
||||
this.isLoading = true;
|
||||
if (!Visibility.hidden()) {
|
||||
this.isLoading = true;
|
||||
poll.makeRequest();
|
||||
}
|
||||
|
||||
return this.service.get()
|
||||
.then(resp => ({
|
||||
headers: resp.headers,
|
||||
body: resp.json(),
|
||||
}))
|
||||
.then((response) => {
|
||||
this.store.storeAvailableCount(response.body.available_count);
|
||||
this.store.storeStoppedCount(response.body.stopped_count);
|
||||
this.store.storeEnvironments(response.body.environments);
|
||||
this.store.setPagination(response.headers);
|
||||
})
|
||||
.then(() => {
|
||||
this.isLoading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.isLoading = false;
|
||||
// eslint-disable-next-line no-new
|
||||
new Flash('An error occurred while fetching the environments.', 'alert');
|
||||
});
|
||||
Visibility.change(() => {
|
||||
if (!Visibility.hidden()) {
|
||||
poll.restart();
|
||||
} else {
|
||||
poll.stop();
|
||||
}
|
||||
});
|
||||
|
||||
eventHub.$on('postAction', this.postAction);
|
||||
},
|
||||
|
||||
beforeDestroyed() {
|
||||
eventHub.$off('postAction');
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
@ -117,6 +130,37 @@ export default {
|
|||
gl.utils.visitUrl(param);
|
||||
return param;
|
||||
},
|
||||
|
||||
fetchEnvironments() {
|
||||
const scope = gl.utils.getParameterByName('scope') || this.visibility;
|
||||
const page = gl.utils.getParameterByName('page') || this.pageNumber;
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
return this.service.get({ scope, page })
|
||||
.then(this.successCallback)
|
||||
.catch(this.errorCallback);
|
||||
},
|
||||
|
||||
successCallback(resp) {
|
||||
this.saveData(resp);
|
||||
},
|
||||
|
||||
errorCallback() {
|
||||
this.isLoading = false;
|
||||
// eslint-disable-next-line no-new
|
||||
new Flash('An error occurred while fetching the environments.');
|
||||
},
|
||||
|
||||
postAction(endpoint) {
|
||||
if (!this.isMakingRequest) {
|
||||
this.isLoading = true;
|
||||
|
||||
this.service.postAction(endpoint)
|
||||
.then(() => this.fetchEnvironments())
|
||||
.catch(() => new Flash('An error occured while making the request.'));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
export default {
|
||||
methods: {
|
||||
saveData(resp) {
|
||||
const response = {
|
||||
headers: resp.headers,
|
||||
body: resp.json(),
|
||||
};
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
this.store.storeAvailableCount(response.body.available_count);
|
||||
this.store.storeStoppedCount(response.body.stopped_count);
|
||||
this.store.storeEnvironments(response.body.environments);
|
||||
this.store.setPagination(response.headers);
|
||||
},
|
||||
},
|
||||
};
|
|
@ -10,7 +10,8 @@ export default class EnvironmentsService {
|
|||
this.folderResults = 3;
|
||||
}
|
||||
|
||||
get(scope, page) {
|
||||
get(options = {}) {
|
||||
const { scope, page } = options;
|
||||
return this.environments.get({ scope, page });
|
||||
}
|
||||
|
||||
|
|
|
@ -153,4 +153,10 @@ export default class EnvironmentsStore {
|
|||
return updatedEnvironments;
|
||||
}
|
||||
|
||||
getOpenFolders() {
|
||||
const environments = this.state.environments;
|
||||
|
||||
return environments.filter(env => env.isFolder && env.isOpen);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -15,6 +15,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController
|
|||
respond_to do |format|
|
||||
format.html
|
||||
format.json do
|
||||
Gitlab::PollingInterval.set_header(response, interval: 3_000)
|
||||
|
||||
render json: {
|
||||
environments: EnvironmentSerializer
|
||||
.new(project: @project, current_user: @current_user)
|
||||
|
|
|
@ -12,6 +12,7 @@ class Deployment < ActiveRecord::Base
|
|||
delegate :name, to: :environment, prefix: true
|
||||
|
||||
after_create :create_ref
|
||||
after_create :invalidate_cache
|
||||
|
||||
def commit
|
||||
project.commit(sha)
|
||||
|
@ -33,6 +34,10 @@ class Deployment < ActiveRecord::Base
|
|||
project.repository.create_ref(ref, ref_path)
|
||||
end
|
||||
|
||||
def invalidate_cache
|
||||
environment.expire_etag_cache
|
||||
end
|
||||
|
||||
def manual_actions
|
||||
@manual_actions ||= deployable.try(:other_actions)
|
||||
end
|
||||
|
|
|
@ -57,6 +57,10 @@ class Environment < ActiveRecord::Base
|
|||
|
||||
state :available
|
||||
state :stopped
|
||||
|
||||
after_transition do |environment|
|
||||
environment.expire_etag_cache
|
||||
end
|
||||
end
|
||||
|
||||
def predefined_variables
|
||||
|
@ -196,6 +200,18 @@ class Environment < ActiveRecord::Base
|
|||
[external_url, public_path].join('/')
|
||||
end
|
||||
|
||||
def expire_etag_cache
|
||||
Gitlab::EtagCaching::Store.new.tap do |store|
|
||||
store.touch(etag_cache_key)
|
||||
end
|
||||
end
|
||||
|
||||
def etag_cache_key
|
||||
Gitlab::Routing.url_helpers.namespace_project_environments_path(
|
||||
project.namespace,
|
||||
project)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Slugifying a name may remove the uniqueness guarantee afforded by it being
|
||||
|
|
4
changelogs/unreleased/zj-realtime-env-list.yml
Normal file
4
changelogs/unreleased/zj-realtime-env-list.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Make environment table realtime
|
||||
merge_request: 11333
|
||||
author:
|
|
@ -9,9 +9,11 @@ module Gitlab
|
|||
# - Ending in `noteable/issue/<id>/notes` for the `issue_notes` route
|
||||
# - Ending in `issues/id`/realtime_changes` for the `issue_title` route
|
||||
USED_IN_ROUTES = %w[noteable issue notes issues realtime_changes
|
||||
commit pipelines merge_requests new].freeze
|
||||
commit pipelines merge_requests new
|
||||
environments].freeze
|
||||
RESERVED_WORDS = Gitlab::PathRegex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES
|
||||
RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS.map(&Regexp.method(:escape)))
|
||||
|
||||
ROUTES = [
|
||||
Gitlab::EtagCaching::Router::Route.new(
|
||||
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/noteable/issue/\d+/notes\z),
|
||||
|
@ -40,6 +42,10 @@ module Gitlab
|
|||
Gitlab::EtagCaching::Router::Route.new(
|
||||
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines/\d+\.json\z),
|
||||
'project_pipeline'
|
||||
),
|
||||
Gitlab::EtagCaching::Router::Route.new(
|
||||
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/environments\.json\z),
|
||||
'environments'
|
||||
)
|
||||
].freeze
|
||||
|
||||
|
|
|
@ -57,6 +57,11 @@ describe Projects::EnvironmentsController do
|
|||
expect(json_response['available_count']).to eq 3
|
||||
expect(json_response['stopped_count']).to eq 1
|
||||
end
|
||||
|
||||
it 'sets the polling interval header' do
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.headers['Poll-Interval']).to eq("3000")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when requesting stopped environments scope' do
|
||||
|
|
|
@ -123,4 +123,13 @@ describe('Store', () => {
|
|||
expect(store.state.paginationInformation).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOpenFolders', () => {
|
||||
it('should return open folder', () => {
|
||||
store.storeEnvironments(serverData);
|
||||
|
||||
store.toggleFolder(store.state.environments[1]);
|
||||
expect(store.getOpenFolders()[0]).toEqual(store.state.environments[1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -77,6 +77,17 @@ describe Gitlab::EtagCaching::Router do
|
|||
expect(result).to be_blank
|
||||
end
|
||||
|
||||
it 'matches the environments path' do
|
||||
env = build_env(
|
||||
'/my-group/my-project/environments.json'
|
||||
)
|
||||
|
||||
result = described_class.match(env)
|
||||
expect(result).to be_present
|
||||
|
||||
expect(result.name).to eq 'environments'
|
||||
end
|
||||
|
||||
it 'matches pipeline#show endpoint' do
|
||||
env = build_env(
|
||||
'/my-group/my-project/pipelines/2.json'
|
||||
|
|
|
@ -16,6 +16,19 @@ describe Deployment, models: true do
|
|||
it { is_expected.to validate_presence_of(:ref) }
|
||||
it { is_expected.to validate_presence_of(:sha) }
|
||||
|
||||
describe 'after_create callbacks' do
|
||||
let(:environment) { create(:environment) }
|
||||
let(:store) { Gitlab::EtagCaching::Store.new }
|
||||
|
||||
it 'invalidates the environment etag cache' do
|
||||
old_value = store.get(environment.etag_cache_key)
|
||||
|
||||
create(:deployment, environment: environment)
|
||||
|
||||
expect(store.get(environment.etag_cache_key)).not_to eq(old_value)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#includes_commit?' do
|
||||
let(:project) { create(:project, :repository) }
|
||||
let(:environment) { create(:environment, project: project) }
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Environment, models: true do
|
||||
let(:project) { create(:empty_project) }
|
||||
set(:project) { create(:empty_project) }
|
||||
subject(:environment) { create(:environment, project: project) }
|
||||
|
||||
it { is_expected.to belong_to(:project) }
|
||||
|
@ -34,6 +34,26 @@ describe Environment, models: true do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'state machine' do
|
||||
it 'invalidates the cache after a change' do
|
||||
expect(environment).to receive(:expire_etag_cache)
|
||||
|
||||
environment.stop
|
||||
end
|
||||
end
|
||||
|
||||
describe '#expire_etag_cache' do
|
||||
let(:store) { Gitlab::EtagCaching::Store.new }
|
||||
|
||||
it 'changes the cached value' do
|
||||
old_value = store.get(environment.etag_cache_key)
|
||||
|
||||
environment.stop
|
||||
|
||||
expect(store.get(environment.etag_cache_key)).not_to eq(old_value)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#nullify_external_url' do
|
||||
it 'replaces a blank url with nil' do
|
||||
env = build(:environment, external_url: "")
|
||||
|
|
Loading…
Reference in a new issue