diff --git a/locust/webui/src/components/LogViewer/LogDisplay.tsx b/locust/webui/src/components/LogViewer/LogDisplay.tsx new file mode 100644 index 0000000000..eeb4fd6bb8 --- /dev/null +++ b/locust/webui/src/components/LogViewer/LogDisplay.tsx @@ -0,0 +1,26 @@ +import { Typography } from '@mui/material'; +import { red, amber, blue } from '@mui/material/colors'; + +const getLogColor = (log: string) => { + if (log.includes('CRITICAL')) { + return red[900]; + } + if (log.includes('ERROR')) { + return red[700]; + } + if (log.includes('WARNING')) { + return amber[900]; + } + if (log.includes('DEBUG')) { + return blue[700]; + } + return 'text.primary'; +}; + +export default function LogDisplay({ log }: { log: string }) { + return ( + + {log} + + ); +} diff --git a/locust/webui/src/components/LogViewer/LogViewer.tsx b/locust/webui/src/components/LogViewer/LogViewer.tsx index 78ff9bfe8d..bfe8438709 100644 --- a/locust/webui/src/components/LogViewer/LogViewer.tsx +++ b/locust/webui/src/components/LogViewer/LogViewer.tsx @@ -1,69 +1,9 @@ -import { useEffect, useState } from 'react'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import PriorityHighIcon from '@mui/icons-material/PriorityHigh'; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Box, - Paper, - Typography, -} from '@mui/material'; -import { red, amber, blue } from '@mui/material/colors'; +import { Box, Paper, Typography } from '@mui/material'; -import { isImportantLog } from 'components/LogViewer/logUtils'; +import LogDisplay from 'components/LogViewer/LogDisplay'; +import WorkerLogs from 'components/LogViewer/WorkerLogs'; import { useSelector } from 'redux/hooks'; -import { objectLength } from 'utils/object'; - -const getLogColor = (log: string) => { - if (log.includes('CRITICAL')) { - return red[900]; - } - if (log.includes('ERROR')) { - return red[700]; - } - if (log.includes('WARNING')) { - return amber[900]; - } - if (log.includes('DEBUG')) { - return blue[700]; - } - return 'text.primary'; -}; - -function LogDisplay({ log }: { log: string }) { - return ( - - {log} - - ); -} - -function WorkerLogs({ workerId, logs }: { workerId: string; logs: string[] }) { - const [hasImportantLog, setHasImportantLog] = useState(false); - - useEffect(() => { - if (logs.some(isImportantLog)) { - setHasImportantLog(true); - } - }, [logs]); - - return ( - - } onClick={() => setHasImportantLog(false)}> - {hasImportantLog && } - {workerId} - - - - {logs.map((log, index) => ( - - ))} - - - - ); -} +import { isEmpty } from 'utils/object'; export default function LogViewer() { const { master: masterLogs, workers: workerLogs } = useSelector(({ logViewer }) => logViewer); @@ -80,7 +20,7 @@ export default function LogViewer() { ))} - {!!objectLength(workerLogs) && ( + {!isEmpty(workerLogs) && ( Worker Logs diff --git a/locust/webui/src/components/LogViewer/logUtils.tsx b/locust/webui/src/components/LogViewer/LogViewer.utils.tsx similarity index 100% rename from locust/webui/src/components/LogViewer/logUtils.tsx rename to locust/webui/src/components/LogViewer/LogViewer.utils.tsx diff --git a/locust/webui/src/components/LogViewer/WorkerLogs.tsx b/locust/webui/src/components/LogViewer/WorkerLogs.tsx new file mode 100644 index 0000000000..a7db668aa5 --- /dev/null +++ b/locust/webui/src/components/LogViewer/WorkerLogs.tsx @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useState } from 'react'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import PriorityHighIcon from '@mui/icons-material/PriorityHigh'; +import { Accordion, AccordionDetails, AccordionSummary, Paper } from '@mui/material'; + +import LogDisplay from 'components/LogViewer/LogDisplay'; +import { isImportantLog } from 'components/LogViewer/LogViewer.utils'; + +export default function WorkerLogs({ workerId, logs }: { workerId: string; logs: string[] }) { + const [hasImportantLog, setHasImportantLog] = useState(false); + + useEffect(() => { + if (logs.slice(localStorage[workerId]).some(isImportantLog)) { + setHasImportantLog(true); + } + }, [logs]); + + const onExpandLogs = useCallback(() => { + setHasImportantLog(false); + localStorage[workerId] = logs.length; + }, []); + + return ( + + } onClick={onExpandLogs}> + {hasImportantLog && ( + + )} + {workerId} + + + + {logs.map((log, index) => ( + + ))} + + + + ); +} diff --git a/locust/webui/src/components/LogViewer/tests/WorkerLogs.test.tsx b/locust/webui/src/components/LogViewer/tests/WorkerLogs.test.tsx new file mode 100644 index 0000000000..54310c96d9 --- /dev/null +++ b/locust/webui/src/components/LogViewer/tests/WorkerLogs.test.tsx @@ -0,0 +1,32 @@ +import { act, fireEvent, render } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import WorkerLogs from '../WorkerLogs'; + +const workerLogs = ['Worker Log 1', 'ERROR Worker Log 2']; +const workerId = 'worker-1'; + +describe('WorkerLogs', () => { + test('should render a notification with important worker logs', () => { + const { getByTestId } = render(); + + expect(getByTestId('worker-notification')).toBeTruthy(); + }); + + test('should hide notification and store log index when expanding logs', () => { + const { queryByTestId, getByTestId, getByRole } = render( + , + ); + + expect(getByTestId('worker-notification')).toBeTruthy(); + + const button = getByRole('button', { name: workerId }); + + act(() => { + fireEvent.click(button); + }); + + expect(queryByTestId('worker-notification')).toBeFalsy(); + expect(localStorage[workerId]).toBe(String(workerLogs.length)); + }); +}); diff --git a/locust/webui/src/components/LogViewer/tests/useLogViewer.test.tsx b/locust/webui/src/components/LogViewer/tests/useLogViewer.test.tsx index 0d7ccb6070..82bbaf895e 100644 --- a/locust/webui/src/components/LogViewer/tests/useLogViewer.test.tsx +++ b/locust/webui/src/components/LogViewer/tests/useLogViewer.test.tsx @@ -1,7 +1,7 @@ import { waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; -import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test } from 'vitest'; import useLogViewer from 'components/LogViewer/useLogViewer'; import { TEST_BASE_API } from 'test/constants'; @@ -15,7 +15,19 @@ const mockLogs = { }, }; -const server = setupServer(http.get(`${TEST_BASE_API}/logs`, () => HttpResponse.json(mockLogs))); +const mockImportantMasterLog = { + master: ['Log 1', 'WARNING Log 2', 'Log 3'], + workers: { + '123': ['Worker Log'], + }, +}; + +const mockImportantWorkerLog = { + master: ['Log 1', 'WARNING Log 2', 'Log 3'], + workers: { + '123': ['ERROR Worker Log'], + }, +}; function MockHook() { const logs = useLogViewer(); @@ -24,11 +36,16 @@ function MockHook() { } describe('useLogViewer', () => { - beforeAll(() => server.listen()); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); + afterEach(() => { + localStorage.clear(); + }); test('should fetch logs from server and store them in state', async () => { + const server = setupServer( + http.get(`${TEST_BASE_API}/logs`, () => HttpResponse.json(mockLogs)), + ); + server.listen(); + const { store, getByTestId } = renderWithProvider(, { swarm: swarmStateMock, }); @@ -37,5 +54,45 @@ describe('useLogViewer', () => { expect(getByTestId('logs').textContent).toBe(JSON.stringify(mockLogs)); expect(store.getState().logViewer).toEqual(mockLogs); }); + server.resetHandlers(); + server.close(); + }); + + test('should set a notification if important logs are present', async () => { + const server = setupServer( + http.get(`${TEST_BASE_API}/logs`, () => HttpResponse.json(mockImportantMasterLog)), + ); + server.listen(); + + const { store, getByTestId } = renderWithProvider(, { + swarm: swarmStateMock, + }); + + await waitFor(() => { + expect(getByTestId('logs').textContent).toBe(JSON.stringify(mockImportantMasterLog)); + expect(store.getState().logViewer).toEqual(mockImportantMasterLog); + expect(store.getState().notification).toEqual({ logViewer: true }); + }); + + server.close(); + }); + + test('should set a notification if important worker logs are present', async () => { + const server = setupServer( + http.get(`${TEST_BASE_API}/logs`, () => HttpResponse.json(mockImportantWorkerLog)), + ); + server.listen(); + + const { store, getByTestId } = renderWithProvider(, { + swarm: swarmStateMock, + }); + + await waitFor(() => { + expect(getByTestId('logs').textContent).toBe(JSON.stringify(mockImportantWorkerLog)); + expect(store.getState().logViewer).toEqual(mockImportantWorkerLog); + expect(store.getState().notification).toEqual({ logViewer: true }); + }); + + server.close(); }); }); diff --git a/locust/webui/src/components/LogViewer/useLogViewer.ts b/locust/webui/src/components/LogViewer/useLogViewer.ts index 61f3c84fc7..3a9f20e96f 100644 --- a/locust/webui/src/components/LogViewer/useLogViewer.ts +++ b/locust/webui/src/components/LogViewer/useLogViewer.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo } from 'react'; -import { isImportantLog } from 'components/LogViewer/logUtils'; +import { isImportantLog } from 'components/LogViewer/LogViewer.utils'; import { LOG_VIEWER_KEY } from 'constants/logs'; import { SWARM_STATE } from 'constants/swarm'; import useInterval from 'hooks/useInterval'; @@ -10,19 +10,21 @@ import { useAction, useSelector } from 'redux/hooks'; import { logViewerActions } from 'redux/slice/logViewer.slice'; import { flatten } from 'utils/array'; +const defaultLogs = { master: [], workers: {} }; + export default function useLogViewer() { const swarm = useSelector(({ swarm }) => swarm); const setLogs = useAction(logViewerActions.setLogs); const { data, refetch: refetchLogs } = useGetLogsQuery(); - const logs = data || { master: [], workers: {} }; + const logs = data || defaultLogs; const workerLogs = useMemo(() => flatten(Object.values(logs.workers)), [logs.workers]); - const allLogs = [...logs.master, ...workerLogs]; + const allLogs = useMemo(() => [...logs.master].concat(workerLogs), [logs.master, logs.workers]); const shouldNotifyLogsUpdate = useCallback( - () => allLogs.slice(localStorage['logViewer']).some(isImportantLog), - [logs], + (key: string) => allLogs.slice(localStorage[key]).some(isImportantLog), + [allLogs], ); useInterval(refetchLogs, 5000, { diff --git a/locust/webui/src/hooks/tests/useNotifications.test.tsx b/locust/webui/src/hooks/tests/useNotifications.test.tsx index 9437fa29f5..167c5bea8d 100644 --- a/locust/webui/src/hooks/tests/useNotifications.test.tsx +++ b/locust/webui/src/hooks/tests/useNotifications.test.tsx @@ -3,18 +3,17 @@ import { afterEach, describe, expect, test } from 'vitest'; import useNotifications from 'hooks/useNotifications'; import { IRootState } from 'redux/store'; import { renderWithProvider } from 'test/testUtils'; -import { objectLength } from 'utils/object'; function MockHook({ data, - notificaitonKey, + notificationKey, shouldNotify, }: { - data: any[] | Record; - notificaitonKey: string; + data: any[]; + notificationKey: string; shouldNotify?: () => boolean; }) { - useNotifications(data, { key: notificaitonKey, shouldNotify }); + useNotifications(data, { key: notificationKey, shouldNotify }); return null; } @@ -26,18 +25,12 @@ describe('useNotifications', () => { test('should set notifications when there is data', async () => { const testArrayKey = 'testArray'; - const testObjectKey = 'testObject'; - const { store: firstRender } = renderWithProvider( - , - ); const { store } = renderWithProvider( - , - firstRender.getState(), + , ); expect((store.getState() as IRootState).notification[testArrayKey]).toBeTruthy(); - expect((store.getState() as IRootState).notification[testObjectKey]).toBeTruthy(); }); test('should store the current length of data', async () => { @@ -45,62 +38,40 @@ describe('useNotifications', () => { localStorage = {} as typeof localStorage; const testArrayKey = 'testArray'; - const testObjectKey = 'testObject'; const mockArray = [1, 2, 3]; - const mockObject = { key1: 1, key2: 2 }; - renderWithProvider(); - renderWithProvider(); + renderWithProvider(); - expect(localStorage[`${testArrayKey}Notification`]).toBe(objectLength(mockArray)); - expect(localStorage[`${testObjectKey}Notification`]).toBe(objectLength(mockObject)); + expect(localStorage[`${testArrayKey}Notification`]).toBe(mockArray.length); localStorage = temp; }); test('should set notifications when shouldNotify returns true', async () => { const testArrayKey = 'testArray'; - const testObjectKey = 'testObject'; const shouldNotify = () => true; - const { store: firstRender } = renderWithProvider( - , - ); const { store } = renderWithProvider( - , - firstRender.getState(), + , ); expect((store.getState() as IRootState).notification[testArrayKey]).toBeTruthy(); - expect((store.getState() as IRootState).notification[testObjectKey]).toBeTruthy(); }); test('should not set notifications when data is empty', async () => { const testArrayKey = 'testArray'; - const testObjectKey = 'testObject'; - const { store: firstRender } = renderWithProvider( - , - ); - const { store } = renderWithProvider( - , - firstRender.getState(), - ); + const { store } = renderWithProvider(); expect((store.getState() as IRootState).notification[testArrayKey]).toBeFalsy(); - expect((store.getState() as IRootState).notification[testObjectKey]).toBeFalsy(); }); test('should not set notifications when viewing page', async () => { const testKey = 'testKey'; - const { store } = renderWithProvider(, { + const { store } = renderWithProvider(, { url: { query: { tab: testKey, @@ -113,23 +84,13 @@ describe('useNotifications', () => { test('should not set notifications when shouldNotify is false', async () => { const testArrayKey = 'testArray'; - const testObjectKey = 'testObject'; const shouldNotify = () => false; - const { store: firstRender } = renderWithProvider( - , - ); const { store } = renderWithProvider( - , - firstRender.getState(), + , ); expect((store.getState() as IRootState).notification[testArrayKey]).toBeFalsy(); - expect((store.getState() as IRootState).notification[testObjectKey]).toBeFalsy(); }); }); diff --git a/locust/webui/src/hooks/useNotifications.ts b/locust/webui/src/hooks/useNotifications.ts index 36d5e07821..9cad63176a 100644 --- a/locust/webui/src/hooks/useNotifications.ts +++ b/locust/webui/src/hooks/useNotifications.ts @@ -2,32 +2,32 @@ import { useEffect } from 'react'; import { useAction, useSelector } from 'redux/hooks'; import { notificationActions } from 'redux/slice/notification.slice'; -import { objectLength } from 'utils/object'; export default function useNotifications( - data: any[] | Record, - { key, shouldNotify }: { key: string; shouldNotify?: () => boolean }, + data: any[], + { key, shouldNotify }: { key: string; shouldNotify?: (key: string) => boolean }, ) { const setNotification = useAction(notificationActions.setNotification); const currentPage = useSelector(({ url: { query } }) => query && query.tab); const storageKey = `${key}Notification`; useEffect(() => { - if (objectLength(data) > 0 && objectLength(data) < localStorage[storageKey]) { - // handles data being reset - // localStorage should always be <= to objectLength(data) - localStorage[storageKey] = objectLength(data); + // handles data being reset + // localStorage should always be <= to data.length + if (data.length > 0 && data.length < localStorage[storageKey]) { + localStorage[storageKey] = data.length; } if ( - objectLength(data) > (localStorage[storageKey] || 0) && - /// don't show notifications for current page + data.length > (localStorage[storageKey] || 0) && + // no current page means no tabs have been clicked and notifications should be shown + // don't show notifications for current page (!currentPage || currentPage !== key) && // allows to customize if notification should be shown - (!shouldNotify || (shouldNotify && shouldNotify())) + (!shouldNotify || (shouldNotify && shouldNotify(storageKey))) ) { setNotification({ [key]: true }); - localStorage[storageKey] = objectLength(data); + localStorage[storageKey] = data.length; } }, [data]); } diff --git a/locust/webui/src/utils/object.ts b/locust/webui/src/utils/object.ts index ae7ab71d70..adbb4dbc56 100644 --- a/locust/webui/src/utils/object.ts +++ b/locust/webui/src/utils/object.ts @@ -1,8 +1,5 @@ -export const objectLength = | any[]>(object: T) => - Array.isArray(object) ? object.length : Object.keys(object).length; - export const isEmpty = | any[]>(object: T) => - objectLength(object) === 0; + (Array.isArray(object) ? object.length : Object.keys(object).length) === 0; export function shallowMerge(objectA: ObjectA, objectB: ObjectB) { return { diff --git a/locust/webui/src/utils/tests/object.test.ts b/locust/webui/src/utils/tests/object.test.ts index 46fde5e283..06db3ee0d9 100644 --- a/locust/webui/src/utils/tests/object.test.ts +++ b/locust/webui/src/utils/tests/object.test.ts @@ -1,32 +1,6 @@ import { test, describe, expect } from 'vitest'; -import { - createFormData, - isEmpty, - objectLength, - shallowMerge, - updateArraysAtProps, -} from 'utils/object'; - -describe('objectLength', () => { - test('should return 0 for an empty object', () => { - expect(objectLength({})).toBe(0); - }); - - test('should return correct length for an object with properties', () => { - expect(objectLength({ key: 'value' })).toBe(1); - expect(objectLength({ key: 'value', key2: 'value2', key3: 'value3' })).toBe(3); - }); - - test('should return 0 for an empty array', () => { - expect(objectLength([])).toBe(0); - }); - - test('should return correct length for an array with elements', () => { - expect(objectLength([1])).toBe(1); - expect(objectLength([1, 2, 3])).toBe(3); - }); -}); +import { createFormData, isEmpty, shallowMerge, updateArraysAtProps } from 'utils/object'; describe('isEmpty', () => { test('should return true for an empty object', () => {