diff --git a/frigate/http.py b/frigate/http.py index 7f29f3f94..04de7371a 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -1055,9 +1055,9 @@ def event_snapshot(id): else: response.headers["Cache-Control"] = "no-store" if download: - response.headers[ - "Content-Disposition" - ] = f"attachment; filename=snapshot-{id}.jpg" + response.headers["Content-Disposition"] = ( + f"attachment; filename=snapshot-{id}.jpg" + ) return response @@ -1244,9 +1244,9 @@ def event_clip(id): if download: response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name response.headers["Content-Length"] = os.path.getsize(clip_path) - response.headers[ - "X-Accel-Redirect" - ] = f"/clips/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers + response.headers["X-Accel-Redirect"] = ( + f"/clips/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers + ) return response @@ -1949,9 +1949,9 @@ def get_recordings_storage_usage(): total_mb = recording_stats["total"] - camera_usages: dict[ - str, dict - ] = current_app.storage_maintainer.calculate_camera_usages() + camera_usages: dict[str, dict] = ( + current_app.storage_maintainer.calculate_camera_usages() + ) for camera_name in camera_usages.keys(): if camera_usages.get(camera_name, {}).get("usage"): @@ -2139,9 +2139,9 @@ def recording_clip(camera_name, start_ts, end_ts): if download: response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name response.headers["Content-Length"] = os.path.getsize(path) - response.headers[ - "X-Accel-Redirect" - ] = f"/cache/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers + response.headers["X-Accel-Redirect"] = ( + f"/cache/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers + ) return response @@ -2395,7 +2395,7 @@ def vod_event(id): @bp.route("/review") def review(): - camera = request.args.get("camera", "all") + cameras = request.args.get("cameras", "all") limit = request.args.get("limit", 100) severity = request.args.get("severity", None) @@ -2406,8 +2406,9 @@ def review(): clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))] - if camera != "all": - clauses.append((ReviewSegment.camera == camera)) + if cameras != "all": + camera_list = cameras.split(",") + clauses.append((ReviewSegment.camera << camera_list)) if severity: clauses.append((ReviewSegment.severity == severity)) diff --git a/web/src/components/filter/HistoryFilterPopover.tsx b/web/src/components/filter/ReviewFilterGroup.tsx similarity index 62% rename from web/src/components/filter/HistoryFilterPopover.tsx rename to web/src/components/filter/ReviewFilterGroup.tsx index d293e266e..252ce3d38 100644 --- a/web/src/components/filter/HistoryFilterPopover.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -1,4 +1,4 @@ -import { LuCheck, LuFilter } from "react-icons/lu"; +import { LuCalendar, LuCheck, LuFilter, LuVideo } from "react-icons/lu"; import { Button } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import useSWR from "swr"; @@ -14,71 +14,49 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { Calendar } from "../ui/calendar"; +import { ReviewFilter } from "@/types/review"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; -type HistoryFilterPopoverProps = { - // @ts-ignore - filter: HistoryFilter | undefined; - // @ts-ignore - onUpdateFilter: (filter: HistoryFilter) => void; +type ReviewFilterGroupProps = { + filter?: ReviewFilter; + onUpdateFilter: (filter: ReviewFilter) => void; }; -export default function HistoryFilterPopover({ +export default function ReviewFilterGroup({ filter, onUpdateFilter, -}: HistoryFilterPopoverProps) { +}: ReviewFilterGroupProps) { 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] + [config, allLabels] ); - 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 ( +
+ { + onUpdateFilter({ ...filter, cameras: newCameras }); + }} + /> + + +
+ ); + + /*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, - }); - } - } - }} - /> - ))} - - - - Detail Level - + Detail Level + );*/ +} + +type CameraFilterButtonProps = { + allCameras: string[]; + selectedCameras: string[] | undefined; + updateCameraFilter: (cameras: string[] | undefined) => void; +}; +function CamerasFilterButton({ + allCameras, + selectedCameras, + updateCameraFilter, +}: CameraFilterButtonProps) { + const [currentCameras, setCurrentCameras] = useState( + selectedCameras + ); + + return ( + { + if (!open) { + updateCameraFilter(currentCameras); + } + }} + > + + + + + Filter Cameras + + { + if (isChecked) { + setCurrentCameras(undefined); + } + }} + /> + + {allCameras.map((item) => ( + { + if (isChecked) { + const updatedCameras = currentCameras + ? [...currentCameras] + : []; + + updatedCameras.push(item); + setCurrentCameras(updatedCameras); + } else { + const updatedCameras = currentCameras + ? [...currentCameras] + : []; + + // can not deselect the last item + if (updatedCameras.length > 1) { + updatedCameras.splice(updatedCameras.indexOf(item), 1); + setCurrentCameras(updatedCameras); + } + } + }} + /> + ))} + + + + ); +} + +type CalendarFilterButtonProps = { + before: number | undefined; + after: number | undefined; +}; +function CalendarFilterButton({ before, after }: CalendarFilterButtonProps) { + const disabledDates = useMemo(() => { + const tomorrow = new Date(); + tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0); + const future = new Date(); + future.setFullYear(tomorrow.getFullYear() + 10); + return { from: tomorrow, to: future }; + }, []); + const dateRange = useMemo(() => { + return before == undefined || after == undefined + ? undefined + : { + from: new Date(after * 1000), + to: new Date(before * 1000), + }; + }, [before, after]); + + return ( + + + + + + + + ); } diff --git a/web/src/hooks/use-api-filter.ts b/web/src/hooks/use-api-filter.ts index 85e3a11b3..7ed74b96b 100644 --- a/web/src/hooks/use-api-filter.ts +++ b/web/src/hooks/use-api-filter.ts @@ -1,13 +1,11 @@ import { useMemo, useState } from "react"; type useApiFilterReturn = [ - filter: F | undefined, + filter: F, setFilter: (filter: F) => void, - searchParams: - | { - [key: string]: any; - } - | undefined, + searchParams: { + [key: string]: any; + }, ]; export default function useApiFilter< @@ -16,7 +14,7 @@ export default function useApiFilter< const [filter, setFilter] = useState(undefined); const searchParams = useMemo(() => { if (filter == undefined) { - return undefined; + return {}; } const search: { [key: string]: string } = {}; diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 77c2a3f61..d358203fe 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,5 +1,6 @@ +import useApiFilter from "@/hooks/use-api-filter"; import useOverlayState from "@/hooks/use-overlay-state"; -import { ReviewSegment } from "@/types/review"; +import { ReviewFilter, ReviewSegment } from "@/types/review"; import DesktopEventView from "@/views/events/DesktopEventView"; import DesktopRecordingView from "@/views/events/DesktopRecordingView"; import MobileEventView from "@/views/events/MobileEventView"; @@ -15,6 +16,11 @@ export default function Events() { // recordings viewer const [selectedReviewId, setSelectedReviewId] = useOverlayState("review"); + // review filter + + const [reviewFilter, setReviewFilter, reviewSearchParams] = + useApiFilter(); + // review paging const timeRange = useMemo(() => { @@ -26,30 +32,26 @@ export default function Events() { return axios.get(path, { params }).then((res) => res.data); }, []); - const reviewSearchParams = {}; const getKey = useCallback( (index: number, prevData: ReviewSegment[]) => { if (index > 0) { const lastDate = prevData[prevData.length - 1].start_time; - const pagedParams = reviewSearchParams - ? { before: lastDate, after: timeRange.after, limit: API_LIMIT } - : { - ...reviewSearchParams, - before: lastDate, - after: timeRange.after, - limit: API_LIMIT, - }; + reviewSearchParams; + const pagedParams = { + cameras: reviewSearchParams["cameras"], + before: lastDate, + after: reviewSearchParams["after"] || timeRange.after, + limit: API_LIMIT, + }; return ["review", pagedParams]; } - const params = reviewSearchParams - ? { limit: API_LIMIT, before: timeRange.before, after: timeRange.after } - : { - ...reviewSearchParams, - limit: API_LIMIT, - before: timeRange.before, - after: timeRange.after, - }; + const params = { + cameras: reviewSearchParams["cameras"], + limit: API_LIMIT, + before: reviewSearchParams["before"] || timeRange.before, + after: reviewSearchParams["after"] || timeRange.after, + }; return ["review", params]; }, [reviewSearchParams] @@ -197,10 +199,12 @@ export default function Events() { timeRange={timeRange} reachedEnd={isDone} isValidating={isValidating} + filter={reviewFilter} loadNextPage={() => setSize(size + 1)} markItemAsReviewed={markItemAsReviewed} onSelectReview={setSelectedReviewId} pullLatestData={updateSegments} + updateFilter={setReviewFilter} /> ); } diff --git a/web/src/types/review.ts b/web/src/types/review.ts index 55a08a819..287c89573 100644 --- a/web/src/types/review.ts +++ b/web/src/types/review.ts @@ -19,3 +19,11 @@ export type ReviewData = { significant_motion_areas: number[]; zones: string[]; }; + +export type ReviewFilter = { + cameras?: string[]; + labels?: string[]; + before?: number; + after?: number; + showReviewed?: boolean; +}; diff --git a/web/src/views/events/DesktopEventView.tsx b/web/src/views/events/DesktopEventView.tsx index bed88be6a..c0e376984 100644 --- a/web/src/views/events/DesktopEventView.tsx +++ b/web/src/views/events/DesktopEventView.tsx @@ -1,20 +1,14 @@ import { useFrigateEvents } from "@/api/ws"; +import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; import ActivityIndicator from "@/components/ui/activity-indicator"; import { Button } from "@/components/ui/button"; -import { Calendar } from "@/components/ui/calendar"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { FrigateConfig } from "@/types/frigateConfig"; -import { ReviewSegment, ReviewSeverity } from "@/types/review"; -import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { LuCalendar, LuFilter, LuRefreshCcw, LuVideo } from "react-icons/lu"; +import { LuRefreshCcw } from "react-icons/lu"; import { MdCircle } from "react-icons/md"; import useSWR from "swr"; @@ -24,10 +18,12 @@ type DesktopEventViewProps = { timeRange: { before: number; after: number }; reachedEnd: boolean; isValidating: boolean; + filter?: ReviewFilter; loadNextPage: () => void; markItemAsReviewed: (reviewId: string) => void; onSelectReview: (reviewId: string) => void; pullLatestData: () => void; + updateFilter: (filter: ReviewFilter) => void; }; export default function DesktopEventView({ reviewPages, @@ -35,10 +31,12 @@ export default function DesktopEventView({ timeRange, reachedEnd, isValidating, + filter, loadNextPage, markItemAsReviewed, onSelectReview, pullLatestData, + updateFilter, }: DesktopEventViewProps) { const { data: config } = useSWR("config"); const [severity, setSeverity] = useState("alert"); @@ -234,17 +232,7 @@ export default function DesktopEventView({ Motion -
- - - -
+
@@ -334,29 +322,3 @@ export default function DesktopEventView({
); } - -function ReviewCalendarButton() { - const disabledDates = useMemo(() => { - const tomorrow = new Date(); - tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0); - const future = new Date(); - future.setFullYear(tomorrow.getFullYear() + 10); - return { from: tomorrow, to: future }; - }, []); - - return ( - - - - - - - - - ); -}