diff --git a/CHANGELOG.md b/CHANGELOG.md index b31ba95654..167f6ce2d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to the Wazuh app project will be documented in this file. - Added a migration task to setup the configuration using a configuration file [#6337](https://github.com/wazuh/wazuh-dashboard-plugins/pull/6337) - Added the ability to manage the API hosts from the Server APIs [#6337](https://github.com/wazuh/wazuh-dashboard-plugins/pull/6337) - Added edit groups action to Endpoints Summary [#6250](https://github.com/wazuh/wazuh-dashboard-plugins/pull/6250) +- Added upgrade agent action to Endpoints Summary [#6476](https://github.com/wazuh/wazuh-dashboard-plugins/pull/6476) - Added global actions add agents to groups and remove agents from groups to Endpoints Summary [#6274](https://github.com/wazuh/wazuh-dashboard-plugins/pull/6274) - Added propagation of updates from the table to dashboard visualizations in Endpoints summary [#6460](https://github.com/wazuh/wazuh-dashboard-plugins/pull/6460) diff --git a/docker/imposter/tasks/empty.json b/docker/imposter/tasks/empty.json new file mode 100644 index 0000000000..086c883d12 --- /dev/null +++ b/docker/imposter/tasks/empty.json @@ -0,0 +1,10 @@ +{ + "data": { + "affected_items": [], + "total_affected_items": 0, + "total_failed_items": 0, + "failed_items": [] + }, + "message": "All specified task's status were returned", + "error": 0 +} diff --git a/docker/imposter/tasks/status.js b/docker/imposter/tasks/status.js new file mode 100644 index 0000000000..1e588f285e --- /dev/null +++ b/docker/imposter/tasks/status.js @@ -0,0 +1,47 @@ +var storeWazuh = stores.open('storeWazuh'); +var attemptRestart = storeWazuh.load('attempt'); + +var taskStatus = context.request.queryParams.status; + +if (!taskStatus) { + respond().withStatusCode(200).withFile('tasks/status_in_progress_2.json'); +} + +if (attemptRestart < 5) { + storeWazuh.save('attempt', attemptRestart + 1); + + if (taskStatus === 'In progress') { + respond().withStatusCode(200).withFile('tasks/status_in_progress_2.json'); + } + + if (taskStatus === 'Done' || taskStatus === 'Failed') { + respond().withStatusCode(200).withFile('tasks/empty.json'); + } +} else if (attemptRestart < 10) { + storeWazuh.save('attempt', attemptRestart + 1); + + if (taskStatus === 'In progress') { + respond().withStatusCode(200).withFile('tasks/status_in_progress_1.json'); + } + + if (taskStatus === 'Done') { + respond().withStatusCode(200).withFile('tasks/status_done.json'); + } + + if (taskStatus === 'Failed') { + respond().withStatusCode(200).withFile('tasks/empty.json'); + } +} else { + if (taskStatus === 'In progress') { + respond().withStatusCode(200).withFile('tasks/empty.json'); + } + + if (taskStatus === 'Done') { + respond().withStatusCode(200).withFile('tasks/status_done.json'); + } + + if (taskStatus === 'Failed') { + storeWazuh.save('attempt', 0); + respond().withStatusCode(200).withFile('tasks/status_failed.json'); + } +} diff --git a/docker/imposter/tasks/status_done.json b/docker/imposter/tasks/status_done.json new file mode 100644 index 0000000000..129a9c7948 --- /dev/null +++ b/docker/imposter/tasks/status_done.json @@ -0,0 +1,22 @@ +{ + "data": { + "affected_items": [ + { + "message": "Success", + "agent": "001", + "task_id": 1, + "node": "worker2", + "module": "upgrade_module", + "command": "upgrade", + "status": "Done", + "create_time": "2024-03-11T11:55:33.000Z", + "last_update_time": "2020-03-11T12:05:10.000Z" + } + ], + "total_affected_items": 1, + "total_failed_items": 0, + "failed_items": [] + }, + "message": "All specified task's status were returned", + "error": 0 +} diff --git a/docker/imposter/tasks/status_failed.json b/docker/imposter/tasks/status_failed.json new file mode 100644 index 0000000000..f4fe48c9da --- /dev/null +++ b/docker/imposter/tasks/status_failed.json @@ -0,0 +1,22 @@ +{ + "data": { + "affected_items": [ + { + "message": "Success", + "agent": "002", + "task_id": 2, + "node": "worker2", + "module": "upgrade_module", + "command": "upgrade", + "status": "Failed", + "create_time": "2024-03-11T11:57:44.000Z", + "last_update_time": "2020-03-11T12:11:32.000Z" + } + ], + "total_affected_items": 1, + "total_failed_items": 0, + "failed_items": [] + }, + "message": "All specified task's status were returned", + "error": 0 +} diff --git a/docker/imposter/tasks/status_in_progress_1.json b/docker/imposter/tasks/status_in_progress_1.json new file mode 100644 index 0000000000..865614332d --- /dev/null +++ b/docker/imposter/tasks/status_in_progress_1.json @@ -0,0 +1,22 @@ +{ + "data": { + "affected_items": [ + { + "message": "Success", + "agent": "002", + "task_id": 2, + "node": "worker2", + "module": "upgrade_module", + "command": "upgrade", + "status": "In progress", + "create_time": "2024-03-11T11:57:44.000Z", + "last_update_time": "2020-03-11T11:57:46.000Z" + } + ], + "total_affected_items": 1, + "total_failed_items": 0, + "failed_items": [] + }, + "message": "All specified task's status were returned", + "error": 0 +} diff --git a/docker/imposter/tasks/status_in_progress_2.json b/docker/imposter/tasks/status_in_progress_2.json new file mode 100644 index 0000000000..5e70a840c1 --- /dev/null +++ b/docker/imposter/tasks/status_in_progress_2.json @@ -0,0 +1,33 @@ +{ + "data": { + "affected_items": [ + { + "message": "Success", + "agent": "001", + "task_id": 1, + "node": "worker2", + "module": "upgrade_module", + "command": "upgrade", + "status": "In progress", + "create_time": "2024-03-11T11:55:33.000Z", + "last_update_time": "2020-03-11T11:55:36.000Z" + }, + { + "message": "Success", + "agent": "002", + "task_id": 2, + "node": "worker2", + "module": "upgrade_module", + "command": "upgrade", + "status": "In progress", + "create_time": "2024-03-11T11:57:44.000Z", + "last_update_time": "2020-03-11T11:57:46.000Z" + } + ], + "total_affected_items": 2, + "total_failed_items": 0, + "failed_items": [] + }, + "message": "All specified task's status were returned", + "error": 0 +} diff --git a/docker/imposter/wazuh-config.yml b/docker/imposter/wazuh-config.yml index dd53ec3723..67a56da9ce 100755 --- a/docker/imposter/wazuh-config.yml +++ b/docker/imposter/wazuh-config.yml @@ -889,6 +889,9 @@ resources: # List tasks - method: GET path: /tasks/status + response: + statusCode: 200 + scriptFile: tasks/status.js # ===================================================== # # VULNERABILITY diff --git a/plugins/main/common/constants.ts b/plugins/main/common/constants.ts index fdd4e7631b..8e0b1c9c93 100644 --- a/plugins/main/common/constants.ts +++ b/plugins/main/common/constants.ts @@ -366,6 +366,18 @@ export const AGENT_STATUS_CODE = [ }, ]; +export const API_NAME_TASK_STATUS = { + DONE: 'Done', + IN_PROGRESS: 'In progress', + FAILED: 'Failed', +} as const; + +export const UI_TASK_STATUS = [ + API_NAME_TASK_STATUS.DONE, + API_NAME_TASK_STATUS.IN_PROGRESS, + API_NAME_TASK_STATUS.FAILED, +]; + // Documentation export const DOCUMENTATION_WEB_BASE_URL = 'https://documentation.wazuh.com'; diff --git a/plugins/main/public/components/common/tables/table-wz-api.tsx b/plugins/main/public/components/common/tables/table-wz-api.tsx index 42821434de..ff2b6f966f 100644 --- a/plugins/main/public/components/common/tables/table-wz-api.tsx +++ b/plugins/main/public/components/common/tables/table-wz-api.tsx @@ -44,6 +44,9 @@ const getFilters = filters => { export function TableWzAPI({ actionButtons, + addOnTitle, + extra, + setReload, ...rest }: { actionButtons?: @@ -53,6 +56,7 @@ export function TableWzAPI({ title?: string; addOnTitle?: ReactNode; description?: string; + extra?: ReactNode; downloadCsv?: boolean | string; searchTable?: boolean; endpoint: string; @@ -166,8 +170,8 @@ export function TableWzAPI({ */ const triggerReload = () => { setReloadFootprint(Date.now()); - if (rest.setReload) { - rest.setReload(Date.now()); + if (setReload) { + setReload(Date.now()); } }; @@ -202,9 +206,9 @@ export function TableWzAPI({ )} - {rest.addOnTitle ? ( + {addOnTitle ? ( - {rest.addOnTitle} + {addOnTitle} ) : null} @@ -297,6 +301,7 @@ export function TableWzAPI({ {rest.description} )} + {extra ? {extra} : null} {table} ); diff --git a/plugins/main/public/components/endpoints-summary/endpoints-summary.tsx b/plugins/main/public/components/endpoints-summary/endpoints-summary.tsx index 7817da0711..3ec1be73ca 100644 --- a/plugins/main/public/components/endpoints-summary/endpoints-summary.tsx +++ b/plugins/main/public/components/endpoints-summary/endpoints-summary.tsx @@ -149,6 +149,7 @@ export const EndpointsSummary = compose( diff --git a/plugins/main/public/components/endpoints-summary/hooks/agents.test.ts b/plugins/main/public/components/endpoints-summary/hooks/agents.test.ts index 32afd959e7..1cc3725ac9 100644 --- a/plugins/main/public/components/endpoints-summary/hooks/agents.test.ts +++ b/plugins/main/public/components/endpoints-summary/hooks/agents.test.ts @@ -1,4 +1,4 @@ -import { renderHook, act } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; import { useGetTotalAgents } from './agents'; import { getAgentsService } from '../services'; diff --git a/plugins/main/public/components/endpoints-summary/hooks/index.ts b/plugins/main/public/components/endpoints-summary/hooks/index.ts index 063e5cc418..c0269a5890 100644 --- a/plugins/main/public/components/endpoints-summary/hooks/index.ts +++ b/plugins/main/public/components/endpoints-summary/hooks/index.ts @@ -1,2 +1,3 @@ export { useGetTotalAgents } from './agents'; export { useGetGroups } from './groups'; +export { useGetUpgradeTasks } from './upgrade-tasks'; diff --git a/plugins/main/public/components/endpoints-summary/hooks/upgrade-tasks.test.ts b/plugins/main/public/components/endpoints-summary/hooks/upgrade-tasks.test.ts new file mode 100644 index 0000000000..0978c9ac41 --- /dev/null +++ b/plugins/main/public/components/endpoints-summary/hooks/upgrade-tasks.test.ts @@ -0,0 +1,91 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { getTasks } from '../services'; +import { useGetUpgradeTasks } from './upgrade-tasks'; +import { API_NAME_TASK_STATUS } from '../../../../common/constants'; + +jest.mock('../services', () => ({ + getTasks: jest.fn(), +})); + +jest.useFakeTimers(); +jest.spyOn(global, 'clearInterval'); + +describe('useGetUpgradeTasks hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch initial data without any error', async () => { + const mockGetTasks = jest.requireMock('../services').getTasks; + mockGetTasks.mockImplementation(async ({ status }) => { + if (status === API_NAME_TASK_STATUS.IN_PROGRESS) { + return { total_affected_items: 5 }; + } + if (status === API_NAME_TASK_STATUS.DONE) { + return { total_affected_items: 3 }; + } + return { total_affected_items: 2 }; + }); + + const { result, waitForNextUpdate } = renderHook(() => + useGetUpgradeTasks(false), + ); + + expect(result.current.getInProgressIsLoading).toBe(true); + expect(result.current.totalInProgressTasks).toBe(0); + expect(result.current.getInProgressError).toBeUndefined(); + + expect(result.current.getSuccessIsLoading).toBe(true); + expect(result.current.totalSuccessTasks).toBe(0); + expect(result.current.getSuccessError).toBeUndefined(); + + expect(result.current.getErrorIsLoading).toBe(true); + expect(result.current.totalErrorUpgradeTasks).toBe(0); + expect(result.current.getErrorTasksError).toBeUndefined(); + + await waitForNextUpdate(); + jest.advanceTimersByTime(500); + + expect(result.current.getInProgressIsLoading).toBe(false); + expect(result.current.totalInProgressTasks).toBe(5); + expect(result.current.getInProgressError).toBeUndefined(); + + jest.advanceTimersByTime(500); + + expect(result.current.getSuccessIsLoading).toBe(false); + expect(result.current.totalSuccessTasks).toBe(3); + expect(result.current.getSuccessError).toBeUndefined(); + + jest.advanceTimersByTime(500); + + expect(result.current.getErrorIsLoading).toBe(false); + expect(result.current.totalErrorUpgradeTasks).toBe(2); + expect(result.current.getErrorTasksError).toBeUndefined(); + }); + + it('should clear interval when totalInProgressTasks is 0', async () => { + const mockGetTasks = jest.requireMock('../services').getTasks; + mockGetTasks.mockResolvedValue({ total_affected_items: 0 }); + + const { waitForNextUpdate } = renderHook(() => useGetUpgradeTasks(false)); + + await waitForNextUpdate(); + jest.advanceTimersByTime(500); + + expect(clearInterval).toHaveBeenCalledTimes(1); + }); + + it('should handle error while fetching data', async () => { + const mockErrorMessage = 'Some error occurred'; + (getTasks as jest.Mock).mockRejectedValue(mockErrorMessage); + + const { result, waitForNextUpdate } = renderHook(() => + useGetUpgradeTasks(0), + ); + + expect(result.current.getInProgressIsLoading).toBeTruthy(); + await waitForNextUpdate(); + expect(result.current.getInProgressError).toBe(mockErrorMessage); + expect(result.current.getInProgressIsLoading).toBeFalsy(); + }); +}); diff --git a/plugins/main/public/components/endpoints-summary/hooks/upgrade-tasks.ts b/plugins/main/public/components/endpoints-summary/hooks/upgrade-tasks.ts new file mode 100644 index 0000000000..f4ccd4a20a --- /dev/null +++ b/plugins/main/public/components/endpoints-summary/hooks/upgrade-tasks.ts @@ -0,0 +1,108 @@ +import { useState, useEffect } from 'react'; +import { getTasks } from '../services'; +import { API_NAME_TASK_STATUS } from '../../../../common/constants'; + +const beforeMinutes = 60; + +export const useGetUpgradeTasks = (reload: any) => { + const [totalInProgressTasks, setTotalInProgressTasks] = useState(0); + const [getInProgressIsLoading, setGetInProgressIsLoading] = useState(true); + const [getInProgressError, setGetInProgressError] = useState(); + + const [totalSuccessTasks, setTotalSuccessTasks] = useState(0); + const [getSuccessIsLoading, setSuccessIsLoading] = useState(true); + const [getSuccessError, setGetSuccessError] = useState(); + + const [totalErrorUpgradeTasks, setTotalErrorUpgradeTasks] = + useState(0); + const [getErrorIsLoading, setErrorIsLoading] = useState(true); + const [getErrorTasksError, setGetErrorTasksError] = useState(); + + const datetime = new Date(); + datetime.setMinutes(datetime.getMinutes() - beforeMinutes); + const formattedDate = datetime.toISOString(); + const timeFilter = `last_update_time>${formattedDate}`; + + const getUpgradesInProgress = async () => { + try { + setGetInProgressIsLoading(true); + const { total_affected_items } = await getTasks({ + status: API_NAME_TASK_STATUS.IN_PROGRESS, + command: 'upgrade', + limit: 1, + }); + setTotalInProgressTasks(total_affected_items); + setGetInProgressError(undefined); + } catch (error: any) { + setGetInProgressError(error); + } finally { + setGetInProgressIsLoading(false); + } + }; + + const getUpgradesSuccess = async () => { + try { + setSuccessIsLoading(true); + const { total_affected_items } = await getTasks({ + status: API_NAME_TASK_STATUS.DONE, + command: 'upgrade', + limit: 1, + q: timeFilter, + }); + setTotalSuccessTasks(total_affected_items); + setGetSuccessError(undefined); + } catch (error: any) { + setGetSuccessError(error); + } finally { + setSuccessIsLoading(false); + } + }; + + const getUpgradesError = async () => { + try { + setErrorIsLoading(true); + const { total_affected_items } = await getTasks({ + status: API_NAME_TASK_STATUS.FAILED, + command: 'upgrade', + limit: 1, + q: timeFilter, + }); + setTotalErrorUpgradeTasks(total_affected_items); + setGetErrorTasksError(undefined); + } catch (error: any) { + setGetErrorTasksError(error); + } finally { + setErrorIsLoading(false); + } + }; + + const fetchData = async () => { + await getUpgradesInProgress(); + await getUpgradesSuccess(); + await getUpgradesError(); + }; + + useEffect(() => { + fetchData(); + + const intervalId = setInterval(getUpgradesInProgress, 3000); + + if (totalInProgressTasks === 0) { + clearInterval(intervalId); + } + + return () => clearInterval(intervalId); + }, [totalInProgressTasks, reload]); + + return { + getInProgressIsLoading, + totalInProgressTasks, + getInProgressError, + getSuccessIsLoading, + totalSuccessTasks, + getSuccessError, + getErrorIsLoading, + totalErrorUpgradeTasks, + getErrorTasksError, + }; +}; diff --git a/plugins/main/public/components/endpoints-summary/services/add-agent-to-group.tsx b/plugins/main/public/components/endpoints-summary/services/add-agent-to-group.ts similarity index 100% rename from plugins/main/public/components/endpoints-summary/services/add-agent-to-group.tsx rename to plugins/main/public/components/endpoints-summary/services/add-agent-to-group.ts diff --git a/plugins/main/public/components/endpoints-summary/services/add-agents-to-group.tsx b/plugins/main/public/components/endpoints-summary/services/add-agents-to-group.ts similarity index 100% rename from plugins/main/public/components/endpoints-summary/services/add-agents-to-group.tsx rename to plugins/main/public/components/endpoints-summary/services/add-agents-to-group.ts diff --git a/plugins/main/public/components/endpoints-summary/services/get-agents.test.tsx b/plugins/main/public/components/endpoints-summary/services/get-agents.test.ts similarity index 100% rename from plugins/main/public/components/endpoints-summary/services/get-agents.test.tsx rename to plugins/main/public/components/endpoints-summary/services/get-agents.test.ts diff --git a/plugins/main/public/components/endpoints-summary/services/get-color-palette-by-index.tsx b/plugins/main/public/components/endpoints-summary/services/get-color-palette-by-index.ts similarity index 100% rename from plugins/main/public/components/endpoints-summary/services/get-color-palette-by-index.tsx rename to plugins/main/public/components/endpoints-summary/services/get-color-palette-by-index.ts diff --git a/plugins/main/public/components/endpoints-summary/services/get-groups.test.tsx b/plugins/main/public/components/endpoints-summary/services/get-groups.test.ts similarity index 100% rename from plugins/main/public/components/endpoints-summary/services/get-groups.test.tsx rename to plugins/main/public/components/endpoints-summary/services/get-groups.test.ts diff --git a/plugins/main/public/components/endpoints-summary/services/get-outdated-agents.ts b/plugins/main/public/components/endpoints-summary/services/get-outdated-agents.ts new file mode 100644 index 0000000000..36e44d173e --- /dev/null +++ b/plugins/main/public/components/endpoints-summary/services/get-outdated-agents.ts @@ -0,0 +1,20 @@ +import { WzRequest } from '../../../react-services/wz-request'; + +export const getOutdatedAgents = async (agentIds?: string[]) => { + const { + data: { + data: { affected_items }, + }, + } = await WzRequest.apiReq( + 'GET', + '/agents/outdated', + agentIds + ? { + params: { + q: `(${agentIds.map(agentId => `id=${agentId}`).join(',')})`, + }, + } + : {}, + ); + return affected_items; +}; diff --git a/plugins/main/public/components/endpoints-summary/services/get-outdated-agents.tsx b/plugins/main/public/components/endpoints-summary/services/get-outdated-agents.tsx deleted file mode 100644 index cd35c2d442..0000000000 --- a/plugins/main/public/components/endpoints-summary/services/get-outdated-agents.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { WzRequest } from '../../../react-services/wz-request'; - -export const getOutdatedAgents = async () => { - const { - data: { - data: { affected_items }, - }, - } = await WzRequest.apiReq('GET', '/agents/outdated', {}); - return affected_items; -}; diff --git a/plugins/main/public/components/endpoints-summary/services/get-tasks.test.ts b/plugins/main/public/components/endpoints-summary/services/get-tasks.test.ts new file mode 100644 index 0000000000..eb06fbed5c --- /dev/null +++ b/plugins/main/public/components/endpoints-summary/services/get-tasks.test.ts @@ -0,0 +1,78 @@ +import { API_NAME_TASK_STATUS } from '../../../../common/constants'; +import { WzRequest } from '../../../react-services/wz-request'; +import { getTasks } from './get-tasks'; + +jest.mock('../../../react-services/wz-request', () => ({ + WzRequest: { + apiReq: jest.fn(), + }, +})); + +describe('getTasks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should retrieve tasks', async () => { + (WzRequest.apiReq as jest.Mock).mockResolvedValue({ + data: { + data: { + affected_items: [ + { + task_id: 1, + agent_id: '001', + status: API_NAME_TASK_STATUS.DONE, + command: 'upgrade', + }, + { + task_id: 2, + agent_id: '002', + status: API_NAME_TASK_STATUS.DONE, + command: 'upgrade', + }, + ], + total_affected_items: 2, + failed_items: [], + total_failed_items: 0, + }, + error: 0, + message: 'Success', + }, + }); + + const result = await getTasks({ + status: API_NAME_TASK_STATUS.DONE, + command: 'upgrade', + limit: 10, + }); + + expect(WzRequest.apiReq).toHaveBeenCalledWith('GET', '/tasks/status', { + params: { + status: API_NAME_TASK_STATUS.DONE, + command: 'upgrade', + limit: 10, + offset: 0, + q: undefined, + wait_for_complete: true, + }, + }); + + expect(result).toEqual({ + affected_items: [ + { + task_id: 1, + agent_id: '001', + status: API_NAME_TASK_STATUS.DONE, + command: 'upgrade', + }, + { + task_id: 2, + agent_id: '002', + status: API_NAME_TASK_STATUS.DONE, + command: 'upgrade', + }, + ], + total_affected_items: 2, + }); + }); +}); diff --git a/plugins/main/public/components/endpoints-summary/services/get-tasks.ts b/plugins/main/public/components/endpoints-summary/services/get-tasks.ts new file mode 100644 index 0000000000..0e772173d4 --- /dev/null +++ b/plugins/main/public/components/endpoints-summary/services/get-tasks.ts @@ -0,0 +1,60 @@ +import IApiResponse from '../../../react-services/interfaces/api-response.interface'; +import { WzRequest } from '../../../react-services/wz-request'; +import { Agent } from '../types'; + +export const getTasks = async ({ + status, + command, + offset, + limit, + q, + pageSize = 1000, +}: { + status: string; + command: string; + offset?: number; + limit?: number; + q?: string; + pageSize?: number; +}) => { + let queryOffset = offset ?? 0; + let queryLimit = limit && limit <= pageSize ? limit : pageSize; + let allAffectedItems: Agent[] = []; + let totalAffectedItems; + + do { + const { + data: { + data: { affected_items, total_affected_items }, + }, + } = (await WzRequest.apiReq('GET', '/tasks/status', { + params: { + limit: queryLimit, + offset: queryOffset, + status, + command, + q, + wait_for_complete: true, + }, + })) as IApiResponse; + + if (totalAffectedItems === undefined) { + totalAffectedItems = total_affected_items; + } + + allAffectedItems = allAffectedItems.concat(affected_items); + + queryOffset += queryLimit; + + const restItems = limit ? limit - allAffectedItems.length : pageSize; + queryLimit = restItems > pageSize ? pageSize : restItems; + } while ( + queryOffset < totalAffectedItems && + (!limit || allAffectedItems.length < limit) + ); + + return { + affected_items: allAffectedItems, + total_affected_items: totalAffectedItems, + }; +}; diff --git a/plugins/main/public/components/endpoints-summary/services/index.tsx b/plugins/main/public/components/endpoints-summary/services/index.tsx index a8d7711ff8..17910f7e44 100644 --- a/plugins/main/public/components/endpoints-summary/services/index.tsx +++ b/plugins/main/public/components/endpoints-summary/services/index.tsx @@ -4,3 +4,6 @@ export { removeAgentsFromGroupService } from './remove-agents-from-group'; export { addAgentToGroupService } from './add-agent-to-group'; export { addAgentsToGroupService } from './add-agents-to-group'; export { getGroupsService } from './get-groups'; +export { upgradeAgentsService } from './upgrade-agents'; +export { getOutdatedAgents } from './get-outdated-agents'; +export { getTasks } from './get-tasks'; diff --git a/plugins/main/public/components/endpoints-summary/services/upgrade-agents.tsx b/plugins/main/public/components/endpoints-summary/services/upgrade-agents.tsx new file mode 100644 index 0000000000..ea9c8f7e6b --- /dev/null +++ b/plugins/main/public/components/endpoints-summary/services/upgrade-agents.tsx @@ -0,0 +1,15 @@ +import IApiResponse from '../../../react-services/interfaces/api-response.interface'; +import { WzRequest } from '../../../react-services/wz-request'; +import { ResponseUpgradeAgents } from '../types'; + +export const upgradeAgentsService = async ({ + agentIds, +}: { + agentIds: string[]; +}) => + (await WzRequest.apiReq('PUT', `/agents/upgrade`, { + params: { + agents_list: agentIds.join(','), + wait_for_complete: true, + }, + })) as IApiResponse; diff --git a/plugins/main/public/components/endpoints-summary/table/__snapshots__/agents-table.test.tsx.snap b/plugins/main/public/components/endpoints-summary/table/__snapshots__/agents-table.test.tsx.snap index 5a0be00f9f..6cd96fd8bc 100644 --- a/plugins/main/public/components/endpoints-summary/table/__snapshots__/agents-table.test.tsx.snap +++ b/plugins/main/public/components/endpoints-summary/table/__snapshots__/agents-table.test.tsx.snap @@ -224,6 +224,9 @@ exports[`AgentsTable component Renders correctly to match the snapshot 1`] = ` +
@@ -562,7 +565,7 @@ exports[`AgentsTable component Renders correctly to match the snapshot 1`] = ` data-test-subj="tableHeaderCell_version_6" role="columnheader" scope="col" - style="width:10%" + style="width:100px" >
+
@@ -1149,7 +1155,7 @@ exports[`AgentsTable component Renders correctly to match the snapshot with cust data-test-subj="tableHeaderCell_version_3" role="columnheader" scope="col" - style="width:10%" + style="width:100px" >
+
@@ -1832,7 +1841,7 @@ exports[`AgentsTable component Renders correctly to match the snapshot with no p data-test-subj="tableHeaderCell_version_6" role="columnheader" scope="col" - style="width:10%" + style="width:100px" > +
+
+
+
+
+
+
+
+ + 5 + + Upgrade tasks in progress +
+
+
+
+
+ + +
+ + 2 + + Failed upgrade tasks + + + +
+
+
+
+
+
+
+`; diff --git a/plugins/main/public/components/endpoints-summary/table/upgrades-in-progress/taskDetailsButton.tsx b/plugins/main/public/components/endpoints-summary/table/upgrades-in-progress/taskDetailsButton.tsx new file mode 100644 index 0000000000..5e1753dde7 --- /dev/null +++ b/plugins/main/public/components/endpoints-summary/table/upgrades-in-progress/taskDetailsButton.tsx @@ -0,0 +1,224 @@ +import React, { useState, memo } from 'react'; +import { EuiHealth, EuiIconTip } from '@elastic/eui'; +import { TableWzAPI } from '../../../common/tables'; +import { formatUIDate } from '../../../../react-services/time-service'; +import { + API_NAME_TASK_STATUS, + SEARCH_BAR_WQL_VALUE_SUGGESTIONS_COUNT, + UI_TASK_STATUS, +} from '../../../../../common/constants'; +import { WzRequest } from '../../../../react-services/wz-request'; +import { get as getLodash, uniqBy as uniqByLodash } from 'lodash'; +import { + EuiModal, + EuiModalHeader, + EuiModalBody, + EuiModalFooter, + EuiButton, + EuiButtonEmpty, +} from '@elastic/eui'; + +export const AgentUpgradesTaskDetailsButton = memo(() => { + const [isModalVisible, setIsModalVisible] = useState(false); + + const datetime = new Date(); + datetime.setMinutes(datetime.getMinutes() - 60); + const formattedDate = datetime.toISOString(); + + const defaultFilters = { + q: `last_update_time>${formattedDate}`, + }; + + const handleOnCloseModal = () => setIsModalVisible(false); + + return ( + <> + setIsModalVisible(true)} + iconType='eye' + > + Task details + + {isModalVisible ? ( + + + + + Create{' '} + + + ), + sortable: true, + searchable: false, + show: true, + render: value => formatUIDate(value), + }, + { + field: 'last_update_time', + name: ( + + Last update{' '} + + + ), + sortable: true, + searchable: false, + show: true, + render: value => formatUIDate(value), + }, + { + field: 'status', + name: 'Status', + width: '100px', + sortable: true, + searchable: true, + show: true, + render: value => ( + + {value} + + ), + }, + { + field: 'error_message', + name: 'Error', + show: true, + searchable: true, + }, + ]} + tableInitialSortingField='last_update_time' + tableInitialSortingDirection='desc' + tablePageSizeOptions={[10, 25, 50, 100]} + filters={defaultFilters} + downloadCsv + showReload + showFieldSelector + searchTable + searchBarWQL={{ + suggestions: { + field(currentValue) { + return [ + { + label: 'agent_id', + description: 'filter by agent id', + }, + { label: 'status', description: 'filter by status' }, + { + label: 'create_time', + description: 'filter by creation date', + }, + { + label: 'last_update_time', + description: 'filter by last update date', + }, + { label: 'task_id', description: 'filter by task id' }, + ]; + }, + value: async (currentValue, { field }) => { + try { + switch (field) { + case 'status': + return UI_TASK_STATUS.map(status => ({ + label: status, + })); + default: { + const response = await WzRequest.apiReq( + 'GET', + '/tasks/status', + { + params: { + limit: SEARCH_BAR_WQL_VALUE_SUGGESTIONS_COUNT, + select: field, + sort: `+${field}`, + ...(currentValue + ? { + q: `${field}~${currentValue}`, + } + : {}), + }, + }, + ); + const suggestionValues = + response?.data?.data.affected_items.map(item => ({ + label: getLodash(item, field), + })); + return uniqByLodash(suggestionValues, 'label'); + } + } + } catch (error) { + return []; + } + }, + }, + validate: { + value: ({ formattedValue, value: rawValue }, { field }) => { + const value = formattedValue ?? rawValue; + if (value) { + if (['create_time', 'last_update_time'].includes(field)) { + const isCorrectDate = + /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2}(.\d{1,6})?Z?)?$/.test( + value, + ); + return isCorrectDate + ? undefined + : `"${value}" is not a expected format. Valid formats: YYYY-MM-DD, YYYY-MM-DD HH:mm:ss, YYYY-MM-DDTHH:mm:ss, YYYY-MM-DDTHH:mm:ssZ.`; + } + } + }, + }, + }} + tableProps={{ + tableLayout: 'auto', + }} + /> + + + + Close + + + + ) : null} + + ); +}); diff --git a/plugins/main/public/components/endpoints-summary/table/upgrades-in-progress/upgrades-in-progress.test.tsx b/plugins/main/public/components/endpoints-summary/table/upgrades-in-progress/upgrades-in-progress.test.tsx new file mode 100644 index 0000000000..df4b3d67eb --- /dev/null +++ b/plugins/main/public/components/endpoints-summary/table/upgrades-in-progress/upgrades-in-progress.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render, fireEvent, waitFor, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { AgentUpgradesInProgress } from './upgrades-in-progress'; +import { useGetUpgradeTasks } from '../../hooks'; + +jest.mock('../../hooks', () => ({ + useGetUpgradeTasks: jest.fn(), +})); + +jest.mock('../../../../react-services/common-services', () => ({ + getErrorOrchestrator: () => ({ + handleError: () => {}, + }), +})); + +describe('AgentUpgradesInProgress component', () => { + test('should return the component', async () => { + (useGetUpgradeTasks as jest.Mock).mockReturnValue({ + getInProgressIsLoading: false, + totalInProgressTasks: 5, + getErrorIsLoading: false, + totalErrorUpgradeTasks: 2, + }); + + const { container, getByText } = render( + , + ); + + expect(container).toMatchSnapshot(); + + const inProgressValue = getByText('5'); + expect(inProgressValue).toBeInTheDocument(); + const inProgressText = getByText('Upgrade tasks in progress'); + expect(inProgressText).toBeInTheDocument(); + + const failedValue = getByText('2'); + expect(failedValue).toBeInTheDocument(); + const failedText = getByText('Failed upgrade tasks'); + expect(failedText).toBeInTheDocument(); + }); + + test('should show upgrade tasks modal', async () => { + const { getByRole, getByText } = render( + , + ); + + const openModalButton = getByRole('button'); + expect(openModalButton).toBeInTheDocument(); + + act(() => { + fireEvent.click(openModalButton); + }); + + await waitFor(() => expect(getByRole('table')).toBeInTheDocument()); + }); +}); diff --git a/plugins/main/public/components/endpoints-summary/table/upgrades-in-progress/upgrades-in-progress.tsx b/plugins/main/public/components/endpoints-summary/table/upgrades-in-progress/upgrades-in-progress.tsx new file mode 100644 index 0000000000..81b1133c94 --- /dev/null +++ b/plugins/main/public/components/endpoints-summary/table/upgrades-in-progress/upgrades-in-progress.tsx @@ -0,0 +1,158 @@ +import React, { useState, useEffect } from 'react'; +import { + EuiPanel, + EuiProgress, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiSpacer, +} from '@elastic/eui'; +import { useGetUpgradeTasks } from '../../hooks'; +import { UI_LOGGER_LEVELS } from '../../../../../common/constants'; +import { UI_ERROR_SEVERITIES } from '../../../../react-services/error-orchestrator/types'; +import { getErrorOrchestrator } from '../../../../react-services/common-services'; +import { AgentUpgradesTaskDetailsButton } from './taskDetailsButton'; + +interface AgentUpgradesInProgress { + reload: any; +} + +export const AgentUpgradesInProgress = ({ + reload, +}: AgentUpgradesInProgress) => { + const [isUpgrading, setIsUpgrading] = useState(false); + const { + totalInProgressTasks, + getInProgressError, + totalSuccessTasks, + getSuccessError, + totalErrorUpgradeTasks, + getErrorTasksError, + } = useGetUpgradeTasks(reload); + + useEffect(() => { + if (totalInProgressTasks > 0) { + setIsUpgrading(true); + } + }, [totalInProgressTasks]); + + if (getInProgressError) { + const options = { + context: `AgentUpgradesInProgress.useGetUpgradeTasks`, + level: UI_LOGGER_LEVELS.ERROR, + severity: UI_ERROR_SEVERITIES.BUSINESS, + store: true, + error: { + error: getInProgressError, + message: getInProgressError.message || getInProgressError, + title: `Could not get upgrade progress tasks`, + }, + }; + getErrorOrchestrator().handleError(options); + } + + if (getSuccessError) { + const options = { + context: `AgentUpgradesInProgress.useGetUpgradeTasks`, + level: UI_LOGGER_LEVELS.ERROR, + severity: UI_ERROR_SEVERITIES.BUSINESS, + store: true, + error: { + error: getSuccessError, + message: getSuccessError.message || getSuccessError, + title: `Could not get upgrade success tasks`, + }, + }; + getErrorOrchestrator().handleError(options); + } + + if (getErrorTasksError) { + const options = { + context: `AgentUpgradesInProgress.useGetUpgradeTasks`, + level: UI_LOGGER_LEVELS.ERROR, + severity: UI_ERROR_SEVERITIES.BUSINESS, + store: true, + error: { + error: getErrorTasksError, + message: getErrorTasksError.message || getErrorTasksError, + title: `Could not get upgrade error tasks`, + }, + }; + getErrorOrchestrator().handleError(options); + } + + return isUpgrading || totalSuccessTasks || totalErrorUpgradeTasks ? ( + + + + Upgrade tasks + + + + + + + + {totalInProgressTasks > 0 ? ( + + + + + {totalInProgressTasks} + {totalInProgressTasks === 1 + ? ' Upgrade task in progress' + : ' Upgrade tasks in progress'} + + + + ) : null} + {totalSuccessTasks > 0 ? ( + + + + + + {totalSuccessTasks} + {totalSuccessTasks === 1 + ? ' Success upgrade task ' + : ' Success upgrade tasks '} + + + + + + ) : null} + {totalErrorUpgradeTasks > 0 ? ( + + + + + + {totalErrorUpgradeTasks} + {totalErrorUpgradeTasks === 1 + ? ' Failed upgrade task ' + : ' Failed upgrade tasks '} + + + + + + + ) : null} + + + ) : null; +}; diff --git a/plugins/main/public/components/endpoints-summary/types.ts b/plugins/main/public/components/endpoints-summary/types.ts index f0d3dca0da..ce9a085954 100644 --- a/plugins/main/public/components/endpoints-summary/types.ts +++ b/plugins/main/public/components/endpoints-summary/types.ts @@ -30,3 +30,8 @@ export type Group = { name: string; count: number; }; + +export type ResponseUpgradeAgents = { + agent: string; + task_id: number; +}; diff --git a/plugins/main/public/components/search-bar/query-language/wql.tsx b/plugins/main/public/components/search-bar/query-language/wql.tsx index 5282a6e096..7d139db27b 100644 --- a/plugins/main/public/components/search-bar/query-language/wql.tsx +++ b/plugins/main/public/components/search-bar/query-language/wql.tsx @@ -343,11 +343,13 @@ function filterTokenValueSuggestion( suggestions: QLOptionSuggestionEntityItemTyped[], ) { return suggestions - .filter(({ label }: QLOptionSuggestionEntityItemTyped) => { - const re = getTokenValueRegularExpression(); - return re.test(label); - }) - .slice(0, SEARCH_BAR_WQL_VALUE_SUGGESTIONS_DISPLAY_COUNT); + ? suggestions + .filter(({ label }: QLOptionSuggestionEntityItemTyped) => { + const re = getTokenValueRegularExpression(); + return re.test(label); + }) + .slice(0, SEARCH_BAR_WQL_VALUE_SUGGESTIONS_DISPLAY_COUNT) + : []; } /**