Commit 26f19542 authored by Jacques Erasmus's avatar Jacques Erasmus Committed by Rémy Coutable

Add polling for cluster environments

- Set Polling interval header (respected by front-end)
- Invalidate etag cache on reactive cache update
- Also invalidate etag cache when visiting HTML page
parent afc723b0
......@@ -111,15 +111,25 @@ export default class Clusters {
this.initApplications(clusterType);
this.initEnvironments();
if (clusterEnvironmentsPath) {
this.fetchEnvironments();
if (clusterEnvironmentsPath && this.environments) {
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.addListeners();
if (statusPath && !this.environments) {
this.initPolling();
this.initPolling(
'fetchClusterStatus',
data => this.handleClusterStatusSuccess(data),
() => this.handlePollError(),
);
}
}
......@@ -179,16 +189,9 @@ export default class Clusters {
});
}
fetchEnvironments() {
this.store.toggleFetchEnvironments(true);
this.service
.fetchClusterEnvironments()
.then(data => {
this.store.toggleFetchEnvironments(false);
this.store.updateEnvironments(data.data);
})
.catch(() => Clusters.handleError());
handleClusterEnvironmentsSuccess(data) {
this.store.toggleFetchEnvironments(false);
this.store.updateEnvironments(data.data);
}
static initDismissableCallout() {
......@@ -224,21 +227,16 @@ export default class Clusters {
eventHub.$off('uninstallApplication');
}
initPolling() {
initPolling(method, successCallback, errorCallback) {
this.poll = new Poll({
resource: this.service,
method: 'fetchData',
successCallback: data => this.handleSuccess(data),
errorCallback: () => Clusters.handleError(),
method,
successCallback,
errorCallback,
});
if (!Visibility.hidden()) {
this.poll.makeRequest();
} else {
this.service
.fetchData()
.then(data => this.handleSuccess(data))
.catch(() => Clusters.handleError());
}
Visibility.change(() => {
......@@ -250,11 +248,21 @@ export default class Clusters {
});
}
handlePollError() {
this.constructor.handleError();
}
handleEnvironmentsPollError() {
this.store.toggleFetchEnvironments(false);
this.handlePollError();
}
static handleError() {
Flash(s__('ClusterIntegration|Something went wrong on our end.'));
}
handleSuccess(data) {
handleClusterStatusSuccess(data) {
const prevStatus = this.store.state.status;
const prevApplicationMap = Object.assign({}, this.store.state.applications);
......
......@@ -17,7 +17,7 @@ export default class ClusterService {
};
}
fetchData() {
fetchClusterStatus() {
return axios.get(this.options.endpoint);
}
......
......@@ -218,6 +218,7 @@ export default class ClusterStore {
environmentPath: environment.environment_path,
lastDeployment: environment.last_deployment,
rolloutStatus: {
status: environment.rollout_status ? environment.rollout_status.status : null,
instances: environment.rollout_status ? environment.rollout_status.instances : [],
},
updatedAt: environment.updated_at,
......
......@@ -137,6 +137,10 @@ The result will then be:
- The Staging cluster will be used for the `deploy to staging` 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
For important information about securely configuring GitLab Runners, see
......
......@@ -85,6 +85,7 @@ export default {
},
methods: {
hasInstances: rolloutStatus => rolloutStatus.instances && rolloutStatus.instances.length,
isLoadingRollout: rolloutStatus => rolloutStatus.status === 'loading',
},
};
</script>
......@@ -127,7 +128,14 @@ export default {
</template>
<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">
<deployment-instance
:key="i"
......
......@@ -3,9 +3,17 @@
module EE
module Groups
module ClustersController
extend ActiveSupport::Concern
prepended do
before_action :expire_etag_cache, only: [:show]
end
def environments
respond_to do |format|
format.json do
::Gitlab::PollingInterval.set_header(response, interval: 5_000)
environments = ::Clusters::EnvironmentsFinder.new(cluster, current_user).execute
render json: serialize_environments(
......@@ -19,6 +27,15 @@ module EE
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)
::Clusters::EnvironmentSerializer
.new(cluster: cluster, current_user: current_user)
......
......@@ -46,6 +46,18 @@ module EE
::Gitlab::EtagCaching::Store.new.tap do |store|
store.touch(
::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
......
......@@ -2,7 +2,7 @@
- is_group_type = @cluster.cluster_type.in? 'group_type'
- 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
%ul.nav-links.mobile-separator.nav.nav-tabs{ role: 'tablist' }
%li.nav-item{ role: 'presentation' }
......
---
title: Implement Cluster Environments polling
merge_request: 16316
author:
type: added
......@@ -98,7 +98,7 @@ describe Groups::ClustersController do
create(:deployment, :success, cluster: cluster)
end
def go
def get_cluster_environments
get :environments,
params: {
group_id: group.to_param,
......@@ -109,21 +109,44 @@ describe Groups::ClustersController do
describe 'functionality' do
it 'responds successfully' do
go
get_cluster_environments
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['Poll-Interval']).to eq("5000")
end
end
describe 'security' do
it { expect { go }.to be_allowed_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(group) }
it { expect { go }.to be_allowed_for(:maintainer).of(group) }
it { expect { go }.to be_denied_for(:developer).of(group) }
it { expect { go }.to be_denied_for(:reporter).of(group) }
it { expect { go }.to be_denied_for(:guest).of(group) }
it { expect { go }.to be_denied_for(:user) }
it { expect { go }.to be_denied_for(:external) }
it { expect { get_cluster_environments }.to be_allowed_for(:admin) }
it { expect { get_cluster_environments }.to be_allowed_for(:owner).of(group) }
it { expect { get_cluster_environments }.to be_allowed_for(:maintainer).of(group) }
it { expect { get_cluster_environments }.to be_denied_for(:developer).of(group) }
it { expect { get_cluster_environments }.to be_denied_for(:reporter).of(group) }
it { expect { get_cluster_environments }.to be_denied_for(:guest).of(group) }
it { expect { get_cluster_environments }.to be_denied_for(:user) }
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
import { createLocalVue, mount } from '@vue/test-utils';
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 environments from './mock_data';
......@@ -58,7 +58,7 @@ describe('Environments', () => {
});
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');
expect(headers.length).toBe(tableHeaders.length);
......@@ -73,6 +73,18 @@ describe('Environments', () => {
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', () => {
environments.forEach((environment, i) => {
const { instances } = environment.rolloutStatus;
......@@ -86,9 +98,9 @@ describe('Environments', () => {
'Deploy progress not found. To see pods, ensure your environment matches deploy board criteria.';
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 emptyStateIcon = emptyState.find(Icon);
......
......@@ -18,9 +18,8 @@ export default [
name: 'staging',
lastDeployment: { id: '456' },
rolloutStatus: {
instances: [
{ status: 'running', pod_name: 'some pod', tooltip: 'success', track: '1', stable: true },
],
status: 'loading',
instances: [],
},
updatedAt: '2019-01-13T12:25:24.098Z',
},
......
......@@ -163,6 +163,28 @@ describe Environment, :use_clean_rails_memory_store_caching do
subject
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
describe '#rollout_status' do
......
......@@ -52,6 +52,10 @@ module Gitlab
%r(#{RESERVED_WORDS_PREFIX}/builds/\d+\.json\z),
'project_build'
),
Gitlab::EtagCaching::Router::Route.new(
%r(#{RESERVED_WORDS_PREFIX}/clusters/\d+/environments\z),
'cluster_environments'
),
Gitlab::EtagCaching::Router::Route.new(
%r(#{RESERVED_WORDS_PREFIX}/environments\.json\z),
'environments'
......
......@@ -11,6 +11,8 @@ import { loadHTMLFixture } from 'helpers/fixtures';
import { setTestTimeout } from 'helpers/timeout';
import $ from 'jquery';
jest.mock('~/lib/utils/poll');
const { INSTALLING, INSTALLABLE, INSTALLED, UNINSTALLING } = APPLICATION_STATUS;
describe('Clusters', () => {
......@@ -44,6 +46,17 @@ describe('Clusters', () => {
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', () => {
it('should update the button and the input field on click', done => {
const toggleButton = document.querySelector(
......@@ -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(() => {
jest.spyOn(cluster.store, 'updateStateFromServer').mockReturnThis();
jest.spyOn(cluster, 'toggleIngressDomainHelpText').mockReturnThis();
jest.spyOn(cluster, 'checkForNewInstalls').mockReturnThis();
jest.spyOn(cluster, 'updateContainer').mockReturnThis();
cluster.handleSuccess({ data: {} });
cluster.handleClusterStatusSuccess({ data: {} });
});
it('updates clusters store', () => {
......
......@@ -92,6 +92,15 @@ describe Gitlab::EtagCaching::Router do
expect(result).to be_blank
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
result = described_class.match(
'/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