From 213a1fbd00117df03519efc05be7aebfb7d69f98 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:33:42 -0600 Subject: [PATCH] Miscellaneous Fixes (#20951) * ensure viewer roles are available in create user dialog * admin-only endpoint to return unmaksed camera paths and go2rtc streams * remove camera edit dropdown pushing camera editing from the UI to 0.18 * clean up camera edit form * rename component for clarity CameraSettingsView is now CameraReviewSettingsView * Catch case where user requsts clip for time that has no recordings * ensure emergency cleanup also sets has_clip on overlapping events improves https://github.com/blakeblackshear/frigate/discussions/20945 * use debug log instead of info * update docs to recommend tmpfs * improve display of in-progress events in explore tracking details * improve seeking logic in tracking details mimic the logic of DynamicVideoController * only use ffprobe for duration to avoid blocking fixes https://github.com/blakeblackshear/frigate/discussions/20737#discussioncomment-14999869 * Revert "only use ffprobe for duration to avoid blocking" This reverts commit 8b15078005aebcfc6062fd0f773d749e3f4a4ee8. * update readme to link to object detector docs --------- Co-authored-by: Nicolas Mowen --- README.md | 2 +- docs/docs/frigate/installation.md | 4 +- frigate/api/app.py | 30 + frigate/api/media.py | 9 + frigate/storage.py | 55 +- .../components/overlay/CreateUserDialog.tsx | 46 +- .../overlay/detail/TrackingDetails.tsx | 217 ++++- .../components/settings/CameraEditForm.tsx | 87 +- web/src/pages/Settings.tsx | 4 +- .../views/settings/CameraManagementView.tsx | 33 - .../settings/CameraReviewSettingsView.tsx | 738 ++++++++++++++++ web/src/views/settings/CameraSettingsView.tsx | 794 ------------------ 12 files changed, 1114 insertions(+), 905 deletions(-) create mode 100644 web/src/views/settings/CameraReviewSettingsView.tsx delete mode 100644 web/src/views/settings/CameraSettingsView.tsx diff --git a/README.md b/README.md index 35e8cb7e9..7954fb960 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras. -Use of a GPU or AI accelerator such as a [Google Coral](https://coral.ai/products/) or [Hailo](https://hailo.ai/) is highly recommended. AI accelerators will outperform even the best CPUs with very little overhead. +Use of a GPU or AI accelerator is highly recommended. AI accelerators will outperform even the best CPUs with very little overhead. See Frigate's supported [object detectors](https://docs.frigate.video/configuration/object_detectors/). - Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration) - Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 6a6fb8106..06fdbcdc0 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -56,7 +56,7 @@ services: volumes: - /path/to/your/config:/config - /path/to/your/storage:/media/frigate - - type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear + - type: tmpfs # Recommended: 1GB of memory target: /tmp/cache tmpfs: size: 1000000000 @@ -310,7 +310,7 @@ services: - /etc/localtime:/etc/localtime:ro - /path/to/your/config:/config - /path/to/your/storage:/media/frigate - - type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear + - type: tmpfs # Recommended: 1GB of memory target: /tmp/cache tmpfs: size: 1000000000 diff --git a/frigate/api/app.py b/frigate/api/app.py index fa34b3dcd..3ef054fc0 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -179,6 +179,36 @@ def config(request: Request): return JSONResponse(content=config) +@router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))]) +def config_raw_paths(request: Request): + """Admin-only endpoint that returns camera paths and go2rtc streams without credential masking.""" + config_obj: FrigateConfig = request.app.frigate_config + + raw_paths = {"cameras": {}, "go2rtc": {"streams": {}}} + + # Extract raw camera ffmpeg input paths + for camera_name, camera in config_obj.cameras.items(): + raw_paths["cameras"][camera_name] = { + "ffmpeg": { + "inputs": [ + {"path": input.path, "roles": input.roles} + for input in camera.ffmpeg.inputs + ] + } + } + + # Extract raw go2rtc stream URLs + go2rtc_config = config_obj.go2rtc.model_dump( + mode="json", warnings="none", exclude_none=True + ) + for stream_name, stream in go2rtc_config.get("streams", {}).items(): + if stream is None: + continue + raw_paths["go2rtc"]["streams"][stream_name] = stream + + return JSONResponse(content=raw_paths) + + @router.get("/config/raw") def config_raw(): config_file = find_config_file() diff --git a/frigate/api/media.py b/frigate/api/media.py index 2bad3658f..a8eb71ce1 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -762,6 +762,15 @@ async def recording_clip( .order_by(Recordings.start_time.asc()) ) + if recordings.count() == 0: + return JSONResponse( + content={ + "success": False, + "message": "No recordings found for the specified time range", + }, + status_code=400, + ) + file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt") file_path = os.path.join(CACHE_DIR, file_name) with open(file_path, "w") as file: diff --git a/frigate/storage.py b/frigate/storage.py index 611412e1e..ee11cf7a9 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -113,6 +113,7 @@ class StorageMaintainer(threading.Thread): recordings: Recordings = ( Recordings.select( Recordings.id, + Recordings.camera, Recordings.start_time, Recordings.end_time, Recordings.segment_size, @@ -137,7 +138,7 @@ class StorageMaintainer(threading.Thread): ) event_start = 0 - deleted_recordings = set() + deleted_recordings = [] for recording in recordings: # check if 1 hour of storage has been reclaimed if deleted_segments_size > hourly_bandwidth: @@ -172,7 +173,7 @@ class StorageMaintainer(threading.Thread): if not keep: try: clear_and_unlink(Path(recording.path), missing_ok=False) - deleted_recordings.add(recording.id) + deleted_recordings.append(recording) deleted_segments_size += recording.segment_size except FileNotFoundError: # this file was not found so we must assume no space was cleaned up @@ -186,6 +187,9 @@ class StorageMaintainer(threading.Thread): recordings = ( Recordings.select( Recordings.id, + Recordings.camera, + Recordings.start_time, + Recordings.end_time, Recordings.path, Recordings.segment_size, ) @@ -201,7 +205,7 @@ class StorageMaintainer(threading.Thread): try: clear_and_unlink(Path(recording.path), missing_ok=False) deleted_segments_size += recording.segment_size - deleted_recordings.add(recording.id) + deleted_recordings.append(recording) except FileNotFoundError: # this file was not found so we must assume no space was cleaned up pass @@ -211,7 +215,50 @@ class StorageMaintainer(threading.Thread): logger.debug(f"Expiring {len(deleted_recordings)} recordings") # delete up to 100,000 at a time max_deletes = 100000 - deleted_recordings_list = list(deleted_recordings) + + # Update has_clip for events that overlap with deleted recordings + if deleted_recordings: + # Group deleted recordings by camera + camera_recordings = {} + for recording in deleted_recordings: + if recording.camera not in camera_recordings: + camera_recordings[recording.camera] = { + "min_start": recording.start_time, + "max_end": recording.end_time, + } + else: + camera_recordings[recording.camera]["min_start"] = min( + camera_recordings[recording.camera]["min_start"], + recording.start_time, + ) + camera_recordings[recording.camera]["max_end"] = max( + camera_recordings[recording.camera]["max_end"], + recording.end_time, + ) + + # Find all events that overlap with deleted recordings time range per camera + events_to_update = [] + for camera, time_range in camera_recordings.items(): + overlapping_events = Event.select(Event.id).where( + Event.camera == camera, + Event.has_clip == True, + Event.start_time < time_range["max_end"], + Event.end_time > time_range["min_start"], + ) + + for event in overlapping_events: + events_to_update.append(event.id) + + # Update has_clip to False for overlapping events + if events_to_update: + for i in range(0, len(events_to_update), max_deletes): + batch = events_to_update[i : i + max_deletes] + Event.update(has_clip=False).where(Event.id << batch).execute() + logger.debug( + f"Updated has_clip to False for {len(events_to_update)} events" + ) + + deleted_recordings_list = [r.id for r in deleted_recordings] for i in range(0, len(deleted_recordings_list), max_deletes): Recordings.delete().where( Recordings.id << deleted_recordings_list[i : i + max_deletes] diff --git a/web/src/components/overlay/CreateUserDialog.tsx b/web/src/components/overlay/CreateUserDialog.tsx index 0b2e3a89e..6f2b3ecf3 100644 --- a/web/src/components/overlay/CreateUserDialog.tsx +++ b/web/src/components/overlay/CreateUserDialog.tsx @@ -13,7 +13,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import ActivityIndicator from "../indicators/activity-indicator"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; +import useSWR from "swr"; import { Dialog, DialogContent, @@ -35,6 +36,7 @@ import { LuCheck, LuX } from "react-icons/lu"; import { useTranslation } from "react-i18next"; import { isDesktop, isMobile } from "react-device-detect"; import { cn } from "@/lib/utils"; +import { FrigateConfig } from "@/types/frigateConfig"; import { MobilePage, MobilePageContent, @@ -54,9 +56,15 @@ export default function CreateUserDialog({ onCreate, onCancel, }: CreateUserOverlayProps) { + const { data: config } = useSWR("config"); const { t } = useTranslation(["views/settings"]); const [isLoading, setIsLoading] = useState(false); + const roles = useMemo(() => { + const existingRoles = config ? Object.keys(config.auth?.roles || {}) : []; + return Array.from(new Set(["admin", "viewer", ...(existingRoles || [])])); + }, [config]); + const formSchema = z .object({ user: z @@ -69,7 +77,7 @@ export default function CreateUserDialog({ confirmPassword: z .string() .min(1, t("users.dialog.createUser.confirmPassword")), - role: z.enum(["admin", "viewer"]), + role: z.string().min(1), }) .refine((data) => data.password === data.confirmPassword, { message: t("users.dialog.form.password.notMatch"), @@ -246,24 +254,22 @@ export default function CreateUserDialog({ - -
- - {t("role.admin", { ns: "common" })} -
-
- -
- - {t("role.viewer", { ns: "common" })} -
-
+ {roles.map((r) => ( + +
+ {r === "admin" ? ( + + ) : ( + + )} + {t(`role.${r}`, { ns: "common" }) || r} +
+
+ ))}
diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index 39ffaf4c8..0cd8a0d13 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -12,7 +12,11 @@ import { cn } from "@/lib/utils"; import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; import { baseUrl } from "@/api/baseUrl"; import { REVIEW_PADDING } from "@/types/review"; -import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; +import { + ASPECT_VERTICAL_LAYOUT, + ASPECT_WIDE_LAYOUT, + Recording, +} from "@/types/record"; import { DropdownMenu, DropdownMenuTrigger, @@ -75,6 +79,139 @@ export function TrackingDetails({ const { data: config } = useSWR("config"); + // Fetch recording segments for the event's time range to handle motion-only gaps + const eventStartRecord = useMemo( + () => (event.start_time ?? 0) + annotationOffset / 1000, + [event.start_time, annotationOffset], + ); + const eventEndRecord = useMemo( + () => (event.end_time ?? Date.now() / 1000) + annotationOffset / 1000, + [event.end_time, annotationOffset], + ); + + const { data: recordings } = useSWR( + event.camera + ? [ + `${event.camera}/recordings`, + { + after: eventStartRecord - REVIEW_PADDING, + before: eventEndRecord + REVIEW_PADDING, + }, + ] + : null, + ); + + // Convert a timeline timestamp to actual video player time, accounting for + // motion-only recording gaps. Uses the same algorithm as DynamicVideoController. + const timestampToVideoTime = useCallback( + (timestamp: number): number => { + if (!recordings || recordings.length === 0) { + // Fallback to simple calculation if no recordings data + return timestamp - (eventStartRecord - REVIEW_PADDING); + } + + const videoStartTime = eventStartRecord - REVIEW_PADDING; + + // If timestamp is before video start, return 0 + if (timestamp < videoStartTime) return 0; + + // Check if timestamp is before the first recording or after the last + if ( + timestamp < recordings[0].start_time || + timestamp > recordings[recordings.length - 1].end_time + ) { + // No recording available at this timestamp + return 0; + } + + // Calculate the inpoint offset - the HLS video may start partway through the first segment + let inpointOffset = 0; + if ( + videoStartTime > recordings[0].start_time && + videoStartTime < recordings[0].end_time + ) { + inpointOffset = videoStartTime - recordings[0].start_time; + } + + let seekSeconds = 0; + for (const segment of recordings) { + // Skip segments that end before our timestamp + if (segment.end_time <= timestamp) { + // Add this segment's duration, but subtract inpoint offset from first segment + if (segment === recordings[0]) { + seekSeconds += segment.duration - inpointOffset; + } else { + seekSeconds += segment.duration; + } + } else if (segment.start_time <= timestamp) { + // The timestamp is within this segment + if (segment === recordings[0]) { + // For the first segment, account for the inpoint offset + seekSeconds += + timestamp - Math.max(segment.start_time, videoStartTime); + } else { + seekSeconds += timestamp - segment.start_time; + } + break; + } + } + + return seekSeconds; + }, + [recordings, eventStartRecord], + ); + + // Convert video player time back to timeline timestamp, accounting for + // motion-only recording gaps. Reverse of timestampToVideoTime. + const videoTimeToTimestamp = useCallback( + (playerTime: number): number => { + if (!recordings || recordings.length === 0) { + // Fallback to simple calculation if no recordings data + const videoStartTime = eventStartRecord - REVIEW_PADDING; + return playerTime + videoStartTime; + } + + const videoStartTime = eventStartRecord - REVIEW_PADDING; + + // Calculate the inpoint offset - the video may start partway through the first segment + let inpointOffset = 0; + if ( + videoStartTime > recordings[0].start_time && + videoStartTime < recordings[0].end_time + ) { + inpointOffset = videoStartTime - recordings[0].start_time; + } + + let timestamp = 0; + let totalTime = 0; + + for (const segment of recordings) { + const segmentDuration = + segment === recordings[0] + ? segment.duration - inpointOffset + : segment.duration; + + if (totalTime + segmentDuration > playerTime) { + // The player time is within this segment + if (segment === recordings[0]) { + // For the first segment, add the inpoint offset + timestamp = + Math.max(segment.start_time, videoStartTime) + + (playerTime - totalTime); + } else { + timestamp = segment.start_time + (playerTime - totalTime); + } + break; + } else { + totalTime += segmentDuration; + } + } + + return timestamp; + }, + [recordings, eventStartRecord], + ); + eventSequence?.map((event) => { event.data.zones_friendly_names = event.data?.zones?.map((zone) => { return resolveZoneName(config, zone); @@ -148,17 +285,14 @@ export function TrackingDetails({ return; } - // For video mode: convert to video-relative time and seek player - const eventStartRecord = - (event.start_time ?? 0) + annotationOffset / 1000; - const videoStartTime = eventStartRecord - REVIEW_PADDING; - const relativeTime = targetTimeRecord - videoStartTime; + // For video mode: convert to video-relative time (accounting for motion-only gaps) + const relativeTime = timestampToVideoTime(targetTimeRecord); if (videoRef.current) { videoRef.current.currentTime = relativeTime; } }, - [event.start_time, annotationOffset, displaySource], + [annotationOffset, displaySource, timestampToVideoTime], ); const formattedStart = config @@ -177,21 +311,22 @@ export function TrackingDetails({ }) : ""; - const formattedEnd = config - ? formatUnixTimestampToDateTime(event.end_time ?? 0, { - timezone: config.ui.timezone, - date_format: - config.ui.time_format == "24hour" - ? t("time.formattedTimestamp.24hour", { - ns: "common", - }) - : t("time.formattedTimestamp.12hour", { - ns: "common", - }), - time_style: "medium", - date_style: "medium", - }) - : ""; + const formattedEnd = + config && event.end_time != null + ? formatUnixTimestampToDateTime(event.end_time, { + timezone: config.ui.timezone, + date_format: + config.ui.time_format == "24hour" + ? t("time.formattedTimestamp.24hour", { + ns: "common", + }) + : t("time.formattedTimestamp.12hour", { + ns: "common", + }), + time_style: "medium", + date_style: "medium", + }) + : ""; useEffect(() => { if (!eventSequence || eventSequence.length === 0) return; @@ -210,24 +345,14 @@ export function TrackingDetails({ } // seekToTimestamp is a record stream timestamp - // event.start_time is detect stream time, convert to record - // The video clip starts at (eventStartRecord - REVIEW_PADDING) + // Convert to video position (accounting for motion-only recording gaps) if (!videoRef.current) return; - const eventStartRecord = event.start_time + annotationOffset / 1000; - const videoStartTime = eventStartRecord - REVIEW_PADDING; - const relativeTime = seekToTimestamp - videoStartTime; + const relativeTime = timestampToVideoTime(seekToTimestamp); if (relativeTime >= 0) { videoRef.current.currentTime = relativeTime; } setSeekToTimestamp(null); - }, [ - seekToTimestamp, - event.start_time, - annotationOffset, - apiHost, - event.camera, - displaySource, - ]); + }, [seekToTimestamp, displaySource, timestampToVideoTime]); const isWithinEventRange = useMemo(() => { if (effectiveTime === undefined || event.start_time === undefined) { @@ -334,14 +459,13 @@ export function TrackingDetails({ const handleTimeUpdate = useCallback( (time: number) => { - // event.start_time is detect stream time, convert to record - const eventStartRecord = event.start_time + annotationOffset / 1000; - const videoStartTime = eventStartRecord - REVIEW_PADDING; - const absoluteTime = time + videoStartTime; + // Convert video player time back to timeline timestamp + // accounting for motion-only recording gaps + const absoluteTime = videoTimeToTimestamp(time); setCurrentTime(absoluteTime); }, - [event.start_time, annotationOffset], + [videoTimeToTimestamp], ); const [src, setSrc] = useState( @@ -525,9 +649,16 @@ export function TrackingDetails({
{label} - - {formattedStart ?? ""} - {formattedEnd ?? ""} - +
+ {formattedStart ?? ""} + {event.end_time != null ? ( + <> - {formattedEnd} + ) : ( +
+ +
+ )} +
{event.data?.recognized_license_plate && ( <> ยท diff --git a/web/src/components/settings/CameraEditForm.tsx b/web/src/components/settings/CameraEditForm.tsx index 3b910c176..c18b5ffd3 100644 --- a/web/src/components/settings/CameraEditForm.tsx +++ b/web/src/components/settings/CameraEditForm.tsx @@ -18,7 +18,7 @@ import { z } from "zod"; import axios from "axios"; import { toast, Toaster } from "sonner"; import { useTranslation } from "react-i18next"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { LuTrash2, LuPlus } from "react-icons/lu"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { FrigateConfig } from "@/types/frigateConfig"; @@ -42,7 +42,15 @@ export default function CameraEditForm({ onCancel, }: CameraEditFormProps) { const { t } = useTranslation(["views/settings"]); - const { data: config } = useSWR("config"); + const { data: config, mutate: mutateConfig } = + useSWR("config"); + const { data: rawPaths, mutate: mutateRawPaths } = useSWR<{ + cameras: Record< + string, + { ffmpeg: { inputs: { path: string; roles: string[] }[] } } + >; + go2rtc: { streams: Record }; + }>(cameraName ? "config/raw_paths" : null); const [isLoading, setIsLoading] = useState(false); const formSchema = useMemo( @@ -145,14 +153,23 @@ export default function CameraEditForm({ if (cameraName && config?.cameras[cameraName]) { const camera = config.cameras[cameraName]; defaultValues.enabled = camera.enabled ?? true; - defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length - ? camera.ffmpeg.inputs.map((input) => ({ + + // Use raw paths from the admin endpoint if available, otherwise fall back to masked paths + const rawCameraData = rawPaths?.cameras?.[cameraName]; + defaultValues.ffmpeg.inputs = rawCameraData?.ffmpeg?.inputs?.length + ? rawCameraData.ffmpeg.inputs.map((input) => ({ path: input.path, roles: input.roles as Role[], })) - : defaultValues.ffmpeg.inputs; + : camera.ffmpeg?.inputs?.length + ? camera.ffmpeg.inputs.map((input) => ({ + path: input.path, + roles: input.roles as Role[], + })) + : defaultValues.ffmpeg.inputs; - const go2rtcStreams = config.go2rtc?.streams || {}; + const go2rtcStreams = + rawPaths?.go2rtc?.streams || config.go2rtc?.streams || {}; const cameraStreams: Record = {}; // get candidate stream names for this camera. could be the camera's own name, @@ -196,6 +213,60 @@ export default function CameraEditForm({ mode: "onChange", }); + // Update form values when rawPaths loads + useEffect(() => { + if ( + cameraName && + config?.cameras[cameraName] && + rawPaths?.cameras?.[cameraName] + ) { + const camera = config.cameras[cameraName]; + const rawCameraData = rawPaths.cameras[cameraName]; + + // Update ffmpeg inputs with raw paths + if (rawCameraData.ffmpeg?.inputs?.length) { + form.setValue( + "ffmpeg.inputs", + rawCameraData.ffmpeg.inputs.map((input) => ({ + path: input.path, + roles: input.roles as Role[], + })), + ); + } + + // Update go2rtc streams with raw URLs + if (rawPaths.go2rtc?.streams) { + const validNames = new Set(); + validNames.add(cameraName); + + camera.ffmpeg?.inputs?.forEach((input) => { + const restreamMatch = input.path.match( + /^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/, + ); + if (restreamMatch) { + validNames.add(restreamMatch[1]); + } + }); + + const liveStreams = camera?.live?.streams; + if (liveStreams) { + Object.keys(liveStreams).forEach((key) => validNames.add(key)); + } + + const cameraStreams: Record = {}; + Object.entries(rawPaths.go2rtc.streams).forEach(([name, urls]) => { + if (validNames.has(name)) { + cameraStreams[name] = Array.isArray(urls) ? urls : [urls]; + } + }); + + if (Object.keys(cameraStreams).length > 0) { + form.setValue("go2rtcStreams", cameraStreams); + } + } + } + }, [cameraName, config, rawPaths, form]); + const { fields, append, remove } = useFieldArray({ control: form.control, name: "ffmpeg.inputs", @@ -268,6 +339,8 @@ export default function CameraEditForm({ }), { position: "top-center" }, ); + mutateConfig(); + mutateRawPaths(); if (onSave) onSave(); }); } else { @@ -277,6 +350,8 @@ export default function CameraEditForm({ }), { position: "top-center" }, ); + mutateConfig(); + mutateRawPaths(); if (onSave) onSave(); } } else { diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 918d492a3..a490a046a 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -26,7 +26,7 @@ import useSWR from "swr"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { PolygonType } from "@/types/canvas"; -import CameraSettingsView from "@/views/settings/CameraSettingsView"; +import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView"; import CameraManagementView from "@/views/settings/CameraManagementView"; import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; @@ -93,7 +93,7 @@ const settingsGroups = [ label: "cameras", items: [ { key: "cameraManagement", component: CameraManagementView }, - { key: "cameraReview", component: CameraSettingsView }, + { key: "cameraReview", component: CameraReviewSettingsView }, { key: "masksAndZones", component: MasksAndZonesView }, { key: "motionTuner", component: MotionTunerView }, ], diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 52d5879e1..1a626fa02 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -5,17 +5,9 @@ import { Button } from "@/components/ui/button"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { useTranslation } from "react-i18next"; -import { Label } from "@/components/ui/label"; import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import { LuPlus } from "react-icons/lu"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { IoMdArrowRoundBack } from "react-icons/io"; import { isDesktop } from "react-device-detect"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; @@ -90,31 +82,6 @@ export default function CameraManagementView({ {cameras.length > 0 && ( <> -
- - -
-
diff --git a/web/src/views/settings/CameraReviewSettingsView.tsx b/web/src/views/settings/CameraReviewSettingsView.tsx new file mode 100644 index 000000000..47ea5c22a --- /dev/null +++ b/web/src/views/settings/CameraReviewSettingsView.tsx @@ -0,0 +1,738 @@ +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 { 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 { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; +import { resolveZoneName } from "@/hooks/use-zone-friendly-name"; +import { formatList } from "@/utils/stringUtil"; + +type CameraReviewSettingsViewProps = { + selectedCamera: string; + setUnsavedChanges: React.Dispatch>; +}; + +type CameraReviewSettingsValueType = { + alerts_zones: string[]; + detections_zones: string[]; +}; + +export default function CameraReviewSettingsView({ + selectedCamera, + setUnsavedChanges, +}: CameraReviewSettingsViewProps) { + 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)!; + + const selectCameraName = useCameraFriendlyName(selectedCamera); + + // zones and labels + + const getZoneName = useCallback( + (zoneId: string, cameraId?: string) => + resolveZoneName(config, zoneId, cameraId), + [config], + ); + + const zones = useMemo(() => { + if (cameraConfig) { + return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ + camera: cameraConfig.name, + name, + friendly_name: cameraConfig.zones[name].friendly_name, + objects: zoneData.objects, + color: zoneData.color, + })); + } + }, [cameraConfig]); + + const alertsLabels = useMemo(() => { + return cameraConfig?.review.alerts.labels + ? formatList( + cameraConfig.review.alerts.labels.map((label) => + getTranslatedLabel( + label, + cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", + ), + ), + ) + : ""; + }, [cameraConfig]); + + const detectionsLabels = useMemo(() => { + return cameraConfig?.review.detections.labels + ? formatList( + cameraConfig.review.detections.labels.map((label) => + getTranslatedLabel( + label, + cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", + ), + ), + ) + : ""; + }, [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]); + + if (!cameraConfig && !selectedCamera) { + return ; + } + + return ( + <> +
+ +
+ + {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.friendly_name || zone.name} + + + )} + /> + ))} +
+ + ) : ( +
+ + cameraReview.reviewClassification.noDefinedZones + +
+ )} + +
+ {watchedAlertsZones && watchedAlertsZones.length > 0 + ? t( + "cameraReview.reviewClassification.zoneObjectAlertsTips", + { + alertsLabels, + zone: formatList( + watchedAlertsZones.map((zone) => + getZoneName(zone), + ), + ), + 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.friendly_name || zone.name} + + + )} + /> + ))} +
+ )} + + +
+ +
+ +
+
+ + )} + +
+ {watchedDetectionsZones && + watchedDetectionsZones.length > 0 ? ( + !selectDetections ? ( + + getZoneName(zone), + ), + ), + cameraName: selectCameraName, + }} + ns="views/settings" + /> + ) : ( + + getZoneName(zone), + ), + ), + cameraName: selectCameraName, + }} + ns="views/settings" + /> + ) + ) : ( + + )} +
+
+ )} + /> +
+ + +
+ + +
+ + +
+
+ + ); +} diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx deleted file mode 100644 index e102dd75d..000000000 --- a/web/src/views/settings/CameraSettingsView.tsx +++ /dev/null @@ -1,794 +0,0 @@ -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 { 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"; -import { resolveZoneName } from "@/hooks/use-zone-friendly-name"; -import { formatList } from "@/utils/stringUtil"; - -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 getZoneName = useCallback( - (zoneId: string, cameraId?: string) => - resolveZoneName(config, zoneId, cameraId), - [config], - ); - - const zones = useMemo(() => { - if (cameraConfig) { - return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ - camera: cameraConfig.name, - name, - friendly_name: cameraConfig.zones[name].friendly_name, - objects: zoneData.objects, - color: zoneData.color, - })); - } - }, [cameraConfig]); - - const alertsLabels = useMemo(() => { - return cameraConfig?.review.alerts.labels - ? formatList( - cameraConfig.review.alerts.labels.map((label) => - getTranslatedLabel( - label, - cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", - ), - ), - ) - : ""; - }, [cameraConfig]); - - const detectionsLabels = useMemo(() => { - return cameraConfig?.review.detections.labels - ? formatList( - cameraConfig.review.detections.labels.map((label) => - getTranslatedLabel( - label, - cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object", - ), - ), - ) - : ""; - }, [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.friendly_name || zone.name} - - - )} - /> - ))} -
- - ) : ( -
- - cameraReview.reviewClassification.noDefinedZones - -
- )} - -
- {watchedAlertsZones && watchedAlertsZones.length > 0 - ? t( - "cameraReview.reviewClassification.zoneObjectAlertsTips", - { - alertsLabels, - zone: formatList( - watchedAlertsZones.map((zone) => - getZoneName(zone), - ), - ), - 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.friendly_name || zone.name} - - - )} - /> - ))} -
- )} - - -
- -
- -
-
- - )} - -
- {watchedDetectionsZones && - watchedDetectionsZones.length > 0 ? ( - !selectDetections ? ( - - getZoneName(zone), - ), - ), - cameraName: selectCameraName, - }} - ns="views/settings" - /> - ) : ( - - getZoneName(zone), - ), - ), - cameraName: selectCameraName, - }} - ns="views/settings" - /> - ) - ) : ( - - )} -
-
- )} - /> -
- - -
- - -
- - - - ) : ( - <> -
- -
-
- -
- - )} -
-
- - setShowWizard(false)} - /> - - ); -}