diff --git a/assets/js/components/HostsList.jsx b/assets/js/components/HostsList.jsx index 108fbcfa1a..95e6d47c2c 100644 --- a/assets/js/components/HostsList.jsx +++ b/assets/js/components/HostsList.jsx @@ -1,10 +1,11 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useSelector, useDispatch } from 'react-redux'; import { EOS_WARNING_OUTLINED } from 'eos-icons-react'; import Table from '@components/Table'; +import DeregistrationModal from '@components/DeregistrationModal'; import HealthIcon from '@components/Health/HealthIcon'; import Tags from '@components/Tags'; import HostLink from '@components/HostLink'; @@ -16,8 +17,10 @@ import HealthSummary from '@components/HealthSummary/HealthSummary'; import { getCounters } from '@components/HealthSummary/summarySelection'; import ProviderLabel from '@components/ProviderLabel'; import Tooltip from '@components/Tooltip'; +import CleanUpButton from '@components/CleanUpButton'; + +import { addTagToHost, removeTagFromHost, deregisterHost } from '@state/hosts'; -import { addTagToHost, removeTagFromHost } from '@state/hosts'; import { post, del } from '@lib/network'; import { agentVersionWarning } from '@lib/agent'; @@ -50,9 +53,21 @@ function HostsList() { ); const [searchParams, setSearchParams] = useSearchParams(); + const [cleanUpModalOpen, setCleanUpModalOpen] = useState(false); + const [hostToDeregister, setHostToDeregister] = useState(undefined); const dispatch = useDispatch(); + const openDeregistrationModal = (host) => { + setHostToDeregister(host); + setCleanUpModalOpen(true); + }; + + const cleanUpHost = (host) => { + setCleanUpModalOpen(false); + dispatch(deregisterHost(host)); + }; + const config = { pagination: true, usePadding: false, @@ -177,6 +192,20 @@ function HostsList() { /> ), }, + { + title: '', + key: 'deregisterable', + className: 'w-48', + render: (content, item) => + content && ( + { + openDeregistrationModal(item); + }} + /> + ), + }, ], }; @@ -199,6 +228,8 @@ function HostsList() { id: host.id, tags: (host.tags && host.tags.map((tag) => tag.value)) || [], sap_systems: sapSystemList, + deregisterable: host.deregisterable, + deregistering: host.deregistering, }; }); @@ -206,6 +237,16 @@ function HostsList() { return ( <> Hosts + { + cleanUpHost(hostToDeregister); + }} + onCancel={() => { + setCleanUpModalOpen(false); + }} + />
{ }); }); + describe('deregistration', () => { + it('should show the clean up button when the host is deregisterable', () => { + const host1 = hostFactory.build({ deregisterable: true }); + const host2 = hostFactory.build({ deregisterable: false }); + const state = { + ...defaultInitialState, + hostsList: { + hosts: [].concat(host1, host2), + }, + }; + + const [StatefulHostsList] = withState(, state); + + renderWithRouter(StatefulHostsList); + const table = screen.getByRole('table'); + const cleanUpCell1 = table.querySelector( + 'tr:nth-child(1) > td:nth-child(9)' + ); + const cleanUpCell2 = table.querySelector( + 'tr:nth-child(2) > td:nth-child(9)' + ); + expect(cleanUpCell1).toHaveTextContent('Clean up'); + expect(cleanUpCell2).not.toHaveTextContent('Clean up'); + }); + + it('should show the host in deregistering state', () => { + const host = hostFactory.build({ + deregisterable: true, + deregistering: true, + }); + const state = { + ...defaultInitialState, + hostsList: { + hosts: [host], + }, + }; + + const [StatefulHostsList] = withState(, state); + + renderWithRouter(StatefulHostsList); + expect(screen.getByLabelText('Loading')).toBeInTheDocument(); + }); + + it('should request a deregistration when the clean up button in the modal is clicked', async () => { + const user = userEvent.setup(); + + const host = hostFactory.build({ deregisterable: true }); + const state = { + ...defaultInitialState, + hostsList: { + hosts: [host], + }, + }; + + const [StatefulHostsList, store] = withState(, state); + + renderWithRouter(StatefulHostsList); + + const table = screen.getByRole('table'); + const cleanUpButton = table.querySelector( + 'tr:nth-child(1) > td:nth-child(9) > button' + ); + + await user.click(cleanUpButton); + + expect( + screen.getByText( + `Clean up data discovered by agent on host ${host.hostname}` + ) + ).toBeInTheDocument(); + + const cleanUpModalButton = screen.getAllByRole('button', { + name: 'Clean up', + })[1]; + + await user.click(cleanUpModalButton); + + const actions = store.getActions(); + const expectedActions = [ + { + type: 'DEREGISTER_HOST', + payload: expect.objectContaining({ + id: host.id, + hostname: host.hostname, + }), + }, + ]; + expect(actions).toEqual(expect.arrayContaining(expectedActions)); + }); + }); + describe('filtering', () => { const cleanInitialState = { hostsList: { diff --git a/assets/js/state/hosts.js b/assets/js/state/hosts.js index 4e62b285b6..d9ac4cb7c2 100644 --- a/assets/js/state/hosts.js +++ b/assets/js/state/hosts.js @@ -79,6 +79,22 @@ export const hostsListSlice = createSlice({ return host; }); }, + setHostDeregistering: (state, action) => { + state.hosts = state.hosts.map((host) => { + if (host.id === action.payload.id) { + return { ...host, deregistering: true }; + } + return host; + }); + }, + setHostNotDeregistering: (state, action) => { + state.hosts = state.hosts.map((host) => { + if (host.id === action.payload.id) { + return { ...host, deregistering: false }; + } + return host; + }); + }, startHostsLoading: (state) => { state.loading = true; }, @@ -95,6 +111,7 @@ export const CHECK_HOST_IS_DEREGISTERABLE = 'CHECK_HOST_IS_DEREGISTERABLE'; export const CANCEL_CHECK_HOST_IS_DEREGISTERABLE = 'CANCEL_CHECK_HOST_IS_DEREGISTERABLE'; export const HOST_DEREGISTERED = 'HOST_DEREGISTERED'; +export const DEREGISTER_HOST = 'DEREGISTER_HOST'; export const checkHostIsDeregisterable = createAction( CHECK_HOST_IS_DEREGISTERABLE @@ -102,6 +119,7 @@ export const checkHostIsDeregisterable = createAction( export const cancelCheckHostIsDeregisterable = createAction( CANCEL_CHECK_HOST_IS_DEREGISTERABLE ); +export const deregisterHost = createAction(DEREGISTER_HOST); export const { setHosts, @@ -113,6 +131,8 @@ export const { setHeartbeatCritical, setHostListDeregisterable, setHostNotDeregisterable, + setHostDeregistering, + setHostNotDeregistering, startHostsLoading, stopHostsLoading, removeHost, diff --git a/assets/js/state/hosts.test.js b/assets/js/state/hosts.test.js index f294b0d664..fd3c0bb66b 100644 --- a/assets/js/state/hosts.test.js +++ b/assets/js/state/hosts.test.js @@ -2,6 +2,8 @@ import hostsReducer, { removeHost, setHostListDeregisterable, setHostNotDeregisterable, + setHostDeregistering, + setHostNotDeregistering, } from '@state/hosts'; import { hostFactory } from '@lib/test-utils/factories'; @@ -39,6 +41,34 @@ describe('Hosts reducer', () => { expect(hostsReducer(initialState, action)).toEqual(expectedState); }); + it('should set host in deregistering state', () => { + const [host1, host2] = hostFactory.buildList(2); + const initialState = { hosts: [host1, host2] }; + + const action = setHostDeregistering(host1); + + const expectedState = { + hosts: [{ ...host1, deregistering: true }, host2], + }; + + expect(hostsReducer(initialState, action)).toEqual(expectedState); + }); + + it('should remove deregistering state from host', () => { + const [host1, host2] = hostFactory.buildList(2); + const initialState = { + hosts: [host1, host2], + }; + + const action = setHostNotDeregistering(host1); + + const expectedState = { + hosts: [{ ...host1, deregistering: false }, host2], + }; + + expect(hostsReducer(initialState, action)).toEqual(expectedState); + }); + it('should remove host from state', () => { const [host1, host2] = hostFactory.buildList(2); const initialState = { diff --git a/assets/js/state/sagas/hosts.js b/assets/js/state/sagas/hosts.js index 9b9fa87e3c..fcd9eb4be2 100644 --- a/assets/js/state/sagas/hosts.js +++ b/assets/js/state/sagas/hosts.js @@ -3,9 +3,14 @@ import { CHECK_HOST_IS_DEREGISTERABLE, CANCEL_CHECK_HOST_IS_DEREGISTERABLE, HOST_DEREGISTERED, + DEREGISTER_HOST, removeHost, setHostListDeregisterable, + setHostDeregistering, + setHostNotDeregistering, } from '@state/hosts'; + +import { del } from '@lib/network'; import { notify } from '@state/actions/notifications'; export function* markDeregisterableHosts(hosts) { @@ -43,6 +48,22 @@ export function* hostDeregistered({ payload }) { ); } +export function* deregisterHost({ payload }) { + yield put(setHostDeregistering(payload)); + try { + yield call(del, `/hosts/${payload.id}`); + } catch (error) { + yield put( + notify({ + text: `Error deregistering host ${payload?.hostname}.`, + icon: '❌', + }) + ); + } finally { + yield put(setHostNotDeregistering(payload)); + } +} + export function* watchHostDeregisterable() { yield takeEvery(CHECK_HOST_IS_DEREGISTERABLE, checkHostDeregisterable); } @@ -50,3 +71,7 @@ export function* watchHostDeregisterable() { export function* watchHostDeregistered() { yield takeEvery(HOST_DEREGISTERED, hostDeregistered); } + +export function* watchDeregisterHost() { + yield takeEvery(DEREGISTER_HOST, deregisterHost); +} diff --git a/assets/js/state/sagas/hosts.test.js b/assets/js/state/sagas/hosts.test.js index 2809e34000..c3fab6e42a 100644 --- a/assets/js/state/sagas/hosts.test.js +++ b/assets/js/state/sagas/hosts.test.js @@ -1,18 +1,39 @@ +import MockAdapter from 'axios-mock-adapter'; + import { recordSaga } from '@lib/test-utils'; import { markDeregisterableHosts, matchHost, checkHostDeregisterable, hostDeregistered, + deregisterHost, } from '@state/sagas/hosts'; + import { cancelCheckHostIsDeregisterable, setHostListDeregisterable, removeHost, + setHostDeregistering, + setHostNotDeregistering, } from '@state/hosts'; + +import { networkClient } from '@lib/network'; +import { notify } from '@state/actions/notifications'; import { hostFactory } from '@lib/test-utils/factories'; +const axiosMock = new MockAdapter(networkClient); + describe('Hosts sagas', () => { + beforeEach(() => { + axiosMock.reset(); + jest.spyOn(console, 'error').mockImplementation(() => null); + }); + + afterEach(() => { + /* eslint-disable-next-line */ + console.error.mockRestore(); + }); + it('should mark hosts as deregisterable', async () => { const passingHost = hostFactory.build({ heartbeat: 'passing' }); const criticalHost = hostFactory.build({ heartbeat: 'critical' }); @@ -59,4 +80,38 @@ describe('Hosts sagas', () => { expect(dispatched).toContainEqual(removeHost(payload)); }); + + it('should send host deregister request', async () => { + const host = hostFactory.build(); + + axiosMock.onDelete(`/hosts/${host.id}`).reply(204, {}); + + const dispatched = await recordSaga(deregisterHost, { + payload: host, + }); + + expect(dispatched).toEqual([ + setHostDeregistering(host), + setHostNotDeregistering(host), + ]); + }); + + it('should notify error on host deregistration request', async () => { + const host = hostFactory.build(); + + axiosMock.onDelete(`/hosts/${host.id}`).reply(404, {}); + + const dispatched = await recordSaga(deregisterHost, { + payload: host, + }); + + expect(dispatched).toEqual([ + setHostDeregistering(host), + notify({ + text: `Error deregistering host ${host.hostname}.`, + icon: '❌', + }), + setHostNotDeregistering(host), + ]); + }); }); diff --git a/assets/js/state/sagas/index.js b/assets/js/state/sagas/index.js index b23fae1649..df723c7e1d 100644 --- a/assets/js/state/sagas/index.js +++ b/assets/js/state/sagas/index.js @@ -70,6 +70,7 @@ import { markDeregisterableHosts, watchHostDeregistered, watchHostDeregisterable, + watchDeregisterHost, } from '@state/sagas/hosts'; import { watchClusterDeregistered } from '@state/sagas/clusters'; @@ -428,5 +429,6 @@ export default function* rootSaga() { refreshHealthSummaryOnComnponentsHealthChange(), watchPerformLogin(), watchHostDeregisterable(), + watchDeregisterHost(), ]); }