From a20c60a515c0e754bcbe7dcdb1c45cb130b67e17 Mon Sep 17 00:00:00 2001 From: Guido Modarelli <38738725+guidomodarelli@users.noreply.github.com> Date: Tue, 5 Nov 2024 11:32:15 -0300 Subject: [PATCH] Added pinned agent mechanic to inventory data, stats, and configuration for consistent functionality (#7135) * feat: integrate ButtonExploreAgent component into MainModuleAgent for enhanced agent interaction and updated layout consistency * feat: add pinned agent mechanic across all pages for improved consistency in functionality and user experience * feat: enhance ButtonPinnedAgent with clsx for dynamic class management and improved background styling * refactor: clean up agent selector styles by removing unnecessary focus and hover states for better maintainability * refactor: update WzButtonProps type to include EUI component properties for better flexibility and maintainability * refactor: improve ButtonPinnedAgent structure by adding data-test attribute and simplifying JSX for cleaner code readability * test: add tests for explore agent button rendering in various agent tabs for improved coverage and verification of UI functionality * test: add tests for explore agent button rendering in various agent tabs for improved coverage and verification of UI functionality refactor: enhance MainModuleAgent layout by adding margin and padding styles to EuiFlexItems for improved UI consistency * refactor: add newline in ButtonPinnedAgent for improved readability and code consistency in explore agent button component * chore: update CHANGELOG to clarify pinned agent mechanic in inventory, stats, and configuration for consistency across pages * refactor: add unPinAgent functionality to agent components for enhanced user interaction and UI consistency across agent views * refactor: remove unused EuiButtonEmpty import for cleaner code in configuration overview component * refactor: update useGenericRequest hook for improved type safety and cleaner data handling in API requests * refactor: enhance type safety in InventoryMetrics and streamline API request handling with updated useGenericRequest usage * refactor: rename ErrorOrchestratorService to ErrorService for improved clarity and type safety in error handling logic * refactor: add JSDoc comments to GenericRequest for improved type safety and better documentation of request parameters * refactor: improve cluster handling logic and error management in WzConfigurationSwitch for better state management and readability * refactor: streamline AgentStats component with improved type safety and error handling for enhanced clarity and maintainability * Fix Prettier issue * refactor: update test descriptions in MainModuleAgent tests to clarify the "Pinned Agent" button functionality across tabs * refactor: remove deprecated Route for syscollector in AgentView to clean up component structure and improve readability * refactor: rename PartialRecordMock to DeepPartialRecordMock for better clarity and maintainability in type definitions * feat: add mock agent data for Debian, Windows, and Darwin to enhance testing and provide a comprehensive environment setup * refactor: replace inline AGENT mock with import from test mocks for improved test organization and maintainability * test: add API call verification in AgentStats tests to ensure correct fetching of agent stats during component rendering * test: enhance AgentStats tests to verify API calls with correct agent IDs and endpoints on component updates * test: refine AgentStats test description to clarify API call behavior when switching agents * test: improve AgentStats tests to ensure correct column structure, titles, and CSV filename updates on rendering changes * refactor: update breadcrumb types in useGlobalBreadcrumb for improved type safety and readability in context management * refactor: enhance withGlobalBreadcrumb HOC for better type handling and maintainability in breadcrumb logic * refactor: replace hardcoded path in agent stats breadcrumb with SECTIONS constant for improved maintainability and readability * refactor: replace hardcoded agents-preview path with SECTIONS constant for improved maintainability and readability in configuration-main.js * refactor: improve agent handling in withGlobalBreadcrumb for better maintainability and readability in configuration-main.js * refactor: add global breadcrumb support in AgentView for better navigation and maintainability in index.tsx * refactor: update unPinAgent to navigate with new URL structure for improved navigation in index.tsx * refactor: add withGlobalBreadcrumb HOC to enhance navigation structure in agent index.tsx * refactor: enhance AgentStats tests with clearer descriptions for column structure and title checks amidst agent changes * refactor: replace jQuery with native DOM method for setting title attribute in breadcrumb configuration component * refactor: improve syscollector metrics tests by adding agent ID handling and verifying data fetching for different agents * refactor: add unit test for SoftwareTab rendering WindowsUpdatesTable on Windows platform in syscollector agent component * refactor: add unit test for WindowsUpdatesTable to verify correct hotfixes endpoint when changing agents in syscollector component * refactor: fix endpoint string in WindowsUpdatesTable test for accurate hotfixes URL when changing agents in syscollector component * refactor: enhance WindowsUpdatesTable test to validate API requests for different agent IDs in syscollector component * refactor: handle potential undefined values in packages-table component for safer access to sorting fields when rendering table * test: add test suite for PackagesTable to validate API requests and table rendering for different agent IDs in syscollector component * refactor: safeguard against undefined values for sorting fields in multiple syscollector table components during rendering * test: add test suite for NetworkInterfacesTable to verify API requests and rendering behavior for changing agent IDs * test: update test descriptions in PackagesTable and WindowsUpdatesTable for clarity on data being fetched for given agent IDs * test: add tests for NetworkPortsTable to verify correct API requests and rendering behavior for different agent IDs * test: rename test suite to NetworkPortsTable for clarity and proper context in related API request tests * test: add NetworkSettingsTable tests to ensure correct rendering and API requests for changing agent IDs * test: add tests for ProcessesTable to validate rendering and API requests when switching between agent IDs * fix: improve optional chaining to direct access for initial sorting fields in network tables for better readability * test: introduce reusable functions for validating agent API requests and endpoint rendering in various system collector tables * refactor(useGenericRequest): rename response state to data and update related logic for clarity --------- Co-authored-by: Federico Rodriguez --- CHANGELOG.md | 1 + .../agents/stats/agent-stats.test.tsx | 195 +++++++++++++++++- .../components/agents/stats/agent-stats.tsx | 93 +++++---- .../check-endpoint-for-given-agent-id.tsx | 59 ++++++ .../network-interfaces-table.test.tsx | 48 +++++ .../components/network-ports-table.test.tsx | 48 +++++ .../network-settings-table.test.tsx | 44 ++++ .../components/packages-table.test.tsx | 48 +++++ .../components/processes-table.test.tsx | 48 +++++ .../components/syscollector-metrics.test.tsx | 36 +++- .../components/syscollector-metrics.tsx | 38 ++-- .../components/windows-updates-table.test.tsx | 44 ++++ .../agents/syscollector/inventory.test.tsx | 20 +- .../agents/syscollector/software.test.tsx | 19 ++ .../components/common/buttons/button.tsx | 26 ++- .../common/hocs/withGlobalBreadcrumb.tsx | 28 ++- .../common/hooks/useGenericRequest.ts | 60 ++++-- .../common/hooks/useGlobalBreadcrumb.ts | 13 +- .../common/modules/main-agent.test.tsx | 91 ++++++++ .../components/common/modules/main-agent.tsx | 15 +- .../common/welcome/agents-welcome.js | 2 +- .../endpoints-summary/agent/index.tsx | 37 +++- .../button-explore-agent.scss | 5 +- .../button-explore-agent.tsx | 90 ++++---- .../configuration/configuration-main.js | 11 +- .../configuration/configuration-overview.js | 7 +- .../configuration/configuration-switch.js | 183 ++++++++-------- .../overview-actions/agents-selector.scss | 14 -- .../public/react-services/common-services.ts | 7 +- .../error-orchestrator.service.ts | 8 +- .../public/react-services/generic-request.js | 9 + plugins/main/test/__mocks__/agent.ts | 78 +++++++ plugins/main/test/types/index.ts | 6 +- 33 files changed, 1139 insertions(+), 292 deletions(-) create mode 100644 plugins/main/public/components/agents/syscollector/components/check-endpoint-for-given-agent-id.tsx create mode 100644 plugins/main/public/components/agents/syscollector/components/network-interfaces-table.test.tsx create mode 100644 plugins/main/public/components/agents/syscollector/components/network-ports-table.test.tsx create mode 100644 plugins/main/public/components/agents/syscollector/components/network-settings-table.test.tsx create mode 100644 plugins/main/public/components/agents/syscollector/components/packages-table.test.tsx create mode 100644 plugins/main/public/components/agents/syscollector/components/processes-table.test.tsx create mode 100644 plugins/main/public/components/agents/syscollector/components/windows-updates-table.test.tsx create mode 100644 plugins/main/public/components/agents/syscollector/software.test.tsx create mode 100644 plugins/main/test/__mocks__/agent.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fdbbe56856..718ce46c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to the Wazuh app project will be documented in this file. - Added ability to filter from File Integrity Monitoring registry inventory [#7119](https://github.com/wazuh/wazuh-dashboard-plugins/pull/7119) - Added new field columns and ability to select the visible fields in the File Integrity Monitoring Files and Registry tables [#7119](https://github.com/wazuh/wazuh-dashboard-plugins/pull/7119) - Added filter by value to document details fields [#7081](https://github.com/wazuh/wazuh-dashboard-plugins/pull/7081) +- Added pinned agent mechanic to inventory data, stats, and configuration for consistent functionality [#7135](https://github.com/wazuh/wazuh-dashboard-plugins/pull/7135) ### Changed diff --git a/plugins/main/public/components/agents/stats/agent-stats.test.tsx b/plugins/main/public/components/agents/stats/agent-stats.test.tsx index 84f8e2a648..c0b7ef3399 100644 --- a/plugins/main/public/components/agents/stats/agent-stats.test.tsx +++ b/plugins/main/public/components/agents/stats/agent-stats.test.tsx @@ -1,8 +1,16 @@ import React from 'react'; -import { render, act } from '@testing-library/react'; +import { render, act, RenderResult } from '@testing-library/react'; import { AgentStats } from './agent-stats'; import { queryDataTestAttr } from '../../../../test/public/query-attr'; import { CSS } from '../../../../test/utils/CSS'; +import { WzRequest } from '../../../react-services'; +import { AgentStatTable } from './table'; + +const agent000 = '000'; +const agent001 = '001'; + +const apiReqMock = WzRequest.apiReq as jest.Mock; +const AgentStatTableMock = AgentStatTable as jest.Mock; jest.mock('../../../react-services', () => ({ WzRequest: { @@ -10,6 +18,43 @@ jest.mock('../../../react-services', () => ({ }, })); +jest.mock('redux', () => ({ + compose: () => (Component: React.JSX.Element) => Component, + __esModule: true, +})); + +jest.mock('../../common/hocs', () => ({ + withGlobalBreadcrumb: () => () => <>, + withGuard: () => () => <>, + withUserAuthorizationPrompt: () => () => <>, + withErrorBoundary: () => () => <>, + __esModule: true, +})); + +jest.mock('../prompts', () => ({ + PromptNoActiveAgentWithoutSelect: () => <>, + PromptAgentFeatureVersion: () => <>, + __esModule: true, +})); + +jest.mock('../../../utils/applications', () => ({ + endpointsSummary: { + id: 'endpoints-summary', + breadcrumbLabel: 'Endpoints', + }, +})); + +jest.mock('../../../react-services/navigation-service', () => ({ + getInstance: () => ({ + getUrlForApp: jest.fn().mockReturnValue('http://url'), + __esModule: true, + }), +})); + +jest.mock('./table', () => ({ + AgentStatTable: jest.fn(() => <>), +})); + describe('AgentStats', () => { it('should not render agent info ribbon', async () => { await act(async () => { @@ -65,4 +110,152 @@ describe('AgentStats', () => { ).toHaveLength(7); }); }); + + it('should call api with correct agent ids and endpoints when changing agent', async () => { + apiReqMock.mockClear(); + + let rerender: RenderResult['rerender']; + + await act(async () => { + ({ rerender } = render()); + }); + + expect(apiReqMock).toHaveBeenCalledTimes(2); + expect(apiReqMock.mock.calls[0]).toEqual([ + 'GET', + `/agents/${agent000}/stats/logcollector`, + {}, + ]); + expect(apiReqMock.mock.calls[1]).toEqual([ + 'GET', + `/agents/${agent000}/stats/agent`, + {}, + ]); + + apiReqMock.mockClear(); + + await act(async () => { + rerender(); + }); + + expect(apiReqMock).toHaveBeenCalledTimes(2); + expect(apiReqMock.mock.calls[0]).toEqual([ + 'GET', + `/agents/${agent001}/stats/logcollector`, + {}, + ]); + expect(apiReqMock.mock.calls[1]).toEqual([ + 'GET', + `/agents/${agent001}/stats/agent`, + {}, + ]); + }); + + it('should maintain column structure across multiple renders, either when changing agent or not', async () => { + AgentStatTableMock.mockClear(); + + const mockColumns = [ + { + field: 'location', + name: 'Location', + sortable: true, + }, + { + field: 'events', + name: 'Events', + sortable: true, + }, + { + field: 'bytes', + name: 'Bytes', + sortable: true, + }, + ]; + + let rerender: RenderResult['rerender']; + + await act(async () => { + ({ rerender } = render()); + }); + + expect(AgentStatTableMock.mock.calls[0][0].columns).toEqual(mockColumns); + expect(AgentStatTableMock.mock.calls[1][0].columns).toEqual(mockColumns); + + AgentStatTableMock.mockClear(); + + await act(async () => { + rerender(); + }); + + expect(AgentStatTableMock.mock.calls[0][0].columns).toEqual(mockColumns); + expect(AgentStatTableMock.mock.calls[1][0].columns).toEqual(mockColumns); + }); + + it('should apply correct titles after render and rerender, either when changing agent or not', async () => { + AgentStatTableMock.mockClear(); + + const mockDataStatLogcollectorTitle = 'Global'; + const mockDataStatAgentTitle = 'Interval'; + + let rerender: RenderResult['rerender']; + + await act(async () => { + ({ rerender } = render()); + }); + + expect(AgentStatTableMock.mock.calls[0][0].title).toEqual( + mockDataStatLogcollectorTitle, + ); + expect(AgentStatTableMock.mock.calls[1][0].title).toEqual( + mockDataStatAgentTitle, + ); + + AgentStatTableMock.mockClear(); + + await act(async () => { + rerender(); + }); + + expect(AgentStatTableMock.mock.calls[0][0].title).toEqual( + mockDataStatLogcollectorTitle, + ); + expect(AgentStatTableMock.mock.calls[1][0].title).toEqual( + mockDataStatAgentTitle, + ); + }); + + it('should update export csv filename correctly when changing agent', async () => { + AgentStatTableMock.mockClear(); + + const mockExportCSVFilename = ( + agent000: string, + suffix: 'global' | 'interval', + ) => `agent-stats-${agent000}-logcollector-${suffix}`; + + let rerender: RenderResult['rerender']; + + await act(async () => { + ({ rerender } = render()); + }); + + expect(AgentStatTableMock.mock.calls[0][0].exportCSVFilename).toEqual( + mockExportCSVFilename(agent000, 'global'), + ); + expect(AgentStatTableMock.mock.calls[1][0].exportCSVFilename).toEqual( + mockExportCSVFilename(agent000, 'interval'), + ); + + AgentStatTableMock.mockClear(); + + await act(async () => { + rerender(); + }); + + expect(AgentStatTableMock.mock.calls[0][0].exportCSVFilename).toEqual( + mockExportCSVFilename(agent001, 'global'), + ); + expect(AgentStatTableMock.mock.calls[1][0].exportCSVFilename).toEqual( + mockExportCSVFilename(agent001, 'interval'), + ); + }); }); diff --git a/plugins/main/public/components/agents/stats/agent-stats.tsx b/plugins/main/public/components/agents/stats/agent-stats.tsx index 98f966281c..86269a7fae 100644 --- a/plugins/main/public/components/agents/stats/agent-stats.tsx +++ b/plugins/main/public/components/agents/stats/agent-stats.tsx @@ -13,14 +13,10 @@ import React, { useState, useEffect } from 'react'; import { EuiFlexGroup, EuiFlexItem, - EuiLoadingSpinner, - EuiPanel, EuiPage, EuiPageBody, EuiSpacer, - EuiText, } from '@elastic/eui'; - import { withGlobalBreadcrumb, withGuard, @@ -48,6 +44,8 @@ import { getErrorOrchestrator } from '../../../react-services/common-services'; import { endpointSummary } from '../../../utils/applications'; import NavigationService from '../../../react-services/navigation-service'; import WzRibbon from '../../common/ribbon/ribbon'; +import { Agent } from '../../endpoints-summary/types'; +import { SECTIONS } from '../../../sections'; const tableColumns = [ { @@ -67,40 +65,43 @@ const tableColumns = [ }, ]; -const statsAgents: { title: string; field: string; render?: (value) => any }[] = - [ - { - title: 'Status', - field: 'status', - }, - { - title: 'Buffer', - field: 'buffer_enabled', - render: value => (value ? 'enabled' : 'disabled'), - }, - { - title: 'Message buffer', - field: 'msg_buffer', - }, - { - title: 'Messages count', - field: 'msg_count', - }, - { - title: 'Messages sent', - field: 'msg_sent', - }, - { - title: 'Last ack', - field: 'last_ack', - render: formatUIDate, - }, - { - title: 'Last keep alive', - field: 'last_keepalive', - render: formatUIDate, - }, - ]; +const statsAgents: { + title: string; + field: string; + render?: (value: any) => any; +}[] = [ + { + title: 'Status', + field: 'status', + }, + { + title: 'Buffer', + field: 'buffer_enabled', + render: value => (value ? 'enabled' : 'disabled'), + }, + { + title: 'Message buffer', + field: 'msg_buffer', + }, + { + title: 'Messages count', + field: 'msg_count', + }, + { + title: 'Messages sent', + field: 'msg_sent', + }, + { + title: 'Last ack', + field: 'last_ack', + render: formatUIDate, + }, + { + title: 'Last keep alive', + field: 'last_keepalive', + render: formatUIDate, + }, +]; export const MainAgentStats = compose( withErrorBoundary, @@ -108,7 +109,7 @@ export const MainAgentStats = compose( { text: endpointSummary.breadcrumbLabel, href: NavigationService.getInstance().getUrlForApp(endpointSummary.id, { - path: `#/agents-preview`, + path: `#/${SECTIONS.AGENTS_PREVIEW}`, }), }, { agent }, @@ -143,9 +144,13 @@ export const MainAgentStats = compose( ), )(AgentStats); -export function AgentStats(props) { +interface AgentStatsProps { + agent: Agent; +} + +export function AgentStats(props: AgentStatsProps) { const { agent } = props; - const [loading, setLoading] = useState(); + const [loading, setLoading] = useState(false); const [dataStatLogcollector, setDataStatLogcollector] = useState({}); const [dataStatAgent, setDataStatAgent] = useState(); useEffect(() => { @@ -175,8 +180,8 @@ export function AgentStats(props) { severity: UI_ERROR_SEVERITIES.BUSINESS as UIErrorSeverity, error: { error: error, - message: error.message || error, - title: error.name || error, + message: (error as Error).message || (error as string), + title: (error as Error).name || (error as string), }, }; getErrorOrchestrator().handleError(options); @@ -184,7 +189,7 @@ export function AgentStats(props) { setLoading(false); } })(); - }, []); + }, [agent.id]); return ( diff --git a/plugins/main/public/components/agents/syscollector/components/check-endpoint-for-given-agent-id.tsx b/plugins/main/public/components/agents/syscollector/components/check-endpoint-for-given-agent-id.tsx new file mode 100644 index 0000000000..b67c724ec1 --- /dev/null +++ b/plugins/main/public/components/agents/syscollector/components/check-endpoint-for-given-agent-id.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +const AGENT_000 = '000'; +const AGENT_001 = '001'; + +export function shouldRenderTableWithCorrectEndpointForAgent( + mocked: jest.Mock, + Component: React.ComponentType<{ + agent: { id: string }; + soPlatform?: string; + }>, + endpoint: string, + soPlatform: string = 'linux', +) { + const { rerender } = render( + , + ); + + expect(mocked.mock.calls[0][0].endpoint).toContain( + `/syscollector/${AGENT_000}/${endpoint}`, + ); + + mocked.mockClear(); + + rerender(); + + expect(mocked.mock.calls[0][0].endpoint).toContain( + `/syscollector/${AGENT_001}/${endpoint}`, + ); +} + +export function shouldFetchDataForGivenAgentId( + mocked: jest.Mock, + Component: React.ComponentType<{ + agent: { id: string }; + soPlatform?: string; + }>, + endpoint: string, + soPlatform: string = 'linux', +) { + const { rerender } = render( + , + ); + + expect(mocked.mock.calls[0][0]).toEqual('GET'); + expect(mocked.mock.calls[0][1]).toEqual( + `/syscollector/${AGENT_000}/${endpoint}`, + ); + + mocked.mockClear(); + + rerender(); + + expect(mocked.mock.calls[0][0]).toEqual('GET'); + expect(mocked.mock.calls[0][1]).toEqual( + `/syscollector/${AGENT_001}/${endpoint}`, + ); +} diff --git a/plugins/main/public/components/agents/syscollector/components/network-interfaces-table.test.tsx b/plugins/main/public/components/agents/syscollector/components/network-interfaces-table.test.tsx new file mode 100644 index 0000000000..f3f008703d --- /dev/null +++ b/plugins/main/public/components/agents/syscollector/components/network-interfaces-table.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { TableWzAPI } from '../../../common/tables'; +import { WzRequest } from '../../../../react-services'; +import { NetworkInterfacesTable } from './network-interfaces-table'; +import { + shouldFetchDataForGivenAgentId, + shouldRenderTableWithCorrectEndpointForAgent, +} from './check-endpoint-for-given-agent-id'; + +let TableWzAPIMock = TableWzAPI as unknown as jest.Mock; +let apiReqMock = WzRequest.apiReq as jest.Mock; + +jest.mock('../../../common/tables', () => ({ + TableWzAPI: jest.fn(({ searchBarWQL }) => { + searchBarWQL.suggestions.value(undefined, { field: 'hotfix' }); + return <>; + }), +})); + +jest.mock('../../../../react-services', () => ({ + WzRequest: { + apiReq: jest.fn().mockResolvedValue({ + data: { + data: { + affected_items: [], + }, + }, + }), + }, +})); + +describe('NetworkInterfacesTable', () => { + it('should render table with correct netiface endpoint for agent either when changing agent or not', () => { + shouldRenderTableWithCorrectEndpointForAgent( + TableWzAPIMock, + NetworkInterfacesTable, + 'netiface', + ); + }); + + it('should fetch netiface data for given agent id either when changing agent or not', () => { + shouldFetchDataForGivenAgentId( + apiReqMock, + NetworkInterfacesTable, + 'netiface', + ); + }); +}); diff --git a/plugins/main/public/components/agents/syscollector/components/network-ports-table.test.tsx b/plugins/main/public/components/agents/syscollector/components/network-ports-table.test.tsx new file mode 100644 index 0000000000..c7cdac4eb6 --- /dev/null +++ b/plugins/main/public/components/agents/syscollector/components/network-ports-table.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { TableWzAPI } from '../../../common/tables'; +import { WzRequest } from '../../../../react-services'; +import { NetworkPortsTable } from './network-ports-table'; +import { + shouldFetchDataForGivenAgentId, + shouldRenderTableWithCorrectEndpointForAgent, +} from './check-endpoint-for-given-agent-id'; + +let TableWzAPIMock = TableWzAPI as unknown as jest.Mock; +let apiReqMock = WzRequest.apiReq as jest.Mock; + +jest.mock('../../../common/tables', () => ({ + TableWzAPI: jest.fn(({ searchBarWQL }) => { + searchBarWQL.suggestions.value(undefined, { field: 'hotfix' }); + return <>; + }), +})); + +jest.mock('../../../../react-services', () => ({ + WzRequest: { + apiReq: jest.fn().mockResolvedValue({ + data: { + data: { + affected_items: [], + }, + }, + }), + }, +})); + +jest.mock('./with-so-platform-guard', () => ({ + withSOPlatformGuard: jest.fn(Component => Component), +})); + +describe('NetworkPortsTable', () => { + it('should render table with correct ports endpoint for agent either when changing agent or not', () => { + shouldRenderTableWithCorrectEndpointForAgent( + TableWzAPIMock, + NetworkPortsTable, + 'ports', + ); + }); + + it('should fetch ports data for given agent id either when changing agent or not', () => { + shouldFetchDataForGivenAgentId(apiReqMock, NetworkPortsTable, 'ports'); + }); +}); diff --git a/plugins/main/public/components/agents/syscollector/components/network-settings-table.test.tsx b/plugins/main/public/components/agents/syscollector/components/network-settings-table.test.tsx new file mode 100644 index 0000000000..68496ded70 --- /dev/null +++ b/plugins/main/public/components/agents/syscollector/components/network-settings-table.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { TableWzAPI } from '../../../common/tables'; +import { WzRequest } from '../../../../react-services'; +import { NetworkSettingsTable } from './network-settings-table'; +import { + shouldFetchDataForGivenAgentId, + shouldRenderTableWithCorrectEndpointForAgent, +} from './check-endpoint-for-given-agent-id'; + +let TableWzAPIMock = TableWzAPI as unknown as jest.Mock; +let apiReqMock = WzRequest.apiReq as jest.Mock; + +jest.mock('../../../common/tables', () => ({ + TableWzAPI: jest.fn(({ searchBarWQL }) => { + searchBarWQL.suggestions.value(undefined, { field: 'hotfix' }); + return <>; + }), +})); + +jest.mock('../../../../react-services', () => ({ + WzRequest: { + apiReq: jest.fn().mockResolvedValue({ + data: { + data: { + affected_items: [], + }, + }, + }), + }, +})); + +describe('NetworkSettingsTable', () => { + it('should render table with correct netaddr endpoint for agent either when changing agent or not', () => { + shouldRenderTableWithCorrectEndpointForAgent( + TableWzAPIMock, + NetworkSettingsTable, + 'netaddr', + ); + }); + + it('should fetch netaddr data for given agent id either when changing agent or not', () => { + shouldFetchDataForGivenAgentId(apiReqMock, NetworkSettingsTable, 'netaddr'); + }); +}); diff --git a/plugins/main/public/components/agents/syscollector/components/packages-table.test.tsx b/plugins/main/public/components/agents/syscollector/components/packages-table.test.tsx new file mode 100644 index 0000000000..155845b12f --- /dev/null +++ b/plugins/main/public/components/agents/syscollector/components/packages-table.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { PackagesTable } from './packages-table'; +import { TableWzAPI } from '../../../common/tables'; +import { WzRequest } from '../../../../react-services'; +import { + shouldFetchDataForGivenAgentId, + shouldRenderTableWithCorrectEndpointForAgent, +} from './check-endpoint-for-given-agent-id'; + +let TableWzAPIMock = TableWzAPI as unknown as jest.Mock; +let apiReqMock = WzRequest.apiReq as jest.Mock; + +jest.mock('../../../common/tables', () => ({ + TableWzAPI: jest.fn(({ searchBarWQL }) => { + searchBarWQL.suggestions.value(undefined, { field: 'hotfix' }); + return <>; + }), +})); + +jest.mock('../../../../react-services', () => ({ + WzRequest: { + apiReq: jest.fn().mockResolvedValue({ + data: { + data: { + affected_items: [], + }, + }, + }), + }, +})); + +jest.mock('./with-so-platform-guard', () => ({ + withSOPlatformGuard: jest.fn(Component => Component), +})); + +describe('PackagesTable', () => { + it('should render table with correct packages endpoint for agent either when changing agent or not', () => { + shouldRenderTableWithCorrectEndpointForAgent( + TableWzAPIMock, + PackagesTable, + 'packages', + ); + }); + + it('should fetch packages data for given agent id either when changing agent or not', () => { + shouldFetchDataForGivenAgentId(apiReqMock, PackagesTable, 'packages'); + }); +}); diff --git a/plugins/main/public/components/agents/syscollector/components/processes-table.test.tsx b/plugins/main/public/components/agents/syscollector/components/processes-table.test.tsx new file mode 100644 index 0000000000..259f0609a3 --- /dev/null +++ b/plugins/main/public/components/agents/syscollector/components/processes-table.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { TableWzAPI } from '../../../common/tables'; +import { WzRequest } from '../../../../react-services'; +import { ProcessesTable } from './processes-table'; +import { + shouldFetchDataForGivenAgentId, + shouldRenderTableWithCorrectEndpointForAgent, +} from './check-endpoint-for-given-agent-id'; + +let TableWzAPIMock = TableWzAPI as unknown as jest.Mock; +let apiReqMock = WzRequest.apiReq as jest.Mock; + +jest.mock('../../../common/tables', () => ({ + TableWzAPI: jest.fn(({ searchBarWQL }) => { + searchBarWQL.suggestions.value(undefined, { field: 'hotfix' }); + return <>; + }), +})); + +jest.mock('../../../../react-services', () => ({ + WzRequest: { + apiReq: jest.fn().mockResolvedValue({ + data: { + data: { + affected_items: [], + }, + }, + }), + }, +})); + +jest.mock('./with-so-platform-guard', () => ({ + withSOPlatformGuard: jest.fn(Component => Component), +})); + +describe('ProcessesTable', () => { + it('should render table with correct processes endpoint for agent either when changing agent or not', () => { + shouldRenderTableWithCorrectEndpointForAgent( + TableWzAPIMock, + ProcessesTable, + 'processes', + ); + }); + + it('should fetch processes data for given agent id either when changing agent or not', () => { + shouldFetchDataForGivenAgentId(apiReqMock, ProcessesTable, 'processes'); + }); +}); diff --git a/plugins/main/public/components/agents/syscollector/components/syscollector-metrics.test.tsx b/plugins/main/public/components/agents/syscollector/components/syscollector-metrics.test.tsx index a1d42d874b..cd344068b8 100644 --- a/plugins/main/public/components/agents/syscollector/components/syscollector-metrics.test.tsx +++ b/plugins/main/public/components/agents/syscollector/components/syscollector-metrics.test.tsx @@ -3,6 +3,12 @@ import { render } from '@testing-library/react'; import { InventoryMetrics } from './syscollector-metrics'; import { queryDataTestAttr } from '../../../../../test/public/query-attr'; import { CSS } from '../../../../../test/utils/CSS'; +import { useGenericRequest } from '../../../common/hooks/useGenericRequest'; + +const AGENT_000 = '000'; +const AGENT_001 = '001'; + +let useGenericRequestMock = useGenericRequest as jest.Mock; jest.mock('../../../../react-services/time-service', () => ({ formatUIDate: jest.fn().mockReturnValue('2022-06-27T16:09:49+00:00'), @@ -33,7 +39,10 @@ jest.mock('../../../common/hooks/useGenericRequest', () => ({ describe('Syscollector metrics', () => { it('should render inventory metrics', () => { - const { container } = render(); + const { container } = render( + // @ts-expect-error + , + ); const inventoryMetrics = container.querySelector( queryDataTestAttr('syscollector-metrics'), @@ -43,7 +52,10 @@ describe('Syscollector metrics', () => { }); it('should render syscollector ribbon items', () => { - const { container } = render(); + const { container } = render( + // @ts-expect-error + , + ); expect( container.querySelector(queryDataTestAttr('ribbon-item-cores')), @@ -85,4 +97,24 @@ describe('Syscollector metrics', () => { ), ).toHaveLength(8); }); + + it('should fetch syscollector data for given agent id either when changing agent or not', () => { + // @ts-expect-error + let { rerender } = render(); + + expect(useGenericRequestMock.mock.calls[0][0].method).toEqual('GET'); + expect(useGenericRequestMock.mock.calls[0][0].path).toEqual( + `/api/syscollector/${AGENT_000}`, + ); + + useGenericRequestMock.mockClear(); + + // @ts-expect-error + rerender(); + + expect(useGenericRequestMock.mock.calls[0][0].method).toEqual('GET'); + expect(useGenericRequestMock.mock.calls[0][0].path).toEqual( + `/api/syscollector/${AGENT_001}`, + ); + }); }); diff --git a/plugins/main/public/components/agents/syscollector/components/syscollector-metrics.tsx b/plugins/main/public/components/agents/syscollector/components/syscollector-metrics.tsx index 682e29585b..52395504e6 100644 --- a/plugins/main/public/components/agents/syscollector/components/syscollector-metrics.tsx +++ b/plugins/main/public/components/agents/syscollector/components/syscollector-metrics.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { EuiPanel, EuiIcon } from '@elastic/eui'; import _ from 'lodash'; import { useGenericRequest } from '../../../common/hooks/useGenericRequest'; @@ -8,24 +8,26 @@ import { IRibbonItem, RibbonItemLabel, } from '../../../common/ribbon/ribbon-item'; +import { Agent } from '../../../endpoints-summary/types'; -export function InventoryMetrics({ agent }) { - const [params, setParams] = useState({}); - const offsetTimestamp = (text, time) => { - try { - return text + formatUIDate(time); - } catch (error) { - return time !== '-' ? `${text}${time} (UTC)` : time; - } - }; - const syscollector = useGenericRequest( - 'GET', - `/api/syscollector/${agent.id}`, - params, - result => { - return (result || {}).data || {}; - }, - ); +interface SyscollectorMetricsProps { + agent: Agent; +} + +const offsetTimestamp = (text: string, time: string) => { + try { + return text + formatUIDate(time); + } catch (error) { + return time !== '-' ? `${text}${time} (UTC)` : time; + } +}; + +export function InventoryMetrics({ agent }: SyscollectorMetricsProps) { + const syscollector = useGenericRequest({ + method: 'GET', + path: `/api/syscollector/${agent.id}`, + resolveData: data => data?.data || {}, + }); if ( !syscollector.isLoading && diff --git a/plugins/main/public/components/agents/syscollector/components/windows-updates-table.test.tsx b/plugins/main/public/components/agents/syscollector/components/windows-updates-table.test.tsx new file mode 100644 index 0000000000..813e2edcb1 --- /dev/null +++ b/plugins/main/public/components/agents/syscollector/components/windows-updates-table.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { WindowsUpdatesTable } from './windows-updates-table'; +import { TableWzAPI } from '../../../common/tables'; +import { WzRequest } from '../../../../react-services'; +import { + shouldFetchDataForGivenAgentId, + shouldRenderTableWithCorrectEndpointForAgent, +} from './check-endpoint-for-given-agent-id'; + +let TableWzAPIMock = TableWzAPI as unknown as jest.Mock; +let apiReqMock = WzRequest.apiReq as jest.Mock; + +jest.mock('../../../common/tables', () => ({ + TableWzAPI: jest.fn(({ searchBarWQL }) => { + searchBarWQL.suggestions.value(undefined, { field: 'hotfix' }); + return <>; + }), +})); + +jest.mock('../../../../react-services', () => ({ + WzRequest: { + apiReq: jest.fn().mockResolvedValue({ + data: { + data: { + affected_items: [], + }, + }, + }), + }, +})); + +describe('WindowsUpdatesTable', () => { + it('should render table with correct hotfixes endpoint for agent either when changing agent or not', () => { + shouldRenderTableWithCorrectEndpointForAgent( + TableWzAPIMock, + WindowsUpdatesTable, + 'hotfixes', + ); + }); + + it('should fetch hotfixes data for given agent id either when changing agent or not', () => { + shouldFetchDataForGivenAgentId(apiReqMock, WindowsUpdatesTable, 'hotfixes'); + }); +}); diff --git a/plugins/main/public/components/agents/syscollector/inventory.test.tsx b/plugins/main/public/components/agents/syscollector/inventory.test.tsx index b39238c7cc..9997767955 100644 --- a/plugins/main/public/components/agents/syscollector/inventory.test.tsx +++ b/plugins/main/public/components/agents/syscollector/inventory.test.tsx @@ -3,6 +3,7 @@ import { render } from 'enzyme'; import { SyscollectorInventory } from './inventory'; import { AgentTabs } from '../../endpoints-summary/agent/agent-tabs'; import { queryDataTestAttr } from '../../../../test/public/query-attr'; +import { AGENT } from '../../../../test/__mocks__/agent'; const TABLE_ID = '__table_7d62db31-1cd0-11ee-8e0c-33242698a3b9'; const SOFTWARE_PACKAGES = 'Packages'; @@ -12,25 +13,6 @@ const NETWORK_INTERFACES = 'Network interfaces'; const NETWORK_SETTINGS = 'Network settings'; const PROCESSES = 'Processes'; -const AGENT = { - DEBIAN: { - os: { - uname: - 'Linux |ip-10-0-1-106 |4.9.0-9-amd64 |1 SMP Debian 4.9.168-1+deb9u2 (2019-05-13) |x86_64', - }, - }, - WINDOWS: { - os: { - platform: 'windows', - }, - }, - DARWIN: { - os: { - platform: 'darwin', - }, - }, -} as const; - const NETWORK_PORTS_COLUMNS = { LOCAL_PORT: 'Local port', LOCAL_IP: 'Local IP address', diff --git a/plugins/main/public/components/agents/syscollector/software.test.tsx b/plugins/main/public/components/agents/syscollector/software.test.tsx new file mode 100644 index 0000000000..ed5183e733 --- /dev/null +++ b/plugins/main/public/components/agents/syscollector/software.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { WindowsUpdatesTable } from './components'; +import SoftwareTab from './software'; + +let WindowsUpdatesTableMock = WindowsUpdatesTable as jest.Mock; + +jest.mock('./components', () => ({ + WindowsUpdatesTable: jest.fn(() => <>), + PackagesTable: jest.fn(() => <>), +})); + +describe('Software', () => { + it('should render WindowsUpdatesTable when platform is windows', () => { + render(); + + expect(WindowsUpdatesTableMock).toHaveBeenCalled(); + }); +}); diff --git a/plugins/main/public/components/common/buttons/button.tsx b/plugins/main/public/components/common/buttons/button.tsx index 46c78f577c..754d616065 100644 --- a/plugins/main/public/components/common/buttons/button.tsx +++ b/plugins/main/public/components/common/buttons/button.tsx @@ -12,11 +12,15 @@ import React from 'react'; import { EuiButton, + EuiButtonProps, EuiButtonEmpty, EuiButtonIcon, EuiLink, EuiToolTip, EuiSwitch, + EuiButtonEmptyProps, + EuiButtonIconProps, + EuiSwitchProps, } from '@elastic/eui'; export enum WzButtonType { @@ -27,14 +31,20 @@ export enum WzButtonType { switch = 'switch', } -interface WzButtonProps { - buttonType?: WzButtonType; - tooltip?: any; - color?: any; - children?: any; - isDisabled?: any; - rest?: any; -} +type WzButtonProps = + | { + buttonType?: WzButtonType; + tooltip?: any; + color?: string; + children?: any; + isDisabled?: any; + className?: string; + rest?: any; + } + | Partial + | Partial + | Partial + | Partial; const WzButtons: { [key in WzButtonType]: React.FunctionComponent } = { default: EuiButton, diff --git a/plugins/main/public/components/common/hocs/withGlobalBreadcrumb.tsx b/plugins/main/public/components/common/hocs/withGlobalBreadcrumb.tsx index 07a2463238..f40a247d95 100644 --- a/plugins/main/public/components/common/hocs/withGlobalBreadcrumb.tsx +++ b/plugins/main/public/components/common/hocs/withGlobalBreadcrumb.tsx @@ -9,15 +9,25 @@ * * Find more information about this on the LICENSE file. */ -import React, { useEffect } from 'react'; -import { useGlobalBreadcrumb } from '../hooks/useGlobalBreadcrumb'; +import React from 'react'; +import { Breadcrumb, useGlobalBreadcrumb } from '../hooks/useGlobalBreadcrumb'; -type TBreadcrumbSection = {text: string, href?: string} | { agent: any }; -type TBreadcrumb = TBreadcrumbSection[]; -type TBreadcrumbParameter = TBreadcrumb | ((props: any) => TBreadcrumb); +type BreadcrumbParam = Breadcrumb | ((props: any) => Breadcrumb); // It returns user permissions -export const withGlobalBreadcrumb = (breadcrumb : TBreadcrumbParameter) => WrappedComponent => props => { - useGlobalBreadcrumb(typeof breadcrumb === 'function' ? breadcrumb(props) : breadcrumb); - return -} +export const withGlobalBreadcrumb = + (breadcrumbParam: BreadcrumbParam) => + (WrappedComponent: React.JSX.Element) => + (props: any) => { + const getBreadcrumb = () => { + if (typeof breadcrumbParam === 'function') { + return breadcrumbParam(props); + } + return breadcrumbParam; + }; + + useGlobalBreadcrumb(getBreadcrumb()); + + // @ts-expect-error + return ; + }; diff --git a/plugins/main/public/components/common/hooks/useGenericRequest.ts b/plugins/main/public/components/common/hooks/useGenericRequest.ts index d8fd733cff..f6119b061f 100644 --- a/plugins/main/public/components/common/hooks/useGenericRequest.ts +++ b/plugins/main/public/components/common/hooks/useGenericRequest.ts @@ -9,36 +9,56 @@ import { UILogLevel, } from '../../../react-services/error-orchestrator/types'; -export function useGenericRequest(method, path, params, formatFunction) { - const [items, setItems] = useState({}); - const [isLoading, setisLoading] = useState(true); - const [error, setError] = useState(""); +interface UseGenericRequestProps { + method: string; + path: string; + params?: any; + resolveData?: (data: any) => any; +} + +export function useGenericRequest({ + method, + path, + params = {}, + resolveData = response => response, +}: UseGenericRequestProps) { + const [data, setData] = useState({}); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); - useEffect( () => { - try{ - setisLoading(true); - const fetchData = async() => { - const response = await GenericRequest.request(method, path, params); - setItems(response); - setisLoading(false); - } - fetchData(); - } catch(error) { - setError(error); - setisLoading(false); + useEffect(() => { + try { + setIsLoading(true); + const fetchData = async () => { + const response = await GenericRequest.request( + method, + path, + params, + ); + setData(resolveData(response)); + }; + fetchData(); + } catch (error) { + setError(error as Error); const options: UIErrorLog = { context: `${useGenericRequest.name}.fetchData`, level: UI_LOGGER_LEVELS.ERROR as UILogLevel, severity: UI_ERROR_SEVERITIES.UI as UIErrorSeverity, error: { error: error, - message: error.message || error, - title: error.name || error, + message: (error as Error).message || (error as string), + title: (error as Error).name || (error as string), }, }; getErrorOrchestrator().handleError(options); + } finally { + setIsLoading(false); } - }, [params]); + }, [JSON.stringify(params), path, method]); - return {isLoading, data: formatFunction(items), error}; + return { + isLoading, + data, + error, + }; } diff --git a/plugins/main/public/components/common/hooks/useGlobalBreadcrumb.ts b/plugins/main/public/components/common/hooks/useGlobalBreadcrumb.ts index 058c343c93..037ac8f393 100644 --- a/plugins/main/public/components/common/hooks/useGlobalBreadcrumb.ts +++ b/plugins/main/public/components/common/hooks/useGlobalBreadcrumb.ts @@ -11,15 +11,16 @@ */ import React, { useEffect } from 'react'; import { useDispatch } from 'react-redux'; -import { updateGlobalBreadcrumb } from '../../../redux/actions/globalBreadcrumbActions'; +import { updateGlobalBreadcrumb } from '../../../redux/actions/globalBreadcrumbActions'; +import { Agent } from '../../endpoints-summary/types'; -type TBreadcrumbSection = {text: string, href?: string} -type TBreadcrumb = TBreadcrumbSection[]; +export type BreadcrumbItem = { text: string; href?: string } | { agent: Agent }; +export type Breadcrumb = BreadcrumbItem[]; // It updates global breadcrumb -export const useGlobalBreadcrumb = (breadcrumb : TBreadcrumb = []) => { +export const useGlobalBreadcrumb = (breadcrumb: Breadcrumb = []) => { const dispatch = useDispatch(); useEffect(() => { - dispatch(updateGlobalBreadcrumb(breadcrumb)) + dispatch(updateGlobalBreadcrumb(breadcrumb)); }); -} +}; diff --git a/plugins/main/public/components/common/modules/main-agent.test.tsx b/plugins/main/public/components/common/modules/main-agent.test.tsx index 02ba68cd67..c685d8dec6 100644 --- a/plugins/main/public/components/common/modules/main-agent.test.tsx +++ b/plugins/main/public/components/common/modules/main-agent.test.tsx @@ -6,6 +6,7 @@ import { queryDataTestAttr } from '../../../../test/public/query-attr'; const GENERATE_REPORT_BUTTON = 'generate-report-button'; const ARIA_SELECTED = '[aria-selected="true"]'; +const EXPLORE_AGENT_BUTTON = 'explore-agent-button'; const REPORT_TAB = { STATS: 'agent-tab-stats', @@ -33,6 +34,19 @@ jest.mock('../data-source', () => ({ __esModule: true, })); +jest.mock('react-redux', () => ({ + connect: () => Component => Component, + __esModule: true, +})); + +jest.mock('../../../react-services/navigation-service', () => ({ + getInstance: () => ({ + getPathname: () => '', + getParams: () => new URLSearchParams(), + renewURL: jest.fn(), + }), +})); + describe('Main Agent', () => { let switchTab: jest.Mock; @@ -356,4 +370,81 @@ describe('Main Agent', () => { expect(generateReportButton).toBeTruthy(); }); }); + describe('Verify the presence of the "Pinned Agent" button', () => { + it('should render "Pinned Agent" button in tab software', () => { + const { container } = render( + , + ); + + const exploreAgentButton = container.querySelector( + queryDataTestAttr(EXPLORE_AGENT_BUTTON), + ); + + expect(exploreAgentButton).toBeTruthy(); + }); + it('should render "Pinned Agent" button in tab network', () => { + const { container } = render( + , + ); + + const exploreAgentButton = container.querySelector( + queryDataTestAttr(EXPLORE_AGENT_BUTTON), + ); + + expect(exploreAgentButton).toBeTruthy(); + }); + it('should render "Pinned Agent" button in tab processes', () => { + const { container } = render( + , + ); + + const exploreAgentButton = container.querySelector( + queryDataTestAttr(EXPLORE_AGENT_BUTTON), + ); + + expect(exploreAgentButton).toBeTruthy(); + }); + it('should render "Pinned Agent" button in tab stats', () => { + const { container } = render( + , + ); + + const exploreAgentButton = container.querySelector( + queryDataTestAttr(EXPLORE_AGENT_BUTTON), + ); + + expect(exploreAgentButton).toBeTruthy(); + }); + it('should render "Pinned Agent" button in tab configuration', () => { + const { container } = render( + , + ); + + const exploreAgentButton = container.querySelector( + queryDataTestAttr(EXPLORE_AGENT_BUTTON), + ); + + expect(exploreAgentButton).toBeTruthy(); + }); + }); }); diff --git a/plugins/main/public/components/common/modules/main-agent.tsx b/plugins/main/public/components/common/modules/main-agent.tsx index 74ebf831e0..960b179997 100644 --- a/plugins/main/public/components/common/modules/main-agent.tsx +++ b/plugins/main/public/components/common/modules/main-agent.tsx @@ -34,6 +34,7 @@ import { toTitleCase } from '../util/change-case'; import clsx from 'clsx'; import { AgentTabs } from '../../endpoints-summary/agent/agent-tabs'; import { Agent } from '../../endpoints-summary/types'; +import { ButtonExploreAgent } from '../../wz-agent-selector/button-explore-agent'; export class MainModuleAgent extends Component { props!: { @@ -44,6 +45,7 @@ export class MainModuleAgent extends Component { tabs?: any[]; renderTabs?: () => JSX.Element; agentsSelectionProps?: any; + unPinAgent?: () => void; }; inventoryTabs = [AgentTabs.SOFTWARE, AgentTabs.NETWORK, AgentTabs.PROCESSES]; @@ -52,7 +54,7 @@ export class MainModuleAgent extends Component { const { agent, section, switchTab } = this.props; return ( - + {this.inventoryTabs.includes(section) ? ( <> @@ -74,13 +76,20 @@ export class MainModuleAgent extends Component { )} - + + + {[AgentTabs.SOFTWARE, AgentTabs.NETWORK, AgentTabs.PROCESSES].includes( section, ) && ( diff --git a/plugins/main/public/components/common/welcome/agents-welcome.js b/plugins/main/public/components/common/welcome/agents-welcome.js index 61d41e9a2e..22b6085226 100644 --- a/plugins/main/public/components/common/welcome/agents-welcome.js +++ b/plugins/main/public/components/common/welcome/agents-welcome.js @@ -331,7 +331,7 @@ export const AgentsWelcome = compose( grow={false} style={{ marginTop: 7, marginRight: '0.5rem' }} > - + { + return [ + { + text: endpointSummary.breadcrumbLabel, + href: NavigationService.getInstance().getUrlForApp(endpointSummary.id, { + path: `#/${SECTIONS.AGENTS_PREVIEW}`, + }), + }, + ]; + }), withGuard( props => !(props.agent && props.agent.id), () => ( @@ -106,6 +117,11 @@ export const AgentView = compose( ); } + const unPinAgent = () => { + // remove the pinned agent from the URL + navigationService.navigate(`/agents?tab=${tab}`); + }; + return ( @@ -113,6 +129,7 @@ export const AgentView = compose( agent={agentData} section={tab} switchTab={switchTab} + unPinAgent={unPinAgent} /> @@ -121,6 +138,7 @@ export const AgentView = compose( agent={agentData} section={tab} switchTab={switchTab} + unPinAgent={unPinAgent} /> @@ -129,19 +147,24 @@ export const AgentView = compose( agent={agentData} section={tab} switchTab={switchTab} + unPinAgent={unPinAgent} /> - - - - - + - + @@ -149,7 +172,7 @@ export const AgentView = compose( switchTab={switchTab} agent={agentData} pinAgent={pinnedAgentManager.pinAgent} - unPinAgent={pinnedAgentManager.unPinAgent} + unPinAgent={unPinAgent} /> diff --git a/plugins/main/public/components/wz-agent-selector/button-explore-agent.scss b/plugins/main/public/components/wz-agent-selector/button-explore-agent.scss index a7a20f9695..6b70c7eb58 100644 --- a/plugins/main/public/components/wz-agent-selector/button-explore-agent.scss +++ b/plugins/main/public/components/wz-agent-selector/button-explore-agent.scss @@ -1,9 +1,8 @@ -.wz-explore-agent .euiButtonEmpty:focus { - background-color: transparent; +.wz-unpin-agent-bg { + background-color: rgba(0, 107, 180, 0.1); } .wz-unpin-agent { - background-color: rgba(0, 107, 180, 0.1); border-radius: 0; height: 40px; } diff --git a/plugins/main/public/components/wz-agent-selector/button-explore-agent.tsx b/plugins/main/public/components/wz-agent-selector/button-explore-agent.tsx index bdfb3a66fc..d0574d1ed6 100644 --- a/plugins/main/public/components/wz-agent-selector/button-explore-agent.tsx +++ b/plugins/main/public/components/wz-agent-selector/button-explore-agent.tsx @@ -5,8 +5,21 @@ import { WzButtonType } from '../common/buttons/button'; import { connect } from 'react-redux'; import { showExploreAgentModalGlobal } from '../../redux/actions/appStateActions'; import './button-explore-agent.scss'; +import clsx from 'clsx'; -const ButtonPinnedAgent = ({ showExploreAgentModalGlobal, module }) => { +interface ButtonPinnedAgentProps { + showExploreAgentModalGlobal: (shouldShow: boolean) => void; + module?: { + availableFor?: string[]; + }; + onUnpinAgent?: () => void; +} + +const ButtonPinnedAgent = ({ + showExploreAgentModalGlobal, + module, + onUnpinAgent, +}: ButtonPinnedAgentProps) => { const pinnedAgentManager = new PinnedAgentManager(); const agent = pinnedAgentManager.isPinnedAgent() ? pinnedAgentManager.getPinnedAgent() @@ -14,41 +27,46 @@ const ButtonPinnedAgent = ({ showExploreAgentModalGlobal, module }) => { const avaliableForAgent = module ? module?.availableFor && module?.availableFor.includes('agent') : true; + + const unPinAgentHandler = () => { + pinnedAgentManager.unPinAgent(); + onUnpinAgent?.(); + }; + return ( - <> -
+
+ showExploreAgentModalGlobal(true)} + > + {agent ? `${agent.name} (${agent.id})` : 'Explore agent'} + + {agent ? ( showExploreAgentModalGlobal(true)} - > - {agent ? `${agent.name} (${agent.id})` : 'Explore agent'} - - {agent ? ( - { - pinnedAgentManager.unPinAgent(); - }} - tooltip={{ position: 'bottom', content: 'Unpin agent' }} - aria-label='Unpin agent' - /> - ) : null} -
- + buttonType={WzButtonType.icon} + className='wz-unpin-agent wz-unpin-agent-bg' + iconType='pinFilled' + onClick={unPinAgentHandler} + tooltip={{ position: 'bottom', content: 'Unpin agent' }} + aria-label='Unpin agent' + /> + ) : null} +
); }; @@ -59,8 +77,8 @@ const mapStateToProps = state => { }; const mapDispatchToProps = dispatch => ({ - showExploreAgentModalGlobal: data => - dispatch(showExploreAgentModalGlobal(data)), + showExploreAgentModalGlobal: (shouldShow: boolean) => + dispatch(showExploreAgentModalGlobal(shouldShow)), }); export const ButtonExploreAgent = connect( diff --git a/plugins/main/public/controllers/management/components/management/configuration/configuration-main.js b/plugins/main/public/controllers/management/components/management/configuration/configuration-main.js index 6d71818717..52396f7efa 100644 --- a/plugins/main/public/controllers/management/components/management/configuration/configuration-main.js +++ b/plugins/main/public/controllers/management/components/management/configuration/configuration-main.js @@ -17,12 +17,13 @@ import { import { compose } from 'redux'; import { endpointSummary, settings } from '../../../../../utils/applications'; import NavigationService from '../../../../../react-services/navigation-service'; +import { SECTIONS } from '../../../../../sections'; export default compose( withErrorBoundary, - withGlobalBreadcrumb(props => { + withGlobalBreadcrumb(({ agent }) => { let breadcrumb = false; - if (props.agent?.id === '000') { + if (agent?.id === '000') { breadcrumb = [{ text: settings.breadcrumbLabel }]; } else { breadcrumb = [ @@ -31,15 +32,15 @@ export default compose( href: NavigationService.getInstance().getUrlForApp( endpointSummary.id, { - path: `#/agents-preview`, + path: `#/${SECTIONS.AGENTS_PREVIEW}`, }, ), }, - { agent: props.agent }, + { agent }, { text: 'Configuration' }, ]; } - $('#breadcrumbNoTitle').attr('title', ''); + document.querySelector('#breadcrumbNoTitle')?.setAttribute('title', ''); return breadcrumb; }), )(WzConfigurationSwitch); diff --git a/plugins/main/public/controllers/management/components/management/configuration/configuration-overview.js b/plugins/main/public/controllers/management/components/management/configuration/configuration-overview.js index 4426bd9f25..8c0d4fe8a1 100644 --- a/plugins/main/public/controllers/management/components/management/configuration/configuration-overview.js +++ b/plugins/main/public/controllers/management/components/management/configuration/configuration-overview.js @@ -12,12 +12,7 @@ import React, { Component, Fragment } from 'react'; -import { - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, -} from '@elastic/eui'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import WzConfigurationOverviewTable from './util-components/configuration-overview-table'; import WzHelpButtonPopover from './util-components/help-button-popover'; diff --git a/plugins/main/public/controllers/management/components/management/configuration/configuration-switch.js b/plugins/main/public/controllers/management/components/management/configuration/configuration-switch.js index 3aaf1139e7..efd6316178 100644 --- a/plugins/main/public/controllers/management/components/management/configuration/configuration-switch.js +++ b/plugins/main/public/controllers/management/components/management/configuration/configuration-switch.js @@ -53,7 +53,7 @@ import WzRefreshClusterInfoButton from './util-components/refresh-cluster-info-b import { withUserAuthorizationPrompt } from '../../../../../components/common/hocs'; import { - clusterNodes, + clusterNodes as requestClusterNodes, clusterReq, agentIsSynchronized, } from './utils/wz-fetch'; @@ -99,103 +99,120 @@ class WzConfigurationSwitch extends Component { loadingOverview: this.props.agent.id === '000', }; } + componentWillUnmount() { - this.props.updateClusterNodes(false); - this.props.updateClusterNodeSelected(false); + this.resetClusterState(); } + updateConfigurationSection = (view, title, description) => { this.setState({ view, viewProps: { title: title, description } }); }; + updateBadge = badgeStatus => { // default value false? this.setState({ viewProps: { ...this.state.viewProps, badge: badgeStatus }, }); }; - async componentDidMount() { + + catchError = (error, context) => { + const options = { + context: `${WzConfigurationSwitch.name}.${context}`, + level: UI_LOGGER_LEVELS.ERROR, + severity: UI_ERROR_SEVERITIES.BUSINESS, + error: { + error: error, + message: error.message || error, + title: error.name || error, + }, + }; + getErrorOrchestrator().handleError(options); + }; + + updateAgentSynchronization = async (/** @type {string} */ context) => { // If agent, check if is synchronized or not - if (this.props.agent.id !== '000') { - try { - const agentSynchronized = await agentIsSynchronized(this.props.agent); - this.setState({ agentSynchronized }); - } catch (error) { - const options = { - context: `${WzConfigurationSwitch.name}.componentDidMount`, - level: UI_LOGGER_LEVELS.ERROR, - severity: UI_ERROR_SEVERITIES.BUSINESS, - error: { - error: error, - message: error.message || error, - title: error.name || error, - }, - }; - getErrorOrchestrator().handleError(options); - } - } else { - try { - // try if it is a cluster - const clusterStatus = await clusterReq(); - const isCluster = - clusterStatus.data.data.enabled === 'yes' && - clusterStatus.data.data.running === 'yes'; - if (isCluster) { - const nodes = await clusterNodes(); - // set cluster nodes in Redux Store - this.props.updateClusterNodes(nodes.data.data.affected_items); - // set cluster node selected in Redux Store - this.props.updateClusterNodeSelected( - nodes.data.data.affected_items.find(node => node.type === 'master') - .name, - ); - } else { - // do nothing if it isn't a cluster - this.props.updateClusterNodes(false); - this.props.updateClusterNodeSelected(false); - } - } catch (error) { + try { + const agentSynchronized = await agentIsSynchronized(this.props.agent); + this.setState({ agentSynchronized }); + } catch (error) { + this.catchError(error, context); + } + }; + + handleClusterNodes = async () => { + const nodes = await requestClusterNodes(); + const clusterNodes = nodes.data.data.affected_items; + + this.props.updateClusterNodes(clusterNodes); + const masterNode = clusterNodes.find(node => node.type === 'master'); + if (masterNode) { + this.props.updateClusterNodeSelected(masterNode.name); + } + }; + + resetClusterState = () => { + this.props.updateClusterNodes(false); + this.props.updateClusterNodeSelected(false); + }; + + updateClusterInformation = async (/** @type {string} */ context) => { + try { + // try if it is a cluster + const clusterStatus = await clusterReq(); + const isCluster = + clusterStatus.data.data.enabled === 'yes' && + clusterStatus.data.data.running === 'yes'; + + if (isCluster) { + await this.handleClusterNodes(); + } else { // do nothing if it isn't a cluster - this.props.updateClusterNodes(false); - this.props.updateClusterNodeSelected(false); - const options = { - context: `${WzConfigurationSwitch.name}.componentDidMount`, - level: UI_LOGGER_LEVELS.ERROR, - severity: UI_ERROR_SEVERITIES.BUSINESS, - error: { - error: error, - message: error.message || error, - title: error.name || error, - }, - }; - getErrorOrchestrator().handleError(options); - } - // If manager/cluster require agent platform info to filter sections in overview. It isn't coming from props for Management/Configuration - try { - this.setState({ loadingOverview: true }); - const masterNodeInfo = await WzRequest.apiReq( - 'GET', - '/agents?agents_list=000', - {}, - ); - this.setState({ - masterNodeInfo: masterNodeInfo.data.data.affected_items[0], - }); - this.setState({ loadingOverview: false }); - } catch (error) { - this.setState({ loadingOverview: false }); - const options = { - context: `${WzConfigurationSwitch.name}.componentDidMount`, - level: UI_LOGGER_LEVELS.ERROR, - severity: UI_ERROR_SEVERITIES.BUSINESS, - error: { - error: error, - message: error.message || error, - title: error.name || error, - }, - }; - getErrorOrchestrator().handleError(options); + this.resetClusterState(); } + } catch (error) { + // do nothing if it isn't a cluster + this.resetClusterState(); + this.catchError(error, context); } + }; + + fetchMasterNodeInfo = async (/** @type {string} */ context) => { + this.setState({ loadingOverview: true }); + + try { + const masterNodeInfo = await WzRequest.apiReq( + 'GET', + '/agents?agents_list=000', + {}, + ); + const masterNode = masterNodeInfo.data.data.affected_items[0]; + this.setState({ masterNodeInfo: masterNode }); + } catch (error) { + this.catchError(error, context); + } finally { + this.setState({ loadingOverview: false }); + } + }; + + handleAgentOrClusterUpdate = (/** @type {string} */ context) => { + if (this.props.agent.id !== '000') { + this.updateAgentSynchronization(context); + } else { + this.updateClusterInformation(context); + this.fetchMasterNodeInfo(); + } + }; + + async componentDidMount() { + this.handleAgentOrClusterUpdate('componentDidMount'); } + + async componentDidUpdate(prevProps) { + if (this.props.agent.id !== prevProps.agent.id) { + this.handleAgentOrClusterUpdate('componentDidUpdate'); + } + } + render() { const { view, @@ -208,7 +225,7 @@ class WzConfigurationSwitch extends Component { - {agent.id !== '000' && agent.group && agent.group.length ? ( + {agent.id !== '000' && agent.group?.length ? ( Groups: diff --git a/plugins/main/public/controllers/overview/components/overview-actions/agents-selector.scss b/plugins/main/public/controllers/overview/components/overview-actions/agents-selector.scss index 00ee2dd91f..77411a551d 100644 --- a/plugins/main/public/controllers/overview/components/overview-actions/agents-selector.scss +++ b/plugins/main/public/controllers/overview/components/overview-actions/agents-selector.scss @@ -32,17 +32,3 @@ background: #bd271e; color: white; } - -.wz-explore-agent .euiButtonEmpty:focus { - background-color: transparent; -} - -.wz-unpin-agent { - background-color: rgba(0, 107, 180, 0.1); - border-radius: 0; - height: 40px; -} - -.wz-unpin-agent:hover { - transform: translateY(0px) !important; -} diff --git a/plugins/main/public/react-services/common-services.ts b/plugins/main/public/react-services/common-services.ts index f57e8d0005..517ad46240 100644 --- a/plugins/main/public/react-services/common-services.ts +++ b/plugins/main/public/react-services/common-services.ts @@ -11,9 +11,8 @@ * Find more information about this on the LICENSE file. * */ -import { ErrorOrchestratorService } from './error-orchestrator/error-orchestrator.service'; +import { ErrorService } from './error-orchestrator/error-orchestrator.service'; import { createGetterSetter } from '../utils/create-getter-setter'; -export const [getErrorOrchestrator, setErrorOrchestrator] = createGetterSetter< - ErrorOrchestratorService ->('ErrorOrchestratorService'); +export const [getErrorOrchestrator, setErrorOrchestrator] = + createGetterSetter('ErrorOrchestratorService'); diff --git a/plugins/main/public/react-services/error-orchestrator/error-orchestrator.service.ts b/plugins/main/public/react-services/error-orchestrator/error-orchestrator.service.ts index 2fdf26f584..456e76fe4b 100644 --- a/plugins/main/public/react-services/error-orchestrator/error-orchestrator.service.ts +++ b/plugins/main/public/react-services/error-orchestrator/error-orchestrator.service.ts @@ -29,7 +29,13 @@ export class ErrorOrchestratorService { error, }: UIErrorLog) { const uiErrorLog = { context, level, severity, display, store, error }; - const errorOrchestrator: ErrorOrchestrator = errorOrchestratorFactory(uiErrorLog.severity); + const errorOrchestrator: ErrorOrchestrator = errorOrchestratorFactory( + uiErrorLog.severity, + ); errorOrchestrator.loadErrorLog(uiErrorLog); } } + +export type ErrorService = ErrorOrchestratorService & { + handleError: (uiErrorLog: UIErrorLog) => void; +}; diff --git a/plugins/main/public/react-services/generic-request.js b/plugins/main/public/react-services/generic-request.js index 320dde6936..5c97ba290a 100644 --- a/plugins/main/public/react-services/generic-request.js +++ b/plugins/main/public/react-services/generic-request.js @@ -20,6 +20,15 @@ import { request } from '../services/request-handler'; import NavigationService from './navigation-service'; export class GenericRequest { + /** + * Generic request + * @template T + * @param {string} method + * @param {string} path + * @param {any} payload + * @param {boolean} returnError + * @returns {Promise} + */ static async request(method, path, payload = null, returnError = false) { try { if (!method || !path) { diff --git a/plugins/main/test/__mocks__/agent.ts b/plugins/main/test/__mocks__/agent.ts new file mode 100644 index 0000000000..fa98a98978 --- /dev/null +++ b/plugins/main/test/__mocks__/agent.ts @@ -0,0 +1,78 @@ +import { Agent } from '../../public/components/endpoints-summary/types'; +import { DeepPartialRecordMock } from '../types'; + +export const AGENT = { + DEBIAN: { + os: { + arch: 'x86_64', + major: '9', + name: 'Debian GNU/Linux', + platform: 'debian', + uname: + 'Linux |ip-10-0-1-106 |4.9.0-9-amd64 |#1 SMP Debian 4.9.168-1+deb9u2 (2019-05-13) |x86_64', + version: '9', + }, + group: ['default', 'debian'], + ip: 'FE80:1234:2223:A000:2202:B3FF:FE1E:8329', + id: '001', + registerIP: 'FE80:1234:2223:A000:2202:B3FF:FE1E:8329', + dateAdd: new Date('2022-08-25T16:17:46Z'), + name: 'Debian agent', + status: 'active', + manager: 'wazuh-manager-master-0', + node_name: 'master', + lastKeepAlive: new Date('9999-12-31T23:59:59Z'), + version: 'Wazuh v4.5.0', + group_config_status: 'not synced', + status_code: 0, + }, + WINDOWS: { + os: { + major: '10', + minor: '0', + name: 'Microsoft Windows 10 Home Single Language', + platform: 'windows', + uname: 'Microsoft Windows 10 Home Single Language', + version: '10.0.19045', + }, + manager: 'test.com', + status: 'active', + name: 'Windows-agent', + dateAdd: new Date('1970-01-01T00:00:00Z'), + group: ['default', 'windows'], + lastKeepAlive: new Date('2023-03-14T04:20:51Z'), + node_name: 'node01', + registerIP: 'any', + id: '003', + version: 'Wazuh v4.3.10', + ip: '111.111.1.111', + mergedSum: 'e669d89eba52f6897060fc65a45300ac', + configSum: '97fccbb67e250b7c80aadc8d0dc59abe', + group_config_status: 'not synced', + status_code: 1, + }, + DARWIN: { + os: { + arch: 'x86_64', + major: '2', + name: 'macOS High Sierra', + platform: 'darwin', + uname: + 'macOS High Sierra |wazuh-manager-master-0 |4.14.114-105.126.amzn2.x86_64 |#1 SMP Tue May 7 02:26:40 UTC 2019 |x86_64', + version: '2', + }, + ip: '127.0.0.1', + id: '004', + group: ['default', 'darwin'], + registerIP: '127.0.0.1', + dateAdd: new Date('2022-08-25T16:17:46Z'), + name: 'macOS High Sierra agent', + status: 'active', + manager: 'wazuh-manager-master-0', + node_name: 'master', + lastKeepAlive: new Date('9999-12-31T23:59:59Z'), + version: 'Wazuh v4.5.0', + group_config_status: 'synced', + status_code: 3, + }, +} satisfies Record>; diff --git a/plugins/main/test/types/index.ts b/plugins/main/test/types/index.ts index f10772d5ea..7fcb77633c 100644 --- a/plugins/main/test/types/index.ts +++ b/plugins/main/test/types/index.ts @@ -5,10 +5,12 @@ export type RecordMock = { ? RecordMock : T[K]; }; -export type PartialRecordMock = Partial<{ +export type DeepPartialRecordMock = Partial<{ [K in keyof T]: T[K] extends Function ? jest.Mock + : T[K] extends Date + ? T[K] : T[K] extends {} - ? PartialRecordMock + ? DeepPartialRecordMock : T[K]; }>;