From d0aefc21215b7e5077ca259c3d756ee1390c4a02 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 25 Apr 2024 18:19:31 -0500 Subject: [PATCH 01/56] Camera group dialog changes and fixes (#11117) * camera group dialog changes and fixes * use drawer on mobile * spacing --- .../components/filter/CameraGroupSelector.tsx | 757 ++++++++++++------ web/src/components/settings/ZoneEditPane.tsx | 8 - web/src/pages/Live.tsx | 15 +- web/src/views/live/LiveDashboardView.tsx | 4 +- 4 files changed, 550 insertions(+), 234 deletions(-) diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index c147ab638..f410e8e8a 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -3,7 +3,7 @@ import { FrigateConfig, GROUP_ICONS, } from "@/types/frigateConfig"; -import { isDesktop } from "react-device-detect"; +import { isDesktop, isMobile } from "react-device-detect"; import useSWR from "swr"; import { MdHome } from "react-icons/md"; import { usePersistedOverlayState } from "@/hooks/use-overlay-state"; @@ -11,19 +11,54 @@ import { Button } from "../ui/button"; import { useCallback, useMemo, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { getIconForGroup } from "@/utils/iconUtil"; -import { LuPencil, LuPlus, LuTrash } from "react-icons/lu"; +import { LuPencil, LuPlus } from "react-icons/lu"; import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; +import { Drawer, DrawerContent } from "../ui/drawer"; import { Input } from "../ui/input"; +import { Separator } from "../ui/separator"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, + DropdownMenuItem, DropdownMenuTrigger, } from "../ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; import axios from "axios"; import FilterSwitch from "./FilterSwitch"; +import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi"; +import IconWrapper from "../ui/icon-wrapper"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Toaster } from "@/components/ui/sonner"; +import { toast } from "sonner"; +import ActivityIndicator from "../indicators/activity-indicator"; +import { ScrollArea, ScrollBar } from "../ui/scroll-area"; type CameraGroupSelectorProps = { className?: string; @@ -71,70 +106,79 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { const [addGroup, setAddGroup] = useState(false); + const Scroller = isMobile ? ScrollArea : "div"; + return ( -
+ <> - - - - - - - All Cameras - - - {groups.map(([name, config]) => { - return ( - + +
+ - {name} + All Cameras - ); - })} - {isDesktop && ( - - )} -
+ {groups.map(([name, config]) => { + return ( + + + + + + {name} + + + ); + })} + + + {isMobile && } +
+ + ); } @@ -142,195 +186,462 @@ type NewGroupDialogProps = { open: boolean; setOpen: (open: boolean) => void; currentGroups: [string, CameraGroupConfig][]; + activeGroup?: string; + setGroup: (value: string | undefined, replace?: boolean | undefined) => void; }; -function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) { +function NewGroupDialog({ + open, + setOpen, + currentGroups, + activeGroup, + setGroup, +}: NewGroupDialogProps) { + const { mutate: updateConfig } = useSWR("config"); + + // editing group and state + + const [editingGroupName, setEditingGroupName] = useState(""); + + const editingGroup = useMemo(() => { + if (currentGroups && editingGroupName !== undefined) { + return currentGroups.find( + ([groupName]) => groupName === editingGroupName, + ); + } else { + return undefined; + } + }, [currentGroups, editingGroupName]); + + const [editState, setEditState] = useState<"none" | "add" | "edit">("none"); + const [isLoading, setIsLoading] = useState(false); + + // callbacks + + const onDeleteGroup = useCallback( + async (name: string) => { + // TODO: reset order on groups when deleting + + await axios + .put(`config/set?camera_groups.${name}`, { requires_restart: 0 }) + .then((res) => { + if (res.status === 200) { + if (activeGroup == name) { + // deleting current group + setGroup("default"); + } + updateConfig(); + } else { + setOpen(false); + setEditState("none"); + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + setOpen(false); + setEditState("none"); + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [updateConfig, activeGroup, setGroup, setOpen], + ); + + const onSave = () => { + setOpen(false); + setEditState("none"); + }; + + const onCancel = () => { + setEditingGroupName(""); + setEditState("none"); + }; + + const onEditGroup = useCallback((group: [string, CameraGroupConfig]) => { + setEditingGroupName(group[0]); + setEditState("edit"); + }, []); + + const Overlay = isDesktop ? Dialog : Drawer; + const Content = isDesktop ? DialogContent : DrawerContent; + + return ( + <> + + { + setEditState("none"); + setOpen(open); + }} + > + +
+ {editState === "none" && ( + <> +
+ Camera Groups + +
+ {currentGroups.map((group) => ( + onDeleteGroup(group[0])} + onEditGroup={() => onEditGroup(group)} + /> + ))} + + )} + + {editState != "none" && ( + <> +
+ + {editState == "add" ? "Add" : "Edit"} Camera Group + +
+ + + )} +
+
+
+ + ); +} + +type CameraGroupRowProps = { + group: [string, CameraGroupConfig]; + onDeleteGroup: () => void; + onEditGroup: () => void; +}; + +export function CameraGroupRow({ + group, + onDeleteGroup, + onEditGroup, +}: CameraGroupRowProps) { + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + if (!group) { + return; + } + + return ( + <> +
+
+

{group[0]}

+
+ setDeleteDialogOpen(!deleteDialogOpen)} + > + + + Confirm Delete + + + Are you sure you want to delete the camera group{" "} + {group[0]}? + + + Cancel + + Delete + + + + + + {isMobile && ( + <> + + + + + + Edit + setDeleteDialogOpen(true)}> + Delete + + + + + )} + {!isMobile && ( +
+ + + + + Edit + + + + + setDeleteDialogOpen(true)} + /> + + Delete + +
+ )} +
+ + ); +} + +type CameraGroupEditProps = { + currentGroups: [string, CameraGroupConfig][]; + editingGroup?: [string, CameraGroupConfig]; + isLoading: boolean; + setIsLoading: React.Dispatch>; + onSave?: () => void; + onCancel?: () => void; +}; + +export function CameraGroupEdit({ + currentGroups, + editingGroup, + isLoading, + setIsLoading, + onSave, + onCancel, +}: CameraGroupEditProps) { const { data: config, mutate: updateConfig } = useSWR("config"); const birdseyeConfig = useMemo(() => config?.birdseye, [config]); - // add fields + const formSchema = z.object({ + name: z + .string() + .min(2, { + message: "Camera group name must be at least 2 characters.", + }) + .transform((val: string) => val.trim().replace(/\s+/g, "_")) + .refine( + (value: string) => { + return ( + editingGroup !== undefined || + !currentGroups.map((group) => group[0]).includes(value) + ); + }, + { + message: "Camera group name already exists.", + }, + ), + cameras: z.array(z.string()).min(2, { + message: "You must select at least two cameras.", + }), + icon: z.string(), + }); - const [editState, setEditState] = useState<"none" | "add" | "edit">("none"); - const [newTitle, setNewTitle] = useState(""); - const [icon, setIcon] = useState(""); - const [cameras, setCameras] = useState([]); - - // validation - - const [error, setError] = useState(""); - - const onCreateGroup = useCallback(async () => { - if (!newTitle) { - setError("A title must be selected"); - return; - } - - if (!icon) { - setError("An icon must be selected"); - return; - } - - if (!cameras || cameras.length < 2) { - setError("At least 2 cameras must be selected"); - return; - } - - setError(""); - const orderQuery = `camera_groups.${newTitle}.order=${currentGroups.length}`; - const iconQuery = `camera_groups.${newTitle}.icon=${icon}`; - const cameraQueries = cameras - .map((cam) => `&camera_groups.${newTitle}.cameras=${cam}`) - .join(""); - - const req = axios.put( - `config/set?${orderQuery}&${iconQuery}${cameraQueries}`, - { requires_restart: 0 }, - ); - - setOpen(false); - - if ((await req).status == 200) { - setNewTitle(""); - setIcon(""); - setCameras([]); - updateConfig(); - } - }, [currentGroups, cameras, newTitle, icon, setOpen, updateConfig]); - - const onDeleteGroup = useCallback( - async (name: string) => { - const req = axios.put(`config/set?camera_groups.${name}`, { - requires_restart: 0, - }); - - if ((await req).status == 200) { - updateConfig(); + const onSubmit = useCallback( + async (values: z.infer) => { + if (!values) { + return; } + + setIsLoading(true); + + const order = + editingGroup === undefined + ? currentGroups.length + 1 + : editingGroup[1].order; + + const orderQuery = `camera_groups.${values.name}.order=${order}`; + const iconQuery = `camera_groups.${values.name}.icon=${values.icon}`; + const cameraQueries = values.cameras + .map((cam) => `&camera_groups.${values.name}.cameras=${cam}`) + .join(""); + + axios + .put(`config/set?${orderQuery}&${iconQuery}${cameraQueries}`, { + requires_restart: 0, + }) + .then((res) => { + if (res.status === 200) { + toast.success(`Camera group (${values.name}) has been saved.`, { + position: "top-center", + }); + updateConfig(); + if (onSave) { + onSave(); + } + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); }, - [updateConfig], + [currentGroups, setIsLoading, onSave, updateConfig, editingGroup], ); + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + name: (editingGroup && editingGroup[0]) ?? "", + icon: editingGroup && editingGroup[1].icon, + cameras: editingGroup && editingGroup[1].cameras, + }, + }); + return ( - { - setEditState("none"); - setNewTitle(""); - setIcon(""); - setCameras([]); - setOpen(open); - }} - > - - Camera Groups - {currentGroups.map((group) => ( -
- {group[0]} -
- - -
-
- ))} - {currentGroups.length > 0 && } - {editState == "none" && ( - - )} - {editState != "none" && ( - <> - setNewTitle(e.target.value)} - /> - - -
- {icon.length == 0 ? "Select Icon" : "Icon: "} - {icon ? getIconForGroup(icon) :
} -
- - - - {GROUP_ICONS.map((gIcon) => ( - - {getIconForGroup(gIcon)} - {gIcon} - - ))} - - - - - -
- {cameras.length == 0 - ? "Select Cameras" - : `${cameras.length} Cameras`} -
-
- - {[ - ...(birdseyeConfig?.enabled ? ["birdseye"] : []), - ...Object.keys(config?.cameras ?? {}), - ].map((camera) => ( +
+ + ( + + Name + + + + + + )} + /> + + + ( + + Cameras + Select cameras for this group. + {[ + ...(birdseyeConfig?.enabled ? ["birdseye"] : []), + ...Object.keys(config?.cameras ?? {}), + ].map((camera) => ( + { - if (checked) { - setCameras([...cameras, camera]); - } else { - const index = cameras.indexOf(camera); - setCameras([ - ...cameras.slice(0, index), - ...cameras.slice(index + 1), - ]); - } + const updatedCameras = checked + ? [...(field.value || []), camera] + : (field.value || []).filter((c) => c !== camera); + form.setValue("cameras", updatedCameras); }} /> - ))} - - - {error &&
{error}
} - - - )} - -
+ + ))} + + + )} + /> + + + ( + + Icon + + + + + + )} + /> + + + +
+ + +
+ + ); } diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 803d172d8..f72cc3907 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -551,14 +551,6 @@ export function ZoneObjectSelector({ const labels = new Set(); - // Object.values(config.cameras).forEach((camera) => { - // camera.objects.track.forEach((label) => { - // if (!ATTRIBUTE_LABELS.includes(label)) { - // labels.add(label); - // } - // }); - // }); - cameraConfig.objects.track.forEach((label) => { if (!ATTRIBUTE_LABELS.includes(label)) { labels.add(label); diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index 4be6b2c16..387aa3856 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -38,7 +38,13 @@ function Live() { // settings const includesBirdseye = useMemo(() => { - if (config && cameraGroup && cameraGroup != "default") { + if ( + config && + Object.keys(config.camera_groups).length && + cameraGroup && + config.camera_groups[cameraGroup] && + cameraGroup != "default" + ) { return config.camera_groups[cameraGroup].cameras.includes("birdseye"); } else { return false; @@ -50,7 +56,12 @@ function Live() { return []; } - if (cameraGroup && cameraGroup != "default") { + if ( + Object.keys(config.camera_groups).length && + cameraGroup && + config.camera_groups[cameraGroup] && + cameraGroup != "default" + ) { const group = config.camera_groups[cameraGroup]; return Object.values(config.cameras) .filter((conf) => conf.enabled && group.cameras.includes(conf.name)) diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 3c186656a..31be2dce1 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -144,7 +144,9 @@ export default function LiveDashboardView({ {isMobile && (
- +
+ +
- - All Cameras - + + + All Cameras + + {groups.map(([name, config]) => { return ( @@ -161,9 +164,11 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { {getIconForGroup(config.icon)} - - {name} - + + + {name} + + ); })} diff --git a/web/src/components/menu/AccountSettings.tsx b/web/src/components/menu/AccountSettings.tsx index 72d05e81f..a5068c753 100644 --- a/web/src/components/menu/AccountSettings.tsx +++ b/web/src/components/menu/AccountSettings.tsx @@ -3,6 +3,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { TooltipPortal } from "@radix-ui/react-tooltip"; import { isDesktop } from "react-device-detect"; import { VscAccount } from "react-icons/vsc"; @@ -19,9 +20,11 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
- -

Account

-
+ + +

Account

+
+
); } diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index f868eddb5..6974c8075 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -65,6 +65,7 @@ import { DialogPortal, DialogTrigger, } from "../ui/dialog"; +import { TooltipPortal } from "@radix-ui/react-tooltip"; type GeneralSettingsProps = { className?: string; @@ -124,9 +125,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
- -

Settings

-
+ + +

Settings

+
+
From c0073db859c4e4e5767bc10b959ce7d618c64dcd Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 6 May 2024 13:35:18 -0500 Subject: [PATCH 41/56] fix touch events (#11265) --- web/src/components/timeline/SummaryTimeline.tsx | 6 +++--- web/src/hooks/use-draggable-element.ts | 4 ++-- web/src/views/live/LiveCameraView.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/components/timeline/SummaryTimeline.tsx b/web/src/components/timeline/SummaryTimeline.tsx index 54573c4f4..db1200546 100644 --- a/web/src/components/timeline/SummaryTimeline.tsx +++ b/web/src/components/timeline/SummaryTimeline.tsx @@ -187,7 +187,7 @@ export function SummaryTimeline({ e.stopPropagation(); let clientY; - if (e.nativeEvent instanceof TouchEvent) { + if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) { clientY = e.nativeEvent.touches[0].clientY; } else if (e.nativeEvent instanceof MouseEvent) { clientY = e.nativeEvent.clientY; @@ -238,7 +238,7 @@ export function SummaryTimeline({ setIsDragging(true); let clientY; - if (e.nativeEvent instanceof TouchEvent) { + if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) { clientY = e.nativeEvent.touches[0].clientY; } else if (e.nativeEvent instanceof MouseEvent) { clientY = e.nativeEvent.clientY; @@ -276,7 +276,7 @@ export function SummaryTimeline({ } e.stopPropagation(); let clientY; - if (e instanceof TouchEvent) { + if ("TouchEvent" in window && e instanceof TouchEvent) { clientY = e.touches[0].clientY; } else if (e instanceof MouseEvent) { clientY = e.clientY; diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index f18cae152..8cc61d451 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -87,7 +87,7 @@ function useDraggableElement({ const getClientYPosition = useCallback( (e: MouseEvent | TouchEvent) => { let clientY; - if (e instanceof TouchEvent) { + if ("TouchEvent" in window && e instanceof TouchEvent) { clientY = e.touches[0].clientY; } else if (e instanceof MouseEvent) { clientY = e.clientY; @@ -113,7 +113,7 @@ function useDraggableElement({ setIsDragging(true); let clientY; - if (e.nativeEvent instanceof TouchEvent) { + if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) { clientY = e.nativeEvent.touches[0].clientY; } else if (e.nativeEvent instanceof MouseEvent) { clientY = e.nativeEvent.clientY; diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index bc27234bb..aef3453e7 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -98,7 +98,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { let clientX; let clientY; - if (e.nativeEvent instanceof TouchEvent) { + if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) { clientX = e.nativeEvent.touches[0].clientX; clientY = e.nativeEvent.touches[0].clientY; } else if (e.nativeEvent instanceof MouseEvent) { From e5e18a50268255fda93bdc62df00a9a5a40d0af9 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 6 May 2024 15:18:13 -0600 Subject: [PATCH 42/56] UI Tweaks (#11263) * Add icons for frigate+ items * Fix bug where export didn't start * Fix mobile overflow * Capitalize first letter function --- .../components/filter/CameraGroupSelector.tsx | 2 +- web/src/components/overlay/ExportDialog.tsx | 8 ++++ .../player/PreviewThumbnailPlayer.tsx | 3 +- web/src/pages/SubmitPlus.tsx | 37 ++++++++++++++++++- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 0b38bb713..2f3628088 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -568,7 +568,7 @@ export function CameraGroupEdit({ /> -
+
{ + const now = new Date(latestTime * 1000); + let start = 0; + now.setHours(now.getHours() - 1); + start = now.getTime() / 1000; + setRange({ + before: latestTime, + after: start, + }); setMode("select"); }} > diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index c36a2e129..97e96713c 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -24,6 +24,7 @@ import ActivityIndicator from "../indicators/activity-indicator"; import { TimeRange } from "@/types/timeline"; import { NoThumbSlider } from "../ui/slider"; import { PREVIEW_FPS, PREVIEW_PADDING } from "@/types/preview"; +import { capitalizeFirstLetter } from "@/utils/stringUtil"; type PreviewPlayerProps = { review: ReviewSegment; @@ -263,7 +264,7 @@ export default function PreviewThumbnailPlayer({ .filter( (item) => item !== undefined && !item.includes("-verified"), ) - .map((text) => text.charAt(0).toUpperCase() + text.substring(1)) + .map((text) => capitalizeFirstLetter(text)) .sort() .join(", ") .replaceAll("-verified", "")} diff --git a/web/src/pages/SubmitPlus.tsx b/web/src/pages/SubmitPlus.tsx index f51ad514d..e2863760a 100644 --- a/web/src/pages/SubmitPlus.tsx +++ b/web/src/pages/SubmitPlus.tsx @@ -3,6 +3,7 @@ import { CamerasFilterButton, GeneralFilterContent, } from "@/components/filter/ReviewFilterGroup"; +import Chip from "@/components/indicators/Chip"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -23,8 +24,15 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { DualThumbSlider } from "@/components/ui/slider"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { Event } from "@/types/event"; import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; +import { getIconForLabel } from "@/utils/iconUtil"; +import { capitalizeFirstLetter } from "@/utils/stringUtil"; import axios from "axios"; import { useCallback, useEffect, useMemo, useState } from "react"; import { isMobile } from "react-device-detect"; @@ -182,9 +190,36 @@ export default function SubmitPlus() { return (
setUpload(event)} > +
+ +
+ +
+ + {[event.label].map((object) => { + return getIconForLabel( + object, + "size-3 text-white", + ); + })} + +
+
+
+ + {[event.label] + .map((text) => capitalizeFirstLetter(text)) + .sort() + .join(", ") + .replaceAll("-verified", "")} + +
+
Date: Mon, 6 May 2024 17:00:21 -0600 Subject: [PATCH 43/56] Implement infinite scrolling for frigate+ view (#11273) * Implement infinite scrolling for frigate+ view * Also fixes safari preview glitch * Show sub label name in hover --- web/src/components/player/LivePlayer.tsx | 8 +- .../player/PreviewThumbnailPlayer.tsx | 5 + web/src/pages/SubmitPlus.tsx | 132 +++++++++++++++--- 3 files changed, 127 insertions(+), 18 deletions(-) diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index dcdd6423d..840b6413c 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -184,7 +184,13 @@ export default function LivePlayer({
- {[...new Set([...(objects || []).map(({ label }) => label)])] + {[ + ...new Set([ + ...(objects || []).map(({ label, sub_label }) => + label.endsWith("verified") ? sub_label : label, + ), + ]), + ] .filter( (label) => label !== undefined && !label.includes("-verified"), diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 97e96713c..59c8d73c9 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -431,6 +431,11 @@ export function VideoPreview({ setReviewed(); if (loop && playerRef.current) { + if (manualPlayback) { + setManualPlayback(false); + setTimeout(() => setManualPlayback(true), 100); + } + playerRef.current.currentTime = playerStartTime; return; } diff --git a/web/src/pages/SubmitPlus.tsx b/web/src/pages/SubmitPlus.tsx index e2863760a..0799597aa 100644 --- a/web/src/pages/SubmitPlus.tsx +++ b/web/src/pages/SubmitPlus.tsx @@ -4,6 +4,7 @@ import { GeneralFilterContent, } from "@/components/filter/ReviewFilterGroup"; import Chip from "@/components/indicators/Chip"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -34,7 +35,7 @@ import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; import { getIconForLabel } from "@/utils/iconUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil"; import axios from "axios"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isMobile } from "react-device-detect"; import { FaList, @@ -44,6 +45,9 @@ import { } from "react-icons/fa"; import { PiSlidersHorizontalFill } from "react-icons/pi"; import useSWR from "swr"; +import useSWRInfinite from "swr/infinite"; + +const API_LIMIT = 100; export default function SubmitPlus() { const { data: config } = useSWR("config"); @@ -64,21 +68,93 @@ export default function SubmitPlus() { // data - const { data: events, mutate: refresh } = useSWR([ - "events", - { - limit: 100, - in_progress: 0, - is_submitted: 0, - cameras: selectedCameras ? selectedCameras.join(",") : null, - labels: selectedLabels ? selectedLabels.join(",") : null, - min_score: scoreRange ? scoreRange[0] : null, - max_score: scoreRange ? scoreRange[1] : null, - sort: sort ? sort : null, + const eventFetcher = useCallback((key: string) => { + const [path, params] = Array.isArray(key) ? key : [key, undefined]; + return axios.get(path, { params }).then((res) => res.data); + }, []); + + const getKey = useCallback( + (index: number, prevData: Event[]) => { + if (index > 0) { + const lastDate = prevData[prevData.length - 1].start_time; + return [ + "events", + { + limit: API_LIMIT, + in_progress: 0, + is_submitted: 0, + cameras: selectedCameras ? selectedCameras.join(",") : null, + labels: selectedLabels ? selectedLabels.join(",") : null, + min_score: scoreRange ? scoreRange[0] : null, + max_score: scoreRange ? scoreRange[1] : null, + sort: sort ? sort : null, + before: lastDate, + }, + ]; + } + + return [ + "events", + { + limit: 100, + in_progress: 0, + is_submitted: 0, + cameras: selectedCameras ? selectedCameras.join(",") : null, + labels: selectedLabels ? selectedLabels.join(",") : null, + min_score: scoreRange ? scoreRange[0] : null, + max_score: scoreRange ? scoreRange[1] : null, + sort: sort ? sort : null, + }, + ]; }, - ]); + [scoreRange, selectedCameras, selectedLabels, sort], + ); + + const { + data: eventPages, + mutate: refresh, + size, + setSize, + isValidating, + } = useSWRInfinite(getKey, eventFetcher, { + revalidateOnFocus: false, + }); + + const events = useMemo( + () => (eventPages ? eventPages.flat() : []), + [eventPages], + ); + const [upload, setUpload] = useState(); + // paging + + const isDone = useMemo( + () => (eventPages?.at(-1)?.length ?? 0) < API_LIMIT, + [eventPages], + ); + + const pagingObserver = useRef(); + const lastEventRef = useCallback( + (node: HTMLElement | null) => { + if (isValidating) return; + if (pagingObserver.current) pagingObserver.current.disconnect(); + try { + pagingObserver.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !isDone) { + setSize(size + 1); + } + }); + if (node) pagingObserver.current.observe(node); + } catch (e) { + // no op + } + }, + [isValidating, isDone, size, setSize], + ); + + // layout + const grow = useMemo(() => { if (!config || !upload) { return ""; @@ -110,18 +186,35 @@ export default function SubmitPlus() { }); refresh( - (data: Event[] | undefined) => { + (data: Event[][] | undefined) => { if (!data) { return data; } - const index = data.findIndex((e) => e.id == upload.id); + let pageIndex = -1; + let index = -1; + + data.forEach((page, pIdx) => { + const search = page.findIndex((e) => e.id == upload.id); + + if (search != -1) { + pageIndex = pIdx; + index = search; + } + }); if (index == -1) { return data; } - return [...data.slice(0, index), ...data.slice(index + 1)]; + return [ + ...data.slice(0, pageIndex), + [ + ...data[pageIndex].slice(0, index), + ...data[pageIndex].slice(index + 1), + ], + ...data.slice(pageIndex + 1), + ]; }, { revalidate: false, populateCache: true }, ); @@ -182,14 +275,17 @@ export default function SubmitPlus() { - {events?.map((event) => { + {events?.map((event, eIdx) => { if (event.data.type != "object") { return; } + const lastRow = eIdx == events.length - 1; + return (
setUpload(event)} > @@ -228,6 +324,8 @@ export default function SubmitPlus() {
); })} + + {isValidating && }
From a0da5018bfd113885abb1047a64cb2ffdd2732b6 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 7 May 2024 07:27:20 -0500 Subject: [PATCH 44/56] add scrollbar on ptz presets dropdown (#11276) --- web/src/views/live/LiveCameraView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index aef3453e7..20e7df8fa 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -545,7 +545,7 @@ function PtzControlPanel({ - + {ptz?.presets.map((preset) => { return ( Date: Tue, 7 May 2024 09:00:25 -0500 Subject: [PATCH 45/56] Use cn() for class names throughout (#11278) * add scrollbar on ptz presets dropdown * use cn function for class names throughout * Revert "add scrollbar on ptz presets dropdown" This reverts commit 2cee93dc3ecf0f77c78b8ef5bcd3c7135227ecd3. --- web/src/components/Logo.tsx | 4 +++- web/src/components/camera/ResizingCameraImage.tsx | 3 ++- web/src/components/card/ExportCard.tsx | 6 +++++- web/src/components/dynamic/CameraFeatureToggle.tsx | 9 ++++++--- web/src/components/filter/CameraGroupSelector.tsx | 7 ++++++- web/src/components/icons/FrigatePlusIcon.tsx | 3 ++- web/src/components/indicators/Chip.tsx | 6 +++++- .../components/indicators/ImageLoadingIndicator.tsx | 5 +++-- .../components/indicators/activity-indicator.tsx | 3 ++- web/src/components/menu/AccountSettings.tsx | 9 ++++++++- web/src/components/navigation/Bottombar.tsx | 6 +++++- web/src/components/navigation/NavItem.tsx | 9 ++++++--- web/src/components/player/BirdseyeLivePlayer.tsx | 6 +++++- web/src/components/player/LivePlayer.tsx | 12 +++++++++--- web/src/components/player/PreviewPlayer.tsx | 13 +++++++++++-- web/src/components/player/VideoControls.tsx | 6 +++++- .../player/dynamic/DynamicVideoPlayer.tsx | 6 +++++- web/src/pages/Logs.tsx | 7 ++++++- 18 files changed, 94 insertions(+), 26 deletions(-) diff --git a/web/src/components/Logo.tsx b/web/src/components/Logo.tsx index 32cd52eda..ca2897ac4 100644 --- a/web/src/components/Logo.tsx +++ b/web/src/components/Logo.tsx @@ -1,9 +1,11 @@ +import { cn } from "@/lib/utils"; + type LogoProps = { className?: string; }; export default function Logo({ className }: LogoProps) { return ( - + ); diff --git a/web/src/components/camera/ResizingCameraImage.tsx b/web/src/components/camera/ResizingCameraImage.tsx index f19c40ff3..47b25124e 100644 --- a/web/src/components/camera/ResizingCameraImage.tsx +++ b/web/src/components/camera/ResizingCameraImage.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import ActivityIndicator from "../indicators/activity-indicator"; import { useResizeObserver } from "@/hooks/resize-observer"; +import { cn } from "@/lib/utils"; type CameraImageProps = { className?: string; @@ -95,7 +96,7 @@ export default function CameraImage({ return (
{enabled ? ( diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index f7dd7f588..2ce048562 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -18,6 +18,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { DeleteClipType, Export } from "@/types/export"; import { MdEditSquare } from "react-icons/md"; import { baseUrl } from "@/api/baseUrl"; +import { cn } from "@/lib/utils"; type ExportProps = { className: string; @@ -104,7 +105,10 @@ export default function ExportCard({
setHovered(true) diff --git a/web/src/components/dynamic/CameraFeatureToggle.tsx b/web/src/components/dynamic/CameraFeatureToggle.tsx index b0418c556..a31bafb9d 100644 --- a/web/src/components/dynamic/CameraFeatureToggle.tsx +++ b/web/src/components/dynamic/CameraFeatureToggle.tsx @@ -5,6 +5,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { isDesktop } from "react-device-detect"; +import { cn } from "@/lib/utils"; const variants = { primary: { @@ -38,9 +39,11 @@ export default function CameraFeatureToggle({ const content = (
diff --git a/web/src/components/icons/FrigatePlusIcon.tsx b/web/src/components/icons/FrigatePlusIcon.tsx index 1a9ff2e05..24ee06eb5 100644 --- a/web/src/components/icons/FrigatePlusIcon.tsx +++ b/web/src/components/icons/FrigatePlusIcon.tsx @@ -1,5 +1,6 @@ import { LuPlus } from "react-icons/lu"; import Logo from "../Logo"; +import { cn } from "@/lib/utils"; type FrigatePlusIconProps = { className?: string; @@ -11,7 +12,7 @@ export default function FrigatePlusIcon({ }: FrigatePlusIconProps) { return (
diff --git a/web/src/components/indicators/Chip.tsx b/web/src/components/indicators/Chip.tsx index 7a2c4f5b5..e9abcf5e6 100644 --- a/web/src/components/indicators/Chip.tsx +++ b/web/src/components/indicators/Chip.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/lib/utils"; import { LogSeverity } from "@/types/log"; import { ReactNode, useMemo, useRef } from "react"; import { CSSTransition } from "react-transition-group"; @@ -32,7 +33,10 @@ export default function Chip({ >
{children} diff --git a/web/src/components/indicators/ImageLoadingIndicator.tsx b/web/src/components/indicators/ImageLoadingIndicator.tsx index 32531ea2b..dc3054181 100644 --- a/web/src/components/indicators/ImageLoadingIndicator.tsx +++ b/web/src/components/indicators/ImageLoadingIndicator.tsx @@ -1,5 +1,6 @@ import { isSafari } from "react-device-detect"; import { Skeleton } from "../ui/skeleton"; +import { cn } from "@/lib/utils"; export default function ImageLoadingIndicator({ className, @@ -13,8 +14,8 @@ export default function ImageLoadingIndicator({ } return isSafari ? ( -
+
) : ( - + ); } diff --git a/web/src/components/indicators/activity-indicator.tsx b/web/src/components/indicators/activity-indicator.tsx index edabf1f1b..5591c9b7d 100644 --- a/web/src/components/indicators/activity-indicator.tsx +++ b/web/src/components/indicators/activity-indicator.tsx @@ -1,9 +1,10 @@ +import { cn } from "@/lib/utils"; import { LuLoader2 } from "react-icons/lu"; export default function ActivityIndicator({ className = "w-full", size = 30 }) { return (
diff --git a/web/src/components/menu/AccountSettings.tsx b/web/src/components/menu/AccountSettings.tsx index a5068c753..f6afa1d99 100644 --- a/web/src/components/menu/AccountSettings.tsx +++ b/web/src/components/menu/AccountSettings.tsx @@ -3,6 +3,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { isDesktop } from "react-device-detect"; import { VscAccount } from "react-icons/vsc"; @@ -15,7 +16,13 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx index d0059ffcf..d71dfba12 100644 --- a/web/src/components/navigation/Bottombar.tsx +++ b/web/src/components/navigation/Bottombar.tsx @@ -14,6 +14,7 @@ import { StatusMessage, } from "@/context/statusbar-provider"; import { Link } from "react-router-dom"; +import { cn } from "@/lib/utils"; function Bottombar() { const navItems = useNavigation("secondary"); @@ -75,7 +76,10 @@ function StatusAlertNav({ className }: StatusAlertNavProps) {
{Object.entries(messages).map(([key, messageArray]) => ( diff --git a/web/src/components/navigation/NavItem.tsx b/web/src/components/navigation/NavItem.tsx index acda54ebe..41f5aeff4 100644 --- a/web/src/components/navigation/NavItem.tsx +++ b/web/src/components/navigation/NavItem.tsx @@ -8,6 +8,7 @@ import { isDesktop } from "react-device-detect"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { NavData } from "@/types/navigation"; import { IconType } from "react-icons"; +import { cn } from "@/lib/utils"; const variants = { primary: { @@ -42,9 +43,11 @@ export default function NavItem({ to={item.url} onClick={onClick} className={({ isActive }) => - `flex flex-col justify-center items-center rounded-lg ${className ?? ""} ${ - variants[item.variant ?? "primary"][isActive ? "active" : "inactive"] - }` + cn( + "flex flex-col justify-center items-center rounded-lg", + className, + variants[item.variant ?? "primary"][isActive ? "active" : "inactive"], + ) } > diff --git a/web/src/components/player/BirdseyeLivePlayer.tsx b/web/src/components/player/BirdseyeLivePlayer.tsx index a14756039..b9c64379b 100644 --- a/web/src/components/player/BirdseyeLivePlayer.tsx +++ b/web/src/components/player/BirdseyeLivePlayer.tsx @@ -4,6 +4,7 @@ import ActivityIndicator from "../indicators/activity-indicator"; import JSMpegPlayer from "./JSMpegPlayer"; import MSEPlayer from "./MsePlayer"; import { LivePlayerMode } from "@/types/live"; +import { cn } from "@/lib/utils"; type LivePlayerProps = { className?: string; @@ -57,7 +58,10 @@ export default function BirdseyeLivePlayer({ return (
diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 840b6413c..3e1503d28 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -13,6 +13,7 @@ import useCameraLiveMode from "@/hooks/use-camera-live-mode"; import { getIconForLabel } from "@/utils/iconUtil"; import Chip from "../indicators/Chip"; import { capitalizeFirstLetter } from "@/utils/stringUtil"; +import { cn } from "@/lib/utils"; type LivePlayerProps = { cameraRef?: (ref: HTMLDivElement | null) => void; @@ -150,11 +151,16 @@ export default function LivePlayer({
diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx index 8554c0669..03a6c47d2 100644 --- a/web/src/components/player/PreviewPlayer.tsx +++ b/web/src/components/player/PreviewPlayer.tsx @@ -15,6 +15,7 @@ import { baseUrl } from "@/api/baseUrl"; import { isAndroid, isChrome, isMobile } from "react-device-detect"; import { TimeRange } from "@/types/timeline"; import { Skeleton } from "../ui/skeleton"; +import { cn } from "@/lib/utils"; type PreviewPlayerProps = { className?: string; @@ -238,7 +239,11 @@ function PreviewVideoPlayer({ return (
{video && features.volume && (
diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 0c095de97..7d1c7e4d9 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -11,6 +11,7 @@ import { TimeRange } from "@/types/timeline"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { VideoResolutionType } from "@/types/live"; import axios from "axios"; +import { cn } from "@/lib/utils"; /** * Dynamically switches between video playback and scrubbing preview player. @@ -202,7 +203,10 @@ export default function DynamicVideoPlayer({ onUploadFrame={onUploadFrameToPlus} />
From ff2948a76b8f864ff927564faec83f5ba8a51112 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 7 May 2024 09:28:10 -0500 Subject: [PATCH 46/56] Drag to reorder/resize cameras in camera groups (#11279) * draggable/resizable cameras in camera groups on desktop/tablets * fix edit button location on tablets * assume 1rem is 16px --- web/package-lock.json | 71 +++ web/package.json | 2 + .../components/filter/CameraGroupSelector.tsx | 28 +- web/src/components/settings/General.tsx | 82 +++- web/src/hooks/use-overlay-state.tsx | 14 +- web/src/hooks/use-persistence.ts | 10 +- web/src/pages/Live.tsx | 1 + web/src/views/live/DraggableGridLayout.tsx | 461 ++++++++++++++++++ web/src/views/live/LiveDashboardView.tsx | 104 ++-- 9 files changed, 714 insertions(+), 59 deletions(-) create mode 100644 web/src/views/live/DraggableGridLayout.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 122c26570..1df2344a4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -48,6 +48,7 @@ "react-day-picker": "^8.10.1", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", + "react-grid-layout": "^1.4.4", "react-hook-form": "^7.51.3", "react-icons": "^5.1.0", "react-konva": "^18.2.10", @@ -76,6 +77,7 @@ "@types/node": "^20.12.7", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", + "@types/react-grid-layout": "^1.3.5", "@types/react-icons": "^3.0.0", "@types/react-transition-group": "^4.4.10", "@types/strftime": "^0.9.8", @@ -2572,6 +2574,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz", + "integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-icons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz", @@ -4392,6 +4403,11 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -6329,6 +6345,44 @@ "react": "^18.2.0" } }, + "node_modules/react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-grid-layout": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.4.4.tgz", + "integrity": "sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==", + "dependencies": { + "clsx": "^2.0.0", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.5", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-hook-form": { "version": "7.51.3", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.3.tgz", @@ -6448,6 +6502,18 @@ } } }, + "node_modules/react-resizable": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", + "integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + }, + "peerDependencies": { + "react": ">= 16.3" + } + }, "node_modules/react-router": { "version": "6.22.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz", @@ -6639,6 +6705,11 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", diff --git a/web/package.json b/web/package.json index 3e8fc2907..980223748 100644 --- a/web/package.json +++ b/web/package.json @@ -53,6 +53,7 @@ "react-day-picker": "^8.10.1", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", + "react-grid-layout": "^1.4.4", "react-hook-form": "^7.51.3", "react-icons": "^5.1.0", "react-konva": "^18.2.10", @@ -81,6 +82,7 @@ "@types/node": "^20.12.7", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", + "@types/react-grid-layout": "^1.3.5", "@types/react-icons": "^3.0.0", "@types/react-transition-group": "^4.4.10", "@types/strftime": "^0.9.8", diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 128645ce2..84d1a36b5 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -59,6 +59,7 @@ import { Toaster } from "@/components/ui/sonner"; import { toast } from "sonner"; import ActivityIndicator from "../indicators/activity-indicator"; import { ScrollArea, ScrollBar } from "../ui/scroll-area"; +import { usePersistence } from "@/hooks/use-persistence"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { cn } from "@/lib/utils"; @@ -89,7 +90,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { // groups - const [group, setGroup] = usePersistedOverlayState( + const [group, setGroup, deleteGroup] = usePersistedOverlayState( "cameraGroup", "default" as string, ); @@ -118,6 +119,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { currentGroups={groups} activeGroup={group} setGroup={setGroup} + deleteGroup={deleteGroup} />
void; + deleteGroup: () => void; }; function NewGroupDialog({ open, @@ -205,6 +208,7 @@ function NewGroupDialog({ currentGroups, activeGroup, setGroup, + deleteGroup, }: NewGroupDialogProps) { const { mutate: updateConfig } = useSWR("config"); @@ -225,11 +229,16 @@ function NewGroupDialog({ const [editState, setEditState] = useState<"none" | "add" | "edit">("none"); const [isLoading, setIsLoading] = useState(false); + const [, , , deleteGridLayout] = usePersistence( + `${activeGroup}-draggable-layout`, + ); + // callbacks const onDeleteGroup = useCallback( async (name: string) => { - // TODO: reset order on groups when deleting + deleteGridLayout(); + deleteGroup(); await axios .put(`config/set?camera_groups.${name}`, { requires_restart: 0 }) @@ -260,7 +269,14 @@ function NewGroupDialog({ setIsLoading(false); }); }, - [updateConfig, activeGroup, setGroup, setOpen], + [ + updateConfig, + activeGroup, + setGroup, + setOpen, + deleteGroup, + deleteGridLayout, + ], ); const onSave = () => { @@ -479,7 +495,11 @@ export function CameraGroupEdit({ { message: "Camera group name already exists.", }, - ), + ) + .refine((value: string) => value.toLowerCase() !== "default", { + message: "Invalid camera group name.", + }), + cameras: z.array(z.string()).min(2, { message: "You must select at least two cameras.", }), diff --git a/web/src/components/settings/General.tsx b/web/src/components/settings/General.tsx index 70f6203b2..bdd30fdb7 100644 --- a/web/src/components/settings/General.tsx +++ b/web/src/components/settings/General.tsx @@ -1,19 +1,91 @@ import Heading from "@/components/ui/heading"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; +import { Toaster } from "sonner"; +import { toast } from "sonner"; +import { Separator } from "../ui/separator"; +import { Button } from "../ui/button"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { del as delData } from "idb-keyval"; export default function General() { + const { data: config } = useSWR("config"); + + const clearStoredLayouts = useCallback(() => { + if (!config) { + return []; + } + + Object.entries(config.camera_groups).forEach(async (value) => { + await delData(`${value[0]}-draggable-layout`) + .then(() => { + toast.success(`Cleared stored layout for ${value[0]}`, { + position: "top-center", + }); + }) + .catch((error) => { + toast.error( + `Failed to clear stored layout: ${error.response.data.message}`, + { position: "top-center" }, + ); + }); + }); + }, [config]); + useEffect(() => { document.title = "General Settings - Frigate"; }, []); return ( <> - Settings -
- {}} /> - +
+ +
+ + General Settings + + +
+
+
+
Stored Layouts
+
+

+ The layout of cameras in a camera group can be + dragged/resized. The positions are stored in your browser's + local storage. +

+
+
+
+ +
+
+ +
+
+
Low Data Mode
+
+

+ Not yet implemented. Default: disabled +

+
+
+
+ {}} + /> + +
+
+
+
); diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx index 391712c6a..f39717288 100644 --- a/web/src/hooks/use-overlay-state.tsx +++ b/web/src/hooks/use-overlay-state.tsx @@ -33,14 +33,15 @@ export function useOverlayState( export function usePersistedOverlayState( key: string, defaultValue: S | undefined = undefined, -): [S | undefined, (value: S | undefined, replace?: boolean) => void] { - const [persistedValue, setPersistedValue] = usePersistence( - key, - defaultValue, - ); +): [ + S | undefined, + (value: S | undefined, replace?: boolean) => void, + () => void, +] { + const [persistedValue, setPersistedValue, , deletePersistedValue] = + usePersistence(key, defaultValue); const location = useLocation(); const navigate = useNavigate(); - const currentLocationState = useMemo(() => location.state, [location]); const setOverlayStateValue = useCallback( @@ -63,6 +64,7 @@ export function usePersistedOverlayState( return [ overlayStateValue ?? persistedValue ?? defaultValue, setOverlayStateValue, + deletePersistedValue, ]; } diff --git a/web/src/hooks/use-persistence.ts b/web/src/hooks/use-persistence.ts index 1b2f2a4d4..8762c1970 100644 --- a/web/src/hooks/use-persistence.ts +++ b/web/src/hooks/use-persistence.ts @@ -1,10 +1,11 @@ import { useEffect, useState, useCallback } from "react"; -import { get as getData, set as setData } from "idb-keyval"; +import { get as getData, set as setData, del as delData } from "idb-keyval"; type usePersistenceReturn = [ value: S | undefined, setValue: (value: S | undefined) => void, loaded: boolean, + deleteValue: () => void, ]; export function usePersistence( @@ -26,6 +27,11 @@ export function usePersistence( [key], ); + const deleteValue = useCallback(async () => { + await delData(key); + setInternalValue(defaultValue); + }, [key, defaultValue]); + useEffect(() => { setLoaded(false); setInternalValue(defaultValue); @@ -41,5 +47,5 @@ export function usePersistence( load(); }, [key, defaultValue, setValue]); - return [value, setValue, loaded]; + return [value, setValue, loaded, deleteValue]; } diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index 387aa3856..bae56b6bb 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -89,6 +89,7 @@ function Live() { return ( diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx new file mode 100644 index 000000000..f0a64b92a --- /dev/null +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -0,0 +1,461 @@ +import { usePersistence } from "@/hooks/use-persistence"; +import { + BirdseyeConfig, + CameraConfig, + FrigateConfig, +} from "@/types/frigateConfig"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Layout, Responsive, WidthProvider } from "react-grid-layout"; +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; +import { LivePlayerMode } from "@/types/live"; +import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useResizeObserver } from "@/hooks/resize-observer"; +import { isEqual } from "lodash"; +import useSWR from "swr"; +import { isSafari } from "react-device-detect"; +import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; +import LivePlayer from "@/components/player/LivePlayer"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { IoClose } from "react-icons/io5"; +import { LuMoveDiagonal2 } from "react-icons/lu"; + +type DraggableGridLayoutProps = { + cameras: CameraConfig[]; + cameraGroup: string; + cameraRef: (node: HTMLElement | null) => void; + includeBirdseye: boolean; + onSelectCamera: (camera: string) => void; + windowVisible: boolean; + visibleCameras: string[]; +}; +export default function DraggableGridLayout({ + cameras, + cameraGroup, + cameraRef, + includeBirdseye, + onSelectCamera, + windowVisible, + visibleCameras, +}: DraggableGridLayoutProps) { + const { data: config } = useSWR("config"); + const birdseyeConfig = useMemo(() => config?.birdseye, [config]); + + const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); + + const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence< + Layout[] + >(`${cameraGroup}-draggable-layout`); + + const [currentCameras, setCurrentCameras] = useState(); + const [currentIncludeBirdseye, setCurrentIncludeBirdseye] = + useState(); + const [currentGridLayout, setCurrentGridLayout] = useState< + Layout[] | undefined + >(); + + const [isEditMode, setIsEditMode] = useState(false); + + const handleLayoutChange = useCallback( + (currentLayout: Layout[]) => { + if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) { + return; + } + // save layout to idb + setGridLayout(currentLayout); + }, + [setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout], + ); + + const generateLayout = useCallback(() => { + if (!isGridLayoutLoaded) { + return; + } + + const cameraNames = + includeBirdseye && birdseyeConfig?.enabled + ? ["birdseye", ...cameras.map((camera) => camera?.name || "")] + : cameras.map((camera) => camera?.name || ""); + + const optionsMap: Layout[] = currentGridLayout + ? currentGridLayout.filter((layout) => cameraNames?.includes(layout.i)) + : []; + + cameraNames.forEach((cameraName, index) => { + const existingLayout = optionsMap.find( + (layout) => layout.i === cameraName, + ); + + // Skip if the camera already exists in the layout + if (existingLayout) { + return; + } + + let aspectRatio; + let col; + + // Handle "birdseye" camera as a special case + if (cameraName === "birdseye") { + aspectRatio = + (birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1); + col = 0; // Set birdseye camera in the first column + } else { + const camera = cameras.find((cam) => cam.name === cameraName); + aspectRatio = + (camera && camera?.detect.width / camera?.detect.height) || 16 / 9; + col = index % 3; // Regular cameras distributed across columns + } + + // Calculate layout options based on aspect ratio + const columnsPerPlayer = 4; + let height; + let width; + + if (aspectRatio < 1) { + // Portrait + height = 2 * columnsPerPlayer; + width = columnsPerPlayer; + } else if (aspectRatio > 2) { + // Wide + height = 1 * columnsPerPlayer; + width = 2 * columnsPerPlayer; + } else { + // Landscape + height = 1 * columnsPerPlayer; + width = columnsPerPlayer; + } + + const options = { + i: cameraName, + x: col * width, + y: 0, // don't set y, grid does automatically + w: width, + h: height, + isDraggable: isEditMode, + isResizable: isEditMode, + }; + + optionsMap.push(options); + }); + + return optionsMap; + }, [ + cameras, + isEditMode, + isGridLayoutLoaded, + currentGridLayout, + includeBirdseye, + birdseyeConfig, + ]); + + const toggleEditMode = useCallback(() => { + if (currentGridLayout) { + const updatedGridLayout = currentGridLayout.map((layout) => ({ + ...layout, + isDraggable: !isEditMode, + isResizable: !isEditMode, + })); + if (isEditMode) { + setGridLayout(updatedGridLayout); + setCurrentGridLayout(updatedGridLayout); + } else { + setGridLayout(updatedGridLayout); + } + setIsEditMode((prevIsEditMode) => !prevIsEditMode); + } + }, [currentGridLayout, isEditMode, setGridLayout]); + + useEffect(() => { + if (isGridLayoutLoaded) { + if (gridLayout) { + // set current grid layout from loaded + setCurrentGridLayout(gridLayout); + } else { + // idb is empty, set it with an initial layout + setGridLayout(generateLayout()); + } + } + }, [ + isEditMode, + gridLayout, + currentGridLayout, + setGridLayout, + isGridLayoutLoaded, + generateLayout, + ]); + + useEffect(() => { + if ( + !isEqual(cameras, currentCameras) || + includeBirdseye !== currentIncludeBirdseye + ) { + setCurrentCameras(cameras); + setCurrentIncludeBirdseye(includeBirdseye); + + // set new grid layout in idb + setGridLayout(generateLayout()); + } + }, [ + cameras, + includeBirdseye, + currentCameras, + currentIncludeBirdseye, + setCurrentGridLayout, + generateLayout, + setGridLayout, + isGridLayoutLoaded, + ]); + + const gridContainerRef = useRef(null); + + const [{ width: containerWidth }] = useResizeObserver(gridContainerRef); + + const cellHeight = useMemo(() => { + const aspectRatio = 16 / 9; + const totalMarginWidth = 11 * 13; // 11 margins with 13px each + const rowHeight = + ((containerWidth ?? window.innerWidth) - totalMarginWidth) / + (13 * aspectRatio); + return rowHeight; + }, [containerWidth]); + + return ( + <> + {!isGridLayoutLoaded || !currentGridLayout ? ( +
+ {includeBirdseye && birdseyeConfig?.enabled && ( + + )} + {cameras.map((camera) => { + return ( + + ); + })} +
+ ) : ( +
+ + {includeBirdseye && birdseyeConfig?.enabled && ( + onSelectCamera("birdseye")} + > + {isEditMode && ( + <> +
+
+
+
+ + )} + + )} + {cameras.map((camera) => { + let grow; + const aspectRatio = camera.detect.width / camera.detect.height; + if (aspectRatio > ASPECT_WIDE_LAYOUT) { + grow = `aspect-wide`; + } else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) { + grow = `aspect-tall`; + } else { + grow = "aspect-video"; + } + return ( + { + !isEditMode && onSelectCamera(camera.name); + }} + > + {isEditMode && ( + <> +
+
+
+
+ + )} + + ); + })} + +
+ + + + + + {isEditMode ? "Exit Editing" : "Edit Layout"} + + +
+
+ )} + + ); +} + +type BirdseyeLivePlayerGridItemProps = { + style?: React.CSSProperties; + className?: string; + onMouseDown?: React.MouseEventHandler; + onMouseUp?: React.MouseEventHandler; + onTouchEnd?: React.TouchEventHandler; + children?: React.ReactNode; + birdseyeConfig: BirdseyeConfig; + liveMode: LivePlayerMode; + onClick: () => void; +}; + +const BirdseyeLivePlayerGridItem = React.forwardRef< + HTMLDivElement, + BirdseyeLivePlayerGridItemProps +>( + ( + { + style, + className, + onMouseDown, + onMouseUp, + onTouchEnd, + children, + birdseyeConfig, + liveMode, + onClick, + ...props + }, + ref, + ) => { + return ( +
+ + {children} +
+ ); + }, +); + +type LivePlayerGridItemProps = { + style?: React.CSSProperties; + className: string; + onMouseDown?: React.MouseEventHandler; + onMouseUp?: React.MouseEventHandler; + onTouchEnd?: React.TouchEventHandler; + children?: React.ReactNode; + cameraRef: (node: HTMLElement | null) => void; + windowVisible: boolean; + cameraConfig: CameraConfig; + preferredLiveMode: LivePlayerMode; + onClick: () => void; +}; + +const LivePlayerGridItem = React.forwardRef< + HTMLDivElement, + LivePlayerGridItemProps +>( + ( + { + style, + className, + onMouseDown, + onMouseUp, + onTouchEnd, + children, + cameraRef, + windowVisible, + cameraConfig, + preferredLiveMode, + onClick, + ...props + }, + ref, + ) => { + return ( +
+ + {children} +
+ ); + }, +); diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 31be2dce1..1c7d2d3e0 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -12,16 +12,24 @@ import { usePersistence } from "@/hooks/use-persistence"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { ReviewSegment } from "@/types/review"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { isDesktop, isMobile, isSafari } from "react-device-detect"; +import { + isDesktop, + isMobile, + isMobileOnly, + isSafari, +} from "react-device-detect"; import useSWR from "swr"; +import DraggableGridLayout from "./DraggableGridLayout"; type LiveDashboardViewProps = { cameras: CameraConfig[]; + cameraGroup?: string; includeBirdseye: boolean; onSelectCamera: (camera: string) => void; }; export default function LiveDashboardView({ cameras, + cameraGroup, includeBirdseye, onSelectCamera, }: LiveDashboardViewProps) { @@ -29,7 +37,7 @@ export default function LiveDashboardView({ // layout - const [layout, setLayout] = usePersistence<"grid" | "list">( + const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">( "live-layout", isDesktop ? "grid" : "list", ); @@ -150,25 +158,25 @@ export default function LiveDashboardView({
@@ -187,41 +195,53 @@ export default function LiveDashboardView({ )} -
- {includeBirdseye && birdseyeConfig?.enabled && ( - onSelectCamera("birdseye")} - /> - )} - {cameras.map((camera) => { - let grow; - const aspectRatio = camera.detect.width / camera.detect.height; - if (aspectRatio > 2) { - grow = `${layout == "grid" ? "col-span-2" : ""} aspect-wide`; - } else if (aspectRatio < 1) { - grow = `${layout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`; - } else { - grow = "aspect-video"; - } - return ( - onSelectCamera(camera.name)} + {!cameraGroup || cameraGroup == "default" || isMobileOnly ? ( +
+ {includeBirdseye && birdseyeConfig?.enabled && ( + onSelectCamera("birdseye")} /> - ); - })} -
+ )} + {cameras.map((camera) => { + let grow; + const aspectRatio = camera.detect.width / camera.detect.height; + if (aspectRatio > 2) { + grow = `${mobileLayout == "grid" ? "col-span-2" : ""} aspect-wide`; + } else if (aspectRatio < 1) { + grow = `${mobileLayout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`; + } else { + grow = "aspect-video"; + } + return ( + onSelectCamera(camera.name)} + /> + ); + })} +
+ ) : ( + + )}
); } From e7ba55691996f55ce0a0f18effd1f2df73558607 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 8 May 2024 06:34:22 -0600 Subject: [PATCH 47/56] Fix setting manual event update time (#11290) --- frigate/review/maintainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 295641656..8963c555c 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -511,7 +511,7 @@ class ReviewSegmentMaintainer(threading.Thread): manual_info["label"] ) # temporarily make it so this event can not end - self.active_review_segments[camera] = sys.maxsize + self.active_review_segments[camera].last_update = sys.maxsize elif manual_info["state"] == ManualEventState.complete: self.active_review_segments[camera].last_update = manual_info[ "end_time" From db8c820677bbecb73fd5fd7c06248f1abe86be47 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 8 May 2024 07:53:22 -0500 Subject: [PATCH 48/56] Draggable camera grid tweaks (#11291) * better math and other tweaks * change icon --- web/src/views/live/DraggableGridLayout.tsx | 189 ++++++++++++++------- web/src/views/live/LiveDashboardView.tsx | 74 +++++--- 2 files changed, 177 insertions(+), 86 deletions(-) diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index f0a64b92a..44f7e0b53 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -7,6 +7,7 @@ import { import React, { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -20,7 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useResizeObserver } from "@/hooks/resize-observer"; import { isEqual } from "lodash"; import useSWR from "swr"; -import { isSafari } from "react-device-detect"; +import { isDesktop, isMobile, isSafari } from "react-device-detect"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; import LivePlayer from "@/components/player/LivePlayer"; import { Button } from "@/components/ui/button"; @@ -30,25 +31,32 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { IoClose } from "react-icons/io5"; -import { LuMoveDiagonal2 } from "react-icons/lu"; +import { LuMove } from "react-icons/lu"; +import { cn } from "@/lib/utils"; type DraggableGridLayoutProps = { cameras: CameraConfig[]; cameraGroup: string; cameraRef: (node: HTMLElement | null) => void; + containerRef: React.RefObject; includeBirdseye: boolean; onSelectCamera: (camera: string) => void; windowVisible: boolean; visibleCameras: string[]; + isEditMode: boolean; + setIsEditMode: React.Dispatch>; }; export default function DraggableGridLayout({ cameras, cameraGroup, + containerRef, cameraRef, includeBirdseye, onSelectCamera, windowVisible, visibleCameras, + isEditMode, + setIsEditMode, }: DraggableGridLayoutProps) { const { data: config } = useSWR("config"); const birdseyeConfig = useMemo(() => config?.birdseye, [config]); @@ -66,8 +74,6 @@ export default function DraggableGridLayout({ Layout[] | undefined >(); - const [isEditMode, setIsEditMode] = useState(false); - const handleLayoutChange = useCallback( (currentLayout: Layout[]) => { if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) { @@ -160,12 +166,12 @@ export default function DraggableGridLayout({ birdseyeConfig, ]); - const toggleEditMode = useCallback(() => { + useEffect(() => { if (currentGridLayout) { const updatedGridLayout = currentGridLayout.map((layout) => ({ ...layout, - isDraggable: !isEditMode, - isResizable: !isEditMode, + isDraggable: isEditMode, + isResizable: isEditMode, })); if (isEditMode) { setGridLayout(updatedGridLayout); @@ -173,9 +179,10 @@ export default function DraggableGridLayout({ } else { setGridLayout(updatedGridLayout); } - setIsEditMode((prevIsEditMode) => !prevIsEditMode); } - }, [currentGridLayout, isEditMode, setGridLayout]); + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditMode, setGridLayout]); useEffect(() => { if (isGridLayoutLoaded) { @@ -218,31 +225,58 @@ export default function DraggableGridLayout({ isGridLayoutLoaded, ]); + const [marginValue, setMarginValue] = useState(16); + + // calculate margin value for browsers that don't have default font size of 16px + useLayoutEffect(() => { + const calculateRemValue = () => { + const htmlElement = document.documentElement; + const fontSize = window.getComputedStyle(htmlElement).fontSize; + setMarginValue(parseFloat(fontSize)); + }; + + calculateRemValue(); + }, []); + const gridContainerRef = useRef(null); - const [{ width: containerWidth }] = useResizeObserver(gridContainerRef); + const [{ width: containerWidth, height: containerHeight }] = + useResizeObserver(gridContainerRef); + + const hasScrollbar = useMemo(() => { + return ( + containerHeight && + containerRef.current && + containerRef.current.offsetHeight < + (gridContainerRef.current?.scrollHeight ?? 0) + ); + }, [containerRef, gridContainerRef, containerHeight]); const cellHeight = useMemo(() => { const aspectRatio = 16 / 9; - const totalMarginWidth = 11 * 13; // 11 margins with 13px each - const rowHeight = - ((containerWidth ?? window.innerWidth) - totalMarginWidth) / - (13 * aspectRatio); - return rowHeight; - }, [containerWidth]); + // subtract container margin, 1 camera takes up at least 4 rows + // account for additional margin on bottom of each row + return ( + ((containerWidth ?? window.innerWidth) - 2 * marginValue) / + 12 / + aspectRatio - + marginValue + + marginValue / 4 + ); + }, [containerWidth, marginValue]); return ( <> {!isGridLayoutLoaded || !currentGridLayout ? (
{includeBirdseye && birdseyeConfig?.enabled && ( - + )} {cameras.map((camera) => { return ( ); })} @@ -264,37 +298,33 @@ export default function DraggableGridLayout({ rowHeight={cellHeight} breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }} - margin={[16, 16]} - containerPadding={[8, 8]} - resizeHandles={["sw", "nw", "se", "ne"]} + margin={[marginValue, marginValue]} + containerPadding={[0, isEditMode ? 6 : 3]} + resizeHandles={isEditMode ? ["sw", "nw", "se", "ne"] : []} onDragStop={handleLayoutChange} onResizeStop={handleLayoutChange} > {includeBirdseye && birdseyeConfig?.enabled && ( onSelectCamera("birdseye")} > - {isEditMode && ( - <> -
-
-
-
- - )} + {isEditMode && } )} {cameras.map((camera) => { let grow; const aspectRatio = camera.detect.width / camera.detect.height; if (aspectRatio > ASPECT_WIDE_LAYOUT) { - grow = `aspect-wide`; + grow = `aspect-wide w-full`; } else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) { - grow = `aspect-tall`; + grow = `aspect-tall h-full`; } else { grow = "aspect-video"; } @@ -302,7 +332,12 @@ export default function DraggableGridLayout({ - {isEditMode && ( - <> -
-
-
-
- - )} + {isEditMode && } ); })} -
- - - - - - {isEditMode ? "Exit Editing" : "Edit Layout"} - - -
+ {isDesktop && ( + + )}
)} ); } +type DesktopEditLayoutButtonProps = { + isEditMode?: boolean; + setIsEditMode: React.Dispatch>; + hasScrollbar?: boolean | 0 | null; +}; + +function DesktopEditLayoutButton({ + isEditMode, + setIsEditMode, + hasScrollbar, +}: DesktopEditLayoutButtonProps) { + return ( +
+ + + + + + {isEditMode ? "Exit Editing" : "Edit Layout"} + + +
+ ); +} + +function CornerCircles() { + return ( + <> +
+
+
+
+ + ); +} + type BirdseyeLivePlayerGridItemProps = { style?: React.CSSProperties; className?: string; diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 1c7d2d3e0..3a9616195 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -17,9 +17,12 @@ import { isMobile, isMobileOnly, isSafari, + isTablet, } from "react-device-detect"; import useSWR from "swr"; import DraggableGridLayout from "./DraggableGridLayout"; +import { IoClose } from "react-icons/io5"; +import { LuMove } from "react-icons/lu"; type LiveDashboardViewProps = { cameras: CameraConfig[]; @@ -42,6 +45,9 @@ export default function LiveDashboardView({ isDesktop ? "grid" : "list", ); + const [isEditMode, setIsEditMode] = useState(false); + const containerRef = useRef(null); + // recent events const { payload: eventUpdate } = useFrigateReviews(); const { data: allEvents, mutate: updateEvents } = useSWR([ @@ -148,37 +154,52 @@ export default function LiveDashboardView({ const birdseyeConfig = useMemo(() => config?.birdseye, [config]); return ( -
+
{isMobile && (
-
- - -
+ {(!cameraGroup || cameraGroup == "default" || isMobileOnly) && ( +
+ + +
+ )} + {cameraGroup && cameraGroup !== "default" && isTablet && ( +
+ +
+ )}
)} @@ -235,11 +256,14 @@ export default function LiveDashboardView({ )}
From 3ed89ec04215cc640c275b10e1e6fd49300e4098 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 8 May 2024 08:46:10 -0600 Subject: [PATCH 49/56] Simplify preview refreshing with custom hook (#11293) --- web/src/components/card/AnimatedEventCard.tsx | 15 +++-- web/src/hooks/use-camera-previews.ts | 57 +++++++++++++++++++ web/src/pages/Events.tsx | 53 ++++------------- 3 files changed, 79 insertions(+), 46 deletions(-) create mode 100644 web/src/hooks/use-camera-previews.ts diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index 126dd4511..125b6dada 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -7,12 +7,12 @@ import { REVIEW_PADDING, ReviewSegment } from "@/types/review"; import { useNavigate } from "react-router-dom"; import { RecordingStartingPoint } from "@/types/record"; import axios from "axios"; -import { Preview } from "@/types/preview"; import { InProgressPreview, VideoPreview, } from "../player/PreviewThumbnailPlayer"; import { isCurrentHour } from "@/utils/dateUtil"; +import { useCameraPreviews } from "@/hooks/use-camera-previews"; type AnimatedEventCardProps = { event: ReviewSegment; @@ -24,10 +24,15 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) { // preview - const { data: previews } = useSWR( - currentHour - ? null - : `/preview/${event.camera}/start/${Math.round(event.start_time)}/end/${Math.round(event.end_time || event.start_time + 20)}`, + const previews = useCameraPreviews( + { + after: Math.round(event.start_time), + before: Math.round(event.end_time || event.start_time + 20), + }, + { + camera: event.camera, + fetchPreviews: !currentHour, + }, ); // interaction diff --git a/web/src/hooks/use-camera-previews.ts b/web/src/hooks/use-camera-previews.ts new file mode 100644 index 000000000..c01fd263a --- /dev/null +++ b/web/src/hooks/use-camera-previews.ts @@ -0,0 +1,57 @@ +import { Preview } from "@/types/preview"; +import { TimeRange } from "@/types/timeline"; +import { useEffect, useState } from "react"; +import useSWR from "swr"; + +type OptionalCameraPreviewProps = { + camera?: string; + autoRefresh?: boolean; + fetchPreviews?: boolean; +}; + +export function useCameraPreviews( + initialTimeRange: TimeRange, + { + camera = "all", + autoRefresh = true, + fetchPreviews = true, + }: OptionalCameraPreviewProps, +) { + const [timeRange, setTimeRange] = useState(initialTimeRange); + + useEffect(() => { + setTimeRange(initialTimeRange); + }, [initialTimeRange]); + + const { data: allPreviews } = useSWR( + fetchPreviews + ? `preview/${camera}/start/${timeRange.after}/end/${timeRange.before}` + : null, + { revalidateOnFocus: false, revalidateOnReconnect: false }, + ); + + // Set a timeout to update previews on the hour + useEffect(() => { + if (!autoRefresh || !fetchPreviews || !allPreviews) { + return; + } + + const callback = () => { + const nextPreviewStart = new Date( + allPreviews[allPreviews.length - 1].end * 1000, + ); + nextPreviewStart.setHours(nextPreviewStart.getHours() + 1); + + if (Date.now() > nextPreviewStart.getTime()) { + setTimeRange({ after: timeRange.after, before: Date.now() / 1000 }); + } + }; + document.addEventListener("focusin", callback); + + return () => { + document.removeEventListener("focusin", callback); + }; + }, [allPreviews, autoRefresh, fetchPreviews, timeRange]); + + return allPreviews; +} diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 0cc3cd6b5..bf325f064 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,9 +1,9 @@ import ActivityIndicator from "@/components/indicators/activity-indicator"; import useApiFilter from "@/hooks/use-api-filter"; +import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useTimezone } from "@/hooks/use-date-utils"; import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; -import { Preview } from "@/types/preview"; import { RecordingStartingPoint } from "@/types/record"; import { ReviewFilter, @@ -161,7 +161,6 @@ export default function Events() { }, [updateSummary]); // preview videos - const [previewKey, setPreviewKey] = useState(0); const previewTimes = useMemo(() => { if (!reviews || reviews.length == 0) { return undefined; @@ -170,50 +169,22 @@ export default function Events() { const startDate = new Date(); startDate.setMinutes(0, 0, 0); - let endDate; - if (previewKey == 0) { - endDate = new Date(reviews.at(-1)?.end_time || 0); - endDate.setHours(0, 0, 0, 0); - } else { - endDate = new Date(); - endDate.setMilliseconds(0); - } + const endDate = new Date(reviews.at(-1)?.end_time || 0); + endDate.setHours(0, 0, 0, 0); return { - start: startDate.getTime() / 1000, - end: endDate.getTime() / 1000, + after: startDate.getTime() / 1000, + before: endDate.getTime() / 1000, }; - }, [reviews, previewKey]); - const { data: allPreviews } = useSWR( - previewTimes - ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` - : null, - { revalidateOnFocus: false, revalidateOnReconnect: false }, + }, [reviews]); + + const allPreviews = useCameraPreviews( + previewTimes ?? { after: 0, before: 0 }, + { + fetchPreviews: previewTimes != undefined, + }, ); - // Set a timeout to update previews on the hour - useEffect(() => { - if (!allPreviews || allPreviews.length == 0) { - return; - } - - const callback = () => { - const nextPreviewStart = new Date( - allPreviews[allPreviews.length - 1].end * 1000, - ); - nextPreviewStart.setHours(nextPreviewStart.getHours() + 1); - - if (Date.now() > nextPreviewStart.getTime()) { - setPreviewKey(10 * Math.random()); - } - }; - document.addEventListener("focusin", callback); - - return () => { - document.removeEventListener("focusin", callback); - }; - }, [allPreviews]); - // review status const markAllItemsAsReviewed = useCallback( From 2be15b6c017f50097d32c3cb9f575a0501f50843 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 8 May 2024 09:46:31 -0500 Subject: [PATCH 50/56] Improve config validation error messages (#11292) --- frigate/app.py | 10 ++++++-- frigate/config.py | 62 +++++++++++++++++++++++++++-------------------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/frigate/app.py b/frigate/app.py index 1306ee97d..c2a489b75 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -16,6 +16,7 @@ import psutil from peewee_migrate import Router from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqliteq import SqliteQueueDatabase +from pydantic import ValidationError from frigate.api.app import create_app from frigate.comms.config_updater import ConfigPublisher @@ -611,8 +612,13 @@ class FrigateApp: print("*************************************************************") print("*** Config Validation Errors ***") print("*************************************************************") - print(e) - print(traceback.format_exc()) + if isinstance(e, ValidationError): + for error in e.errors(): + location = ".".join(str(item) for item in error["loc"]) + print(f"{location}: {error['msg']}") + else: + print(e) + print(traceback.format_exc()) print("*************************************************************") print("*** End Config Validation Errors ***") print("*************************************************************") diff --git a/frigate/config.py b/frigate/config.py index 602f03f1f..008176956 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -555,19 +555,24 @@ class ZoneConfig(BaseModel): # old native resolution coordinates if isinstance(coordinates, list): explicit = any(p.split(",")[0] > "1.0" for p in coordinates) - self._contour = np.array( - [ - ( - [int(p.split(",")[0]), int(p.split(",")[1])] - if explicit - else [ - int(float(p.split(",")[0]) * frame_shape[1]), - int(float(p.split(",")[1]) * frame_shape[0]), - ] - ) - for p in coordinates - ] - ) + try: + self._contour = np.array( + [ + ( + [int(p.split(",")[0]), int(p.split(",")[1])] + if explicit + else [ + int(float(p.split(",")[0]) * frame_shape[1]), + int(float(p.split(",")[1]) * frame_shape[0]), + ] + ) + for p in coordinates + ] + ) + except ValueError: + raise ValueError( + f"Invalid coordinates found in configuration file. Coordinates must be relative (between 0-1): {coordinates}" + ) if explicit: self.coordinates = ",".join( @@ -579,19 +584,24 @@ class ZoneConfig(BaseModel): elif isinstance(coordinates, str): points = coordinates.split(",") explicit = any(p > "1.0" for p in points) - self._contour = np.array( - [ - ( - [int(points[i]), int(points[i + 1])] - if explicit - else [ - int(float(points[i]) * frame_shape[1]), - int(float(points[i + 1]) * frame_shape[0]), - ] - ) - for i in range(0, len(points), 2) - ] - ) + try: + self._contour = np.array( + [ + ( + [int(points[i]), int(points[i + 1])] + if explicit + else [ + int(float(points[i]) * frame_shape[1]), + int(float(points[i + 1]) * frame_shape[0]), + ] + ) + for i in range(0, len(points), 2) + ] + ) + except ValueError: + raise ValueError( + f"Invalid coordinates found in configuration file. Coordinates must be relative (between 0-1): {coordinates}" + ) if explicit: self.coordinates = ",".join( From e1cbefb692ceb9315315eba54eb042717afd1212 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 9 May 2024 07:19:41 -0600 Subject: [PATCH 51/56] Add link to system stats from status bar (#11303) --- web/src/components/Statusbar.tsx | 56 ++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/web/src/components/Statusbar.tsx b/web/src/components/Statusbar.tsx index 00374ad71..c2e81cf90 100644 --- a/web/src/components/Statusbar.tsx +++ b/web/src/components/Statusbar.tsx @@ -58,18 +58,20 @@ export default function Statusbar() {
{cpuPercent && ( -
- - CPU {cpuPercent}% -
+ +
+ + CPU {cpuPercent}% +
+ )} {Object.entries(stats?.gpu_usages || {}).map(([name, stats]) => { if (name == "error-gpu") { @@ -93,18 +95,24 @@ export default function Statusbar() { const gpu = parseInt(stats.gpu); return ( -
- - {gpuTitle} {gpu}% -
+ + {" "} +
+ + {gpuTitle} {gpu}% +
+ ); })}
From 4bcbf7435af7df2387dc997372ffcbccfa2123c3 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 9 May 2024 07:20:07 -0600 Subject: [PATCH 52/56] Web Deps (#11304) * Update easy web deps * Update react * Update final --- web/package-lock.json | 311 ++++++++++++++++++------------------------ web/package.json | 40 +++--- 2 files changed, 152 insertions(+), 199 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 1df2344a4..1a680777a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -29,32 +29,32 @@ "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", - "apexcharts": "^3.48.0", + "apexcharts": "^3.49.0", "axios": "^1.6.8", "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", + "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", "hls.js": "^1.5.8", "idb-keyval": "^6.2.1", - "immer": "^10.0.4", + "immer": "^10.1.1", "konva": "^9.3.6", "lodash": "^4.17.21", - "lucide-react": "^0.372.0", + "lucide-react": "^0.378.0", "monaco-yaml": "^5.1.1", "next-themes": "^0.3.0", - "react": "^18.2.0", + "react": "^18.3.1", "react-apexcharts": "^1.4.1", "react-day-picker": "^8.10.1", "react-device-detect": "^2.2.3", - "react-dom": "^18.2.0", + "react-dom": "^18.3.1", "react-grid-layout": "^1.4.4", - "react-hook-form": "^7.51.3", - "react-icons": "^5.1.0", + "react-hook-form": "^7.51.4", + "react-icons": "^5.2.1", "react-konva": "^18.2.10", - "react-router-dom": "^6.22.3", + "react-router-dom": "^6.23.0", "react-swipeable": "^7.0.1", - "react-tracked": "^1.7.14", + "react-tracked": "^2.0.0", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.8.1", "react-zoom-pan-pinch": "^3.4.4", @@ -66,17 +66,17 @@ "swr": "^2.2.5", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", - "vaul": "^0.9.0", + "vaul": "^0.9.1", "vite-plugin-monaco-editor": "^1.1.0", - "zod": "^3.22.5" + "zod": "^3.23.7" }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", "@testing-library/jest-dom": "^6.1.5", - "@types/lodash": "^4.17.0", - "@types/node": "^20.12.7", - "@types/react": "^18.2.79", - "@types/react-dom": "^18.2.25", + "@types/lodash": "^4.17.1", + "@types/node": "^20.12.11", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", "@types/react-grid-layout": "^1.3.5", "@types/react-icons": "^3.0.0", "@types/react-transition-group": "^4.4.10", @@ -84,7 +84,7 @@ "@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/parser": "^7.5.0", "@vitejs/plugin-react-swc": "^3.6.0", - "@vitest/coverage-v8": "^1.4.0", + "@vitest/coverage-v8": "^1.6.0", "autoprefixer": "^10.4.19", "eslint": "^8.55.0", "eslint-config-prettier": "^9.1.0", @@ -101,8 +101,8 @@ "prettier": "^3.2.5", "tailwindcss": "^3.4.3", "typescript": "^5.4.5", - "vite": "^5.2.9", - "vitest": "^1.4.0" + "vite": "^5.2.11", + "vitest": "^1.6.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2031,9 +2031,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz", - "integrity": "sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.0.tgz", + "integrity": "sha512-Quz1KOffeEf/zwkCBM3kBtH4ZoZ+pT3xIXBG4PPW/XFtDP7EGhtTiC2+gpL9GnR7+Qdet5Oa6cYSvwKYg6kN9Q==", "engines": { "node": ">=14.0.0" } @@ -2516,21 +2516,15 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, "node_modules/@types/lodash": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", - "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.1.tgz", + "integrity": "sha512-X+2qazGS3jxLAIz5JDXDzglAF3KpijdhFxlf/V1+hEsOUc+HnWi81L/uv/EvGuV90WY+7mPGFCUDGfQC3Gj95Q==", "dev": true }, "node_modules/@types/mute-stream": { @@ -2543,9 +2537,9 @@ } }, "node_modules/@types/node": { - "version": "20.12.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", - "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "version": "20.12.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz", + "integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -2557,18 +2551,18 @@ "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/react": { - "version": "18.2.79", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz", - "integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", + "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "18.2.25", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz", - "integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==", + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", "devOptional": true, "dependencies": { "@types/react": "*" @@ -2867,9 +2861,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.4.0.tgz", - "integrity": "sha512-4hDGyH1SvKpgZnIByr9LhGgCEuF9DKM34IBLCC/fVfy24Z3+PZ+Ii9hsVBsHvY1umM1aGPEjceRkzxCfcQ10wg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.0.tgz", + "integrity": "sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.1", @@ -2884,24 +2878,23 @@ "picocolors": "^1.0.0", "std-env": "^3.5.0", "strip-literal": "^2.0.0", - "test-exclude": "^6.0.0", - "v8-to-istanbul": "^9.2.0" + "test-exclude": "^6.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "1.4.0" + "vitest": "1.6.0" } }, "node_modules/@vitest/expect": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.4.0.tgz", - "integrity": "sha512-Jths0sWCJZ8BxjKe+p+eKsoqev1/T8lYcrjavEaz8auEJ4jAVY0GwW3JKmdVU4mmNPLPHixh4GNXP7GFtAiDHA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz", + "integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==", "dev": true, "dependencies": { - "@vitest/spy": "1.4.0", - "@vitest/utils": "1.4.0", + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", "chai": "^4.3.10" }, "funding": { @@ -2909,12 +2902,12 @@ } }, "node_modules/@vitest/runner": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.4.0.tgz", - "integrity": "sha512-EDYVSmesqlQ4RD2VvWo3hQgTJ7ZrFQ2VSJdfiJiArkCerDAGeyF1i6dHkmySqk573jLp6d/cfqCN+7wUB5tLgg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz", + "integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==", "dev": true, "dependencies": { - "@vitest/utils": "1.4.0", + "@vitest/utils": "1.6.0", "p-limit": "^5.0.0", "pathe": "^1.1.1" }, @@ -2950,9 +2943,9 @@ } }, "node_modules/@vitest/snapshot": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.4.0.tgz", - "integrity": "sha512-saAFnt5pPIA5qDGxOHxJ/XxhMFKkUSBJmVt5VgDsAqPTX6JP326r5C/c9UuCMPoXNzuudTPsYDZCoJ5ilpqG2A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz", + "integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==", "dev": true, "dependencies": { "magic-string": "^0.30.5", @@ -2964,9 +2957,9 @@ } }, "node_modules/@vitest/spy": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.4.0.tgz", - "integrity": "sha512-Ywau/Qs1DzM/8Uc+yA77CwSegizMlcgTJuYGAi0jujOteJOUf1ujunHThYo243KG9nAyWT3L9ifPYZ5+As/+6Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz", + "integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==", "dev": true, "dependencies": { "tinyspy": "^2.2.0" @@ -2976,9 +2969,9 @@ } }, "node_modules/@vitest/utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.4.0.tgz", - "integrity": "sha512-mx3Yd1/6e2Vt/PUC98DcqTirtfxUyAZ32uK82r8rZzbtBeBo+nqgnjx/LvqQdWsrvNtm14VmurNgcf4nqY5gJg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==", "dev": true, "dependencies": { "diff-sequences": "^29.6.3", @@ -3122,9 +3115,9 @@ } }, "node_modules/apexcharts": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.48.0.tgz", - "integrity": "sha512-Lhpj1Ij6lKlrUke8gf+P+SE6uGUn+Pe1TnCJ+zqrY0YMvbqM3LMb1lY+eybbTczUyk0RmMZomlTa2NgX2EUs4Q==", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.49.0.tgz", + "integrity": "sha512-2T9HnbQFLCuYRPndQLmh+bEQFoz0meUbvASaGgiSKDuYhWcLBodJtIpKql2aOtMx4B/sHrWW0dm90HsW4+h2PQ==", "dependencies": { "@yr/monotone-cubic-spline": "^1.0.3", "svg.draggable.js": "^2.2.2", @@ -3543,9 +3536,9 @@ } }, "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "engines": { "node": ">=6" } @@ -3597,12 +3590,6 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, "node_modules/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -4831,9 +4818,9 @@ } }, "node_modules/immer": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.4.tgz", - "integrity": "sha512-cuBuGK40P/sk5IzWa9QPUaAdvPHjkk1c+xYsd9oZw+YQQEV+10G0P5uMpGctZZKnyQ+ibRO08bD25nWLmYi2pw==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -5364,9 +5351,9 @@ } }, "node_modules/lucide-react": { - "version": "0.372.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.372.0.tgz", - "integrity": "sha512-0cKdqmilHXWUwWAWnf6CrrjHD8YaqPMtLrmEHXolZusNTr9epULCsiJwIOHk2q1yFxdEwd96D4zShlAj67UJdA==", + "version": "0.378.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.378.0.tgz", + "integrity": "sha512-u6EPU8juLUk9ytRcyapkWI18epAv3RU+6+TC23ivjR0e+glWKBobFeSgRwOIJihzktILQuy6E0E80P2jVTDR5g==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } @@ -6236,9 +6223,9 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/proxy-compare": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.6.0.tgz", - "integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.0.tgz", + "integrity": "sha512-y44MCkgtZUCT9tZGuE278fB7PWVf7fRYy0vbRXAts2o5F0EfC4fIQrvQQGBJo1WJbFcVLXzApOscyJuZqHQc1w==" }, "node_modules/proxy-from-env": { "version": "1.1.0", @@ -6286,9 +6273,9 @@ ] }, "node_modules/react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dependencies": { "loose-envify": "^1.1.0" }, @@ -6334,15 +6321,15 @@ } }, "node_modules/react-dom": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", - "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dependencies": { "loose-envify": "^1.1.0", - "scheduler": "^0.23.0" + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.3.1" } }, "node_modules/react-draggable": { @@ -6384,9 +6371,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.51.3", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.3.tgz", - "integrity": "sha512-cvJ/wbHdhYx8aviSWh28w9ImjmVsb5Y05n1+FW786vEZQJV5STNM0pW6ujS+oiBecb0ARBxJFyAnXj9+GHXACQ==", + "version": "7.51.4", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.4.tgz", + "integrity": "sha512-V14i8SEkh+V1gs6YtD0hdHYnoL4tp/HX/A45wWQN15CYr9bFRmmRdYStSO5L65lCCZRF+kYiSKhm9alqbcdiVA==", "engines": { "node": ">=12.22.0" }, @@ -6399,9 +6386,9 @@ } }, "node_modules/react-icons": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.1.0.tgz", - "integrity": "sha512-D3zug1270S4hbSlIRJ0CUS97QE1yNNKDjzQe3HqY0aefp2CBn9VgzgES27sRR2gOvFK+0CNx/BW0ggOESp6fqQ==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz", + "integrity": "sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==", "peerDependencies": { "react": "*" } @@ -6515,11 +6502,11 @@ } }, "node_modules/react-router": { - "version": "6.22.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz", - "integrity": "sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ==", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.0.tgz", + "integrity": "sha512-wPMZ8S2TuPadH0sF5irFGjkNLIcRvOSaEe7v+JER8508dyJumm6XZB1u5kztlX0RVq6AzRVndzqcUh6sFIauzA==", "dependencies": { - "@remix-run/router": "1.15.3" + "@remix-run/router": "1.16.0" }, "engines": { "node": ">=14.0.0" @@ -6529,12 +6516,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.22.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.3.tgz", - "integrity": "sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw==", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.23.0.tgz", + "integrity": "sha512-Q9YaSYvubwgbal2c9DJKfx6hTNoBp3iJDsl+Duva/DwxoJH+OTXkxGpql4iUK2sla/8z4RpjAm6EWx1qUDuopQ==", "dependencies": { - "@remix-run/router": "1.15.3", - "react-router": "6.22.3" + "@remix-run/router": "1.16.0", + "react-router": "6.23.0" }, "engines": { "node": ">=14.0.0" @@ -6575,26 +6562,16 @@ } }, "node_modules/react-tracked": { - "version": "1.7.14", - "resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-1.7.14.tgz", - "integrity": "sha512-6UMlgQeRAGA+uyYzuQGm7kZB6ZQYFhc7sntgP7Oxwwd6M0Ud/POyb4K3QWT1eXvoifSa80nrAWnXWFGpOvbwkw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-2.0.0.tgz", + "integrity": "sha512-Px8Ms9zhQKzAj3gnwQm6L+sJwzB0uPa8/BgHKOhB8bIuQEgB2iJfryM7GVja9oviiGAa7vtgEBtM+poT1E7V2w==", "dependencies": { - "proxy-compare": "2.6.0", - "use-context-selector": "1.4.4" + "proxy-compare": "^3.0.0", + "use-context-selector": "^2.0.0" }, "peerDependencies": { - "react": ">=16.8.0", - "react-dom": "*", - "react-native": "*", + "react": ">=18.0.0", "scheduler": ">=0.19.0" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } } }, "node_modules/react-transition-group": { @@ -6969,9 +6946,9 @@ } }, "node_modules/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "dependencies": { "loose-envify": "^1.1.0" } @@ -7740,22 +7717,12 @@ } }, "node_modules/use-context-selector": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.4.4.tgz", - "integrity": "sha512-pS790zwGxxe59GoBha3QYOwk8AFGp4DN6DOtH+eoqVmgBBRXVx4IlPDhJmmMiNQAgUaLlP+58aqRC3A4rdaSjg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-2.0.0.tgz", + "integrity": "sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g==", "peerDependencies": { - "react": ">=16.8.0", - "react-dom": "*", - "react-native": "*", + "react": ">=18.0.0", "scheduler": ">=0.19.0" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } } }, "node_modules/use-sidecar": { @@ -7792,24 +7759,10 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, - "node_modules/v8-to-istanbul": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", - "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, "node_modules/vaul": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.0.tgz", - "integrity": "sha512-bZSySGbAHiTXmZychprnX/dE0EsSige88xtyyL3/MCRbrFotRPQZo7UdydGXZWw+CKbNOw5Ow8gwAo93/nB/Cg==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.1.tgz", + "integrity": "sha512-fAhd7i4RNMinx+WEm6pF3nOl78DFkAazcN04ElLPFF9BMCNGbY/kou8UMhIcicm0rJCNePJP0Yyza60gGOD0Jw==", "dependencies": { "@radix-ui/react-dialog": "^1.0.4" }, @@ -7819,9 +7772,9 @@ } }, "node_modules/vite": { - "version": "5.2.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.9.tgz", - "integrity": "sha512-uOQWfuZBlc6Y3W/DTuQ1Sr+oIXWvqljLvS881SVmAj00d5RdgShLcuXWxseWPd4HXwiYBFW/vXHfKFeqj9uQnw==", + "version": "5.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", + "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", "dev": true, "dependencies": { "esbuild": "^0.20.1", @@ -7874,9 +7827,9 @@ } }, "node_modules/vite-node": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.4.0.tgz", - "integrity": "sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.0.tgz", + "integrity": "sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -7904,16 +7857,16 @@ } }, "node_modules/vitest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.4.0.tgz", - "integrity": "sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz", + "integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==", "dev": true, "dependencies": { - "@vitest/expect": "1.4.0", - "@vitest/runner": "1.4.0", - "@vitest/snapshot": "1.4.0", - "@vitest/spy": "1.4.0", - "@vitest/utils": "1.4.0", + "@vitest/expect": "1.6.0", + "@vitest/runner": "1.6.0", + "@vitest/snapshot": "1.6.0", + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", @@ -7925,9 +7878,9 @@ "std-env": "^3.5.0", "strip-literal": "^2.0.0", "tinybench": "^2.5.1", - "tinypool": "^0.8.2", + "tinypool": "^0.8.3", "vite": "^5.0.0", - "vite-node": "1.4.0", + "vite-node": "1.6.0", "why-is-node-running": "^2.2.2" }, "bin": { @@ -7942,8 +7895,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.4.0", - "@vitest/ui": "1.4.0", + "@vitest/browser": "1.6.0", + "@vitest/ui": "1.6.0", "happy-dom": "*", "jsdom": "*" }, @@ -8216,9 +8169,9 @@ } }, "node_modules/zod": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.5.tgz", - "integrity": "sha512-HqnGsCdVZ2xc0qWPLdO25WnseXThh0kEYKIdV5F/hTHO75hNZFp8thxSeHhiPrHZKrFTo1SOgkAj9po5bexZlw==", + "version": "3.23.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.7.tgz", + "integrity": "sha512-NBeIoqbtOiUMomACV/y+V3Qfs9+Okr18vR5c/5pHClPpufWOrsx8TENboDPe265lFdfewX2yBtNTLPvnmCxwog==", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package.json b/web/package.json index 980223748..4638572a6 100644 --- a/web/package.json +++ b/web/package.json @@ -34,32 +34,32 @@ "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", - "apexcharts": "^3.48.0", + "apexcharts": "^3.49.0", "axios": "^1.6.8", "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", + "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", "hls.js": "^1.5.8", "idb-keyval": "^6.2.1", - "immer": "^10.0.4", + "immer": "^10.1.1", "konva": "^9.3.6", "lodash": "^4.17.21", - "lucide-react": "^0.372.0", + "lucide-react": "^0.378.0", "monaco-yaml": "^5.1.1", "next-themes": "^0.3.0", - "react": "^18.2.0", + "react": "^18.3.1", "react-apexcharts": "^1.4.1", "react-day-picker": "^8.10.1", "react-device-detect": "^2.2.3", - "react-dom": "^18.2.0", + "react-dom": "^18.3.1", "react-grid-layout": "^1.4.4", - "react-hook-form": "^7.51.3", - "react-icons": "^5.1.0", + "react-hook-form": "^7.51.4", + "react-icons": "^5.2.1", "react-konva": "^18.2.10", - "react-router-dom": "^6.22.3", + "react-router-dom": "^6.23.0", "react-swipeable": "^7.0.1", - "react-tracked": "^1.7.14", + "react-tracked": "^2.0.0", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.8.1", "react-zoom-pan-pinch": "^3.4.4", @@ -71,17 +71,17 @@ "swr": "^2.2.5", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", - "vaul": "^0.9.0", + "vaul": "^0.9.1", "vite-plugin-monaco-editor": "^1.1.0", - "zod": "^3.22.5" + "zod": "^3.23.7" }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", "@testing-library/jest-dom": "^6.1.5", - "@types/lodash": "^4.17.0", - "@types/node": "^20.12.7", - "@types/react": "^18.2.79", - "@types/react-dom": "^18.2.25", + "@types/lodash": "^4.17.1", + "@types/node": "^20.12.11", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", "@types/react-grid-layout": "^1.3.5", "@types/react-icons": "^3.0.0", "@types/react-transition-group": "^4.4.10", @@ -89,7 +89,7 @@ "@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/parser": "^7.5.0", "@vitejs/plugin-react-swc": "^3.6.0", - "@vitest/coverage-v8": "^1.4.0", + "@vitest/coverage-v8": "^1.6.0", "autoprefixer": "^10.4.19", "eslint": "^8.55.0", "eslint-config-prettier": "^9.1.0", @@ -101,12 +101,12 @@ "fake-indexeddb": "^5.0.2", "jest-websocket-mock": "^2.5.0", "jsdom": "^24.0.0", - "msw": "^2.2.14", + "msw": "^2.3.0", "postcss": "^8.4.38", "prettier": "^3.2.5", "tailwindcss": "^3.4.3", "typescript": "^5.4.5", - "vite": "^5.2.9", - "vitest": "^1.4.0" + "vite": "^5.2.11", + "vitest": "^1.6.0" } } From 4216d080998ce20b6fa299df3fbd3649a102a31d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 9 May 2024 07:20:33 -0600 Subject: [PATCH 53/56] Backend and webui fixes (#11309) * Ensure that items without end times are set to not have a snapshot * Save full frame if no frame is currently saved * Webui fixes * Cleanup --- frigate/app.py | 6 ++-- frigate/review/maintainer.py | 34 +++++++++++++++++++ .../player/dynamic/DynamicVideoPlayer.tsx | 2 +- web/src/hooks/use-camera-previews.ts | 2 +- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/frigate/app.py b/frigate/app.py index c2a489b75..1fce7c1ac 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -683,9 +683,9 @@ class FrigateApp: self.stop_event.set() # set an end_time on entries without an end_time before exiting - Event.update(end_time=datetime.datetime.now().timestamp()).where( - Event.end_time == None - ).execute() + Event.update( + end_time=datetime.datetime.now().timestamp(), has_snapshot=False + ).where(Event.end_time == None).execute() ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where( ReviewSegment.end_time == None ).execute() diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 8963c555c..77d3d2a6b 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -110,6 +110,18 @@ class PendingReviewSegment: self.frame_path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60] ) + def save_full_frame(self, camera_config: CameraConfig, frame): + color_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) + width = int(THUMB_HEIGHT * color_frame.shape[1] / color_frame.shape[0]) + self.frame = cv2.resize( + color_frame, dsize=(width, THUMB_HEIGHT), interpolation=cv2.INTER_AREA + ) + + if self.frame is not None: + cv2.imwrite( + self.frame_path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60] + ) + def get_data(self, ended: bool) -> dict: return { ReviewSegment.id: self.id, @@ -273,8 +285,30 @@ class ReviewSegmentMaintainer(threading.Thread): if segment.severity == SeverityEnum.alert and frame_time > ( segment.last_update + THRESHOLD_ALERT_ACTIVITY ): + if segment.frame is None: + try: + frame_id = f"{camera_config.name}{frame_time}" + yuv_frame = self.frame_manager.get( + frame_id, camera_config.frame_shape_yuv + ) + segment.save_full_frame(camera_config, yuv_frame) + self.frame_manager.close(frame_id) + except FileNotFoundError: + return + self.end_segment(segment) elif frame_time > (segment.last_update + THRESHOLD_DETECTION_ACTIVITY): + if segment.frame is None: + try: + frame_id = f"{camera_config.name}{frame_time}" + yuv_frame = self.frame_manager.get( + frame_id, camera_config.frame_shape_yuv + ) + segment.save_full_frame(camera_config, yuv_frame) + self.frame_manager.close(frame_id) + except FileNotFoundError: + return + self.end_segment(segment) def check_if_new_segment( diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 7d1c7e4d9..e3e677530 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -204,8 +204,8 @@ export default function DynamicVideoPlayer({ /> ( fetchPreviews - ? `preview/${camera}/start/${timeRange.after}/end/${timeRange.before}` + ? `preview/${camera}/start/${Math.round(timeRange.after)}/end/${Math.round(timeRange.before)}` : null, { revalidateOnFocus: false, revalidateOnReconnect: false }, ); From 50ee447e527d8136ae38a97716984749d3b2aada Mon Sep 17 00:00:00 2001 From: Marc Altmann <40744649+MarcA711@users.noreply.github.com> Date: Thu, 9 May 2024 15:22:34 +0200 Subject: [PATCH 54/56] Fix aarch64 build (#11289) * fix aarch64 build * change order of packages --- docker/main/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 3354a21cc..de823bf1c 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -51,7 +51,7 @@ ARG DEBIAN_FRONTEND # Install OpenVino Runtime and Dev library COPY docker/main/requirements-ov.txt /requirements-ov.txt RUN apt-get -qq update \ - && apt-get -qq install -y wget python3 python3-distutils \ + && apt-get -qq install -y wget python3 python3-dev python3-distutils gcc pkg-config libhdf5-dev \ && wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ && python3 get-pip.py "pip" \ && pip install -r /requirements-ov.txt From f8523d9ddf4127c8baa3232ad00fc15f5bcd62aa Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 9 May 2024 08:22:48 -0500 Subject: [PATCH 55/56] Icon picker component (#11310) * icon picker component * keep box the same size when filtering icons --- .../components/filter/CameraGroupSelector.tsx | 72 ++++---- web/src/components/icons/IconPicker.tsx | 154 ++++++++++++++++++ web/src/components/ui/popover.tsx | 51 +++--- web/src/pages/UIPlayground.tsx | 12 ++ web/src/types/frigateConfig.ts | 5 +- web/src/utils/iconUtil.tsx | 24 +-- 6 files changed, 233 insertions(+), 85 deletions(-) create mode 100644 web/src/components/icons/IconPicker.tsx diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 84d1a36b5..38a246c00 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -1,8 +1,4 @@ -import { - CameraGroupConfig, - FrigateConfig, - GROUP_ICONS, -} from "@/types/frigateConfig"; +import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig"; import { isDesktop, isMobile } from "react-device-detect"; import useSWR from "swr"; import { MdHome } from "react-icons/md"; @@ -10,7 +6,6 @@ import { usePersistedOverlayState } from "@/hooks/use-overlay-state"; import { Button } from "../ui/button"; import { useCallback, useMemo, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; -import { getIconForGroup } from "@/utils/iconUtil"; import { LuPencil, LuPlus } from "react-icons/lu"; import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; import { Drawer, DrawerContent } from "../ui/drawer"; @@ -31,13 +26,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { AlertDialog, AlertDialogAction, @@ -62,6 +50,9 @@ import { ScrollArea, ScrollBar } from "../ui/scroll-area"; import { usePersistence } from "@/hooks/use-persistence"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { cn } from "@/lib/utils"; +import * as LuIcons from "react-icons/lu"; +import IconPicker, { IconName, IconRenderer } from "../icons/IconPicker"; +import { isValidIconName } from "@/utils/iconUtil"; type CameraGroupSelectorProps = { className?: string; @@ -168,7 +159,12 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { isDesktop ? showTooltip(undefined) : null } > - {getIconForGroup(config.icon)} + {config && config.icon && isValidIconName(config.icon) && ( + + )} @@ -503,7 +499,12 @@ export function CameraGroupEdit({ cameras: z.array(z.string()).min(2, { message: "You must select at least two cameras.", }), - icon: z.string(), + icon: z + .string() + .min(1, { message: "You must select an icon." }) + .refine((value) => Object.keys(LuIcons).includes(value), { + message: "Invalid icon", + }), }); const onSubmit = useCallback( @@ -559,10 +560,10 @@ export function CameraGroupEdit({ const form = useForm>({ resolver: zodResolver(formSchema), - mode: "onChange", + mode: "onSubmit", defaultValues: { name: (editingGroup && editingGroup[0]) ?? "", - icon: editingGroup && editingGroup[1].icon, + icon: editingGroup && (editingGroup[1].icon as IconName), cameras: editingGroup && editingGroup[1].cameras, }, }); @@ -571,7 +572,7 @@ export function CameraGroupEdit({ ( - + Icon - + { + field.onChange(newIcon?.name ?? undefined); + }} + /> @@ -662,7 +654,7 @@ export function CameraGroupEdit({ -
+
diff --git a/web/src/components/icons/IconPicker.tsx b/web/src/components/icons/IconPicker.tsx new file mode 100644 index 000000000..21883ccdc --- /dev/null +++ b/web/src/components/icons/IconPicker.tsx @@ -0,0 +1,154 @@ +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { IconType } from "react-icons"; +import * as LuIcons from "react-icons/lu"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { IoClose } from "react-icons/io5"; +import Heading from "../ui/heading"; +import { cn } from "@/lib/utils"; +import { Button } from "../ui/button"; + +export type IconName = keyof typeof LuIcons; + +export type IconElement = { + name?: string; + Icon?: IconType; +}; + +type IconPickerProps = { + selectedIcon?: IconElement; + setSelectedIcon?: React.Dispatch< + React.SetStateAction + >; +}; + +export default function IconPicker({ + selectedIcon, + setSelectedIcon, +}: IconPickerProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const [searchTerm, setSearchTerm] = useState(""); + + const iconSets = useMemo(() => [...Object.entries(LuIcons)], []); + + const icons = useMemo( + () => + iconSets.filter( + ([name]) => + name.toLowerCase().includes(searchTerm.toLowerCase()) || + searchTerm === "", + ), + [iconSets, searchTerm], + ); + + const handleIconSelect = useCallback( + ({ name, Icon }: IconElement) => { + if (setSelectedIcon) { + setSelectedIcon({ name, Icon }); + } + setSearchTerm(""); + }, + [setSelectedIcon], + ); + + return ( +
+ { + setOpen(open); + }} + > + + {!selectedIcon?.name || !selectedIcon?.Icon ? ( + + ) : ( +
+
+
+ +
+ {selectedIcon.name + .replace(/^Lu/, "") + .replace(/([A-Z])/g, " $1")} +
+
+ + { + handleIconSelect({ name: undefined, Icon: undefined }); + }} + /> +
+
+ )} +
+ +
+ Select an icon + { + setOpen(false); + }} + /> +
+ setSearchTerm(e.target.value)} + /> +
+
+ {icons.map(([name, Icon]) => ( +
+ { + handleIconSelect({ name, Icon }); + setOpen(false); + }} + /> +
+ ))} +
+
+
+
+
+ ); +} + +type IconRendererProps = { + icon: IconType; + size?: number; + className?: string; +}; + +export function IconRenderer({ icon, size, className }: IconRendererProps) { + return <>{React.createElement(icon, { size, className })}; +} diff --git a/web/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx index bbba7e0eb..bba83f977 100644 --- a/web/src/components/ui/popover.tsx +++ b/web/src/components/ui/popover.tsx @@ -1,29 +1,36 @@ -import * as React from "react" -import * as PopoverPrimitive from "@radix-ui/react-popover" +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Popover = PopoverPrimitive.Root +const Popover = PopoverPrimitive.Root; -const PopoverTrigger = PopoverPrimitive.Trigger +const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - - - -)) -PopoverContent.displayName = PopoverPrimitive.Content.displayName + React.ComponentPropsWithoutRef & { + container?: HTMLElement | null; + } +>( + ( + { className, container, align = "center", sideOffset = 4, ...props }, + ref, + ) => ( + + + + ), +); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent } +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 3cad4696b..67602a858 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -28,6 +28,7 @@ import { Label } from "@/components/ui/label"; import { useNavigate } from "react-router-dom"; import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import { isMobile } from "react-device-detect"; +import IconPicker, { IconElement } from "@/components/icons/IconPicker"; // Color data const colors = [ @@ -207,6 +208,8 @@ function UIPlayground() { const [isEventsReviewTimeline, setIsEventsReviewTimeline] = useState(true); const birdseyeConfig = config?.birdseye; + const [selectedIcon, setSelectedIcon] = useState(); + return ( <>
@@ -214,6 +217,15 @@ function UIPlayground() {
UI Playground + + + {selectedIcon?.name && ( +

Selected icon name: {selectedIcon.name}

+ )} + Scrubber diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 517a00761..b24841151 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -1,3 +1,4 @@ +import { IconName } from "@/components/icons/IconPicker"; import { LivePlayerMode } from "./live"; export interface UiConfig { @@ -222,11 +223,9 @@ export interface CameraConfig { }; } -export const GROUP_ICONS = ["car", "cat", "dog", "leaf"] as const; - export type CameraGroupConfig = { cameras: string[]; - icon: (typeof GROUP_ICONS)[number]; + icon: IconName; order: number; }; diff --git a/web/src/utils/iconUtil.tsx b/web/src/utils/iconUtil.tsx index d2ba08715..3e8b8cca0 100644 --- a/web/src/utils/iconUtil.tsx +++ b/web/src/utils/iconUtil.tsx @@ -1,3 +1,4 @@ +import { IconName } from "@/components/icons/IconPicker"; import { BsPersonWalking } from "react-icons/bs"; import { FaAmazon, @@ -6,35 +7,18 @@ import { FaCarSide, FaCat, FaCheckCircle, - FaCircle, FaDog, FaFedex, FaFire, - FaLeaf, FaUps, } from "react-icons/fa"; import { GiHummingbird } from "react-icons/gi"; import { LuBox, LuLassoSelect } from "react-icons/lu"; +import * as LuIcons 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 ; +export function isValidIconName(value: string): value is IconName { + return Object.keys(LuIcons).includes(value as IconName); } export function getIconForLabel(label: string, className?: string) { From 021ffb2437a58ad2e9478a709c8a64e54d3fe91e Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 9 May 2024 08:55:19 -0500 Subject: [PATCH 56/56] fix nan check in system graphs (#11312) --- web/src/components/graph/SystemGraph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/graph/SystemGraph.tsx b/web/src/components/graph/SystemGraph.tsx index 834f6e6b4..fbaacc729 100644 --- a/web/src/components/graph/SystemGraph.tsx +++ b/web/src/components/graph/SystemGraph.tsx @@ -145,7 +145,7 @@ export function ThresholdBarGraph({ } const getUnitSize = (MB: number) => { - if (isNaN(MB) || MB < 0) return "Invalid number"; + if (MB === null || isNaN(MB) || MB < 0) return "Invalid number"; if (MB < 1024) return `${MB.toFixed(2)} MiB`; if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;