diff --git a/CHANGELOG.md b/CHANGELOG.md index d2eb2371c9..f3fe5a64ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to the Wazuh app project will be documented in this file. - Updated and added operating systems, versions, architectures commands of Install and enroll the agent and commands of Start the agent in the deploy new agent section [#4458](https://github.com/wazuh/wazuh-kibana-app/pull/4458) - Makes Agents Overview loading icons independent [#4363](https://github.com/wazuh/wazuh-kibana-app/pull/4363) +- Added cluster's IP and protocol as suggestions in the agent deployment wizard. [#4776](https://github.com/wazuh/wazuh-kibana-app/pull/4776) ### Fixed diff --git a/public/controllers/agent/components/register-agent-service.test.ts b/public/controllers/agent/components/register-agent-service.test.ts new file mode 100644 index 0000000000..589a3f8dbb --- /dev/null +++ b/public/controllers/agent/components/register-agent-service.test.ts @@ -0,0 +1,272 @@ +import * as RegisterAgentService from './register-agent-service'; +import { WzRequest } from '../../../react-services/wz-request'; +import { ServerAddressOptions } from '../register-agent/steps'; + +jest.mock('../../../react-services', () => ({ + ...jest.requireActual('../../../react-services') as object, + WzRequest: () => ({ + apiReq: jest.fn(), + }), +})); + + +describe('Register agent service', () => { + beforeEach(() => jest.clearAllMocks()); + describe('getRemoteConfiguration', () => { + it('should return secure connection = TRUE when have connection secure', async () => { + const remoteWithSecureAndNoSecure = [ + { + connection: 'syslog', + ipv6: 'no', + protocol: ['UDP'], + port: '514', + 'allowed-ips': ['0.0.0.0/0'], + }, + { + connection: 'secure', + ipv6: 'no', + protocol: ['UDP'], + port: '1514', + queue_size: '131072', + }, + ]; + const mockedResponse = { + data: { + data: { + affected_items: [ + { + remote: remoteWithSecureAndNoSecure, + }, + ], + }, + }, + }; + WzRequest.apiReq = jest.fn().mockResolvedValueOnce(mockedResponse); + const nodeName = 'example-node'; + const res = await RegisterAgentService.getRemoteConfiguration('example-node'); + expect(res.name).toBe(nodeName); + expect(res.haveSecureConnection).toBe(true); + }); + + it('should return secure connection = FALSE available when dont have connection secure', async () => { + const remoteWithSecureAndNoSecure = [ + { + connection: 'syslog', + ipv6: 'no', + protocol: ['UDP', 'TCP'], + port: '514', + 'allowed-ips': ['0.0.0.0/0'], + }, + ]; + const mockedResponse = { + data: { + data: { + affected_items: [ + { + remote: remoteWithSecureAndNoSecure, + }, + ], + }, + }, + }; + WzRequest.apiReq = jest.fn().mockResolvedValueOnce(mockedResponse); + const nodeName = 'example-node'; + const res = await RegisterAgentService.getRemoteConfiguration('example-node'); + expect(res.name).toBe(nodeName); + expect(res.haveSecureConnection).toBe(false); + }); + + it('should return protocols UDP when is the only connection protocol available', async () => { + const remoteWithSecureAndNoSecure = [ + { + connection: 'syslog', + ipv6: 'no', + protocol: ['UDP'], + port: '514', + 'allowed-ips': ['0.0.0.0/0'], + }, + { + connection: 'secure', + ipv6: 'no', + protocol: ['UDP'], + port: '1514', + queue_size: '131072', + }, + ]; + const mockedResponse = { + data: { + data: { + affected_items: [ + { + remote: remoteWithSecureAndNoSecure, + }, + ], + }, + }, + }; + WzRequest.apiReq = jest.fn().mockResolvedValueOnce(mockedResponse); + const nodeName = 'example-node'; + const res = await RegisterAgentService.getRemoteConfiguration('example-node'); + expect(res.name).toBe(nodeName); + expect(res.isUdp).toEqual(true); + }); + + it('should return protocols TCP when is the only connection protocol available', async () => { + const remoteWithSecureAndNoSecure = [ + { + connection: 'syslog', + ipv6: 'no', + protocol: ['TCP'], + port: '514', + 'allowed-ips': ['0.0.0.0/0'], + }, + { + connection: 'secure', + ipv6: 'no', + protocol: ['TCP'], + port: '1514', + queue_size: '131072', + }, + ]; + const mockedResponse = { + data: { + data: { + affected_items: [ + { + remote: remoteWithSecureAndNoSecure, + }, + ], + }, + }, + }; + WzRequest.apiReq = jest.fn().mockResolvedValueOnce(mockedResponse); + const nodeName = 'example-node'; + const res = await RegisterAgentService.getRemoteConfiguration('example-node'); + expect(res.name).toBe(nodeName); + expect(res.isUdp).toEqual(false); + }); + + it('should return is not UDP when have UDP and TCP protocols available', async () => { + const remoteWithSecureAndNoSecure = [ + { + connection: 'syslog', + ipv6: 'no', + protocol: ['TCP'], + port: '514', + 'allowed-ips': ['0.0.0.0/0'], + }, + { + connection: 'secure', + ipv6: 'no', + protocol: ['UDP'], + port: '1514', + queue_size: '131072', + }, + ]; + const mockedResponse = { + data: { + data: { + affected_items: [ + { + remote: remoteWithSecureAndNoSecure, + }, + ], + }, + }, + }; + WzRequest.apiReq = jest.fn().mockResolvedValueOnce(mockedResponse); + const nodeName = 'example-node'; + const res = await RegisterAgentService.getRemoteConfiguration('example-node'); + expect(res.name).toBe(nodeName); + expect(res.isUdp).toEqual(false); + }); + }); + + describe('getConnectionConfig', () => { + + beforeAll(() => { + jest.clearAllMocks(); + }) + + it('should return UDP when the server address is typed manually (custom)', async () => { + const nodeSelected: ServerAddressOptions = { + label: 'node-selected', + value: 'node-selected', + nodetype: 'master' + }; + + const remoteWithSecureAndNoSecure = [ + { + connection: 'syslog', + ipv6: 'no', + protocol: ['UDP'], + port: '514', + 'allowed-ips': ['0.0.0.0/0'], + }, + { + connection: 'secure', + ipv6: 'no', + protocol: ['UDP'], + port: '1514', + queue_size: '131072', + }, + ]; + const mockedResponse = { + data: { + data: { + affected_items: [ + { + remote: remoteWithSecureAndNoSecure, + }, + ], + }, + }, + }; + WzRequest.apiReq = jest.fn().mockResolvedValueOnce(mockedResponse); + + const config = await RegisterAgentService.getConnectionConfig(nodeSelected, 'default-dns-address'); + expect(config.udpProtocol).toEqual(true); + expect(config.serverAddress).toBe('default-dns-address'); + }) + + it('should return UDP when the server address is received like default server address dns (custom)', async () => { + const nodeSelected: ServerAddressOptions = { + label: 'node-selected', + value: 'node-selected', + nodetype: 'master' + }; + + const remoteWithSecureAndNoSecure = [ + { + connection: 'syslog', + ipv6: 'no', + protocol: ['UDP'], + port: '514', + 'allowed-ips': ['0.0.0.0/0'], + }, + { + connection: 'secure', + ipv6: 'no', + protocol: ['UDP'], + port: '1514', + queue_size: '131072', + }, + ]; + const mockedResponse = { + data: { + data: { + affected_items: [ + { + remote: remoteWithSecureAndNoSecure, + }, + ], + }, + }, + }; + WzRequest.apiReq = jest.fn().mockResolvedValueOnce(mockedResponse); + + const config = await RegisterAgentService.getConnectionConfig(nodeSelected); + expect(config.udpProtocol).toEqual(true); + }) + }) +}); diff --git a/public/controllers/agent/components/register-agent-service.ts b/public/controllers/agent/components/register-agent-service.ts new file mode 100644 index 0000000000..80e1e1ecbe --- /dev/null +++ b/public/controllers/agent/components/register-agent-service.ts @@ -0,0 +1,147 @@ +import { WzRequest } from '../../../react-services/wz-request'; +import { ServerAddressOptions } from '../register-agent/steps'; + +type Protocol = 'TCP' | 'UDP'; + +type RemoteItem = { + connection: 'syslog' | 'secure'; + ipv6: 'yes' | 'no'; + protocol: Protocol[]; + allowed_ips?: string[]; + queue_size?: string; +}; + +type RemoteConfig = { + name: string; + isUdp: boolean | null; + haveSecureConnection: boolean | null; +}; + +/** + * Get the remote configuration from api + */ +async function getRemoteConfiguration( + nodeName: string, +): Promise{ + let config: RemoteConfig = { + name: nodeName, + isUdp: null, + haveSecureConnection: null, + }; + const result = await WzRequest.apiReq( + 'GET', + `/cluster/${nodeName}/configuration/request/remote`, + {}, + ); + const items = ((result.data || {}).data || {}).affected_items || []; + const remote = items[0]?.remote; + if (remote) { + const remoteFiltered = remote.filter((item: RemoteItem) => { + return item.connection === 'secure'; + }); + + remoteFiltered.length > 0 + ? (config.haveSecureConnection = true) + : (config.haveSecureConnection = false); + + let protocolsAvailable: Protocol[] = []; + remote.forEach((item: RemoteItem) => { + // get all protocols available + item.protocol.forEach(protocol => { + protocolsAvailable = protocolsAvailable.concat(protocol); + }); + }); + + config.isUdp = + getRemoteProtocol(protocolsAvailable) === 'UDP' ? true : false; + } + return config; +}; + +/** + * Get the remote protocol available from list of protocols + * @param protocols + */ +function getRemoteProtocol(protocols: Protocol[]) { + if (protocols.length === 1) { + return protocols[0]; + } else { + return !protocols.includes('TCP') ? 'UDP' : 'TCP'; + } +}; + + +/** + * Get the remote configuration from nodes registered in the cluster and decide the protocol to setting up in deploy agent param + * @param nodeSelected + * @param defaultServerAddress + */ +async function getConnectionConfig(nodeSelected: ServerAddressOptions, defaultServerAddress?: string) { + const nodeName = nodeSelected?.label; + if(!defaultServerAddress){ + if(nodeSelected.nodetype !== 'custom'){ + const remoteConfig = await getRemoteConfiguration(nodeName); + return { serverAddress: remoteConfig.name, udpProtocol: remoteConfig.isUdp, connectionSecure: remoteConfig.haveSecureConnection }; + }else{ + return { serverAddress: nodeName, udpProtocol: true, connectionSecure: true }; + } + }else{ + return { serverAddress: defaultServerAddress, udpProtocol: true, connectionSecure: true }; + } +} + +type NodeItem = { + name: string; + ip: string; + type: string; +} + +type NodeResponse = { + data: { + data: { + affected_items: NodeItem[]; + } + } +} + +/** + * Get the list of the cluster nodes and parse it into a list of options + */ +export const getNodeIPs = async (): Promise => { + return await WzRequest.apiReq('GET', '/cluster/nodes', {}); +}; + +/** + * Parse the nodes list from the API response to a format that can be used by the EuiComboBox + * @param nodes + */ +export const parseNodesInOptions = (nodes: NodeResponse): ServerAddressOptions[] => { + return nodes.data.data.affected_items.map((item: NodeItem) => ({ + label: item.name, + value: item.ip, + nodetype: item.type, + })); +}; + +/** + * Get the list of the cluster nodes from API and parse it into a list of options + */ +export const fetchClusterNodesOptions = async (): Promise => { + const nodes = await getNodeIPs(); + return parseNodesInOptions(nodes); +} + +/** + * Get the master node data from the list of cluster nodes + * @param nodeIps + */ +export const getMasterNode = (nodeIps: ServerAddressOptions[]): ServerAddressOptions[] => { + return nodeIps.filter((nodeIp) => nodeIp.nodetype === 'master'); +}; + + + +export { + getConnectionConfig, + getRemoteConfiguration, +} \ No newline at end of file diff --git a/public/controllers/agent/components/register-agent.js b/public/controllers/agent/components/register-agent.js index 1458f199be..833103cf87 100644 --- a/public/controllers/agent/components/register-agent.js +++ b/public/controllers/agent/components/register-agent.js @@ -43,6 +43,8 @@ import { getErrorOrchestrator } from '../../../react-services/common-services'; import { webDocumentationLink } from '../../../../common/services/web_documentation'; import { architectureButtons, architectureButtonsi386, architecturei386Andx86_64, versionButtonsRaspbian, versionButtonsSuse, versionButtonsOracleLinux, versionButtonFedora, architectureButtonsSolaris, architectureButtonsWithPPC64LE, architectureButtonsOpenSuse, architectureButtonsAix, architectureButtonsHpUx, versionButtonAmazonLinux, versionButtonsRedHat, versionButtonsCentos, architectureButtonsMacos, osButtons, versionButtonsDebian, versionButtonsUbuntu, versionButtonsWindows, versionButtonsMacOS, versionButtonsOpenSuse, versionButtonsSolaris, versionButtonsAix, versionButtonsHPUX } from '../wazuh-config' import './register-agent.scss' +import ServerAddress from '../register-agent/steps/server-address'; +import { getConnectionConfig, fetchClusterNodesOptions } from './register-agent-service' export const RegisterAgent = withErrorBoundary( @@ -65,9 +67,11 @@ export const RegisterAgent = withErrorBoundary( wazuhPassword: '', groups: [], selectedGroup: [], + defaultServerAddress: '', udpProtocol: false, showPassword: false, showProtocol: true, + connectionSecure: true }; this.restartAgentCommand = { rpm: this.systemSelector(), @@ -84,13 +88,9 @@ export const RegisterAgent = withErrorBoundary( try { this.setState({ loading: true }); const wazuhVersion = await this.props.getWazuhVersion(); - let serverAddress = false; let wazuhPassword = ''; let hidePasswordInput = false; - serverAddress = this.configuration['enrollment.dns'] || false; - if (!serverAddress) { - serverAddress = await this.props.getCurrentApiAddress(); - } + this.getEnrollDNSConfig(); let authInfo = await this.getAuthInfo(); const needsPassword = (authInfo.auth || {}).use_password === 'yes'; if (needsPassword) { @@ -99,11 +99,8 @@ export const RegisterAgent = withErrorBoundary( hidePasswordInput = true; } } - - const udpProtocol = await this.getRemoteInfo(); const groups = await this.getGroups(); this.setState({ - serverAddress, needsPassword, hidePasswordInput, versionButtonsRedHat, @@ -143,7 +140,7 @@ export const RegisterAgent = withErrorBoundary( context: `${RegisterAgent.name}.componentDidMount`, level: UI_LOGGER_LEVELS.ERROR, severity: UI_ERROR_SEVERITIES.BUSINESS, - display: false, + display: true, store: false, error: { error: error, @@ -155,6 +152,16 @@ export const RegisterAgent = withErrorBoundary( } } + getEnrollDNSConfig = () => { + let serverAddress = this.configuration['enrollment.dns'] || ''; + this.setState({ defaultServerAddress: serverAddress }); + if(serverAddress){ + this.setState({ udpProtocol: true }); + }else{ + this.setState({ udpProtocol: false }); + } + } + async getAuthInfo() { try { const result = await WzRequest.apiReq('GET', '/agents/000/config/auth/auth', {}); @@ -165,18 +172,6 @@ export const RegisterAgent = withErrorBoundary( } } - async getRemoteInfo() { - try { - const result = await WzRequest.apiReq('GET', '/agents/000/config/request/remote', {}); - const remote = ((result.data || {}).data || {}).remote || {}; - if (remote.length === 2) { - this.setState({ udpProtocol: true }) - } - } catch (error) { - throw new Error(error); - } - } - selectOS(os) { this.setState({ selectedOS: os, @@ -216,8 +211,12 @@ export const RegisterAgent = withErrorBoundary( this.setState({ selectedSYS: sys }); } - setServerAddress(event) { - this.setState({ serverAddress: event.target.value }); + setServerAddress(serverAddress) { + this.setState({ serverAddress }); + } + + setAgentName(event) { + this.setState({ agentName: event.target.value }); } setAgentName(event) { @@ -266,7 +265,9 @@ export const RegisterAgent = withErrorBoundary( } optionalDeploymentVariables() { - let deployment = `WAZUH_MANAGER='${this.state.serverAddress}' `; + + let deployment = this.state.serverAddress && `WAZUH_MANAGER='${this.state.serverAddress}' `; + const protocol = false if (this.state.selectedOS == 'win') { deployment += `WAZUH_REGISTRATION_SERVER='${this.state.serverAddress}' `; } @@ -275,7 +276,7 @@ export const RegisterAgent = withErrorBoundary( deployment += `WAZUH_REGISTRATION_PASSWORD='${this.state.wazuhPassword}' `; } - if (!this.state.udpProtocol == true) { + if (this.state.udpProtocol) { deployment += `WAZUH_PROTOCOL='UDP' `; } @@ -295,8 +296,7 @@ export const RegisterAgent = withErrorBoundary( agentNameVariable() { let agentName = `WAZUH_AGENT_NAME='${this.state.agentName}' `; - - if (this.state.selectedArchitecture && this.state.agentName !== '') { + if(this.state.selectedArchitecture && this.state.agentName !== '') { return agentName; } else { return ''; @@ -719,18 +719,7 @@ export const RegisterAgent = withErrorBoundary(

); const missingOSSelection = this.checkMissingOSSelection(); - const ipInput = ( - -

- This is the address the agent uses to communicate with the Wazuh server. It can be an IP address or a fully qualified domain name (FQDN). -

- this.setServerAddress(event)} - /> -
- ); + const agentName = ( ); - + const agentGroup = ( - -

Select one or more existing groups

- { - this.setGroupName(group); - }} - isDisabled={!this.state.groups.length} - isClearable={true} - data-test-subj="demoComboBox" - /> -
+ +

