Commit e4f18b14 authored by Rémy Coutable's avatar Rémy Coutable

Merge branch 'ee-cluster-environments-polling' into 'master'

Implement Cluster Environments polling

See merge request gitlab-org/gitlab!16316
parents afc723b0 26f19542
...@@ -111,15 +111,25 @@ export default class Clusters { ...@@ -111,15 +111,25 @@ export default class Clusters {
this.initApplications(clusterType); this.initApplications(clusterType);
this.initEnvironments(); this.initEnvironments();
if (clusterEnvironmentsPath) { if (clusterEnvironmentsPath && this.environments) {
this.fetchEnvironments(); this.store.toggleFetchEnvironments(true);
this.initPolling(
'fetchClusterEnvironments',
data => this.handleClusterEnvironmentsSuccess(data),
() => this.handleEnvironmentsPollError(),
);
} }
this.updateContainer(null, this.store.state.status, this.store.state.statusReason); this.updateContainer(null, this.store.state.status, this.store.state.statusReason);
this.addListeners(); this.addListeners();
if (statusPath && !this.environments) { if (statusPath && !this.environments) {
this.initPolling(); this.initPolling(
'fetchClusterStatus',
data => this.handleClusterStatusSuccess(data),
() => this.handlePollError(),
);
} }
} }
...@@ -179,16 +189,9 @@ export default class Clusters { ...@@ -179,16 +189,9 @@ export default class Clusters {
}); });
} }
fetchEnvironments() { handleClusterEnvironmentsSuccess(data) {
this.store.toggleFetchEnvironments(true); this.store.toggleFetchEnvironments(false);
this.store.updateEnvironments(data.data);
this.service
.fetchClusterEnvironments()
.then(data => {
this.store.toggleFetchEnvironments(false);
this.store.updateEnvironments(data.data);
})
.catch(() => Clusters.handleError());
} }
static initDismissableCallout() { static initDismissableCallout() {
...@@ -224,21 +227,16 @@ export default class Clusters { ...@@ -224,21 +227,16 @@ export default class Clusters {
eventHub.$off('uninstallApplication'); eventHub.$off('uninstallApplication');
} }
initPolling() { initPolling(method, successCallback, errorCallback) {
this.poll = new Poll({ this.poll = new Poll({
resource: this.service, resource: this.service,
method: 'fetchData', method,
successCallback: data => this.handleSuccess(data), successCallback,
errorCallback: () => Clusters.handleError(), errorCallback,
}); });
if (!Visibility.hidden()) { if (!Visibility.hidden()) {
this.poll.makeRequest(); this.poll.makeRequest();
} else {
this.service
.fetchData()
.then(data => this.handleSuccess(data))
.catch(() => Clusters.handleError());
} }
Visibility.change(() => { Visibility.change(() => {
...@@ -250,11 +248,21 @@ export default class Clusters { ...@@ -250,11 +248,21 @@ export default class Clusters {
}); });
} }
handlePollError() {
this.constructor.handleError();
}
handleEnvironmentsPollError() {
this.store.toggleFetchEnvironments(false);
this.handlePollError();
}
static handleError() { static handleError() {
Flash(s__('ClusterIntegration|Something went wrong on our end.')); Flash(s__('ClusterIntegration|Something went wrong on our end.'));
} }
handleSuccess(data) { handleClusterStatusSuccess(data) {
const prevStatus = this.store.state.status; const prevStatus = this.store.state.status;
const prevApplicationMap = Object.assign({}, this.store.state.applications); const prevApplicationMap = Object.assign({}, this.store.state.applications);
......
...@@ -17,7 +17,7 @@ export default class ClusterService { ...@@ -17,7 +17,7 @@ export default class ClusterService {
}; };
} }
fetchData() { fetchClusterStatus() {
return axios.get(this.options.endpoint); return axios.get(this.options.endpoint);
} }
......
...@@ -218,6 +218,7 @@ export default class ClusterStore { ...@@ -218,6 +218,7 @@ export default class ClusterStore {
environmentPath: environment.environment_path, environmentPath: environment.environment_path,
lastDeployment: environment.last_deployment, lastDeployment: environment.last_deployment,
rolloutStatus: { rolloutStatus: {
status: environment.rollout_status ? environment.rollout_status.status : null,
instances: environment.rollout_status ? environment.rollout_status.instances : [], instances: environment.rollout_status ? environment.rollout_status.instances : [],
}, },
updatedAt: environment.updated_at, updatedAt: environment.updated_at,
......
...@@ -137,6 +137,10 @@ The result will then be: ...@@ -137,6 +137,10 @@ The result will then be:
- The Staging cluster will be used for the `deploy to staging` job. - The Staging cluster will be used for the `deploy to staging` job.
- The Production cluster will be used for the `deploy to production` job. - The Production cluster will be used for the `deploy to production` job.
## Cluster environments **(PREMIUM)**
Please see the documentation for [cluster environments](../../clusters/environments.md).
## Security of Runners ## Security of Runners
For important information about securely configuring GitLab Runners, see For important information about securely configuring GitLab Runners, see
......
...@@ -85,6 +85,7 @@ export default { ...@@ -85,6 +85,7 @@ export default {
}, },
methods: { methods: {
hasInstances: rolloutStatus => rolloutStatus.instances && rolloutStatus.instances.length, hasInstances: rolloutStatus => rolloutStatus.instances && rolloutStatus.instances.length,
isLoadingRollout: rolloutStatus => rolloutStatus.status === 'loading',
}, },
}; };
</script> </script>
...@@ -127,7 +128,14 @@ export default { ...@@ -127,7 +128,14 @@ export default {
</template> </template>
<template slot="rolloutStatus" slot-scope="row"> <template slot="rolloutStatus" slot-scope="row">
<div v-if="hasInstances(row.item.rolloutStatus)" class="d-flex flex-wrap flex-row"> <!-- Loading Rollout -->
<gl-loading-icon
v-if="isLoadingRollout(row.item.rolloutStatus)"
class="d-inline-flex mt-1"
/>
<!-- Rollout Instances -->
<div v-else-if="hasInstances(row.item.rolloutStatus)" class="d-flex flex-wrap flex-row">
<template v-for="(instance, i) in row.item.rolloutStatus.instances"> <template v-for="(instance, i) in row.item.rolloutStatus.instances">
<deployment-instance <deployment-instance
:key="i" :key="i"
......
...@@ -3,9 +3,17 @@ ...@@ -3,9 +3,17 @@
module EE module EE
module Groups module Groups
module ClustersController module ClustersController
extend ActiveSupport::Concern
prepended do
before_action :expire_etag_cache, only: [:show]
end
def environments def environments
respond_to do |format| respond_to do |format|
format.json do format.json do
::Gitlab::PollingInterval.set_header(response, interval: 5_000)
environments = ::Clusters::EnvironmentsFinder.new(cluster, current_user).execute environments = ::Clusters::EnvironmentsFinder.new(cluster, current_user).execute
render json: serialize_environments( render json: serialize_environments(
...@@ -19,6 +27,15 @@ module EE ...@@ -19,6 +27,15 @@ module EE
private private
def expire_etag_cache
return if request.format.json?
# this forces to reload json content
::Gitlab::EtagCaching::Store.new.tap do |store|
store.touch(environments_group_cluster_path(group, cluster))
end
end
def serialize_environments(environments, request, response) def serialize_environments(environments, request, response)
::Clusters::EnvironmentSerializer ::Clusters::EnvironmentSerializer
.new(cluster: cluster, current_user: current_user) .new(cluster: cluster, current_user: current_user)
......
...@@ -46,6 +46,18 @@ module EE ...@@ -46,6 +46,18 @@ module EE
::Gitlab::EtagCaching::Store.new.tap do |store| ::Gitlab::EtagCaching::Store.new.tap do |store|
store.touch( store.touch(
::Gitlab::Routing.url_helpers.project_environments_path(project, format: :json)) ::Gitlab::Routing.url_helpers.project_environments_path(project, format: :json))
store.touch(cluster_environments_etag_key) if cluster_environments_etag_key
end
end
def cluster_environments_etag_key
strong_memoize(:cluster_environments_key) do
cluster = last_deployment&.cluster
if cluster&.group_type?
::Gitlab::Routing.url_helpers.environments_group_cluster_path(cluster.group, cluster)
end
end end
end end
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
- is_group_type = @cluster.cluster_type.in? 'group_type' - is_group_type = @cluster.cluster_type.in? 'group_type'
- is_creating = @cluster.status_name.in? %i/scheduled creating/ - is_creating = @cluster.status_name.in? %i/scheduled creating/
- if is_group_type && !is_creating && Feature.enabled?(:view_group_cluster_deployments) - if is_group_type && !is_creating
.js-toggle-container .js-toggle-container
%ul.nav-links.mobile-separator.nav.nav-tabs{ role: 'tablist' } %ul.nav-links.mobile-separator.nav.nav-tabs{ role: 'tablist' }
%li.nav-item{ role: 'presentation' } %li.nav-item{ role: 'presentation' }
......
---
title: Implement Cluster Environments polling
merge_request: 16316
author:
type: added
...@@ -98,7 +98,7 @@ describe Groups::ClustersController do ...@@ -98,7 +98,7 @@ describe Groups::ClustersController do
create(:deployment, :success, cluster: cluster) create(:deployment, :success, cluster: cluster)
end end
def go def get_cluster_environments
get :environments, get :environments,
params: { params: {
group_id: group.to_param, group_id: group.to_param,
...@@ -109,21 +109,44 @@ describe Groups::ClustersController do ...@@ -109,21 +109,44 @@ describe Groups::ClustersController do
describe 'functionality' do describe 'functionality' do
it 'responds successfully' do it 'responds successfully' do
go get_cluster_environments
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['Poll-Interval']).to eq("5000")
end end
end end
describe 'security' do describe 'security' do
it { expect { go }.to be_allowed_for(:admin) } it { expect { get_cluster_environments }.to be_allowed_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(group) } it { expect { get_cluster_environments }.to be_allowed_for(:owner).of(group) }
it { expect { go }.to be_allowed_for(:maintainer).of(group) } it { expect { get_cluster_environments }.to be_allowed_for(:maintainer).of(group) }
it { expect { go }.to be_denied_for(:developer).of(group) } it { expect { get_cluster_environments }.to be_denied_for(:developer).of(group) }
it { expect { go }.to be_denied_for(:reporter).of(group) } it { expect { get_cluster_environments }.to be_denied_for(:reporter).of(group) }
it { expect { go }.to be_denied_for(:guest).of(group) } it { expect { get_cluster_environments }.to be_denied_for(:guest).of(group) }
it { expect { go }.to be_denied_for(:user) } it { expect { get_cluster_environments }.to be_denied_for(:user) }
it { expect { go }.to be_denied_for(:external) } it { expect { get_cluster_environments }.to be_denied_for(:external) }
end
end
describe 'GET show' do
let(:cluster) { create(:cluster_for_group, groups: [group]) }
def get_cluster
get :show,
params: {
group_id: group,
id: cluster
}
end
it 'expires etag cache to force reload environments list' do
expect_next_instance_of(Gitlab::EtagCaching::Store) do |store|
expect(store).to receive(:touch)
.with(environments_group_cluster_path(cluster.group, cluster))
.and_call_original
end
get_cluster
end end
end end
end end
import { createLocalVue, mount } from '@vue/test-utils'; import { createLocalVue, mount } from '@vue/test-utils';
import Environments from 'ee/clusters/components/environments.vue'; import Environments from 'ee/clusters/components/environments.vue';
import { GlTable, GlEmptyState } from '@gitlab/ui'; import { GlTable, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import environments from './mock_data'; import environments from './mock_data';
...@@ -58,7 +58,7 @@ describe('Environments', () => { ...@@ -58,7 +58,7 @@ describe('Environments', () => {
}); });
it('renders the correct table headers', () => { it('renders the correct table headers', () => {
const tableHeaders = ['Project', 'Environment', 'Job', 'Pods in use 3', 'Last updated']; const tableHeaders = ['Project', 'Environment', 'Job', `Pods in use 2`, 'Last updated'];
const headers = table.findAll('th'); const headers = table.findAll('th');
expect(headers.length).toBe(tableHeaders.length); expect(headers.length).toBe(tableHeaders.length);
...@@ -73,6 +73,18 @@ describe('Environments', () => { ...@@ -73,6 +73,18 @@ describe('Environments', () => {
tableRows = table.findAll('tbody tr'); tableRows = table.findAll('tbody tr');
}); });
it('renders a loader if the rollout status is loading', () => {
environments.forEach((environment, i) => {
const { status } = environment.rolloutStatus;
if (status === 'loading') {
const loader = tableRows.at(i).find(GlLoadingIcon);
expect(loader.exists()).toBe(true);
}
});
});
it('renders deployment instances', () => { it('renders deployment instances', () => {
environments.forEach((environment, i) => { environments.forEach((environment, i) => {
const { instances } = environment.rolloutStatus; const { instances } = environment.rolloutStatus;
...@@ -86,9 +98,9 @@ describe('Environments', () => { ...@@ -86,9 +98,9 @@ describe('Environments', () => {
'Deploy progress not found. To see pods, ensure your environment matches deploy board criteria.'; 'Deploy progress not found. To see pods, ensure your environment matches deploy board criteria.';
environments.forEach((environment, i) => { environments.forEach((environment, i) => {
const { instances } = environment.rolloutStatus; const { status, instances } = environment.rolloutStatus;
if (instances.length === 0) { if (status !== 'loading' && instances.length === 0) {
const emptyState = tableRows.at(i).find('.deployments-empty'); const emptyState = tableRows.at(i).find('.deployments-empty');
const emptyStateIcon = emptyState.find(Icon); const emptyStateIcon = emptyState.find(Icon);
......
...@@ -18,9 +18,8 @@ export default [ ...@@ -18,9 +18,8 @@ export default [
name: 'staging', name: 'staging',
lastDeployment: { id: '456' }, lastDeployment: { id: '456' },
rolloutStatus: { rolloutStatus: {
instances: [ status: 'loading',
{ status: 'running', pod_name: 'some pod', tooltip: 'success', track: '1', stable: true }, instances: [],
],
}, },
updatedAt: '2019-01-13T12:25:24.098Z', updatedAt: '2019-01-13T12:25:24.098Z',
}, },
......
...@@ -163,6 +163,28 @@ describe Environment, :use_clean_rails_memory_store_caching do ...@@ -163,6 +163,28 @@ describe Environment, :use_clean_rails_memory_store_caching do
subject subject
end end
context 'with a group cluster' do
let(:cluster) { create(:cluster, :group) }
before do
create(:deployment, :success, environment: environment, cluster: cluster)
end
it 'expires the environments path for the group cluster' do
expect_next_instance_of(Gitlab::EtagCaching::Store) do |store|
expect(store).to receive(:touch)
.with(::Gitlab::Routing.url_helpers.project_environments_path(project, format: :json))
.and_call_original
expect(store).to receive(:touch)
.with(::Gitlab::Routing.url_helpers.environments_group_cluster_path(cluster.group, cluster))
.and_call_original
end
subject
end
end
end end
describe '#rollout_status' do describe '#rollout_status' do
......
...@@ -52,6 +52,10 @@ module Gitlab ...@@ -52,6 +52,10 @@ module Gitlab
%r(#{RESERVED_WORDS_PREFIX}/builds/\d+\.json\z), %r(#{RESERVED_WORDS_PREFIX}/builds/\d+\.json\z),
'project_build' 'project_build'
), ),
Gitlab::EtagCaching::Router::Route.new(
%r(#{RESERVED_WORDS_PREFIX}/clusters/\d+/environments\z),
'cluster_environments'
),
Gitlab::EtagCaching::Router::Route.new( Gitlab::EtagCaching::Router::Route.new(
%r(#{RESERVED_WORDS_PREFIX}/environments\.json\z), %r(#{RESERVED_WORDS_PREFIX}/environments\.json\z),
'environments' 'environments'
......
...@@ -11,6 +11,8 @@ import { loadHTMLFixture } from 'helpers/fixtures'; ...@@ -11,6 +11,8 @@ import { loadHTMLFixture } from 'helpers/fixtures';
import { setTestTimeout } from 'helpers/timeout'; import { setTestTimeout } from 'helpers/timeout';
import $ from 'jquery'; import $ from 'jquery';
jest.mock('~/lib/utils/poll');
const { INSTALLING, INSTALLABLE, INSTALLED, UNINSTALLING } = APPLICATION_STATUS; const { INSTALLING, INSTALLABLE, INSTALLED, UNINSTALLING } = APPLICATION_STATUS;
describe('Clusters', () => { describe('Clusters', () => {
...@@ -44,6 +46,17 @@ describe('Clusters', () => { ...@@ -44,6 +46,17 @@ describe('Clusters', () => {
mock.restore(); mock.restore();
}); });
describe('class constructor', () => {
beforeEach(() => {
jest.spyOn(Clusters.prototype, 'initPolling');
cluster = new Clusters();
});
it('should call initPolling on construct', () => {
expect(cluster.initPolling).toHaveBeenCalled();
});
});
describe('toggle', () => { describe('toggle', () => {
it('should update the button and the input field on click', done => { it('should update the button and the input field on click', done => {
const toggleButton = document.querySelector( const toggleButton = document.querySelector(
...@@ -327,14 +340,31 @@ describe('Clusters', () => { ...@@ -327,14 +340,31 @@ describe('Clusters', () => {
}); });
}); });
describe('handleSuccess', () => { describe('fetch cluster environments success', () => {
beforeEach(() => {
jest.spyOn(cluster.store, 'toggleFetchEnvironments').mockReturnThis();
jest.spyOn(cluster.store, 'updateEnvironments').mockReturnThis();
cluster.handleClusterEnvironmentsSuccess({ data: {} });
});
it('toggles the cluster environments loading icon', () => {
expect(cluster.store.toggleFetchEnvironments).toHaveBeenCalled();
});
it('updates the store when cluster environments is retrieved', () => {
expect(cluster.store.updateEnvironments).toHaveBeenCalled();
});
});
describe('handleClusterStatusSuccess', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(cluster.store, 'updateStateFromServer').mockReturnThis(); jest.spyOn(cluster.store, 'updateStateFromServer').mockReturnThis();
jest.spyOn(cluster, 'toggleIngressDomainHelpText').mockReturnThis(); jest.spyOn(cluster, 'toggleIngressDomainHelpText').mockReturnThis();
jest.spyOn(cluster, 'checkForNewInstalls').mockReturnThis(); jest.spyOn(cluster, 'checkForNewInstalls').mockReturnThis();
jest.spyOn(cluster, 'updateContainer').mockReturnThis(); jest.spyOn(cluster, 'updateContainer').mockReturnThis();
cluster.handleSuccess({ data: {} }); cluster.handleClusterStatusSuccess({ data: {} });
}); });
it('updates clusters store', () => { it('updates clusters store', () => {
......
...@@ -92,6 +92,15 @@ describe Gitlab::EtagCaching::Router do ...@@ -92,6 +92,15 @@ describe Gitlab::EtagCaching::Router do
expect(result).to be_blank expect(result).to be_blank
end end
it 'matches the cluster environments path' do
result = described_class.match(
'/my-group/my-project/-/clusters/47/environments'
)
expect(result).to be_present
expect(result.name).to eq 'cluster_environments'
end
it 'matches the environments path' do it 'matches the environments path' do
result = described_class.match( result = described_class.match(
'/my-group/my-project/environments.json' '/my-group/my-project/environments.json'
......
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