import Heading from "@/components/ui/heading"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { Toaster } from "sonner"; import { 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"; type CameraSettingsViewProps = { selectedCamera: string; setUnsavedChanges: React.Dispatch>; }; type CameraReviewSettingsValueType = { alerts_zones: string[]; detections_zones: string[]; }; export default function CameraSettingsView({ selectedCamera, setUnsavedChanges, }: CameraSettingsViewProps) { 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 { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; // 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) => label.replaceAll("_", " ")) .join(", ") : ""; }, [cameraConfig]); const detectionsLabels = useMemo(() => { return cameraConfig?.review.detections.labels ? cameraConfig.review.detections.labels .map((label) => label.replaceAll("_", " ")) .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 handleCheckedChange = useCallback( (isChecked: boolean) => { if (!isChecked) { form.reset({ alerts_zones: watchedAlertsZones, detections_zones: cameraConfig?.review.detections.required_zones || [], }); } setChangedValue(true); setSelectDetections(isChecked as boolean); }, [watchedAlertsZones, cameraConfig, form], ); const saveToConfig = useCallback( async ( { alerts_zones, detections_zones }: CameraReviewSettingsValueType, // values submitted via the form ) => { const alertQueries = [...alerts_zones] .map( (zone) => `&cameras.${selectedCamera}.review.alerts.required_zones=${zone}`, ) .join(""); const detectionQueries = [...detections_zones] .map( (zone) => `&cameras.${selectedCamera}.review.detections.required_zones=${zone}`, ) .join(""); axios .put(`config/set?${alertQueries}${detectionQueries}`, { requires_restart: 0, }) .then((res) => { if (res.status === 200) { toast.success( `Review classification configuration has been saved. Restart Frigate to apply changes.`, { position: "top-center", }, ); updateConfig(); } else { toast.error(`Failed to save config changes: ${res.statusText}`, { position: "top-center", }); } }) .catch((error) => { toast.error( `Failed to save config changes: ${error.response.data.message}`, { position: "top-center" }, ); }) .finally(() => { setIsLoading(false); }); }, [updateConfig, setIsLoading, selectedCamera], ); 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, ); }, [removeMessage, selectedCamera, setUnsavedChanges, form, 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", `Unsaved review classification settings for ${capitalizeFirstLetter(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 = "Camera Settings - Frigate"; }, []); if (!cameraConfig && !selectedCamera) { return ; } return ( <>
Camera Settings Review Classification

Frigate categorizes review items as Alerts and Detections. By default, all person and car objects are considered Alerts. You can refine categorization of your review items by configuring required zones for them.

Read the Documentation{" "}
0 && "grid items-start gap-5 md:grid-cols-2", )} > ( {zones && zones?.length > 0 ? ( <>
Alerts{" "} Select zones for Alerts
{zones?.map((zone) => ( { return ( { setChangedValue(true); return checked ? field.onChange([ ...field.value, zone.name, ]) : field.onChange( field.value?.filter( (value) => value !== zone.name, ), ); }} /> {zone.name.replaceAll("_", " ")} ); }} /> ))}
) : (
No zones are defined for this camera.
)}
All {alertsLabels} objects {watchedAlertsZones && watchedAlertsZones.length > 0 ? ` detected in ${watchedAlertsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}` : ""}{" "} on{" "} {capitalizeFirstLetter( cameraConfig?.name ?? "", ).replaceAll("_", " ")}{" "} will be shown as Alerts.
)} /> ( {zones && zones?.length > 0 && ( <>
Detections{" "} {selectDetections && ( Select zones for Detections )}
{selectDetections && (
{zones?.map((zone) => ( { return ( { return checked ? field.onChange([ ...field.value, zone.name, ]) : field.onChange( field.value?.filter( (value) => value !== zone.name, ), ); }} /> {zone.name.replaceAll("_", " ")} ); }} /> ))}
)}
)}
All {detectionsLabels} objects{" "} not classified as Alerts{" "} {watchedDetectionsZones && watchedDetectionsZones.length > 0 ? ` that are detected in ${watchedDetectionsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}` : ""}{" "} on{" "} {capitalizeFirstLetter( cameraConfig?.name ?? "", ).replaceAll("_", " ")}{" "} will be shown as Detections {(!selectDetections || (watchedDetectionsZones && watchedDetectionsZones.length === 0)) && ", regardless of zone"} .
)} />
); }