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