Get cameras filter working

This commit is contained in:
Nicolas Mowen 2024-02-25 06:49:43 -07:00
parent 4a7c159a44
commit 01ded3fcc5
6 changed files with 200 additions and 195 deletions

View File

@ -1055,9 +1055,9 @@ def event_snapshot(id):
else: else:
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
if download: if download:
response.headers[ response.headers["Content-Disposition"] = (
"Content-Disposition" f"attachment; filename=snapshot-{id}.jpg"
] = f"attachment; filename=snapshot-{id}.jpg" )
return response return response
@ -1244,9 +1244,9 @@ def event_clip(id):
if download: if download:
response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name
response.headers["Content-Length"] = os.path.getsize(clip_path) response.headers["Content-Length"] = os.path.getsize(clip_path)
response.headers[ response.headers["X-Accel-Redirect"] = (
"X-Accel-Redirect" f"/clips/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
] = f"/clips/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers )
return response return response
@ -1949,9 +1949,9 @@ def get_recordings_storage_usage():
total_mb = recording_stats["total"] total_mb = recording_stats["total"]
camera_usages: dict[ camera_usages: dict[str, dict] = (
str, dict current_app.storage_maintainer.calculate_camera_usages()
] = current_app.storage_maintainer.calculate_camera_usages() )
for camera_name in camera_usages.keys(): for camera_name in camera_usages.keys():
if camera_usages.get(camera_name, {}).get("usage"): if camera_usages.get(camera_name, {}).get("usage"):
@ -2139,9 +2139,9 @@ def recording_clip(camera_name, start_ts, end_ts):
if download: if download:
response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name response.headers["Content-Disposition"] = "attachment; filename=%s" % file_name
response.headers["Content-Length"] = os.path.getsize(path) response.headers["Content-Length"] = os.path.getsize(path)
response.headers[ response.headers["X-Accel-Redirect"] = (
"X-Accel-Redirect" f"/cache/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
] = f"/cache/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers )
return response return response
@ -2395,7 +2395,7 @@ def vod_event(id):
@bp.route("/review") @bp.route("/review")
def review(): def review():
camera = request.args.get("camera", "all") cameras = request.args.get("cameras", "all")
limit = request.args.get("limit", 100) limit = request.args.get("limit", 100)
severity = request.args.get("severity", None) severity = request.args.get("severity", None)
@ -2406,8 +2406,9 @@ def review():
clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))] clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))]
if camera != "all": if cameras != "all":
clauses.append((ReviewSegment.camera == camera)) camera_list = cameras.split(",")
clauses.append((ReviewSegment.camera << camera_list))
if severity: if severity:
clauses.append((ReviewSegment.severity == severity)) clauses.append((ReviewSegment.severity == severity))

View File

