From a1e5c658d5f5ad340b1e1c5d0efc5521876fcbc6 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 21 Dec 2023 05:52:54 -0700 Subject: [PATCH] Add support for filtering history page and add support for creating timeline entries for audio / custom events (#9034) * Add filter popover * Add api filter hook and use UI with filtering * Get history filtering working for cameras and labels * Allow filtering on detail level * Save timeline entries for api events * reset * fix width --- frigate/events/external.py | 2 +- frigate/events/maintainer.py | 10 + frigate/http.py | 16 +- frigate/timeline.py | 39 +++ .../filter/HistoryFilterPopover.tsx | 305 ++++++++++++++++++ web/src/hooks/use-api-filter.ts | 42 +++ web/src/pages/History.tsx | 57 +++- web/src/types/filter.ts | 1 + web/src/types/history.ts | 77 +++-- web/src/utils/timelineUtil.tsx | 19 +- 10 files changed, 519 insertions(+), 49 deletions(-) create mode 100644 web/src/components/filter/HistoryFilterPopover.tsx create mode 100644 web/src/hooks/use-api-filter.ts create mode 100644 web/src/types/filter.ts diff --git a/frigate/events/external.py b/frigate/events/external.py index b02aaeba5..9c99ef50c 100644 --- a/frigate/events/external.py +++ b/frigate/events/external.py @@ -52,7 +52,7 @@ class ExternalEventProcessor: ( EventTypeEnum.api, "new", - camera_config, + camera, { "id": event_id, "label": label, diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index 19bb44ef4..eadf888c9 100644 --- a/frigate/events/maintainer.py +++ b/frigate/events/maintainer.py @@ -109,6 +109,16 @@ class EventProcessor(threading.Thread): self.handle_object_detection(event_type, camera, event_data) elif source_type == EventTypeEnum.api: + self.timeline_queue.put( + ( + camera, + source_type, + event_type, + {}, + event_data, + ) + ) + self.handle_external_detection(event_type, event_data) # set an end_time on events without an end_time before exiting diff --git a/frigate/http.py b/frigate/http.py index 6b7ff8c3a..427831b1e 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -614,20 +614,30 @@ def timeline(): @bp.route("/timeline/hourly") def hourly_timeline(): """Get hourly summary for timeline.""" - camera = request.args.get("camera", "all") + cameras = request.args.get("cameras", "all") + labels = request.args.get("labels", "all") before = request.args.get("before", type=float) + after = request.args.get("after", type=float) limit = request.args.get("limit", 200) tz_name = request.args.get("timezone", default="utc", type=str) _, minute_modifier, _ = get_tz_modifiers(tz_name) clauses = [] - if camera != "all": - clauses.append((Timeline.camera == camera)) + if cameras != "all": + camera_list = cameras.split(",") + clauses.append((Timeline.camera << camera_list)) + + if labels != "all": + label_list = labels.split(",") + clauses.append((Timeline.data["label"] << label_list)) if before: clauses.append((Timeline.timestamp < before)) + if after: + clauses.append((Timeline.timestamp > after)) + if len(clauses) == 0: clauses.append((True)) diff --git a/frigate/timeline.py b/frigate/timeline.py index 984dd3ae2..927fd6845 100644 --- a/frigate/timeline.py +++ b/frigate/timeline.py @@ -48,6 +48,8 @@ class TimelineProcessor(threading.Thread): self.handle_object_detection( camera, event_type, prev_event_data, event_data ) + elif input_type == EventTypeEnum.api: + self.handle_api_entry(camera, event_type, event_data) def insert_or_save( self, @@ -140,3 +142,40 @@ class TimelineProcessor(threading.Thread): if save: self.insert_or_save(timeline_entry, prev_event_data, event_data) + + def handle_api_entry( + self, + camera: str, + event_type: str, + event_data: dict[any, any], + ) -> bool: + if event_type != "new": + return False + + if event_data.get("type", "api") == "audio": + timeline_entry = { + Timeline.class_type: "heard", + Timeline.timestamp: event_data["start_time"], + Timeline.camera: camera, + Timeline.source: "audio", + Timeline.source_id: event_data["id"], + Timeline.data: { + "label": event_data["label"], + "sub_label": event_data.get("sub_label"), + }, + } + else: + timeline_entry = { + Timeline.class_type: "external", + Timeline.timestamp: event_data["start_time"], + Timeline.camera: camera, + Timeline.source: "api", + Timeline.source_id: event_data["id"], + Timeline.data: { + "label": event_data["label"], + "sub_label": event_data.get("sub_label"), + }, + } + + Timeline.insert(timeline_entry).execute() + return True diff --git a/web/src/components/filter/HistoryFilterPopover.tsx b/web/src/components/filter/HistoryFilterPopover.tsx new file mode 100644 index 000000000..45f5ad2c5 --- /dev/null +++ b/web/src/components/filter/HistoryFilterPopover.tsx @@ -0,0 +1,305 @@ +import { LuCheck, LuFilter } from "react-icons/lu"; +import { Button } from "../ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { useMemo, useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { Calendar } from "../ui/calendar"; + +type HistoryFilterPopoverProps = { + filter: HistoryFilter | undefined; + onUpdateFilter: (filter: HistoryFilter) => void; +}; + +export default function HistoryFilterPopover({ + filter, + onUpdateFilter, +}: HistoryFilterPopoverProps) { + const { data: config } = useSWR("config"); + + const [open, setOpen] = useState(false); + const disabledDates = useMemo(() => { + const tomorrow = new Date(); + tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0); + const future = new Date(); + future.setFullYear(2032); + return { from: tomorrow, to: future }; + }, []); + + const { data: allLabels } = useSWR(["labels"], { + revalidateOnFocus: false, + }); + const { data: allSubLabels } = useSWR( + ["sub_labels", { split_joined: 1 }], + { + revalidateOnFocus: false, + } + ); + const filterValues = useMemo( + () => ({ + cameras: Object.keys(config?.cameras || {}), + labels: Object.values(allLabels || {}), + }), + [config, allLabels, allSubLabels] + ); + const [selectedFilters, setSelectedFilters] = useState({ + cameras: filter == undefined ? ["all"] : filter.cameras, + labels: filter == undefined ? ["all"] : filter.labels, + before: filter?.before, + after: filter?.after, + detailLevel: filter?.detailLevel ?? "normal", + }); + const dateRange = useMemo(() => { + return selectedFilters?.before == undefined || + selectedFilters?.after == undefined + ? undefined + : { + from: new Date(selectedFilters.after * 1000), + to: new Date(selectedFilters.before * 1000), + }; + }, [selectedFilters]); + + const allItems = useMemo(() => { + return { + cameras: + JSON.stringify(selectedFilters.cameras) == JSON.stringify(["all"]), + labels: JSON.stringify(selectedFilters.labels) == JSON.stringify(["all"]), + }; + }, [selectedFilters]); + + return ( + setOpen(open)}> + + + + +
+ + + + + + Filter Cameras + + { + if (isChecked) { + setSelectedFilters({ + ...selectedFilters, + cameras: ["all"], + }); + } + }} + /> + + {filterValues.cameras.map((item) => ( + { + if (isChecked) { + const selectedCameras = allItems.cameras + ? [] + : [...selectedFilters.cameras]; + selectedCameras.push(item); + setSelectedFilters({ + ...selectedFilters, + cameras: selectedCameras, + }); + } else { + const selectedCameraList = [...selectedFilters.cameras]; + + // can not deselect the last item + if (selectedCameraList.length > 1) { + selectedCameraList.splice( + selectedCameraList.indexOf(item), + 1 + ); + setSelectedFilters({ + ...selectedFilters, + cameras: selectedCameraList, + }); + } + } + }} + /> + ))} + + + + + + + + Filter Labels + + { + if (isChecked) { + setSelectedFilters({ + ...selectedFilters, + labels: ["all"], + }); + } + }} + /> + + {filterValues.labels.map((item) => ( + { + if (isChecked) { + const selectedLabels = allItems.labels + ? [] + : [...selectedFilters.labels]; + selectedLabels.push(item); + setSelectedFilters({ + ...selectedFilters, + labels: selectedLabels, + }); + } else { + const selectedLabelList = [...selectedFilters.labels]; + + // can not deselect the last item + if (selectedLabelList.length > 1) { + selectedLabelList.splice( + selectedLabelList.indexOf(item), + 1 + ); + setSelectedFilters({ + ...selectedFilters, + labels: selectedLabelList, + }); + } + } + }} + /> + ))} + + + + + + + + + Detail Level + + + { + setSelectedFilters({ + ...selectedFilters, + // @ts-ignore we know that value is one of the detailLevel + detailLevel: value, + }); + }} + > + + Normal + + + Extra + + Full + + + +
+ { + let afterTime = undefined; + if (range?.from != undefined) { + afterTime = range.from.getTime() / 1000; + } + + // need to make sure the day selected for before covers the entire day + let beforeTime = undefined; + if (range?.from != undefined) { + const beforeDate = range.to ?? range.from; + beforeDate.setHours(beforeDate.getHours() + 24, -1, 0, 0); + beforeTime = beforeDate.getTime() / 1000; + } + + setSelectedFilters({ + ...selectedFilters, + after: afterTime, + before: beforeTime, + }); + }} + /> + +
+
+ ); +} + +type FilterCheckBoxProps = { + label: string; + isChecked: boolean; + onCheckedChange: (isChecked: boolean) => void; +}; + +function FilterCheckBox({ + label, + isChecked, + onCheckedChange, +}: FilterCheckBoxProps) { + return ( + + ); +} diff --git a/web/src/hooks/use-api-filter.ts b/web/src/hooks/use-api-filter.ts new file mode 100644 index 000000000..85e3a11b3 --- /dev/null +++ b/web/src/hooks/use-api-filter.ts @@ -0,0 +1,42 @@ +import { useMemo, useState } from "react"; + +type useApiFilterReturn = [ + filter: F | undefined, + setFilter: (filter: F) => void, + searchParams: + | { + [key: string]: any; + } + | undefined, +]; + +export default function useApiFilter< + F extends FilterType, +>(): useApiFilterReturn { + const [filter, setFilter] = useState(undefined); + const searchParams = useMemo(() => { + if (filter == undefined) { + return undefined; + } + + const search: { [key: string]: string } = {}; + + Object.entries(filter).forEach(([key, value]) => { + if (Array.isArray(value)) { + if (value.length == 0) { + // empty array means all so ignore + } else { + search[key] = value.join(","); + } + } else { + if (value != undefined) { + search[key] = `${value}`; + } + } + }); + + return search; + }, [filter]); + + return [filter, setFilter, searchParams]; +} diff --git a/web/src/pages/History.tsx b/web/src/pages/History.tsx index e9e5a00a3..ec40a4db5 100644 --- a/web/src/pages/History.tsx +++ b/web/src/pages/History.tsx @@ -19,6 +19,8 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import HistoryFilterPopover from "@/components/filter/HistoryFilterPopover"; +import useApiFilter from "@/hooks/use-api-filter"; const API_LIMIT = 200; @@ -29,20 +31,39 @@ function History() { config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config] ); + + const [historyFilter, setHistoryFilter, historySearchParams] = + useApiFilter(); + const timelineFetcher = useCallback((key: any) => { const [path, params] = Array.isArray(key) ? key : [key, undefined]; return axios.get(path, { params }).then((res) => res.data); }, []); - const getKey = useCallback((index: number, prevData: HourlyTimeline) => { - if (index > 0) { - const lastDate = prevData.end; - const pagedParams = { before: lastDate, timezone, limit: API_LIMIT }; - return ["timeline/hourly", pagedParams]; - } + const getKey = useCallback( + (index: number, prevData: HourlyTimeline) => { + if (index > 0) { + const lastDate = prevData.end; + const pagedParams = + historySearchParams == undefined + ? { before: lastDate, timezone, limit: API_LIMIT } + : { + ...historySearchParams, + before: lastDate, + timezone, + limit: API_LIMIT, + }; + return ["timeline/hourly", pagedParams]; + } - return ["timeline/hourly", { timezone, limit: API_LIMIT }]; - }, []); + const params = + historySearchParams == undefined + ? { timezone, limit: API_LIMIT } + : { ...historySearchParams, timezone, limit: API_LIMIT }; + return ["timeline/hourly", params]; + }, + [historySearchParams] + ); const { data: timelinePages, @@ -59,7 +80,6 @@ function History() { { revalidateOnFocus: false } ); - const [detailLevel, _] = useState<"normal" | "extra" | "full">("normal"); const [playback, setPlayback] = useState(); const shouldAutoPlay = useMemo(() => { @@ -71,8 +91,11 @@ function History() { return []; } - return getHourlyTimelineData(timelinePages, detailLevel); - }, [detailLevel, timelinePages]); + return getHourlyTimelineData( + timelinePages, + historyFilter?.detailLevel ?? "normal" + ); + }, [historyFilter, timelinePages]); const isDone = (timelinePages?.[timelinePages.length - 1]?.count ?? 0) < API_LIMIT; @@ -137,7 +160,13 @@ function History() { return ( <> - Review +
+ History + setHistoryFilter(filter)} + /> +
{formatUnixTimestampToDateTime(parseInt(day), { @@ -242,7 +271,7 @@ function History() { ); })} - {lastRow && } + {lastRow && !isDone && } ); } diff --git a/web/src/types/filter.ts b/web/src/types/filter.ts new file mode 100644 index 000000000..e1c6c6cfc --- /dev/null +++ b/web/src/types/filter.ts @@ -0,0 +1 @@ +type FilterType = { [searchKey: string]: any }; diff --git a/web/src/types/history.ts b/web/src/types/history.ts index e544f7617..a6681dac9 100644 --- a/web/src/types/history.ts +++ b/web/src/types/history.ts @@ -1,40 +1,57 @@ type CardsData = { + [key: string]: { [key: string]: { - [key: string]: { - [key: string]: Card - } - } -} + [key: string]: Card; + }; + }; +}; type Card = { - camera: string, - time: number, - entries: Timeline[], - uniqueKeys: string[], -} + camera: string; + time: number; + entries: Timeline[]; + uniqueKeys: string[]; +}; type Preview = { - camera: string, - src: string, - type: string, - start: number, - end: number, -} + camera: string; + src: string; + type: string; + start: number; + end: number; +}; type Timeline = { - camera: string, - timestamp: number, - data: { - [key: string]: any - }, - class_type: string, - source_id: string, - source: string, -} + camera: string; + timestamp: number; + data: { + [key: string]: any; + }; + class_type: + | "visible" + | "gone" + | "sub_label" + | "entered_zone" + | "attribute" + | "active" + | "stationary" + | "heard" + | "external"; + source_id: string; + source: string; +}; type HourlyTimeline = { - start: number, - end: number, - count: number, - hours: { [key: string]: Timeline[] }; -} \ No newline at end of file + start: number; + end: number; + count: number; + hours: { [key: string]: Timeline[] }; +}; + +interface HistoryFilter extends FilterType { + cameras: string[]; + labels: string[]; + before: number | undefined; + after: number | undefined; + detailLevel: "normal" | "extra" | "full"; +} diff --git a/web/src/utils/timelineUtil.tsx b/web/src/utils/timelineUtil.tsx index f85be6ad8..5f9333291 100644 --- a/web/src/utils/timelineUtil.tsx +++ b/web/src/utils/timelineUtil.tsx @@ -1,4 +1,11 @@ -import { LuCircle, LuPlay, LuPlayCircle, LuTruck } from "react-icons/lu"; +import { + LuCircle, + LuCircleDot, + LuEar, + LuPlay, + LuPlayCircle, + LuTruck, +} from "react-icons/lu"; import { IoMdExit } from "react-icons/io"; import { MdFaceUnlock, @@ -33,7 +40,13 @@ export function getTimelineIcon(timelineItem: Timeline) { return ; case "car": return ; + default: + return ; } + case "heard": + return ; + case "external": + return ; } } @@ -76,5 +89,9 @@ export function getTimelineItemDescription(timelineItem: Timeline) { return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`; case "gone": return `${label} left`; + case "heard": + return `${label} heard`; + case "external": + return `${label} detected`; } }