From 1e733f03b3e4a5dd477b7469844b623dacbaa0f4 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Tue, 8 Oct 2024 13:24:33 +0200 Subject: [PATCH] Add error message to swarm form --- locust/webui/src/components/Form/Form.tsx | 8 ++- .../components/Layout/Navbar/SwarmMonitor.tsx | 2 +- .../webui/src/components/Reports/Reports.tsx | 2 +- .../components/SwarmForm/SwarmEditForm.tsx | 5 +- .../src/components/SwarmForm/SwarmForm.tsx | 53 ++++++++++++------- .../SwarmForm/SwarmUserClassPicker.tsx | 4 +- locust/webui/src/constants/swarm.ts | 3 +- locust/webui/src/redux/api/swarm.ts | 9 +++- locust/webui/src/redux/slice/root.slice.ts | 3 +- locust/webui/src/redux/slice/swarm.slice.ts | 33 +----------- locust/webui/src/types/swarm.types.ts | 50 ++++++++++++++--- locust/webui/src/types/window.types.ts | 3 +- locust/webui/src/utils/object.ts | 6 ++- 13 files changed, 109 insertions(+), 72 deletions(-) diff --git a/locust/webui/src/components/Form/Form.tsx b/locust/webui/src/components/Form/Form.tsx index 7b20732ab5..297180c769 100644 --- a/locust/webui/src/components/Form/Form.tsx +++ b/locust/webui/src/components/Form/Form.tsx @@ -6,6 +6,7 @@ interface IForm { children: React.ReactElement | React.ReactElement[]; className?: string; onSubmit: (inputData: IInputData) => void; + onChange?: (formEvent: React.ChangeEvent) => void; } const FORM_INPUT_ELEMENTS = 'input, select, textarea'; @@ -32,6 +33,7 @@ const getInputValue = (inputElement: HTMLInputElement | HTMLSelectElement) => { export default function Form({ children, onSubmit, + onChange, }: IForm) { const formSubmitHandler = useCallback( async (event: FormEvent) => { @@ -53,5 +55,9 @@ export default function Form({ [onSubmit], ); - return
{children}
; + return ( +
+ {children} +
+ ); } diff --git a/locust/webui/src/components/Layout/Navbar/SwarmMonitor.tsx b/locust/webui/src/components/Layout/Navbar/SwarmMonitor.tsx index 4038e50026..587b172911 100644 --- a/locust/webui/src/components/Layout/Navbar/SwarmMonitor.tsx +++ b/locust/webui/src/components/Layout/Navbar/SwarmMonitor.tsx @@ -2,9 +2,9 @@ import { Box, Divider, Tooltip, Typography } from '@mui/material'; import { connect } from 'react-redux'; import { SWARM_STATE } from 'constants/swarm'; -import { ISwarmState } from 'redux/slice/swarm.slice'; import { IUiState } from 'redux/slice/ui.slice'; import { IRootState } from 'redux/store'; +import { ISwarmState } from 'types/swarm.types'; interface ISwarmMonitor extends Pick, diff --git a/locust/webui/src/components/Reports/Reports.tsx b/locust/webui/src/components/Reports/Reports.tsx index 4ea1bc3763..be991b2d64 100644 --- a/locust/webui/src/components/Reports/Reports.tsx +++ b/locust/webui/src/components/Reports/Reports.tsx @@ -3,8 +3,8 @@ import { connect } from 'react-redux'; import { THEME_MODE } from 'constants/theme'; import { useSelector } from 'redux/hooks'; -import { ISwarmState } from 'redux/slice/swarm.slice'; import { IRootState } from 'redux/store'; +import { ISwarmState } from 'types/swarm.types'; function Reports({ extendedCsvFiles, diff --git a/locust/webui/src/components/SwarmForm/SwarmEditForm.tsx b/locust/webui/src/components/SwarmForm/SwarmEditForm.tsx index dfc3283995..739ceee861 100644 --- a/locust/webui/src/components/SwarmForm/SwarmEditForm.tsx +++ b/locust/webui/src/components/SwarmForm/SwarmEditForm.tsx @@ -3,10 +3,9 @@ import { connect } from 'react-redux'; import Form from 'components/Form/Form'; import { useStartSwarmMutation } from 'redux/api/swarm'; -import { ISwarmState, swarmActions } from 'redux/slice/swarm.slice'; +import { swarmActions } from 'redux/slice/swarm.slice'; import { IRootState } from 'redux/store'; - -type ISwarmFormInput = Pick; +import { ISwarmFormInput, ISwarmState } from 'types/swarm.types'; interface ISwarmForm extends ISwarmFormInput { onSubmit: () => void; diff --git a/locust/webui/src/components/SwarmForm/SwarmForm.tsx b/locust/webui/src/components/SwarmForm/SwarmForm.tsx index 97a5b98f19..9158227ef1 100644 --- a/locust/webui/src/components/SwarmForm/SwarmForm.tsx +++ b/locust/webui/src/components/SwarmForm/SwarmForm.tsx @@ -20,16 +20,11 @@ import CustomParameters from 'components/SwarmForm/SwarmCustomParameters'; import SwarmUserClassPicker from 'components/SwarmForm/SwarmUserClassPicker'; import { SWARM_STATE } from 'constants/swarm'; import { useStartSwarmMutation } from 'redux/api/swarm'; -import { swarmActions, ISwarmState } from 'redux/slice/swarm.slice'; +import { swarmActions } from 'redux/slice/swarm.slice'; import { IRootState } from 'redux/store'; +import { ISwarmFormInput, ISwarmState } from 'types/swarm.types'; import { isEmpty } from 'utils/object'; -interface ISwarmFormInput extends Pick { - runTime: string; - userClasses: string[]; - shapeClass: string; -} - interface IDispatchProps { setSwarm: (swarmPayload: Partial) => void; } @@ -54,6 +49,7 @@ interface ISwarmForm message: string; }; isDisabled?: boolean; + onFormChange?: (formData: React.ChangeEvent) => void; } function SwarmForm({ @@ -70,23 +66,39 @@ function SwarmForm({ spawnRate, alert, isDisabled = false, + onFormChange, }: ISwarmForm) { const [startSwarm] = useStartSwarmMutation(); + const [errorMessage, setErrorMessage] = useState(''); const [selectedUserClasses, setSelectedUserClasses] = useState(availableUserClasses); - const onStartSwarm = (inputData: ISwarmFormInput) => { - setSwarm({ - state: SWARM_STATE.RUNNING, - host: inputData.host || host, - runTime: inputData.runTime, - spawnRate: Number(inputData.spawnRate) || null, - numUsers: Number(inputData.userCount) || null, - }); - - startSwarm({ + const onStartSwarm = async (inputData: ISwarmFormInput) => { + const { data } = await startSwarm({ ...inputData, ...(showUserclassPicker && selectedUserClasses ? { userClasses: selectedUserClasses } : {}), }); + + if (data && data.success) { + setSwarm({ + state: SWARM_STATE.RUNNING, + host: inputData.host || host, + runTime: inputData.runTime, + spawnRate: Number(inputData.spawnRate) || null, + numUsers: Number(inputData.userCount) || null, + }); + } else { + setErrorMessage(data ? data.message : 'An unknown error occured.'); + } + }; + + const handleSwarmFormChange = (formEvent: React.ChangeEvent) => { + if (errorMessage) { + setErrorMessage(''); + } + + if (onFormChange) { + onFormChange(formEvent); + } }; return ( @@ -103,7 +115,7 @@ function SwarmForm({ /> )} - onSubmit={onStartSwarm}> + onChange={handleSwarmFormChange} onSubmit={onStartSwarm}> {!isEmpty(extraOptions) && } - {alert && {alert.message}} + {alert && !errorMessage && ( + {alert.message} + )} + {errorMessage && {errorMessage}} diff --git a/locust/webui/src/components/SwarmForm/SwarmUserClassPicker.tsx b/locust/webui/src/components/SwarmForm/SwarmUserClassPicker.tsx index a082f8b773..d9f5d97a95 100644 --- a/locust/webui/src/components/SwarmForm/SwarmUserClassPicker.tsx +++ b/locust/webui/src/components/SwarmForm/SwarmUserClassPicker.tsx @@ -23,9 +23,9 @@ import Form from 'components/Form/Form'; import Select from 'components/Form/Select'; import Modal from 'components/Modal/Modal'; import { useUpdateUserSettingsMutation } from 'redux/api/swarm'; -import { ISwarmState, swarmActions } from 'redux/slice/swarm.slice'; +import { swarmActions } from 'redux/slice/swarm.slice'; import { IRootState } from 'redux/store'; -import { ISwarmUser } from 'types/swarm.types'; +import { ISwarmState, ISwarmUser } from 'types/swarm.types'; import { toTitleCase } from 'utils/string'; interface IDispatchProps { diff --git a/locust/webui/src/constants/swarm.ts b/locust/webui/src/constants/swarm.ts index fa9d2e5bdb..12a912251c 100644 --- a/locust/webui/src/constants/swarm.ts +++ b/locust/webui/src/constants/swarm.ts @@ -1,5 +1,4 @@ -import { ISwarmState } from 'redux/slice/swarm.slice'; -import { IReport, IReportTemplateArgs } from 'types/swarm.types'; +import { IReport, IReportTemplateArgs, ISwarmState } from 'types/swarm.types'; import { ICharts } from 'types/ui.types'; import { updateArraysAtProps } from 'utils/object'; import { camelCaseKeys } from 'utils/string'; diff --git a/locust/webui/src/redux/api/swarm.ts b/locust/webui/src/redux/api/swarm.ts index 23735ec6d0..c326f7f0cb 100644 --- a/locust/webui/src/redux/api/swarm.ts +++ b/locust/webui/src/redux/api/swarm.ts @@ -1,5 +1,6 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { ISwarmFormInput } from 'types/swarm.types'; import { IStatsResponse, ISwarmExceptionsResponse, @@ -9,6 +10,12 @@ import { import { createFormData } from 'utils/object'; import { camelCaseKeys, snakeCaseKeys } from 'utils/string'; +interface IStartSwarmResponse { + success: boolean; + message: string; + host: string; +} + export const api = createApi({ baseQuery: fetchBaseQuery(), endpoints: builder => ({ @@ -29,7 +36,7 @@ export const api = createApi({ transformResponse: camelCaseKeys, }), - startSwarm: builder.mutation({ + startSwarm: builder.mutation({ query: body => ({ url: 'swarm', method: 'POST', diff --git a/locust/webui/src/redux/slice/root.slice.ts b/locust/webui/src/redux/slice/root.slice.ts index a6b75df80e..967b8df518 100644 --- a/locust/webui/src/redux/slice/root.slice.ts +++ b/locust/webui/src/redux/slice/root.slice.ts @@ -6,10 +6,11 @@ import notification, { INotificationState, NotificationAction, } from 'redux/slice/notification.slice'; -import swarm, { ISwarmState, SwarmAction } from 'redux/slice/swarm.slice'; +import swarm, { SwarmAction } from 'redux/slice/swarm.slice'; import theme, { IThemeState, ThemeAction } from 'redux/slice/theme.slice'; import ui, { IUiState, UiAction } from 'redux/slice/ui.slice'; import url, { IUrlState, UrlAction } from 'redux/slice/url.slice'; +import { ISwarmState } from 'types/swarm.types'; export interface IRootState { logViewer: ILogViewerState; diff --git a/locust/webui/src/redux/slice/swarm.slice.ts b/locust/webui/src/redux/slice/swarm.slice.ts index 718238c627..8a15e6d33a 100644 --- a/locust/webui/src/redux/slice/swarm.slice.ts +++ b/locust/webui/src/redux/slice/swarm.slice.ts @@ -2,38 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { swarmTemplateArgs } from 'constants/swarm'; import { updateStateWithPayload } from 'redux/utils'; -import { IExtraOptions, IHistory, ISwarmUser } from 'types/swarm.types'; -import { ITab } from 'types/tab.types'; -import { ITableStructure } from 'types/table.types'; - -export interface ISwarmState { - availableShapeClasses: string[]; - availableUserClasses: string[]; - availableUserTasks: { [key: string]: string[] }; - extraOptions: IExtraOptions; - extendedTabs?: ITab[]; - extendedTables?: { key: string; structure: ITableStructure[] }[]; - extendedCsvFiles?: { href: string; title: string }[]; - history: IHistory[]; - host: string; - isDistributed: boolean; - isShape: boolean | null; - locustfile: string; - numUsers: number | null; - overrideHostWarning: boolean; - percentilesToChart: number[]; - percentilesToStatistics: number[]; - runTime?: string | number; - showUserclassPicker: boolean; - spawnRate: number | null; - state: string; - statsHistoryEnabled: boolean; - tasks: string; - userCount: number | string; - users: { [key: string]: ISwarmUser }; - version: string; - workerCount: number; -} +import { ISwarmState } from 'types/swarm.types'; export type SwarmAction = PayloadAction>; diff --git a/locust/webui/src/types/swarm.types.ts b/locust/webui/src/types/swarm.types.ts index 4f899cce78..6626a0ec48 100644 --- a/locust/webui/src/types/swarm.types.ts +++ b/locust/webui/src/types/swarm.types.ts @@ -1,3 +1,5 @@ +import { ITab } from 'types/tab.types'; +import { ITableStructure } from 'types/table.types'; import { ICharts, ISwarmError, @@ -18,7 +20,7 @@ export interface IExtraOptions { [key: string]: IExtraOptionParameter; } -export interface IHistory { +interface IHistory { currentRps: [string, number]; currentFailPerSec: [string, number]; userCount: [string, number]; @@ -29,6 +31,42 @@ export interface IHistory { time: string; } +export interface ISwarmUser { + fixedCount: number; + host: string; + weight: number; + tasks: string[]; +} + +export interface ISwarmState { + availableShapeClasses: string[]; + availableUserClasses: string[]; + availableUserTasks: { [key: string]: string[] }; + extraOptions: IExtraOptions; + extendedTabs?: ITab[]; + extendedTables?: { key: string; structure: ITableStructure[] }[]; + extendedCsvFiles?: { href: string; title: string }[]; + history: IHistory[]; + host: string; + isDistributed: boolean; + isShape: boolean | null; + locustfile: string; + numUsers: number | null; + overrideHostWarning: boolean; + percentilesToChart: number[]; + percentilesToStatistics: number[]; + runTime?: string | number; + showUserclassPicker: boolean; + spawnRate: number | null; + state: string; + statsHistoryEnabled: boolean; + tasks: string; + userCount: number | string; + users: { [key: string]: ISwarmUser }; + version: string; + workerCount: number; +} + export interface IReport { locustfile: string; showDownloadLink: boolean; @@ -50,9 +88,9 @@ export interface IReportTemplateArgs extends Omit { percentilesToStatistics: number[]; } -export interface ISwarmUser { - fixedCount: number; - host: string; - weight: number; - tasks: string[]; +export interface ISwarmFormInput + extends Partial> { + runTime?: string; + userClasses?: string[]; + shapeClass?: string; } diff --git a/locust/webui/src/types/window.types.ts b/locust/webui/src/types/window.types.ts index cfd6b3c306..d437c2300d 100644 --- a/locust/webui/src/types/window.types.ts +++ b/locust/webui/src/types/window.types.ts @@ -1,8 +1,7 @@ import { PaletteMode } from '@mui/material'; -import type { ISwarmState } from 'redux/slice/swarm.slice'; import { IAuthArgs } from 'types/auth.types'; -import { IReportTemplateArgs } from 'types/swarm.types'; +import { IReportTemplateArgs, ISwarmState } from 'types/swarm.types'; export interface IWindow { templateArgs: IReportTemplateArgs | ISwarmState; diff --git a/locust/webui/src/utils/object.ts b/locust/webui/src/utils/object.ts index adbb4dbc56..ed4da66a29 100644 --- a/locust/webui/src/utils/object.ts +++ b/locust/webui/src/utils/object.ts @@ -8,7 +8,11 @@ export function shallowMerge(objectA: ObjectA, objectB: Object }; } -export const createFormData = (inputData: { [key: string]: string | string[] }) => { +export const createFormData = < + IInputdata extends Record = { [key: string]: string | string[] }, +>( + inputData: IInputdata, +) => { const formData = new URLSearchParams(); for (const [key, value] of Object.entries(inputData)) {