From c98ec7d0479d53d8318579d764b2df5bf344f417 Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Mon, 25 May 2026 23:35:51 +0530 Subject: [PATCH] feat(client): split uptime monitor creation into a 3-step wizard --- client/src/Pages/CreateMonitor/index.tsx | 1157 ++++++++++++---------- client/src/locales/en.json | 2 + 2 files changed, 628 insertions(+), 531 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 3aae2e5e1..292c38b83 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { logger } from "@/Utils/logger"; import { useParams, useLocation, useNavigate } from "react-router"; import { useForm, Controller } from "react-hook-form"; +import type { FieldPath } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; @@ -18,7 +19,12 @@ import { Trash2 } from "lucide-react"; import { HeaderDeleteControls } from "@/Components/monitors"; import { GeoContinents } from "@/Types/GeoCheck"; -import { BasePage, ColoredLabel, ConfigBox } from "@/Components/design-elements"; +import { + BasePage, + ColoredLabel, + ConfigBox, + StepProgress, +} from "@/Components/design-elements"; import { RadioWithDescription, Button, @@ -207,6 +213,33 @@ const getGeneralSettingsConfig = ( return configs[type] || configs.http; }; +const TOTAL_STEPS = 3; + +// Fields validated before advancing past each wizard step (uptime create only). +const STEP_FIELDS: FieldPath[][] = [ + [ + "type", + "url", + "name", + "port", + "gameId", + "grpcServiceName", + "dnsServer", + "dnsRecordType", + ], + ["interval", "statusWindowSize", "statusWindowThreshold", "notifications", "tags"], + [ + "ignoreTlsErrors", + "useAdvancedMatching", + "matchMethod", + "expectedValue", + "jsonPath", + "geoCheckEnabled", + "geoCheckLocations", + "geoCheckInterval", + ], +]; + const CreateMonitorPage = () => { const theme = useTheme(); const { t } = useTranslation(); @@ -249,12 +282,25 @@ const CreateMonitorPage = () => { resolver: zodResolver(schema), defaultValues: defaults, }); - const { control, watch, handleSubmit, clearErrors } = form; + const { control, watch, handleSubmit, clearErrors, trigger } = form; useEffect(() => { form.reset(defaults); }, [defaults, form]); + // Multi-step wizard (uptime create only). Other flows keep the flat form. + const isWizard = showTypeSelector; + const [currentStep, setCurrentStep] = useState(0); + const showStep = (step: number) => !isWizard || currentStep === step; + + const handleNext = async () => { + const isValid = await trigger(STEP_FIELDS[currentStep]); + if (isValid) { + setCurrentStep((step) => Math.min(step + 1, TOTAL_STEPS - 1)); + } + }; + const handleBack = () => setCurrentStep((step) => Math.max(step - 1, 0)); + const watchedType = watch("type") as MonitorType; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; @@ -332,8 +378,14 @@ const CreateMonitorPage = () => { refetch={refetchMonitor} onDelete={handleDeleteClick} /> + {isWizard && ( + + )} {/* Monitor Type Selection - only shown for uptime monitors */} - {showTypeSelector && ( + {showTypeSelector && showStep(0) && ( { /> )} - - ), - }} - /> - } - rightContent={ - - {/* URL/Host/Container field - not shown for hardware */} - {generalSettingsConfig.showUrl && ( - ( - - )} - /> - )} - - {/* Strategy field - only for pagespeed type */} - {generalSettingsConfig.showStrategy && ( - ( - - )} - /> - )} - - {/* Port field - only for port and game types */} - {generalSettingsConfig.showPort && ( - ( - { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - type="number" - fieldLabel={t("pages.createMonitor.form.general.option.port.label")} - placeholder={t( - "pages.createMonitor.form.general.option.port.placeholder" - )} - fullWidth - error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} - /> - )} - /> - )} - - {/* Game select - only for game type */} - {generalSettingsConfig.showGameSelect && ( - ( - - )} - /> - )} - - {/* gRPC Service Name field - only for grpc type */} - {generalSettingsConfig.showGrpcServiceName && ( - ( - - )} - /> - )} - - {/* Secret field - only for hardware type */} - {generalSettingsConfig.showSecret && ( - ( - - )} - /> - )} - {generalSettingsConfig.showDnsServer && ( - ( - - )} - /> - )} - {generalSettingsConfig.showDnsRecordType && ( - ( - - )} - /> - )} - - ( - + } + rightContent={ + + {/* URL/Host/Container field - not shown for hardware */} + {generalSettingsConfig.showUrl && ( + ( + + )} /> )} - /> - - } - /> - ( - + + {t("pages.createMonitor.form.general.option.strategy.desktop")} + + + {t("pages.createMonitor.form.general.option.strategy.mobile")} + + + )} + /> + )} + + {/* Port field - only for port and game types */} + {generalSettingsConfig.showPort && ( + ( + { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} + type="number" + fieldLabel={t("pages.createMonitor.form.general.option.port.label")} + placeholder={t( + "pages.createMonitor.form.general.option.port.placeholder" + )} + fullWidth + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + )} + + {/* Game select - only for game type */} + {generalSettingsConfig.showGameSelect && ( + ( + + )} + /> + )} + + {/* gRPC Service Name field - only for grpc type */} + {generalSettingsConfig.showGrpcServiceName && ( + ( + + )} + /> + )} + + {/* Secret field - only for hardware type */} + {generalSettingsConfig.showSecret && ( + ( + + )} + /> + )} + {generalSettingsConfig.showDnsServer && ( + ( + + )} + /> + )} + {generalSettingsConfig.showDnsRecordType && ( + ( + + )} + /> + )} + + ( + )} - error={!!fieldState.error} - > - - {t( - "pages.createMonitor.form.frequency.option.frequency.value.fifteenSeconds" + /> + + } + /> + )} + + {showStep(1) && ( + ( + - )} - /> - } - /> + error={!!fieldState.error} + > + + {t( + "pages.createMonitor.form.frequency.option.frequency.value.fifteenSeconds" + )} + + + {t( + "pages.createMonitor.form.frequency.option.frequency.value.thirtySeconds" + )} + + + {t( + "pages.createMonitor.form.frequency.option.frequency.value.oneMinute" + )} + + + {t( + "pages.createMonitor.form.frequency.option.frequency.value.twoMinutes" + )} + + + {t( + "pages.createMonitor.form.frequency.option.frequency.value.threeMinutes" + )} + + + {t( + "pages.createMonitor.form.frequency.option.frequency.value.fourMinutes" + )} + + + {t( + "pages.createMonitor.form.frequency.option.frequency.value.fiveMinutes" + )} + + + {t( + "pages.createMonitor.form.frequency.option.frequency.value.tenMinutes" + )} + + + {t( + "pages.createMonitor.form.frequency.option.frequency.value.fifteenMinutes" + )} + + + {t( + "pages.createMonitor.form.frequency.option.frequency.value.thirtyMinutes" + )} + + + )} + /> + } + /> + )} {/* Alert Thresholds - only for hardware type */} - {generalSettingsConfig.showSecret && ( + {generalSettingsConfig.showSecret && showStep(1) && ( { /> )} - - ( - - )} - /> - ( - - )} - /> - - } - /> - - { - // Map notifications to have 'name' property for Autocomplete - const notificationOptions = (notifications ?? []).map((n) => ({ - ...n, - name: n.notificationName, - })); - const selectedNotifications = notificationOptions.filter((n) => - (field.value ?? []).includes(n.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof notificationOptions) => { - field.onChange(newValue.map((n) => n.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - /> - {selectedNotifications.length > 0 && ( - - {selectedNotifications.map((notification, index) => ( - - - {notification.notificationName} - - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== notification.id - ) - ); - }} - aria-label="Remove notification" - > - - - {index < selectedNotifications.length - 1 && } - - ))} - - )} - - ); - }} - /> - } - /> - - { - const tagOptions = tags ?? []; - const selectedTags = tagOptions.filter((tag) => - (field.value ?? []).includes(tag.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof tagOptions) => { - field.onChange(newValue.map((tag) => tag.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - renderOptionContent={(option) => ( - - )} - /> - {selectedTags.length > 0 && ( - - {selectedTags.map((tag, index) => ( - - -
- { - field.onChange( - (field.value ?? []).filter((id: string) => id !== tag.id) - ); - }} - aria-label="Remove tag" - > - - - {index < selectedTags.length - 1 && } - - ))} - - )} - - ); - }} - /> - } - /> - - {(watchedType === "http" || - watchedType === "grpc" || - watchedType === "websocket") && ( + {showStep(1) && ( + ( + + )} + /> + ( + + )} + /> + + } + /> + )} + + {showStep(1) && ( + ( - - field.onChange(e.target.checked)} - /> - - {t("pages.createMonitor.form.ignoreTls.option.tls.label")} - - - )} + render={({ field }) => { + // Map notifications to have 'name' property for Autocomplete + const notificationOptions = (notifications ?? []).map((n) => ({ + ...n, + name: n.notificationName, + })); + const selectedNotifications = notificationOptions.filter((n) => + (field.value ?? []).includes(n.id) + ); + return ( + + option.name} + onChange={(_: unknown, newValue: typeof notificationOptions) => { + field.onChange(newValue.map((n) => n.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + /> + {selectedNotifications.length > 0 && ( + + {selectedNotifications.map((notification, index) => ( + + + {notification.notificationName} + + { + field.onChange( + (field.value ?? []).filter( + (id: string) => id !== notification.id + ) + ); + }} + aria-label="Remove notification" + > + + + {index < selectedNotifications.length - 1 && } + + ))} + + )} + + ); + }} /> } /> )} - {watchedType === "http" && ( + {showStep(1) && ( + { + const tagOptions = tags ?? []; + const selectedTags = tagOptions.filter((tag) => + (field.value ?? []).includes(tag.id) + ); + return ( + + option.name} + onChange={(_: unknown, newValue: typeof tagOptions) => { + field.onChange(newValue.map((tag) => tag.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderOptionContent={(option) => ( + + )} + /> + {selectedTags.length > 0 && ( + + {selectedTags.map((tag, index) => ( + + +
+ { + field.onChange( + (field.value ?? []).filter( + (id: string) => id !== tag.id + ) + ); + }} + aria-label="Remove tag" + > + + + {index < selectedTags.length - 1 && } + + ))} + + )} + + ); + }} + /> + } + /> + )} + + {showStep(2) && + (watchedType === "http" || + watchedType === "grpc" || + watchedType === "websocket") && ( + ( + + field.onChange(e.target.checked)} + /> + + {t("pages.createMonitor.form.ignoreTls.option.tls.label")} + + + )} + /> + } + /> + )} + + {showStep(2) && watchedType === "http" && ( { /> )} - {supportsGeoCheck(watchedType) && ( + {showStep(2) && supportsGeoCheck(watchedType) && ( { - + {isWizard && ( + + )} + {isWizard && currentStep < TOTAL_STEPS - 1 ? ( + + ) : ( + + )}