@ -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 { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import useSWR from "swr"; import useSWR from "swr";
@ -14,71 +14,49 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import { Calendar } from "../ui/calendar"; import { Calendar } from "../ui/calendar";
import { ReviewFilter } from "@/types/review";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
type HistoryFilterPopoverProps = { type ReviewFilterGroupProps = {
// @ts-ignore filter?: ReviewFilter;
filter: HistoryFilter | undefined; onUpdateFilter: (filter: ReviewFilter) => void;
// @ts-ignore
onUpdateFilter: (filter: HistoryFilter) => void;
}; };
export default function HistoryFilterPopover({ export default function ReviewFilterGroup({
filter, filter,
onUpdateFilter, onUpdateFilter,
}: HistoryFilterPopoverProps) { }: ReviewFilterGroupProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("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<string[]>(["labels"], { const { data: allLabels } = useSWR<string[]>(["labels"], {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
const { data: allSubLabels } = useSWR<string[]>(
["sub_labels", { split_joined: 1 }],
{
revalidateOnFocus: false,
}
);
const filterValues = useMemo( const filterValues = useMemo(
() => ({ () => ({
cameras: Object.keys(config?.cameras || {}), cameras: Object.keys(config?.cameras || {}),
labels: Object.values(allLabels || {}), 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 ( return (
<div className="mr-2">
<CamerasFilterButton
allCameras={filterValues.cameras}
selectedCameras={filter?.cameras}
updateCameraFilter={(newCameras) => {
onUpdateFilter({ ...filter, cameras: newCameras });
}}
/>
<CalendarFilterButton before={filter?.before} after={filter?.after} />
<Button className="mx-1" variant="secondary">
<LuFilter className=" mr-[10px]" />
Filter
</Button>
</div>
);
/*return (
<Popover open={open} onOpenChange={(open) => setOpen(open)}> <Popover open={open} onOpenChange={(open) => setOpen(open)}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button> <Button>
@ -88,65 +66,7 @@ export default function HistoryFilterPopover({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-screen sm:w-[340px]"> <PopoverContent className="w-screen sm:w-[340px]">
<div className="flex justify-around"> <div className="flex justify-around">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="capitalize" variant="outline">
{allItems.cameras
? "All Cameras"
: `${selectedFilters.cameras.length} Cameras`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Filter Cameras</DropdownMenuLabel>
<DropdownMenuSeparator />
<FilterCheckBox
isChecked={allItems.cameras}
label="All Cameras"
onCheckedChange={(isChecked) => {
if (isChecked) {
setSelectedFilters({
...selectedFilters,
cameras: ["all"],
});
}
}}
/>
<DropdownMenuSeparator />
{filterValues.cameras.map((item) => (
<FilterCheckBox
key={item}
isChecked={selectedFilters.cameras.includes(item)}
label={item.replaceAll("_", " ")}
onCheckedChange={(isChecked) => {
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,
});
}
}
}}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button className="capitalize" variant="outline"> <Button className="capitalize" variant="outline">
@ -216,9 +136,7 @@ export default function HistoryFilterPopover({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuLabel> <DropdownMenuLabel>Detail Level</DropdownMenuLabel>
Detail Level
</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuRadioGroup <DropdownMenuRadioGroup
value={selectedFilters.detailLevel} value={selectedFilters.detailLevel}
@ -276,6 +194,120 @@ export default function HistoryFilterPopover({
</Button> </Button>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
);*/
}
type CameraFilterButtonProps = {
allCameras: string[];
selectedCameras: string[] | undefined;
updateCameraFilter: (cameras: string[] | undefined) => void;
};
function CamerasFilterButton({
allCameras,
selectedCameras,
updateCameraFilter,
}: CameraFilterButtonProps) {
const [currentCameras, setCurrentCameras] = useState<string[] | undefined>(
selectedCameras
);
return (
<DropdownMenu
onOpenChange={(open) => {
if (!open) {
updateCameraFilter(currentCameras);
}
}}
>
<DropdownMenuTrigger asChild>
<Button className="mx-1 capitalize" variant="secondary">
<LuVideo className=" mr-[10px]" />
{selectedCameras == undefined
? "All Cameras"
: `${selectedCameras.length} Cameras`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Filter Cameras</DropdownMenuLabel>
<DropdownMenuSeparator />
<FilterCheckBox
isChecked={currentCameras == undefined}
label="All Cameras"
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentCameras(undefined);
}
}}
/>
<DropdownMenuSeparator />
{allCameras.map((item) => (
<FilterCheckBox
key={item}
isChecked={currentCameras?.includes(item) ?? false}
label={item.replaceAll("_", " ")}
onCheckedChange={(isChecked) => {
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);
}
}
}}
/>
))}
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
);
}
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 (
<Popover>
<PopoverTrigger asChild>
<Button className="mx-1" variant="secondary">
<LuCalendar className=" mr-[10px]" />
{formatUnixTimestampToDateTime(Date.now() / 1000, {
strftime_fmt: "%b %-d",
})}
</Button>
</PopoverTrigger>
<PopoverContent>
<Calendar mode="single" disabled={disabledDates} />
</PopoverContent>
</Popover>
); );
} }

View File

@ -1,13 +1,11 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
type useApiFilterReturn<F extends FilterType> = [ type useApiFilterReturn<F extends FilterType> = [
filter: F | undefined, filter: F,
setFilter: (filter: F) => void, setFilter: (filter: F) => void,
searchParams: searchParams: {
| { [key: string]: any;
[key: string]: any; },
}
| undefined,
]; ];
export default function useApiFilter< export default function useApiFilter<
@ -16,7 +14,7 @@ export default function useApiFilter<
const [filter, setFilter] = useState<F | undefined>(undefined); const [filter, setFilter] = useState<F | undefined>(undefined);
const searchParams = useMemo(() => { const searchParams = useMemo(() => {
if (filter == undefined) { if (filter == undefined) {
return undefined; return {};
} }
const search: { [key: string]: string } = {}; const search: { [key: string]: string } = {};

View File

@ -1,5 +1,6 @@
import useApiFilter from "@/hooks/use-api-filter";
import useOverlayState from "@/hooks/use-overlay-state"; 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 DesktopEventView from "@/views/events/DesktopEventView";
import DesktopRecordingView from "@/views/events/DesktopRecordingView"; import DesktopRecordingView from "@/views/events/DesktopRecordingView";
import MobileEventView from "@/views/events/MobileEventView"; import MobileEventView from "@/views/events/MobileEventView";
@ -15,6 +16,11 @@ export default function Events() {
// recordings viewer // recordings viewer
const [selectedReviewId, setSelectedReviewId] = useOverlayState("review"); const [selectedReviewId, setSelectedReviewId] = useOverlayState("review");
// review filter
const [reviewFilter, setReviewFilter, reviewSearchParams] =
useApiFilter<ReviewFilter>();
// review paging // review paging
const timeRange = useMemo(() => { const timeRange = useMemo(() => {
@ -26,30 +32,26 @@ export default function Events() {
return axios.get(path, { params }).then((res) => res.data); return axios.get(path, { params }).then((res) => res.data);
}, []); }, []);
const reviewSearchParams = {};
const getKey = useCallback( const getKey = useCallback(
(index: number, prevData: ReviewSegment[]) => { (index: number, prevData: ReviewSegment[]) => {
if (index > 0) { if (index > 0) {
const lastDate = prevData[prevData.length - 1].start_time; const lastDate = prevData[prevData.length - 1].start_time;
const pagedParams = reviewSearchParams reviewSearchParams;
? { before: lastDate, after: timeRange.after, limit: API_LIMIT } const pagedParams = {
: { cameras: reviewSearchParams["cameras"],
...reviewSearchParams, before: lastDate,
before: lastDate, after: reviewSearchParams["after"] || timeRange.after,
after: timeRange.after, limit: API_LIMIT,
limit: API_LIMIT, };
};
return ["review", pagedParams]; return ["review", pagedParams];
} }
const params = reviewSearchParams const params = {
? { limit: API_LIMIT, before: timeRange.before, after: timeRange.after } cameras: reviewSearchParams["cameras"],
: { limit: API_LIMIT,
...reviewSearchParams, before: reviewSearchParams["before"] || timeRange.before,
limit: API_LIMIT, after: reviewSearchParams["after"] || timeRange.after,
before: timeRange.before, };
after: timeRange.after,
};
return ["review", params]; return ["review", params];
}, },
[reviewSearchParams] [reviewSearchParams]
@ -197,10 +199,12 @@ export default function Events() {
timeRange={timeRange} timeRange={timeRange}
reachedEnd={isDone} reachedEnd={isDone}
isValidating={isValidating} isValidating={isValidating}
filter={reviewFilter}
loadNextPage={() => setSize(size + 1)} loadNextPage={() => setSize(size + 1)}
markItemAsReviewed={markItemAsReviewed} markItemAsReviewed={markItemAsReviewed}
onSelectReview={setSelectedReviewId} onSelectReview={setSelectedReviewId}
pullLatestData={updateSegments} pullLatestData={updateSegments}
updateFilter={setReviewFilter}
/> />
); );
} }

View File

@ -19,3 +19,11 @@ export type ReviewData = {
significant_motion_areas: number[]; significant_motion_areas: number[];
zones: string[]; zones: string[];
}; };
export type ReviewFilter = {
cameras?: string[];
labels?: string[];
before?: number;
after?: number;
showReviewed?: boolean;
};

View File

@ -1,20 +1,14 @@
import { useFrigateEvents } from "@/api/ws"; import { useFrigateEvents } from "@/api/ws";
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import ActivityIndicator from "@/components/ui/activity-indicator"; import ActivityIndicator from "@/components/ui/activity-indicator";
import { Button } from "@/components/ui/button"; 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 { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { MdCircle } from "react-icons/md";
import useSWR from "swr"; import useSWR from "swr";
@ -24,10 +18,12 @@ type DesktopEventViewProps = {
timeRange: { before: number; after: number }; timeRange: { before: number; after: number };
reachedEnd: boolean; reachedEnd: boolean;
isValidating: boolean; isValidating: boolean;
filter?: ReviewFilter;
loadNextPage: () => void; loadNextPage: () => void;
markItemAsReviewed: (reviewId: string) => void; markItemAsReviewed: (reviewId: string) => void;
onSelectReview: (reviewId: string) => void; onSelectReview: (reviewId: string) => void;
pullLatestData: () => void; pullLatestData: () => void;
updateFilter: (filter: ReviewFilter) => void;
}; };
export default function DesktopEventView({ export default function DesktopEventView({
reviewPages, reviewPages,
@ -35,10 +31,12 @@ export default function DesktopEventView({
timeRange, timeRange,
reachedEnd, reachedEnd,
isValidating, isValidating,
filter,
loadNextPage, loadNextPage,
markItemAsReviewed, markItemAsReviewed,
onSelectReview, onSelectReview,
pullLatestData, pullLatestData,
updateFilter,
}: DesktopEventViewProps) { }: DesktopEventViewProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [severity, setSeverity] = useState<ReviewSeverity>("alert"); const [severity, setSeverity] = useState<ReviewSeverity>("alert");
@ -234,17 +232,7 @@ export default function DesktopEventView({
Motion Motion
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
<div> <ReviewFilterGroup filter={filter} onUpdateFilter={updateFilter} />
<Button className="mx-1" variant="secondary">
<LuVideo className=" mr-[10px]" />
All Cameras
</Button>
<ReviewCalendarButton />
<Button className="mx-1" variant="secondary">
<LuFilter className=" mr-[10px]" />
Filter
</Button>
</div>
</div> </div>
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
@ -334,29 +322,3 @@ export default function DesktopEventView({
</div> </div>
); );
} }
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 (
<Popover>
<PopoverTrigger asChild>
<Button className="mx-1" variant="secondary">
<LuCalendar className=" mr-[10px]" />
{formatUnixTimestampToDateTime(Date.now() / 1000, {
strftime_fmt: "%b %-d",
})}
</Button>
</PopoverTrigger>
<PopoverContent>
<Calendar mode="single" disabled={disabledDates} />
</PopoverContent>
</Popover>
);
}