Commit 29c243b0 authored by Grzegorz Bizon's avatar Grzegorz Bizon

Merge branch 'list-multiple-clusters' into 'master'

List multiple clusters

See merge request gitlab-org/gitlab-ce!15403
parents 67a94b2f 0ef16b27
...@@ -150,8 +150,8 @@ export default class Clusters { ...@@ -150,8 +150,8 @@ export default class Clusters {
} }
toggle() { toggle() {
this.toggleButton.classList.toggle('checked'); this.toggleButton.classList.toggle('is-checked');
this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('checked').toString()); this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('is-checked').toString());
} }
showToken() { showToken() {
......
import Flash from '../flash';
import { s__ } from '../locale';
import ClustersService from './services/clusters_service';
/**
* Toggles loading and disabled classes.
* @param {HTMLElement} button
*/
const toggleLoadingButton = (button) => {
if (button.getAttribute('disabled')) {
button.removeAttribute('disabled');
} else {
button.setAttribute('disabled', true);
}
button.classList.toggle('is-loading');
};
/**
* Toggles checked class for the given button
* @param {HTMLElement} button
*/
const toggleValue = (button) => {
button.classList.toggle('is-checked');
};
/**
* Handles toggle buttons in the cluster's table.
*
* When the user clicks the toggle button for each cluster, it:
* - toggles the button
* - shows a loading and disables button
* - Makes a put request to the given endpoint
* Once we receive the response, either:
* 1) Show updated status in case of successfull response
* 2) Show initial status in case of failed response
*/
export default function setClusterTableToggles() {
document.querySelectorAll('.js-toggle-cluster-list')
.forEach(button => button.addEventListener('click', (e) => {
const toggleButton = e.currentTarget;
const endpoint = toggleButton.getAttribute('data-endpoint');
toggleValue(toggleButton);
toggleLoadingButton(toggleButton);
const value = toggleButton.classList.contains('is-checked');
ClustersService.updateCluster(endpoint, { cluster: { enabled: value } })
.then(() => {
toggleLoadingButton(toggleButton);
})
.catch(() => {
toggleLoadingButton(toggleButton);
toggleValue(toggleButton);
Flash(s__('ClusterIntegration|Something went wrong on our end.'));
});
}));
}
...@@ -17,4 +17,8 @@ export default class ClusterService { ...@@ -17,4 +17,8 @@ export default class ClusterService {
installApplication(appId) { installApplication(appId) {
return axios.post(this.appInstallEndpointMap[appId]); return axios.post(this.appInstallEndpointMap[appId]);
} }
static updateCluster(endpoint, data) {
return axios.put(endpoint, data);
}
} }
...@@ -562,7 +562,15 @@ import ProjectVariables from './project_variables'; ...@@ -562,7 +562,15 @@ import ProjectVariables from './project_variables';
import(/* webpackChunkName: "clusters" */ './clusters/clusters_bundle') import(/* webpackChunkName: "clusters" */ './clusters/clusters_bundle')
.then(cluster => new cluster.default()) // eslint-disable-line new-cap .then(cluster => new cluster.default()) // eslint-disable-line new-cap
.catch((err) => { .catch((err) => {
Flash(s__('ClusterIntegration|Problem setting up the cluster JavaScript')); Flash(s__('ClusterIntegration|Problem setting up the cluster'));
throw err;
});
break;
case 'projects:clusters:index':
import(/* webpackChunkName: "clusters_index" */ './clusters/clusters_index')
.then(clusterIndex => clusterIndex.default())
.catch((err) => {
Flash(s__('ClusterIntegration|Problem setting up the clusters list'));
throw err; throw err;
}); });
break; break;
......
<script> <script>
import projectFeatureToggle from './project_feature_toggle.vue'; import projectFeatureToggle from '../../../vue_shared/components/toggle_button.vue';
export default { export default {
props: { props: {
......
<script>
export default {
props: {
name: {
type: String,
required: false,
default: '',
},
value: {
type: Boolean,
required: true,
},
disabledInput: {
type: Boolean,
required: false,
default: false,
},
},
model: {
prop: 'value',
event: 'change',
},
methods: {
toggleFeature() {
if (!this.disabledInput) this.$emit('change', !this.value);
},
},
};
</script>
<template>
<label class="toggle-wrapper">
<input
v-if="name"
type="hidden"
:name="name"
:value="value"
/>
<button
type="button"
aria-label="Toggle"
class="project-feature-toggle"
data-enabled-text="Enabled"
data-disabled-text="Disabled"
:class="{ checked: value, disabled: disabledInput }"
@click="toggleFeature"
/>
</label>
</template>
<script> <script>
import projectFeatureSetting from './project_feature_setting.vue'; import projectFeatureSetting from './project_feature_setting.vue';
import projectFeatureToggle from './project_feature_toggle.vue'; import projectFeatureToggle from '../../../vue_shared/components/toggle_button.vue';
import projectSettingRow from './project_setting_row.vue'; import projectSettingRow from './project_setting_row.vue';
import { visibilityOptions, visibilityLevelDescriptions } from '../constants'; import { visibilityOptions, visibilityLevelDescriptions } from '../constants';
import { toggleHiddenClassBySelector } from '../external'; import { toggleHiddenClassBySelector } from '../external';
......
<script>
import loadingIcon from './loading_icon.vue';
export default {
props: {
name: {
type: String,
required: false,
default: '',
},
value: {
type: Boolean,
required: true,
},
disabledInput: {
type: Boolean,
required: false,
default: false,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
enabledText: {
type: String,
required: false,
default: 'Enabled',
},
disabledText: {
type: String,
required: false,
default: 'Disabled',
},
},
components: {
loadingIcon,
},
model: {
prop: 'value',
event: 'change',
},
methods: {
toggleFeature() {
if (!this.disabledInput) this.$emit('change', !this.value);
},
},
};
</script>
<template>
<label class="toggle-wrapper">
<input
type="hidden"
:name="name"
:value="value"
/>
<button
type="button"
aria-label="Toggle"
class="project-feature-toggle"
:data-enabled-text="enabledText"
:data-disabled-text="disabledText"
:class="{
'is-checked': value,
'is-disabled': disabledInput,
'is-loading': isLoading
}"
@click="toggleFeature"
>
<loadingIcon class="loading-icon" />
</button>
</label>
</template>
...@@ -44,6 +44,7 @@ ...@@ -44,6 +44,7 @@
@import "framework/tabs"; @import "framework/tabs";
@import "framework/timeline"; @import "framework/timeline";
@import "framework/tooltips"; @import "framework/tooltips";
@import "framework/toggle";
@import "framework/typography"; @import "framework/typography";
@import "framework/zen"; @import "framework/zen";
@import "framework/blank"; @import "framework/blank";
......
/**
* Toggle button
*
* @usage
* ### Active and Inactive text should be provided as data attributes:
* <button type="button" class="project-feature-toggle" data-enabled-text="Enabled" data-disabled-text="Disabled">
* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
* </button>
* ### Checked should have `is-checked` class
* <button type="button" class="project-feature-toggle is-checked" data-enabled-text="Enabled" data-disabled-text="Disabled">
* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
* </button>
* ### Disabled should have `is-disabled` class
* <button type="button" class="project-feature-toggle is-disabled" data-enabled-text="Enabled" data-disabled-text="Disabled" disabled="true">
* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
* </button>
* ### Loading should have `is-loading` and an icon with `loading-icon` class
* <button type="button" class="project-feature-toggle is-loading" data-enabled-text="Enabled" data-disabled-text="Disabled">
* <i class="fa fa-spinner fa-spin loading-icon"></i>
* </button>
*/
.project-feature-toggle {
position: relative;
border: 0;
outline: 0;
display: block;
width: 100px;
height: 24px;
cursor: pointer;
user-select: none;
background: $feature-toggle-color-disabled;
border-radius: 12px;
padding: 3px;
transition: all .4s ease;
&::selection,
&::before::selection,
&::after::selection {
background: none;
}
&::before {
color: $feature-toggle-text-color;
font-size: 12px;
line-height: 24px;
position: absolute;
top: 0;
left: 25px;
right: 5px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
animation: animate-disabled .2s ease-in;
content: attr(data-disabled-text);
}
&::after {
position: relative;
display: block;
content: "";
width: 22px;
height: 18px;
left: 0;
border-radius: 9px;
background: $feature-toggle-color;
transition: all .2s ease;
}
.loading-icon {
display: none;
font-size: 12px;
color: $white-light;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
&.is-loading {
&::before {
display: none;
}
.loading-icon {
display: block;
&::before {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
}
&.is-checked {
background: $feature-toggle-color-enabled;
&::before {
left: 5px;
right: 25px;
animation: animate-enabled .2s ease-in;
content: attr(data-enabled-text);
}
&::after {
left: calc(100% - 22px);
}
}
&.is-disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: $screen-xs-min) {
width: 50px;
&::before,
&.is-checked::before {
display: none;
}
}
@keyframes animate-enabled {
0%, 35% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes animate-disabled {
0%, 35% { opacity: 0; }
100% { opacity: 1; }
}
}
...@@ -14,3 +14,17 @@ ...@@ -14,3 +14,17 @@
} }
@include new-style-dropdown('.clusters-dropdown '); @include new-style-dropdown('.clusters-dropdown ');
.clusters-container {
.nav-bar-right {
padding: $gl-padding-top $gl-padding;
}
.empty-state .svg-content img {
width: 145px;
}
.top-area .nav-controls > .btn.btn-add-cluster {
margin-right: 0;
}
}
...@@ -126,93 +126,6 @@ ...@@ -126,93 +126,6 @@
} }
} }
.project-feature-toggle {
position: relative;
border: 0;
outline: 0;
display: block;
width: 100px;
height: 24px;
cursor: pointer;
user-select: none;
background: $feature-toggle-color-disabled;
border-radius: 12px;
padding: 3px;
transition: all .4s ease;
&::selection,
&::before::selection,
&::after::selection {
background: none;
}
&::before {
color: $feature-toggle-text-color;
font-size: 12px;
line-height: 24px;
position: absolute;
top: 0;
left: 25px;
right: 5px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
animation: animate-disabled .2s ease-in;
content: attr(data-disabled-text);
}
&::after {
position: relative;
display: block;
content: "";
width: 22px;
height: 18px;
left: 0;
border-radius: 9px;
background: $feature-toggle-color;
transition: all .2s ease;
}
&.checked {
background: $feature-toggle-color-enabled;
&::before {
left: 5px;
right: 25px;
animation: animate-enabled .2s ease-in;
content: attr(data-enabled-text);
}
&::after {
left: calc(100% - 22px);
}
}
&.disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: $screen-xs-min) {
width: 50px;
&::before,
&.checked::before {
display: none;
}
}
@keyframes animate-enabled {
0%, 35% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes animate-disabled {
0%, 35% { opacity: 0; }
100% { opacity: 1; }
}
}
.project-home-panel, .project-home-panel,
.group-home-panel { .group-home-panel {
padding-top: 24px; padding-top: 24px;
......
...@@ -8,11 +8,11 @@ class Projects::ClustersController < Projects::ApplicationController ...@@ -8,11 +8,11 @@ class Projects::ClustersController < Projects::ApplicationController
STATUS_POLLING_INTERVAL = 10_000 STATUS_POLLING_INTERVAL = 10_000
def index def index
if project.cluster @scope = params[:scope] || 'all'
redirect_to project_cluster_path(project, project.cluster) @clusters = ClustersFinder.new(project, current_user, @scope).execute.page(params[:page])
else @active_count = ClustersFinder.new(project, current_user, :active).execute.count
redirect_to new_project_cluster_path(project) @inactive_count = ClustersFinder.new(project, current_user, :inactive).execute.count
end @all_count = @active_count + @inactive_count
end end
def new def new
...@@ -39,10 +39,20 @@ class Projects::ClustersController < Projects::ApplicationController ...@@ -39,10 +39,20 @@ class Projects::ClustersController < Projects::ApplicationController
.execute(cluster) .execute(cluster)
if cluster.valid? if cluster.valid?
flash[:notice] = "Cluster was successfully updated." respond_to do |format|
redirect_to project_cluster_path(project, project.cluster) format.json do
head :no_content
end
format.html do
flash[:notice] = "Cluster was successfully updated."
redirect_to project_cluster_path(project, cluster)
end
end
else else
render :show respond_to do |format|
format.json { head :bad_request }
format.html { render :show }
end
end end
end end
...@@ -63,6 +73,19 @@ class Projects::ClustersController < Projects::ApplicationController ...@@ -63,6 +73,19 @@ class Projects::ClustersController < Projects::ApplicationController
.present(current_user: current_user) .present(current_user: current_user)
end end
def create_params
params.require(:cluster).permit(
:enabled,
:name,
:provider_type,
provider_gcp_attributes: [
:gcp_project_id,
:zone,
:num_nodes,
:machine_type
])
end
def update_params def update_params
if cluster.managed? if cluster.managed?
params.require(:cluster).permit( params.require(:cluster).permit(
......
class ClustersFinder
def initialize(project, user, scope)
@project = project
@user = user
@scope = scope || :active
end
def execute
clusters = project.clusters
filter_by_scope(clusters)
end
private
attr_reader :project, :user, :scope
def filter_by_scope(clusters)
case scope.to_sym
when :all
clusters
when :inactive
clusters.disabled
when :active
clusters.enabled
else
raise "Invalid scope #{scope}"
end
end
end
...@@ -55,6 +55,10 @@ module Clusters ...@@ -55,6 +55,10 @@ module Clusters
end end
end end
def created?
status_name == :created
end
def applications def applications
[ [
application_helm || build_application_helm, application_helm || build_application_helm,
......
...@@ -189,7 +189,6 @@ class Project < ActiveRecord::Base ...@@ -189,7 +189,6 @@ class Project < ActiveRecord::Base
has_one :statistics, class_name: 'ProjectStatistics' has_one :statistics, class_name: 'ProjectStatistics'
has_one :cluster_project, class_name: 'Clusters::Project' has_one :cluster_project, class_name: 'Clusters::Project'
has_one :cluster, through: :cluster_project, class_name: 'Clusters::Cluster'
has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster' has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster'
# Container repositories need to remove data from the container registry, # Container repositories need to remove data from the container registry,
......
...@@ -5,5 +5,9 @@ module Clusters ...@@ -5,5 +5,9 @@ module Clusters
def gke_cluster_url def gke_cluster_url
"https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp? "https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp?
end end
def can_toggle_cluster?
can?(current_user, :update_cluster, cluster) && created?
end
end end
end end
...@@ -5,6 +5,8 @@ module Clusters ...@@ -5,6 +5,8 @@ module Clusters
def execute(access_token = nil) def execute(access_token = nil)
@access_token = access_token @access_token = access_token
raise ArgumentError.new('Instance does not support multiple clusters') unless can_create_cluster?
create_cluster.tap do |cluster| create_cluster.tap do |cluster|
ClusterProvisionWorker.perform_async(cluster.id) if cluster.persisted? ClusterProvisionWorker.perform_async(cluster.id) if cluster.persisted?
end end
...@@ -25,5 +27,9 @@ module Clusters ...@@ -25,5 +27,9 @@ module Clusters
@cluster_params = params.merge(user: current_user, projects: [project]) @cluster_params = params.merge(user: current_user, projects: [project])
end end
def can_create_cluster?
project.clusters.empty?
end
end end
end end
...@@ -187,7 +187,7 @@ ...@@ -187,7 +187,7 @@
= nav_link(controller: [:clusters, :user, :gcp]) do = nav_link(controller: [:clusters, :user, :gcp]) do
= link_to project_clusters_path(@project), title: 'Cluster', class: 'shortcuts-cluster' do = link_to project_clusters_path(@project), title: 'Cluster', class: 'shortcuts-cluster' do
%span %span
Cluster Clusters
- if @project.feature_available?(:builds, current_user) && !@project.empty_repo? - if @project.feature_available?(:builds, current_user) && !@project.empty_repo?
= nav_link(path: 'pipelines#charts') do = nav_link(path: 'pipelines#charts') do
......
.gl-responsive-table-row
.table-section.section-30
.table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Cluster")
.table-mobile-content
= link_to cluster.name, namespace_project_cluster_path(@project.namespace, @project, cluster)
.table-section.section-30
.table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Environment pattern")
.table-mobile-content= cluster.environment_scope
.table-section.section-30
.table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Project namespace")
.table-mobile-content= cluster.platform_kubernetes&.actual_namespace
.table-section.section-10
.table-mobile-header{ role: "rowheader" }
.table-mobile-content
%button{ type: "button",
class: "js-toggle-cluster-list project-feature-toggle #{'is-checked' if cluster.enabled?} #{'is-disabled' if !cluster.can_toggle_cluster?}",
"aria-label": s_("ClusterIntegration|Toggle Cluster"),
disabled: !cluster.can_toggle_cluster?,
data: { "enabled-text": s_("ClusterIntegration|Active"),
"disabled-text": s_("ClusterIntegration|Inactive"),
endpoint: namespace_project_cluster_path(@project.namespace, @project, cluster, format: :json) } }
= icon("spinner spin", class: "loading-icon")
.row.empty-state
.col-xs-12
.svg-content= image_tag 'illustrations/clusters_empty.svg'
.col-xs-12.text-center
.text-content
%h4= s_('ClusterIntegration|Integrate cluster automation')
- link_to_help_page = link_to(s_('ClusterIntegration|Learn more about Clusters'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
%p= s_('ClusterIntegration|Clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page}
%p
= link_to s_('ClusterIntegration|Add cluster'), new_project_cluster_path(@project), class: 'btn btn-success'
...@@ -5,12 +5,11 @@ ...@@ -5,12 +5,11 @@
= field.hidden_field :enabled, { class: 'js-toggle-input'} = field.hidden_field :enabled, { class: 'js-toggle-input'}
%button{ type: 'button', %button{ type: 'button',
class: "js-toggle-cluster project-feature-toggle #{'checked' unless !@cluster.enabled?} #{'disabled' unless can?(current_user, :update_cluster, @cluster)}", class: "js-toggle-cluster project-feature-toggle #{'is-checked' unless !@cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}",
'aria-label': s_('ClusterIntegration|Toggle Cluster'), "aria-label": s_("ClusterIntegration|Toggle Cluster"),
disabled: !can?(current_user, :update_cluster, @cluster), disabled: !can?(current_user, :update_cluster, @cluster),
data: { 'enabled-text': 'Enabled', 'disabled-text': 'Disabled' } } data: { "enabled-text": s_("ClusterIntegration|Active"), "disabled-text": s_("ClusterIntegration|Inactive"), } }
- if can?(current_user, :update_cluster, @cluster) - if can?(current_user, :update_cluster, @cluster)
.form-group .form-group
= field.submit _('Save'), class: 'btn btn-success' = field.submit _('Save'), class: 'btn btn-success'
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= icon("angle-left")
.fade-right= icon("angle-right")
%ul.nav-links.scrolling-tabs
%li{ class: ('active' if @scope == 'active') }>
= link_to project_clusters_path(@project, scope: :active), class: "js-active-tab" do
= s_("ClusterIntegration|Active")
%span.badge= @active_count
%li{ class: ('active' if @scope == 'inactive') }>
= link_to project_clusters_path(@project, scope: :inactive), class: "js-inactive-tab" do
= s_("ClusterIntegration|Inactive")
%span.badge= @inactive_count
%li{ class: ('active' if @scope.nil? || @scope == 'all') }>
= link_to project_clusters_path(@project), class: "js-all-tab" do
= s_("ClusterIntegration|All")
%span.badge= @all_count
- breadcrumb_title "Clusters"
- page_title "Clusters"
.clusters-container
- if !@clusters.empty?
= render "tabs"
.ci-table.js-clusters-list
.gl-responsive-table-row.table-row-header{ role: "row" }
.table-section.section-30{ role: "rowheader" }
= s_("ClusterIntegration|Cluster")
.table-section.section-30{ role: "rowheader" }
= s_("ClusterIntegration|Environment pattern")
.table-section.section-30{ role: "rowheader" }
= s_("ClusterIntegration|Project namespace")
.table-section.section-10{ role: "rowheader" }
- @clusters.each do |cluster|
= render "cluster", cluster: cluster.present(current_user: current_user)
= paginate @clusters, theme: "gitlab"
- elsif @scope == 'all'
= render "empty_state"
- else
= render "tabs"
.prepend-top-20.text-center
= s_("ClusterIntegration|There are no clusters to show")
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
- breadcrumb_title "Cluster" - add_to_breadcrumbs "Clusters", project_clusters_path(@project)
- breadcrumb_title @cluster.id
- page_title _("Cluster") - page_title _("Cluster")
- expanded = Rails.env.test? - expanded = Rails.env.test?
...@@ -28,7 +29,6 @@ ...@@ -28,7 +29,6 @@
%button.btn.js-settings-toggle %button.btn.js-settings-toggle
= expanded ? 'Collapse' : 'Expand' = expanded ? 'Collapse' : 'Expand'
%p= s_('ClusterIntegration|See and edit the details for your cluster') %p= s_('ClusterIntegration|See and edit the details for your cluster')
.settings-content .settings-content
- if @cluster.managed? - if @cluster.managed?
= render 'projects/clusters/gcp/show' = render 'projects/clusters/gcp/show'
......
This diff is collapsed.
...@@ -143,9 +143,9 @@ describe Projects::Clusters::GcpController do ...@@ -143,9 +143,9 @@ describe Projects::Clusters::GcpController do
expect(ClusterProvisionWorker).to receive(:perform_async) expect(ClusterProvisionWorker).to receive(:perform_async)
expect { go }.to change { Clusters::Cluster.count } expect { go }.to change { Clusters::Cluster.count }
.and change { Clusters::Providers::Gcp.count } .and change { Clusters::Providers::Gcp.count }
expect(response).to redirect_to(project_cluster_path(project, project.cluster)) expect(response).to redirect_to(project_cluster_path(project, project.clusters.first))
expect(project.cluster).to be_gcp expect(project.clusters.first).to be_gcp
expect(project.cluster).to be_kubernetes expect(project.clusters.first).to be_kubernetes
end end
end end
end end
......
...@@ -64,7 +64,9 @@ describe Projects::Clusters::UserController do ...@@ -64,7 +64,9 @@ describe Projects::Clusters::UserController do
expect(ClusterProvisionWorker).to receive(:perform_async) expect(ClusterProvisionWorker).to receive(:perform_async)
expect { go }.to change { Clusters::Cluster.count } expect { go }.to change { Clusters::Cluster.count }
.and change { Clusters::Platforms::Kubernetes.count } .and change { Clusters::Platforms::Kubernetes.count }
expect(response).to redirect_to(project_cluster_path(project, project.cluster)) expect(response).to redirect_to(project_cluster_path(project, project.clusters.first))
expect(project.clusters.first).to be_user
expect(project.clusters.first).to be_kubernetes
end end
end end
end end
......
...@@ -15,14 +15,72 @@ describe Projects::ClustersController do ...@@ -15,14 +15,72 @@ describe Projects::ClustersController do
sign_in(user) sign_in(user)
end end
context 'when project has a cluster' do context 'when project has one or more clusters' do
let!(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } let(:project) { create(:project) }
let!(:enabled_cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
let!(:disabled_cluster) { create(:cluster, :disabled, :provided_by_gcp, projects: [project]) }
it 'lists available clusters' do
go
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index)
expect(assigns(:clusters)).to match_array([enabled_cluster, disabled_cluster])
end
it 'assigns counters to correct values' do
go
expect(assigns(:active_count)).to eq(1)
expect(assigns(:inactive_count)).to eq(1)
end
context 'when page is specified' do
let(:last_page) { project.clusters.page.total_pages }
it { expect(go).to redirect_to(project_cluster_path(project, project.cluster)) } before do
allow(Clusters::Cluster).to receive(:paginates_per).and_return(1)
create_list(:cluster, 2, :provided_by_gcp, projects: [project])
get :index, namespace_id: project.namespace, project_id: project, page: last_page
end
it 'redirects to the page' do
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:clusters).current_page).to eq(last_page)
end
end
context 'when only enabled clusters are requested' do
it 'returns only enabled clusters' do
get :index, namespace_id: project.namespace, project_id: project, scope: 'active'
expect(assigns(:clusters)).to all(have_attributes(enabled: true))
end
end
context 'when only disabled clusters are requested' do
it 'returns only disabled clusters' do
get :index, namespace_id: project.namespace, project_id: project, scope: 'inactive'
expect(assigns(:clusters)).to all(have_attributes(enabled: false))
end
end
end end
context 'when project does not have a cluster' do context 'when project does not have a cluster' do
it { expect(go).to redirect_to(new_project_cluster_path(project)) } let(:project) { create(:project) }
it 'returns an empty state page' do
go
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index, partial: :empty_state)
expect(assigns(:clusters)).to eq([])
end
it 'assigns counters to zero' do
go
expect(assigns(:active_count)).to eq(0)
expect(assigns(:inactive_count)).to eq(0)
end
end end
end end
...@@ -146,7 +204,7 @@ describe Projects::ClustersController do ...@@ -146,7 +204,7 @@ describe Projects::ClustersController do
go go
cluster.reload cluster.reload
expect(response).to redirect_to(project_cluster_path(project, project.cluster)) expect(response).to redirect_to(project_cluster_path(project, cluster))
expect(flash[:notice]).to eq('Cluster was successfully updated.') expect(flash[:notice]).to eq('Cluster was successfully updated.')
expect(cluster.enabled).to be_falsey expect(cluster.enabled).to be_falsey
end end
...@@ -180,28 +238,77 @@ describe Projects::ClustersController do ...@@ -180,28 +238,77 @@ describe Projects::ClustersController do
sign_in(user) sign_in(user)
end end
context 'when changing parameters' do context 'when format is json' do
let(:params) do context 'when changing parameters' do
{ context 'when valid parameters are used' do
cluster: { let(:params) do
enabled: false, {
name: 'my-new-cluster-name', cluster: {
platform_kubernetes_attributes: { enabled: false,
namespace: 'my-namespace' name: 'my-new-cluster-name',
platform_kubernetes_attributes: {
namespace: 'my-namespace'
}
}
} }
} end
}
it "updates and redirects back to show page" do
go_json
cluster.reload
expect(response).to have_http_status(:no_content)
expect(cluster.enabled).to be_falsey
expect(cluster.name).to eq('my-new-cluster-name')
expect(cluster.platform_kubernetes.namespace).to eq('my-namespace')
end
end
context 'when invalid parameters are used' do
let(:params) do
{
cluster: {
enabled: false,
platform_kubernetes_attributes: {
namespace: 'my invalid namespace #@'
}
}
}
end
it "rejects changes" do
go_json
expect(response).to have_http_status(:bad_request)
end
end
end end
end
it "updates and redirects back to show page" do context 'when format is html' do
go context 'when update enabled' do
let(:params) do
{
cluster: {
enabled: false,
name: 'my-new-cluster-name',
platform_kubernetes_attributes: {
namespace: 'my-namespace'
}
}
}
end
cluster.reload it "updates and redirects back to show page" do
expect(response).to redirect_to(project_cluster_path(project, project.cluster)) go
expect(flash[:notice]).to eq('Cluster was successfully updated.')
expect(cluster.enabled).to be_falsey cluster.reload
expect(cluster.name).to eq('my-new-cluster-name') expect(response).to redirect_to(project_cluster_path(project, cluster))
expect(cluster.platform_kubernetes.namespace).to eq('my-namespace') expect(flash[:notice]).to eq('Cluster was successfully updated.')
expect(cluster.enabled).to be_falsey
expect(cluster.name).to eq('my-new-cluster-name')
expect(cluster.platform_kubernetes.namespace).to eq('my-namespace')
end
end end
end end
end end
...@@ -228,6 +335,13 @@ describe Projects::ClustersController do ...@@ -228,6 +335,13 @@ describe Projects::ClustersController do
project_id: project, project_id: project,
id: cluster) id: cluster)
end end
def go_json
put :update, params.merge(namespace_id: project.namespace,
project_id: project,
id: cluster,
format: :json)
end
end end
describe 'DELETE destroy' do describe 'DELETE destroy' do
......
...@@ -28,5 +28,9 @@ FactoryGirl.define do ...@@ -28,5 +28,9 @@ FactoryGirl.define do
provider_type :gcp provider_type :gcp
provider_gcp factory: [:cluster_provider_gcp, :creating] provider_gcp factory: [:cluster_provider_gcp, :creating]
end end
trait :disabled do
enabled false
end
end end
end end
...@@ -24,6 +24,7 @@ feature 'Gcp Cluster', :js do ...@@ -24,6 +24,7 @@ feature 'Gcp Cluster', :js do
before do before do
visit project_clusters_path(project) visit project_clusters_path(project)
click_link 'Add cluster'
click_link 'Create on GKE' click_link 'Create on GKE'
end end
...@@ -116,7 +117,7 @@ feature 'Gcp Cluster', :js do ...@@ -116,7 +117,7 @@ feature 'Gcp Cluster', :js do
it 'user sees creation form with the successful message' do it 'user sees creation form with the successful message' do
expect(page).to have_content('Cluster integration was successfully removed.') expect(page).to have_content('Cluster integration was successfully removed.')
expect(page).to have_link('Create on GKE') expect(page).to have_link('Add cluster')
end end
end end
end end
...@@ -126,6 +127,7 @@ feature 'Gcp Cluster', :js do ...@@ -126,6 +127,7 @@ feature 'Gcp Cluster', :js do
before do before do
visit project_clusters_path(project) visit project_clusters_path(project)
click_link 'Add cluster'
click_link 'Create on GKE' click_link 'Create on GKE'
end end
......
...@@ -16,6 +16,7 @@ feature 'User Cluster', :js do ...@@ -16,6 +16,7 @@ feature 'User Cluster', :js do
before do before do
visit project_clusters_path(project) visit project_clusters_path(project)
click_link 'Add cluster'
click_link 'Add an existing cluster' click_link 'Add an existing cluster'
end end
...@@ -94,7 +95,7 @@ feature 'User Cluster', :js do ...@@ -94,7 +95,7 @@ feature 'User Cluster', :js do
it 'user sees creation form with the successful message' do it 'user sees creation form with the successful message' do
expect(page).to have_content('Cluster integration was successfully removed.') expect(page).to have_content('Cluster integration was successfully removed.')
expect(page).to have_link('Add an existing cluster') expect(page).to have_link('Add cluster')
end end
end end
end end
......
...@@ -14,12 +14,78 @@ feature 'Clusters', :js do ...@@ -14,12 +14,78 @@ feature 'Clusters', :js do
context 'when user does not have a cluster and visits cluster index page' do context 'when user does not have a cluster and visits cluster index page' do
before do before do
visit project_clusters_path(project) visit project_clusters_path(project)
end
click_link 'Create on GKE' it 'sees empty state' do
expect(page).to have_link('Add cluster')
expect(page).to have_selector('.empty-state')
end end
end
context 'when user has a cluster and visits cluster index page' do
let!(:cluster) { create(:cluster, :project, :provided_by_gcp) }
let(:project) { cluster.project }
before do
visit project_clusters_path(project)
end
it 'user sees a table with one cluster' do
# One is the header row, the other the cluster row
expect(page).to have_selector('.gl-responsive-table-row', count: 2)
end
it 'user sees navigation tabs' do
expect(page.find('.js-active-tab').text).to include('Active')
expect(page.find('.js-active-tab .badge').text).to include('1')
expect(page.find('.js-inactive-tab').text).to include('Inactive')
expect(page.find('.js-inactive-tab .badge').text).to include('0')
expect(page.find('.js-all-tab').text).to include('All')
expect(page.find('.js-all-tab .badge').text).to include('1')
end
context 'inline update of cluster' do
it 'user can update cluster' do
expect(page).to have_selector('.js-toggle-cluster-list')
end
context 'with sucessfull request' do
it 'user sees updated cluster' do
expect do
page.find('.js-toggle-cluster-list').click
wait_for_requests
end.to change { cluster.reload.enabled }
expect(page).not_to have_selector('.is-checked')
expect(cluster.reload).not_to be_enabled
end
end
context 'with failed request' do
it 'user sees not update cluster and error message' do
expect_any_instance_of(Clusters::UpdateService).to receive(:execute).and_call_original
allow_any_instance_of(Clusters::Cluster).to receive(:valid?) { false }
page.find('.js-toggle-cluster-list').click
expect(page).to have_content('Something went wrong on our end.')
expect(page).to have_selector('.is-checked')
expect(cluster.reload).to be_enabled
end
end
end
context 'when user clicks on a cluster' do
before do
click_link cluster.name
end
it 'user sees a new page' do it 'user sees a cluster details page' do
expect(page).to have_button('Create cluster') expect(page).to have_button('Save')
expect(page.find(:css, '.cluster-name').value).to eq(cluster.name)
end
end end
end end
end end
...@@ -177,7 +177,7 @@ describe 'Edit Project Settings' do ...@@ -177,7 +177,7 @@ describe 'Edit Project Settings' do
click_button "Save changes" click_button "Save changes"
end end
expect(find(".sharing-permissions")).to have_selector(".project-feature-toggle.disabled", count: 2) expect(find(".sharing-permissions")).to have_selector(".project-feature-toggle.is-disabled", count: 2)
end end
it "shows empty features project homepage" do it "shows empty features project homepage" do
...@@ -272,10 +272,10 @@ describe 'Edit Project Settings' do ...@@ -272,10 +272,10 @@ describe 'Edit Project Settings' do
end end
def toggle_feature_off(feature_name) def toggle_feature_off(feature_name)
find(".project-feature-controls[data-for=\"#{feature_name}\"] .project-feature-toggle.checked").click find(".project-feature-controls[data-for=\"#{feature_name}\"] .project-feature-toggle.is-checked").click
end end
def toggle_feature_on(feature_name) def toggle_feature_on(feature_name)
find(".project-feature-controls[data-for=\"#{feature_name}\"] .project-feature-toggle:not(.checked)").click find(".project-feature-controls[data-for=\"#{feature_name}\"] .project-feature-toggle:not(.is-checked)").click
end end
end end
require 'spec_helper'
describe ClustersFinder do
let(:project) { create(:project) }
set(:user) { create(:user) }
describe '#execute' do
let(:enabled_cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
let(:disabled_cluster) { create(:cluster, :disabled, :provided_by_gcp, projects: [project]) }
subject { described_class.new(project, user, scope).execute }
context 'when scope is all' do
let(:scope) { :all }
it { is_expected.to match_array([enabled_cluster, disabled_cluster]) }
end
context 'when scope is active' do
let(:scope) { :active }
it { is_expected.to match_array([enabled_cluster]) }
end
context 'when scope is inactive' do
let(:scope) { :inactive }
it { is_expected.to match_array([disabled_cluster]) }
end
end
end
...@@ -28,7 +28,7 @@ describe('Clusters', () => { ...@@ -28,7 +28,7 @@ describe('Clusters', () => {
expect( expect(
cluster.toggleButton.classList, cluster.toggleButton.classList,
).not.toContain('checked'); ).not.toContain('is-checked');
expect( expect(
cluster.toggleInput.getAttribute('value'), cluster.toggleInput.getAttribute('value'),
......
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import setClusterTableToggles from '~/clusters/clusters_index';
import { setTimeout } from 'core-js/library/web/timers';
describe('Clusters table', () => {
preloadFixtures('clusters/index_cluster.html.raw');
let mock;
beforeEach(() => {
loadFixtures('clusters/index_cluster.html.raw');
mock = new MockAdapter(axios);
setClusterTableToggles();
});
describe('update cluster', () => {
it('renders loading state while request is made', () => {
const button = document.querySelector('.js-toggle-cluster-list');
button.click();
expect(button.classList).toContain('is-loading');
expect(button.getAttribute('disabled')).toEqual('true');
});
afterEach(() => {
mock.restore();
});
it('shows updated state after sucessfull request', (done) => {
mock.onPut().reply(200, {}, {});
const button = document.querySelector('.js-toggle-cluster-list');
button.click();
expect(button.classList).toContain('is-loading');
setTimeout(() => {
expect(button.classList).not.toContain('is-loading');
expect(button.classList).not.toContain('is-checked');
done();
}, 0);
});
it('shows inital state after failed request', (done) => {
mock.onPut().reply(500, {}, {});
const button = document.querySelector('.js-toggle-cluster-list');
button.click();
expect(button.classList).toContain('is-loading');
setTimeout(() => {
expect(button.classList).not.toContain('is-loading');
expect(button.classList).toContain('is-checked');
done();
}, 0);
});
});
});
...@@ -31,4 +31,19 @@ describe Projects::ClustersController, '(JavaScript fixtures)', type: :controlle ...@@ -31,4 +31,19 @@ describe Projects::ClustersController, '(JavaScript fixtures)', type: :controlle
expect(response).to be_success expect(response).to be_success
store_frontend_fixture(response, example.description) store_frontend_fixture(response, example.description)
end end
context 'rendering non-empty state' do
before do
cluster
end
it 'clusters/index_cluster.html.raw' do |example|
get :index,
namespace_id: namespace,
project_id: project
expect(response).to be_success
store_frontend_fixture(response, example.description)
end
end
end end
import Vue from 'vue';
import toggleButton from '~/vue_shared/components/toggle_button.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
describe('Toggle Button', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(toggleButton);
});
afterEach(() => {
vm.$destroy();
});
describe('render output', () => {
beforeEach(() => {
vm = mountComponent(Component, {
value: true,
name: 'foo',
});
});
it('renders input with provided name', () => {
expect(vm.$el.querySelector('input').getAttribute('name')).toEqual('foo');
});
it('renders input with provided value', () => {
expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true');
});
it('renders Enabled and Disabled text data attributes', () => {
expect(vm.$el.querySelector('button').getAttribute('data-enabled-text')).toEqual('Enabled');
expect(vm.$el.querySelector('button').getAttribute('data-disabled-text')).toEqual('Disabled');
});
});
describe('is-checked', () => {
beforeEach(() => {
vm = mountComponent(Component, {
value: true,
});
spyOn(vm, '$emit');
});
it('renders is checked class', () => {
expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true);
});
it('emits change event when clicked', () => {
vm.$el.querySelector('button').click();
expect(vm.$emit).toHaveBeenCalledWith('change', false);
});
});
describe('is-disabled', () => {
beforeEach(() => {
vm = mountComponent(Component, {
value: true,
disabledInput: true,
});
spyOn(vm, '$emit');
});
it('renders disabled button', () => {
expect(vm.$el.querySelector('button').classList.contains('is-disabled')).toEqual(true);
});
it('does not emit change event when clicked', () => {
vm.$el.querySelector('button').click();
expect(vm.$emit).not.toHaveBeenCalled();
});
});
describe('is-loading', () => {
beforeEach(() => {
vm = mountComponent(Component, {
value: true,
isLoading: true,
});
});
it('renders loading class', () => {
expect(vm.$el.querySelector('button').classList.contains('is-loading')).toEqual(true);
});
});
});
...@@ -57,7 +57,7 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do ...@@ -57,7 +57,7 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do
expect(cluster.platform_type).to eq('kubernetes') expect(cluster.platform_type).to eq('kubernetes')
expect(cluster.project).to eq(project) expect(cluster.project).to eq(project)
expect(project.cluster).to eq(cluster) expect(project.clusters).to include(cluster)
expect(cluster.provider_gcp.cluster).to eq(cluster) expect(cluster.provider_gcp.cluster).to eq(cluster)
expect(cluster.provider_gcp.status).to eq(status) expect(cluster.provider_gcp.status).to eq(status)
...@@ -134,7 +134,7 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do ...@@ -134,7 +134,7 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do
expect(cluster.platform_type).to eq('kubernetes') expect(cluster.platform_type).to eq('kubernetes')
expect(cluster.project).to eq(project) expect(cluster.project).to eq(project)
expect(project.cluster).to eq(cluster) expect(project.clusters).to include(cluster)
expect(cluster.provider_gcp.cluster).to eq(cluster) expect(cluster.provider_gcp.cluster).to eq(cluster)
expect(cluster.provider_gcp.status).to eq(status) expect(cluster.provider_gcp.status).to eq(status)
......
...@@ -198,4 +198,26 @@ describe Clusters::Cluster do ...@@ -198,4 +198,26 @@ describe Clusters::Cluster do
end end
end end
end end
describe '#created?' do
let(:cluster) { create(:cluster, :provided_by_gcp) }
subject { cluster.created? }
context 'when status_name is :created' do
before do
allow(cluster).to receive_message_chain(:provider, :status_name).and_return(:created)
end
it { is_expected.to eq(true) }
end
context 'when status_name is not :created' do
before do
allow(cluster).to receive_message_chain(:provider, :status_name).and_return(:creating)
end
it { is_expected.to eq(false) }
end
end
end end
...@@ -78,7 +78,7 @@ describe Project do ...@@ -78,7 +78,7 @@ describe Project do
it { is_expected.to have_many(:uploads).dependent(:destroy) } it { is_expected.to have_many(:uploads).dependent(:destroy) }
it { is_expected.to have_many(:pipeline_schedules) } it { is_expected.to have_many(:pipeline_schedules) }
it { is_expected.to have_many(:members_and_requesters) } it { is_expected.to have_many(:members_and_requesters) }
it { is_expected.to have_one(:cluster) } it { is_expected.to have_many(:clusters) }
it { is_expected.to have_many(:custom_attributes).class_name('ProjectCustomAttribute') } it { is_expected.to have_many(:custom_attributes).class_name('ProjectCustomAttribute') }
context 'after initialized' do context 'after initialized' do
......
...@@ -31,4 +31,44 @@ describe Clusters::ClusterPresenter do ...@@ -31,4 +31,44 @@ describe Clusters::ClusterPresenter do
it { is_expected.to include(cluster.provider.zone) } it { is_expected.to include(cluster.provider.zone) }
it { is_expected.to include(cluster.name) } it { is_expected.to include(cluster.name) }
end end
describe '#can_toggle_cluster' do
let(:user) { create(:user) }
before do
allow(cluster).to receive(:current_user).and_return(user)
end
subject { described_class.new(cluster).can_toggle_cluster? }
context 'when user can update' do
before do
allow_any_instance_of(described_class).to receive(:can?).with(user, :update_cluster, cluster).and_return(true)
end
context 'when cluster is created' do
before do
allow(cluster).to receive(:created?).and_return(true)
end
it { is_expected.to eq(true) }
end
context 'when cluster is not created' do
before do
allow(cluster).to receive(:created?).and_return(false)
end
it { is_expected.to eq(false) }
end
end
context 'when user can not update' do
before do
allow_any_instance_of(described_class).to receive(:can?).with(user, :update_cluster, cluster).and_return(false)
end
it { is_expected.to eq(false) }
end
end
end end
...@@ -4,10 +4,11 @@ describe Clusters::CreateService do ...@@ -4,10 +4,11 @@ describe Clusters::CreateService do
let(:access_token) { 'xxx' } let(:access_token) { 'xxx' }
let(:project) { create(:project) } let(:project) { create(:project) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:result) { described_class.new(project, user, params).execute(access_token) }
subject { described_class.new(project, user, params).execute(access_token) }
context 'when provider is gcp' do context 'when provider is gcp' do
context 'when correct params' do shared_context 'valid params' do
let(:params) do let(:params) do
{ {
name: 'test-cluster', name: 'test-cluster',
...@@ -20,27 +21,9 @@ describe Clusters::CreateService do ...@@ -20,27 +21,9 @@ describe Clusters::CreateService do
} }
} }
end end
it 'creates a cluster object and performs a worker' do
expect(ClusterProvisionWorker).to receive(:perform_async)
expect { result }
.to change { Clusters::Cluster.count }.by(1)
.and change { Clusters::Providers::Gcp.count }.by(1)
expect(result.name).to eq('test-cluster')
expect(result.user).to eq(user)
expect(result.project).to eq(project)
expect(result.provider.gcp_project_id).to eq('gcp-project')
expect(result.provider.zone).to eq('us-central1-a')
expect(result.provider.num_nodes).to eq(1)
expect(result.provider.machine_type).to eq('machine_type-a')
expect(result.provider.access_token).to eq(access_token)
expect(result.platform).to be_nil
end
end end
context 'when invalid params' do shared_context 'invalid params' do
let(:params) do let(:params) do
{ {
name: 'test-cluster', name: 'test-cluster',
...@@ -53,11 +36,57 @@ describe Clusters::CreateService do ...@@ -53,11 +36,57 @@ describe Clusters::CreateService do
} }
} }
end end
end
shared_examples 'create cluster' do
it 'creates a cluster object and performs a worker' do
expect(ClusterProvisionWorker).to receive(:perform_async)
expect { subject }
.to change { Clusters::Cluster.count }.by(1)
.and change { Clusters::Providers::Gcp.count }.by(1)
expect(subject.name).to eq('test-cluster')
expect(subject.user).to eq(user)
expect(subject.project).to eq(project)
expect(subject.provider.gcp_project_id).to eq('gcp-project')
expect(subject.provider.zone).to eq('us-central1-a')
expect(subject.provider.num_nodes).to eq(1)
expect(subject.provider.machine_type).to eq('machine_type-a')
expect(subject.provider.access_token).to eq(access_token)
expect(subject.platform).to be_nil
end
end
shared_examples 'error' do
it 'returns an error' do it 'returns an error' do
expect(ClusterProvisionWorker).not_to receive(:perform_async) expect(ClusterProvisionWorker).not_to receive(:perform_async)
expect { result }.to change { Clusters::Cluster.count }.by(0) expect { subject }.to change { Clusters::Cluster.count }.by(0)
expect(result.errors[:"provider_gcp.gcp_project_id"]).to be_present expect(subject.errors[:"provider_gcp.gcp_project_id"]).to be_present
end
end
context 'when project has no clusters' do
context 'when correct params' do
include_context 'valid params'
include_examples 'create cluster'
end
context 'when invalid params' do
include_context 'invalid params'
include_examples 'error'
end
end
context 'when project has a cluster' do
include_context 'valid params'
let!(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
it 'does not create a cluster' do
expect(ClusterProvisionWorker).not_to receive(:perform_async)
expect { subject }.to raise_error(ArgumentError).and change { Clusters::Cluster.count }.by(0)
end end
end end
end end
......
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