Files
frigate/web/src/pages/Events.tsx
T
Josh HawkinsandGitHub f9a37d4f4f
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
remember last selected history sidebar tab (#24488)
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.
2026-09-27 17:21:46 -06:00

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);
}