mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-01 16:42:18 +03:00
CI / AMD64 Build (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
* improve display of gpu graphs in system metrics * docs tweaks * Only hide cameras with ui.dashboard disabled from the All Cameras dashboard The settings camera selector and zone editor also filtered on ui.dashboard, so hiding a camera from the dashboard made its zones and masks uneditable in the UI (GH 23870). Drop those filters and correct the field title, help text, and reference docs to describe what the option actually does * hide cameras with ui.review disabled from the Motion tab and the review summaries The Motion tab built its own camera list that never checked ui.review, so a hidden camera still got a preview tile, and its motion and overlap queries fell back to every allowed camera. The review and recordings summaries had the same gap: they are aggregate day counts that can't be filtered client side, so a hidden camera kept contributing to the severity tab counts and calendar indicators while its items were absent from the list. Filter the motion camera list on ui.review and query all four endpoints with the visible camera list instead of letting the backend default to all, and skip the summary queries until the config resolves so the counts don't briefly render as zero. * Scope every review page query to the cameras visible in review The segments and the summary counts were derived from different camera sets: the list was fetched for all cameras and filtered client side, while the summaries were fetched for the visible cameras only when no explicit camera filter was set. A ?cameras= link can name a camera hidden from review, which left the count above zero with an empty list, pinning the new items to review popover open and making the auto refresh effect loop. Intersect an explicit camera selection with the visible list rather than trusting it, pass that to the segment and summary queries alike, and drop the now redundant client side filter, which the raw segments handed to the history view were bypassing anyway.
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>("timeline");
|
|
|
|
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,
|
|
},
|
|
);
|
|
|
|
// 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);
|
|
}
|