From 04addb05c22b46001c798a99068c720215fd1d54 Mon Sep 17 00:00:00 2001
From: Anna Vovchenko <avovchenko@gitlab.com>
Date: Thu, 15 Feb 2024 13:41:18 +0000
Subject: [PATCH] Add Kubernetes overview on the Environment details page

Use the components from the Environments list page.
Use the existing query.
Update the layout.

Changelog: added
---
 .../components/kubernetes_agent_info.vue      |   3 +-
 .../components/kubernetes_status_bar.vue      |   4 +-
 .../javascripts/environments/constants.js     |   7 +-
 .../components/kubernetes_overview.vue        | 155 ++++++++++++++
 .../environment_details/index.vue             |  28 ++-
 .../helpers/k8s_integration_helper.js         |  14 ++
 .../javascripts/environments/mount_show.js    |   2 +
 .../projects/environments_controller.rb       |   4 +-
 .../projects/environments/show.html.haml      |   2 +-
 .../projects/environments/environment_spec.rb |   1 +
 locale/gitlab.pot                             |  12 ++
 .../projects/environments_controller_spec.rb  |  18 ++
 .../projects/environments/environment_spec.rb |   4 +
 .../components/kubernetes_overview_spec.js    | 200 ++++++++++++++++++
 .../environment_details/index_spec.js         |  40 +++-
 .../helpers/k8s_integration_helper_spec.js    |  46 +++-
 .../kubernetes_status_bar_spec.js             |   6 +-
 17 files changed, 530 insertions(+), 16 deletions(-)
 create mode 100644 app/assets/javascripts/environments/environment_details/components/kubernetes_overview.vue
 create mode 100644 spec/frontend/environments/environment_details/components/kubernetes_overview_spec.js

