From 9f096360beace4e5a2fe8dbc71baa808372c8e87 Mon Sep 17 00:00:00 2001 From: Cindy Halim Date: Mon, 2 Jun 2025 13:29:05 +0900 Subject: [PATCH 1/7] Refactor org cluster agent availability toggle --- .../components/availability_toggle.vue | 48 +++++++++++++++++++ ...zation_workspaces_cluster_agents_query.vue | 6 +-- .../workspaces/admin_settings/constants.js | 11 ----- .../workspaces/admin_settings/pages/app.vue | 27 +++++------ ...on_workspaces_cluster_agents_query_spec.js | 32 ++++++------- 5 files changed, 78 insertions(+), 46 deletions(-) create mode 100644 ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue delete mode 100644 ee/app/assets/javascripts/workspaces/admin_settings/constants.js diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue b/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue new file mode 100644 index 00000000000000..7721d2bd65d0ac --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue @@ -0,0 +1,48 @@ + + diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue b/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue index 66a0097f55fb7f..09866cd9d27818 100644 --- a/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue +++ b/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue @@ -4,7 +4,6 @@ import { logError } from '~/lib/logger'; import organizationWorkspacesClusterAgentsQuery from '../graphql/queries/organization_workspaces_cluster_agents.query.graphql'; import mappedOrganizationClusterAgentsQuery from '../graphql/queries/organization_mapped_agents.query.graphql'; -import { AVAILABILITY_OPTIONS } from '../constants'; export default { props: { @@ -74,15 +73,14 @@ export default { this.afterCursor = pageInfo.endCursor; const agents = data.organization.organizationWorkspacesClusterAgents.nodes.map((agent) => ({ + id: agent.id, name: agent.name, url: joinPaths(window.gon.gitlab_url, agent.webPath), group: agent.project?.group?.name || '', project: agent.project?.name || '', isConnected: Boolean(agent.connections?.nodes.length), workspacesEnabled: Boolean(agent.workspacesAgentConfig?.enabled), - availability: this.mappedAgents.has(agent.id) - ? AVAILABILITY_OPTIONS.AVAILABLE - : AVAILABILITY_OPTIONS.BLOCKED, + isMapped: this.mappedAgents.has(agent.id), })); return agents; diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/constants.js b/ee/app/assets/javascripts/workspaces/admin_settings/constants.js deleted file mode 100644 index 1d30a9c9940ec3..00000000000000 --- a/ee/app/assets/javascripts/workspaces/admin_settings/constants.js +++ /dev/null @@ -1,11 +0,0 @@ -import { s__ } from '~/locale'; - -export const AVAILABILITY_OPTIONS = { - AVAILABLE: 'available', - BLOCKED: 'blocked', -}; - -export const AVAILABILITY_TEXT = { - [AVAILABILITY_OPTIONS.AVAILABLE]: s__('Workspaces|Available'), - [AVAILABILITY_OPTIONS.BLOCKED]: s__('Workspaces|Blocked'), -}; diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/pages/app.vue b/ee/app/assets/javascripts/workspaces/admin_settings/pages/app.vue index c0338217d1a108..977d820d8d06a4 100644 --- a/ee/app/assets/javascripts/workspaces/admin_settings/pages/app.vue +++ b/ee/app/assets/javascripts/workspaces/admin_settings/pages/app.vue @@ -2,7 +2,6 @@ import { GlBadge, GlTableLite, - GlToggle, GlSkeletonLoader, GlLink, GlAlert, @@ -12,19 +11,18 @@ import { s__ } from '~/locale'; import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; import AvailabilityPopover from '../components/availability_popover.vue'; +import ClusterAgentAvailabilityToggle from '../components/availability_toggle.vue'; import GetOrganizationWorkspacesClusterAgentsQuery from '../components/get_organization_workspaces_cluster_agents_query.vue'; -import { AVAILABILITY_TEXT } from '../constants'; - export default { name: 'WorkspacesAgentAvailabilityApp', components: { SettingsBlock, GetOrganizationWorkspacesClusterAgentsQuery, AvailabilityPopover, + ClusterAgentAvailabilityToggle, GlTableLite, GlBadge, - GlToggle, GlSkeletonLoader, GlLink, GlAlert, @@ -41,8 +39,12 @@ export default { }, }, methods: { - getAvailabilityText(availability) { - return AVAILABILITY_TEXT[availability] ?? null; + getStatusBadgeMetadata(item) { + const { isConnected } = item; + return { + text: isConnected ? s__('Workspaces|Connected') : s__('Workspaces|Not connected'), + variant: isConnected ? 'success' : 'neutral', + }; }, getStatusBadgeMetadata(item) { const { isConnected } = item; @@ -126,15 +128,10 @@ export default { }} Date: Mon, 2 Jun 2025 17:08:26 +0900 Subject: [PATCH 2/7] Refactor GetOrganizationWorkspacesClusterAgentsQuery to handle cache updates --- ...zation_workspaces_cluster_agents_query.vue | 24 +- ...on_workspaces_cluster_agents.query.graphql | 2 +- ...on_workspaces_cluster_agents_query_spec.js | 210 ++++++++++-------- 3 files changed, 141 insertions(+), 95 deletions(-) diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue b/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue index 09866cd9d27818..b0387deb14eba3 100644 --- a/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue +++ b/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue @@ -16,7 +16,7 @@ export default { return { mappedAgentsLoaded: false, mappedAgents: null, - agents: [], + agents: null, error: null, beforeCursor: null, afterCursor: null, @@ -24,6 +24,20 @@ export default { hasPreviousPage: false, }; }, + watch: { + // why: this ensures we don't have stale agents data when the mapped agents data + // in the cache is updated after a successful mutation. + mappedAgents(newMappedAgents) { + // this handles the case when agents query is not yet executed + if (!this.agents) return; + + const updatedAgents = this.agents.map((agent) => ({ + ...agent, + isMapped: newMappedAgents.has(agent.id), + })); + this.agents = updatedAgents; + }, + }, apollo: { mappedAgents: { query: mappedOrganizationClusterAgentsQuery, @@ -36,6 +50,7 @@ export default { return !this.organizationId; }, error(error) { + this.$emit('error', { error }); logError(error); this.error = error; }, @@ -59,20 +74,21 @@ export default { return !this.mappedAgentsLoaded; }, error(error) { + this.$emit('error', { error }); logError(error); this.error = error; }, update(data) { this.error = null; - const { pageInfo } = data.organization.organizationWorkspacesClusterAgents; + const { pageInfo, nodes } = data.organization.organizationWorkspacesClusterAgents; this.hasNextPage = pageInfo.hasNextPage; this.hasPreviousPage = pageInfo.hasPreviousPage; this.beforeCursor = pageInfo.startCursor; this.afterCursor = pageInfo.endCursor; - const agents = data.organization.organizationWorkspacesClusterAgents.nodes.map((agent) => ({ + return nodes.map((agent) => ({ id: agent.id, name: agent.name, url: joinPaths(window.gon.gitlab_url, agent.webPath), @@ -82,8 +98,6 @@ export default { workspacesEnabled: Boolean(agent.workspacesAgentConfig?.enabled), isMapped: this.mappedAgents.has(agent.id), })); - - return agents; }, }, }, diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/graphql/queries/organization_workspaces_cluster_agents.query.graphql b/ee/app/assets/javascripts/workspaces/admin_settings/graphql/queries/organization_workspaces_cluster_agents.query.graphql index 833c3892009e5a..14b75f56842992 100644 --- a/ee/app/assets/javascripts/workspaces/admin_settings/graphql/queries/organization_workspaces_cluster_agents.query.graphql +++ b/ee/app/assets/javascripts/workspaces/admin_settings/graphql/queries/organization_workspaces_cluster_agents.query.graphql @@ -6,7 +6,7 @@ query organizationWorkspacesClusterAgents( organization(id: $organizationId) { id organizationWorkspacesClusterAgents: workspacesClusterAgents( - filter: DIRECTLY_MAPPED + filter: ALL first: 5 before: $before after: $after diff --git a/ee/spec/frontend/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query_spec.js b/ee/spec/frontend/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query_spec.js index f196b0792cba54..8f17b4b5b1f2fc 100644 --- a/ee/spec/frontend/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query_spec.js +++ b/ee/spec/frontend/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query_spec.js @@ -18,6 +18,88 @@ Vue.use(VueApollo); jest.mock('~/lib/logger'); const MOCK_ORG_ID = 'gid://gitlab/Organizations::Organization/1'; +const MOCK_AGENTS_RESULT = [ + { + id: 'gid://gitlab/Clusters::Agent/14', + isMapped: true, + group: 'Gitlab Org', + isConnected: false, + name: 'midnightowlgarden', + project: 'gitlab-agent-configurations', + url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/midnightowlgarden', + workspacesEnabled: true, + }, + { + id: 'gid://gitlab/Clusters::Agent/13', + isMapped: false, + group: 'Gitlab Org', + isConnected: false, + name: 'coastalechovalley', + project: 'gitlab-agent-configurations', + url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/coastalechovalley', + workspacesEnabled: true, + }, + { + id: 'gid://gitlab/Clusters::Agent/12', + isMapped: true, + group: 'Gitlab Org', + isConnected: false, + name: 'wandingbreezetale', + project: 'gitlab-agent-configurations', + url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/wandingbreezetale', + workspacesEnabled: false, + }, + { + id: 'gid://gitlab/Clusters::Agent/11', + isMapped: false, + group: 'Gitlab Org', + isConnected: false, + name: 'crimsonmapleshadow', + project: 'gitlab-agent-configurations', + url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/crimsonmapleshadow', + workspacesEnabled: false, + }, + { + id: 'gid://gitlab/Clusters::Agent/10', + isMapped: true, + group: 'Gitlab Org', + isConnected: true, + name: 'meadowsageharbor', + project: 'gitlab-agent-configurations', + url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/meadowsageharbor', + workspacesEnabled: true, + }, + { + id: 'gid://gitlab/Clusters::Agent/16', + isMapped: false, + group: 'Gitlab Org', + isConnected: true, + name: 'silvermoonharbor', + project: 'gitlab-agent-configurations', + url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/silvermoonharbor', + workspacesEnabled: true, + }, + { + id: 'gid://gitlab/Clusters::Agent/17', + isMapped: true, + group: 'Gitlab Org', + isConnected: true, + name: 'silvermoonharbor', + project: 'gitlab-agent-configurations', + url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/silvermoonharbor', + workspacesEnabled: false, + }, + { + id: 'gid://gitlab/Clusters::Agent/18', + isMapped: false, + group: 'Gitlab Org', + isConnected: true, + name: 'oceanbreezecliff', + project: 'gitlab-agent-configurations', + url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/oceanbreezecliff', + workspacesEnabled: false, + }, +]; describe('workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue', () => { const defaultSlotSpy = jest.fn(); @@ -93,7 +175,7 @@ describe('workspaces/admin_settings/components/get_organization_workspaces_clust loading: false, pagination: null, error: mockError, - agents: [], + agents: null, }); }); }); @@ -138,7 +220,7 @@ describe('workspaces/admin_settings/components/get_organization_workspaces_clust loading: false, pagination: null, error: mockError, - agents: [], + agents: null, }); }); @@ -149,11 +231,16 @@ describe('workspaces/admin_settings/components/get_organization_workspaces_clust ORGANIZATION_WORKSPACES_CLUSTER_AGENTS_QUERY_RESULT, ); await buildWrapperWithOrg(); + await nextTick(); }); - it('renders correct data to scoped slot', () => { + const getAgentInScopedSlot = (agentId) => { + return defaultSlotSpy.mock.lastCall[0].agents.find((agent) => agent.id === agentId); + }; + + it('returns correct data to scoped slot', () => { const scopedSlotCall = defaultSlotSpy.mock.lastCall[0]; - const expectedPaginationData = { + const expectedPaginationResult = { show: true, hasPreviousPage: false, hasNextPage: true, @@ -161,97 +248,42 @@ describe('workspaces/admin_settings/components/get_organization_workspaces_clust prevPage: wrapper.vm.prevPage, }; - const expectedAgentsResult = [ - { - id: 'gid://gitlab/Clusters::Agent/14', - isMapped: true, - group: 'Gitlab Org', - isConnected: false, - name: 'midnightowlgarden', - project: 'gitlab-agent-configurations', - url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/midnightowlgarden', - workspacesEnabled: true, - }, - { - id: 'gid://gitlab/Clusters::Agent/13', - isMapped: false, - group: 'Gitlab Org', - isConnected: false, - name: 'coastalechovalley', - project: 'gitlab-agent-configurations', - url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/coastalechovalley', - workspacesEnabled: true, - }, - { - id: 'gid://gitlab/Clusters::Agent/12', - isMapped: true, - group: 'Gitlab Org', - isConnected: false, - name: 'wandingbreezetale', - project: 'gitlab-agent-configurations', - url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/wandingbreezetale', - workspacesEnabled: false, - }, - { - id: 'gid://gitlab/Clusters::Agent/11', - isMapped: false, - group: 'Gitlab Org', - isConnected: false, - name: 'crimsonmapleshadow', - project: 'gitlab-agent-configurations', - url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/crimsonmapleshadow', - workspacesEnabled: false, - }, - { - id: 'gid://gitlab/Clusters::Agent/10', - isMapped: true, - group: 'Gitlab Org', - isConnected: true, - name: 'meadowsageharbor', - project: 'gitlab-agent-configurations', - url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/meadowsageharbor', - workspacesEnabled: true, - }, - { - id: 'gid://gitlab/Clusters::Agent/16', - isMapped: false, - group: 'Gitlab Org', - isConnected: true, - name: 'silvermoonharbor', - project: 'gitlab-agent-configurations', - url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/silvermoonharbor', - workspacesEnabled: true, - }, - { - id: 'gid://gitlab/Clusters::Agent/17', - isMapped: true, - group: 'Gitlab Org', - isConnected: true, - name: 'silvermoonharbor', - project: 'gitlab-agent-configurations', - url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/silvermoonharbor', - workspacesEnabled: false, - }, - { - id: 'gid://gitlab/Clusters::Agent/18', - isMapped: false, - group: 'Gitlab Org', - isConnected: true, - name: 'oceanbreezecliff', - project: 'gitlab-agent-configurations', - url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/oceanbreezecliff', - workspacesEnabled: false, - }, - ]; - expect(scopedSlotCall).toMatchObject({ loading: false, - pagination: expectedPaginationData, + pagination: expectedPaginationResult, error: null, - agents: expectedAgentsResult, + agents: MOCK_AGENTS_RESULT, }); }); + it('returns correct agents to scopped slot when mappedAgents is updated', async () => { + const MOCK_AGENT_ID = 'gid://gitlab/Clusters::Agent/14'; + const agentResult = MOCK_AGENTS_RESULT.find((agent) => agent.id === MOCK_AGENT_ID); + + const agentBefore = getAgentInScopedSlot(MOCK_AGENT_ID); + + expect(agentBefore).toStrictEqual(agentResult); + + // Unmap the agent + const newMappedAgentIds = + ORGANIZATION_MAPPED_AGENTS_QUERY_RESULT.data.organization.mappedAgents.nodes.filter( + (agent) => agent.id !== MOCK_AGENT_ID, + ); + const newMappedAgents = new Set(newMappedAgentIds); + + // Directly set the data to simulate Apollo's update + wrapper.vm.mappedAgents = newMappedAgents; + + await nextTick(); + + const agentAfter = getAgentInScopedSlot(MOCK_AGENT_ID); + const expectedAgentResult = { + ...agentResult, + isMapped: false, + }; + expect(agentAfter).toStrictEqual(expectedAgentResult); + }); + it.each` methodName | expectedVariables ${'nextPage'} | ${{ organizationId: MOCK_ORG_ID, before: null, after: 'eyJpZCI6IjEwIn0' }} -- GitLab From 6120af1710465c81542d47b4904033209d95eac1 Mon Sep 17 00:00:00 2001 From: Cindy Halim Date: Mon, 2 Jun 2025 18:46:46 +0900 Subject: [PATCH 3/7] Implement org mapping mutations --- .../components/availability_toggle.vue | 148 ++++++++++-- ...org_cluster_agent_mapping.mutation.graphql | 7 + ...org_cluster_agent_mapping.mutation.graphql | 7 + .../workspaces/admin_settings/pages/app.vue | 5 +- .../components/availability_toggle_spec.js | 211 ++++++++++++++++++ .../frontend/workspaces/mock_data/index.js | 32 +++ locale/gitlab.pot | 6 + 7 files changed, 391 insertions(+), 25 deletions(-) create mode 100644 ee/app/assets/javascripts/workspaces/admin_settings/graphql/mutations/create_org_cluster_agent_mapping.mutation.graphql create mode 100644 ee/app/assets/javascripts/workspaces/admin_settings/graphql/mutations/delete_org_cluster_agent_mapping.mutation.graphql create mode 100644 ee/spec/frontend/workspaces/admin_settings/components/availability_toggle_spec.js diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue b/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue index 7721d2bd65d0ac..bacf8964989ebd 100644 --- a/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue +++ b/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue @@ -1,18 +1,49 @@ diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/graphql/mutations/create_org_cluster_agent_mapping.mutation.graphql b/ee/app/assets/javascripts/workspaces/admin_settings/graphql/mutations/create_org_cluster_agent_mapping.mutation.graphql new file mode 100644 index 00000000000000..023a6c942e8deb --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/admin_settings/graphql/mutations/create_org_cluster_agent_mapping.mutation.graphql @@ -0,0 +1,7 @@ +mutation createOrganizationClusterAgentMapping( + $input: OrganizationCreateClusterAgentMappingInput! +) { + organizationCreateClusterAgentMapping(input: $input) { + errors + } +} diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/graphql/mutations/delete_org_cluster_agent_mapping.mutation.graphql b/ee/app/assets/javascripts/workspaces/admin_settings/graphql/mutations/delete_org_cluster_agent_mapping.mutation.graphql new file mode 100644 index 00000000000000..504eb0da10365a --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/admin_settings/graphql/mutations/delete_org_cluster_agent_mapping.mutation.graphql @@ -0,0 +1,7 @@ +mutation deleteOrganizationClusterAgentMapping( + $input: OrganizationDeleteClusterAgentMappingInput! +) { + organizationDeleteClusterAgentMapping(input: $input) { + errors + } +} diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/pages/app.vue b/ee/app/assets/javascripts/workspaces/admin_settings/pages/app.vue index 977d820d8d06a4..d182bac0be7e49 100644 --- a/ee/app/assets/javascripts/workspaces/admin_settings/pages/app.vue +++ b/ee/app/assets/javascripts/workspaces/admin_settings/pages/app.vue @@ -128,10 +128,7 @@ export default { }} { + let createOrgClusterAgentMappingMutationHandler; + let deleteOrgClusterAgentMappingMutationHandler; + let apolloProvider; + let wrapper; + + const setupApolloProvider = ( + MOCK_CREATE_RESULT = CREATE_ORG_CLUSTER_AGENT_MAPPING_MUTATION_RESULT, + MOCK_DELETE_RESULT = DELETE_ORG_CLUSTER_AGENT_MAPPING_MUTATION_RESULT, + ) => { + createOrgClusterAgentMappingMutationHandler = jest + .fn() + .mockResolvedValueOnce(MOCK_CREATE_RESULT); + deleteOrgClusterAgentMappingMutationHandler = jest + .fn() + .mockResolvedValueOnce(MOCK_DELETE_RESULT); + + apolloProvider = createMockApollo([ + [createClusterAgentMappingMutation, createOrgClusterAgentMappingMutationHandler], + [deleteClusterAgentMappingMutation, deleteOrgClusterAgentMappingMutationHandler], + ]); + + apolloProvider.clients.defaultClient.writeQuery({ + query: mappedOrganizationClusterAgentsQuery, + variables: { + organizationId: MOCK_ORG_ID, + }, + data: ORGANIZATION_MAPPED_AGENTS_QUERY_RESULT.data, + }); + }; + + const getAgentFromMappedAgentsStore = (agentId) => { + const data = apolloProvider.clients.defaultClient.cache.readQuery({ + query: mappedOrganizationClusterAgentsQuery, + variables: { + organizationId: MOCK_ORG_ID, + }, + }); + + const mappedAgents = data.organization.mappedAgents.nodes; + return mappedAgents.filter((agent) => agent.id === agentId); + }; + + const buildWrapper = async (propsData = {}) => { + wrapper = shallowMount(ClusterAgentAvailabilityToggle, { + apolloProvider, + propsData: { + agentId: MOCK_AGENT_ID, + mapped: true, + ...propsData, + }, + provide: { + organizationId: MOCK_ORG_ID, + }, + }); + + await waitForPromises(); + }; + + const findToggle = () => wrapper.findComponent(GlToggle); + const findAvailabilityText = () => wrapper.find('[data-test-id="availability-text"]'); + const findErrorMessage = () => wrapper.find('[data-test-id="error-message"]'); + + describe('when agent is mapped', () => { + beforeEach(() => { + setupApolloProvider(); + buildWrapper({ + mapped: true, + }); + }); + + it('renders toggle with correct text', () => { + expect(findToggle().props('label')).toEqual('Available'); + expect(findToggle().props('value')).toBe(true); + expect(findAvailabilityText().text()).toEqual('Available'); + }); + + it('calls delete org cluster agent mutation on toggle', async () => { + findToggle().vm.$emit('change'); + await nextTick(); + + expect(deleteOrgClusterAgentMappingMutationHandler).toHaveBeenCalledTimes(1); + expect(deleteOrgClusterAgentMappingMutationHandler).toHaveBeenCalledWith({ + input: { + organizationId: MOCK_ORG_ID, + clusterAgentId: MOCK_AGENT_ID, + }, + }); + }); + + it('removes agent from mappedAgents data in store when mutation is successful', async () => { + // This agent ID exists in ORGANIZATION_MAPPED_AGENTS_QUERY_RESULT + const MOCK_MAPPED_AGENT_ID = 'gid://gitlab/Clusters::Agent/10'; + + buildWrapper({ + agentId: MOCK_MAPPED_AGENT_ID, + mapped: true, + }); + + expect(getAgentFromMappedAgentsStore(MOCK_MAPPED_AGENT_ID)).toHaveLength(1); + + findToggle().vm.$emit('change'); + await waitForPromises(); + await nextTick(); + + expect(findToggle().props('disabled')).toBe(false); + expect(getAgentFromMappedAgentsStore(MOCK_MAPPED_AGENT_ID)).toHaveLength(0); + }); + }); + + describe('when agent is unmapped', () => { + beforeEach(() => { + setupApolloProvider(); + buildWrapper({ + mapped: false, + }); + }); + + it('renders toggle with correct text', () => { + expect(findToggle().props('label')).toEqual('Blocked'); + expect(findToggle().props('value')).toBe(false); + expect(findAvailabilityText().text()).toEqual('Blocked'); + }); + + it('calls create org cluster agent mutation on toggle', async () => { + findToggle().vm.$emit('change'); + await nextTick(); + + expect(createOrgClusterAgentMappingMutationHandler).toHaveBeenCalledTimes(1); + expect(createOrgClusterAgentMappingMutationHandler).toHaveBeenCalledWith({ + input: { + organizationId: MOCK_ORG_ID, + clusterAgentId: MOCK_AGENT_ID, + }, + }); + }); + + it('adds agent to mappedAgents data in store when mutation is successful', async () => { + // This agent ID does not exist in ORGANIZATION_MAPPED_AGENTS_QUERY_RESULT + const MOCK_UNMAPPED_AGENT_ID = 'gid://gitlab/Clusters::Agent/6'; + + buildWrapper({ + agentId: MOCK_UNMAPPED_AGENT_ID, + mapped: false, + }); + + expect(getAgentFromMappedAgentsStore(MOCK_UNMAPPED_AGENT_ID)).toHaveLength(0); + + findToggle().vm.$emit('change'); + await waitForPromises(); + await nextTick(); + + expect(findToggle().props('disabled')).toBe(false); + expect(getAgentFromMappedAgentsStore(MOCK_UNMAPPED_AGENT_ID)).toHaveLength(1); + }); + }); + + describe('on mutation error', () => { + beforeEach(() => { + setupApolloProvider( + CREATE_ORG_CLUSTER_AGENT_MAPPING_MUTATION_RESULT_WITH_ERROR, + DELETE_ORG_CLUSTER_AGENT_MAPPING_MUTATION_RESULT_WITH_ERROR, + ); + }); + + it.each` + isMapped | expectedErrorMessage + ${true} | ${'This agent is already blocked.'} + ${false} | ${'This agent is already available.'} + `( + 'displays correct error message when mutation fails when agent is mapped: $isMapped', + async ({ isMapped, expectedErrorMessage }) => { + buildWrapper({ + mapped: isMapped, + }); + findToggle().vm.$emit('change'); + await waitForPromises(); + await nextTick(); + + expect(findToggle().props('disabled')).toBe(false); + expect(findErrorMessage().text()).toEqual(expectedErrorMessage); + }, + ); + }); +}); diff --git a/ee/spec/frontend/workspaces/mock_data/index.js b/ee/spec/frontend/workspaces/mock_data/index.js index 129db9a6c2b2b5..038bb58da21068 100644 --- a/ee/spec/frontend/workspaces/mock_data/index.js +++ b/ee/spec/frontend/workspaces/mock_data/index.js @@ -907,3 +907,35 @@ export const ORGANIZATION_WORKSPACES_CLUSTER_AGENTS_QUERY_RESULT = { }, }, }; + +export const CREATE_ORG_CLUSTER_AGENT_MAPPING_MUTATION_RESULT = { + data: { + organizationCreateClusterAgentMapping: { + errors: [], + }, + }, +}; + +export const CREATE_ORG_CLUSTER_AGENT_MAPPING_MUTATION_RESULT_WITH_ERROR = { + data: { + organizationCreateClusterAgentMapping: { + errors: ['Cluster agent mapping already exists'], + }, + }, +}; + +export const DELETE_ORG_CLUSTER_AGENT_MAPPING_MUTATION_RESULT = { + data: { + organizationDeleteClusterAgentMapping: { + errors: [], + }, + }, +}; + +export const DELETE_ORG_CLUSTER_AGENT_MAPPING_MUTATION_RESULT_WITH_ERROR = { + data: { + organizationDeleteClusterAgentMapping: { + errors: ['Cluster agent mapping already exists'], + }, + }, +}; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9d6fb02941f271..be7298b4459b99 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -59277,6 +59277,9 @@ msgstr "" msgid "Something went wrong. Please try again." msgstr "" +msgid "Something went wrong. Refresh the page and try again." +msgstr "" + msgid "Something went wrong. Unable to create identity verification exemption." msgstr "" @@ -70841,6 +70844,9 @@ msgstr "" msgid "Workspaces|This agent is already allowed." msgstr "" +msgid "Workspaces|This agent is already available." +msgstr "" + msgid "Workspaces|This agent is already blocked." msgstr "" -- GitLab From c4fb49dfd56609f20be43cfa736ab5d7e2aece01 Mon Sep 17 00:00:00 2001 From: Cindy Halim Date: Wed, 4 Jun 2025 15:12:33 +0900 Subject: [PATCH 4/7] Apply Duo suggestions --- .../get_organization_workspaces_cluster_agents_query.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue b/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue index b0387deb14eba3..bf0f9f9e67d107 100644 --- a/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue +++ b/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue @@ -25,10 +25,10 @@ export default { }; }, watch: { - // why: this ensures we don't have stale agents data when the mapped agents data - // in the cache is updated after a successful mutation. + // why: This ensures that when mappedAgents is updated in the Apollo cache after a successful mutation, + // we update the UI with the latest agent availability status without requiring a full query refetch. mappedAgents(newMappedAgents) { - // this handles the case when agents query is not yet executed + // This handles the case when agents query is not yet executed if (!this.agents) return; const updatedAgents = this.agents.map((agent) => ({ -- GitLab From 820db65dfbe09a06d8702cf8029edcbd5b5e8e2a Mon Sep 17 00:00:00 2001 From: Cindy Halim Date: Thu, 5 Jun 2025 15:11:54 +0900 Subject: [PATCH 5/7] Update copy and clean up code --- .../components/availability_popover.vue | 16 ++++++++-------- .../components/availability_toggle.vue | 2 +- ...anization_workspaces_cluster_agents_query.vue | 2 -- .../workspaces/admin_settings/pages/app.vue | 7 ------- locale/gitlab.pot | 6 ------ 5 files changed, 9 insertions(+), 24 deletions(-) diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_popover.vue b/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_popover.vue index 572d4a56a1c129..2e32c6aefe3200 100644 --- a/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_popover.vue +++ b/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_popover.vue @@ -8,20 +8,20 @@ export default { GlIcon, GlPopover, }, - AGENT_CONFIG_NOTE_POPOVER_CONTENT: s__( - "Workspaces|In order to make an agent available/blocked, workspaces must be enabled in the agent's configuration.", - ), - BLOCKING_AGENT_POPOVER_CONTENT: s__( - "Workspaces|Blocking an agent doesn't delete it. Agents can only be deleted in the project where they were created. In addition, existing workspaces using a blocked agent will continue to run.", - ), + computed: { + popoverText() { + return s__( + "Workspaces|Blocking an agent doesn't delete it. Agents can only be deleted in the project where they were created. In addition, existing workspaces using a blocked agent will continue to run.", + ); + }, + }, }; diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue b/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue index bacf8964989ebd..6f42b9c05adc64 100644 --- a/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue +++ b/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue @@ -7,7 +7,7 @@ import createClusterAgentMappingMutation from '../graphql/mutations/create_org_c import deleteClusterAgentMappingMutation from '../graphql/mutations/delete_org_cluster_agent_mapping.mutation.graphql'; import mappedOrganizationClusterAgentsQuery from '../graphql/queries/organization_mapped_agents.query.graphql'; -const GENERIC_ERROR_MESSAGE = __('Something went wrong. Refresh the page and try again.'); +const GENERIC_ERROR_MESSAGE = __('Something went wrong. Please try again.'); const extractErrorFromMutationResult = (data, mutation) => { switch (mutation) { diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue b/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue index bf0f9f9e67d107..fb3f6a8376ea20 100644 --- a/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue +++ b/ee/app/assets/javascripts/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue @@ -50,7 +50,6 @@ export default { return !this.organizationId; }, error(error) { - this.$emit('error', { error }); logError(error); this.error = error; }, @@ -74,7 +73,6 @@ export default { return !this.mappedAgentsLoaded; }, error(error) { - this.$emit('error', { error }); logError(error); this.error = error; }, diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/pages/app.vue b/ee/app/assets/javascripts/workspaces/admin_settings/pages/app.vue index d182bac0be7e49..18fee8dbdf3d08 100644 --- a/ee/app/assets/javascripts/workspaces/admin_settings/pages/app.vue +++ b/ee/app/assets/javascripts/workspaces/admin_settings/pages/app.vue @@ -46,13 +46,6 @@ export default { variant: isConnected ? 'success' : 'neutral', }; }, - getStatusBadgeMetadata(item) { - const { isConnected } = item; - return { - text: isConnected ? s__('Workspaces|Connected') : s__('Workspaces|Not connected'), - variant: isConnected ? 'success' : 'neutral', - }; - }, }, fields: [ { diff --git a/locale/gitlab.pot b/locale/gitlab.pot index be7298b4459b99..f63a886604a6bd 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -59277,9 +59277,6 @@ msgstr "" msgid "Something went wrong. Please try again." msgstr "" -msgid "Something went wrong. Refresh the page and try again." -msgstr "" - msgid "Something went wrong. Unable to create identity verification exemption." msgstr "" @@ -70757,9 +70754,6 @@ msgstr "" msgid "Workspaces|If your devfile is not in the root directory of your project, specify a relative path." msgstr "" -msgid "Workspaces|In order to make an agent available/blocked, workspaces must be enabled in the agent's configuration." -msgstr "" - msgid "Workspaces|Instant development environments" msgstr "" -- GitLab From 001eb84d98dcfdd18f2494dc0620f1578edefe59 Mon Sep 17 00:00:00 2001 From: Cindy Halim Date: Mon, 9 Jun 2025 14:20:18 +0900 Subject: [PATCH 6/7] Apply MR suggestions --- .../components/availability_toggle.vue | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue b/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue index 6f42b9c05adc64..7f5c1a1a82dfe1 100644 --- a/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue +++ b/ee/app/assets/javascripts/workspaces/admin_settings/components/availability_toggle.vue @@ -7,8 +7,6 @@ import createClusterAgentMappingMutation from '../graphql/mutations/create_org_c import deleteClusterAgentMappingMutation from '../graphql/mutations/delete_org_cluster_agent_mapping.mutation.graphql'; import mappedOrganizationClusterAgentsQuery from '../graphql/queries/organization_mapped_agents.query.graphql'; -const GENERIC_ERROR_MESSAGE = __('Something went wrong. Please try again.'); - const extractErrorFromMutationResult = (data, mutation) => { switch (mutation) { case deleteClusterAgentMappingMutation: @@ -27,7 +25,7 @@ const getErrorMessage = (mutation) => { case createClusterAgentMappingMutation: return s__('Workspaces|This agent is already available.'); default: - return GENERIC_ERROR_MESSAGE; + return __('Something went wrong. Please try again.'); } }; @@ -64,8 +62,29 @@ export default { }, }, methods: { + updateMappedAgentsStore(store, agentId, isAgentMapped) { + store.updateQuery( + { + query: mappedOrganizationClusterAgentsQuery, + variables: { organizationId: this.organizationId }, + }, + (sourceData) => + produce(sourceData, (draftData) => { + const { mappedAgents } = draftData.organization; + + if (!isAgentMapped) { + mappedAgents.nodes.push({ id: agentId }); + } else { + const updatedMappedAgents = mappedAgents.nodes.filter( + (agent) => agent.id !== agentId, + ); + mappedAgents.nodes = updatedMappedAgents; + } + }), + ); + }, async toggleAvailability() { - const { organizationId, agentId } = this; + const { organizationId, agentId, updateMappedAgentsStore } = this; const isAgentMapped = this.mapped; const mutation = isAgentMapped @@ -95,25 +114,7 @@ export default { return; } - store.updateQuery( - { - query: mappedOrganizationClusterAgentsQuery, - variables: { organizationId }, - }, - (sourceData) => - produce(sourceData, (draftData) => { - const { mappedAgents } = draftData.organization; - - if (!isAgentMapped) { - mappedAgents.nodes.push({ id: agentId }); - } else { - const updatedMappedAgents = mappedAgents.nodes.filter( - (agent) => agent.id !== agentId, - ); - mappedAgents.nodes = updatedMappedAgents; - } - }), - ); + updateMappedAgentsStore(store, agentId, isAgentMapped); }, }); @@ -121,13 +122,12 @@ export default { this.errorMessage = getErrorMessage(mutation); } } catch (e) { - this.errorMessage = this.$options.GENERIC_ERROR_MESSAGE; + this.errorMessage = __('Something went wrong. Please try again.'); } finally { this.loading = false; } }, }, - GENERIC_ERROR_MESSAGE, }; { apolloProvider, propsData: { agentId: MOCK_AGENT_ID, - mapped: true, + isMapped: true, ...propsData, }, provide: { @@ -91,7 +91,7 @@ describe('workspaces/admin_settings/components/availability_toggle.vue', () => { beforeEach(() => { setupApolloProvider(); buildWrapper({ - mapped: true, + isMapped: true, }); }); @@ -120,7 +120,7 @@ describe('workspaces/admin_settings/components/availability_toggle.vue', () => { buildWrapper({ agentId: MOCK_MAPPED_AGENT_ID, - mapped: true, + isMapped: true, }); expect(getAgentFromMappedAgentsStore(MOCK_MAPPED_AGENT_ID)).toHaveLength(1); @@ -138,7 +138,7 @@ describe('workspaces/admin_settings/components/availability_toggle.vue', () => { beforeEach(() => { setupApolloProvider(); buildWrapper({ - mapped: false, + isMapped: false, }); }); @@ -167,7 +167,7 @@ describe('workspaces/admin_settings/components/availability_toggle.vue', () => { buildWrapper({ agentId: MOCK_UNMAPPED_AGENT_ID, - mapped: false, + isMapped: false, }); expect(getAgentFromMappedAgentsStore(MOCK_UNMAPPED_AGENT_ID)).toHaveLength(0); @@ -197,7 +197,7 @@ describe('workspaces/admin_settings/components/availability_toggle.vue', () => { 'displays correct error message when mutation fails when agent is mapped: $isMapped', async ({ isMapped, expectedErrorMessage }) => { buildWrapper({ - mapped: isMapped, + isMapped, }); findToggle().vm.$emit('change'); await waitForPromises(); diff --git a/ee/spec/frontend/workspaces/admin_settings/pages/app_spec.js b/ee/spec/frontend/workspaces/admin_settings/pages/app_spec.js index 71b0446d652975..3d970cf9e9a097 100644 --- a/ee/spec/frontend/workspaces/admin_settings/pages/app_spec.js +++ b/ee/spec/frontend/workspaces/admin_settings/pages/app_spec.js @@ -11,6 +11,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import WorkspacesAgentAvailabilityApp from 'ee_component/workspaces/admin_settings/pages/app.vue'; import AvailabilityPopover from 'ee_component/workspaces/admin_settings/components/availability_popover.vue'; import GetOrganizationWorkspacesClusterAgentsQuery from 'ee_component/workspaces/admin_settings/components/get_organization_workspaces_cluster_agents_query.vue'; +import AvailabilityToggle from 'ee_component/workspaces/admin_settings/components/availability_toggle.vue'; import { stubComponent } from 'helpers/stub_component'; const MOCK_ORG_ID = 'gid://gitlab/Organizations::Organization/1'; @@ -18,13 +19,14 @@ const MOCK_ORG_ID = 'gid://gitlab/Organizations::Organization/1'; const createMockAgents = (customAgent = {}) => { return [ { - availability: 'available', - group: 'Gitlab Org', - isConnected: false, + id: 'gid://gitlab/Clusters::Agent/14', name: 'midnightowlgarden', - project: 'gitlab-agent-configurations', url: 'http://test.host/gitlab-org/gitlab-agent-configurations/-/cluster_agents/midnightowlgarden', + project: 'gitlab-agent-configurations', + group: 'Gitlab Org', workspacesEnabled: true, + isConnected: false, + isMapped: true, ...customAgent, }, ]; @@ -73,6 +75,7 @@ describe('workspaces/admin_settings/pages/app.vue', () => { const findAlert = () => wrapper.findComponent(GlAlert); const findPagination = () => wrapper.findComponent(GlKeysetPagination); const findLoadingState = () => wrapper.findComponent(GlSkeletonLoader); + const findAvailabilityToggle = () => wrapper.findComponent(AvailabilityToggle); describe('default', () => { beforeEach(async () => { @@ -150,6 +153,12 @@ describe('workspaces/admin_settings/pages/app.vue', () => { expect(nameElement.exists()).toBe(true); expect(nameElement.attributes('href')).toBe(mockResult[0].url); }); + + it('renders agent availability toggle', () => { + expect(findAvailabilityToggle().exists()).toBe(true); + expect(findAvailabilityToggle().props('agentId')).toBe(mockResult[0].id); + expect(findAvailabilityToggle().props('isMapped')).toBe(mockResult[0].isMapped); + }); }); }); -- GitLab