import { useMemo, useState } from "react"; import { useEffect } from "react"; import { logger } from "@/Utils/logger"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; import { useParams, useLocation, useNavigate } from "react-router"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; import RadioGroup from "@mui/material/RadioGroup"; import FormControl from "@mui/material/FormControl"; import { Trans, useTranslation } from "react-i18next"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import Link from "@mui/material/Link"; import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; import { Trash2 } from "lucide-react"; import { HeaderDeleteControls } from "@/Components/monitors"; import { GeoContinents } from "@/Types/GeoCheck"; import { BasePage, ColoredLabel, ConfigBox, StepProgress, } from "@/Components/design-elements"; import { RadioWithDescription, Button, TextField, Select, Autocomplete, SwitchComponent as Switch, SliderWithLabel, Dialog, } from "@/Components/inputs"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi"; import { useMonitorForm, getMonitorDefaults } from "@/Hooks/useMonitorForm"; import { type Monitor, type MonitorType, type GamesMap, supportsGeoCheck, DefaultPageSpeedStrategy, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; import { stepFieldsFor, monitorStepCount, type MonitorFormData, } from "@/Validation/monitor"; interface GeneralSettingsConfig { urlLabel: string; urlPlaceholder: string; namePlaceholder: string; showUrl: boolean; showPort: boolean; showGameSelect: boolean; showSecret: boolean; showGrpcServiceName: boolean; showIgnoreTls: boolean; showStrategy: boolean; showDnsServer: boolean; showDnsRecordType: boolean; } const getGeneralSettingsConfig = ( type: MonitorType, t: (key: string) => string ): GeneralSettingsConfig => { const configs: Record = { http: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showPort: false, showGameSelect: false, showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, showStrategy: false, showDnsServer: false, showDnsRecordType: false, }, ping: { urlLabel: t("pages.createMonitor.form.general.option.host.label"), urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showPort: false, showGameSelect: false, showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, showStrategy: false, showDnsServer: false, showDnsRecordType: false, }, docker: { urlLabel: t("pages.createMonitor.form.general.option.container.label"), urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showPort: false, showGameSelect: false, showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, showStrategy: false, showDnsServer: false, showDnsRecordType: false, }, port: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showPort: true, showGameSelect: false, showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, showStrategy: false, showDnsServer: false, showDnsRecordType: false, }, game: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showPort: true, showGameSelect: true, showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, showStrategy: false, showDnsServer: false, showDnsRecordType: false, }, grpc: { urlLabel: t("pages.createMonitor.form.general.option.host.label"), urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showPort: true, showGameSelect: false, showSecret: false, showGrpcServiceName: true, showIgnoreTls: true, showStrategy: false, showDnsServer: false, showDnsRecordType: false, }, pagespeed: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showPort: false, showGameSelect: false, showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, showStrategy: true, showDnsServer: false, showDnsRecordType: false, }, hardware: { urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"), urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showPort: false, showGameSelect: false, showSecret: true, showGrpcServiceName: false, showIgnoreTls: false, showStrategy: false, showDnsServer: false, showDnsRecordType: false, }, websocket: { urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"), urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showPort: false, showGameSelect: false, showSecret: false, showGrpcServiceName: false, showIgnoreTls: true, showStrategy: false, showDnsServer: false, showDnsRecordType: false, }, dns: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showPort: false, showGameSelect: false, showSecret: false, showGrpcServiceName: false, showIgnoreTls: false, showStrategy: false, showDnsServer: true, showDnsRecordType: true, }, }; return configs[type] || configs.http; }; const CreateMonitorPage = () => { const theme = useTheme(); const { t } = useTranslation(); const { monitorId } = useParams(); const location = useLocation(); const navigate = useNavigate(); const isEditMode = Boolean(monitorId); // Extract page type from URL path (e.g., /pagespeed/create -> pagespeed) const pageType = useMemo(() => { const pathSegments = location.pathname.split("/").filter(Boolean); const firstSegment = pathSegments[0]; if (firstSegment === "pagespeed") return "pagespeed"; if (firstSegment === "infrastructure") return "hardware"; return "uptime"; }, [location.pathname]); const showTypeSelector = pageType === "uptime" && !isEditMode; const defaultType: MonitorType = pageType === "pagespeed" ? "pagespeed" : pageType === "hardware" ? "hardware" : "http"; const { data: existingMonitor, refetch: refetchMonitor } = useGet( isEditMode ? `/monitors/${monitorId}` : null ); const { data: notifications } = useGet("/notifications/team"); const { data: games } = useGet("/monitors/games"); const { data: tags } = useGet("/tags/team"); const { schema, defaults } = useMonitorForm({ data: existingMonitor ?? null, defaultType, }); const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); const { control, watch, handleSubmit, clearErrors, trigger, reset } = form; useEffect(() => { reset(defaults); }, [defaults, reset]); // Multi-step wizard on every create flow; edit mode keeps the flat form // where showStep() is always true. const [currentStep, setCurrentStep] = useState(0); const showStep = (step: number) => isEditMode || currentStep === step; const watchedType = watch("type") as MonitorType; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean; // Steps without an advanced section drop it, so the last step is the form's. const totalSteps = monitorStepCount(watchedType); const handleNext = async () => { const isValid = await trigger(stepFieldsFor(watchedType, currentStep)); if (isValid) { setCurrentStep((step) => step + 1); } }; const handleBack = () => setCurrentStep((step) => step - 1); // Reset the form when the monitor type changes so stale type-specific fields // can't produce an invalid monitor. Type can only be changed on step 0. useEffect(() => { if (showTypeSelector) { reset(getMonitorDefaults(watchedType)); } else { clearErrors(); } }, [watchedType, showTypeSelector, reset, clearErrors]); const generalSettingsConfig = useMemo( () => getGeneralSettingsConfig(watchedType, t), [watchedType, t] ); const { post, loading: isCreating } = usePost(); const { patch, loading: isUpdating } = usePatch(); const isSubmitting = isCreating || isUpdating; // Delete functionality const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const { deleteFn, loading: isDeleting } = useDelete(); const handleDeleteClick = () => { setIsDeleteDialogOpen(true); }; const handleDeleteConfirm = async () => { if (!monitorId) return; await deleteFn(`/monitors/${monitorId}`); setIsDeleteDialogOpen(false); // Navigate based on page type if (pageType === "pagespeed") { navigate("/pagespeed"); } else if (pageType === "hardware") { navigate("/infrastructure"); } else { navigate("/uptime"); } }; const handleDeleteCancel = () => { setIsDeleteDialogOpen(false); }; const onSubmit = async (data: MonitorFormData) => { let result; if (isEditMode && monitorId) { result = await patch(`/monitors/${monitorId}`, data); } else { result = await post("/monitors", data); } if (result?.success) { if (pageType === "pagespeed") { navigate("/pagespeed"); } else if (pageType === "hardware") { navigate("/infrastructure"); } else { navigate("/uptime"); } } }; const onError = (errors: unknown) => { logger.debug("Monitor creation validation errors", errors); }; return ( {!isEditMode && ( )} {/* Monitor Type Selection - only shown for uptime monitors */} {showTypeSelector && showStep(0) && ( ( )} /> } /> )} {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 && ( ( )} /> )} ( )} /> } /> )} {showStep(1) && ( ( )} /> } /> )} {/* Alert Thresholds - only for hardware type */} {generalSettingsConfig.showSecret && showStep(1) && ( ( `${value}%`} /> )} /> ( `${value}%`} /> )} /> ( `${value}%`} /> )} /> ( `${value}°C`} /> )} /> } /> )} {showStep(1) && ( ( )} /> ( )} /> } /> )} {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 && } ))} )} ); }} /> } /> )} {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" && ( { const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) => field.value?.includes(option.id) ); return ( option.name} isOptionEqualToValue={(option, value) => option.id === value.id} onChange={( _: unknown, newValue: typeof ALL_HTTP_STATUS_CODES ) => { field.onChange(newValue?.map((v) => v.id) || []); }} fieldLabel={t( "pages.createMonitor.form.advanced.option.customUpCodes.label" )} /> {selectedOptions.length > 0 && ( {selectedOptions.map((option, index) => ( {option.name} { field.onChange( field.value?.filter((id) => id !== option.id) ); }} aria-label={t( "pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel" )} > {index < selectedOptions.length - 1 && } ))} )} ); }} /> ( field.onChange(e.target.checked)} /> {t( "pages.createMonitor.form.advanced.option.advancedMatching.label" )} )} /> {watchedUseAdvancedMatching && ( ( )} /> ( )} /> ( )} /> ), }} /> )} } /> )} {showStep(2) && supportsGeoCheck(watchedType) && ( ( field.onChange(e.target.checked)} /> {t("pages.createMonitor.form.geoChecks.option.enabled.label")} )} /> {watchGeoCheckEnabled && ( { // Map continents to have 'name' property for Autocomplete const locationOptions = GeoContinents.map((continent) => ({ id: continent, name: t( `pages.createMonitor.form.geoChecks.option.locations.options.${continent}` ), })); const selectedLocations = locationOptions.filter((loc) => (field.value ?? []).includes(loc.id) ); return ( option.name} onChange={(_: unknown, newValue: typeof locationOptions) => { field.onChange(newValue.map((loc) => loc.id)); }} isOptionEqualToValue={(option, value) => option.id === value.id } fieldLabel={t( "pages.createMonitor.form.geoChecks.option.locations.label" )} /> {selectedLocations.length > 0 && ( {selectedLocations.map((location, index) => ( {location.name} { field.onChange( (field.value ?? []).filter( (id: string) => id !== location.id ) ); }} aria-label="Remove location" > {index < selectedLocations.length - 1 && } ))} )} ); }} /> ( )} /> )} } /> )} {!isEditMode && ( )} {!isEditMode && currentStep < totalSteps - 1 ? ( ) : ( )} ); }; export default CreateMonitorPage;