Select one or more existing groups

+ { + this.setGroupName(group); + }} + isDisabled={!this.state.groups.length} + isClearable={true} + data-test-subj="demoComboBox" + /> +
) const passwordInput = ( - ) : - this.state.selectedOS && ( + ) : (this.state.connectionSecure === true && this.state.udpProtocol === false) ? ( + +

+ You can use this command to install and enroll the Wazuh agent in one or more hosts. +

+ + + {windowsAdvice} +
+ + {this.state.wazuhPassword && !this.state.showPassword ? this.obfuscatePassword(text) : text} + + + {(copy) => ( +
+

Copy command

+
+ )} +
+
+ {this.state.needsPassword && ( + this.setShowPassword(active)} + /> + )} + +
) : (this.state.connectionSecure === false) ? + ( + +

+ You can use this command to install and enroll the Wazuh agent in one or more hosts. +

+ + + + Warning: there's no secure protocol configured and agents will not be able to communicate with the manager. + + } + iconType="iInCircle" + /> + + {windowsAdvice} +
+ + {this.state.wazuhPassword && !this.state.showPassword ? this.obfuscatePassword(text) : text} + + + {(copy) => ( +
+

Copy command

+
+ )} +
+
+ {this.state.needsPassword && ( + this.setShowPassword(active)} + /> + )} + +
) : (

You can use this command to install and enroll the Wazuh agent in one or more hosts.

- If the installer finds another Wazuh agent in the system, it will upgrade it preserving the configuration. - - } + title={warningUpgrade} iconType="iInCircle" /> @@ -871,7 +931,7 @@ export const RegisterAgent = withErrorBoundary( {this.state.wazuhPassword && !this.state.showPassword ? this.obfuscatePassword(text) : text} - + {(copy) => (

Copy command

@@ -1092,6 +1152,47 @@ export const RegisterAgent = withErrorBoundary( ) } + const onChangeServerAddress = async (selectedNodes) => { + if(selectedNodes.length === 0){ + this.setState({ + serverAddress: '', + udpProtocol: false, + connectionSecure: null + }) + }else{ + const nodeSelected = selectedNodes[0]; + try { + const remoteConfig = await getConnectionConfig(nodeSelected); + this.setState({ + serverAddress: remoteConfig.serverAddress, + udpProtocol: remoteConfig.udpProtocol, + connectionSecure: remoteConfig.connectionSecure + }) + }catch(error){ + const options = { + context: `${RegisterAgent.name}.onChangeServerAddress`, + level: UI_LOGGER_LEVELS.ERROR, + severity: UI_ERROR_SEVERITIES.BUSINESS, + display: true, + store: false, + error: { + error: error, + message: error.message || error, + title: error.name || error, + }, + }; + getErrorOrchestrator().handleError(options); + this.setState({ + serverAddress: nodeSelected.label, + udpProtocol: false, + connectionSecure: false + }) + } + } + + } + + const steps = [ { title: 'Choose the operating system', @@ -1245,7 +1346,6 @@ export const RegisterAgent = withErrorBoundary( title: 'Choose the version', children: ( this.state.selectedVersion == '11.31' ? buttonGroupWithMessage("Choose the version", versionButtonsHPUX, this.state.selectedVersion, (version) => this.setVersion(version)) : buttonGroup("Choose the version", versionButtonsHPUX, this.state.selectedVersion, (version) => this.setVersion(version)) - ), }, ] @@ -1342,7 +1442,12 @@ export const RegisterAgent = withErrorBoundary( : []), { title: 'Wazuh server address', - children: {ipInput}, + children: + + , }, ...(!(!this.state.needsPassword || this.state.hidePasswordInput) ? [ @@ -1437,6 +1542,7 @@ export const RegisterAgent = withErrorBoundary( ] : []), ]; + return (
@@ -1471,7 +1577,7 @@ export const RegisterAgent = withErrorBoundary( )} - + {this.state.loading && ( <> diff --git a/public/controllers/agent/register-agent/steps/__snapshots__/server-address.test.tsx.snap b/public/controllers/agent/register-agent/steps/__snapshots__/server-address.test.tsx.snap new file mode 100644 index 0000000000..d41e54d637 --- /dev/null +++ b/public/controllers/agent/register-agent/steps/__snapshots__/server-address.test.tsx.snap @@ -0,0 +1,84 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Server Address Combobox should match snapshot 1`] = ` +
+
+

+ This is the address the agent uses to communicate with the Wazuh server. It can be an IP address or a fully qualified domain name (FQDN). +

+ +
+`; diff --git a/public/controllers/agent/register-agent/steps/index.ts b/public/controllers/agent/register-agent/steps/index.ts new file mode 100644 index 0000000000..5732aed9ae --- /dev/null +++ b/public/controllers/agent/register-agent/steps/index.ts @@ -0,0 +1 @@ +export * from './server-address'; \ No newline at end of file diff --git a/public/controllers/agent/register-agent/steps/server-address.test.tsx b/public/controllers/agent/register-agent/steps/server-address.test.tsx new file mode 100644 index 0000000000..86dd698c7f --- /dev/null +++ b/public/controllers/agent/register-agent/steps/server-address.test.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { act } from 'react-dom/test-utils'; +import ServerAddress from './server-address'; +import * as registerAgentsUtils from '../../components/register-agent-service'; + +const mockedNodesIps = [ + { + name: 'master-node', + type: 'master', + version: '4.x', + ip: 'wazuh-master', + }, + { + name: 'worker1', + type: 'worker', + version: '4.x', + ip: '172.26.0.7', + }, + { + name: 'worker2', + type: 'worker', + version: '4.x', + ip: '172.26.0.6', + }, +]; + +const mockedClusterNodes = { + data: { + data: { + affected_items: mockedNodesIps, + total_affected_items: mockedNodesIps.length, + total_failed_items: 0, + failed_items: [], + }, + message: 'All selected nodes information was returned', + error: 0, + }, +}; + +const promiseFetchOptions = Promise.resolve( + registerAgentsUtils.parseNodesInOptions(mockedClusterNodes), +); +const mockedFetchOptions = () => promiseFetchOptions; + +describe('Server Address Combobox', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render correctly', () => { + const { container } = render( + {}} + fetchOptions={() => + Promise.resolve( + registerAgentsUtils.parseNodesInOptions(mockedClusterNodes), + ) + } + />, + ); + expect(container).toBeInTheDocument(); + }); + + it('should match snapshot', () => { + const { container } = render( + {}} fetchOptions={mockedFetchOptions} />, + ); + expect(container).toMatchSnapshot(); + }); + + it('should set default combobox value and disable input when defaultValue is defined', async () => { + const onChangeMocked = jest.fn(); + const { container, getByText, getByRole } = render( + , + ); + + await act(async () => { + await promiseFetchOptions; + expect(onChangeMocked).toBeCalledTimes(1); + expect(onChangeMocked).toBeCalledWith([ + { label: 'default-dns', value: 'default-dns', nodetype: 'custom' } + ]); + expect(getByText('default-dns')).toBeInTheDocument(); + expect(getByRole('textbox')).toHaveAttribute('disabled'); + expect(container).toBeInTheDocument(); + }); + }); + + it('should set node type master like default value when combobox is initiliazed and not have defaultValue', async () => { + const { container, getByText } = render( + {}} fetchOptions={mockedFetchOptions} />, + ); + + await act(async () => { + await promiseFetchOptions; // waiting for the combobox items are loaded + expect(getByText('master-node')).toBeInTheDocument(); + expect(container).toBeInTheDocument(); + }); + }); + + it('should render the correct number of options', async () => { + const { getByRole, findByText } = render( + {}} fetchOptions={mockedFetchOptions} />, + ); + + await act(async () => { + await promiseFetchOptions; // waiting for the combobox items are loaded + fireEvent.click(getByRole('button', { name: 'Clear input' })); + await findByText(`${mockedNodesIps[0].name}:${mockedNodesIps[0].ip}`); + await findByText(`${mockedNodesIps[1].name}:${mockedNodesIps[1].ip}`); + await findByText(`${mockedNodesIps[2].name}:${mockedNodesIps[2].ip}`); + }); + + }); + + it('should allow only single selection', async () => { + const onChangeMocked = jest.fn(); + const { getByRole, getByText,findByText } = render( + , + ); + await act(async () => { + await promiseFetchOptions; // waiting for the combobox items are loaded + fireEvent.click(getByRole('button', { name: 'Clear input' })); + const serverAddresInput = getByRole('textbox'); + fireEvent.change(serverAddresInput, { target: { value: 'first-typed' } }); + fireEvent.keyDown(serverAddresInput, { key: 'Enter', code: 'Enter' }); + fireEvent.change(serverAddresInput, { target: { value: 'last-typed' } }); + fireEvent.keyDown(serverAddresInput, { key: 'Enter', code: 'Enter' }); + expect(onChangeMocked).toHaveBeenLastCalledWith([{ label: 'last-typed', value: 'last-typed', nodetype: 'custom' }]); + expect(getByText('last-typed')).toBeInTheDocument(); + }); + }); + + it('should return EMPTY parsed Node IPs when options are not selected', async () => { + const onChangeMocked = jest.fn(); + const { getByRole, container } = render( + , + ); + await act(async () => { + await promiseFetchOptions; // waiting for the combobox items are loaded + fireEvent.click(getByRole('button', { name: 'Clear input' })); + expect(onChangeMocked).toBeCalledTimes(2); + expect(onChangeMocked).toBeCalledWith([]); + expect(container).toBeInTheDocument(); + }); + }); + + it('should allow create customs options when user type and trigger enter key', async () => { + const onChangeMocked = jest.fn(); + + const { getByRole } = render( + , + ); + await act(async () => { + await promiseFetchOptions; // waiting for the combobox items are loaded + fireEvent.change(getByRole('textbox'), { + target: { value: 'custom-ip-dns' }, + }); + fireEvent.keyDown(getByRole('textbox'), { key: 'Enter', code: 'Enter' }); + expect(onChangeMocked).toBeCalledTimes(2); + expect(onChangeMocked).toHaveBeenNthCalledWith(2,[{ label: 'custom-ip-dns', value: 'custom-ip-dns', nodetype: 'custom' }]); + }); + }); + + it('should show "node.name:node.ip" in the combobox options', async () => { + const { getByRole, getByText } = render( + {}} fetchOptions={mockedFetchOptions} />, + ); + await act(async () => { + await promiseFetchOptions; // waiting for the combobox items are loaded + fireEvent.click(getByRole('button', { name: 'Clear input' })); + }); + + mockedNodesIps.forEach(nodeItem => { + expect( + getByText(`${nodeItem.name}:${nodeItem.ip}`), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/public/controllers/agent/register-agent/steps/server-address.tsx b/public/controllers/agent/register-agent/steps/server-address.tsx new file mode 100644 index 0000000000..8c23073f36 --- /dev/null +++ b/public/controllers/agent/register-agent/steps/server-address.tsx @@ -0,0 +1,170 @@ +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiHighlight, + EuiText, +} from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { UI_LOGGER_LEVELS } from '../../../../../common/constants'; +import { getErrorOrchestrator } from '../../../../react-services/common-services'; +import { UI_ERROR_SEVERITIES } from '../../../../react-services/error-orchestrator/types'; +import { getMasterNode } from '../../components/register-agent-service'; + +type Props = { + onChange: (value: EuiComboBoxOptionOption[]) => void; + fetchOptions: () => Promise[]>; + defaultValue?: string; +}; + +export type ServerAddressOptions = EuiComboBoxOptionOption & { + nodetype?: string; +}; + +const ServerAddress = (props: Props) => { + const { onChange, fetchOptions, defaultValue } = props; + const [nodeIPs, setNodeIPs] = useState([]); + const [selectedNodeIPs, setSelectedNodeIPs] = useState< + ServerAddressOptions[] + >([]); + const [isLoading, setIsLoading] = useState(false); + const [isDisabled, setIsDisabled] = useState(false); + + useEffect(() => { + initialize(); + }, []); + + /** + * Fetches the node IPs (options) and sets the state + */ + const initialize = async () => { + if (!fetchOptions) { + throw new Error('fetchOptions is required'); + } + try { + setIsLoading(true); + await setDefaultValue(); + setIsLoading(false); + } catch (error) { + setIsLoading(false); + const options = { + context: `${ServerAddress.name}.initialize`, + level: UI_LOGGER_LEVELS.ERROR, + severity: UI_ERROR_SEVERITIES.BUSINESS, + display: true, + store: false, + error: { + error: error, + message: error.message || error, + title: error.name || error, + }, + }; + getErrorOrchestrator().handleError(options); + } + }; + + /** + * Sets the default value of server address + */ + const setDefaultValue = async () => { + if (defaultValue) { + const defaultNode = [{ label: defaultValue, value: defaultValue, nodetype: 'custom' }]; + handleOnChange(defaultNode); + setIsDisabled(true); + } else { + setIsDisabled(false); + const nodeIps = await fetchOptions(); + setNodeIPs(nodeIps); + const defaultNode = getMasterNode(nodeIps); + if (defaultNode.length > 0) { + handleOnChange(defaultNode); + } + } + }; + + /** + * Handles the change of the selected node IP + * @param value + */ + const handleOnChange = (value: EuiComboBoxOptionOption[]) => { + setSelectedNodeIPs(value); + onChange(value); + }; + + /** + * Handle the render of the custom options in the combobox list + * @param option + * @param searchValue + * @param contentClassName + */ + const handleRenderOption = ( + option: EuiComboBoxOptionOption, + inputValue: string, + contentClassName: string, + ) => { + const { label, value } = option; + return ( + + {`${label}:${value}`} + + ); + }; + + /** + * Handle the interaction when the user enter a option that is not in the list + * Creating new options in the list and selecting it + * @param inputValue + * @param options + */ + const handleOnCreateOption = ( + inputValue: string, + options: ServerAddressOptions[] = [], + ) => { + if (!inputValue) { + return; + } + + const normalizedSearchValue = inputValue.trim().toLowerCase(); + if (!normalizedSearchValue) { + return; + } + + const newOption = { + value: inputValue, + label: inputValue, + nodetype: 'custom', + }; + // Create the option if it doesn't exist. + if ( + options.findIndex( + (option: ServerAddressOptions) => + option.label.trim().toLowerCase() === normalizedSearchValue, + ) === -1 + ) { + setNodeIPs([...nodeIPs, newOption]); + } + // Select the option. + handleOnChange([newOption]); + }; + + return ( + +

+ This is the address the agent uses to communicate with the Wazuh server. It can be an IP address or a fully qualified domain name (FQDN). +

+ handleOnCreateOption(sv, fo)} + /> +
+ ); +}; + +export default ServerAddress;