import Heading from "@/components/ui/heading"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { Toaster, toast } from "sonner"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { Checkbox } from "@/components/ui/checkbox"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import axios from "axios"; import { Link } from "react-router-dom"; import { LuExternalLink } from "react-icons/lu"; import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { MdCircle } from "react-icons/md"; import { cn } from "@/lib/utils"; import { Trans, useTranslation } from "react-i18next"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { getTranslatedLabel } from "@/utils/i18n"; import { useAlertsState, useDetectionsState, useObjectDescriptionState, useReviewDescriptionState, } from "@/api/ws"; import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import { IoMdArrowRoundBack } from "react-icons/io"; import { isDesktop } from "react-device-detect"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; type CameraSettingsViewProps = { selectedCamera: string; setUnsavedChanges: React.Dispatch>; }; type CameraReviewSettingsValueType = { alerts_zones: string[]; detections_zones: string[]; }; export default function CameraSettingsView({ selectedCamera, setUnsavedChanges, }: CameraSettingsViewProps) { const { t } = useTranslation(["views/settings"]); const { getLocaleDocUrl } = useDocDomain(); const { data: config, mutate: updateConfig } = useSWR("config"); const cameraConfig = useMemo(() => { if (config && selectedCamera) { return config.cameras[selectedCamera]; } }, [config, selectedCamera]); const [changedValue, setChangedValue] = useState(false); const [isLoading, setIsLoading] = useState(false); const [selectDetections, setSelectDetections] = useState(false); const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">( "settings", ); // Control view state const [editCameraName, setEditCameraName] = useState( undefined, ); // Track camera being edited const [showWizard, setShowWizard] = useState(false); const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; const selectCameraName = useCameraFriendlyName(selectedCamera); // zones and labels const zones = useMemo(() => { if (cameraConfig) { return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ camera: cameraConfig.name, name, objects: zoneData.objects, color: zoneData.color, })); } }, [cameraConfig]); const alertsLabels = useMemo(() => { return cameraConfig?.review.alerts.labels ? cameraConfig.review.alerts.labels .map((label) => getTranslatedLabel( label, cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", ), ) .join(", ") : ""; }, [cameraConfig]); const detectionsLabels = useMemo(() => { return cameraConfig?.review.detections.labels ? cameraConfig.review.detections.labels .map((label) => getTranslatedLabel( label, cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", ), ) .join(", ") : ""; }, [cameraConfig]); // form const formSchema = z.object({ alerts_zones: z.array(z.string()), detections_zones: z.array(z.string()), }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues: { alerts_zones: cameraConfig?.review.alerts.required_zones || [], detections_zones: cameraConfig?.review.detections.required_zones || [], }, }); const watchedAlertsZones = form.watch("alerts_zones"); const watchedDetectionsZones = form.watch("detections_zones"); const { payload: alertsState, send: sendAlerts } = useAlertsState(selectedCamera); const { payload: detectionsState, send: sendDetections } = useDetectionsState(selectedCamera); const { payload: objDescState, send: sendObjDesc } = useObjectDescriptionState(selectedCamera); const { payload: revDescState, send: sendRevDesc } = useReviewDescriptionState(selectedCamera); const handleCheckedChange = useCallback( (isChecked: boolean) => { if (!isChecked) { form.reset({ alerts_zones: watchedAlertsZones, detections_zones: [], }); } setChangedValue(true); setSelectDetections(isChecked as boolean); }, // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps [watchedAlertsZones], ); const saveToConfig = useCallback( async ( { alerts_zones, detections_zones }: CameraReviewSettingsValueType, // values submitted via the form ) => { const createQuery = (zones: string[], type: "alerts" | "detections") => zones.length ? zones .map( (zone) => `&cameras.${selectedCamera}.review.${type}.required_zones=${zone}`, ) .join("") : cameraConfig?.review[type]?.required_zones && cameraConfig?.review[type]?.required_zones.length > 0 ? `&cameras.${selectedCamera}.review.${type}.required_zones` : ""; const alertQueries = createQuery(alerts_zones, "alerts"); const detectionQueries = createQuery(detections_zones, "detections"); axios .put(`config/set?${alertQueries}${detectionQueries}`, { requires_restart: 0, }) .then((res) => { if (res.status === 200) { toast.success( t("cameraReview.reviewClassification.toast.success"), { position: "top-center", }, ); updateConfig(); } else { toast.error( t("toast.save.error.title", { errorMessage: res.statusText, ns: "common", }), { position: "top-center", }, ); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("toast.save.error.title", { errorMessage, ns: "common", }), { position: "top-center", }, ); }) .finally(() => { setIsLoading(false); }); }, [updateConfig, setIsLoading, selectedCamera, cameraConfig, t], ); const onCancel = useCallback(() => { if (!cameraConfig) { return; } setChangedValue(false); setUnsavedChanges(false); removeMessage( "camera_settings", `review_classification_settings_${selectedCamera}`, ); form.reset({ alerts_zones: cameraConfig?.review.alerts.required_zones ?? [], detections_zones: cameraConfig?.review.detections.required_zones || [], }); setSelectDetections( !!cameraConfig?.review.detections.required_zones?.length, ); // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [removeMessage, selectedCamera, setUnsavedChanges, cameraConfig]); useEffect(() => { onCancel(); // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedCamera]); useEffect(() => { if (changedValue) { addMessage( "camera_settings", t("cameraReview.reviewClassification.unsavedChanges", { camera: selectedCamera, }), undefined, `review_classification_settings_${selectedCamera}`, ); } else { removeMessage( "camera_settings", `review_classification_settings_${selectedCamera}`, ); } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [changedValue, selectedCamera]); function onSubmit(values: z.infer) { setIsLoading(true); saveToConfig(values as CameraReviewSettingsValueType); } useEffect(() => { document.title = t("documentTitle.cameraReview"); }, [t]); // Handle back navigation from add/edit form const handleBack = useCallback(() => { setViewMode("settings"); setEditCameraName(undefined); updateConfig(); }, [updateConfig]); if (!cameraConfig && !selectedCamera && viewMode === "settings") { return ; } return ( <>
{viewMode === "settings" ? ( <> {t("cameraReview.title")} cameraReview.review.title
{ sendAlerts(isChecked ? "ON" : "OFF"); }} />
{ sendDetections(isChecked ? "ON" : "OFF"); }} />
cameraReview.review.desc
{cameraConfig?.objects?.genai?.enabled_in_config && ( <> cameraReview.object_descriptions.title
{ sendObjDesc(isChecked ? "ON" : "OFF"); }} />
cameraReview.object_descriptions.desc
)} {cameraConfig?.review?.genai?.enabled_in_config && ( <> cameraReview.review_descriptions.title
{ sendRevDesc(isChecked ? "ON" : "OFF"); }} />
cameraReview.review_descriptions.desc
)} cameraReview.reviewClassification.title

cameraReview.reviewClassification.desc

{t("readTheDocumentation", { ns: "common" })}
0 && "grid items-start gap-5 md:grid-cols-2", )} > ( {zones && zones?.length > 0 ? ( <>
camera.review.alerts cameraReview.reviewClassification.selectAlertsZones
{zones?.map((zone) => ( ( { setChangedValue(true); return checked ? field.onChange([ ...field.value, zone.name, ]) : field.onChange( field.value?.filter( (value) => value !== zone.name, ), ); }} /> {zone.name.replaceAll("_", " ")} )} /> ))}
) : (
cameraReview.reviewClassification.noDefinedZones
)}
{watchedAlertsZones && watchedAlertsZones.length > 0 ? t( "cameraReview.reviewClassification.zoneObjectAlertsTips", { alertsLabels, zone: watchedAlertsZones .map((zone) => capitalizeFirstLetter(zone).replaceAll( "_", " ", ), ) .join(", "), cameraName: selectCameraName, }, ) : t( "cameraReview.reviewClassification.objectAlertsTips", { alertsLabels, cameraName: selectCameraName, }, )}
)} /> ( {zones && zones?.length > 0 && ( <>
camera.review.detections {selectDetections && ( cameraReview.reviewClassification.selectDetectionsZones )}
{selectDetections && (
{zones?.map((zone) => ( ( { return checked ? field.onChange([ ...field.value, zone.name, ]) : field.onChange( field.value?.filter( (value) => value !== zone.name, ), ); }} /> {zone.name.replaceAll("_", " ")} )} /> ))}
)}
)}
{watchedDetectionsZones && watchedDetectionsZones.length > 0 ? ( !selectDetections ? ( capitalizeFirstLetter(zone).replaceAll( "_", " ", ), ) .join(", "), cameraName: selectCameraName, }} ns="views/settings" /> ) : ( capitalizeFirstLetter(zone).replaceAll( "_", " ", ), ) .join(", "), cameraName: selectCameraName, }} ns="views/settings" /> ) ) : ( )}
)} />
) : ( <>
)}
setShowWizard(false)} /> ); }