diff --git a/app/assets/javascripts/environments/components/kubernetes_agent_info.vue b/app/assets/javascripts/environments/components/kubernetes_agent_info.vue
index 03bde8d64ac20..a2c7daf0797ed 100644
--- a/app/assets/javascripts/environments/components/kubernetes_agent_info.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_agent_info.vue
@@ -46,7 +46,7 @@ export default {
         ><template #agentId>{{ agentId }}</template></gl-sprintf
       >
     </gl-link>
-    <span class="gl-mr-3" data-testid="agent-status">
+    <span data-testid="agent-status">
       <gl-icon
         :name="$options.AGENT_STATUSES[agentStatus].icon"
         :class="$options.AGENT_STATUSES[agentStatus].class"
@@ -55,7 +55,6 @@ export default {
     </span>
 
     <span data-testid="agent-last-used-date">
-      <gl-icon name="calendar" />
       <time-ago-tooltip v-if="agentLastContact" :time="agentLastContact" />
       <span v-else>{{ $options.i18n.neverConnectedText }}</span>
     </span>
diff --git a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue
index b41d177385147..2cf37ccca279f 100644
--- a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue
@@ -2,6 +2,8 @@
 import { GlLoadingIcon, GlBadge, GlPopover, GlSprintf, GlLink } from '@gitlab/ui';
 import { s__ } from '~/locale';
 import {
+  CLUSTER_HEALTH_SUCCESS,
+  CLUSTER_HEALTH_ERROR,
   HEALTH_BADGES,
   SYNC_STATUS_BADGES,
   STATUS_TRUE,
@@ -28,7 +30,7 @@ export default {
       type: String,
       default: '',
       validator(val) {
-        return ['error', 'success', ''].includes(val);
+        return [CLUSTER_HEALTH_ERROR, CLUSTER_HEALTH_SUCCESS, ''].includes(val);
       },
     },
     configuration: {
diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js
index 64873a6ac6811..28f0bde547e7d 100644
--- a/app/assets/javascripts/environments/constants.js
+++ b/app/assets/javascripts/environments/constants.js
@@ -94,13 +94,16 @@ export const SERVICES_LIMIT_PER_PAGE = 10;
 export const CLUSTER_STATUS_HEALTHY_TEXT = s__('Environment|Healthy');
 export const CLUSTER_STATUS_UNHEALTHY_TEXT = s__('Environment|Unhealthy');
 
+export const CLUSTER_HEALTH_SUCCESS = 'success';
+export const CLUSTER_HEALTH_ERROR = 'error';
+
 export const HEALTH_BADGES = {
-  success: {
+  [CLUSTER_HEALTH_SUCCESS]: {
     variant: 'success',
     text: CLUSTER_STATUS_HEALTHY_TEXT,
     icon: 'status-success',
   },
-  error: {
+  [CLUSTER_HEALTH_ERROR]: {
     variant: 'danger',
     text: CLUSTER_STATUS_UNHEALTHY_TEXT,
     icon: 'status-alert',
diff --git a/app/assets/javascripts/environments/environment_details/components/kubernetes_overview.vue b/app/assets/javascripts/environments/environment_details/components/kubernetes_overview.vue
new file mode 100644
index 0000000000000..e7d6d63967967
--- /dev/null
+++ b/app/assets/javascripts/environments/environment_details/components/kubernetes_overview.vue
@@ -0,0 +1,155 @@
+<script>
+import { GlLoadingIcon, GlEmptyState, GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
+import CLUSTER_EMPTY_SVG from '@gitlab/svgs/dist/illustrations/empty-state/empty-state-clusters.svg?url';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { createK8sAccessConfiguration } from '~/environments/helpers/k8s_integration_helper';
+import { CLUSTER_HEALTH_SUCCESS, CLUSTER_HEALTH_ERROR } from '~/environments/constants';
+import environmentClusterAgentQuery from '~/environments/graphql/queries/environment_cluster_agent.query.graphql';
+import KubernetesStatusBar from '~/environments/components/kubernetes_status_bar.vue';
+import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info.vue';
+import KubernetesTabs from '~/environments/components/kubernetes_tabs.vue';
+
+export default {
+  components: {
+    GlLoadingIcon,
+    GlEmptyState,
+    KubernetesStatusBar,
+    KubernetesAgentInfo,
+    KubernetesTabs,
+    GlSprintf,
+    GlLink,
+    GlAlert,
+  },
+  inject: ['kasTunnelUrl'],
+  props: {
+    projectFullPath: {
+      type: String,
+      required: true,
+    },
+    environmentName: {
+      type: String,
+      required: true,
+    },
+  },
+  apollo: {
+    environment: {
+      query: environmentClusterAgentQuery,
+      variables() {
+        return {
+          projectFullPath: this.projectFullPath,
+          environmentName: this.environmentName,
+        };
+      },
+      update(data) {
+        return data?.project?.environment;
+      },
+    },
+  },
+  data() {
+    return {
+      error: null,
+      failedState: {},
+      podsLoading: false,
+    };
+  },
+  computed: {
+    isLoading() {
+      return this.$apollo.queries.environment.loading;
+    },
+    clusterAgent() {
+      return this.environment?.clusterAgent;
+    },
+    kubernetesNamespace() {
+      return this.environment?.kubernetesNamespace || '';
+    },
+    fluxResourcePath() {
+      return this.environment?.fluxResourcePath || '';
+    },
+    gitlabAgentId() {
+      return getIdFromGraphQLId(this.clusterAgent.id).toString();
+    },
+    k8sAccessConfiguration() {
+      return createK8sAccessConfiguration({
+        kasTunnelUrl: this.kasTunnelUrl,
+        gitlabAgentId: this.gitlabAgentId,
+      });
+    },
+    clusterHealthStatus() {
+      if (this.podsLoading) {
+        return '';
+      }
+      return this.hasFailedState ? CLUSTER_HEALTH_ERROR : CLUSTER_HEALTH_SUCCESS;
+    },
+    hasFailedState() {
+      return Object.values(this.failedState).some((item) => item);
+    },
+  },
+  methods: {
+    handleClusterError(message) {
+      this.error = message;
+    },
+    handleFailedState(event) {
+      this.failedState = {
+        ...this.failedState,
+        ...event,
+      };
+    },
+  },
+  i18n: {
+    emptyTitle: s__('Environment|No Kubernetes clusters configured'),
+    emptyDescription: s__(
+      'Environment|There are no Kubernetes cluster connections configured for this environment. Connect a cluster to add the status of your workloads, resources, and the Flux reconciliation state to the dashboard. %{linkStart}Learn more about Kubernetes integration.%{linkEnd}',
+    ),
+    emptyButton: s__('Environment|Get started'),
+  },
+  learnMoreLink: helpPagePath('user/clusters/agent/index'),
+  getStartedLink: helpPagePath('ci/environments/kubernetes_dashboard'),
+  CLUSTER_EMPTY_SVG,
+};
+</script>
+<template>
+  <gl-loading-icon v-if="isLoading" />
+  <div v-else-if="clusterAgent" class="gl-p-5 gl-bg-gray-10 gl-mt-n3">
+    <div
+      class="gl-display-flex gl-flex-wrap gl-justify-content-space-between gl-align-items-center"
+    >
+      <kubernetes-agent-info :cluster-agent="clusterAgent" class="gl-mb-2 gl-mr-5" />
+      <kubernetes-status-bar
+        :cluster-health-status="clusterHealthStatus"
+        :configuration="k8sAccessConfiguration"
+        :environment-name="environmentName"
+        :flux-resource-path="fluxResourcePath"
+      />
+    </div>
+
+    <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-my-5">
+      {{ error }}
+    </gl-alert>
+
+    <kubernetes-tabs
+      :configuration="k8sAccessConfiguration"
+      :namespace="kubernetesNamespace"
+      class="gl-mb-5"
+      @cluster-error="handleClusterError"
+      @loading="podsLoading = $event"
+      @update-failed-state="handleFailedState"
+    />
+  </div>
+  <gl-empty-state
+    v-else
+    :title="$options.i18n.emptyTitle"
+    :primary-button-text="$options.i18n.emptyButton"
+    :primary-button-link="$options.getStartedLink"
+    :svg-path="$options.CLUSTER_EMPTY_SVG"
+  >
+    <template #description>
+      <gl-sprintf :message="$options.i18n.emptyDescription">
+        <template #link="{ content }">
+          <gl-link :href="$options.learnMoreLink">{{ content }}</gl-link>
+        </template></gl-sprintf
+      >
+    </template>
+  </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue
index e22a51389ee6c..690942349d66a 100644
--- a/app/assets/javascripts/environments/environment_details/index.vue
+++ b/app/assets/javascripts/environments/environment_details/index.vue
@@ -3,12 +3,14 @@
 import { GlTabs, GlTab } from '@gitlab/ui';
 import { s__ } from '~/locale';
 import DeploymentHistory from './components/deployment_history.vue';
+import KubernetesOverview from './components/kubernetes_overview.vue';
 
 export default {
   components: {
     GlTabs,
     GlTab,
     DeploymentHistory,
+    KubernetesOverview,
   },
   props: {
     projectFullPath: {
@@ -30,19 +32,43 @@ export default {
       default: null,
     },
   },
+  data() {
+    return {
+      currentTabIndex: 0,
+    };
+  },
   i18n: {
     deploymentHistory: s__('Environments|Deployment history'),
+    kubernetesOverview: s__('Environments|Kubernetes overview'),
   },
   params: {
     deployments: 'deployment-history',
+    kubernetes: 'kubernetes-overview',
+  },
+  methods: {
+    linkClass(index) {
+      return index === this.currentTabIndex ? 'gl-inset-border-b-2-theme-accent' : '';
+    },
   },
 };
 </script>
 <template>
-  <gl-tabs sync-active-tab-with-query-params>
+  <gl-tabs v-model="currentTabIndex" sync-active-tab-with-query-params>
+    <gl-tab
+      :title="$options.i18n.kubernetesOverview"
+      :query-param-value="$options.params.kubernetes"
+      :title-link-class="linkClass(0)"
+    >
+      <kubernetes-overview
+        :project-full-path="projectFullPath"
+        :environment-name="environmentName"
+      />
+    </gl-tab>
+
     <gl-tab
       :title="$options.i18n.deploymentHistory"
       :query-param-value="$options.params.deployments"
+      :title-link-class="linkClass(1)"
     >
       <deployment-history
         :project-full-path="projectFullPath"
diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
index 2275803ff92d2..efd352e64290b 100644
--- a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
+++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
@@ -1,3 +1,4 @@
+import csrf from '~/lib/utils/csrf';
 import { CLUSTER_AGENT_ERROR_MESSAGES } from '../constants';
 
 export function humanizeClusterErrors(reason) {
@@ -5,3 +6,16 @@ export function humanizeClusterErrors(reason) {
   const errorMessage = CLUSTER_AGENT_ERROR_MESSAGES[errorReason];
   return errorMessage || CLUSTER_AGENT_ERROR_MESSAGES.other;
 }
+
+export function createK8sAccessConfiguration({ kasTunnelUrl, gitlabAgentId }) {
+  return {
+    basePath: kasTunnelUrl,
+    headers: {
+      'GitLab-Agent-Id': gitlabAgentId,
+      'Content-Type': 'application/json',
+      Accept: 'application/json',
+      ...csrf.headers,
+    },
+    credentials: 'include',
+  };
+}
diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js
index 9924e1c7d7b3c..abe0a36fe01a6 100644
--- a/app/assets/javascripts/environments/mount_show.js
+++ b/app/assets/javascripts/environments/mount_show.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
 import VueApollo from 'vue-apollo';
 import VueRouter from 'vue-router';
 import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility';
 import EnvironmentsDetailHeader from './components/environments_detail_header.vue';
 import { apolloProvider as createApolloProvider } from './graphql/client';
 import environmentsMixin from './mixins/environments_mixin';
@@ -98,6 +99,7 @@ export const initPage = async () => {
     provide: {
       projectPath: dataSet.projectFullPath,
       graphqlEtagKey: dataSet.graphqlEtagKey,
+      kasTunnelUrl: removeLastSlashInUrlPath(dataElement.dataset.kasTunnelUrl),
     },
     render(createElement) {
       return createElement('router-view');
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 65cbe5a78cee4..862aac2dd29c5 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -10,7 +10,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
 
   layout 'project'
 
-  before_action only: [:index] do
+  before_action only: [:index, :show] do
     push_frontend_feature_flag(:k8s_watch_api, project)
   end
 
@@ -26,7 +26,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
   before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :cancel_auto_stop]
   before_action :verify_api_request!, only: :terminal_websocket_authorize
   before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? }
-  before_action :set_kas_cookie, only: [:index, :folder, :edit, :new], if: -> { current_user && request.format.html? }
+  before_action :set_kas_cookie, only: [:index, :folder, :edit, :new, :show], if: -> { current_user && request.format.html? }
   after_action :expire_etag_cache, only: [:cancel_auto_stop]
 
   track_event :index, :folder, :show, :new, :edit, :create, :update, :stop, :cancel_auto_stop, :terminal,
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 46ec430cadbb5..4c7291a002c23 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -5,7 +5,7 @@
 - add_page_specific_style 'page_bundles/environments'
 - add_page_specific_style 'page_bundles/ci_status'
 
-#environments-detail-view{ data: { details: environments_detail_data_json(current_user, @project, @environment) } }
+#environments-detail-view{ data: { details: environments_detail_data_json(current_user, @project, @environment), kas_tunnel_url: ::Gitlab::Kas.tunnel_url } }
   #environments-detail-view-header
 
   #environment_details_page
diff --git a/ee/spec/features/projects/environments/environment_spec.rb b/ee/spec/features/projects/environments/environment_spec.rb
index a423dbfcd0146..a59296168af71 100644
--- a/ee/spec/features/projects/environments/environment_spec.rb
+++ b/ee/spec/features/projects/environments/environment_spec.rb
@@ -49,6 +49,7 @@
     before do
       sign_in(operator_user)
       visit project_environment_path(project, environment)
+      click_link s_('Environments|Deployment history')
     end
 
     it 'shows re-deploy button' do
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 9293ec2c004bf..7b1100957f5e4 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -19367,6 +19367,9 @@ msgstr ""
 msgid "Environments|Kubernetes namespace (optional)"
 msgstr ""
 
+msgid "Environments|Kubernetes overview"
+msgstr ""
+
 msgid "Environments|Kustomizations"
 msgstr ""
 
@@ -19502,12 +19505,18 @@ msgstr ""
 msgid "Environment|Forbidden to access the cluster agent from this environment."
 msgstr ""
 
+msgid "Environment|Get started"
+msgstr ""
+
 msgid "Environment|Healthy"
 msgstr ""
 
 msgid "Environment|Kubernetes overview"
 msgstr ""
 
+msgid "Environment|No Kubernetes clusters configured"
+msgstr ""
+
 msgid "Environment|Pods"
 msgstr ""
 
@@ -19529,6 +19538,9 @@ msgstr ""
 msgid "Environment|Sync status"
 msgstr ""
 
+msgid "Environment|There are no Kubernetes cluster connections configured for this environment. Connect a cluster to add the status of your workloads, resources, and the Flux reconciliation state to the dashboard. %{linkStart}Learn more about Kubernetes integration.%{linkEnd}"
+msgstr ""
+
 msgid "Environment|There was an error connecting to the cluster agent."
 msgstr ""
 
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index c421aee88f8e5..e9d724f2becd0 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -280,6 +280,24 @@
         let(:request_params) { environment_params }
         let(:target_id) { 'users_visiting_environments_pages' }
       end
+
+      it 'sets the kas cookie if the request format is html' do
+        allow(::Gitlab::Kas::UserAccess).to receive(:enabled?).and_return(true)
+        get :show, params: environment_params
+
+        expect(
+          request.env['action_dispatch.cookies'][Gitlab::Kas::COOKIE_KEY]
+        ).to be_present
+      end
+
+      it 'does not set the kas_cookie if the request format is not html' do
+        allow(::Gitlab::Kas::UserAccess).to receive(:enabled?).and_return(true)
+        get :show, params: environment_params(format: :json)
+
+        expect(
+          request.env['action_dispatch.cookies'][Gitlab::Kas::COOKIE_KEY]
+        ).to be_nil
+      end
     end
 
     context 'with invalid id' do
diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb
index 699de6b0784e0..61878a05fdfd8 100644
--- a/spec/features/projects/environments/environment_spec.rb
+++ b/spec/features/projects/environments/environment_spec.rb
@@ -50,6 +50,7 @@ def auto_stop_button_selector
     context 'without deployments' do
       before do
         visit_environment(environment)
+        click_link s_('Environments|Deployment history')
       end
 
       it 'does not show deployments' do
@@ -60,6 +61,7 @@ def auto_stop_button_selector
     context 'with deployments' do
       before do
         visit_environment(environment)
+        click_link s_('Environments|Deployment history')
       end
 
       context 'when there is no related deployable' do
@@ -124,6 +126,7 @@ def auto_stop_button_selector
 
         before do
           visit_environment(environment)
+          click_link s_('Environments|Deployment history')
         end
 
         # This ordering is unexpected and to be fixed.
@@ -155,6 +158,7 @@ def auto_stop_button_selector
 
         before do
           visit_environment(environment)
+          click_link s_('Environments|Deployment history')
         end
 
         it 'shows deployment information and buttons', :js do
diff --git a/spec/frontend/environments/environment_details/components/kubernetes_overview_spec.js b/spec/frontend/environments/environment_details/components/kubernetes_overview_spec.js
new file mode 100644
index 0000000000000..82a21e3239e88
--- /dev/null
+++ b/spec/frontend/environments/environment_details/components/kubernetes_overview_spec.js
@@ -0,0 +1,200 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlLoadingIcon, GlEmptyState, GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import KubernetesOverview from '~/environments/environment_details/components/kubernetes_overview.vue';
+import KubernetesStatusBar from '~/environments/components/kubernetes_status_bar.vue';
+import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info.vue';
+import KubernetesTabs from '~/environments/components/kubernetes_tabs.vue';
+import environmentClusterAgentQuery from '~/environments/graphql/queries/environment_cluster_agent.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { agent, kubernetesNamespace, fluxResourcePathMock } from '../../graphql/mock_data';
+import { mockKasTunnelUrl } from '../../mock_data';
+
+describe('~/environments/kubernetes_overview/index.vue', () => {
+  Vue.use(VueApollo);
+
+  let wrapper;
+
+  const propsData = {
+    environmentName: 'production',
+    projectFullPath: 'gitlab-group/test-project',
+  };
+
+  const provide = {
+    kasTunnelUrl: mockKasTunnelUrl,
+  };
+
+  const configuration = {
+    basePath: provide.kasTunnelUrl.replace(/\/$/, ''),
+    headers: {
+      'GitLab-Agent-Id': '1',
+      'Content-Type': 'application/json',
+      Accept: 'application/json',
+    },
+    credentials: 'include',
+  };
+
+  const createWrapper = (clusterAgent = agent) => {
+    const defaultEnvironmentData = {
+      data: {
+        project: {
+          id: '1',
+          environment: {
+            id: '1',
+            clusterAgent,
+            kubernetesNamespace,
+            fluxResourcePath: fluxResourcePathMock,
+          },
+        },
+      },
+    };
+    const mockApollo = createMockApollo(
+      [[environmentClusterAgentQuery, jest.fn().mockResolvedValue(defaultEnvironmentData)]],
+      [],
+    );
+
+    return shallowMount(KubernetesOverview, {
+      apolloProvider: mockApollo,
+      provide,
+      propsData,
+    });
+  };
+
+  const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+  const findAgentInfo = () => wrapper.findComponent(KubernetesAgentInfo);
+  const findKubernetesStatusBar = () => wrapper.findComponent(KubernetesStatusBar);
+  const findKubernetesTabs = () => wrapper.findComponent(KubernetesTabs);
+  const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+
+  const findAlert = () => wrapper.findComponent(GlAlert);
+
+  describe('when fetching data', () => {
+    beforeEach(() => {
+      wrapper = createWrapper();
+    });
+
+    it('renders loading indicator', () => {
+      expect(findLoadingIcon().exists()).toBe(true);
+    });
+
+    it("doesn't render Kubernetes related components", () => {
+      expect(findAgentInfo().exists()).toBe(false);
+      expect(findKubernetesStatusBar().exists()).toBe(false);
+      expect(findKubernetesTabs().exists()).toBe(false);
+    });
+
+    it("doesn't render empty state", () => {
+      expect(findEmptyState().exists()).toBe(false);
+    });
+  });
+
+  describe('when data is fetched', () => {
+    it('hides loading indicator', async () => {
+      wrapper = createWrapper();
+      await waitForPromises();
+
+      expect(findLoadingIcon().exists()).toBe(false);
+    });
+
+    describe('and there is cluster agent data', () => {
+      beforeEach(async () => {
+        wrapper = createWrapper();
+        await waitForPromises();
+      });
+
+      it('renders kubernetes agent info', () => {
+        expect(findAgentInfo().props('clusterAgent')).toEqual(agent);
+      });
+
+      it('renders kubernetes tabs', () => {
+        expect(findKubernetesTabs().props()).toEqual({
+          namespace: kubernetesNamespace,
+          configuration,
+        });
+      });
+
+      it('renders kubernetes status bar', () => {
+        expect(findKubernetesStatusBar().props()).toEqual({
+          clusterHealthStatus: 'success',
+          configuration,
+          environmentName: propsData.environmentName,
+          fluxResourcePath: fluxResourcePathMock,
+        });
+      });
+
+      describe('Kubernetes health status', () => {
+        beforeEach(async () => {
+          wrapper = createWrapper();
+          await waitForPromises();
+        });
+
+        it("doesn't set `clusterHealthStatus` when pods are still loading", async () => {
+          findKubernetesTabs().vm.$emit('loading', true);
+          await nextTick();
+
+          expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('');
+        });
+
+        it('sets `clusterHealthStatus` as error when pods emitted a failure', async () => {
+          findKubernetesTabs().vm.$emit('update-failed-state', { pods: true });
+          await nextTick();
+
+          expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error');
+        });
+
+        it('sets `clusterHealthStatus` as success when data is loaded and no failures where emitted', () => {
+          expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('success');
+        });
+
+        it('sets `clusterHealthStatus` as success after state update if there are no failures', async () => {
+          findKubernetesTabs().vm.$emit('update-failed-state', { pods: true });
+          await nextTick();
+          expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error');
+
+          findKubernetesTabs().vm.$emit('update-failed-state', { pods: false });
+          await nextTick();
+          expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('success');
+        });
+      });
+
+      describe('on cluster error', () => {
+        beforeEach(async () => {
+          wrapper = createWrapper();
+          await waitForPromises();
+        });
+
+        it('shows alert with the error message', async () => {
+          const error = 'Error message from pods';
+
+          findKubernetesTabs().vm.$emit('cluster-error', error);
+          await nextTick();
+
+          expect(findAlert().text()).toBe(error);
+        });
+      });
+    });
+
+    describe('and there is no cluster agent data', () => {
+      beforeEach(async () => {
+        wrapper = createWrapper(null);
+        await waitForPromises();
+      });
+
+      it('renders empty state component', () => {
+        expect(findEmptyState().props()).toMatchObject({
+          title: 'No Kubernetes clusters configured',
+          primaryButtonText: 'Get started',
+          primaryButtonLink: '/help/ci/environments/kubernetes_dashboard',
+        });
+      });
+
+      it("doesn't render Kubernetes related components", () => {
+        expect(findAgentInfo().exists()).toBe(false);
+        expect(findKubernetesStatusBar().exists()).toBe(false);
+        expect(findKubernetesTabs().exists()).toBe(false);
+      });
+    });
+  });
+});
diff --git a/spec/frontend/environments/environment_details/index_spec.js b/spec/frontend/environments/environment_details/index_spec.js
index 90ce05a09b07b..b352079ac2e51 100644
--- a/spec/frontend/environments/environment_details/index_spec.js
+++ b/spec/frontend/environments/environment_details/index_spec.js
@@ -1,7 +1,9 @@
 import { GlTabs, GlTab } from '@gitlab/ui';
 import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
 import EnvironmentsDetailPage from '~/environments/environment_details/index.vue';
 import DeploymentsHistory from '~/environments/environment_details/components/deployment_history.vue';
+import KubernetesOverview from '~/environments/environment_details/components/kubernetes_overview.vue';
 
 const projectFullPath = 'gitlab-group/test-project';
 const environmentName = 'test-environment-name';
@@ -24,8 +26,10 @@ describe('~/environments/environment_details/index.vue', () => {
   };
 
   const findTabs = () => wrapper.findComponent(GlTabs);
-  const findTab = () => wrapper.findComponent(GlTab);
+  const findAllTabs = () => wrapper.findAllComponents(GlTab);
+  const findTabByIndex = (index) => findAllTabs().at(index);
   const findDeploymentHistory = () => wrapper.findComponent(DeploymentsHistory);
+  const findKubernetesOverview = () => wrapper.findComponent(KubernetesOverview);
 
   beforeEach(() => {
     wrapper = createWrapper();
@@ -35,13 +39,43 @@ describe('~/environments/environment_details/index.vue', () => {
     expect(findTabs().props('syncActiveTabWithQueryParams')).toBe(true);
   });
 
+  it('sets proper CSS class to the active tab', () => {
+    expect(findTabByIndex(0).props('titleLinkClass')).toBe('gl-inset-border-b-2-theme-accent');
+    expect(findTabByIndex(1).props('titleLinkClass')).toBe('');
+  });
+
+  it('updates the CSS class when the active tab changes', async () => {
+    findTabs().vm.$emit('input', 1);
+    await nextTick();
+
+    expect(findTabByIndex(0).props('titleLinkClass')).toBe('');
+    expect(findTabByIndex(1).props('titleLinkClass')).toBe('gl-inset-border-b-2-theme-accent');
+  });
+
+  describe('kubernetes overview tab', () => {
+    it('renders correct title', () => {
+      expect(findTabByIndex(0).attributes('title')).toBe('Kubernetes overview');
+    });
+
+    it('renders correct query param value', () => {
+      expect(findTabByIndex(0).attributes('query-param-value')).toBe('kubernetes-overview');
+    });
+
+    it('renders kubernetes_overview component with correct props', () => {
+      expect(findKubernetesOverview().props()).toEqual({
+        projectFullPath,
+        environmentName,
+      });
+    });
+  });
+
   describe('deployment history tab', () => {
     it('renders correct title', () => {
-      expect(findTab().attributes('title')).toBe('Deployment history');
+      expect(findTabByIndex(1).attributes('title')).toBe('Deployment history');
     });
 
     it('renders correct query param value', () => {
-      expect(findTab().attributes('query-param-value')).toBe('deployment-history');
+      expect(findTabByIndex(1).attributes('query-param-value')).toBe('deployment-history');
     });
 
     it('renders deployment_history component with correct props', () => {
diff --git a/spec/frontend/environments/helpers/k8s_integration_helper_spec.js b/spec/frontend/environments/helpers/k8s_integration_helper_spec.js
index fdfbddf0d9411..7db466dd4a509 100644
--- a/spec/frontend/environments/helpers/k8s_integration_helper_spec.js
+++ b/spec/frontend/environments/helpers/k8s_integration_helper_spec.js
@@ -1,7 +1,11 @@
-import { humanizeClusterErrors } from '~/environments/helpers/k8s_integration_helper';
-
+import {
+  humanizeClusterErrors,
+  createK8sAccessConfiguration,
+} from '~/environments/helpers/k8s_integration_helper';
 import { CLUSTER_AGENT_ERROR_MESSAGES } from '~/environments/constants';
 
+jest.mock('~/lib/utils/csrf', () => ({ headers: { token: 'mock-csrf-token' } }));
+
 describe('k8s_integration_helper', () => {
   describe('humanizeClusterErrors', () => {
     it.each(['unauthorized', 'forbidden', 'not found', 'other'])(
@@ -11,4 +15,42 @@ describe('k8s_integration_helper', () => {
       },
     );
   });
+
+  describe('createK8sAccessConfiguration', () => {
+    const kasTunnelUrl = '//kas-tunnel-url';
+    const gitlabAgentId = 1;
+
+    const subject = createK8sAccessConfiguration({ kasTunnelUrl, gitlabAgentId });
+
+    it('receives kasTunnelUrl and sets it as a basePath', () => {
+      expect(subject).toMatchObject({
+        basePath: kasTunnelUrl,
+      });
+    });
+
+    it('receives gitlabAgentId and sets it as part of headers', () => {
+      expect(subject.headers).toMatchObject({
+        'GitLab-Agent-Id': gitlabAgentId,
+      });
+    });
+
+    it('provides csrf headers into headers', () => {
+      expect(subject.headers).toMatchObject({
+        token: 'mock-csrf-token',
+      });
+    });
+
+    it('provides proper content type to the headers', () => {
+      expect(subject.headers).toMatchObject({
+        'Content-Type': 'application/json',
+        Accept: 'application/json',
+      });
+    });
+
+    it('includes credentials', () => {
+      expect(subject).toMatchObject({
+        credentials: 'include',
+      });
+    });
+  });
 });
diff --git a/spec/frontend/environments/kubernetes_status_bar_spec.js b/spec/frontend/environments/kubernetes_status_bar_spec.js
index 9c729c8da206b..21f8f75f0683b 100644
--- a/spec/frontend/environments/kubernetes_status_bar_spec.js
+++ b/spec/frontend/environments/kubernetes_status_bar_spec.js
@@ -4,6 +4,8 @@ import { GlLoadingIcon, GlPopover, GlSprintf } from '@gitlab/ui';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import KubernetesStatusBar from '~/environments/components/kubernetes_status_bar.vue';
 import {
+  CLUSTER_HEALTH_SUCCESS,
+  CLUSTER_HEALTH_ERROR,
   CLUSTER_STATUS_HEALTHY_TEXT,
   CLUSTER_STATUS_UNHEALTHY_TEXT,
   SYNC_STATUS_BADGES,
@@ -72,8 +74,8 @@ describe('~/environments/components/kubernetes_status_bar.vue', () => {
     });
 
     it.each([
-      ['success', 'success', 'status-success', CLUSTER_STATUS_HEALTHY_TEXT],
-      ['error', 'danger', 'status-alert', CLUSTER_STATUS_UNHEALTHY_TEXT],
+      [CLUSTER_HEALTH_SUCCESS, 'success', 'status-success', CLUSTER_STATUS_HEALTHY_TEXT],
+      [CLUSTER_HEALTH_ERROR, 'danger', 'status-alert', CLUSTER_STATUS_UNHEALTHY_TEXT],
     ])(
       'when clusterHealthStatus is %s shows health badge with variant %s, icon %s and text %s',
       (status, variant, icon, text) => {
-- 
GitLab