From b4b2162adabffe94ebf18dccd0766372e16096a6 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 4 Mar 2024 16:18:30 -0700 Subject: [PATCH] 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 --- frigate/config.py | 13 +++ .../components/filter/CameraGroupSelector.tsx | 102 ++++++++++++++++++ .../components/filter/ReviewFilterGroup.tsx | 39 ++++++- web/src/components/navigation/NavItem.tsx | 47 ++++---- web/src/components/navigation/Sidebar.tsx | 24 +++-- web/src/components/ui/button.tsx | 2 +- web/src/hooks/use-overlay-state.tsx | 16 ++- web/src/pages/Live.tsx | 11 +- web/src/types/frigateConfig.ts | 8 ++ web/src/utils/iconUtil.tsx | 22 ++++ web/src/views/live/LiveDashboardView.tsx | 3 +- 11 files changed, 247 insertions(+), 40 deletions(-) create mode 100644 web/src/components/filter/CameraGroupSelector.tsx diff --git a/frigate/config.py b/frigate/config.py index 4191eafc0..3ab7ea956 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -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.", diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx new file mode 100644 index 000000000..f975d290c --- /dev/null +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -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("config"); + const navigate = useNavigate(); + + // tooltip + + const [tooltip, setTooltip] = useState(); + const [timeoutId, setTimeoutId] = useState(); + 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 ( +
+ + + + + + Home + + + {groups.map(([name, config]) => { + return ( + + + + + + {name} + + + ); + })} +
+ ); +} diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 6fb5c7d42..4ecf71c4e 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -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({
{ 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 && ( + <> + + {groups.map(([name, conf]) => { + return ( + { + setCurrentCameras([...conf.cameras]); + }} + /> + ); + })} + + )} {allCameras.map((item) => ( void; }; function FilterCheckBox({ label, + CheckIcon = LuCheck, isChecked, onCheckedChange, }: FilterCheckBoxProps) { @@ -366,7 +401,7 @@ function FilterCheckBox({ onClick={() => onCheckedChange(!isChecked)} > {isChecked ? ( - + ) : (
)} diff --git a/web/src/components/navigation/NavItem.tsx b/web/src/components/navigation/NavItem.tsx index e19ff92a2..37436ffde 100644 --- a/web/src/components/navigation/NavItem.tsx +++ b/web/src/components/navigation/NavItem.tsx @@ -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 && ( - - - `${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)} - > - - - - + const content = ( + + `${className} flex flex-col justify-center items-center rounded-lg ${ + variants[variant][isActive ? "active" : "inactive"] + }` + } + > + + + ); + + if (isDesktop) { + return ( + + {content}

{title}

- ) - ); + ); + } + + return content; } diff --git a/web/src/components/navigation/Sidebar.tsx b/web/src/components/navigation/Sidebar.tsx index 1fdea0c41..1956a8562 100644 --- a/web/src/components/navigation/Sidebar.tsx +++ b/web/src/components/navigation/Sidebar.tsx @@ -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 (