diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index ca3589df1..0ef6774a8 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -159,7 +159,8 @@ Published when a license plate is recognized on a car object. See the [License P "plate": "123ABC", "score": 0.95, "camera": "driveway_cam", - "timestamp": 1607123958.748393 + "timestamp": 1607123958.748393, + "plate_box": [917, 487, 1029, 529] // box coordinates of the detected license plate in the frame } ``` diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 488ec1e1f..cb69a56e3 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -1,5 +1,6 @@ """Camera apis.""" +import asyncio import json import logging import re @@ -11,7 +12,9 @@ import httpx import requests from fastapi import APIRouter, Depends, Query, Request, Response from fastapi.responses import JSONResponse +from filelock import FileLock, Timeout from onvif import ONVIFCamera, ONVIFError +from ruamel.yaml import YAML from zeep.exceptions import Fault, TransportError from zeep.transports import AsyncTransport @@ -21,8 +24,14 @@ from frigate.api.auth import ( require_role, ) from frigate.api.defs.tags import Tags -from frigate.config.config import FrigateConfig +from frigate.config import FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateTopic, +) from frigate.util.builtin import clean_camera_user_pass +from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files +from frigate.util.config import find_config_file from frigate.util.image import run_ffmpeg_snapshot from frigate.util.services import ffprobe_stream @@ -995,3 +1004,154 @@ async def onvif_probe( await onvif_camera.close() except Exception as e: logger.debug(f"Error closing ONVIF camera session: {e}") + + +@router.delete( + "/cameras/{camera_name}", + dependencies=[Depends(require_role(["admin"]))], +) +async def delete_camera( + request: Request, + camera_name: str, + delete_exports: bool = Query(default=False), +): + """Delete a camera and all its associated data. + + Removes the camera from config, stops processes, and cleans up + all database entries and media files. + + Args: + camera_name: Name of the camera to delete + delete_exports: Whether to also delete exports for this camera + """ + frigate_config: FrigateConfig = request.app.frigate_config + + if camera_name not in frigate_config.cameras: + return JSONResponse( + content={ + "success": False, + "message": f"Camera {camera_name} not found", + }, + status_code=404, + ) + + old_camera_config = frigate_config.cameras[camera_name] + config_file = find_config_file() + lock = FileLock(f"{config_file}.lock", timeout=5) + + try: + with lock: + with open(config_file, "r") as f: + old_raw_config = f.read() + + try: + yaml = YAML() + yaml.indent(mapping=2, sequence=4, offset=2) + + with open(config_file, "r") as f: + data = yaml.load(f) + + # Remove camera from config + if "cameras" in data and camera_name in data["cameras"]: + del data["cameras"][camera_name] + + # Remove camera from auth roles + auth = data.get("auth", {}) + if auth and "roles" in auth: + empty_roles = [] + for role_name, cameras_list in auth["roles"].items(): + if ( + isinstance(cameras_list, list) + and camera_name in cameras_list + ): + cameras_list.remove(camera_name) + # Custom roles can't be empty; mark for removal + if not cameras_list and role_name not in ( + "admin", + "viewer", + ): + empty_roles.append(role_name) + for role_name in empty_roles: + del auth["roles"][role_name] + + with open(config_file, "w") as f: + yaml.dump(data, f) + + with open(config_file, "r") as f: + new_raw_config = f.read() + + try: + config = FrigateConfig.parse(new_raw_config) + except Exception: + with open(config_file, "w") as f: + f.write(old_raw_config) + logger.exception( + "Config error after removing camera %s", + camera_name, + ) + return JSONResponse( + content={ + "success": False, + "message": "Error parsing config after camera removal", + }, + status_code=400, + ) + except Exception as e: + logger.error( + "Error updating config to remove camera %s: %s", camera_name, e + ) + return JSONResponse( + content={ + "success": False, + "message": "Error updating config", + }, + status_code=500, + ) + + # Update runtime config + request.app.frigate_config = config + request.app.genai_manager.update_config(config) + + # Publish removal to stop ffmpeg processes and clean up runtime state + request.app.config_publisher.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, camera_name), + old_camera_config, + ) + + except Timeout: + return JSONResponse( + content={ + "success": False, + "message": "Another process is currently updating the config", + }, + status_code=409, + ) + + # Clean up database entries + counts, export_paths = await asyncio.to_thread( + cleanup_camera_db, camera_name, delete_exports + ) + + # Clean up media files in background thread + await asyncio.to_thread( + cleanup_camera_files, camera_name, export_paths if delete_exports else None + ) + + # Best-effort go2rtc stream removal + try: + requests.delete( + "http://127.0.0.1:1984/api/streams", + params={"src": camera_name}, + timeout=5, + ) + except Exception: + logger.debug("Failed to remove go2rtc stream for %s", camera_name) + + return JSONResponse( + content={ + "success": True, + "message": f"Camera {camera_name} has been deleted", + "cleanup": counts, + }, + status_code=200, + ) diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index c184b8b75..e4fbd1172 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -1225,6 +1225,8 @@ class LicensePlateProcessingMixin: logger.debug(f"{camera}: License plate area below minimum threshold.") return + plate_box = license_plate + license_plate_frame = rgb[ license_plate[1] : license_plate[3], license_plate[0] : license_plate[2], @@ -1341,6 +1343,20 @@ class LicensePlateProcessingMixin: logger.debug(f"{camera}: License plate is less than min_area") return + # Scale back to original car coordinates and then to frame + plate_box_in_car = ( + license_plate[0] // 2, + license_plate[1] // 2, + license_plate[2] // 2, + license_plate[3] // 2, + ) + plate_box = ( + left + plate_box_in_car[0], + top + plate_box_in_car[1], + left + plate_box_in_car[2], + top + plate_box_in_car[3], + ) + license_plate_frame = car[ license_plate[1] : license_plate[3], license_plate[0] : license_plate[2], @@ -1404,6 +1420,8 @@ class LicensePlateProcessingMixin: 0, [license_plate_frame.shape[1], license_plate_frame.shape[0]] * 2 ) + plate_box = tuple(int(x) for x in expanded_box) + # Crop using the expanded box license_plate_frame = license_plate_frame[ int(expanded_box[1]) : int(expanded_box[3]), @@ -1611,6 +1629,7 @@ class LicensePlateProcessingMixin: "id": id, "camera": camera, "timestamp": start, + "plate_box": plate_box, } ), ) diff --git a/frigate/debug_replay.py b/frigate/debug_replay.py index 504184667..15ca3777a 100644 --- a/frigate/debug_replay.py +++ b/frigate/debug_replay.py @@ -21,7 +21,8 @@ from frigate.const import ( REPLAY_DIR, THUMB_DIR, ) -from frigate.models import Event, Recordings, ReviewSegment, Timeline +from frigate.models import Recordings +from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.config import find_config_file logger = logging.getLogger(__name__) @@ -357,43 +358,13 @@ class DebugReplayManager: def _cleanup_db(self, camera_name: str) -> None: """Defensively remove any database rows for the replay camera.""" - try: - Event.delete().where(Event.camera == camera_name).execute() - except Exception as e: - logger.error("Failed to delete replay events: %s", e) - - try: - Timeline.delete().where(Timeline.camera == camera_name).execute() - except Exception as e: - logger.error("Failed to delete replay timeline: %s", e) - - try: - Recordings.delete().where(Recordings.camera == camera_name).execute() - except Exception as e: - logger.error("Failed to delete replay recordings: %s", e) - - try: - ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute() - except Exception as e: - logger.error("Failed to delete replay review segments: %s", e) + cleanup_camera_db(camera_name) def _cleanup_files(self, camera_name: str) -> None: """Remove filesystem artifacts for the replay camera.""" - dirs_to_clean = [ - os.path.join(RECORD_DIR, camera_name), - os.path.join(CLIPS_DIR, camera_name), - os.path.join(THUMB_DIR, camera_name), - ] + cleanup_camera_files(camera_name) - for dir_path in dirs_to_clean: - if os.path.exists(dir_path): - try: - shutil.rmtree(dir_path) - logger.debug("Removed replay directory: %s", dir_path) - except Exception as e: - logger.error("Failed to remove %s: %s", dir_path, e) - - # Remove replay clip and any related files + # Remove replay-specific cache directory if os.path.exists(REPLAY_DIR): try: shutil.rmtree(REPLAY_DIR) diff --git a/frigate/util/camera_cleanup.py b/frigate/util/camera_cleanup.py new file mode 100644 index 000000000..4344adb5a --- /dev/null +++ b/frigate/util/camera_cleanup.py @@ -0,0 +1,153 @@ +"""Utilities for cleaning up camera data from database and filesystem.""" + +import glob +import logging +import os +import shutil + +from frigate.const import CLIPS_DIR, RECORD_DIR, THUMB_DIR +from frigate.models import ( + Event, + Export, + Previews, + Recordings, + Regions, + ReviewSegment, + Timeline, + Trigger, +) + +logger = logging.getLogger(__name__) + + +def cleanup_camera_db( + camera_name: str, delete_exports: bool = False +) -> tuple[dict[str, int], list[str]]: + """Remove all database rows for a camera. + + Args: + camera_name: The camera name to clean up + delete_exports: Whether to also delete export records + + Returns: + Tuple of (deletion counts dict, list of export file paths to remove) + """ + counts: dict[str, int] = {} + export_paths: list[str] = [] + + try: + counts["events"] = Event.delete().where(Event.camera == camera_name).execute() + except Exception as e: + logger.error("Failed to delete events for camera %s: %s", camera_name, e) + + try: + counts["timeline"] = ( + Timeline.delete().where(Timeline.camera == camera_name).execute() + ) + except Exception as e: + logger.error("Failed to delete timeline for camera %s: %s", camera_name, e) + + try: + counts["recordings"] = ( + Recordings.delete().where(Recordings.camera == camera_name).execute() + ) + except Exception as e: + logger.error("Failed to delete recordings for camera %s: %s", camera_name, e) + + try: + counts["review_segments"] = ( + ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute() + ) + except Exception as e: + logger.error( + "Failed to delete review segments for camera %s: %s", camera_name, e + ) + + try: + counts["previews"] = ( + Previews.delete().where(Previews.camera == camera_name).execute() + ) + except Exception as e: + logger.error("Failed to delete previews for camera %s: %s", camera_name, e) + + try: + counts["regions"] = ( + Regions.delete().where(Regions.camera == camera_name).execute() + ) + except Exception as e: + logger.error("Failed to delete regions for camera %s: %s", camera_name, e) + + try: + counts["triggers"] = ( + Trigger.delete().where(Trigger.camera == camera_name).execute() + ) + except Exception as e: + logger.error("Failed to delete triggers for camera %s: %s", camera_name, e) + + if delete_exports: + try: + exports = Export.select(Export.video_path, Export.thumb_path).where( + Export.camera == camera_name + ) + for export in exports: + export_paths.append(export.video_path) + export_paths.append(export.thumb_path) + + counts["exports"] = ( + Export.delete().where(Export.camera == camera_name).execute() + ) + except Exception as e: + logger.error("Failed to delete exports for camera %s: %s", camera_name, e) + + return counts, export_paths + + +def cleanup_camera_files( + camera_name: str, export_paths: list[str] | None = None +) -> None: + """Remove filesystem artifacts for a camera. + + Args: + camera_name: The camera name to clean up + export_paths: Optional list of export file paths to remove + """ + dirs_to_clean = [ + os.path.join(RECORD_DIR, camera_name), + os.path.join(CLIPS_DIR, camera_name), + os.path.join(THUMB_DIR, camera_name), + os.path.join(CLIPS_DIR, "previews", camera_name), + ] + + for dir_path in dirs_to_clean: + if os.path.exists(dir_path): + try: + shutil.rmtree(dir_path) + logger.debug("Removed directory: %s", dir_path) + except Exception as e: + logger.error("Failed to remove %s: %s", dir_path, e) + + # Remove event snapshot files + for snapshot in glob.glob(os.path.join(CLIPS_DIR, f"{camera_name}-*.jpg")): + try: + os.remove(snapshot) + except Exception as e: + logger.error("Failed to remove snapshot %s: %s", snapshot, e) + + # Remove review thumbnail files + for thumb in glob.glob( + os.path.join(CLIPS_DIR, "review", f"thumb-{camera_name}-*.webp") + ): + try: + os.remove(thumb) + except Exception as e: + logger.error("Failed to remove review thumbnail %s: %s", thumb, e) + + # Remove export files if requested + if export_paths: + for path in export_paths: + if path and os.path.exists(path): + try: + os.remove(path) + logger.debug("Removed export file: %s", path) + except Exception as e: + logger.error("Failed to remove export file %s: %s", path, e) diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index ec0b29116..2efbd2652 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -80,6 +80,9 @@ "back": "Back", "empty": "No previews available", "noPreview": "Preview unavailable", - "seekAria": "Seek {{camera}} player to {{time}}" + "seekAria": "Seek {{camera}} player to {{time}}", + "filter": "Filter", + "filterDesc": "Select areas to only show clips with motion in those regions.", + "filterClear": "Clear" } } diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 81c9b8075..afbf27f82 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -422,6 +422,18 @@ "cameraManagement": { "title": "Manage Cameras", "addCamera": "Add New Camera", + "deleteCamera": "Delete Camera", + "deleteCameraDialog": { + "title": "Delete Camera", + "description": "Deleting a camera will permanently remove all recordings, tracked objects, and configuration for that camera. Any go2rtc streams associated with this camera may still need to be manually removed.", + "selectPlaceholder": "Choose camera...", + "confirmTitle": "Are you sure?", + "confirmWarning": "Deleting {{cameraName}} cannot be undone.", + "deleteExports": "Also delete exports for this camera", + "confirmButton": "Delete Permanently", + "success": "Camera {{cameraName}} deleted successfully", + "error": "Failed to delete camera {{cameraName}}" + }, "editCamera": "Edit Camera:", "selectCamera": "Select a Camera", "backToSettings": "Back to Camera Settings", diff --git a/web/src/components/filter/MotionRegionFilterGrid.tsx b/web/src/components/filter/MotionRegionFilterGrid.tsx new file mode 100644 index 000000000..86457d07b --- /dev/null +++ b/web/src/components/filter/MotionRegionFilterGrid.tsx @@ -0,0 +1,150 @@ +import { baseUrl } from "@/api/baseUrl"; +import { useCallback, useRef } from "react"; + +const GRID_SIZE = 16; + +type MotionRegionFilterGridProps = { + cameraName: string; + selectedCells: Set; + onCellsChange: (cells: Set) => void; +}; + +export default function MotionRegionFilterGrid({ + cameraName, + selectedCells, + onCellsChange, +}: MotionRegionFilterGridProps) { + const paintingRef = useRef<{ active: boolean; adding: boolean }>({ + active: false, + adding: true, + }); + const lastCellRef = useRef(-1); + const gridRef = useRef(null); + + const toggleCell = useCallback( + (index: number, forceAdd?: boolean) => { + const next = new Set(selectedCells); + + if (forceAdd !== undefined) { + if (forceAdd) { + next.add(index); + } else { + next.delete(index); + } + } else if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + + onCellsChange(next); + }, + [selectedCells, onCellsChange], + ); + + const getCellFromPoint = useCallback( + (clientX: number, clientY: number): number | null => { + const grid = gridRef.current; + + if (!grid) { + return null; + } + + const rect = grid.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + + if (x < 0 || y < 0 || x >= rect.width || y >= rect.height) { + return null; + } + + const col = Math.floor((x / rect.width) * GRID_SIZE); + const row = Math.floor((y / rect.height) * GRID_SIZE); + + return row * GRID_SIZE + col; + }, + [], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + const index = getCellFromPoint(e.clientX, e.clientY); + + if (index === null) { + return; + } + + const adding = !selectedCells.has(index); + paintingRef.current = { active: true, adding }; + lastCellRef.current = index; + toggleCell(index, adding); + }, + [selectedCells, toggleCell, getCellFromPoint], + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!paintingRef.current.active) { + return; + } + + const index = getCellFromPoint(e.clientX, e.clientY); + + if (index === null || index === lastCellRef.current) { + return; + } + + lastCellRef.current = index; + toggleCell(index, paintingRef.current.adding); + }, + [toggleCell, getCellFromPoint], + ); + + const handlePointerUp = useCallback(() => { + paintingRef.current.active = false; + lastCellRef.current = -1; + }, []); + + return ( +
+
+ +
+ {Array.from({ length: GRID_SIZE * GRID_SIZE }, (_, index) => { + const isSelected = selectedCells.has(index); + return ( +
+ ); + })} +
+
+
+ ); +} diff --git a/web/src/components/overlay/dialog/DeleteCameraDialog.tsx b/web/src/components/overlay/dialog/DeleteCameraDialog.tsx new file mode 100644 index 000000000..472e6bfa7 --- /dev/null +++ b/web/src/components/overlay/dialog/DeleteCameraDialog.tsx @@ -0,0 +1,215 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Trans } from "react-i18next"; +import axios from "axios"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { Switch } from "@/components/ui/switch"; + +type DeleteCameraDialogProps = { + show: boolean; + cameras: string[]; + onClose: () => void; + onDeleted: () => void; +}; + +export default function DeleteCameraDialog({ + show, + cameras, + onClose, + onDeleted, +}: DeleteCameraDialogProps) { + const { t } = useTranslation(["views/settings", "common"]); + const [phase, setPhase] = useState<"select" | "confirm">("select"); + const [selectedCamera, setSelectedCamera] = useState(""); + const [deleteExports, setDeleteExports] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const handleClose = useCallback(() => { + if (isDeleting) return; + setPhase("select"); + setSelectedCamera(""); + setDeleteExports(false); + onClose(); + }, [isDeleting, onClose]); + + const handleDelete = useCallback(() => { + setPhase("confirm"); + }, []); + + const handleBack = useCallback(() => { + setPhase("select"); + }, []); + + const handleConfirmDelete = useCallback(async () => { + if (!selectedCamera || isDeleting) return; + + setIsDeleting(true); + + try { + await axios.delete( + `cameras/${selectedCamera}?delete_exports=${deleteExports}`, + ); + toast.success( + t("cameraManagement.deleteCameraDialog.success", { + cameraName: selectedCamera, + }), + { position: "top-center" }, + ); + setPhase("select"); + setSelectedCamera(""); + setDeleteExports(false); + onDeleted(); + } catch (error) { + const errorMessage = + axios.isAxiosError(error) && + (error.response?.data?.message || error.response?.data?.detail) + ? error.response?.data?.message || error.response?.data?.detail + : t("cameraManagement.deleteCameraDialog.error", { + cameraName: selectedCamera, + }); + + toast.error(errorMessage, { position: "top-center" }); + } finally { + setIsDeleting(false); + } + }, [selectedCamera, deleteExports, isDeleting, onDeleted, t]); + + return ( + + + {phase === "select" ? ( + <> + + + {t("cameraManagement.deleteCameraDialog.title")} + + + {t("cameraManagement.deleteCameraDialog.description")} + + + + +
+
+ + +
+
+
+ + ) : ( + <> + + + {t("cameraManagement.deleteCameraDialog.confirmTitle")} + + + }} + > + cameraManagement.deleteCameraDialog.confirmWarning + + + +
+ + setDeleteExports(checked === true) + } + /> + +
+ +
+
+ + +
+
+
+ + )} +
+
+ ); +} diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index f66f45858..442fb91d4 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -79,7 +79,17 @@ import { GiSoundWaves } from "react-icons/gi"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { useTranslation } from "react-i18next"; -import { FaCog } from "react-icons/fa"; +import { FaCog, FaFilter } from "react-icons/fa"; +import MotionRegionFilterGrid from "@/components/filter/MotionRegionFilterGrid"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import ReviewActivityCalendar from "@/components/overlay/ReviewActivityCalendar"; import PlatformAwareDialog from "@/components/overlay/dialog/PlatformAwareDialog"; import MotionPreviewsPane from "./MotionPreviewsPane"; @@ -1117,6 +1127,13 @@ function MotionReview({ const [controlsOpen, setControlsOpen] = useState(false); const [dimStrength, setDimStrength] = useState(82); const [isPreviewSettingsOpen, setIsPreviewSettingsOpen] = useState(false); + const [motionFilterCells, setMotionFilterCells] = useState>( + new Set(), + ); + const [pendingFilterCells, setPendingFilterCells] = useState>( + new Set(), + ); + const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false); const objectReviewItems = useMemo( () => @@ -1314,6 +1331,68 @@ function MotionReview({ updateSelectedDay={onUpdateSelectedDay} /> )} + { + if (open) { + setPendingFilterCells(new Set(motionFilterCells)); + } + setIsRegionFilterOpen(open); + }} + > + + + + + + {t("motionPreviews.filter")} + + {t("motionPreviews.filterDesc")} + + + + + + + + + { onOpenRecording({ camera: selectedMotionPreviewCamera.name, diff --git a/web/src/views/events/MotionPreviewsPane.tsx b/web/src/views/events/MotionPreviewsPane.tsx index fc080c956..7f126f107 100644 --- a/web/src/views/events/MotionPreviewsPane.tsx +++ b/web/src/views/events/MotionPreviewsPane.tsx @@ -589,6 +589,7 @@ type MotionPreviewsPaneProps = { isLoadingMotionRanges?: boolean; playbackRate: number; nonMotionAlpha: number; + motionFilterCells?: Set; onSeek: (timestamp: number) => void; }; @@ -600,6 +601,7 @@ export default function MotionPreviewsPane({ isLoadingMotionRanges = false, playbackRate, nonMotionAlpha, + motionFilterCells, onSeek, }: MotionPreviewsPaneProps) { const { t } = useTranslation(["views/events"]); @@ -879,6 +881,26 @@ export default function MotionPreviewsPane({ ], ); + const filteredClipData = useMemo(() => { + if (!motionFilterCells || motionFilterCells.size === 0) { + return clipData; + } + + return clipData.filter(({ motionHeatmap }) => { + if (!motionHeatmap) { + return false; + } + + for (const cellIndex of motionFilterCells) { + if ((motionHeatmap[cellIndex.toString()] ?? 0) > 0) { + return true; + } + } + + return false; + }); + }, [clipData, motionFilterCells]); + const hasCurrentHourRanges = useMemo( () => motionRanges.some((range) => isCurrentHour(range.end_time)), [motionRanges], @@ -901,13 +923,13 @@ export default function MotionPreviewsPane({ ref={setContentNode} className="no-scrollbar min-h-0 flex-1 overflow-y-auto" > - {clipData.length === 0 ? ( + {filteredClipData.length === 0 ? (
{t("motionPreviews.empty")}
) : (
- {clipData.map( + {filteredClipData.map( ({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => { const clipId = `${camera.name}-${range.start_time}-${range.end_time}-${idx}`; const isMounted = mountedClips.has(clipId); diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index df250fab9..1c5168953 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -13,7 +13,8 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { useTranslation } from "react-i18next"; import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; -import { LuPlus } from "react-icons/lu"; +import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog"; +import { LuPlus, LuTrash2 } from "react-icons/lu"; import { IoMdArrowRoundBack } from "react-icons/io"; import { isDesktop } from "react-device-detect"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; @@ -45,6 +46,7 @@ export default function CameraManagementView({ undefined, ); // Track camera being edited const [showWizard, setShowWizard] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); // State for restart dialog when enabling a disabled camera const [restartDialogOpen, setRestartDialogOpen] = useState(false); @@ -98,14 +100,26 @@ export default function CameraManagementView({
- +
+ + {enabledCameras.length + disabledCameras.length > 0 && ( + + )} +
{enabledCameras.length > 0 && ( setShowWizard(false)} /> + setShowDeleteDialog(false)} + onDeleted={() => { + setShowDeleteDialog(false); + updateConfig(); + }} + /> setRestartDialogOpen(false)}