diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index cdb5c430aa9..2cfd6179a25 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -150,8 +150,8 @@ export default class Clusters { } toggle() { - this.toggleButton.classList.toggle('checked'); - this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('checked').toString()); + this.toggleButton.classList.toggle('is-checked'); + this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('is-checked').toString()); } showToken() { diff --git a/app/assets/javascripts/clusters/clusters_index.js b/app/assets/javascripts/clusters/clusters_index.js new file mode 100644 index 00000000000..6844d1dbd83 --- /dev/null +++ b/app/assets/javascripts/clusters/clusters_index.js @@ -0,0 +1,58 @@ +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.')); + }); + })); +} diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js index ce14c9a9945..755c2981c2e 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -17,4 +17,8 @@ export default class ClusterService { installApplication(appId) { return axios.post(this.appInstallEndpointMap[appId]); } + + static updateCluster(endpoint, data) { + return axios.put(endpoint, data); + } } diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 6d2907e2164..1eab5e5c81e 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -562,7 +562,15 @@ import ProjectVariables from './project_variables'; import(/* webpackChunkName: "clusters" */ './clusters/clusters_bundle') .then(cluster => new cluster.default()) // eslint-disable-line new-cap .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; }); break; diff --git a/app/assets/javascripts/projects/permissions/components/project_feature_setting.vue b/app/assets/javascripts/projects/permissions/components/project_feature_setting.vue index 80c5d39f736..8fce4c63872 100644 --- a/app/assets/javascripts/projects/permissions/components/project_feature_setting.vue +++ b/app/assets/javascripts/projects/permissions/components/project_feature_setting.vue @@ -1,5 +1,5 @@ - - diff --git a/app/assets/javascripts/projects/permissions/components/settings_panel.vue b/app/assets/javascripts/projects/permissions/components/settings_panel.vue index 326d9105666..639429baf26 100644 --- a/app/assets/javascripts/projects/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/projects/permissions/components/settings_panel.vue @@ -1,6 +1,6 @@ + + diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 66212be1b8f..43b16d3cf7d 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -44,6 +44,7 @@ @import "framework/tabs"; @import "framework/timeline"; @import "framework/tooltips"; +@import "framework/toggle"; @import "framework/typography"; @import "framework/zen"; @import "framework/blank"; diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss new file mode 100644 index 00000000000..71765da3908 --- /dev/null +++ b/app/assets/stylesheets/framework/toggle.scss @@ -0,0 +1,138 @@ +/** +* Toggle button +* +* @usage +* ### Active and Inactive text should be provided as data attributes: +* + +* ### Checked should have `is-checked` class +* + +* ### Disabled should have `is-disabled` class +* + +* ### Loading should have `is-loading` and an icon with `loading-icon` class +* +*/ +.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; } + } +} diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 83e211d6086..c303f016ff9 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -14,3 +14,17 @@ } @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; + } +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 9345177a4dc..674588752d2 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -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, .group-home-panel { padding-top: 24px; diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index d18b6d4b78c..0907daacbc3 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -8,11 +8,11 @@ class Projects::ClustersController < Projects::ApplicationController STATUS_POLLING_INTERVAL = 10_000 def index - if project.cluster - redirect_to project_cluster_path(project, project.cluster) - else - redirect_to new_project_cluster_path(project) - end + @scope = params[:scope] || 'all' + @clusters = ClustersFinder.new(project, current_user, @scope).execute.page(params[:page]) + @active_count = ClustersFinder.new(project, current_user, :active).execute.count + @inactive_count = ClustersFinder.new(project, current_user, :inactive).execute.count + @all_count = @active_count + @inactive_count end def new @@ -39,10 +39,20 @@ class Projects::ClustersController < Projects::ApplicationController .execute(cluster) if cluster.valid? - flash[:notice] = "Cluster was successfully updated." - redirect_to project_cluster_path(project, project.cluster) + respond_to do |format| + 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 - render :show + respond_to do |format| + format.json { head :bad_request } + format.html { render :show } + end end end @@ -63,6 +73,19 @@ class Projects::ClustersController < Projects::ApplicationController .present(current_user: current_user) 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 if cluster.managed? params.require(:cluster).permit( diff --git a/app/finders/clusters_finder.rb b/app/finders/clusters_finder.rb new file mode 100644 index 00000000000..c13f98257bf --- /dev/null +++ b/app/finders/clusters_finder.rb @@ -0,0 +1,29 @@ +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 diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 45beced1427..55419189282 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -55,6 +55,10 @@ module Clusters end end + def created? + status_name == :created + end + def applications [ application_helm || build_application_helm, diff --git a/app/models/project.rb b/app/models/project.rb index cc530076bf7..41657c171e2 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -189,7 +189,6 @@ class Project < ActiveRecord::Base has_one :statistics, class_name: 'ProjectStatistics' 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' # Container repositories need to remove data from the container registry, diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index 01cb59d0d44..a424da5ab24 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -5,5 +5,9 @@ module Clusters def gke_cluster_url "https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp? end + + def can_toggle_cluster? + can?(current_user, :update_cluster, cluster) && created? + end end end diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb index 7b697f6d807..0471b0f17a2 100644 --- a/app/services/clusters/create_service.rb +++ b/app/services/clusters/create_service.rb @@ -5,6 +5,8 @@ module Clusters def execute(access_token = nil) @access_token = access_token + raise ArgumentError.new('Instance does not support multiple clusters') unless can_create_cluster? + create_cluster.tap do |cluster| ClusterProvisionWorker.perform_async(cluster.id) if cluster.persisted? end @@ -25,5 +27,9 @@ module Clusters @cluster_params = params.merge(user: current_user, projects: [project]) end + + def can_create_cluster? + project.clusters.empty? + end end end diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 8b2d2a5c74d..53a9162b703 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -187,7 +187,7 @@ = nav_link(controller: [:clusters, :user, :gcp]) do = link_to project_clusters_path(@project), title: 'Cluster', class: 'shortcuts-cluster' do %span - Cluster + Clusters - if @project.feature_available?(:builds, current_user) && !@project.empty_repo? = nav_link(path: 'pipelines#charts') do diff --git a/app/views/projects/clusters/_cluster.html.haml b/app/views/projects/clusters/_cluster.html.haml new file mode 100644 index 00000000000..18ca01d2d49 --- /dev/null +++ b/app/views/projects/clusters/_cluster.html.haml @@ -0,0 +1,22 @@ +.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") diff --git a/app/views/projects/clusters/_empty_state.html.haml b/app/views/projects/clusters/_empty_state.html.haml new file mode 100644 index 00000000000..e629cc58b06 --- /dev/null +++ b/app/views/projects/clusters/_empty_state.html.haml @@ -0,0 +1,12 @@ +.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' + diff --git a/app/views/projects/clusters/_enabled.html.haml b/app/views/projects/clusters/_enabled.html.haml index f4d261df8f5..70c677f7856 100644 --- a/app/views/projects/clusters/_enabled.html.haml +++ b/app/views/projects/clusters/_enabled.html.haml @@ -5,12 +5,11 @@ = field.hidden_field :enabled, { class: 'js-toggle-input'} %button{ type: 'button', - class: "js-toggle-cluster project-feature-toggle #{'checked' unless !@cluster.enabled?} #{'disabled' unless can?(current_user, :update_cluster, @cluster)}", - 'aria-label': s_('ClusterIntegration|Toggle 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"), 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) .form-group = field.submit _('Save'), class: 'btn btn-success' - diff --git a/app/views/projects/clusters/_tabs.html.haml b/app/views/projects/clusters/_tabs.html.haml new file mode 100644 index 00000000000..c8120e806fa --- /dev/null +++ b/app/views/projects/clusters/_tabs.html.haml @@ -0,0 +1,16 @@ +.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 diff --git a/app/views/projects/clusters/index.html.haml b/app/views/projects/clusters/index.html.haml new file mode 100644 index 00000000000..104e39b0e06 --- /dev/null +++ b/app/views/projects/clusters/index.html.haml @@ -0,0 +1,24 @@ +- 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") diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index d23efe4d9aa..fe6dacf1f0d 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -1,5 +1,6 @@ - @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") - expanded = Rails.env.test? @@ -28,7 +29,6 @@ %button.btn.js-settings-toggle = expanded ? 'Collapse' : 'Expand' %p= s_('ClusterIntegration|See and edit the details for your cluster') - .settings-content - if @cluster.managed? = render 'projects/clusters/gcp/show' diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 32afb7b06e4..2220cc72502 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-10-22 16:40+0300\n" -"PO-Revision-Date: 2017-10-22 16:40+0300\n" +"POT-Creation-Date: 2017-12-05 20:31+0100\n" +"PO-Revision-Date: 2017-12-05 20:31+0100\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" @@ -36,6 +36,11 @@ msgstr[1] "" msgid "%{commit_author_link} committed %{commit_timeago}" msgstr "" +msgid "%{count} participant" +msgid_plural "%{count} participants" +msgstr[0] "" +msgstr[1] "" + msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead" msgstr "" @@ -53,9 +58,18 @@ msgid_plural "%{storage_name}: %{failed_attempts} failed storage access attempts msgstr[0] "" msgstr[1] "" +msgid "%{text} is available" +msgstr "" + msgid "(checkout the %{link} for information on how to install it)." msgstr "" +msgid "+ %{moreCount} more" +msgstr "" + +msgid "- show less" +msgstr "" + msgid "1 pipeline" msgid_plural "%d pipelines" msgstr[0] "" @@ -100,9 +114,6 @@ msgstr "" msgid "Add License" msgstr "" -msgid "Add an SSH key to your profile to pull or push via SSH." -msgstr "" - msgid "Add new directory" msgstr "" @@ -115,6 +126,12 @@ msgstr "" msgid "All" msgstr "" +msgid "An error occurred when toggling the notification subscription" +msgstr "" + +msgid "An error occurred while fetching sidebar data" +msgstr "" + msgid "An error occurred. Please try again." msgstr "" @@ -124,6 +141,12 @@ msgstr "" msgid "Applications" msgstr "" +msgid "Apr" +msgstr "" + +msgid "April" +msgstr "" + msgid "Archived project! Repository is read-only" msgstr "" @@ -151,6 +174,12 @@ msgstr "" msgid "Attach a file by drag & drop or %{upload_link}" msgstr "" +msgid "Aug" +msgstr "" + +msgid "August" +msgstr "" + msgid "Authentication Log" msgstr "" @@ -184,6 +213,9 @@ msgstr "" msgid "AutoDevOps|You can activate %{link_to_settings} for this project." msgstr "" +msgid "Available" +msgstr "" + msgid "Branch" msgid_plural "Branches" msgstr[0] "" @@ -195,6 +227,12 @@ msgstr "" msgid "Branch has changed" msgstr "" +msgid "Branch is already taken" +msgstr "" + +msgid "Branch name" +msgstr "" + msgid "BranchSwitcherPlaceholder|Search branches" msgstr "" @@ -330,6 +368,12 @@ msgstr "" msgid "Chat" msgstr "" +msgid "Checking %{text} availability…" +msgstr "" + +msgid "Checking branch availability..." +msgstr "" + msgid "Cherry-pick this commit" msgstr "" @@ -399,7 +443,40 @@ msgstr "" msgid "Cluster" msgstr "" -msgid "ClusterIntegration|This account must have permissions to create a cluster in the %{link_to_container_project} specified below" +msgid "ClusterIntegration|%{appList} was successfully installed on your cluster" +msgstr "" + +msgid "ClusterIntegration|%{boldNotice} This will add some extra resources like a load balancer, which incur additional costs. See %{pricingLink}" +msgstr "" + +msgid "ClusterIntegration|API URL" +msgstr "" + +msgid "ClusterIntegration|Active" +msgstr "" + +msgid "ClusterIntegration|Add an existing cluster" +msgstr "" + +msgid "ClusterIntegration|Add cluster" +msgstr "" + +msgid "ClusterIntegration|All" +msgstr "" + +msgid "ClusterIntegration|Applications" +msgstr "" + +msgid "ClusterIntegration|CA Certificate" +msgstr "" + +msgid "ClusterIntegration|Certificate Authority bundle (PEM format)" +msgstr "" + +msgid "ClusterIntegration|Choose how to set up cluster integration" +msgstr "" + +msgid "ClusterIntegration|Cluster" msgstr "" msgid "ClusterIntegration|Cluster details" @@ -423,21 +500,54 @@ msgstr "" msgid "ClusterIntegration|Cluster name" msgstr "" -msgid "ClusterIntegration|Cluster was successfully created on Google Container Engine" +msgid "ClusterIntegration|Cluster was successfully created on Google Container Engine. Refresh the page to see cluster's details" +msgstr "" + +msgid "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}" +msgstr "" + +msgid "ClusterIntegration|Copy API URL" +msgstr "" + +msgid "ClusterIntegration|Copy CA Certificate" +msgstr "" + +msgid "ClusterIntegration|Copy Token" msgstr "" msgid "ClusterIntegration|Copy cluster name" msgstr "" +msgid "ClusterIntegration|Create a new cluster on Google Engine right from GitLab" +msgstr "" + msgid "ClusterIntegration|Create cluster" msgstr "" -msgid "ClusterIntegration|Create new cluster on Google Container Engine" +msgid "ClusterIntegration|Create cluster on Google Container Engine" +msgstr "" + +msgid "ClusterIntegration|Create on GKE" msgstr "" msgid "ClusterIntegration|Enable cluster integration" msgstr "" +msgid "ClusterIntegration|Enter the details for an existing Kubernetes cluster" +msgstr "" + +msgid "ClusterIntegration|Enter the details for your cluster" +msgstr "" + +msgid "ClusterIntegration|Environment pattern" +msgstr "" + +msgid "ClusterIntegration|GKE pricing" +msgstr "" + +msgid "ClusterIntegration|GitLab Runner" +msgstr "" + msgid "ClusterIntegration|Google Cloud Platform project ID" msgstr "" @@ -447,27 +557,75 @@ msgstr "" msgid "ClusterIntegration|Google Container Engine project" msgstr "" +msgid "ClusterIntegration|Helm Tiller" +msgstr "" + +msgid "ClusterIntegration|Inactive" +msgstr "" + +msgid "ClusterIntegration|Ingress" +msgstr "" + +msgid "ClusterIntegration|Install" +msgstr "" + +msgid "ClusterIntegration|Install applications on your cluster. Read more about %{helpLink}" +msgstr "" + +msgid "ClusterIntegration|Installed" +msgstr "" + +msgid "ClusterIntegration|Installing" +msgstr "" + +msgid "ClusterIntegration|Integrate cluster automation" +msgstr "" + msgid "ClusterIntegration|Learn more about %{link_to_documentation}" msgstr "" +msgid "ClusterIntegration|Learn more about Clusters" +msgstr "" + msgid "ClusterIntegration|Machine type" msgstr "" msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters" msgstr "" -msgid "ClusterIntegration|Manage Cluster integration on your GitLab project" +msgid "ClusterIntegration|Manage cluster integration on your GitLab project" msgstr "" msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}" msgstr "" +msgid "ClusterIntegration|Multiple clusters are available in GitLab Entreprise Edition Premium and Ultimate" +msgstr "" + +msgid "ClusterIntegration|Note:" +msgstr "" + msgid "ClusterIntegration|Number of nodes" msgstr "" +msgid "ClusterIntegration|Please enter access information for your cluster. If you need help, you can read our %{link_to_help_page} on clusters" +msgstr "" + msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:" msgstr "" +msgid "ClusterIntegration|Problem setting up the cluster" +msgstr "" + +msgid "ClusterIntegration|Problem setting up the clusters list" +msgstr "" + +msgid "ClusterIntegration|Project ID" +msgstr "" + +msgid "ClusterIntegration|Project namespace" +msgstr "" + msgid "ClusterIntegration|Project namespace (optional, unique)" msgstr "" @@ -483,6 +641,12 @@ msgstr "" msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your cluster on Google Container Engine." msgstr "" +msgid "ClusterIntegration|Request to begin installing failed" +msgstr "" + +msgid "ClusterIntegration|Save changes" +msgstr "" + msgid "ClusterIntegration|See and edit the details for your cluster" msgstr "" @@ -495,15 +659,33 @@ msgstr "" msgid "ClusterIntegration|See zones" msgstr "" +msgid "ClusterIntegration|Service token" +msgstr "" + +msgid "ClusterIntegration|Show" +msgstr "" + msgid "ClusterIntegration|Something went wrong on our end." msgstr "" msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine" msgstr "" +msgid "ClusterIntegration|Something went wrong while installing %{title}" +msgstr "" + +msgid "ClusterIntegration|There are no clusters to show" +msgstr "" + +msgid "ClusterIntegration|This account must have permissions to create a cluster in the %{link_to_container_project} specified below" +msgstr "" + msgid "ClusterIntegration|Toggle Cluster" msgstr "" +msgid "ClusterIntegration|Token" +msgstr "" + msgid "ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way." msgstr "" @@ -519,9 +701,15 @@ msgstr "" msgid "ClusterIntegration|cluster" msgstr "" +msgid "ClusterIntegration|documentation" +msgstr "" + msgid "ClusterIntegration|help page" msgstr "" +msgid "ClusterIntegration|installing applications" +msgstr "" + msgid "ClusterIntegration|meets the requirements" msgstr "" @@ -617,6 +805,15 @@ msgstr "" msgid "Contributors" msgstr "" +msgid "ContributorsPage|Building repository graph." +msgstr "" + +msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits." +msgstr "" + +msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready." +msgstr "" + msgid "Copy URL to clipboard" msgstr "" @@ -635,12 +832,21 @@ msgstr "" msgid "Create empty bare repository" msgstr "" +msgid "Create file" +msgstr "" + msgid "Create merge request" msgstr "" msgid "Create new branch" msgstr "" +msgid "Create new directory" +msgstr "" + +msgid "Create new file" +msgstr "" + msgid "Create new..." msgstr "" @@ -698,6 +904,12 @@ msgstr "" msgid "DashboardProjects|Personal" msgstr "" +msgid "Dec" +msgstr "" + +msgid "December" +msgstr "" + msgid "Define a custom pattern with cron syntax" msgstr "" @@ -766,6 +978,60 @@ msgstr "" msgid "Emails" msgstr "" +msgid "Environments|An error occurred while fetching the environments." +msgstr "" + +msgid "Environments|An error occurred while making the request." +msgstr "" + +msgid "Environments|Commit" +msgstr "" + +msgid "Environments|Deployment" +msgstr "" + +msgid "Environments|Environment" +msgstr "" + +msgid "Environments|Environments" +msgstr "" + +msgid "Environments|Environments are places where code gets deployed, such as staging or production." +msgstr "" + +msgid "Environments|Job" +msgstr "" + +msgid "Environments|New environment" +msgstr "" + +msgid "Environments|No deployments yet" +msgstr "" + +msgid "Environments|Open" +msgstr "" + +msgid "Environments|Re-deploy" +msgstr "" + +msgid "Environments|Read more about environments" +msgstr "" + +msgid "Environments|Rollback" +msgstr "" + +msgid "Environments|Show all" +msgstr "" + +msgid "Environments|Updated" +msgstr "" + +msgid "Environments|You don't have any environments right now." +msgstr "" + +msgid "Error occurred when toggling the notification subscription" +msgstr "" + msgid "EventFilterBy|Filter by all" msgstr "" @@ -805,6 +1071,15 @@ msgstr "" msgid "Failed to remove the pipeline schedule" msgstr "" +msgid "Feb" +msgstr "" + +msgid "February" +msgstr "" + +msgid "File name" +msgstr "" + msgid "Files" msgstr "" @@ -981,6 +1256,24 @@ msgstr "" msgid "Issues" msgstr "" +msgid "Jan" +msgstr "" + +msgid "January" +msgstr "" + +msgid "Jul" +msgstr "" + +msgid "July" +msgstr "" + +msgid "Jun" +msgstr "" + +msgid "June" +msgstr "" + msgid "LFSStatus|Disabled" msgstr "" @@ -1048,9 +1341,18 @@ msgstr "" msgid "Login" msgstr "" +msgid "Mar" +msgstr "" + +msgid "March" +msgstr "" + msgid "Maximum git storage failures" msgstr "" +msgid "May" +msgstr "" + msgid "Median" msgstr "" @@ -1092,6 +1394,9 @@ msgstr "" msgid "New branch" msgstr "" +msgid "New branch unavailable" +msgstr "" + msgid "New directory" msgstr "" @@ -1131,6 +1436,12 @@ msgstr "" msgid "No schedules" msgstr "" +msgid "No time spent" +msgstr "" + +msgid "None" +msgstr "" + msgid "Not available" msgstr "" @@ -1194,6 +1505,24 @@ msgstr "" msgid "Notifications" msgstr "" +msgid "Nov" +msgstr "" + +msgid "November" +msgstr "" + +msgid "Number of access attempts" +msgstr "" + +msgid "Number of failures before backing off" +msgstr "" + +msgid "Oct" +msgstr "" + +msgid "October" +msgstr "" + msgid "OfSearchInADropdown|Filter" msgstr "" @@ -1431,6 +1760,12 @@ msgstr "" msgid "ProjectNetworkGraph|Graph" msgstr "" +msgid "ProjectSettings|Immediately run a pipeline on the default branch" +msgstr "" + +msgid "ProjectSettings|Problem setting up the CI/CD settings JavaScript" +msgstr "" + msgid "Projects" msgstr "" @@ -1455,6 +1790,39 @@ msgstr "" msgid "ProjectsDropdown|This feature requires browser localStorage support" msgstr "" +msgid "PrometheusService|By default, Prometheus listens on ‘http://localhost:9090’. It’s not recommended to change the default address and port as this might affect or conflict with other services running on the GitLab server." +msgstr "" + +msgid "PrometheusService|Finding and configuring metrics..." +msgstr "" + +msgid "PrometheusService|Metrics" +msgstr "" + +msgid "PrometheusService|Metrics are automatically configured and monitored based on a library of metrics from popular exporters." +msgstr "" + +msgid "PrometheusService|Missing environment variable" +msgstr "" + +msgid "PrometheusService|Monitored" +msgstr "" + +msgid "PrometheusService|More information" +msgstr "" + +msgid "PrometheusService|No metrics are being monitored. To start monitoring, deploy to an environment." +msgstr "" + +msgid "PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/" +msgstr "" + +msgid "PrometheusService|Prometheus monitoring" +msgstr "" + +msgid "PrometheusService|View environments" +msgstr "" + msgid "Public - The group and any public projects can be viewed without any authentication." msgstr "" @@ -1563,6 +1931,12 @@ msgstr "" msgid "Select target branch" msgstr "" +msgid "Sep" +msgstr "" + +msgid "September" +msgstr "" + msgid "Service Templates" msgstr "" @@ -1601,7 +1975,7 @@ msgstr "" msgid "Something went wrong on our end." msgstr "" -msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}" +msgid "Something went wrong trying to change the locked state of this ${this.issuableDisplayName}" msgstr "" msgid "Something went wrong while fetching the projects." @@ -1700,9 +2074,15 @@ msgstr "" msgid "SortOptions|Start soon" msgstr "" +msgid "Source" +msgstr "" + msgid "Source code" msgstr "" +msgid "Source is not available" +msgstr "" + msgid "Spam Logs" msgstr "" @@ -1721,9 +2101,15 @@ msgstr "" msgid "Start the Runner!" msgstr "" +msgid "Stopped" +msgstr "" + msgid "Subgroups" msgstr "" +msgid "Subscribe" +msgstr "" + msgid "Switch branch/tag" msgstr "" @@ -1738,12 +2124,84 @@ msgstr[1] "" msgid "Tags" msgstr "" +msgid "TagsPage|Browse commits" +msgstr "" + +msgid "TagsPage|Browse files" +msgstr "" + +msgid "TagsPage|Can't find HEAD commit for this tag" +msgstr "" + +msgid "TagsPage|Cancel" +msgstr "" + +msgid "TagsPage|Create tag" +msgstr "" + +msgid "TagsPage|Delete tag" +msgstr "" + +msgid "TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?" +msgstr "" + +msgid "TagsPage|Edit release notes" +msgstr "" + +msgid "TagsPage|Existing branch name, tag, or commit SHA" +msgstr "" + +msgid "TagsPage|Filter by tag name" +msgstr "" + +msgid "TagsPage|New Tag" +msgstr "" + +msgid "TagsPage|New tag" +msgstr "" + +msgid "TagsPage|Optionally, add a message to the tag." +msgstr "" + +msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page." +msgstr "" + +msgid "TagsPage|Release notes" +msgstr "" + +msgid "TagsPage|Repository has no tags yet." +msgstr "" + +msgid "TagsPage|Sort by" +msgstr "" + +msgid "TagsPage|Tags" +msgstr "" + +msgid "TagsPage|Tags give the ability to mark specific points in history as being important" +msgstr "" + +msgid "TagsPage|This tag has no release notes." +msgstr "" + +msgid "TagsPage|Use git tag command to add a new one:" +msgstr "" + +msgid "TagsPage|Write your release notes or drag files here..." +msgstr "" + +msgid "TagsPage|protected" +msgstr "" + msgid "Target Branch" msgstr "" msgid "Team" msgstr "" +msgid "The circuitbreaker backoff threshold should be lower than the failure count threshold" +msgstr "" + msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" @@ -1756,6 +2214,12 @@ msgstr "" msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "" +msgid "The number of attempts GitLab will make to access a storage." +msgstr "" + +msgid "The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host" +msgstr "" + msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}." msgstr "" @@ -1976,6 +2440,9 @@ msgstr "" msgid "Total Time" msgstr "" +msgid "Total issue time spent" +msgstr "" + msgid "Total test time for all commits/merges" msgstr "" @@ -1988,6 +2455,9 @@ msgstr "" msgid "Unstar" msgstr "" +msgid "Unsubscribe" +msgstr "" + msgid "Upload New File" msgstr "" @@ -2189,6 +2659,9 @@ msgstr "" msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile" msgstr "" +msgid "You won't be able to pull or push project code via SSH until you add an SSH key to your profile" +msgstr "" + msgid "Your comment will not be visible to the public." msgstr "" @@ -2201,6 +2674,9 @@ msgstr "" msgid "Your projects" msgstr "" +msgid "branch name" +msgstr "" + msgid "day" msgid_plural "days" msgstr[0] "" @@ -2223,5 +2699,8 @@ msgstr "" msgid "personal access token" msgstr "" +msgid "source" +msgstr "" + msgid "username" msgstr "" diff --git a/spec/controllers/projects/clusters/gcp_controller_spec.rb b/spec/controllers/projects/clusters/gcp_controller_spec.rb index bb5ef7bb365..ee7928beb7e 100644 --- a/spec/controllers/projects/clusters/gcp_controller_spec.rb +++ b/spec/controllers/projects/clusters/gcp_controller_spec.rb @@ -143,9 +143,9 @@ describe Projects::Clusters::GcpController do expect(ClusterProvisionWorker).to receive(:perform_async) expect { go }.to change { Clusters::Cluster.count } .and change { Clusters::Providers::Gcp.count } - expect(response).to redirect_to(project_cluster_path(project, project.cluster)) - expect(project.cluster).to be_gcp - expect(project.cluster).to be_kubernetes + expect(response).to redirect_to(project_cluster_path(project, project.clusters.first)) + expect(project.clusters.first).to be_gcp + expect(project.clusters.first).to be_kubernetes end end end diff --git a/spec/controllers/projects/clusters/user_controller_spec.rb b/spec/controllers/projects/clusters/user_controller_spec.rb index 22005e0dc66..913976d187f 100644 --- a/spec/controllers/projects/clusters/user_controller_spec.rb +++ b/spec/controllers/projects/clusters/user_controller_spec.rb @@ -64,7 +64,9 @@ describe Projects::Clusters::UserController do expect(ClusterProvisionWorker).to receive(:perform_async) expect { go }.to change { Clusters::Cluster.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 diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb index 66e67652dad..280b7e4d8b9 100644 --- a/spec/controllers/projects/clusters_controller_spec.rb +++ b/spec/controllers/projects/clusters_controller_spec.rb @@ -15,14 +15,72 @@ describe Projects::ClustersController do sign_in(user) end - context 'when project has a cluster' do - let!(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } + context 'when project has one or more clusters' do + 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 - it { expect(go).to redirect_to(project_cluster_path(project, project.cluster)) } + 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 } + + 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 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 @@ -146,7 +204,7 @@ describe Projects::ClustersController do go 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(cluster.enabled).to be_falsey end @@ -180,28 +238,77 @@ describe Projects::ClustersController do sign_in(user) end - context 'when changing parameters' do - let(:params) do - { - cluster: { - enabled: false, - name: 'my-new-cluster-name', - platform_kubernetes_attributes: { - namespace: 'my-namespace' + context 'when format is json' do + context 'when changing parameters' do + context 'when valid parameters are used' do + let(:params) do + { + cluster: { + enabled: false, + 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 + + context 'when format is html' do + context 'when update enabled' do + let(:params) do + { + cluster: { + enabled: false, + name: 'my-new-cluster-name', + platform_kubernetes_attributes: { + namespace: 'my-namespace' + } } } - } - end + end - it "updates and redirects back to show page" do - go + it "updates and redirects back to show page" do + go - cluster.reload - expect(response).to redirect_to(project_cluster_path(project, project.cluster)) - 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') + cluster.reload + expect(response).to redirect_to(project_cluster_path(project, cluster)) + 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 @@ -228,6 +335,13 @@ describe Projects::ClustersController do project_id: project, id: cluster) end + + def go_json + put :update, params.merge(namespace_id: project.namespace, + project_id: project, + id: cluster, + format: :json) + end end describe 'DELETE destroy' do diff --git a/spec/factories/clusters/clusters.rb b/spec/factories/clusters/clusters.rb index 81866845a20..9e73a19e856 100644 --- a/spec/factories/clusters/clusters.rb +++ b/spec/factories/clusters/clusters.rb @@ -28,5 +28,9 @@ FactoryGirl.define do provider_type :gcp provider_gcp factory: [:cluster_provider_gcp, :creating] end + + trait :disabled do + enabled false + end end end diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index 8a0da669147..5a00b463960 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -24,6 +24,7 @@ feature 'Gcp Cluster', :js do before do visit project_clusters_path(project) + click_link 'Add cluster' click_link 'Create on GKE' end @@ -116,7 +117,7 @@ feature 'Gcp Cluster', :js 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_link('Create on GKE') + expect(page).to have_link('Add cluster') end end end @@ -126,6 +127,7 @@ feature 'Gcp Cluster', :js do before do visit project_clusters_path(project) + click_link 'Add cluster' click_link 'Create on GKE' end diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb index e97ba88f2f4..414f4acba86 100644 --- a/spec/features/projects/clusters/user_spec.rb +++ b/spec/features/projects/clusters/user_spec.rb @@ -16,6 +16,7 @@ feature 'User Cluster', :js do before do visit project_clusters_path(project) + click_link 'Add cluster' click_link 'Add an existing cluster' end @@ -94,7 +95,7 @@ feature 'User Cluster', :js 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_link('Add an existing cluster') + expect(page).to have_link('Add cluster') end end end diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb index 4243c4fd266..008bdf2044b 100644 --- a/spec/features/projects/clusters_spec.rb +++ b/spec/features/projects/clusters_spec.rb @@ -14,12 +14,78 @@ feature 'Clusters', :js do context 'when user does not have a cluster and visits cluster index page' do before do visit project_clusters_path(project) - - click_link 'Create on GKE' end - it 'user sees a new page' do - expect(page).to have_button('Create cluster') + it 'sees empty state' do + expect(page).to have_link('Add cluster') + expect(page).to have_selector('.empty-state') + 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 cluster details page' do + expect(page).to have_button('Save') + expect(page.find(:css, '.cluster-name').value).to eq(cluster.name) + end end end end diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index 951456763dc..033c45a60bf 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -177,7 +177,7 @@ describe 'Edit Project Settings' do click_button "Save changes" 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 it "shows empty features project homepage" do @@ -272,10 +272,10 @@ describe 'Edit Project Settings' do end 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 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 diff --git a/spec/finders/clusters_finder_spec.rb b/spec/finders/clusters_finder_spec.rb new file mode 100644 index 00000000000..c10efac2432 --- /dev/null +++ b/spec/finders/clusters_finder_spec.rb @@ -0,0 +1,31 @@ +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 diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js index 6d6e71cc215..f5be9ea0fb2 100644 --- a/spec/javascripts/clusters/clusters_bundle_spec.js +++ b/spec/javascripts/clusters/clusters_bundle_spec.js @@ -28,7 +28,7 @@ describe('Clusters', () => { expect( cluster.toggleButton.classList, - ).not.toContain('checked'); + ).not.toContain('is-checked'); expect( cluster.toggleInput.getAttribute('value'), diff --git a/spec/javascripts/clusters/clusters_index_spec.js b/spec/javascripts/clusters/clusters_index_spec.js new file mode 100644 index 00000000000..0a8b63ed5b4 --- /dev/null +++ b/spec/javascripts/clusters/clusters_index_spec.js @@ -0,0 +1,58 @@ +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); + }); + }); +}); diff --git a/spec/javascripts/fixtures/clusters.rb b/spec/javascripts/fixtures/clusters.rb index 8e74c4f859c..d26ea3febe8 100644 --- a/spec/javascripts/fixtures/clusters.rb +++ b/spec/javascripts/fixtures/clusters.rb @@ -31,4 +31,19 @@ describe Projects::ClustersController, '(JavaScript fixtures)', type: :controlle expect(response).to be_success store_frontend_fixture(response, example.description) 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 diff --git a/spec/javascripts/vue_shared/components/toggle_button_spec.js b/spec/javascripts/vue_shared/components/toggle_button_spec.js new file mode 100644 index 00000000000..447d74d4e08 --- /dev/null +++ b/spec/javascripts/vue_shared/components/toggle_button_spec.js @@ -0,0 +1,91 @@ +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); + }); + }); +}); diff --git a/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb b/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb index 9f41534441b..05f281fffff 100644 --- a/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb +++ b/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb @@ -57,7 +57,7 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do expect(cluster.platform_type).to eq('kubernetes') 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.status).to eq(status) @@ -134,7 +134,7 @@ describe MigrateGcpClustersToNewClustersArchitectures, :migration do expect(cluster.platform_type).to eq('kubernetes') 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.status).to eq(status) diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index 7f43e747000..2683d21ddbe 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -198,4 +198,26 @@ describe Clusters::Cluster do 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 diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 6bda1eb15a8..bda1d1cb612 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -78,7 +78,7 @@ describe Project do it { is_expected.to have_many(:uploads).dependent(:destroy) } it { is_expected.to have_many(:pipeline_schedules) } 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') } context 'after initialized' do diff --git a/spec/presenters/clusters/cluster_presenter_spec.rb b/spec/presenters/clusters/cluster_presenter_spec.rb index 48d4f3671c5..e96dbfb73c0 100644 --- a/spec/presenters/clusters/cluster_presenter_spec.rb +++ b/spec/presenters/clusters/cluster_presenter_spec.rb @@ -31,4 +31,44 @@ describe Clusters::ClusterPresenter do it { is_expected.to include(cluster.provider.zone) } it { is_expected.to include(cluster.name) } 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 diff --git a/spec/services/clusters/create_service_spec.rb b/spec/services/clusters/create_service_spec.rb index 5b6edb73beb..e2e64659dfa 100644 --- a/spec/services/clusters/create_service_spec.rb +++ b/spec/services/clusters/create_service_spec.rb @@ -4,10 +4,11 @@ describe Clusters::CreateService do let(:access_token) { 'xxx' } let(:project) { create(:project) } 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 correct params' do + shared_context 'valid params' do let(:params) do { name: 'test-cluster', @@ -20,27 +21,9 @@ describe Clusters::CreateService do } } 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 - context 'when invalid params' do + shared_context 'invalid params' do let(:params) do { name: 'test-cluster', @@ -53,11 +36,57 @@ describe Clusters::CreateService do } } 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 expect(ClusterProvisionWorker).not_to receive(:perform_async) - expect { result }.to change { Clusters::Cluster.count }.by(0) - expect(result.errors[:"provider_gcp.gcp_project_id"]).to be_present + expect { subject }.to change { Clusters::Cluster.count }.by(0) + 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