mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-12-29 00:06:19 +01:00
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
This commit is contained in:
parent
feb3ee0703
commit
a1e5c658d5
@ -52,7 +52,7 @@ class ExternalEventProcessor:
|
|||||||
(
|
(
|
||||||
EventTypeEnum.api,
|
EventTypeEnum.api,
|
||||||
"new",
|
"new",
|
||||||
camera_config,
|
camera,
|
||||||
{
|
{
|
||||||
"id": event_id,
|
"id": event_id,
|
||||||
"label": label,
|
"label": label,
|
||||||
|
@ -109,6 +109,16 @@ class EventProcessor(threading.Thread):
|
|||||||
|
|
||||||
self.handle_object_detection(event_type, camera, event_data)
|
self.handle_object_detection(event_type, camera, event_data)
|
||||||
elif source_type == EventTypeEnum.api:
|
elif source_type == EventTypeEnum.api:
|
||||||
|
self.timeline_queue.put(
|
||||||
|
(
|
||||||
|
camera,
|
||||||
|
source_type,
|
||||||
|
event_type,
|
||||||
|
{},
|
||||||
|
event_data,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
self.handle_external_detection(event_type, event_data)
|
self.handle_external_detection(event_type, event_data)
|
||||||
|
|
||||||
# set an end_time on events without an end_time before exiting
|
# set an end_time on events without an end_time before exiting
|
||||||
|
@ -614,20 +614,30 @@ def timeline():
|
|||||||
@bp.route("/timeline/hourly")
|
@bp.route("/timeline/hourly")
|
||||||
def hourly_timeline():
|
def hourly_timeline():
|
||||||
"""Get hourly summary for 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)
|
before = request.args.get("before", type=float)
|
||||||
|
after = request.args.get("after", type=float)
|
||||||
limit = request.args.get("limit", 200)
|
limit = request.args.get("limit", 200)
|
||||||
tz_name = request.args.get("timezone", default="utc", type=str)
|
tz_name = request.args.get("timezone", default="utc", type=str)
|
||||||
_, minute_modifier, _ = get_tz_modifiers(tz_name)
|
_, minute_modifier, _ = get_tz_modifiers(tz_name)
|
||||||
|
|
||||||
clauses = []
|
clauses = []
|
||||||
|
|
||||||
if camera != "all":
|
if cameras != "all":
|
||||||
clauses.append((Timeline.camera == camera))
|
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:
|
if before:
|
||||||
clauses.append((Timeline.timestamp < before))
|
clauses.append((Timeline.timestamp < before))
|
||||||
|
|
||||||
|
if after:
|
||||||
|
clauses.append((Timeline.timestamp > after))
|
||||||
|
|
||||||
if len(clauses) == 0:
|
if len(clauses) == 0:
|
||||||
clauses.append((True))
|
clauses.append((True))
|
||||||
|
|
||||||
|
@ -48,6 +48,8 @@ class TimelineProcessor(threading.Thread):
|
|||||||
self.handle_object_detection(
|
self.handle_object_detection(
|
||||||
camera, event_type, prev_event_data, event_data
|
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(
|
def insert_or_save(
|
||||||
self,
|
self,
|
||||||
@ -140,3 +142,40 @@ class TimelineProcessor(threading.Thread):
|
|||||||
|
|
||||||
if save:
|
if save:
|
||||||
self.insert_or_save(timeline_entry, prev_event_data, event_data)
|
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
|
||||||
|
305
web/src/components/filter/HistoryFilterPopover.tsx
Normal file
305
web/src/components/filter/HistoryFilterPopover.tsx
Normal file
@ -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<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"], {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
const { data: allSubLabels } = useSWR<string[]>(
|
||||||
|
["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 (
|
||||||
|
<Popover open={open} onOpenChange={(open) => setOpen(open)}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<LuFilter className="mx-1" />
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-screen sm:w-[340px]">
|
||||||
|
<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>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button className="capitalize" variant="outline">
|
||||||
|
{allItems.labels
|
||||||
|
? "All Labels"
|
||||||
|
: `${selectedFilters.labels.length} Labels`}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuLabel>Filter Labels</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<FilterCheckBox
|
||||||
|
isChecked={allItems.labels}
|
||||||
|
label="All Labels"
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (isChecked) {
|
||||||
|
setSelectedFilters({
|
||||||
|
...selectedFilters,
|
||||||
|
labels: ["all"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{filterValues.labels.map((item) => (
|
||||||
|
<FilterCheckBox
|
||||||
|
key={item}
|
||||||
|
isChecked={
|
||||||
|
selectedFilters.labels.length == 0 ||
|
||||||
|
selectedFilters.labels.includes(item)
|
||||||
|
}
|
||||||
|
label={item.replaceAll("_", " ")}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button className="capitalize" variant="outline">
|
||||||
|
{selectedFilters.detailLevel}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
Detail Level
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={selectedFilters.detailLevel}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedFilters({
|
||||||
|
...selectedFilters,
|
||||||
|
// @ts-ignore we know that value is one of the detailLevel
|
||||||
|
detailLevel: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="normal">
|
||||||
|
Normal
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="extra">
|
||||||
|
Extra
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="full">Full</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<Calendar
|
||||||
|
mode="range"
|
||||||
|
disabled={disabledDates}
|
||||||
|
selected={dateRange}
|
||||||
|
onSelect={(range) => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateFilter(selectedFilters);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterCheckBoxProps = {
|
||||||
|
label: string;
|
||||||
|
isChecked: boolean;
|
||||||
|
onCheckedChange: (isChecked: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FilterCheckBox({
|
||||||
|
label,
|
||||||
|
isChecked,
|
||||||
|
onCheckedChange,
|
||||||
|
}: FilterCheckBoxProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="capitalize flex justify-between items-center cursor-pointer w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(_) => onCheckedChange(!isChecked)}
|
||||||
|
>
|
||||||
|
{isChecked ? (
|
||||||
|
<LuCheck className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<div className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
<div className="ml-1 w-full flex justify-start">{label}</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
42
web/src/hooks/use-api-filter.ts
Normal file
42
web/src/hooks/use-api-filter.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type useApiFilterReturn<F extends FilterType> = [
|
||||||
|
filter: F | undefined,
|
||||||
|
setFilter: (filter: F) => void,
|
||||||
|
searchParams:
|
||||||
|
| {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function useApiFilter<
|
||||||
|
F extends FilterType,
|
||||||
|
>(): useApiFilterReturn<F> {
|
||||||
|
const [filter, setFilter] = useState<F | undefined>(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];
|
||||||
|
}
|
@ -19,6 +19,8 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import HistoryFilterPopover from "@/components/filter/HistoryFilterPopover";
|
||||||
|
import useApiFilter from "@/hooks/use-api-filter";
|
||||||
|
|
||||||
const API_LIMIT = 200;
|
const API_LIMIT = 200;
|
||||||
|
|
||||||
@ -29,20 +31,39 @@ function History() {
|
|||||||
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
[config]
|
[config]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [historyFilter, setHistoryFilter, historySearchParams] =
|
||||||
|
useApiFilter<HistoryFilter>();
|
||||||
|
|
||||||
const timelineFetcher = useCallback((key: any) => {
|
const timelineFetcher = useCallback((key: any) => {
|
||||||
const [path, params] = Array.isArray(key) ? key : [key, undefined];
|
const [path, params] = Array.isArray(key) ? key : [key, undefined];
|
||||||
return axios.get(path, { params }).then((res) => res.data);
|
return axios.get(path, { params }).then((res) => res.data);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getKey = useCallback((index: number, prevData: HourlyTimeline) => {
|
const getKey = useCallback(
|
||||||
if (index > 0) {
|
(index: number, prevData: HourlyTimeline) => {
|
||||||
const lastDate = prevData.end;
|
if (index > 0) {
|
||||||
const pagedParams = { before: lastDate, timezone, limit: API_LIMIT };
|
const lastDate = prevData.end;
|
||||||
return ["timeline/hourly", pagedParams];
|
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 {
|
const {
|
||||||
data: timelinePages,
|
data: timelinePages,
|
||||||
@ -59,7 +80,6 @@ function History() {
|
|||||||
{ revalidateOnFocus: false }
|
{ revalidateOnFocus: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const [detailLevel, _] = useState<"normal" | "extra" | "full">("normal");
|
|
||||||
const [playback, setPlayback] = useState<Card | undefined>();
|
const [playback, setPlayback] = useState<Card | undefined>();
|
||||||
|
|
||||||
const shouldAutoPlay = useMemo(() => {
|
const shouldAutoPlay = useMemo(() => {
|
||||||
@ -71,8 +91,11 @@ function History() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return getHourlyTimelineData(timelinePages, detailLevel);
|
return getHourlyTimelineData(
|
||||||
}, [detailLevel, timelinePages]);
|
timelinePages,
|
||||||
|
historyFilter?.detailLevel ?? "normal"
|
||||||
|
);
|
||||||
|
}, [historyFilter, timelinePages]);
|
||||||
|
|
||||||
const isDone =
|
const isDone =
|
||||||
(timelinePages?.[timelinePages.length - 1]?.count ?? 0) < API_LIMIT;
|
(timelinePages?.[timelinePages.length - 1]?.count ?? 0) < API_LIMIT;
|
||||||
@ -137,7 +160,13 @@ function History() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Heading as="h2">Review</Heading>
|
<div className="flex justify-between">
|
||||||
|
<Heading as="h2">History</Heading>
|
||||||
|
<HistoryFilterPopover
|
||||||
|
filter={historyFilter}
|
||||||
|
onUpdateFilter={(filter) => setHistoryFilter(filter)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={itemsToDelete != null}
|
open={itemsToDelete != null}
|
||||||
@ -176,7 +205,7 @@ function History() {
|
|||||||
return (
|
return (
|
||||||
<div key={day}>
|
<div key={day}>
|
||||||
<Heading
|
<Heading
|
||||||
className="sticky py-2 -top-4 left-0 bg-background w-full z-10"
|
className="sticky py-2 -top-4 left-0 bg-background w-full z-20"
|
||||||
as="h3"
|
as="h3"
|
||||||
>
|
>
|
||||||
{formatUnixTimestampToDateTime(parseInt(day), {
|
{formatUnixTimestampToDateTime(parseInt(day), {
|
||||||
@ -242,7 +271,7 @@ function History() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{lastRow && <ActivityIndicator />}
|
{lastRow && !isDone && <ActivityIndicator />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
1
web/src/types/filter.ts
Normal file
1
web/src/types/filter.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
type FilterType = { [searchKey: string]: any };
|
@ -1,40 +1,57 @@
|
|||||||
type CardsData = {
|
type CardsData = {
|
||||||
|
[key: string]: {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
[key: string]: {
|
[key: string]: Card;
|
||||||
[key: string]: Card
|
};
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
type Card = {
|
type Card = {
|
||||||
camera: string,
|
camera: string;
|
||||||
time: number,
|
time: number;
|
||||||
entries: Timeline[],
|
entries: Timeline[];
|
||||||
uniqueKeys: string[],
|
uniqueKeys: string[];
|
||||||
}
|
};
|
||||||
|
|
||||||
type Preview = {
|
type Preview = {
|
||||||
camera: string,
|
camera: string;
|
||||||
src: string,
|
src: string;
|
||||||
type: string,
|
type: string;
|
||||||
start: number,
|
start: number;
|
||||||
end: number,
|
end: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
type Timeline = {
|
type Timeline = {
|
||||||
camera: string,
|
camera: string;
|
||||||
timestamp: number,
|
timestamp: number;
|
||||||
data: {
|
data: {
|
||||||
[key: string]: any
|
[key: string]: any;
|
||||||
},
|
};
|
||||||
class_type: string,
|
class_type:
|
||||||
source_id: string,
|
| "visible"
|
||||||
source: string,
|
| "gone"
|
||||||
}
|
| "sub_label"
|
||||||
|
| "entered_zone"
|
||||||
|
| "attribute"
|
||||||
|
| "active"
|
||||||
|
| "stationary"
|
||||||
|
| "heard"
|
||||||
|
| "external";
|
||||||
|
source_id: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
type HourlyTimeline = {
|
type HourlyTimeline = {
|
||||||
start: number,
|
start: number;
|
||||||
end: number,
|
end: number;
|
||||||
count: number,
|
count: number;
|
||||||
hours: { [key: string]: Timeline[] };
|
hours: { [key: string]: Timeline[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HistoryFilter extends FilterType {
|
||||||
|
cameras: string[];
|
||||||
|
labels: string[];
|
||||||
|
before: number | undefined;
|
||||||
|
after: number | undefined;
|
||||||
|
detailLevel: "normal" | "extra" | "full";
|
||||||
}
|
}
|
@ -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 { IoMdExit } from "react-icons/io";
|
||||||
import {
|
import {
|
||||||
MdFaceUnlock,
|
MdFaceUnlock,
|
||||||
@ -33,7 +40,13 @@ export function getTimelineIcon(timelineItem: Timeline) {
|
|||||||
return <MdFaceUnlock className="w-4 mr-1" />;
|
return <MdFaceUnlock className="w-4 mr-1" />;
|
||||||
case "car":
|
case "car":
|
||||||
return <MdOutlinePictureInPictureAlt className="w-4 mr-1" />;
|
return <MdOutlinePictureInPictureAlt className="w-4 mr-1" />;
|
||||||
|
default:
|
||||||
|
return <LuCircleDot className="w-4 mr-1" />;
|
||||||
}
|
}
|
||||||
|
case "heard":
|
||||||
|
return <LuEar className="w-4 mr-1" />;
|
||||||
|
case "external":
|
||||||
|
return <LuCircleDot className="w-4 mr-1" />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,5 +89,9 @@ export function getTimelineItemDescription(timelineItem: Timeline) {
|
|||||||
return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`;
|
return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`;
|
||||||
case "gone":
|
case "gone":
|
||||||
return `${label} left`;
|
return `${label} left`;
|
||||||
|
case "heard":
|
||||||
|
return `${label} heard`;
|
||||||
|
case "external":
|
||||||
|
return `${label} detected`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user