Add initial implementation of history view in new webui framework (#8895)

* Add support for review grid

* Cleanup reloading on focus

* Adjust timeline api to include metadata and before

* Be more efficient about getting info

* Adjust to new data format

* Cleanup types

* Cleanup text

* Transition to history

* Cleanup

* remove old web implementations

* Cleanup
This commit is contained in:
Nicolas Mowen 2023-12-12 19:48:52 -07:00 committed by Blake Blackshear
parent c1f14e2d87
commit 4524d9440c
13 changed files with 932 additions and 33 deletions

View File

@ -45,6 +45,12 @@ def should_update_state(prev_event: Event, current_event: Event) -> bool:
if prev_event["attributes"] != current_event["attributes"]: if prev_event["attributes"] != current_event["attributes"]:
return True return True
if prev_event["sub_label"] != current_event["sub_label"]:
return True
if len(prev_event["current_zones"]) < len(current_event["current_zones"]):
return True
return False return False

View File

@ -611,6 +611,78 @@ def timeline():
return jsonify([t for t in timeline]) return jsonify([t for t in timeline])
@bp.route("/timeline/hourly")
def hourly_timeline():
"""Get hourly summary for timeline."""
camera = request.args.get("camera", "all")
before = request.args.get("before", 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 before:
clauses.append((Timeline.timestamp < before))
if len(clauses) == 0:
clauses.append((True))
timeline = (
Timeline.select(
Timeline.camera,
Timeline.timestamp,
Timeline.data,
Timeline.class_type,
Timeline.source_id,
Timeline.source,
)
.where(reduce(operator.and_, clauses))
.order_by(Timeline.timestamp.desc())
.limit(limit)
.dicts()
.iterator()
)
count = 0
start = 0
end = 0
hours: dict[str, list[dict[str, any]]] = {}
for t in timeline:
if count == 0:
start = t["timestamp"]
else:
end = t["timestamp"]
count += 1
hour = (
datetime.fromtimestamp(t["timestamp"]).replace(
minute=0, second=0, microsecond=0
)
+ timedelta(
minutes=int(minute_modifier.split(" ")[0]),
)
).timestamp()
if hour not in hours:
hours[hour] = [t]
else:
hours[hour].insert(0, t)
return jsonify(
{
"start": start,
"end": end,
"count": count,
"hours": hours,
}
)
@bp.route("/<camera_name>/<label>/best.jpg") @bp.route("/<camera_name>/<label>/best.jpg")
@bp.route("/<camera_name>/<label>/thumbnail.jpg") @bp.route("/<camera_name>/<label>/thumbnail.jpg")
def label_thumbnail(camera_name, label): def label_thumbnail(camera_name, label):
@ -1863,17 +1935,27 @@ def vod_hour(year_month, day, hour, camera_name, tz_name):
@bp.route("/preview/<camera_name>/start/<float:start_ts>/end/<float:end_ts>") @bp.route("/preview/<camera_name>/start/<float:start_ts>/end/<float:end_ts>")
def preview_ts(camera_name, start_ts, end_ts): def preview_ts(camera_name, start_ts, end_ts):
"""Get all mp4 previews relevant for time period.""" """Get all mp4 previews relevant for time period."""
if camera_name != "all":
camera_clause = Previews.camera == camera_name
else:
camera_clause = True
previews = ( previews = (
Previews.select( Previews.select(
Previews.path, Previews.duration, Previews.start_time, Previews.end_time Previews.camera,
Previews.path,
Previews.duration,
Previews.start_time,
Previews.end_time,
) )
.where( .where(
Previews.start_time.between(start_ts, end_ts) Previews.start_time.between(start_ts, end_ts)
| Previews.end_time.between(start_ts, end_ts) | Previews.end_time.between(start_ts, end_ts)
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time)) | ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
) )
.where(Previews.camera == camera_name) .where(camera_clause)
.order_by(Previews.start_time.asc()) .order_by(Previews.start_time.asc())
.dicts()
.iterator() .iterator()
) )
@ -1883,15 +1965,15 @@ def preview_ts(camera_name, start_ts, end_ts):
for preview in previews: for preview in previews:
clips.append( clips.append(
{ {
"src": preview.path.replace("/media/frigate", ""), "camera": preview["camera"],
"src": preview["path"].replace("/media/frigate", ""),
"type": "video/mp4", "type": "video/mp4",
"start": preview.start_time, "start": preview["start_time"],
"end": preview.end_time, "end": preview["end_time"],
} }
) )
if not clips: if not clips:
logger.error("No previews found for the requested time range")
return make_response( return make_response(
jsonify( jsonify(
{ {
@ -1919,6 +2001,40 @@ def preview_hour(year_month, day, hour, camera_name, tz_name):
return preview_ts(camera_name, start_ts, end_ts) return preview_ts(camera_name, start_ts, end_ts)
@bp.route("/preview/<camera_name>/<frame_time>/thumbnail.jpg")
def preview_thumbnail(camera_name, frame_time):
"""Get a thumbnail from the cached preview jpgs."""
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
file_start = f"preview_{camera_name}"
file_check = f"{file_start}-{frame_time}.jpg"
selected_preview = None
for file in os.listdir(preview_dir):
if file.startswith(file_start):
if file < file_check:
selected_preview = file
break
if selected_preview is None:
return make_response(
jsonify(
{
"success": False,
"message": "Could not find valid preview jpg.",
}
),
404,
)
with open(os.path.join(preview_dir, selected_preview), "rb") as image_file:
jpg_bytes = image_file.read()
response = make_response(jpg_bytes)
response.headers["Content-Type"] = "image/jpeg"
response.headers["Cache-Control"] = "private, max-age=31536000"
return response
@bp.route("/vod/event/<id>") @bp.route("/vod/event/<id>")
def vod_event(id): def vod_event(id):
try: try:

View File

@ -29,6 +29,7 @@ class TimelineProcessor(threading.Thread):
self.config = config self.config = config
self.queue = queue self.queue = queue
self.stop_event = stop_event self.stop_event = stop_event
self.pre_event_cache: dict[str, list[dict[str, any]]] = {}
def run(self) -> None: def run(self) -> None:
while not self.stop_event.is_set(): while not self.stop_event.is_set():
@ -48,14 +49,39 @@ class TimelineProcessor(threading.Thread):
camera, event_type, prev_event_data, event_data camera, event_type, prev_event_data, event_data
) )
def insert_or_save(
self,
entry: dict[str, any],
prev_event_data: dict[any, any],
event_data: dict[any, any],
) -> None:
"""Insert into db or cache."""
id = entry[Timeline.source_id]
if not event_data["has_clip"] and not event_data["has_snapshot"]:
# the related event has not been saved yet, should be added to cache
if id in self.pre_event_cache.keys():
self.pre_event_cache[id].append(entry)
else:
self.pre_event_cache[id] = [entry]
else:
# the event is saved, insert to db and insert cached into db
if id in self.pre_event_cache.keys():
for e in self.pre_event_cache[id]:
Timeline.insert(e).execute()
self.pre_event_cache.pop(id)
Timeline.insert(entry).execute()
def handle_object_detection( def handle_object_detection(
self, self,
camera: str, camera: str,
event_type: str, event_type: str,
prev_event_data: dict[any, any], prev_event_data: dict[any, any],
event_data: dict[any, any], event_data: dict[any, any],
) -> None: ) -> bool:
"""Handle object detection.""" """Handle object detection."""
save = False
camera_config = self.config.cameras[camera] camera_config = self.config.cameras[camera]
timeline_entry = { timeline_entry = {
@ -70,6 +96,7 @@ class TimelineProcessor(threading.Thread):
event_data["box"], event_data["box"],
), ),
"label": event_data["label"], "label": event_data["label"],
"sub_label": event_data.get("sub_label"),
"region": to_relative_box( "region": to_relative_box(
camera_config.detect.width, camera_config.detect.width,
camera_config.detect.height, camera_config.detect.height,
@ -80,41 +107,36 @@ class TimelineProcessor(threading.Thread):
} }
if event_type == "start": if event_type == "start":
timeline_entry[Timeline.class_type] = "visible" timeline_entry[Timeline.class_type] = "visible"
Timeline.insert(timeline_entry).execute() save = True
elif event_type == "update": elif event_type == "update":
# zones have been updated
if ( if (
prev_event_data["current_zones"] != event_data["current_zones"] len(prev_event_data["current_zones"]) < len(event_data["current_zones"])
and len(event_data["current_zones"]) > 0
and not event_data["stationary"] and not event_data["stationary"]
): ):
timeline_entry[Timeline.class_type] = "entered_zone" timeline_entry[Timeline.class_type] = "entered_zone"
timeline_entry[Timeline.data]["zones"] = event_data["current_zones"] timeline_entry[Timeline.data]["zones"] = event_data["current_zones"]
Timeline.insert(timeline_entry).execute() save = True
elif prev_event_data["stationary"] != event_data["stationary"]: elif prev_event_data["stationary"] != event_data["stationary"]:
timeline_entry[Timeline.class_type] = ( timeline_entry[Timeline.class_type] = (
"stationary" if event_data["stationary"] else "active" "stationary" if event_data["stationary"] else "active"
) )
Timeline.insert(timeline_entry).execute() save = True
elif prev_event_data["attributes"] == {} and event_data["attributes"] != {}: elif prev_event_data["attributes"] == {} and event_data["attributes"] != {}:
timeline_entry[Timeline.class_type] = "attribute" timeline_entry[Timeline.class_type] = "attribute"
timeline_entry[Timeline.data]["attribute"] = list( timeline_entry[Timeline.data]["attribute"] = list(
event_data["attributes"].keys() event_data["attributes"].keys()
)[0] )[0]
Timeline.insert(timeline_entry).execute() save = True
elif not prev_event_data.get("sub_label") and event_data.get("sub_label"): elif not prev_event_data.get("sub_label") and event_data.get("sub_label"):
sub_label = event_data["sub_label"][0] sub_label = event_data["sub_label"][0]
if sub_label not in ALL_ATTRIBUTE_LABELS: if sub_label not in ALL_ATTRIBUTE_LABELS:
timeline_entry[Timeline.class_type] = "sub_label" timeline_entry[Timeline.class_type] = "sub_label"
timeline_entry[Timeline.data]["sub_label"] = sub_label timeline_entry[Timeline.data]["sub_label"] = sub_label
Timeline.insert(timeline_entry).execute() save = True
elif event_type == "end": elif event_type == "end":
if event_data["has_clip"] or event_data["has_snapshot"]:
timeline_entry[Timeline.class_type] = "gone" timeline_entry[Timeline.class_type] = "gone"
Timeline.insert(timeline_entry).execute() save = True
else:
# if event was not saved then the timeline entries should be deleted if save:
Timeline.delete().where( self.insert_or_save(timeline_entry, prev_event_data, event_data)
Timeline.source_id == event_data["id"]
).execute()

View File

@ -17,6 +17,7 @@
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
@ -1443,6 +1444,37 @@
} }
} }
}, },
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.5.tgz",
"integrity": "sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/number": "1.0.1",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-direction": "1.0.1",
"@radix-ui/react-presence": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-callback-ref": "1.0.1",
"@radix-ui/react-use-layout-effect": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": { "node_modules/@radix-ui/react-select": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz",

View File

@ -22,6 +22,7 @@
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",

View File

@ -0,0 +1,144 @@
import useSWR from "swr";
import PreviewThumbnailPlayer from "../player/PreviewThumbnailPlayer";
import { Card } from "../ui/card";
import { FrigateConfig } from "@/types/frigateConfig";
import ActivityIndicator from "../ui/activity-indicator";
import {
LuCircle,
LuClock,
LuPlay,
LuPlayCircle,
LuTruck,
} from "react-icons/lu";
import { IoMdExit } from "react-icons/io";
import {
MdFaceUnlock,
MdOutlineLocationOn,
MdOutlinePictureInPictureAlt,
} from "react-icons/md";
import { HiOutlineVideoCamera } from "react-icons/hi";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
type HistoryCardProps = {
timeline: Card;
allPreviews?: Preview[];
};
export default function HistoryCard({
allPreviews,
timeline,
}: HistoryCardProps) {
const { data: config } = useSWR<FrigateConfig>("config");
if (!config) {
return <ActivityIndicator />;
}
return (
<Card className="my-2 mr-2 bg-secondary">
<PreviewThumbnailPlayer
camera={timeline.camera}
allPreviews={allPreviews || []}
startTs={Object.values(timeline.entries)[0].timestamp}
/>
<div className="p-2">
<div className="text-sm flex">
<LuClock className="h-5 w-5 mr-2 inline" />
{formatUnixTimestampToDateTime(timeline.time, {
strftime_fmt:
config.ui.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S",
})}
</div>
<div className="capitalize text-sm flex align-center mt-1">
<HiOutlineVideoCamera className="h-5 w-5 mr-2 inline" />
{timeline.camera.replaceAll("_", " ")}
</div>
<div className="my-2 text-sm font-medium">Activity:</div>
{Object.entries(timeline.entries).map(([_, entry]) => {
return (
<div
key={entry.timestamp}
className="flex text-xs capitalize my-1 items-center"
>
{getTimelineIcon(entry)}
{getTimelineItemDescription(entry)}
</div>
);
})}
</div>
</Card>
);
}
function getTimelineIcon(timelineItem: Timeline) {
switch (timelineItem.class_type) {
case "visible":
return <LuPlay className="w-4 mr-1" />;
case "gone":
return <IoMdExit className="w-4 mr-1" />;
case "active":
return <LuPlayCircle className="w-4 mr-1" />;
case "stationary":
return <LuCircle className="w-4 mr-1" />;
case "entered_zone":
return <MdOutlineLocationOn className="w-4 mr-1" />;
case "attribute":
switch (timelineItem.data.attribute) {
case "face":
return <MdFaceUnlock className="w-4 mr-1" />;
case "license_plate":
return <MdOutlinePictureInPictureAlt className="w-4 mr-1" />;
default:
return <LuTruck className="w-4 mr-1" />;
}
case "sub_label":
switch (timelineItem.data.label) {
case "person":
return <MdFaceUnlock className="w-4 mr-1" />;
case "car":
return <MdOutlinePictureInPictureAlt className="w-4 mr-1" />;
}
}
}
function getTimelineItemDescription(timelineItem: Timeline) {
const label = (
(Array.isArray(timelineItem.data.sub_label)
? timelineItem.data.sub_label[0]
: timelineItem.data.sub_label) || timelineItem.data.label
).replaceAll("_", " ");
switch (timelineItem.class_type) {
case "visible":
return `${label} detected`;
case "entered_zone":
return `${label} entered ${timelineItem.data.zones
.join(" and ")
.replaceAll("_", " ")}`;
case "active":
return `${label} became active`;
case "stationary":
return `${label} became stationary`;
case "attribute": {
let title = "";
if (
timelineItem.data.attribute == "face" ||
timelineItem.data.attribute == "license_plate"
) {
title = `${timelineItem.data.attribute.replaceAll(
"_",
" "
)} detected for ${label}`;
} else {
title = `${
timelineItem.data.sub_label
} recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}`;
}
return title;
}
case "sub_label":
return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`;
case "gone":
return `${label} left`;
}
}

View File

@ -0,0 +1,99 @@
import { FrigateConfig } from "@/types/frigateConfig";
import VideoPlayer from "./VideoPlayer";
import useSWR from "swr";
import { useCallback, useMemo, useRef } from "react";
import { useApiHost } from "@/api";
import Player from "video.js/dist/types/player";
type PreviewPlayerProps = {
camera: string,
allPreviews: Preview[],
startTs: number,
}
type Preview = {
camera: string,
src: string,
type: string,
start: number,
end: number,
}
export default function PreviewThumbnailPlayer({ camera, allPreviews, startTs }: PreviewPlayerProps) {
const { data: config } = useSWR('config');
const playerRef = useRef<Player | null>(null);
const apiHost = useApiHost();
const relevantPreview = useMemo(() => {
return Object.values(allPreviews || []).find(
(preview) => preview.camera == camera && preview.start < startTs && preview.end > startTs
);
}, [allPreviews, camera, startTs]);
const onHover = useCallback((isHovered: Boolean) => {
if (!relevantPreview || !playerRef.current) {
return;
}
if (isHovered) {
playerRef.current.play();
} else {
playerRef.current.pause();
playerRef.current.currentTime(startTs - relevantPreview.start);
}
},
[relevantPreview, startTs]
);
if (!relevantPreview) {
return (
<img className={getThumbWidth(camera, config)} src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`} />
);
}
return (
<div
className={getThumbWidth(camera, config)}
onMouseEnter={() => onHover(true)}
onMouseLeave={() => onHover(false)}
>
<VideoPlayer
options={{
preload: 'auto',
autoplay: false,
controls: false,
muted: true,
loadingSpinner: false,
sources: [
{
src: `${relevantPreview.src}`,
type: 'video/mp4',
},
],
}}
seekOptions={{}}
onReady={(player) => {
playerRef.current = player;
player.playbackRate(8);
player.currentTime(startTs - relevantPreview.start);
}}
onDispose={() => {
playerRef.current = null;
}}
/>
</div>
);
}
function getThumbWidth(camera: string, config: FrigateConfig) {
const detect = config.cameras[camera].detect;
if (detect.width / detect.height > 2) {
return 'w-[320px]';
}
if (detect.width / detect.height < 1.4) {
return 'w-[200px]';
}
return 'w-[240px]';
}

View File

@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -1,9 +1,169 @@
import { useMemo, useState } from "react";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import ActivityIndicator from "@/components/ui/activity-indicator";
import HistoryCard from "@/components/card/HistoryCard";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
function History() { function History() {
const { data: config } = useSWR<FrigateConfig>("config");
const timezone = useMemo(
() =>
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
[config]
);
const { data: hourlyTimeline } = useSWR<HourlyTimeline>([
"timeline/hourly",
{ timezone },
]);
const { data: allPreviews } = useSWR<Preview[]>(
`preview/all/start/${hourlyTimeline?.start || 0}/end/${
hourlyTimeline?.end || 0
}`,
{ revalidateOnFocus: false }
);
const [detailLevel, setDetailLevel] = useState<"normal" | "extra" | "full">(
"normal"
);
const timelineCards: CardsData | never[] = useMemo(() => {
if (!hourlyTimeline) {
return [];
}
const cards: CardsData = {};
Object.keys(hourlyTimeline["hours"])
.reverse()
.forEach((hour) => {
const day = new Date(parseInt(hour) * 1000);
day.setHours(0, 0, 0, 0);
const dayKey = (day.getTime() / 1000).toString();
const source_to_types: { [key: string]: string[] } = {};
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
const time = new Date(i.timestamp * 1000);
time.setSeconds(0);
time.setMilliseconds(0);
const key = `${i.source_id}-${time.getMinutes()}`;
if (key in source_to_types) {
source_to_types[key].push(i.class_type);
} else {
source_to_types[key] = [i.class_type];
}
});
if (!Object.keys(cards).includes(dayKey)) {
cards[dayKey] = {};
}
cards[dayKey][hour] = {};
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
const time = new Date(i.timestamp * 1000);
const key = `${i.camera}-${time.getMinutes()}`;
// detail level for saving items
// detail level determines which timeline items for each moment is returned
// values can be normal, extra, or full
// normal: return all items except active / attribute / gone / stationary / visible unless that is the only item.
// extra: return all items except attribute / gone / visible unless that is the only item
// full: return all items
let add = true;
if (detailLevel == "normal") {
if (
source_to_types[`${i.source_id}-${time.getMinutes()}`].length >
1 &&
["active", "attribute", "gone", "stationary", "visible"].includes(
i.class_type
)
) {
add = false;
}
} else if (detailLevel == "extra") {
if (
source_to_types[`${i.source_id}-${time.getMinutes()}`].length >
1 &&
i.class_type in ["attribute", "gone", "visible"]
) {
add = false;
}
}
if (add) {
if (key in cards[dayKey][hour]) {
cards[dayKey][hour][key].entries.push(i);
} else {
cards[dayKey][hour][key] = {
camera: i.camera,
time: time.getTime() / 1000,
entries: [i],
};
}
}
});
});
return cards;
}, [detailLevel, hourlyTimeline]);
if (!config || !timelineCards) {
return <ActivityIndicator />;
}
return ( return (
<> <>
<Heading as="h2">History</Heading> <Heading as="h2">Review</Heading>
<div className="text-xs mb-4">
Dates and times are based on the timezone {timezone}
</div>
<div>
{Object.entries(timelineCards)
.reverse()
.map(([day, timelineDay]) => {
return (
<div key={day}>
<Heading as="h3">
{formatUnixTimestampToDateTime(parseInt(day), {
strftime_fmt: "%A %b %d",
})}
</Heading>
{Object.entries(timelineDay).map(([hour, timelineHour]) => {
if (Object.values(timelineHour).length == 0) {
return <></>;
}
return (
<div key={hour}>
<Heading as="h4">
{formatUnixTimestampToDateTime(parseInt(hour), {
strftime_fmt: "%I:00",
})}
</Heading>
<ScrollArea>
<div className="flex">
{Object.entries(timelineHour).map(
([key, timeline]) => {
return (
<HistoryCard
key={key}
timeline={timeline}
allPreviews={allPreviews}
/>
);
}
)}
</div>
<ScrollBar className="m-2" orientation="horizontal" />
</ScrollArea>
</div>
);
})}
</div>
);
})}
</div>
</> </>
); );
} }

View File

@ -1,3 +1,13 @@
interface UiConfig {
timezone: string;
time_format: 'browser' | '12hour' | '24hour';
date_style: 'full' | 'long' | 'medium' | 'short';
time_style: 'full' | 'long' | 'medium' | 'short';
strftime_fmt: string;
live_mode: string;
use_experimental: boolean;
}
export interface FrigateConfig { export interface FrigateConfig {
audio: { audio: {
enabled: boolean; enabled: boolean;
@ -389,14 +399,6 @@ export interface FrigateConfig {
thickness: number; thickness: number;
}; };
ui: { ui: UiConfig;
date_style: string;
live_mode: string;
strftime_fmt: string | null;
time_format: string;
time_style: string;
timezone: string | null;
use_experimental: boolean;
};
} }

View File

@ -0,0 +1,39 @@
type CardsData = {
[key: string]: {
[key: string]: {
[key: string]: Card
}
}
}
type Card = {
camera: string,
time: number,
entries: Timeline[],
}
type Preview = {
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,
}
type HourlyTimeline = {
start: number,
end: number,
count: number,
hours: { [key: string]: Timeline[] };
}

View File

@ -0,0 +1,229 @@
import strftime from 'strftime';
import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns';
import { FrigateConfig, UiConfig } from "@/types/frigateConfig";
export const longToDate = (long: number): Date => new Date(long * 1000);
export const epochToLong = (date: number): number => date / 1000;
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
const getDateTimeYesterday = (dateTime: Date): Date => {
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
};
const getNowYesterday = (): Date => {
return getDateTimeYesterday(new Date());
};
export const getNowYesterdayInLong = (): number => {
return dateToLong(getNowYesterday());
};
/**
* This function takes in a Unix timestamp, configuration options for date/time display, and an optional strftime format string,
* and returns a formatted date/time string.
*
* If the Unix timestamp is not provided, it returns "Invalid time".
*
* The configuration options determine how the date and time are formatted.
* The `timezone` option allows you to specify a specific timezone for the output, otherwise the user's browser timezone will be used.
* The `use12hour` option allows you to display time in a 12-hour format if true, and 24-hour format if false.
* The `dateStyle` and `timeStyle` options allow you to specify pre-defined formats for displaying the date and time.
* The `strftime_fmt` option allows you to specify a custom format using the strftime syntax.
*
* If both `strftime_fmt` and `dateStyle`/`timeStyle` are provided, `strftime_fmt` takes precedence.
*
* @param unixTimestamp The Unix timestamp to format
* @param config An object containing the configuration options for date/time display
* @returns The formatted date/time string, or "Invalid time" if the Unix timestamp is not provided or invalid.
*/
// only used as a fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
const formatMap: {
[k: string]: {
date: { year: 'numeric' | '2-digit'; month: 'long' | 'short' | '2-digit'; day: 'numeric' | '2-digit' };
time: { hour: 'numeric'; minute: 'numeric'; second?: 'numeric'; timeZoneName?: 'short' | 'long' };
};
} = {
full: {
date: { year: 'numeric', month: 'long', day: 'numeric' },
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' },
},
long: {
date: { year: 'numeric', month: 'long', day: 'numeric' },
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' },
},
medium: {
date: { year: 'numeric', month: 'short', day: 'numeric' },
time: { hour: 'numeric', minute: 'numeric', second: 'numeric' },
},
short: { date: { year: '2-digit', month: '2-digit', day: '2-digit' }, time: { hour: 'numeric', minute: 'numeric' } },
};
/**
* Attempts to get the system's time zone using Intl.DateTimeFormat. If that fails (for instance, in environments
* where Intl is not fully supported), it calculates the UTC offset for the current system time and returns
* it in a string format.
*
* Keeping the Intl.DateTimeFormat for now, as this is the recommended way to get the time zone.
* https://stackoverflow.com/a/34602679
*
* Intl.DateTimeFormat function as of April 2023, works in 95.03% of the browsers used globally
* https://caniuse.com/mdn-javascript_builtins_intl_datetimeformat_resolvedoptions_computed_timezone
*
* @returns {string} The resolved time zone or a calculated UTC offset.
* The returned string will either be a named time zone (e.g., "America/Los_Angeles"), or it will follow
* the format "UTC±HH:MM".
*/
const getResolvedTimeZone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (error) {
const offsetMinutes = new Date().getTimezoneOffset();
return `UTC${offsetMinutes < 0 ? '+' : '-'}${Math.abs(offsetMinutes / 60)
.toString()
.padStart(2, '0')}:${Math.abs(offsetMinutes % 60)
.toString()
.padStart(2, '0')}`;
}
};
/**
* Formats a Unix timestamp into a human-readable date/time string.
*
* The format of the output string is determined by a configuration object passed as an argument, which
* may specify a time zone, 12- or 24-hour time, and various stylistic options for the date and time.
* If these options are not specified, the function will use system defaults or sensible fallbacks.
*
* The function is robust to environments where the Intl API is not fully supported, and includes a
* fallback method to create a formatted date/time string in such cases.
*
* @param {number} unixTimestamp - The Unix timestamp to be formatted.
* @param {DateTimeStyle} config - User configuration object.
* @returns {string} A formatted date/time string.
*
* @throws {Error} If the given unixTimestamp is not a valid number, the function will return 'Invalid time'.
*/
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiConfig): string => {
const { timezone, time_format, date_style, time_style, strftime_fmt } = config;
const locale = window.navigator?.language || 'en-us';
if (isNaN(unixTimestamp)) {
return 'Invalid time';
}
try {
const date = new Date(unixTimestamp * 1000);
const resolvedTimeZone = getResolvedTimeZone();
// use strftime_fmt if defined in config
if (strftime_fmt) {
const offset = getUTCOffset(date, timezone || resolvedTimeZone);
const strftime_locale = strftime.timezone(offset).localizeByIdentifier(locale);
return strftime_locale(strftime_fmt, date);
}
// DateTime format options
const options: Intl.DateTimeFormatOptions = {
dateStyle: date_style,
timeStyle: time_style,
hour12: time_format !== 'browser' ? time_format == '12hour' : undefined,
};
// Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config
const isUTCOffsetFormat = /^UTC[+-]\d{2}:\d{2}$/.test(resolvedTimeZone);
if (timezone || !isUTCOffsetFormat) {
options.timeZone = timezone || resolvedTimeZone;
}
const formatter = new Intl.DateTimeFormat(locale, options);
const formattedDateTime = formatter.format(date);
// Regex to check for existence of time. This is needed because dateStyle/timeStyle is not always supported.
const containsTime = /\d{1,2}:\d{1,2}/.test(formattedDateTime);
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
// This works even tough the timezone is undefined, it will use the runtime's default time zone
if (!containsTime) {
const dateOptions = { ...formatMap[date_style]?.date, timeZone: options.timeZone, hour12: options.hour12 };
const timeOptions = { ...formatMap[time_style]?.time, timeZone: options.timeZone, hour12: options.hour12 };
return `${date.toLocaleDateString(locale, dateOptions)} ${date.toLocaleTimeString(locale, timeOptions)}`;
}
return formattedDateTime;
} catch (error) {
return 'Invalid time';
}
};
interface DurationToken {
xSeconds: string;
xMinutes: string;
xHours: string;
}
/**
* This function takes in start and end time in unix timestamp,
* and returns the duration between start and end time in hours, minutes and seconds.
* If end time is not provided, it returns 'In Progress'
* @param start_time: number - Unix timestamp for start time
* @param end_time: number|null - Unix timestamp for end time
* @returns string - duration or 'In Progress' if end time is not provided
*/
export const getDurationFromTimestamps = (start_time: number, end_time: number | null): string => {
if (isNaN(start_time)) {
return 'Invalid start time';
}
let duration = 'In Progress';
if (end_time !== null) {
if (isNaN(end_time)) {
return 'Invalid end time';
}
const start = fromUnixTime(start_time);
const end = fromUnixTime(end_time);
const formatDistanceLocale: DurationToken = {
xSeconds: '{{count}}s',
xMinutes: '{{count}}m',
xHours: '{{count}}h',
};
const shortEnLocale = {
formatDistance: (token: keyof DurationToken, count: number) =>
formatDistanceLocale[token].replace('{{count}}', count.toString()),
};
duration = formatDuration(intervalToDuration({ start, end }), {
format: ['hours', 'minutes', 'seconds'],
locale: shortEnLocale,
});
}
return duration;
};
/**
* Adapted from https://stackoverflow.com/a/29268535 this takes a timezone string and
* returns the offset of that timezone from UTC in minutes.
* @param timezone string representation of the timezone the user is requesting
* @returns number of minutes offset from UTC
*/
const getUTCOffset = (date: Date, timezone: string): number => {
// If timezone is in UTC±HH:MM format, parse it to get offset
const utcOffsetMatch = timezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
if (utcOffsetMatch) {
const hours = parseInt(utcOffsetMatch[2], 10);
const minutes = parseInt(utcOffsetMatch[3], 10);
return (utcOffsetMatch[1] === '+' ? 1 : -1) * (hours * 60 + minutes);
}
// Otherwise, calculate offset using provided timezone
const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
// locale of en-CA is required for proper locale format
let iso = utcDate.toLocaleString('en-CA', { timeZone: timezone, hour12: false }).replace(', ', 'T');
iso += `.${utcDate.getMilliseconds().toString().padStart(3, '0')}`;
let target = new Date(`${iso}Z`);
// safari doesn't like the default format
if (isNaN(target.getTime())) {
iso = iso.replace("T", " ").split(".")[0];
target = new Date(`${iso}+000`);
}
return (target.getTime() - utcDate.getTime()) / 60 / 1000;
};

View File

@ -18,6 +18,9 @@ export default defineConfig({
'/vod': { '/vod': {
target: 'http://localhost:5000' target: 'http://localhost:5000'
}, },
'/clips': {
target: 'http://localhost:5000'
},
'/exports': { '/exports': {
target: 'http://localhost:5000' target: 'http://localhost:5000'
}, },