mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Camera groups (#10223)
* Add camera group config * Add saving of camera group selection * Implement camera groups in config and live view * Fix warnings * Add tooltips to camera group items on desktop * Add camera groups to the filters for events * Fix tooltips and group selection * Cleanup
This commit is contained in:
		
							parent
							
								
									38e76666e7
								
							
						
					
					
						commit
						b4b2162ada
					
				| @ -1003,6 +1003,16 @@ class LoggerConfig(FrigateBaseModel): | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| class CameraGroupConfig(FrigateBaseModel): | ||||
|     """Represents a group of cameras.""" | ||||
| 
 | ||||
|     cameras: list[str] = Field( | ||||
|         default_factory=list, title="List of cameras in this group." | ||||
|     ) | ||||
|     icon: str = Field(default="generic", title="Icon that represents camera group.") | ||||
|     order: int = Field(default=0, title="Sort order for group.") | ||||
| 
 | ||||
| 
 | ||||
| def verify_config_roles(camera_config: CameraConfig) -> None: | ||||
|     """Verify that roles are setup in the config correctly.""" | ||||
|     assigned_roles = list( | ||||
| @ -1157,6 +1167,9 @@ class FrigateConfig(FrigateBaseModel): | ||||
|         default_factory=DetectConfig, title="Global object tracking configuration." | ||||
|     ) | ||||
|     cameras: Dict[str, CameraConfig] = Field(title="Camera configuration.") | ||||
|     camera_groups: Dict[str, CameraGroupConfig] = Field( | ||||
|         default_factory=CameraGroupConfig, title="Camera group configuration" | ||||
|     ) | ||||
|     timestamp_style: TimestampStyleConfig = Field( | ||||
|         default_factory=TimestampStyleConfig, | ||||
|         title="Global timestamp style configuration.", | ||||
|  | ||||
							
								
								
									
										102
									
								
								web/src/components/filter/CameraGroupSelector.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								web/src/components/filter/CameraGroupSelector.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,102 @@ | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| import { isDesktop } from "react-device-detect"; | ||||
| import useSWR from "swr"; | ||||
| import { MdHome } from "react-icons/md"; | ||||
| import useOverlayState from "@/hooks/use-overlay-state"; | ||||
| import { Button } from "../ui/button"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { useCallback, useMemo, useState } from "react"; | ||||
| import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; | ||||
| import { getIconForGroup } from "@/utils/iconUtil"; | ||||
| 
 | ||||
| type CameraGroupSelectorProps = { | ||||
|   className?: string; | ||||
| }; | ||||
| export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
|   const navigate = useNavigate(); | ||||
| 
 | ||||
|   // tooltip
 | ||||
| 
 | ||||
|   const [tooltip, setTooltip] = useState<string>(); | ||||
|   const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout>(); | ||||
|   const showTooltip = useCallback( | ||||
|     (newTooltip: string | undefined) => { | ||||
|       if (!newTooltip) { | ||||
|         setTooltip(newTooltip); | ||||
| 
 | ||||
|         if (timeoutId) { | ||||
|           clearTimeout(timeoutId); | ||||
|         } | ||||
|       } else { | ||||
|         setTimeoutId(setTimeout(() => setTooltip(newTooltip), 500)); | ||||
|       } | ||||
|     }, | ||||
|     [timeoutId], | ||||
|   ); | ||||
| 
 | ||||
|   // groups
 | ||||
| 
 | ||||
|   const [group, setGroup] = useOverlayState("cameraGroup"); | ||||
| 
 | ||||
|   const groups = useMemo(() => { | ||||
|     if (!config) { | ||||
|       return []; | ||||
|     } | ||||
| 
 | ||||
|     return Object.entries(config.camera_groups).sort( | ||||
|       (a, b) => a[1].order - b[1].order, | ||||
|     ); | ||||
|   }, [config]); | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       className={`flex items-center justify-start gap-2 ${className ?? ""} ${isDesktop ? "flex-col" : ""}`} | ||||
|     > | ||||
|       <Tooltip open={tooltip == "home"}> | ||||
|         <TooltipTrigger asChild> | ||||
|           <Button | ||||
|             className={ | ||||
|               group == undefined | ||||
|                 ? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60" | ||||
|                 : "text-muted-foreground bg-secondary focus:text-muted-foreground focus:bg-secondary" | ||||
|             } | ||||
|             size="xs" | ||||
|             onClick={() => navigate(-1)} | ||||
|             onMouseEnter={() => (isDesktop ? showTooltip("home") : null)} | ||||
|             onMouseLeave={() => (isDesktop ? showTooltip(undefined) : null)} | ||||
|           > | ||||
|             <MdHome className="size-4" /> | ||||
|           </Button> | ||||
|         </TooltipTrigger> | ||||
|         <TooltipContent className="capitalize" side="right"> | ||||
|           Home | ||||
|         </TooltipContent> | ||||
|       </Tooltip> | ||||
|       {groups.map(([name, config]) => { | ||||
|         return ( | ||||
|           <Tooltip key={name} open={tooltip == name}> | ||||
|             <TooltipTrigger asChild> | ||||
|               <Button | ||||
|                 className={ | ||||
|                   group == name | ||||
|                     ? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60" | ||||
|                     : "text-muted-foreground bg-secondary" | ||||
|                 } | ||||
|                 size="xs" | ||||
|                 onClick={() => setGroup(name, group != undefined)} | ||||
|                 onMouseEnter={() => (isDesktop ? showTooltip(name) : null)} | ||||
|                 onMouseLeave={() => (isDesktop ? showTooltip(undefined) : null)} | ||||
|               > | ||||
|                 {getIconForGroup(config.icon)} | ||||
|               </Button> | ||||
|             </TooltipTrigger> | ||||
|             <TooltipContent className="capitalize" side="right"> | ||||
|               {name} | ||||
|             </TooltipContent> | ||||
|           </Tooltip> | ||||
|         ); | ||||
|       })} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| @ -2,7 +2,7 @@ import { LuCheck, LuVideo } from "react-icons/lu"; | ||||
| import { Button } from "../ui/button"; | ||||
| import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | ||||
| import useSWR from "swr"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig"; | ||||
| import { useCallback, useMemo, useState } from "react"; | ||||
| import { | ||||
|   DropdownMenu, | ||||
| @ -16,6 +16,8 @@ import { ReviewFilter } from "@/types/review"; | ||||
| import { getEndOfDayTimestamp } from "@/utils/dateUtil"; | ||||
| import { useFormattedTimestamp } from "@/hooks/use-date-utils"; | ||||
| import { FaCalendarAlt, FaFilter, FaVideo } from "react-icons/fa"; | ||||
| import { getIconTypeForGroup } from "@/utils/iconUtil"; | ||||
| import { IconType } from "react-icons"; | ||||
| 
 | ||||
| const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; | ||||
| 
 | ||||
| @ -57,6 +59,16 @@ export default function ReviewFilterGroup({ | ||||
|     [config, allLabels], | ||||
|   ); | ||||
| 
 | ||||
|   const groups = useMemo(() => { | ||||
|     if (!config) { | ||||
|       return []; | ||||
|     } | ||||
| 
 | ||||
|     return Object.entries(config.camera_groups).sort( | ||||
|       (a, b) => a[1].order - b[1].order, | ||||
|     ); | ||||
|   }, [config]); | ||||
| 
 | ||||
|   // handle updating filters
 | ||||
| 
 | ||||
|   const onUpdateSelectedDay = useCallback( | ||||
| @ -74,6 +86,7 @@ export default function ReviewFilterGroup({ | ||||
|     <div> | ||||
|       <CamerasFilterButton | ||||
|         allCameras={filterValues.cameras} | ||||
|         groups={groups} | ||||
|         selectedCameras={filter?.cameras} | ||||
|         updateCameraFilter={(newCameras) => { | ||||
|           onUpdateFilter({ ...filter, cameras: newCameras }); | ||||
| @ -102,11 +115,13 @@ export default function ReviewFilterGroup({ | ||||
| 
 | ||||
| type CameraFilterButtonProps = { | ||||
|   allCameras: string[]; | ||||
|   groups: [string, CameraGroupConfig][]; | ||||
|   selectedCameras: string[] | undefined; | ||||
|   updateCameraFilter: (cameras: string[] | undefined) => void; | ||||
| }; | ||||
| function CamerasFilterButton({ | ||||
|   allCameras, | ||||
|   groups, | ||||
|   selectedCameras, | ||||
|   updateCameraFilter, | ||||
| }: CameraFilterButtonProps) { | ||||
| @ -144,6 +159,24 @@ function CamerasFilterButton({ | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|         {groups.length > 0 && ( | ||||
|           <> | ||||
|             <DropdownMenuSeparator /> | ||||
|             {groups.map(([name, conf]) => { | ||||
|               return ( | ||||
|                 <FilterCheckBox | ||||
|                   key={name} | ||||
|                   label={name} | ||||
|                   CheckIcon={getIconTypeForGroup(conf.icon)} | ||||
|                   isChecked | ||||
|                   onCheckedChange={() => { | ||||
|                     setCurrentCameras([...conf.cameras]); | ||||
|                   }} | ||||
|                 /> | ||||
|               ); | ||||
|             })} | ||||
|           </> | ||||
|         )} | ||||
|         <DropdownMenuSeparator /> | ||||
|         {allCameras.map((item) => ( | ||||
|           <FilterCheckBox | ||||
| @ -350,12 +383,14 @@ function LabelsFilterButton({ | ||||
| 
 | ||||
| type FilterCheckBoxProps = { | ||||
|   label: string; | ||||
|   CheckIcon?: IconType; | ||||
|   isChecked: boolean; | ||||
|   onCheckedChange: (isChecked: boolean) => void; | ||||
| }; | ||||
| 
 | ||||
| function FilterCheckBox({ | ||||
|   label, | ||||
|   CheckIcon = LuCheck, | ||||
|   isChecked, | ||||
|   onCheckedChange, | ||||
| }: FilterCheckBoxProps) { | ||||
| @ -366,7 +401,7 @@ function FilterCheckBox({ | ||||
|       onClick={() => onCheckedChange(!isChecked)} | ||||
|     > | ||||
|       {isChecked ? ( | ||||
|         <LuCheck className="w-6 h-6" /> | ||||
|         <CheckIcon className="w-6 h-6" /> | ||||
|       ) : ( | ||||
|         <div className="w-6 h-6" /> | ||||
|       )} | ||||
|  | ||||
| @ -6,7 +6,6 @@ import { | ||||
|   TooltipContent, | ||||
|   TooltipTrigger, | ||||
| } from "@/components/ui/tooltip"; | ||||
| import { useState } from "react"; | ||||
| import { isDesktop } from "react-device-detect"; | ||||
| import { TooltipPortal } from "@radix-ui/react-tooltip"; | ||||
| 
 | ||||
| @ -42,32 +41,36 @@ export default function NavItem({ | ||||
| }: NavItemProps) { | ||||
|   const shouldRender = dev ? ENV !== "production" : true; | ||||
| 
 | ||||
|   const [showTooltip, setShowTooltip] = useState(false); | ||||
|   if (!shouldRender) { | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     shouldRender && ( | ||||
|       <Tooltip open={isDesktop && showTooltip}> | ||||
|         <NavLink | ||||
|           to={url} | ||||
|           onClick={onClick} | ||||
|           className={({ isActive }) => | ||||
|             `${className} flex flex-col justify-center items-center rounded-lg ${ | ||||
|               variants[variant][isActive ? "active" : "inactive"] | ||||
|             }` | ||||
|           } | ||||
|           onMouseEnter={() => (isDesktop ? setShowTooltip(true) : null)} | ||||
|           onMouseLeave={() => (isDesktop ? setShowTooltip(false) : null)} | ||||
|         > | ||||
|           <TooltipTrigger> | ||||
|             <Icon className="size-5 md:m-[6px]" /> | ||||
|           </TooltipTrigger> | ||||
|         </NavLink> | ||||
|   const content = ( | ||||
|     <NavLink | ||||
|       to={url} | ||||
|       onClick={onClick} | ||||
|       className={({ isActive }) => | ||||
|         `${className} flex flex-col justify-center items-center rounded-lg ${ | ||||
|           variants[variant][isActive ? "active" : "inactive"] | ||||
|         }` | ||||
|       } | ||||
|     > | ||||
|       <Icon className="size-5 md:m-[6px]" /> | ||||
|     </NavLink> | ||||
|   ); | ||||
| 
 | ||||
|   if (isDesktop) { | ||||
|     return ( | ||||
|       <Tooltip> | ||||
|         <TooltipTrigger>{content}</TooltipTrigger> | ||||
|         <TooltipPortal> | ||||
|           <TooltipContent side="right"> | ||||
|             <p>{title}</p> | ||||
|           </TooltipContent> | ||||
|         </TooltipPortal> | ||||
|       </Tooltip> | ||||
|     ) | ||||
|   ); | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return content; | ||||
| } | ||||
|  | ||||
| @ -2,22 +2,30 @@ import Logo from "../Logo"; | ||||
| import { navbarLinks } from "@/pages/site-navigation"; | ||||
| import SettingsNavItems from "../settings/SettingsNavItems"; | ||||
| import NavItem from "./NavItem"; | ||||
| import { CameraGroupSelector } from "../filter/CameraGroupSelector"; | ||||
| import { useLocation } from "react-router-dom"; | ||||
| 
 | ||||
| function Sidebar() { | ||||
|   const location = useLocation(); | ||||
| 
 | ||||
|   return ( | ||||
|     <aside className="absolute w-[52px] z-10 left-o inset-y-0 overflow-y-auto scrollbar-hidden py-4 flex flex-col justify-between bg-primary border-r border-secondary-highlight"> | ||||
|       <span tabIndex={0} className="sr-only" /> | ||||
|       <div className="w-full flex flex-col gap-0 items-center"> | ||||
|         <Logo className="w-8 h-8 mb-6" /> | ||||
|         {navbarLinks.map((item) => ( | ||||
|           <NavItem | ||||
|             className="mx-[10px] mb-6" | ||||
|             key={item.id} | ||||
|             Icon={item.icon} | ||||
|             title={item.title} | ||||
|             url={item.url} | ||||
|             dev={item.dev} | ||||
|           /> | ||||
|           <div key={item.id}> | ||||
|             <NavItem | ||||
|               className={`mx-[10px] ${item.id == 1 ? "mb-2" : "mb-4"}`} | ||||
|               Icon={item.icon} | ||||
|               title={item.title} | ||||
|               url={item.url} | ||||
|               dev={item.dev} | ||||
|             /> | ||||
|             {item.id == 1 && item.url == location.pathname && ( | ||||
|               <CameraGroupSelector className="mb-4" /> | ||||
|             )} | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|       <SettingsNavItems className="hidden md:flex flex-col items-center mb-8" /> | ||||
|  | ||||
| @ -22,7 +22,7 @@ const buttonVariants = cva( | ||||
|       }, | ||||
|       size: { | ||||
|         default: "h-10 px-4 py-2", | ||||
|         xs: "h-6 rounded-md", | ||||
|         xs: "size-6 rounded-md", | ||||
|         sm: "h-9 rounded-md px-3", | ||||
|         lg: "h-11 rounded-md px-8", | ||||
|         icon: "h-10 w-10", | ||||
|  | ||||
| @ -1,22 +1,28 @@ | ||||
| import { useCallback } from "react"; | ||||
| import { useCallback, useMemo } from "react"; | ||||
| import { useLocation, useNavigate } from "react-router-dom"; | ||||
| 
 | ||||
| export default function useOverlayState(key: string) { | ||||
| export default function useOverlayState( | ||||
|   key: string, | ||||
| ): [string | undefined, (value: string, replace?: boolean) => void] { | ||||
|   const location = useLocation(); | ||||
|   const navigate = useNavigate(); | ||||
|   const currentLocationState = location.state; | ||||
| 
 | ||||
|   const setOverlayStateValue = useCallback( | ||||
|     (value: string) => { | ||||
|     (value: string, replace: boolean = false) => { | ||||
|       const newLocationState = { ...currentLocationState }; | ||||
|       newLocationState[key] = value; | ||||
|       navigate(location.pathname, { state: newLocationState }); | ||||
|       navigate(location.pathname, { state: newLocationState, replace }); | ||||
|     }, | ||||
|     // we know that these deps are correct
 | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [key, navigate], | ||||
|   ); | ||||
| 
 | ||||
|   const overlayStateValue = location.state && location.state[key]; | ||||
|   const overlayStateValue = useMemo<string | undefined>( | ||||
|     () => location.state && location.state[key], | ||||
|     [location, key], | ||||
|   ); | ||||
| 
 | ||||
|   return [overlayStateValue, setOverlayStateValue]; | ||||
| } | ||||
|  | ||||
| @ -7,17 +7,26 @@ import useSWR from "swr"; | ||||
| 
 | ||||
| function Live() { | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
| 
 | ||||
|   const [selectedCameraName, setSelectedCameraName] = useOverlayState("camera"); | ||||
|   const [cameraGroup] = useOverlayState("cameraGroup"); | ||||
| 
 | ||||
|   const cameras = useMemo(() => { | ||||
|     if (!config) { | ||||
|       return []; | ||||
|     } | ||||
| 
 | ||||
|     if (cameraGroup) { | ||||
|       const group = config.camera_groups[cameraGroup]; | ||||
|       return Object.values(config.cameras) | ||||
|         .filter((conf) => conf.enabled && group.cameras.includes(conf.name)) | ||||
|         .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); | ||||
|     } | ||||
| 
 | ||||
|     return Object.values(config.cameras) | ||||
|       .filter((conf) => conf.ui.dashboard && conf.enabled) | ||||
|       .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); | ||||
|   }, [config]); | ||||
|   }, [config, cameraGroup]); | ||||
| 
 | ||||
|   const selectedCamera = useMemo( | ||||
|     () => cameras.find((cam) => cam.name == selectedCameraName), | ||||
|  | ||||
| @ -204,6 +204,12 @@ export interface CameraConfig { | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export type CameraGroupConfig = { | ||||
|   cameras: string[]; | ||||
|   icon: string; | ||||
|   order: number; | ||||
| }; | ||||
| 
 | ||||
| export interface FrigateConfig { | ||||
|   audio: { | ||||
|     enabled: boolean; | ||||
| @ -276,6 +282,8 @@ export interface FrigateConfig { | ||||
| 
 | ||||
|   go2rtc: Record<string, unknown>; | ||||
| 
 | ||||
|   camera_groups: { [groupName: string]: CameraGroupConfig }; | ||||
| 
 | ||||
|   live: { | ||||
|     height: number; | ||||
|     quality: number; | ||||
|  | ||||
| @ -3,14 +3,36 @@ import { | ||||
|   FaAmazon, | ||||
|   FaCarSide, | ||||
|   FaCat, | ||||
|   FaCircle, | ||||
|   FaDog, | ||||
|   FaFedex, | ||||
|   FaFire, | ||||
|   FaLeaf, | ||||
|   FaUps, | ||||
| } from "react-icons/fa"; | ||||
| import { LuBox, LuLassoSelect } from "react-icons/lu"; | ||||
| import { MdRecordVoiceOver } from "react-icons/md"; | ||||
| 
 | ||||
| export function getIconTypeForGroup(icon: string) { | ||||
|   switch (icon) { | ||||
|     case "car": | ||||
|       return FaCarSide; | ||||
|     case "cat": | ||||
|       return FaCat; | ||||
|     case "dog": | ||||
|       return FaDog; | ||||
|     case "leaf": | ||||
|       return FaLeaf; | ||||
|     default: | ||||
|       return FaCircle; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function getIconForGroup(icon: string, className: string = "size-4") { | ||||
|   const GroupIcon = getIconTypeForGroup(icon); | ||||
|   return <GroupIcon className={className} />; | ||||
| } | ||||
| 
 | ||||
| export function getIconForLabel(label: string, className?: string) { | ||||
|   switch (label) { | ||||
|     case "car": | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import { useFrigateReviews } from "@/api/ws"; | ||||
| import Logo from "@/components/Logo"; | ||||
| import { CameraGroupSelector } from "@/components/filter/CameraGroupSelector"; | ||||
| import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail"; | ||||
| import LivePlayer from "@/components/player/LivePlayer"; | ||||
| import { Button } from "@/components/ui/button"; | ||||
| @ -78,7 +79,7 @@ export default function LiveDashboardView({ | ||||
|       {isMobile && ( | ||||
|         <div className="relative h-9 flex items-center justify-between"> | ||||
|           <Logo className="absolute inset-y-0 inset-x-1/2 -translate-x-1/2 h-8" /> | ||||
|           <div /> | ||||
|           <CameraGroupSelector /> | ||||
|           <div className="flex items-center gap-1"> | ||||
|             <Button | ||||
|               className={ | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user