summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js4
-rw-r--r--app/assets/javascripts/clusters/clusters_index.js57
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js4
-rw-r--r--app/assets/javascripts/dispatcher.js10
-rw-r--r--app/assets/javascripts/projects/permissions/components/project_feature_setting.vue2
-rw-r--r--app/assets/javascripts/projects/permissions/components/project_feature_toggle.vue51
-rw-r--r--app/assets/javascripts/projects/permissions/components/settings_panel.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/toggle_button.vue77
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/toggle.scss138
-rw-r--r--app/assets/stylesheets/pages/clusters.scss10
-rw-r--r--app/assets/stylesheets/pages/projects.scss87
-rw-r--r--app/controllers/projects/clusters_controller.rb42
-rw-r--r--app/finders/clusters_finder.rb29
-rw-r--r--app/helpers/clusters_helper.rb5
-rw-r--r--app/models/clusters/cluster.rb4
-rw-r--r--app/services/clusters/create_service.rb6
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml2
-rw-r--r--app/views/projects/clusters/_cluster.html.haml22
-rw-r--r--app/views/projects/clusters/_empty_state.html.haml12
-rw-r--r--app/views/projects/clusters/_tabs.html.haml18
-rw-r--r--app/views/projects/clusters/index.html.haml24
-rw-r--r--app/views/projects/clusters/show.html.haml3
23 files changed, 456 insertions, 154 deletions
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..3fd188a8770
--- /dev/null
+++ b/app/assets/javascripts/clusters/clusters_index.js
@@ -0,0 +1,57 @@
+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 loding and disabled state
+ * - 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 value = toggleButton.classList.contains('checked').toString();
+ const endpoint = toggleButton.getAttribute('data-endpoint');
+
+ toggleValue(toggleButton);
+ toggleLoadingButton(toggleButton);
+
+ 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 a21c92f24d6..7793140e608 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -558,7 +558,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 @@
<script>
-import projectFeatureToggle from './project_feature_toggle.vue';
+import projectFeatureToggle from '../../../vue_shared/components/toggle_button.vue';
export default {
props: {
diff --git a/app/assets/javascripts/projects/permissions/components/project_feature_toggle.vue b/app/assets/javascripts/projects/permissions/components/project_feature_toggle.vue
deleted file mode 100644
index 2403c60186a..00000000000
--- a/app/assets/javascripts/projects/permissions/components/project_feature_toggle.vue
+++ /dev/null
@@ -1,51 +0,0 @@
-<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>
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 @@
<script>
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 { visibilityOptions, visibilityLevelDescriptions } from '../constants';
import { toggleHiddenClassBySelector } from '../external';
diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue
new file mode 100644
index 00000000000..ddc9ddbc3a3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue
@@ -0,0 +1,77 @@
+<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>
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:
+* <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; }
+ }
+}
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index 83e211d6086..b5ac6db04ad 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -14,3 +14,13 @@
}
@include new-style-dropdown('.clusters-dropdown ');
+
+.clusters-container {
+ .nav-bar-right {
+ padding: $gl-padding-top $gl-padding;
+ }
+
+ .empty-state .svg-content img {
+ width: 145px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 2dc0c288a6d..2856af37f8d 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 639874e6231..ae22243c0ee 100644
--- a/app/controllers/projects/clusters_controller.rb
+++ b/app/controllers/projects/clusters_controller.rb
@@ -8,11 +8,12 @@ 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
+ @clusters = clusters.page(params[:page])
+ @active_count = project.clusters.enabled.count
+ @inactive_count = project.clusters.disabled.count
+ @all_count = @active_count + @inactive_count
end
def new
@@ -39,10 +40,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, project.cluster)
+ end
+ end
else
- render :show
+ respond_to do |format|
+ format.json { head :bad_request }
+ format.html { render :show }
+ end
end
end
@@ -59,7 +70,20 @@ class Projects::ClustersController < Projects::ApplicationController
private
def cluster
- @cluster ||= project.clusters.find(params[:id]).present(current_user: current_user) || render_404
+ @cluster ||= project.clusters.find_by(id: params[:id])&.present(current_user: current_user) || render_404
+ 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
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/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
new file mode 100644
index 00000000000..f8281c893fa
--- /dev/null
+++ b/app/helpers/clusters_helper.rb
@@ -0,0 +1,5 @@
+module ClustersHelper
+ def can_toggle_cluster?(cluster)
+ can?(current_user, :update_cluster, cluster) && cluster.created?
+ end
+end
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 441cbcb701d..c97a6fdc78a 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/services/clusters/create_service.rb b/app/services/clusters/create_service.rb
index 1d407739b21..a47ce5a8887 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)
@access_token = access_token
+ raise Exception.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?
+ return 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..ba36828d407
--- /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&.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 !can_toggle_cluster?(cluster)}",
+ "aria-label": s_("ClusterIntegration|Toggle Cluster"),
+ disabled: !can_toggle_cluster?(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/_tabs.html.haml b/app/views/projects/clusters/_tabs.html.haml
new file mode 100644
index 00000000000..955a9940727
--- /dev/null
+++ b/app/views/projects/clusters/_tabs.html.haml
@@ -0,0 +1,18 @@
+.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
+ .pull-right.nav-bar-right
+ = link_to s_("ClusterIntegration|Add cluster"), new_project_cluster_path(@project), class: "btn btn-success disabled has-tooltip js-add-cluster", title: s_("ClusterIntegration|Multiple clusters are available in GitLab Entreprise Edition Premium and Ultimate")
diff --git a/app/views/projects/clusters/index.html.haml b/app/views/projects/clusters/index.html.haml
new file mode 100644
index 00000000000..4dd956971ae
--- /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
+ = 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..c177e675b3c 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?