diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js index 04a42bed41612f158035b48fdef6c93724c528bc..5813ce488ffbbfe352034888616a10ffcd48db4c 100644 --- a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js +++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js @@ -80,10 +80,10 @@ export function getStatefulSetStatuses(items) { export function getReplicaSetStatuses(items) { const failed = items.filter((item) => { - return item.status?.readyReplicas < item.spec?.replicas; + return calculateStatefulSetStatus(item) === STATUS_FAILED; }); const ready = items.filter((item) => { - return item.status?.readyReplicas === item.spec?.replicas; + return calculateStatefulSetStatus(item) === STATUS_READY; }); return { diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/client.js b/app/assets/javascripts/kubernetes_dashboard/graphql/client.js index a3ad75eacf527a5cf59c4b4cdcbf8c4004240b99..e4f0c7ee344e8944997414a2c12eadaca3f1d447 100644 --- a/app/assets/javascripts/kubernetes_dashboard/graphql/client.js +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/client.js @@ -4,6 +4,7 @@ import typeDefs from '~/environments/graphql/typedefs.graphql'; import k8sPodsQuery from './queries/k8s_dashboard_pods.query.graphql'; import k8sDeploymentsQuery from './queries/k8s_dashboard_deployments.query.graphql'; import k8sStatefulSetsQuery from './queries/k8s_dashboard_stateful_sets.query.graphql'; +import k8sReplicaSetsQuery from './queries/k8s_dashboard_replica_sets.query.graphql'; import { resolvers } from './resolvers'; export const apolloProvider = () => { @@ -63,6 +64,25 @@ export const apolloProvider = () => { }, }); + cache.writeQuery({ + query: k8sReplicaSetsQuery, + data: { + metadata: { + name: null, + namespace: null, + creationTimestamp: null, + labels: null, + annotations: null, + }, + status: { + readyReplicas: null, + }, + spec: { + replicas: null, + }, + }, + }); + return new VueApollo({ defaultClient, }); diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_replica_sets.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_replica_sets.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..38aaa79853c5844afa757e40f5e998ef31182381 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_replica_sets.query.graphql @@ -0,0 +1,17 @@ +query getK8sDashboardReplicaSets($configuration: LocalConfiguration) { + k8sReplicaSets(configuration: $configuration) @client { + metadata { + name + namespace + creationTimestamp + labels + annotations + } + status { + readyReplicas + } + spec { + replicas + } + } +} diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js b/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js index e61545c9b45a17aed7d8f2bfc566eeebce40b93b..818295dab07f2fcb3de38e73cacae9e1f5842370 100644 --- a/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js @@ -11,6 +11,7 @@ import { import k8sDashboardPodsQuery from '../queries/k8s_dashboard_pods.query.graphql'; import k8sDashboardDeploymentsQuery from '../queries/k8s_dashboard_deployments.query.graphql'; import k8sDashboardStatefulSetsQuery from '../queries/k8s_dashboard_stateful_sets.query.graphql'; +import k8sDashboardReplicaSetsQuery from '../queries/k8s_dashboard_replica_sets.query.graphql'; export default { k8sPods(_, { configuration }, { client }) { @@ -91,4 +92,41 @@ export default { } }); }, + + k8sReplicaSets(_, { configuration, namespace = '' }, { client }) { + const config = new Configuration(configuration); + + const appsV1api = new AppsV1Api(config); + const deploymentsApi = namespace + ? appsV1api.listAppsV1NamespacedReplicaSet({ namespace }) + : appsV1api.listAppsV1ReplicaSetForAllNamespaces(); + return deploymentsApi + .then((res) => { + const watchPath = buildWatchPath({ + resource: 'replicasets', + api: 'apis/apps/v1', + namespace, + }); + watchWorkloadItems({ + client, + query: k8sDashboardReplicaSetsQuery, + configuration, + namespace, + watchPath, + queryField: 'k8sReplicaSets', + mapFn: mapSetItem, + }); + + const data = res?.items || []; + + return data.map(mapSetItem); + }) + .catch(async (err) => { + try { + await handleClusterError(err); + } catch (error) { + throw new Error(error.message); + } + }); + }, }; diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/replica_sets_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/replica_sets_page.vue new file mode 100644 index 0000000000000000000000000000000000000000..212cc0dbaf73ef0eb7633c4e51ef5d68f92f37ed --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/pages/replica_sets_page.vue @@ -0,0 +1,80 @@ +<script> +import { s__ } from '~/locale'; +import { getAge, calculateStatefulSetStatus } from '../helpers/k8s_integration_helper'; +import WorkloadLayout from '../components/workload_layout.vue'; +import k8sReplicaSetsQuery from '../graphql/queries/k8s_dashboard_replica_sets.query.graphql'; +import { STATUS_FAILED, STATUS_READY, STATUS_LABELS } from '../constants'; + +export default { + components: { + WorkloadLayout, + }, + inject: ['configuration'], + apollo: { + k8sReplicaSets: { + query: k8sReplicaSetsQuery, + variables() { + return { + configuration: this.configuration, + }; + }, + update(data) { + return ( + data?.k8sReplicaSets?.map((replicaSet) => { + return { + name: replicaSet.metadata?.name, + namespace: replicaSet.metadata?.namespace, + status: calculateStatefulSetStatus(replicaSet), + age: getAge(replicaSet.metadata?.creationTimestamp), + labels: replicaSet.metadata?.labels, + annotations: replicaSet.metadata?.annotations, + kind: s__('KubernetesDashboard|ReplicaSet'), + }; + }) || [] + ); + }, + error(err) { + this.errorMessage = err?.message; + }, + }, + }, + data() { + return { + k8sReplicaSets: [], + errorMessage: '', + }; + }, + computed: { + replicaSetsStats() { + return [ + { + value: this.countReplicaSetsByStatus(STATUS_READY), + title: STATUS_LABELS[STATUS_READY], + }, + { + value: this.countReplicaSetsByStatus(STATUS_FAILED), + title: STATUS_LABELS[STATUS_FAILED], + }, + ]; + }, + loading() { + return this.$apollo.queries.k8sReplicaSets.loading; + }, + }, + methods: { + countReplicaSetsByStatus(phase) { + const filteredReplicaSets = this.k8sReplicaSets.filter((item) => item.status === phase) || []; + + return filteredReplicaSets.length; + }, + }, +}; +</script> +<template> + <workload-layout + :loading="loading" + :error-message="errorMessage" + :stats="replicaSetsStats" + :items="k8sReplicaSets" + /> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/router/constants.js b/app/assets/javascripts/kubernetes_dashboard/router/constants.js index 34bb4e8f42f5de8157283bf70d52c41963b497a7..daa2bd6075be867b35f5e05b7abc29a256ad9bc9 100644 --- a/app/assets/javascripts/kubernetes_dashboard/router/constants.js +++ b/app/assets/javascripts/kubernetes_dashboard/router/constants.js @@ -1,7 +1,9 @@ export const PODS_ROUTE_NAME = 'pods'; export const DEPLOYMENTS_ROUTE_NAME = 'deployments'; export const STATEFUL_SETS_ROUTE_NAME = 'statefulSets'; +export const REPLICA_SETS_ROUTE_NAME = 'replicaSets'; export const PODS_ROUTE_PATH = '/pods'; export const DEPLOYMENTS_ROUTE_PATH = '/deployments'; export const STATEFUL_SETS_ROUTE_PATH = '/statefulsets'; +export const REPLICA_SETS_ROUTE_PATH = '/replicasets'; diff --git a/app/assets/javascripts/kubernetes_dashboard/router/routes.js b/app/assets/javascripts/kubernetes_dashboard/router/routes.js index 96e7a35bfe2afe0a53ebab844b4830e498b0decb..ee5afcef14f0679a08c189bd2df13cacb3123550 100644 --- a/app/assets/javascripts/kubernetes_dashboard/router/routes.js +++ b/app/assets/javascripts/kubernetes_dashboard/router/routes.js @@ -2,6 +2,7 @@ import { s__ } from '~/locale'; import PodsPage from '../pages/pods_page.vue'; import DeploymentsPage from '../pages/deployments_page.vue'; import StatefulSetsPage from '../pages/stateful_sets_page.vue'; +import ReplicaSetsPage from '../pages/replica_sets_page.vue'; import { PODS_ROUTE_NAME, PODS_ROUTE_PATH, @@ -9,6 +10,8 @@ import { DEPLOYMENTS_ROUTE_PATH, STATEFUL_SETS_ROUTE_NAME, STATEFUL_SETS_ROUTE_PATH, + REPLICA_SETS_ROUTE_NAME, + REPLICA_SETS_ROUTE_PATH, } from './constants'; export default [ @@ -36,4 +39,12 @@ export default [ title: s__('KubernetesDashboard|StatefulSets'), }, }, + { + name: REPLICA_SETS_ROUTE_NAME, + path: REPLICA_SETS_ROUTE_PATH, + component: ReplicaSetsPage, + meta: { + title: s__('KubernetesDashboard|ReplicaSets'), + }, + }, ]; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d4a4fd6fd7a60764631d1c659af46d2429841d9a..fdc2ce3cbd0746c1d680ec0da1ffa43b75e4b002 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -27899,6 +27899,12 @@ msgstr "" msgid "KubernetesDashboard|Ready" msgstr "" +msgid "KubernetesDashboard|ReplicaSet" +msgstr "" + +msgid "KubernetesDashboard|ReplicaSets" +msgstr "" + msgid "KubernetesDashboard|Running" msgstr "" diff --git a/spec/frontend/kubernetes_dashboard/graphql/mock_data.js b/spec/frontend/kubernetes_dashboard/graphql/mock_data.js index 030e801c06dec8c9fc778bea348a8ef44e98c563..fe26bf5c60083aecab72a928cf1c1cfc13adeeed 100644 --- a/spec/frontend/kubernetes_dashboard/graphql/mock_data.js +++ b/spec/frontend/kubernetes_dashboard/graphql/mock_data.js @@ -289,3 +289,9 @@ export const mockStatefulSetsTableItems = [ kind: 'StatefulSet', }, ]; + +export const k8sReplicaSetsMock = [readyStatefulSet, readyStatefulSet, failedStatefulSet]; + +export const mockReplicaSetsTableItems = mockStatefulSetsTableItems.map((item) => { + return { ...item, kind: 'ReplicaSet' }; +}); diff --git a/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js b/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js index 5841c73ea7163b7d32efa3caa6470c756491e51c..feee63546dd9e76460398d9e93b1ce7a0be56150 100644 --- a/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js +++ b/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js @@ -3,7 +3,13 @@ import { resolvers } from '~/kubernetes_dashboard/graphql/resolvers'; import k8sDashboardPodsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_pods.query.graphql'; import k8sDashboardDeploymentsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_deployments.query.graphql'; import k8sDashboardStatefulSetsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_stateful_sets.query.graphql'; -import { k8sPodsMock, k8sDeploymentsMock, k8sStatefulSetsMock } from '../mock_data'; +import k8sDashboardReplicaSetsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_replica_sets.query.graphql'; +import { + k8sPodsMock, + k8sDeploymentsMock, + k8sStatefulSetsMock, + k8sReplicaSetsMock, +} from '../mock_data'; describe('~/frontend/environments/graphql/resolvers', () => { let mockResolvers; @@ -276,4 +282,92 @@ describe('~/frontend/environments/graphql/resolvers', () => { ).rejects.toThrow('API error'); }); }); + + describe('k8sReplicaSets', () => { + const client = { writeQuery: jest.fn() }; + + const mockWatcher = WatchApi.prototype; + const mockReplicaSetsListWatcherFn = jest.fn().mockImplementation(() => { + return Promise.resolve(mockWatcher); + }); + + const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => { + if (eventName === 'data') { + callback([]); + } + }); + + const mockReplicaSetsListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: k8sReplicaSetsMock, + }); + }); + + const mockAllReplicaSetsListFn = jest.fn().mockImplementation(mockReplicaSetsListFn); + + describe('when the ReplicaSets data is present', () => { + beforeEach(() => { + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1ReplicaSetForAllNamespaces') + .mockImplementation(mockAllReplicaSetsListFn); + jest + .spyOn(mockWatcher, 'subscribeToStream') + .mockImplementation(mockReplicaSetsListWatcherFn); + jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn); + }); + + it('should request all ReplicaSets from the cluster_client library and watch the events', async () => { + const ReplicaSets = await mockResolvers.Query.k8sReplicaSets( + null, + { + configuration, + }, + { client }, + ); + + expect(mockAllReplicaSetsListFn).toHaveBeenCalled(); + expect(mockReplicaSetsListWatcherFn).toHaveBeenCalled(); + + expect(ReplicaSets).toEqual(k8sReplicaSetsMock); + }); + + it('should update cache with the new data when received from the library', async () => { + await mockResolvers.Query.k8sReplicaSets( + null, + { configuration, namespace: '' }, + { client }, + ); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: k8sDashboardReplicaSetsQuery, + variables: { configuration, namespace: '' }, + data: { k8sReplicaSets: [] }, + }); + }); + }); + + it('should not watch ReplicaSets from the cluster_client library when the ReplicaSets data is not present', async () => { + jest.spyOn(AppsV1Api.prototype, 'listAppsV1ReplicaSetForAllNamespaces').mockImplementation( + jest.fn().mockImplementation(() => { + return Promise.resolve({ + items: [], + }); + }), + ); + + await mockResolvers.Query.k8sReplicaSets(null, { configuration }, { client }); + + expect(mockReplicaSetsListWatcherFn).not.toHaveBeenCalled(); + }); + + it('should throw an error if the API call fails', async () => { + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1ReplicaSetForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect( + mockResolvers.Query.k8sReplicaSets(null, { configuration }, { client }), + ).rejects.toThrow('API error'); + }); + }); }); diff --git a/spec/frontend/kubernetes_dashboard/pages/replica_sets_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/replica_sets_page_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0e442ec8328afedf21e9f6e121643933c05ec548 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/pages/replica_sets_page_spec.js @@ -0,0 +1,106 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import ReplicaSetsPage from '~/kubernetes_dashboard/pages/replica_sets_page.vue'; +import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue'; +import { useFakeDate } from 'helpers/fake_date'; +import { + k8sReplicaSetsMock, + mockStatefulSetsStats, + mockReplicaSetsTableItems, +} from '../graphql/mock_data'; + +Vue.use(VueApollo); + +describe('Kubernetes dashboard replicaSets page', () => { + let wrapper; + + const configuration = { + basePath: 'kas/tunnel/url', + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const findWorkloadLayout = () => wrapper.findComponent(WorkloadLayout); + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + k8sReplicaSets: jest.fn().mockReturnValue(k8sReplicaSetsMock), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (apolloProvider = createApolloProvider()) => { + wrapper = shallowMount(ReplicaSetsPage, { + provide: { configuration }, + apolloProvider, + }); + }; + + describe('mounted', () => { + it('renders WorkloadLayout component', () => { + createWrapper(); + + expect(findWorkloadLayout().exists()).toBe(true); + }); + + it('sets loading prop for the WorkloadLayout', () => { + createWrapper(); + + expect(findWorkloadLayout().props('loading')).toBe(true); + }); + + it('removes loading prop from the WorkloadLayout when the list of pods loaded', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('loading')).toBe(false); + }); + }); + + describe('when gets pods data', () => { + useFakeDate(2023, 10, 23, 10, 10); + + it('sets correct stats object for the WorkloadLayout', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('stats')).toEqual(mockStatefulSetsStats); + }); + + it('sets correct table items object for the WorkloadLayout', async () => { + createWrapper(); + await waitForPromises(); + + expect(findWorkloadLayout().props('items')).toMatchObject(mockReplicaSetsTableItems); + }); + }); + + 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: { + k8sReplicaSets: jest.fn().mockRejectedValueOnce(error), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + beforeEach(async () => { + createWrapper(createErroredApolloProvider()); + await waitForPromises(); + }); + + it('sets errorMessage prop for the WorkloadLayout', () => { + expect(findWorkloadLayout().props('errorMessage')).toBe(error.message); + }); + }); +});