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"; import { Trans, useTranslation } from "react-i18next"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws"; import { useDocDomain } from "@/hooks/use-doc-domain"; 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 { 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) => t(label, { ns: "objects" })) .join(", ") : ""; }, [cameraConfig, t]); const detectionsLabels = useMemo(() => { return cameraConfig?.review.detections.labels ? cameraConfig.review.detections.labels .map((label) => t(label, { ns: "objects" })) .join(", ") : ""; }, [cameraConfig, t]); // 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: enabledState, send: sendEnabled } = useEnabledState(selectedCamera); const { payload: alertsState, send: sendAlerts } = useAlertsState(selectedCamera); const { payload: detectionsState, send: sendDetections } = useDetectionsState(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("camera.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("camera.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.camera"); }, [t]); if (!cameraConfig && !selectedCamera) { return ; } return ( <>
camera.title camera.streams.title
{ sendEnabled(isChecked ? "ON" : "OFF"); }} />
camera.streams.desc
camera.review.title
{ sendAlerts(isChecked ? "ON" : "OFF"); }} />
{ sendDetections(isChecked ? "ON" : "OFF"); }} />
camera.review.desc
camera.reviewClassification.title

camera.reviewClassification.desc

camera.reviewClassification.readTheDocumentation {" "}
0 && "grid items-start gap-5 md:grid-cols-2", )} > ( {zones && zones?.length > 0 ? ( <>
camera.review.alerts camera.reviewClassification.selectAlertsZones
{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("_", " ")} ); }} /> ))}
) : (
camera.reviewClassification.noDefinedZones
)}
{watchedAlertsZones && watchedAlertsZones.length > 0 ? t( "camera.reviewClassification.zoneObjectAlertsTips", { alertsLabels, zone: watchedAlertsZones .map((zone) => capitalizeFirstLetter(zone).replaceAll( "_", " ", ), ) .join(", "), cameraName: capitalizeFirstLetter( cameraConfig?.name ?? "", ).replaceAll("_", " "), }, ) : t("camera.reviewClassification.objectAlertsTips", { alertsLabels, cameraName: capitalizeFirstLetter( cameraConfig?.name ?? "", ).replaceAll("_", " "), })}
)} /> ( {zones && zones?.length > 0 && ( <>
camera.review.detections {selectDetections && ( camera.reviewClassification.selectDetectionsZones )}
{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("_", " ")} ); }} /> ))}
)}
)}
{watchedDetectionsZones && watchedDetectionsZones.length > 0 ? ( !selectDetections ? ( capitalizeFirstLetter(zone).replaceAll( "_", " ", ), ) .join(", "), cameraName: capitalizeFirstLetter( cameraConfig?.name ?? "", ).replaceAll("_", " "), }} ns="views/settings" > ) : ( capitalizeFirstLetter(zone).replaceAll( "_", " ", ), ) .join(", "), cameraName: capitalizeFirstLetter( cameraConfig?.name ?? "", ).replaceAll("_", " "), }} ns="views/settings" /> ) ) : ( )}
)} />
); }