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', () => {