mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-03 04:10:33 +03:00
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
The History view always opened on Timeline unless the link carried a tab. The selected tab is now saved with useUserPersistence and used as the default when no tab is passed. Notification and shared review links no longer default to "timeline", so they pick up the saved tab too, and `?tab=` still overrides it.
755 lines
20 KiB
TypeScript
755 lines
20 KiB
TypeScript
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
import useApiFilter from "@/hooks/use-api-filter";
|
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
|
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
|
import { useTimezone } from "@/hooks/use-date-utils";
|
|
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
|
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
import { RecordingStartingPoint } from "@/types/record";
|
|
import {
|
|
RecordingsSummary,
|
|
REVIEW_PADDING,
|
|
ReviewFilter,
|
|
ReviewSegment,
|
|
ReviewSeverity,
|
|
ReviewSummary,
|
|
SegmentedReviewData,
|
|
} from "@/types/review";
|
|
import { TimelineType } from "@/types/timeline";
|
|
import {
|
|
getBeginningOfDayTimestamp,
|
|
getEndOfDayTimestamp,
|
|
} from "@/utils/dateUtil";
|
|
import {
|
|
parseRecordingReviewLink,
|
|
RECORDING_REVIEW_LINK_PARAM,
|
|
} from "@/utils/recordingReviewUrl";
|
|
import EventView from "@/views/events/EventView";
|
|
import MotionSearchView from "@/views/motion-search/MotionSearchView";
|
|
import { RecordingView } from "@/views/recording/RecordingView";
|
|
import { useFrigateReviews } from "@/api/ws";
|
|
import axios from "axios";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import useSWR from "swr";
|
|
|
|
export default function Events() {
|
|
const { t } = useTranslation(["views/events"]);
|
|
|
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
|
revalidateOnFocus: false,
|
|
});
|
|
const timezone = useTimezone(config);
|
|
const allowedCameras = useAllowedCameras();
|
|
|
|
// recordings viewer
|
|
|
|
const [severity, setSeverity] = useOverlayState<ReviewSeverity>(
|
|
"severity",
|
|
"alert",
|
|
);
|
|
|
|
const [showReviewed, setShowReviewed] = useUserPersistence(
|
|
"showReviewed",
|
|
false,
|
|
);
|
|
|
|
const [recording, setRecording] = useOverlayState<
|
|
RecordingStartingPoint | undefined
|
|
>("recording", undefined, false);
|
|
const [motionPreviewsCamera, setMotionPreviewsCamera] = useOverlayState<
|
|
string | undefined
|
|
>("motionPreviewsCamera", undefined);
|
|
|
|
const [motionSearchCamera, setMotionSearchCamera] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [motionSearchDay, setMotionSearchDay] = useState<Date | undefined>(
|
|
undefined,
|
|
);
|
|
|
|
const reviewCameras = useMemo(() => {
|
|
if (!config?.cameras) {
|
|
return [] as string[];
|
|
}
|
|
|
|
return Object.values(config.cameras)
|
|
.filter(
|
|
(conf) =>
|
|
allowedCameras.includes(conf.name) && conf.ui?.review !== false,
|
|
)
|
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order)
|
|
.map((conf) => conf.name);
|
|
}, [allowedCameras, config?.cameras]);
|
|
|
|
const selectedMotionSearchCamera = useMemo(() => {
|
|
if (!motionSearchCamera) {
|
|
return null;
|
|
}
|
|
|
|
if (reviewCameras.includes(motionSearchCamera)) {
|
|
return motionSearchCamera;
|
|
}
|
|
|
|
return reviewCameras[0] ?? null;
|
|
}, [motionSearchCamera, reviewCameras]);
|
|
|
|
const motionSearchTimeRange = useMemo(() => {
|
|
if (motionSearchDay) {
|
|
return {
|
|
after: getBeginningOfDayTimestamp(new Date(motionSearchDay)),
|
|
before: getEndOfDayTimestamp(new Date(motionSearchDay)),
|
|
};
|
|
}
|
|
|
|
const now = Date.now() / 1000;
|
|
return {
|
|
after: now - 86400,
|
|
before: now,
|
|
};
|
|
}, [motionSearchDay]);
|
|
|
|
const closeMotionSearch = useCallback(() => {
|
|
setMotionSearchCamera(null);
|
|
setMotionSearchDay(undefined);
|
|
setBeforeTs(Date.now() / 1000);
|
|
}, []);
|
|
|
|
const handleMotionSearchCameraSelect = useCallback((camera: string) => {
|
|
setMotionSearchCamera(camera);
|
|
}, []);
|
|
|
|
const handleMotionSearchDaySelect = useCallback((day: Date | undefined) => {
|
|
if (day == undefined) {
|
|
setMotionSearchDay(undefined);
|
|
return;
|
|
}
|
|
|
|
const normalizedDay = new Date(day);
|
|
normalizedDay.setHours(0, 0, 0, 0);
|
|
setMotionSearchDay(normalizedDay);
|
|
}, []);
|
|
|
|
const [notificationTab, setNotificationTab] = useState<TimelineType>();
|
|
|
|
const getReviewDayBounds = useCallback((date: Date) => {
|
|
const now = Date.now() / 1000;
|
|
return {
|
|
after: getBeginningOfDayTimestamp(date),
|
|
before: Math.min(getEndOfDayTimestamp(date), now),
|
|
};
|
|
}, []);
|
|
|
|
useSearchEffect("tab", (tab: string) => {
|
|
if (tab === "timeline" || tab === "events" || tab === "detail") {
|
|
setNotificationTab(tab as TimelineType);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
useSearchEffect("id", (reviewId: string) => {
|
|
axios
|
|
.get(`review/${reviewId}`)
|
|
.then((resp) => {
|
|
if (resp.status == 200 && resp.data) {
|
|
const startTime = resp.data.start_time - REVIEW_PADDING;
|
|
const date = new Date(startTime * 1000);
|
|
|
|
setReviewFilter(getReviewDayBounds(date));
|
|
setRecording(
|
|
{
|
|
camera: resp.data.camera,
|
|
startTime,
|
|
severity: resp.data.severity,
|
|
timelineType: notificationTab,
|
|
},
|
|
true,
|
|
);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
|
|
return true;
|
|
});
|
|
|
|
const [startTime, setStartTime] = useState<number>();
|
|
|
|
useEffect(() => {
|
|
if (recording) {
|
|
document.title = t("recordings.documentTitle");
|
|
} else {
|
|
document.title = t("documentTitle");
|
|
}
|
|
}, [recording, severity, t]);
|
|
|
|
// review filter
|
|
|
|
const [reviewFilter, setReviewFilter, reviewSearchParams] =
|
|
useApiFilter<ReviewFilter>();
|
|
|
|
useSearchEffect("cameras", (cameras: string) => {
|
|
setReviewFilter({
|
|
...reviewFilter,
|
|
cameras: cameras.includes(",") ? cameras.split(",") : [cameras],
|
|
});
|
|
return true;
|
|
});
|
|
|
|
const reviewCamerasParam = useMemo(() => {
|
|
const selected: string | undefined = reviewSearchParams["cameras"];
|
|
|
|
if (!selected) {
|
|
return reviewCameras.join(",");
|
|
}
|
|
|
|
const selectedCameras = new Set(selected.split(","));
|
|
return reviewCameras
|
|
.filter((camera) => selectedCameras.has(camera))
|
|
.join(",");
|
|
}, [reviewCameras, reviewSearchParams]);
|
|
|
|
useSearchEffect("labels", (labels: string) => {
|
|
setReviewFilter({
|
|
...reviewFilter,
|
|
labels: labels.includes(",") ? labels.split(",") : [labels],
|
|
});
|
|
return true;
|
|
});
|
|
|
|
useSearchEffect("zones", (zones: string) => {
|
|
setReviewFilter({
|
|
...reviewFilter,
|
|
zones: zones.includes(",") ? zones.split(",") : [zones],
|
|
});
|
|
return true;
|
|
});
|
|
|
|
useSearchEffect("group", (reviewGroup) => {
|
|
if (config && reviewGroup && reviewGroup != "default") {
|
|
const group = config.camera_groups[reviewGroup];
|
|
const isBirdseyeOnly =
|
|
group.cameras.length == 1 && group.cameras[0] == "birdseye";
|
|
|
|
if (group && !isBirdseyeOnly) {
|
|
setReviewFilter({
|
|
...reviewFilter,
|
|
cameras: group.cameras,
|
|
});
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
const onUpdateFilter = useCallback(
|
|
(newFilter: ReviewFilter) => {
|
|
setReviewFilter(newFilter);
|
|
|
|
// update recording start time if filter
|
|
// was changed on recording page
|
|
if (recording != undefined && newFilter.after != undefined) {
|
|
setRecording({ ...recording, startTime: newFilter.after }, true);
|
|
}
|
|
},
|
|
[recording, setRecording, setReviewFilter],
|
|
);
|
|
|
|
useSearchEffect(RECORDING_REVIEW_LINK_PARAM, (reviewLinkValue: string) => {
|
|
if (!config) {
|
|
return false;
|
|
}
|
|
|
|
const reviewLink = parseRecordingReviewLink(reviewLinkValue);
|
|
|
|
if (!reviewLink) {
|
|
toast.error(t("recordings.invalidSharedLink"), {
|
|
position: "top-center",
|
|
});
|
|
return true;
|
|
}
|
|
|
|
const validCamera =
|
|
config.cameras[reviewLink.camera] &&
|
|
allowedCameras.includes(reviewLink.camera);
|
|
|
|
if (!validCamera) {
|
|
toast.error(t("recordings.invalidSharedCamera"), {
|
|
position: "top-center",
|
|
});
|
|
return true;
|
|
}
|
|
|
|
setReviewFilter({
|
|
...reviewFilter,
|
|
...getReviewDayBounds(new Date(reviewLink.timestamp * 1000)),
|
|
});
|
|
setRecording(
|
|
{
|
|
camera: reviewLink.camera,
|
|
startTime: reviewLink.timestamp,
|
|
// severity not actually applicable here, but the type requires it
|
|
// this pattern is also used LiveCameraView to enter recording view
|
|
severity: "alert",
|
|
timelineType: notificationTab,
|
|
navigationSource: "shared-link",
|
|
},
|
|
true,
|
|
);
|
|
|
|
return true;
|
|
});
|
|
|
|
// review paging
|
|
|
|
const [beforeTs, setBeforeTs] = useState(Math.ceil(Date.now() / 1000));
|
|
const last24Hours = useMemo(() => {
|
|
return { before: beforeTs, after: getHoursAgo(24) };
|
|
}, [beforeTs]);
|
|
const selectedTimeRange = useMemo(() => {
|
|
if (reviewSearchParams["after"] == undefined) {
|
|
return last24Hours;
|
|
}
|
|
|
|
return {
|
|
before: Math.ceil(reviewSearchParams["before"]),
|
|
after: Math.floor(reviewSearchParams["after"]),
|
|
};
|
|
}, [last24Hours, reviewSearchParams]);
|
|
|
|
// we want to update the items whenever the severity changes
|
|
useEffect(() => {
|
|
if (recording) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now() / 1000;
|
|
|
|
if (now - beforeTs > 60) {
|
|
setBeforeTs(now);
|
|
}
|
|
|
|
// only refresh when severity changes
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [severity]);
|
|
|
|
const reviewSegmentFetcher = useCallback((key: Array<string> | string) => {
|
|
const [path, params] = Array.isArray(key) ? key : [key, undefined];
|
|
return axios.get(path, { params }).then((res) => res.data);
|
|
}, []);
|
|
|
|
const getKey = useCallback(() => {
|
|
if (!timezone) {
|
|
return null;
|
|
}
|
|
|
|
const params = {
|
|
cameras: reviewCamerasParam,
|
|
labels: reviewSearchParams["labels"],
|
|
zones: reviewSearchParams["zones"],
|
|
reviewed: null, // We want both reviewed and unreviewed items as we filter in the UI
|
|
before: reviewSearchParams["before"] || last24Hours.before,
|
|
after: reviewSearchParams["after"] || last24Hours.after,
|
|
};
|
|
return ["review", params];
|
|
}, [reviewSearchParams, reviewCamerasParam, last24Hours, timezone]);
|
|
|
|
const { data: reviews, mutate: updateSegments } = useSWR<ReviewSegment[]>(
|
|
getKey,
|
|
reviewSegmentFetcher,
|
|
{
|
|
revalidateOnFocus: false,
|
|
revalidateOnReconnect: false,
|
|
},
|
|
);
|
|
|
|
const reviewItems = useMemo<SegmentedReviewData>(() => {
|
|
if (!reviews) {
|
|
return undefined;
|
|
}
|
|
|
|
const all: ReviewSegment[] = [];
|
|
const alerts: ReviewSegment[] = [];
|
|
const detections: ReviewSegment[] = [];
|
|
const motion: ReviewSegment[] = [];
|
|
|
|
reviews?.forEach((segment) => {
|
|
all.push(segment);
|
|
|
|
switch (segment.severity) {
|
|
case "alert":
|
|
alerts.push(segment);
|
|
break;
|
|
case "detection":
|
|
detections.push(segment);
|
|
break;
|
|
default:
|
|
motion.push(segment);
|
|
break;
|
|
}
|
|
});
|
|
|
|
return {
|
|
all: all,
|
|
alert: alerts,
|
|
detection: detections,
|
|
significant_motion: motion,
|
|
};
|
|
}, [reviews]);
|
|
|
|
// update review items in place when a review segment ends
|
|
const reviewUpdate = useFrigateReviews();
|
|
const [endedReviews, setEndedReviews] = useState(
|
|
new Map<string, ReviewSegment>(),
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (reviewUpdate?.type === "end" && reviews) {
|
|
updateSegments(
|
|
(data) => {
|
|
if (!data) return data;
|
|
return data.map((seg) =>
|
|
seg.id === reviewUpdate.after.id ? reviewUpdate.after : seg,
|
|
);
|
|
},
|
|
{ revalidate: false, populateCache: true },
|
|
);
|
|
setEndedReviews((prev) =>
|
|
new Map(prev).set(reviewUpdate.after.id, reviewUpdate.after),
|
|
);
|
|
}
|
|
// reviews is intentionally excluded - only used to guard against
|
|
// updating the SWR cache before data has loaded
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [reviewUpdate, updateSegments]);
|
|
|
|
const currentItems = useMemo(() => {
|
|
if (!reviewItems || !severity) {
|
|
return null;
|
|
}
|
|
|
|
let current;
|
|
|
|
if (reviewFilter?.showAll) {
|
|
current = reviewItems.all;
|
|
} else {
|
|
current = reviewItems[severity];
|
|
}
|
|
|
|
if (!current || current.length == 0) {
|
|
return [];
|
|
}
|
|
|
|
if (!showReviewed) {
|
|
return current.filter((seg) => !seg.has_been_reviewed);
|
|
} else {
|
|
return current;
|
|
}
|
|
// only refresh when severity or filter changes
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [severity, reviewFilter, showReviewed, reviewItems?.all.length]);
|
|
|
|
// overlay end_time updates onto currentItems without re-running
|
|
// the has_been_reviewed filter, so hover-reviewed items stay visible
|
|
const displayItems = useMemo(() => {
|
|
if (!currentItems || endedReviews.size === 0) return currentItems;
|
|
return currentItems.map((seg) => endedReviews.get(seg.id) ?? seg);
|
|
}, [currentItems, endedReviews]);
|
|
|
|
// review summary
|
|
|
|
const { data: reviewSummary, mutate: updateSummary } = useSWR<ReviewSummary>(
|
|
timezone
|
|
? [
|
|
"review/summary",
|
|
{
|
|
timezone: timezone,
|
|
cameras: reviewCamerasParam,
|
|
labels: reviewSearchParams["labels"] ?? null,
|
|
zones: reviewSearchParams["zones"] ?? null,
|
|
},
|
|
]
|
|
: null,
|
|
{
|
|
revalidateOnFocus: true,
|
|
refreshInterval: 30000,
|
|
revalidateOnReconnect: false,
|
|
},
|
|
);
|
|
|
|
const reloadData = useCallback(() => {
|
|
setBeforeTs(Date.now() / 1000);
|
|
updateSummary();
|
|
}, [updateSummary]);
|
|
|
|
// recordings summary
|
|
|
|
const { data: recordingsSummary } = useSWR<RecordingsSummary>(
|
|
timezone
|
|
? [
|
|
"recordings/summary",
|
|
{
|
|
timezone: timezone,
|
|
cameras: reviewCamerasParam,
|
|
},
|
|
]
|
|
: null,
|
|
);
|
|
|
|
// preview videos
|
|
const previewTimes = useMemo(() => {
|
|
const startDate = new Date(selectedTimeRange.after * 1000);
|
|
startDate.setUTCMinutes(0, 0, 0);
|
|
|
|
const endDate = new Date(selectedTimeRange.before * 1000);
|
|
endDate.setHours(endDate.getHours() + 1, 0, 0, 0);
|
|
|
|
return {
|
|
after: startDate.getTime() / 1000,
|
|
before: endDate.getTime() / 1000,
|
|
};
|
|
}, [selectedTimeRange]);
|
|
|
|
const allPreviews = useCameraPreviews(
|
|
previewTimes ?? { after: 0, before: 0 },
|
|
{
|
|
fetchPreviews: previewTimes != undefined,
|
|
refreshOnHourRollover: true,
|
|
},
|
|
);
|
|
|
|
// review status
|
|
|
|
const markItemsAsReviewed = useCallback(
|
|
async (
|
|
currentItems: ReviewSegment[],
|
|
itemsToMarkReviewed: ReviewSegment[] | undefined = undefined,
|
|
) => {
|
|
if (currentItems.length == 0) {
|
|
return;
|
|
}
|
|
|
|
const severity = currentItems[0].severity;
|
|
updateSegments(
|
|
(data: ReviewSegment[] | undefined) => {
|
|
if (!data) {
|
|
return data;
|
|
}
|
|
|
|
const newData = [...data];
|
|
|
|
newData.forEach((seg) => {
|
|
if (seg.end_time && seg.severity == severity) {
|
|
seg.has_been_reviewed = true;
|
|
}
|
|
});
|
|
|
|
return newData;
|
|
},
|
|
{ revalidate: false, populateCache: true },
|
|
);
|
|
|
|
const reviewList =
|
|
itemsToMarkReviewed?.map((seg) => seg.id) ||
|
|
currentItems?.filter((seg) => seg.end_time)?.map((seg) => seg.id) ||
|
|
[];
|
|
|
|
if (reviewList.length > 0) {
|
|
await axios.post(`reviews/viewed`, {
|
|
ids: reviewList,
|
|
reviewed: true,
|
|
});
|
|
reloadData();
|
|
|
|
if (reviewSearchParams["after"] != undefined) {
|
|
updateSegments();
|
|
}
|
|
}
|
|
},
|
|
[reloadData, updateSegments, reviewSearchParams],
|
|
);
|
|
|
|
const markItemAsReviewed = useCallback(
|
|
async (review: ReviewSegment) => {
|
|
const resp = await axios.post(`reviews/viewed`, {
|
|
ids: [review.id],
|
|
reviewed: true,
|
|
});
|
|
|
|
if (resp.status == 200) {
|
|
updateSegments(
|
|
(data: ReviewSegment[] | undefined) => {
|
|
if (!data) {
|
|
return data;
|
|
}
|
|
|
|
const reviewIndex = data.findIndex((item) => item.id == review.id);
|
|
if (reviewIndex == -1) {
|
|
return data;
|
|
}
|
|
|
|
const newData = [
|
|
...data.slice(0, reviewIndex),
|
|
{ ...data[reviewIndex], has_been_reviewed: true },
|
|
...data.slice(reviewIndex + 1),
|
|
];
|
|
|
|
return newData;
|
|
},
|
|
{ revalidate: false, populateCache: true },
|
|
);
|
|
|
|
updateSummary(
|
|
(data: ReviewSummary | undefined) => {
|
|
if (!data) {
|
|
return data;
|
|
}
|
|
|
|
const day = new Date(review.start_time * 1000);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
let key;
|
|
if (day.getTime() > today.getTime()) {
|
|
key = "last24Hours";
|
|
} else {
|
|
key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`;
|
|
}
|
|
|
|
if (!Object.keys(data).includes(key)) {
|
|
return data;
|
|
}
|
|
|
|
const item = data[key];
|
|
return {
|
|
...data,
|
|
[key]: {
|
|
...item,
|
|
reviewed_alert:
|
|
review.severity == "alert"
|
|
? item.reviewed_alert + 1
|
|
: item.reviewed_alert,
|
|
reviewed_detection:
|
|
review.severity == "detection"
|
|
? item.reviewed_detection + 1
|
|
: item.reviewed_detection,
|
|
},
|
|
};
|
|
},
|
|
{ revalidate: false, populateCache: true },
|
|
);
|
|
}
|
|
},
|
|
[updateSegments, updateSummary],
|
|
);
|
|
|
|
// selected items
|
|
|
|
const selectedReviewData = useMemo(() => {
|
|
if (!recording) {
|
|
return undefined;
|
|
}
|
|
|
|
if (!config) {
|
|
return undefined;
|
|
}
|
|
|
|
if (!reviews) {
|
|
return undefined;
|
|
}
|
|
|
|
setStartTime(recording.startTime);
|
|
const allCameras = reviewFilter?.cameras ?? reviewCameras;
|
|
|
|
return {
|
|
camera: recording.camera,
|
|
start_time: recording.startTime,
|
|
allCameras: allCameras,
|
|
};
|
|
|
|
// previews will not update after item is selected
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [recording, reviews]);
|
|
|
|
if (!timezone) {
|
|
return <ActivityIndicator />;
|
|
}
|
|
|
|
if (recording) {
|
|
if (selectedReviewData) {
|
|
return (
|
|
<RecordingView
|
|
key={selectedTimeRange.before}
|
|
startCamera={selectedReviewData.camera}
|
|
startTime={selectedReviewData.start_time}
|
|
allCameras={selectedReviewData.allCameras}
|
|
reviewItems={reviews}
|
|
reviewSummary={reviewSummary}
|
|
allPreviews={allPreviews}
|
|
timeRange={selectedTimeRange}
|
|
filter={reviewFilter}
|
|
updateFilter={onUpdateFilter}
|
|
refreshData={reloadData}
|
|
onMotionSearch={(camera) => {
|
|
setMotionSearchCamera(camera);
|
|
setRecording(undefined);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
} else {
|
|
return motionSearchCamera ? (
|
|
!config || !selectedMotionSearchCamera ? (
|
|
<ActivityIndicator />
|
|
) : (
|
|
<MotionSearchView
|
|
config={config}
|
|
cameras={reviewCameras}
|
|
selectedCamera={selectedMotionSearchCamera}
|
|
onCameraSelect={handleMotionSearchCameraSelect}
|
|
cameraLocked={true}
|
|
selectedDay={motionSearchDay}
|
|
onDaySelect={handleMotionSearchDaySelect}
|
|
timeRange={motionSearchTimeRange}
|
|
timezone={timezone}
|
|
onBack={closeMotionSearch}
|
|
/>
|
|
)
|
|
) : (
|
|
<EventView
|
|
reviewItems={reviewItems}
|
|
currentReviewItems={displayItems}
|
|
reviewSummary={reviewSummary}
|
|
recordingsSummary={recordingsSummary}
|
|
relevantPreviews={allPreviews}
|
|
timeRange={selectedTimeRange}
|
|
filter={reviewFilter}
|
|
severity={severity ?? "alert"}
|
|
startTime={startTime}
|
|
showReviewed={showReviewed ?? false}
|
|
setShowReviewed={setShowReviewed}
|
|
setSeverity={setSeverity}
|
|
markItemAsReviewed={markItemAsReviewed}
|
|
markItemsAsReviewed={markItemsAsReviewed}
|
|
onOpenRecording={setRecording}
|
|
motionPreviewsCamera={motionPreviewsCamera ?? null}
|
|
setMotionPreviewsCamera={(camera) =>
|
|
setMotionPreviewsCamera(camera ?? undefined)
|
|
}
|
|
setMotionSearchCamera={setMotionSearchCamera}
|
|
pullLatestData={reloadData}
|
|
updateFilter={onUpdateFilter}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
function getHoursAgo(hours: number): number {
|
|
const now = new Date();
|
|
now.setHours(now.getHours() - hours);
|
|
return Math.ceil(now.getTime() / 1000);
|
|
}
|