diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue index cfb18cc4f824e9d6a4e5903df2e0820928933f3e..736eaa7062d629fa2c5c79a28f6b0d86beaea66c 100644 --- a/app/assets/javascripts/environments/components/kubernetes_overview.vue +++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue @@ -1,14 +1,20 @@ <script> -import { GlCollapse, GlButton } from '@gitlab/ui'; +import { GlCollapse, GlButton, GlAlert } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; +import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import KubernetesAgentInfo from './kubernetes_agent_info.vue'; +import KubernetesPods from './kubernetes_pods.vue'; export default { components: { GlCollapse, GlButton, + GlAlert, KubernetesAgentInfo, + KubernetesPods, }, + inject: ['kasTunnelUrl'], props: { agentName: { required: true, @@ -22,10 +28,16 @@ export default { required: true, type: String, }, + namespace: { + required: false, + type: String, + default: '', + }, }, data() { return { isVisible: false, + error: '', }; }, computed: { @@ -35,11 +47,26 @@ export default { label() { return this.isVisible ? this.$options.i18n.collapse : this.$options.i18n.expand; }, + gitlabAgentId() { + const id = isGid(this.agentId) ? getIdFromGraphQLId(this.agentId) : this.agentId; + return id.toString(); + }, + k8sAccessConfiguration() { + return { + basePath: this.kasTunnelUrl, + baseOptions: { + headers: { 'GitLab-Agent-Id': this.gitlabAgentId, ...csrf.headers }, + }, + }; + }, }, methods: { toggleCollapse() { this.isVisible = !this.isVisible; }, + onClusterError(message) { + this.error = message; + }, }, i18n: { collapse: __('Collapse'), @@ -66,7 +93,17 @@ export default { :agent-name="agentName" :agent-id="agentId" :agent-project-path="agentProjectPath" + class="gl-mb-5" /> + + <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5"> + {{ error }} + </gl-alert> + + <kubernetes-pods + :configuration="k8sAccessConfiguration" + :namespace="namespace" class="gl-mb-5" + @cluster-error="onClusterError" /></template> </gl-collapse> </div> diff --git a/app/assets/javascripts/environments/components/kubernetes_pods.vue b/app/assets/javascripts/environments/components/kubernetes_pods.vue new file mode 100644 index 0000000000000000000000000000000000000000..e43bc838708809850c2fe2cbb034869b018a1c93 --- /dev/null +++ b/app/assets/javascripts/environments/components/kubernetes_pods.vue @@ -0,0 +1,111 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { s__ } from '~/locale'; +import k8sPodsQuery from '../graphql/queries/k8s_pods.query.graphql'; + +export default { + components: { + GlLoadingIcon, + GlSingleStat, + }, + apollo: { + k8sPods: { + query: k8sPodsQuery, + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + }; + }, + update(data) { + return data?.k8sPods || []; + }, + error(error) { + this.error = error; + this.$emit('cluster-error', this.error); + }, + }, + }, + props: { + configuration: { + required: true, + type: Object, + }, + namespace: { + required: true, + type: String, + }, + }, + data() { + return { + error: '', + }; + }, + + computed: { + podStats() { + if (!this.k8sPods) return null; + + return [ + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Running'), + title: this.$options.i18n.runningPods, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Pending'), + title: this.$options.i18n.pendingPods, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Succeeded'), + title: this.$options.i18n.succeededPods, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Failed'), + title: this.$options.i18n.failedPods, + }, + ]; + }, + loading() { + return this.$apollo.queries.k8sPods.loading; + }, + }, + methods: { + getPodsByPhase(phase) { + const filteredPods = this.k8sPods.filter((item) => item.status.phase === phase); + return filteredPods.length; + }, + }, + i18n: { + podsTitle: s__('Environment|Pods'), + runningPods: s__('Environment|Running'), + pendingPods: s__('Environment|Pending'), + succeededPods: s__('Environment|Succeeded'), + failedPods: s__('Environment|Failed'), + }, +}; +</script> +<template> + <div> + <p class="gl-text-gray-500">{{ $options.i18n.podsTitle }}</p> + + <gl-loading-icon v-if="loading" /> + + <div + v-else-if="podStats && !error" + class="gl-display-flex gl-flex-wrap-wrap gl-sm-flex-wrap-nowrap gl-mx-n3 gl-mt-n3" + > + <gl-single-stat + v-for="(stat, index) in podStats" + :key="index" + class="gl-w-full gl-flex-direction-column gl-align-items-center gl-justify-content-center gl-bg-white gl-border gl-border-gray-a-08 gl-mx-3 gl-p-3 gl-mt-3" + :value="stat.value" + :title="stat.title" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index f46bd0e37800db497f07046093d9d59b32386284..b5ef7d00cc35d5acd91f4a1052cb96bd7b3d6c10 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -173,7 +173,8 @@ export default { return this.glFeatures?.kasUserAccessProject; }, hasRequiredAgentData() { - return this.agent.project && this.agent.id && this.agent.name; + const { project, id, name } = this.agent || {}; + return project && id && name; }, showKubernetesOverview() { return this.isKubernetesOverviewAvailable && this.hasRequiredAgentData; @@ -367,6 +368,7 @@ export default { :agent-project-path="agent.project" :agent-name="agent.name" :agent-id="agent.id" + :namespace="agent.kubernetesNamespace" /> </div> <div v-if="rolloutStatus" :class="$options.deployBoardClasses"> diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index 26514b59995fb76e66bb0a42981c34fc4baee302..0482741979bb7ea56bf4301304f538012e0d42b7 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -5,6 +5,7 @@ import pageInfoQuery from './queries/page_info.query.graphql'; import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; import environmentToStopQuery from './queries/environment_to_stop.query.graphql'; +import k8sPodsQuery from './queries/k8s_pods.query.graphql'; import { resolvers } from './resolvers'; import typeDefs from './typedefs.graphql'; @@ -82,6 +83,14 @@ export const apolloProvider = (endpoint) => { }, }, }); + cache.writeQuery({ + query: k8sPodsQuery, + data: { + status: { + phase: '', + }, + }, + }); return new VueApollo({ defaultClient, }); diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..818bca24d51dc4f7f3f6c753d203233c53c16c2a --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql @@ -0,0 +1,7 @@ +query getK8sPods($configuration: Object, $namespace: String) { + k8sPods(configuration: $configuration, namespace: $namespace) @client { + status { + phase + } + } +} diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index e21670870b8715458a1a71e86bb520495eded3f3..39e05825cf0930279dd0670cd5bc8fb30471ea71 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -1,3 +1,4 @@ +import { CoreV1Api, Configuration } from '@gitlab/cluster-client'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import { @@ -71,6 +72,19 @@ export const resolvers = (endpoint) => ({ isLastDeployment(_, { environment }) { return environment?.lastDeployment?.isLast; }, + k8sPods(_, { configuration, namespace }) { + const coreV1Api = new CoreV1Api(new Configuration(configuration)); + const podsApi = namespace + ? coreV1Api.listCoreV1NamespacedPod(namespace) + : coreV1Api.listCoreV1PodForAllNamespaces(); + + return podsApi + .then((res) => res?.data?.items || []) + .catch((err) => { + const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; + throw error; + }); + }, }, Mutation: { stopEnvironment(_, { environment }, { client }) { diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index b4d1f7326f6b7df12ebdf105090c0c075aff382b..7c102fd04d89d0f19a2d485e91085ecb1df0a293 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -62,6 +62,19 @@ type LocalPageInfo { previousPage: Int! } +type k8sPodStatus { + phase: String +} + +type LocalK8sPods { + status: k8sPodStatus +} + +input LocalConfiguration { + basePath: String + baseOptions: JSON +} + extend type Query { environmentApp(page: Int, scope: String): LocalEnvironmentApp folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder @@ -71,6 +84,7 @@ extend type Query { environmentToStop: LocalEnvironment isEnvironmentStopping(environment: LocalEnvironmentInput): Boolean isLastDeployment(environment: LocalEnvironmentInput): Boolean + k8sPods(configuration: LocalConfiguration, namespace: String): [LocalK8sPods] } extend type Mutation { diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index d9a523fd806840a63ae5a1bf7452ae86105cb901..3f746bc538304339630bf59c1b46f1bf6c529a09 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility'; import { parseBoolean } from '../lib/utils/common_utils'; import { apolloProvider } from './graphql/client'; import EnvironmentsApp from './components/environments_app.vue'; @@ -16,6 +17,7 @@ export default (el) => { projectPath, defaultBranchName, projectId, + kasTunnelUrl, } = el.dataset; return new Vue({ @@ -28,6 +30,7 @@ export default (el) => { newEnvironmentPath, helpPagePath, projectId, + kasTunnelUrl: removeLastSlashInUrlPath(kasTunnelUrl), canCreateEnvironment: parseBoolean(canCreateEnvironment), }, render(h) { diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index e4b8750b96cb3c88933ba198a6829e95d958deb9..7ddaf868a355fd81be19a1fff619043fc5471263 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -8,4 +8,5 @@ "help-page-path" => help_page_path("ci/environments/index.md"), "project-path" => @project.full_path, "project-id" => @project.id, - "default-branch-name" => @project.default_branch_or_main } } + "default-branch-name" => @project.default_branch_or_main, + "kas-tunnel-url" => ::Gitlab::Kas.tunnel_url } } diff --git a/jest.config.base.js b/jest.config.base.js index e8ba15f0a095d2e099d56742323f0206d0e08c61..3cbf2fdd61b6a8c20b765134c22e1cc73c39b985 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -199,6 +199,7 @@ module.exports = (path, options = {}) => { 'vue-test-utils-compat', '@gitlab/ui', '@gitlab/favicon-overlay', + '@gitlab/cluster-client', 'bootstrap-vue', 'three', 'monaco-editor', diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 06809f912e6a53af6bc898bcedfa3ff74da07a5b..45d81ac4f337d77cd62bbe79a653ecff1ee68d94 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -16690,9 +16690,24 @@ msgstr "" msgid "Environment|Deployment tier" msgstr "" +msgid "Environment|Failed" +msgstr "" + msgid "Environment|Kubernetes overview" msgstr "" +msgid "Environment|Pending" +msgstr "" + +msgid "Environment|Pods" +msgstr "" + +msgid "Environment|Running" +msgstr "" + +msgid "Environment|Succeeded" +msgstr "" + msgid "Epic" msgstr "" diff --git a/package.json b/package.json index 5f68270975a597a39e9c69a05419ef71a6d9506b..6418a12910e4c88532e7231562edf67f08bfd840 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@cubejs-client/core": "^0.32.17", "@cubejs-client/vue": "^0.32.17", "@gitlab/at.js": "1.5.7", + "@gitlab/cluster-client": "^1.2.0", "@gitlab/favicon-overlay": "2.0.0", "@gitlab/fonts": "^1.2.0", "@gitlab/svgs": "3.38.0", diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index b54359900426c93db339218cd18280f59ce367eb..8d91ffe5ffc46a61546c254a859b9320f816a8a6 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -801,6 +801,14 @@ export const resolvedDeploymentDetails = { export const agent = { project: 'agent-project', - id: '1', + id: 'gid://gitlab/ClusterAgent/1', name: 'agent-name', + kubernetesNamespace: 'agent-namespace', }; + +const runningPod = { status: { phase: 'Running' } }; +const pendingPod = { status: { phase: 'Pending' } }; +const succeededPod = { status: { phase: 'Succeeded' } }; +const failedPod = { status: { phase: 'Failed' } }; + +export const k8sPodsMock = [runningPod, runningPod, pendingPod, succeededPod, failedPod, failedPod]; diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers_spec.js index 2c223d3a1a7c88a6d0d4daaf6d9a9107c1da5fe2..c66844f5f24d368b542d0b6b2253fa2b294522e8 100644 --- a/spec/frontend/environments/graphql/resolvers_spec.js +++ b/spec/frontend/environments/graphql/resolvers_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { CoreV1Api } from '@gitlab/cluster-client'; import { s__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; @@ -17,6 +18,7 @@ import { resolvedEnvironment, folder, resolvedFolder, + k8sPodsMock, } from './mock_data'; const ENDPOINT = `${TEST_HOST}/environments`; @@ -143,6 +145,61 @@ describe('~/frontend/environments/graphql/resolvers', () => { expect(environmentFolder).toEqual(resolvedFolder); }); }); + describe('k8sPods', () => { + const namespace = 'default'; + const configuration = { + basePath: 'kas-proxy/', + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const mockPodsListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + items: k8sPodsMock, + }, + }); + }); + + const mockNamespacedPodsListFn = jest.fn().mockImplementation(mockPodsListFn); + const mockAllPodsListFn = jest.fn().mockImplementation(mockPodsListFn); + + beforeEach(() => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedPod') + .mockImplementation(mockNamespacedPodsListFn); + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces') + .mockImplementation(mockAllPodsListFn); + }); + + it('should request namespaced pods from the cluster_client library if namespace is specified', async () => { + const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace }); + + expect(mockNamespacedPodsListFn).toHaveBeenCalledWith(namespace); + expect(mockAllPodsListFn).not.toHaveBeenCalled(); + + expect(pods).toEqual(k8sPodsMock); + }); + it('should request all pods from the cluster_client library if namespace is not specified', async () => { + const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace: '' }); + + expect(mockAllPodsListFn).toHaveBeenCalled(); + expect(mockNamespacedPodsListFn).not.toHaveBeenCalled(); + + expect(pods).toEqual(k8sPodsMock); + }); + it('should throw an error if the API call fails', async () => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect(mockResolvers.Query.k8sPods(null, { configuration })).rejects.toThrow( + 'API error', + ); + }); + }); describe('stopEnvironment', () => { it('should post to the stop environment path', async () => { mock.onPost(ENDPOINT).reply(HTTP_STATUS_OK); diff --git a/spec/frontend/environments/kubernetes_overview_spec.js b/spec/frontend/environments/kubernetes_overview_spec.js index 8673c657760106204ad42ed51b947a31ff171e8e..1912fd4a82bd051964e8ed761069ea17d101807a 100644 --- a/spec/frontend/environments/kubernetes_overview_spec.js +++ b/spec/frontend/environments/kubernetes_overview_spec.js @@ -1,19 +1,28 @@ import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; -import { GlCollapse, GlButton } from '@gitlab/ui'; +import { GlCollapse, GlButton, GlAlert } from '@gitlab/ui'; import KubernetesOverview from '~/environments/components/kubernetes_overview.vue'; import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info.vue'; - -const agent = { - project: 'agent-project', - id: '1', - name: 'agent-name', -}; +import KubernetesPods from '~/environments/components/kubernetes_pods.vue'; +import { agent } from './graphql/mock_data'; +import { mockKasTunnelUrl } from './mock_data'; const propsData = { agentId: agent.id, agentName: agent.name, agentProjectPath: agent.project, + namespace: agent.kubernetesNamespace, +}; + +const provide = { + kasTunnelUrl: mockKasTunnelUrl, +}; + +const configuration = { + basePath: provide.kasTunnelUrl.replace(/\/$/, ''), + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, }; describe('~/environments/components/kubernetes_overview.vue', () => { @@ -22,10 +31,13 @@ describe('~/environments/components/kubernetes_overview.vue', () => { const findCollapse = () => wrapper.findComponent(GlCollapse); const findCollapseButton = () => wrapper.findComponent(GlButton); const findAgentInfo = () => wrapper.findComponent(KubernetesAgentInfo); + const findKubernetesPods = () => wrapper.findComponent(KubernetesPods); + const findAlert = () => wrapper.findComponent(GlAlert); const createWrapper = () => { wrapper = shallowMount(KubernetesOverview, { propsData, + provide, }); }; @@ -57,6 +69,7 @@ describe('~/environments/components/kubernetes_overview.vue', () => { it("doesn't render components when the collapse is not visible", () => { expect(findAgentInfo().exists()).toBe(false); + expect(findKubernetesPods().exists()).toBe(false); }); it('opens on click', async () => { @@ -70,15 +83,40 @@ describe('~/environments/components/kubernetes_overview.vue', () => { }); describe('when section is expanded', () => { - it('renders kubernetes agent info', async () => { + beforeEach(() => { createWrapper(); - await toggleCollapse(); + toggleCollapse(); + }); + it('renders kubernetes agent info', () => { expect(findAgentInfo().props()).toEqual({ agentName: agent.name, agentId: agent.id, agentProjectPath: agent.project, }); }); + + it('renders kubernetes pods', () => { + expect(findKubernetesPods().props()).toEqual({ + namespace: agent.kubernetesNamespace, + configuration, + }); + }); + }); + + describe('on cluster error', () => { + beforeEach(() => { + createWrapper(); + toggleCollapse(); + }); + + it('shows alert with the error message', async () => { + const error = 'Error message from pods'; + + findKubernetesPods().vm.$emit('cluster-error', error); + await nextTick(); + + expect(findAlert().text()).toBe(error); + }); }); }); diff --git a/spec/frontend/environments/kubernetes_pods_spec.js b/spec/frontend/environments/kubernetes_pods_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..137309d78535a4735b150d924c00490c90d1ec7d --- /dev/null +++ b/spec/frontend/environments/kubernetes_pods_spec.js @@ -0,0 +1,114 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import KubernetesPods from '~/environments/components/kubernetes_pods.vue'; +import { mockKasTunnelUrl } from './mock_data'; +import { k8sPodsMock } from './graphql/mock_data'; + +Vue.use(VueApollo); + +describe('~/environments/components/kubernetes_pods.vue', () => { + let wrapper; + + const namespace = 'my-kubernetes-namespace'; + const configuration = { + basePath: mockKasTunnelUrl, + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAllStats = () => wrapper.findAllComponents(GlSingleStat); + const findSingleStat = (at) => findAllStats().at(at); + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + k8sPods: jest.fn().mockReturnValue(k8sPodsMock), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (apolloProvider = createApolloProvider()) => { + wrapper = shallowMount(KubernetesPods, { + propsData: { namespace, configuration }, + apolloProvider, + }); + }; + + describe('mounted', () => { + it('shows the loading icon', () => { + createWrapper(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('hides the loading icon when the list of pods loaded', async () => { + createWrapper(); + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when gets pods data', () => { + it('renders stats', async () => { + createWrapper(); + await waitForPromises(); + + expect(findAllStats()).toHaveLength(4); + }); + + it.each` + count | title | index + ${2} | ${KubernetesPods.i18n.runningPods} | ${0} + ${1} | ${KubernetesPods.i18n.pendingPods} | ${1} + ${1} | ${KubernetesPods.i18n.succeededPods} | ${2} + ${2} | ${KubernetesPods.i18n.failedPods} | ${3} + `( + 'renders stat with title "$title" and count "$count" at index $index', + async ({ count, title, index }) => { + createWrapper(); + await waitForPromises(); + + expect(findSingleStat(index).props()).toMatchObject({ + value: count, + title, + }); + }, + ); + }); + + describe('when gets an error from the cluster_client API', () => { + const error = new Error('Error from the cluster_client API'); + const createErroredApolloProvider = () => { + const mockResolvers = { + Query: { + k8sPods: jest.fn().mockRejectedValueOnce(error), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + beforeEach(async () => { + createWrapper(createErroredApolloProvider()); + await waitForPromises(); + }); + + it("doesn't show pods stats", () => { + expect(findAllStats()).toHaveLength(0); + }); + + it('emits an error message', () => { + expect(wrapper.emitted('cluster-error')).toMatchObject([[error]]); + }); + }); +}); diff --git a/spec/frontend/environments/mock_data.js b/spec/frontend/environments/mock_data.js index a6d67c26304c27d46b1f90d73a8c367c9b1e4454..bd2c6b7c892912dd83fe876028420ac8882472f3 100644 --- a/spec/frontend/environments/mock_data.js +++ b/spec/frontend/environments/mock_data.js @@ -313,6 +313,8 @@ const createEnvironment = (data = {}) => ({ ...data, }); +const mockKasTunnelUrl = 'https://kas.gitlab.com/k8s-proxy'; + export { environment, environmentsList, @@ -321,4 +323,5 @@ export { tableData, deployBoardMockData, createEnvironment, + mockKasTunnelUrl, }; diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index 89a9ca725ba1807443e155643b4af0b5cffc7aba..b4f5263a15147a0f6509ad9423493bca674cf8c0 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -12,6 +12,7 @@ import Deployment from '~/environments/components/deployment.vue'; import DeployBoardWrapper from '~/environments/components/deploy_board_wrapper.vue'; import KubernetesOverview from '~/environments/components/kubernetes_overview.vue'; import { resolvedEnvironment, rolloutStatus, agent } from './graphql/mock_data'; +import { mockKasTunnelUrl } from './mock_data'; Vue.use(VueApollo); @@ -26,7 +27,13 @@ describe('~/environments/components/new_environment_item.vue', () => { mountExtended(EnvironmentItem, { apolloProvider, propsData: { environment: resolvedEnvironment, ...propsData }, - provide: { helpPagePath: '/help', projectId: '1', projectPath: '/1', ...provideData }, + provide: { + helpPagePath: '/help', + projectId: '1', + projectPath: '/1', + kasTunnelUrl: mockKasTunnelUrl, + ...provideData, + }, stubs: { transition: stubTransition() }, }); @@ -536,6 +543,7 @@ describe('~/environments/components/new_environment_item.vue', () => { agentProjectPath: agent.project, agentName: agent.name, agentId: agent.id, + namespace: agent.kubernetesNamespace, }); }); diff --git a/yarn.lock b/yarn.lock index a9a4dd4d600a5022c95aee308e95f789710ca1f1..a948e43630ea49c2b859b652e6b0458b9a15dbdb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1085,10 +1085,19 @@ resolved "https://registry.yarnpkg.com/@gitlab/at.js/-/at.js-1.5.7.tgz#1ee6f838cc4410a1d797770934df91d90df8179e" integrity sha512-c6ySRK/Ma7lxwpIVbSAF3P+xiTLrNTGTLRx4/pHK111AdFxwgUwrYF6aVZFXvmG65jHOJHoa0eQQ21RW6rm0Rg== +"@gitlab/cluster-client@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@gitlab/cluster-client/-/cluster-client-1.2.0.tgz#3b56da46748403354b5af73678b17db3851cbe53" + integrity sha512-2emHgfOF9CdibzwXJ2yZVf2d+ez8b67O47qa+pwlG+NnYatjfIcj9Pkzs4kBcO/9+j2lVH/EegPDyEkZZt8Irg== + dependencies: + axios "^0.24.0" + core-js "^3.29.1" + "@gitlab/eslint-plugin@18.3.2": version "18.3.2" resolved "https://registry.yarnpkg.com/@gitlab/eslint-plugin/-/eslint-plugin-18.3.2.tgz#dc4d5b487e26a1473106c1a3e34ae3ea219d4dd1" integrity sha512-Lz0RnEW5isZ/jkeHcr2k6NqaHISwgKeWN/vkWUU5J4Ax7oYPR0CgA2KO/dEnOvIPmGfbnUKowsekBmmy5SUQHA== + dependencies: "@babel/core" "^7.17.0" "@babel/eslint-parser" "^7.17.0"