Commit 8d131eb8 authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch 'zj-realtime-env-list' into 'master'

Realtime env list

Closes #31701

See merge request !11333
parents b92e3d74 696b0395
<script> <script>
/* global Flash */ /* global Flash */
import Visibility from 'visibilityjs';
import EnvironmentsService from '../services/environments_service'; import EnvironmentsService from '../services/environments_service';
import environmentTable from './environments_table.vue'; import environmentTable from './environments_table.vue';
import EnvironmentsStore from '../stores/environments_store'; import EnvironmentsStore from '../stores/environments_store';
...@@ -7,6 +8,8 @@ import loadingIcon from '../../vue_shared/components/loading_icon.vue'; ...@@ -7,6 +8,8 @@ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tablePagination from '../../vue_shared/components/table_pagination.vue'; import tablePagination from '../../vue_shared/components/table_pagination.vue';
import '../../lib/utils/common_utils'; import '../../lib/utils/common_utils';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import Poll from '../../lib/utils/poll';
import environmentsMixin from '../mixins/environments_mixin';
export default { export default {
...@@ -16,6 +19,10 @@ export default { ...@@ -16,6 +19,10 @@ export default {
loadingIcon, loadingIcon,
}, },
mixins: [
environmentsMixin,
],
data() { data() {
const environmentsData = document.querySelector('#environments-list-view').dataset; const environmentsData = document.querySelector('#environments-list-view').dataset;
const store = new EnvironmentsStore(); const store = new EnvironmentsStore();
...@@ -35,6 +42,7 @@ export default { ...@@ -35,6 +42,7 @@ export default {
projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath, projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath,
newEnvironmentPath: environmentsData.newEnvironmentPath, newEnvironmentPath: environmentsData.newEnvironmentPath,
helpPagePath: environmentsData.helpPagePath, helpPagePath: environmentsData.helpPagePath,
isMakingRequest: false,
// Pagination Properties, // Pagination Properties,
paginationInformation: {}, paginationInformation: {},
...@@ -65,17 +73,43 @@ export default { ...@@ -65,17 +73,43 @@ export default {
* Toggles loading property. * Toggles loading property.
*/ */
created() { created() {
const scope = gl.utils.getParameterByName('scope') || this.visibility;
const page = gl.utils.getParameterByName('page') || this.pageNumber;
this.service = new EnvironmentsService(this.endpoint); 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('toggleFolder', this.toggleFolder);
eventHub.$on('postAction', this.postAction); eventHub.$on('postAction', this.postAction);
}, },
beforeDestroyed() { beforeDestroyed() {
eventHub.$off('refreshEnvironments');
eventHub.$off('toggleFolder'); eventHub.$off('toggleFolder');
eventHub.$off('postAction'); eventHub.$off('postAction');
}, },
...@@ -104,29 +138,13 @@ export default { ...@@ -104,29 +138,13 @@ export default {
fetchEnvironments() { fetchEnvironments() {
const scope = gl.utils.getParameterByName('scope') || this.visibility; 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; this.isLoading = true;
return this.service.get(scope, pageNumber) return this.service.get({ scope, page })
.then(resp => ({ .then(this.successCallback)
headers: resp.headers, .catch(this.errorCallback);
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.');
});
}, },
fetchChildEnvironments(folder, folderUrl) { fetchChildEnvironments(folder, folderUrl) {
...@@ -146,9 +164,34 @@ export default { ...@@ -146,9 +164,34 @@ export default {
}, },
postAction(endpoint) { postAction(endpoint) {
if (!this.isMakingRequest) {
this.isLoading = true;
this.service.postAction(endpoint) this.service.postAction(endpoint)
.then(() => this.fetchEnvironments()) .then(() => this.fetchEnvironments())
.catch(() => new Flash('An error occured while making the request.')); .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.');
}, },
}, },
}; };
......
<script> <script>
/* global Flash */ /* global Flash */
import Visibility from 'visibilityjs';
import EnvironmentsService from '../services/environments_service'; import EnvironmentsService from '../services/environments_service';
import environmentTable from '../components/environments_table.vue'; import environmentTable from '../components/environments_table.vue';
import EnvironmentsStore from '../stores/environments_store'; import EnvironmentsStore from '../stores/environments_store';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tablePagination from '../../vue_shared/components/table_pagination.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 '../../lib/utils/common_utils';
import '../../vue_shared/vue_resource_interceptor';
export default { export default {
components: { components: {
...@@ -15,6 +18,10 @@ export default { ...@@ -15,6 +18,10 @@ export default {
loadingIcon, loadingIcon,
}, },
mixins: [
environmentsMixin,
],
data() { data() {
const environmentsData = document.querySelector('#environments-folder-list-view').dataset; const environmentsData = document.querySelector('#environments-folder-list-view').dataset;
const store = new EnvironmentsStore(); const store = new EnvironmentsStore();
...@@ -76,33 +83,39 @@ export default { ...@@ -76,33 +83,39 @@ export default {
*/ */
created() { created() {
const scope = gl.utils.getParameterByName('scope') || this.visibility; 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;
},
});
if (!Visibility.hidden()) {
this.isLoading = true; this.isLoading = true;
poll.makeRequest();
return this.service.get() }
.then(resp => ({
headers: resp.headers, Visibility.change(() => {
body: resp.json(), if (!Visibility.hidden()) {
})) poll.restart();
.then((response) => { } else {
this.store.storeAvailableCount(response.body.available_count); poll.stop();
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');
}); });
eventHub.$on('postAction', this.postAction);
},
beforeDestroyed() {
eventHub.$off('postAction');
}, },
methods: { methods: {
...@@ -117,6 +130,37 @@ export default { ...@@ -117,6 +130,37 @@ export default {
gl.utils.visitUrl(param); gl.utils.visitUrl(param);
return 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> </script>
......
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 { ...@@ -10,7 +10,8 @@ export default class EnvironmentsService {
this.folderResults = 3; this.folderResults = 3;
} }
get(scope, page) { get(options = {}) {
const { scope, page } = options;
return this.environments.get({ scope, page }); return this.environments.get({ scope, page });
} }
......
...@@ -153,4 +153,10 @@ export default class EnvironmentsStore { ...@@ -153,4 +153,10 @@ export default class EnvironmentsStore {
return updatedEnvironments; 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 ...@@ -15,6 +15,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController
respond_to do |format| respond_to do |format|
format.html format.html
format.json do format.json do
Gitlab::PollingInterval.set_header(response, interval: 3_000)
render json: { render json: {
environments: EnvironmentSerializer environments: EnvironmentSerializer
.new(project: @project, current_user: @current_user) .new(project: @project, current_user: @current_user)
......
...@@ -12,6 +12,7 @@ class Deployment < ActiveRecord::Base ...@@ -12,6 +12,7 @@ class Deployment < ActiveRecord::Base
delegate :name, to: :environment, prefix: true delegate :name, to: :environment, prefix: true
after_create :create_ref after_create :create_ref
after_create :invalidate_cache
def commit def commit
project.commit(sha) project.commit(sha)
...@@ -33,6 +34,10 @@ class Deployment < ActiveRecord::Base ...@@ -33,6 +34,10 @@ class Deployment < ActiveRecord::Base
project.repository.create_ref(ref, ref_path) project.repository.create_ref(ref, ref_path)
end end
def invalidate_cache
environment.expire_etag_cache
end
def manual_actions def manual_actions
@manual_actions ||= deployable.try(:other_actions) @manual_actions ||= deployable.try(:other_actions)
end end
......
...@@ -57,6 +57,10 @@ class Environment < ActiveRecord::Base ...@@ -57,6 +57,10 @@ class Environment < ActiveRecord::Base
state :available state :available
state :stopped state :stopped
after_transition do |environment|
environment.expire_etag_cache
end
end end
def predefined_variables def predefined_variables
...@@ -196,6 +200,18 @@ class Environment < ActiveRecord::Base ...@@ -196,6 +200,18 @@ class Environment < ActiveRecord::Base
[external_url, public_path].join('/') [external_url, public_path].join('/')
end 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 private
# Slugifying a name may remove the uniqueness guarantee afforded by it being # Slugifying a name may remove the uniqueness guarantee afforded by it being
......
---
title: Make environment table realtime
merge_request: 11333
author:
...@@ -9,9 +9,11 @@ module Gitlab ...@@ -9,9 +9,11 @@ module Gitlab
# - Ending in `noteable/issue/<id>/notes` for the `issue_notes` route # - Ending in `noteable/issue/<id>/notes` for the `issue_notes` route
# - Ending in `issues/id`/realtime_changes` for the `issue_title` route # - Ending in `issues/id`/realtime_changes` for the `issue_title` route
USED_IN_ROUTES = %w[noteable issue notes issues realtime_changes 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 = Gitlab::PathRegex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES
RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS.map(&Regexp.method(:escape))) RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS.map(&Regexp.method(:escape)))
ROUTES = [ ROUTES = [
Gitlab::EtagCaching::Router::Route.new( Gitlab::EtagCaching::Router::Route.new(
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/noteable/issue/\d+/notes\z), %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/noteable/issue/\d+/notes\z),
...@@ -40,6 +42,10 @@ module Gitlab ...@@ -40,6 +42,10 @@ module Gitlab
Gitlab::EtagCaching::Router::Route.new( Gitlab::EtagCaching::Router::Route.new(
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines/\d+\.json\z), %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines/\d+\.json\z),
'project_pipeline' 'project_pipeline'
),
Gitlab::EtagCaching::Router::Route.new(
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/environments\.json\z),
'environments'
) )
].freeze ].freeze
......
...@@ -57,6 +57,11 @@ describe Projects::EnvironmentsController do ...@@ -57,6 +57,11 @@ describe Projects::EnvironmentsController do
expect(json_response['available_count']).to eq 3 expect(json_response['available_count']).to eq 3
expect(json_response['stopped_count']).to eq 1 expect(json_response['stopped_count']).to eq 1
end 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 end
context 'when requesting stopped environments scope' do context 'when requesting stopped environments scope' do
......
...@@ -123,4 +123,13 @@ describe('Store', () => { ...@@ -123,4 +123,13 @@ describe('Store', () => {
expect(store.state.paginationInformation).toEqual(expectedResult); 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 ...@@ -77,6 +77,17 @@ describe Gitlab::EtagCaching::Router do
expect(result).to be_blank expect(result).to be_blank
end 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 it 'matches pipeline#show endpoint' do
env = build_env( env = build_env(
'/my-group/my-project/pipelines/2.json' '/my-group/my-project/pipelines/2.json'
......
...@@ -16,6 +16,19 @@ describe Deployment, models: true do ...@@ -16,6 +16,19 @@ describe Deployment, models: true do
it { is_expected.to validate_presence_of(:ref) } it { is_expected.to validate_presence_of(:ref) }
it { is_expected.to validate_presence_of(:sha) } 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 describe '#includes_commit?' do
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
let(:environment) { create(:environment, project: project) } let(:environment) { create(:environment, project: project) }
......
require 'spec_helper' require 'spec_helper'
describe Environment, models: true do describe Environment, models: true do
let(:project) { create(:empty_project) } set(:project) { create(:empty_project) }
subject(:environment) { create(:environment, project: project) } subject(:environment) { create(:environment, project: project) }
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
...@@ -34,6 +34,26 @@ describe Environment, models: true do ...@@ -34,6 +34,26 @@ describe Environment, models: true do
end end
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 describe '#nullify_external_url' do
it 'replaces a blank url with nil' do it 'replaces a blank url with nil' do
env = build(:environment, external_url: "") env = build(:environment, external_url: "")
......
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