Files
frigate/web/src/pages/Events.tsx
T
Josh HawkinsandGitHub f1cc0e49d4
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
Miscellaneous fixes (0.18 beta) (#23873)
* 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.
2026-07-30 17:20:41 -05: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>("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);
}