Commit 46d198b8 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '323055-add-the-ability-to-delete-an-agent-from-the-ui' into 'master'

Add the ability to Delete an Agent from the actions dropdown

See merge request gitlab-org/gitlab!77199
parents ca090441 cfba3a08
<script>
import {
GlDropdown,
GlDropdownItem,
GlModal,
GlModalDirective,
GlSprintf,
GlFormGroup,
GlFormInput,
} from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import { DELETE_AGENT_MODAL_ID } from '../constants';
import deleteAgent from '../graphql/mutations/delete_agent.mutation.graphql';
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
import { removeAgentFromStore } from '../graphql/cache_update';
export default {
i18n: {
dropdownText: __('More options'),
deleteButton: s__('ClusterAgents|Delete agent'),
modalTitle: __('Are you sure?'),
modalBody: s__(
'ClusterAgents|Are you sure you want to delete this agent? You cannot undo this.',
),
modalInputLabel: s__('ClusterAgents|To delete the agent, type %{name} to confirm:'),
modalAction: s__('ClusterAgents|Delete'),
modalCancel: __('Cancel'),
successMessage: s__('ClusterAgents|%{name} successfully deleted'),
defaultError: __('An error occurred. Please try again.'),
},
components: {
GlDropdown,
GlDropdownItem,
GlModal,
GlSprintf,
GlFormGroup,
GlFormInput,
},
directives: {
GlModalDirective,
},
inject: ['projectPath'],
props: {
agent: {
required: true,
type: Object,
validator: (value) => ['id', 'name'].every((prop) => value[prop]),
},
defaultBranchName: {
default: '.noBranch',
required: false,
type: String,
},
maxAgents: {
default: null,
required: false,
type: Number,
},
},
data() {
return {
loading: false,
error: null,
deleteConfirmText: null,
agentName: this.agent.name,
};
},
computed: {
getAgentsQueryVariables() {
return {
defaultBranchName: this.defaultBranchName,
first: this.maxAgents,
last: null,
projectPath: this.projectPath,
};
},
modalId() {
return sprintf(DELETE_AGENT_MODAL_ID, {
agentName: this.agent.name,
});
},
primaryModalProps() {
return {
text: this.$options.i18n.modalAction,
attributes: [
{ disabled: this.loading || this.disableModalSubmit, loading: this.loading },
{ variant: 'danger' },
],
};
},
cancelModalProps() {
return {
text: this.$options.i18n.modalCancel,
attributes: [],
};
},
disableModalSubmit() {
return this.deleteConfirmText !== this.agent.name;
},
},
methods: {
async deleteAgent() {
if (this.disableModalSubmit || this.loading) {
return;
}
this.loading = true;
this.error = null;
try {
const { errors } = await this.deleteAgentMutation();
if (errors.length) {
throw new Error(errors[0]);
}
} catch (error) {
this.error = error?.message || this.$options.i18n.defaultError;
} finally {
this.loading = false;
const successMessage = sprintf(this.$options.i18n.successMessage, { name: this.agentName });
this.$toast.show(this.error || successMessage);
this.$refs.modal.hide();
}
},
deleteAgentMutation() {
return this.$apollo
.mutate({
mutation: deleteAgent,
variables: {
input: {
id: this.agent.id,
},
},
update: (store) => {
const deleteClusterAgent = this.agent;
removeAgentFromStore(
store,
deleteClusterAgent,
getAgentsQuery,
this.getAgentsQueryVariables,
);
},
})
.then(({ data: { clusterAgentDelete } }) => {
return clusterAgentDelete;
});
},
hideModal() {
this.loading = false;
this.error = null;
this.deleteConfirmText = null;
},
},
};
</script>
<template>
<div>
<gl-dropdown
icon="ellipsis_v"
right
:disabled="loading"
:text="$options.i18n.dropdownText"
text-sr-only
category="tertiary"
no-caret
>
<gl-dropdown-item v-gl-modal-directive="modalId">
{{ $options.i18n.deleteButton }}
</gl-dropdown-item>
</gl-dropdown>
<gl-modal
ref="modal"
:modal-id="modalId"
:title="$options.i18n.modalTitle"
:action-primary="primaryModalProps"
:action-cancel="cancelModalProps"
size="sm"
@primary="deleteAgent"
@hide="hideModal"
>
<p>{{ $options.i18n.modalBody }}</p>
<gl-form-group>
<template #label>
<gl-sprintf :message="$options.i18n.modalInputLabel">
<template #name>
<code>{{ agent.name }}</code>
</template>
</gl-sprintf>
</template>
<gl-form-input v-model="deleteConfirmText" @keydown.enter="deleteAgent" />
</gl-form-group>
</gl-modal>
</div>
</template>
<script>
import {
GlLink,
GlModalDirective,
GlTable,
GlIcon,
GlSprintf,
GlTooltip,
GlPopover,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { GlLink, GlTable, GlIcon, GlSprintf, GlTooltip, GlPopover } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { helpPagePath } from '~/helpers/help_page_helper';
import { INSTALL_AGENT_MODAL_ID, AGENT_STATUSES } from '../constants';
import { AGENT_STATUSES } from '../constants';
import { getAgentConfigPath } from '../clusters_util';
import AgentOptions from './agent_options.vue';
export default {
i18n: {
nameLabel: s__('ClusterAgents|Name'),
statusLabel: s__('ClusterAgents|Connection status'),
lastContactLabel: s__('ClusterAgents|Last contact'),
configurationLabel: s__('ClusterAgents|Configuration'),
optionsLabel: __('Options'),
troubleshootingText: s__('ClusterAgents|Learn how to troubleshoot'),
neverConnectedText: s__('ClusterAgents|Never'),
},
components: {
GlLink,
GlTable,
......@@ -24,14 +26,10 @@ export default {
GlTooltip,
GlPopover,
TimeAgoTooltip,
},
directives: {
GlModalDirective,
AgentOptions,
},
mixins: [timeagoMixin],
INSTALL_AGENT_MODAL_ID,
AGENT_STATUSES,
troubleshooting_link: helpPagePath('user/clusters/agent/index', {
anchor: 'troubleshooting',
}),
......@@ -40,6 +38,16 @@ export default {
required: true,
type: Array,
},
defaultBranchName: {
default: '.noBranch',
required: false,
type: String,
},
maxAgents: {
default: null,
required: false,
type: Number,
},
},
computed: {
fields() {
......@@ -47,22 +55,27 @@ export default {
return [
{
key: 'name',
label: s__('ClusterAgents|Name'),
label: this.$options.i18n.nameLabel,
tdClass,
},
{
key: 'status',
label: s__('ClusterAgents|Connection status'),
label: this.$options.i18n.statusLabel,
tdClass,
},
{
key: 'lastContact',
label: s__('ClusterAgents|Last contact'),
label: this.$options.i18n.lastContactLabel,
tdClass,
},
{
key: 'configuration',
label: s__('ClusterAgents|Configuration'),
label: this.$options.i18n.configurationLabel,
tdClass,
},
{
key: 'options',
label: this.$options.i18n.optionsLabel,
tdClass,
},
];
......@@ -118,7 +131,7 @@ export default {
</p>
<p class="gl-mb-0">
<gl-link :href="$options.troubleshooting_link" target="_blank" class="gl-font-sm">
{{ s__('ClusterAgents|Learn how to troubleshoot') }}</gl-link
{{ $options.i18n.troubleshootingText }}</gl-link
>
</p>
</gl-popover>
......@@ -127,7 +140,7 @@ export default {
<template #cell(lastContact)="{ item }">
<span data-testid="cluster-agent-last-contact">
<time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" />
<span v-else>{{ s__('ClusterAgents|Never') }}</span>
<span v-else>{{ $options.i18n.neverConnectedText }}</span>
</span>
</template>
......@@ -140,5 +153,13 @@ export default {
<span v-else>{{ getAgentConfigPath(item.name) }}</span>
</span>
</template>
<template #cell(options)="{ item }">
<agent-options
:agent="item"
:default-branch-name="defaultBranchName"
:max-agents="maxAgents"
/>
</template>
</gl-table>
</template>
......@@ -151,7 +151,11 @@ export default {
<section v-else-if="agentList">
<div v-if="agentList.length">
<agent-table :agents="agentList" />
<agent-table
:agents="agentList"
:default-branch-name="defaultBranchName"
:max-agents="cursor.first"
/>
<div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-keyset-pagination v-bind="agentPageInfo" @prev="prevPage" @next="nextPage" />
......
......@@ -242,3 +242,5 @@ export const EVENT_ACTIONS_CHANGE = 'change_tab';
export const MODAL_TYPE_EMPTY = 'empty_state';
export const MODAL_TYPE_REGISTER = 'agent_registration';
export const DELETE_AGENT_MODAL_ID = 'delete-agent-modal-%{agentName}';
......@@ -63,3 +63,25 @@ export function addAgentConfigToStore(
});
}
}
export function removeAgentFromStore(store, deleteClusterAgent, query, variables) {
if (!hasErrors(deleteClusterAgent)) {
const sourceData = store.readQuery({
query,
variables,
});
const data = produce(sourceData, (draftData) => {
draftData.project.clusterAgents.nodes = draftData.project.clusterAgents.nodes.filter(
({ id }) => id !== deleteClusterAgent.id,
);
draftData.project.clusterAgents.count -= 1;
});
store.writeQuery({
query,
variables,
data,
});
}
}
mutation deleteClusterAgent($input: ClusterAgentDeleteInput!) {
clusterAgentDelete(input: $input) {
errors
}
}
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import loadClusters from './load_clusters';
import loadMainView from './load_main_view';
Vue.use(GlToast);
Vue.use(VueApollo);
export default () => {
......
......@@ -136,7 +136,22 @@ with the following differences:
## Remove an agent
1. Get the `<cluster-agent-id>` and the `<cluster-agent-token-id>` from a query in the interactive GraphQL explorer.
You can remove an agent using the [GitLab UI](#remove-an-agent-through-the-gitlab-ui) or through the [GraphQL API](#remove-an-agent-with-the-gitlab-graphql-api).
### Remove an agent through the GitLab UI
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/323055) in GitLab 14.7.
To remove an agent from the UI:
1. Go to your agent's configuration repository.
1. From your project's sidebar, select **Infrastructure > Kubernetes clusters**.
1. Select your agent from the table, and then in the **Options** column, click the vertical ellipsis
(**{ellipsis_v}**) button and select **Delete agent**.
### Remove an agent with the GitLab GraphQL API
1. Get the `<cluster-agent-token-id>` from a query in the interactive GraphQL explorer.
For GitLab.com, go to <https://gitlab.com/-/graphql-explorer> to open GraphQL Explorer.
For self-managed GitLab instances, go to `https://gitlab.example.com/-/graphql-explorer`, replacing `gitlab.example.com` with your own instance's URL.
......@@ -157,7 +172,7 @@ For self-managed GitLab instances, go to `https://gitlab.example.com/-/graphql-e
}
```
1. Remove an Agent record with GraphQL by deleting the `clusterAgent` and the `clusterAgentToken`.
1. Remove an agent record with GraphQL by deleting the `clusterAgentToken`.
```graphql
mutation deleteAgent {
......
......@@ -7503,6 +7503,9 @@ msgstr ""
msgid "Cluster type must be specificed for Stages::ClusterEndpointInserter"
msgstr ""
msgid "ClusterAgents|%{name} successfully deleted"
msgstr ""
msgid "ClusterAgents|%{number} of %{total} agents"
msgstr ""
......@@ -7560,6 +7563,9 @@ msgstr ""
msgid "ClusterAgents|An unknown error occurred. Please try again."
msgstr ""
msgid "ClusterAgents|Are you sure you want to delete this agent? You cannot undo this."
msgstr ""
msgid "ClusterAgents|Certificate"
msgstr ""
......@@ -7605,6 +7611,12 @@ msgstr ""
msgid "ClusterAgents|Date created"
msgstr ""
msgid "ClusterAgents|Delete"
msgstr ""
msgid "ClusterAgents|Delete agent"
msgstr ""
msgid "ClusterAgents|Deprecated"
msgstr ""
......@@ -7718,6 +7730,9 @@ msgstr[1] ""
msgid "ClusterAgents|This agent has no tokens"
msgstr ""
msgid "ClusterAgents|To delete the agent, type %{name} to confirm:"
msgstr ""
msgid "ClusterAgents|To install a new agent, first add the agent's configuration file to this repository. %{linkStart}What's the agent's configuration file?%{linkEnd}"
msgstr ""
......@@ -23038,6 +23053,9 @@ msgstr ""
msgid "More information."
msgstr ""
msgid "More options"
msgstr ""
msgid "More than %{number_commits_distance} commits different with %{default_branch}"
msgstr ""
......
import { GlDropdown, GlDropdownItem, GlModal, GlFormInput } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { ENTER_KEY } from '~/lib/utils/keys';
import getAgentsQuery from '~/clusters_list/graphql/queries/get_agents.query.graphql';
import deleteAgentMutation from '~/clusters_list/graphql/mutations/delete_agent.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import AgentOptions from '~/clusters_list/components/agent_options.vue';
import { MAX_LIST_COUNT } from '~/clusters_list/constants';
import { getAgentResponse, mockDeleteResponse, mockErrorDeleteResponse } from '../mocks/apollo';
Vue.use(VueApollo);
const projectPath = 'path/to/project';
const defaultBranchName = 'default';
const maxAgents = MAX_LIST_COUNT;
const agent = {
id: 'agent-id',
name: 'agent-name',
webPath: 'agent-webPath',
};
describe('AgentOptions', () => {
let wrapper;
let toast;
let apolloProvider;
let deleteResponse;
const findModal = () => wrapper.findComponent(GlModal);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDeleteBtn = () => wrapper.findComponent(GlDropdownItem);
const findInput = () => wrapper.findComponent(GlFormInput);
const findPrimaryAction = () => findModal().props('actionPrimary');
const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[0][attr];
const createMockApolloProvider = ({ mutationResponse }) => {
deleteResponse = jest.fn().mockResolvedValue(mutationResponse);
return createMockApollo([[deleteAgentMutation, deleteResponse]]);
};
const writeQuery = () => {
apolloProvider.clients.defaultClient.cache.writeQuery({
query: getAgentsQuery,
variables: {
projectPath,
defaultBranchName,
first: maxAgents,
last: null,
},
data: getAgentResponse.data,
});
};
const createWrapper = ({ mutationResponse = mockDeleteResponse } = {}) => {
apolloProvider = createMockApolloProvider({ mutationResponse });
const provide = {
projectPath,
};
const propsData = {
defaultBranchName,
maxAgents,
agent,
};
toast = jest.fn();
wrapper = shallowMountExtended(AgentOptions, {
apolloProvider,
provide,
propsData,
mocks: { $toast: { show: toast } },
stubs: { GlModal },
});
wrapper.vm.$refs.modal.hide = jest.fn();
writeQuery();
return wrapper.vm.$nextTick();
};
const submitAgentToDelete = async () => {
findDeleteBtn().vm.$emit('click');
findInput().vm.$emit('input', agent.name);
await findModal().vm.$emit('primary');
};
beforeEach(() => {
return createWrapper({});
});
afterEach(() => {
wrapper.destroy();
apolloProvider = null;
deleteResponse = null;
toast = null;
});
describe('delete agent action', () => {
it('displays a delete button', () => {
expect(findDeleteBtn().text()).toBe('Delete agent');
});
describe('when clicking the delete button', () => {
beforeEach(() => {
findDeleteBtn().vm.$emit('click');
});
it('displays a delete confirmation modal', () => {
expect(findModal().isVisible()).toBe(true);
});
});
describe.each`
condition | agentName | isDisabled | mutationCalled
${'the input with agent name is missing'} | ${''} | ${true} | ${false}
${'the input with agent name is incorrect'} | ${'wrong-name'} | ${true} | ${false}
${'the input with agent name is correct'} | ${agent.name} | ${false} | ${true}
`('when $condition', ({ agentName, isDisabled, mutationCalled }) => {
beforeEach(() => {
findDeleteBtn().vm.$emit('click');
findInput().vm.$emit('input', agentName);
});
it(`${isDisabled ? 'disables' : 'enables'} the modal primary button`, () => {
expect(findPrimaryActionAttributes('disabled')).toBe(isDisabled);
});
describe('when user clicks the modal primary button', () => {
beforeEach(async () => {
await findModal().vm.$emit('primary');
});
if (mutationCalled) {
it('calls the delete mutation', () => {
expect(deleteResponse).toHaveBeenCalledWith({ input: { id: agent.id } });
});
} else {
it("doesn't call the delete mutation", () => {
expect(deleteResponse).not.toHaveBeenCalled();
});
}
});
describe('when user presses the enter button', () => {
beforeEach(async () => {
await findInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
});
if (mutationCalled) {
it('calls the delete mutation', () => {
expect(deleteResponse).toHaveBeenCalledWith({ input: { id: agent.id } });
});
} else {
it("doesn't call the delete mutation", () => {
expect(deleteResponse).not.toHaveBeenCalled();
});
}
});
});
describe('when agent was deleted successfully', () => {
beforeEach(async () => {
await submitAgentToDelete();
});
it('calls the toast action', () => {
expect(toast).toHaveBeenCalledWith(`${agent.name} successfully deleted`);
});
});
});
describe('when getting an error deleting agent', () => {
beforeEach(async () => {
await createWrapper({ mutationResponse: mockErrorDeleteResponse });
submitAgentToDelete();
});
it('displays the error message', () => {
expect(toast).toHaveBeenCalledWith('could not delete agent');
});
});
describe('when the delete modal was closed', () => {
beforeEach(async () => {
const loadingResponse = new Promise(() => {});
await createWrapper({ mutationResponse: loadingResponse });
submitAgentToDelete();
});
it('reenables the options dropdown', async () => {
expect(findPrimaryActionAttributes('loading')).toBe(true);
expect(findDropdown().attributes('disabled')).toBe('true');
await findModal().vm.$emit('hide');
expect(findPrimaryActionAttributes('loading')).toBe(false);
expect(findDropdown().attributes('disabled')).toBeUndefined();
});
it('clears the agent name input', async () => {
expect(findInput().attributes('value')).toBe(agent.name);
await findModal().vm.$emit('hide');
expect(findInput().attributes('value')).toBeUndefined();
});
});
});
import { GlLink, GlIcon } from '@gitlab/ui';
import AgentTable from '~/clusters_list/components/agent_table.vue';
import AgentOptions from '~/clusters_list/components/agent_options.vue';
import { ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import timeagoMixin from '~/vue_shared/mixins/timeago';
const connectedTimeNow = new Date();
const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIVE_CONNECTION_TIME);
const provideData = {
projectPath: 'path/to/project',
};
const propsData = {
agents: [
{
name: 'agent-1',
id: 'agent-1-id',
configFolder: {
webPath: '/agent/full/path',
},
......@@ -21,6 +27,7 @@ const propsData = {
},
{
name: 'agent-2',
id: 'agent-2-id',
webPath: '/agent-2',
status: 'active',
lastContact: connectedTimeNow.getTime(),
......@@ -34,6 +41,7 @@ const propsData = {
},
{
name: 'agent-3',
id: 'agent-3-id',
webPath: '/agent-3',
status: 'inactive',
lastContact: connectedTimeInactive.getTime(),
......@@ -48,6 +56,10 @@ const propsData = {
],
};
const AgentOptionsStub = stubComponent(AgentOptions, {
template: `<div></div>`,
});
describe('AgentTable', () => {
let wrapper;
......@@ -57,15 +69,21 @@ describe('AgentTable', () => {
const findLastContactText = (at) => wrapper.findAllByTestId('cluster-agent-last-contact').at(at);
const findConfiguration = (at) =>
wrapper.findAllByTestId('cluster-agent-configuration-link').at(at);
const findAgentOptions = () => wrapper.findAllComponents(AgentOptions);
beforeEach(() => {
wrapper = mountExtended(AgentTable, { propsData });
wrapper = mountExtended(AgentTable, {
propsData,
provide: provideData,
stubs: {
AgentOptions: AgentOptionsStub,
},
});
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
......@@ -108,5 +126,9 @@ describe('AgentTable', () => {
expect(findLink.exists()).toBe(hasLink);
expect(findConfiguration(lineNumber).text()).toBe(agentPath);
});
it('displays actions menu for each agent', () => {
expect(findAgentOptions()).toHaveLength(3);
});
});
});
......@@ -75,3 +75,15 @@ export const getAgentResponse = {
},
},
};
export const mockDeleteResponse = {
data: { clusterAgentDelete: { errors: [] } },
};
export const mockErrorDeleteResponse = {
data: {
clusterAgentDelete: {
errors: ['could not delete agent'],
},
},
};
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment