mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
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:
parent
c1f14e2d87
commit
4524d9440c
@ -45,6 +45,12 @@ def should_update_state(prev_event: Event, current_event: Event) -> bool:
|
||||
if prev_event["attributes"] != current_event["attributes"]:
|
||||
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
|
||||
|
||||
|
||||
|
128
frigate/http.py
128
frigate/http.py
@ -611,6 +611,78 @@ def 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>/thumbnail.jpg")
|
||||
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>")
|
||||
def preview_ts(camera_name, start_ts, end_ts):
|
||||
"""Get all mp4 previews relevant for time period."""
|
||||
if camera_name != "all":
|
||||
camera_clause = Previews.camera == camera_name
|
||||
else:
|
||||
camera_clause = True
|
||||
|
||||
previews = (
|
||||
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(
|
||||
Previews.start_time.between(start_ts, end_ts)
|
||||
| Previews.end_time.between(start_ts, end_ts)
|
||||
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
||||
)
|
||||
.where(Previews.camera == camera_name)
|
||||
.where(camera_clause)
|
||||
.order_by(Previews.start_time.asc())
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
@ -1883,15 +1965,15 @@ def preview_ts(camera_name, start_ts, end_ts):
|
||||
for preview in previews:
|
||||
clips.append(
|
||||
{
|
||||
"src": preview.path.replace("/media/frigate", ""),
|
||||
"camera": preview["camera"],
|
||||
"src": preview["path"].replace("/media/frigate", ""),
|
||||
"type": "video/mp4",
|
||||
"start": preview.start_time,
|
||||
"end": preview.end_time,
|
||||
"start": preview["start_time"],
|
||||
"end": preview["end_time"],
|
||||
}
|
||||
)
|
||||
|
||||
if not clips:
|
||||
logger.error("No previews found for the requested time range")
|
||||
return make_response(
|
||||
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)
|
||||
|
||||
|
||||
@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>")
|
||||
def vod_event(id):
|
||||
try:
|
||||
|
@ -29,6 +29,7 @@ class TimelineProcessor(threading.Thread):
|
||||
self.config = config
|
||||
self.queue = queue
|
||||
self.stop_event = stop_event
|
||||
self.pre_event_cache: dict[str, list[dict[str, any]]] = {}
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
@ -48,14 +49,39 @@ class TimelineProcessor(threading.Thread):
|
||||
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(
|
||||
self,
|
||||
camera: str,
|
||||
event_type: str,
|
||||
prev_event_data: dict[any, any],
|
||||
event_data: dict[any, any],
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""Handle object detection."""
|
||||
save = False
|
||||
camera_config = self.config.cameras[camera]
|
||||
|
||||
timeline_entry = {
|
||||
@ -70,6 +96,7 @@ class TimelineProcessor(threading.Thread):
|
||||
event_data["box"],
|
||||
),
|
||||
"label": event_data["label"],
|
||||
"sub_label": event_data.get("sub_label"),
|
||||
"region": to_relative_box(
|
||||
camera_config.detect.width,
|
||||
camera_config.detect.height,
|
||||
@ -80,41 +107,36 @@ class TimelineProcessor(threading.Thread):
|
||||
}
|
||||
if event_type == "start":
|
||||
timeline_entry[Timeline.class_type] = "visible"
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
save = True
|
||||
elif event_type == "update":
|
||||
# zones have been updated
|
||||
if (
|
||||
prev_event_data["current_zones"] != event_data["current_zones"]
|
||||
and len(event_data["current_zones"]) > 0
|
||||
len(prev_event_data["current_zones"]) < len(event_data["current_zones"])
|
||||
and not event_data["stationary"]
|
||||
):
|
||||
timeline_entry[Timeline.class_type] = "entered_zone"
|
||||
timeline_entry[Timeline.data]["zones"] = event_data["current_zones"]
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
save = True
|
||||
elif prev_event_data["stationary"] != event_data["stationary"]:
|
||||
timeline_entry[Timeline.class_type] = (
|
||||
"stationary" if event_data["stationary"] else "active"
|
||||
)
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
save = True
|
||||
elif prev_event_data["attributes"] == {} and event_data["attributes"] != {}:
|
||||
timeline_entry[Timeline.class_type] = "attribute"
|
||||
timeline_entry[Timeline.data]["attribute"] = list(
|
||||
event_data["attributes"].keys()
|
||||
)[0]
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
save = True
|
||||
elif not prev_event_data.get("sub_label") and event_data.get("sub_label"):
|
||||
sub_label = event_data["sub_label"][0]
|
||||
|
||||
if sub_label not in ALL_ATTRIBUTE_LABELS:
|
||||
timeline_entry[Timeline.class_type] = "sub_label"
|
||||
timeline_entry[Timeline.data]["sub_label"] = sub_label
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
save = True
|
||||
elif event_type == "end":
|
||||
if event_data["has_clip"] or event_data["has_snapshot"]:
|
||||
timeline_entry[Timeline.class_type] = "gone"
|
||||
Timeline.insert(timeline_entry).execute()
|
||||
else:
|
||||
# if event was not saved then the timeline entries should be deleted
|
||||
Timeline.delete().where(
|
||||
Timeline.source_id == event_data["id"]
|
||||
).execute()
|
||||
timeline_entry[Timeline.class_type] = "gone"
|
||||
save = True
|
||||
|
||||
if save:
|
||||
self.insert_or_save(timeline_entry, prev_event_data, event_data)
|
||||
|
32
web-new/package-lock.json
generated
32
web-new/package-lock.json
generated
@ -17,6 +17,7 @@
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@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-slider": "^1.1.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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz",
|
||||
|
@ -22,6 +22,7 @@
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@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-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
|
144
web-new/src/components/card/HistoryCard.tsx
Normal file
144
web-new/src/components/card/HistoryCard.tsx
Normal 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`;
|
||||
}
|
||||
}
|
99
web-new/src/components/player/PreviewThumbnailPlayer.tsx
Normal file
99
web-new/src/components/player/PreviewThumbnailPlayer.tsx
Normal 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]';
|
||||
}
|
46
web-new/src/components/ui/scroll-area.tsx
Normal file
46
web-new/src/components/ui/scroll-area.tsx
Normal 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 }
|
@ -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 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() {
|
||||
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 (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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 {
|
||||
audio: {
|
||||
enabled: boolean;
|
||||
@ -389,14 +399,6 @@ export interface FrigateConfig {
|
||||
thickness: number;
|
||||
};
|
||||
|
||||
ui: {
|
||||
date_style: string;
|
||||
live_mode: string;
|
||||
strftime_fmt: string | null;
|
||||
time_format: string;
|
||||
time_style: string;
|
||||
timezone: string | null;
|
||||
use_experimental: boolean;
|
||||
};
|
||||
ui: UiConfig;
|
||||
|
||||
}
|
39
web-new/src/types/history.ts
Normal file
39
web-new/src/types/history.ts
Normal 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[] };
|
||||
}
|
229
web-new/src/utils/dateUtil.ts
Normal file
229
web-new/src/utils/dateUtil.ts
Normal 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;
|
||||
};
|
@ -18,6 +18,9 @@ export default defineConfig({
|
||||
'/vod': {
|
||||
target: 'http://localhost:5000'
|
||||
},
|
||||
'/clips': {
|
||||
target: 'http://localhost:5000'
|
||||
},
|
||||
'/exports': {
|
||||
target: 'http://localhost:5000'
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user