diff --git a/frigate/api/app.py b/frigate/api/app.py index f4f513e14..6fdedab90 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -292,7 +292,7 @@ def config_set(): f.close() # Validate the config schema try: - FrigateConfig.parse_raw(new_raw_config) + config_obj = FrigateConfig.parse_raw(new_raw_config) except Exception: with open(config_file, "w") as f: f.write(old_raw_config) @@ -314,6 +314,13 @@ def config_set(): 500, ) + json = request.get_json(silent=True) or {} + + if json.get("requires_restart", 1) == 0: + current_app.frigate_config = FrigateConfig.runtime_config( + config_obj, current_app.plus_api + ) + return make_response( jsonify( { diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 30388251d..aa009aa04 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -204,17 +204,22 @@ def update_yaml_from_url(file_path, url): key_path.pop(i - 1) except ValueError: pass - new_value = new_value_list[0] - update_yaml_file(file_path, key_path, new_value) + + if len(new_value_list) > 1: + update_yaml_file(file_path, key_path, new_value_list) + else: + update_yaml_file(file_path, key_path, new_value_list[0]) def update_yaml_file(file_path, key_path, new_value): yaml = YAML() + yaml.indent(mapping=2, sequence=4, offset=2) with open(file_path, "r") as f: data = yaml.load(f) data = update_yaml(data, key_path, new_value) - + with open("/config/test.yaml", "w") as f: + yaml.dump(data, f) with open(file_path, "w") as f: yaml.dump(data, f) diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index f975d290c..61414281a 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -1,4 +1,8 @@ -import { FrigateConfig } from "@/types/frigateConfig"; +import { + CameraGroupConfig, + FrigateConfig, + GROUP_ICONS, +} from "@/types/frigateConfig"; import { isDesktop } from "react-device-detect"; import useSWR from "swr"; import { MdHome } from "react-icons/md"; @@ -8,6 +12,19 @@ import { useNavigate } from "react-router-dom"; 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 { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; +import { Input } from "../ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import FilterCheckBox from "./FilterCheckBox"; +import axios from "axios"; type CameraGroupSelectorProps = { className?: string; @@ -49,10 +66,20 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { ); }, [config]); + // add group + + const [addGroup, setAddGroup] = useState(false); + return (
+ + + )}
); } + +type NewGroupDialogProps = { + open: boolean; + setOpen: (open: boolean) => void; + currentGroups: [string, CameraGroupConfig][]; +}; +function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + + // add fields + + 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(); + } + }, + [updateConfig], + ); + + 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`} +
+
+ + {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), + ]); + } + }} + /> + ))} + +
+ {error &&
{error}
} + + + )} + +
+ ); +} diff --git a/web/src/components/filter/FilterCheckBox.tsx b/web/src/components/filter/FilterCheckBox.tsx new file mode 100644 index 000000000..b23a4e42c --- /dev/null +++ b/web/src/components/filter/FilterCheckBox.tsx @@ -0,0 +1,32 @@ +import { LuCheck } from "react-icons/lu"; +import { Button } from "../ui/button"; +import { IconType } from "react-icons"; + +type FilterCheckBoxProps = { + label: string; + CheckIcon?: IconType; + isChecked: boolean; + onCheckedChange: (isChecked: boolean) => void; +}; + +export default function FilterCheckBox({ + label, + CheckIcon = LuCheck, + isChecked, + onCheckedChange, +}: FilterCheckBoxProps) { + return ( + + ); +} diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 489a997fb..1d862998c 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -1,4 +1,3 @@ -import { LuCheck } from "react-icons/lu"; import { Button } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import useSWR from "swr"; @@ -16,11 +15,11 @@ import { ReviewFilter } from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { FaCalendarAlt, FaFilter, FaVideo } from "react-icons/fa"; -import { IconType } from "react-icons"; import { isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; +import FilterCheckBox from "./FilterCheckBox"; const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; @@ -479,32 +478,3 @@ function GeneralFilterButton({ ); } - -type FilterCheckBoxProps = { - label: string; - CheckIcon?: IconType; - isChecked: boolean; - onCheckedChange: (isChecked: boolean) => void; -}; - -function FilterCheckBox({ - label, - CheckIcon = LuCheck, - isChecked, - onCheckedChange, -}: FilterCheckBoxProps) { - return ( - - ); -} diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 7825e7f8f..a6c6b3864 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -204,9 +204,11 @@ export interface CameraConfig { }; } +export const GROUP_ICONS = ["car", "cat", "dog", "leaf"] as const; + export type CameraGroupConfig = { cameras: string[]; - icon: string; + icon: (typeof GROUP_ICONS)[number]; order: number; };