From 3c86363f8207bc742b742a988716dca321002b56 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 11 Oct 2025 15:45:01 -0500 Subject: [PATCH] add main wizard dialog component --- .../settings/CameraWizardDialog.tsx | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 web/src/components/settings/CameraWizardDialog.tsx diff --git a/web/src/components/settings/CameraWizardDialog.tsx b/web/src/components/settings/CameraWizardDialog.tsx new file mode 100644 index 000000000..3085f911b --- /dev/null +++ b/web/src/components/settings/CameraWizardDialog.tsx @@ -0,0 +1,381 @@ +import StepIndicator from "@/components/indicators/StepIndicator"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useTranslation } from "react-i18next"; +import { useCallback, useState, useEffect, useReducer } from "react"; +import { toast } from "sonner"; +import useSWR from "swr"; +import axios from "axios"; +import Step1NameCamera from "./Step1NameCamera"; +import Step2StreamConfig from "./Step2StreamConfig"; +import Step3Validation from "./Step3Validation"; +import type { + WizardFormData, + CameraConfigData, + ConfigSetBody, +} from "@/types/cameraWizard"; + +type WizardState = { + wizardData: Partial; + shouldNavigateNext: boolean; +}; + +type WizardAction = + | { type: "UPDATE_DATA"; payload: Partial } + | { type: "UPDATE_AND_NEXT"; payload: Partial } + | { type: "RESET_NAVIGATE" }; + +const wizardReducer = ( + state: WizardState, + action: WizardAction, +): WizardState => { + switch (action.type) { + case "UPDATE_DATA": + return { + ...state, + wizardData: { ...state.wizardData, ...action.payload }, + }; + case "UPDATE_AND_NEXT": + return { + wizardData: { ...state.wizardData, ...action.payload }, + shouldNavigateNext: true, + }; + case "RESET_NAVIGATE": + return { ...state, shouldNavigateNext: false }; + default: + return state; + } +}; + +const STEPS = [ + "cameraWizard.steps.nameAndConnection", + "cameraWizard.steps.streamConfiguration", + "cameraWizard.steps.validationAndTesting", +]; + +type CameraWizardDialogProps = { + open: boolean; + onClose: () => void; +}; + +export default function CameraWizardDialog({ + open, + onClose, +}: CameraWizardDialogProps) { + const { t } = useTranslation(["views/settings"]); + const { mutate: updateConfig } = useSWR("config"); + const [currentStep, setCurrentStep] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [state, dispatch] = useReducer(wizardReducer, { + wizardData: { streams: [] }, + shouldNavigateNext: false, + }); + + // Reset wizard when opened + useEffect(() => { + if (open) { + setCurrentStep(0); + dispatch({ type: "UPDATE_DATA", payload: { streams: [] } }); + } + }, [open]); + + const handleClose = useCallback(() => { + setCurrentStep(0); + dispatch({ type: "UPDATE_DATA", payload: { streams: [] } }); + onClose(); + }, [onClose]); + + const onUpdate = useCallback((data: Partial) => { + dispatch({ type: "UPDATE_DATA", payload: data }); + }, []); + + const canProceedToNext = useCallback((): boolean => { + switch (currentStep) { + case 0: + // Can proceed if camera name is set and at least one stream exists + return !!( + state.wizardData.cameraName && + (state.wizardData.streams?.length ?? 0) > 0 + ); + case 1: + // Can proceed if at least one stream has 'detect' role + return !!( + state.wizardData.streams?.some((stream) => + stream.roles.includes("detect"), + ) ?? false + ); + case 2: + // Always can proceed from final step (save will be handled there) + return true; + default: + return false; + } + }, [currentStep, state.wizardData]); + + const handleNext = useCallback( + (data?: Partial) => { + if (data) { + // Atomic update and navigate + dispatch({ type: "UPDATE_AND_NEXT", payload: data }); + } else { + // Just navigate + if (currentStep < STEPS.length - 1 && canProceedToNext()) { + setCurrentStep((s) => s + 1); + } + } + }, + [currentStep, canProceedToNext], + ); + + const handleBack = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + + // Handle navigation after atomic update + useEffect(() => { + if (state.shouldNavigateNext) { + if (currentStep < STEPS.length - 1 && canProceedToNext()) { + setCurrentStep((s) => s + 1); + } + dispatch({ type: "RESET_NAVIGATE" }); + } + }, [state.shouldNavigateNext, currentStep, canProceedToNext]); + + // Handle wizard save + const handleSave = useCallback( + (wizardData: WizardFormData) => { + if (!wizardData.cameraName || !wizardData.streams) { + toast.error("Invalid wizard data"); + return; + } + + setIsLoading(true); + + // Convert wizard data to Frigate config format + const cameraName = wizardData.cameraName; + const configData: CameraConfigData = { + cameras: { + [cameraName]: { + enabled: true, + ffmpeg: { + inputs: wizardData.streams.map((stream, index) => { + const isRestreamed = + wizardData.restreamIds?.includes(stream.id) ?? false; + if (isRestreamed) { + const go2rtcStreamName = + wizardData.streams!.length === 1 + ? cameraName + : `${cameraName}_${index + 1}`; + return { + path: `rtsp://127.0.0.1:8554/${go2rtcStreamName}`, + input_args: "preset-rtsp-restream", + roles: stream.roles, + }; + } else { + return { + path: stream.url, + roles: stream.roles, + }; + } + }), + }, + }, + }, + }; + + // Add friendly name if different from camera name + if (wizardData.cameraName !== cameraName) { + configData.cameras[cameraName].friendly_name = wizardData.cameraName; + } + + // Add live.streams configuration for go2rtc streams + if (wizardData.streams && wizardData.streams.length > 0) { + configData.cameras[cameraName].live = { + streams: {}, + }; + wizardData.streams.forEach((_, index) => { + const go2rtcStreamName = + wizardData.streams!.length === 1 + ? cameraName + : `${cameraName}_${index + 1}`; + configData.cameras[cameraName].live!.streams[`Stream ${index + 1}`] = + go2rtcStreamName; + }); + } + + const requestBody: ConfigSetBody = { + requires_restart: 1, + config_data: configData, + update_topic: `config/cameras/${cameraName}/add`, + }; + + axios + .put("config/set", requestBody) + .then((response) => { + if (response.status === 200) { + // Configure go2rtc streams for all streams + if (wizardData.streams && wizardData.streams.length > 0) { + const go2rtcStreams: Record = {}; + + wizardData.streams.forEach((stream, index) => { + // Use camera name with index suffix for multiple streams + const streamName = + wizardData.streams!.length === 1 + ? cameraName + : `${cameraName}_${index + 1}`; + go2rtcStreams[streamName] = [stream.url]; + }); + + if (Object.keys(go2rtcStreams).length > 0) { + // Update frigate go2rtc config for persistence + const go2rtcConfigData = { + go2rtc: { + streams: go2rtcStreams, + }, + }; + + const go2rtcRequestBody = { + requires_restart: 0, + config_data: go2rtcConfigData, + }; + + axios + .put("config/set", go2rtcRequestBody) + .then(() => { + // also update the running go2rtc instance for immediate effect + const updatePromises = Object.entries(go2rtcStreams).map( + ([streamName, urls]) => + axios.put( + `go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`, + ), + ); + + Promise.allSettled(updatePromises).then(() => { + toast.success( + t("cameraWizard.save.successWithLive", { + cameraName: wizardData.cameraName, + }), + { position: "top-center" }, + ); + updateConfig(); + onClose(); + }); + }) + .catch(() => { + // log the error but don't fail the entire save + toast.warning( + t("cameraWizard.save.successWithoutLive", { + cameraName: wizardData.cameraName, + }), + { position: "top-center" }, + ); + updateConfig(); + onClose(); + }); + } else { + // No valid streams found + toast.success( + t("cameraWizard.save.successWithoutLive", { + cameraName: wizardData.cameraName, + }), + { position: "top-center" }, + ); + updateConfig(); + onClose(); + } + } else { + toast.success( + t("camera.cameraConfig.toast.success", { + cameraName: wizardData.cameraName, + }), + { position: "top-center" }, + ); + updateConfig(); + onClose(); + } + } else { + throw new Error(response.statusText); + } + }) + .catch((error) => { + const apiError = error as { + response?: { data?: { message?: string; detail?: string } }; + message?: string; + }; + const errorMessage = + apiError.response?.data?.message || + apiError.response?.data?.detail || + apiError.message || + "Unknown error"; + + toast.error( + t("toast.save.error.title", { + errorMessage, + ns: "common", + }), + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [updateConfig, t, onClose], + ); + + return ( + + + + + {t("cameraWizard.title")} + {t("cameraWizard.description")} + + +
+
+ {currentStep === 0 && ( + + )} + {currentStep === 1 && ( + + )} + {currentStep === 2 && ( + + )} +
+
+
+
+ ); +}