From d5b60237a2682ec51e68d2029514fc878e9f6641 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:02:36 -0600 Subject: [PATCH] Improve display of recordings data (#16436) * backend * frontend * add earliest recording available to storage metrics page --- .../api/defs/query/media_query_parameters.py | 5 + frigate/api/media.py | 45 ++++++++ .../filter/CalendarFilterButton.tsx | 5 +- .../components/filter/ReviewFilterGroup.tsx | 11 +- .../overlay/MobileReviewSettingsDrawer.tsx | 10 +- .../overlay/ReviewActivityCalendar.tsx | 107 ++++++++++++------ web/src/pages/Events.tsx | 12 ++ web/src/types/review.ts | 4 + web/src/views/events/EventView.tsx | 4 + web/src/views/recording/RecordingView.tsx | 16 +++ web/src/views/system/StorageMetrics.tsx | 37 +++++- 11 files changed, 217 insertions(+), 39 deletions(-) diff --git a/frigate/api/defs/query/media_query_parameters.py b/frigate/api/defs/query/media_query_parameters.py index bcccb99bf..4750d3277 100644 --- a/frigate/api/defs/query/media_query_parameters.py +++ b/frigate/api/defs/query/media_query_parameters.py @@ -41,3 +41,8 @@ class MediaMjpegFeedQueryParams(BaseModel): mask: Optional[int] = None motion: Optional[int] = None regions: Optional[int] = None + + +class MediaRecordingsSummaryQueryParams(BaseModel): + timezone: str = "utc" + cameras: Optional[str] = "all" diff --git a/frigate/api/media.py b/frigate/api/media.py index 6cd11b30e..f67ad52f2 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -25,6 +25,7 @@ from frigate.api.defs.query.media_query_parameters import ( MediaEventsSnapshotQueryParams, MediaLatestFrameQueryParams, MediaMjpegFeedQueryParams, + MediaRecordingsSummaryQueryParams, ) from frigate.api.defs.tags import Tags from frigate.config import FrigateConfig @@ -372,6 +373,50 @@ def get_recordings_storage_usage(request: Request): return JSONResponse(content=camera_usages) +@router.get("/recordings/summary") +def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()): + """Returns true/false by day indicating if recordings exist""" + hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone) + + cameras = params.cameras + + query = ( + Recordings.select( + fn.strftime( + "%Y-%m-%d", + fn.datetime( + Recordings.start_time + seconds_offset, + "unixepoch", + hour_modifier, + minute_modifier, + ), + ).alias("day") + ) + .group_by( + fn.strftime( + "%Y-%m-%d", + fn.datetime( + Recordings.start_time + seconds_offset, + "unixepoch", + hour_modifier, + minute_modifier, + ), + ) + ) + .order_by(Recordings.start_time.desc()) + ) + + if cameras != "all": + query = query.where(Recordings.camera << cameras.split(",")) + + print(query) + + recording_days = query.namedtuples() + days = {day.day: True for day in recording_days} + + return JSONResponse(content=days) + + @router.get("/{camera_name}/recordings/summary") def recordings_summary(camera_name: str, timezone: str = "utc"): """Returns hourly summary for recordings of given camera""" diff --git a/web/src/components/filter/CalendarFilterButton.tsx b/web/src/components/filter/CalendarFilterButton.tsx index efc990294..afa70b4e5 100644 --- a/web/src/components/filter/CalendarFilterButton.tsx +++ b/web/src/components/filter/CalendarFilterButton.tsx @@ -2,7 +2,7 @@ import { useFormattedRange, useFormattedTimestamp, } from "@/hooks/use-date-utils"; -import { ReviewSummary } from "@/types/review"; +import { RecordingsSummary, ReviewSummary } from "@/types/review"; import { Button } from "../ui/button"; import { FaCalendarAlt } from "react-icons/fa"; import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar"; @@ -17,11 +17,13 @@ import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; type CalendarFilterButtonProps = { reviewSummary?: ReviewSummary; + recordingsSummary?: RecordingsSummary; day?: Date; updateSelectedDay: (day?: Date) => void; }; export default function CalendarFilterButton({ reviewSummary, + recordingsSummary, day, updateSelectedDay, }: CalendarFilterButtonProps) { @@ -52,6 +54,7 @@ export default function CalendarFilterButton({ <> diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index d31596561..dedcb06fc 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -3,7 +3,12 @@ import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useEffect, useMemo, useState } from "react"; import { DropdownMenuSeparator } from "../ui/dropdown-menu"; -import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review"; +import { + RecordingsSummary, + ReviewFilter, + ReviewSeverity, + ReviewSummary, +} from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { FaCheckCircle, FaFilter, FaRunning } from "react-icons/fa"; import { isDesktop, isMobile } from "react-device-detect"; @@ -39,6 +44,7 @@ type ReviewFilterGroupProps = { filters?: ReviewFilters[]; currentSeverity?: ReviewSeverity; reviewSummary?: ReviewSummary; + recordingsSummary?: RecordingsSummary; filter?: ReviewFilter; motionOnly: boolean; filterList?: FilterList; @@ -52,6 +58,7 @@ export default function ReviewFilterGroup({ filters = DEFAULT_REVIEW_FILTERS, currentSeverity, reviewSummary, + recordingsSummary, filter, motionOnly, filterList, @@ -192,6 +199,7 @@ export default function ReviewFilterGroup({ {isDesktop && filters.includes("date") && ( void; @@ -54,6 +60,7 @@ export default function MobileReviewSettingsDrawer({ mode, showExportPreview, reviewSummary, + recordingsSummary, allLabels, allZones, onUpdateFilter, @@ -211,6 +218,7 @@ export default function MobileReviewSettingsDrawer({
void; }; export default function ReviewActivityCalendar({ reviewSummary, + recordingsSummary, selectedDay, onSelect, }: ReviewActivityCalendarProps) { @@ -30,39 +33,56 @@ export default function ReviewActivityCalendar({ }, []); const modifiers = useMemo(() => { - if (!reviewSummary) { - return { alerts: [], detections: [] }; + const recordings: Date[] = []; + const alerts: Date[] = []; + const detections: Date[] = []; + + // Handle recordings + if (recordingsSummary) { + Object.keys(recordingsSummary).forEach((date) => { + if (date === LAST_24_HOURS_KEY) { + return; + } + + const parts = date.split("-"); + const cal = new Date(date); + + cal.setFullYear( + parseInt(parts[0]), + parseInt(parts[1]) - 1, + parseInt(parts[2]), + ); + + recordings.push(cal); + }); } - const unreviewedDetections: Date[] = []; - const unreviewedAlerts: Date[] = []; + // Handle reviews if present + if (reviewSummary) { + Object.entries(reviewSummary).forEach(([date, data]) => { + if (date === LAST_24_HOURS_KEY) { + return; + } - Object.entries(reviewSummary).forEach(([date, data]) => { - if (date == LAST_24_HOURS_KEY) { - return; - } + const parts = date.split("-"); + const cal = new Date(date); - const parts = date.split("-"); - const cal = new Date(date); + cal.setFullYear( + parseInt(parts[0]), + parseInt(parts[1]) - 1, + parseInt(parts[2]), + ); - cal.setFullYear( - parseInt(parts[0]), - parseInt(parts[1]) - 1, - parseInt(parts[2]), - ); + if (data.total_alert > data.reviewed_alert) { + alerts.push(cal); + } else if (data.total_detection > data.reviewed_detection) { + detections.push(cal); + } + }); + } - if (data.total_alert > data.reviewed_alert) { - unreviewedAlerts.push(cal); - } else if (data.total_detection > data.reviewed_detection) { - unreviewedDetections.push(cal); - } - }); - - return { - alerts: unreviewedAlerts, - detections: unreviewedDetections, - }; - }, [reviewSummary]); + return { alerts, detections, recordings }; + }, [reviewSummary, recordingsSummary]); return ( - {date.getDate()} - {dayActivity != "none" && ( - - )} +
+ + {date.getDate()} + +
+ {dayActivity != "none" && ( + + )} +
); } diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 28625bbd8..50db5211e 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -7,6 +7,7 @@ import { usePersistence } from "@/hooks/use-persistence"; import { FrigateConfig } from "@/types/frigateConfig"; import { RecordingStartingPoint } from "@/types/record"; import { + RecordingsSummary, REVIEW_PADDING, ReviewFilter, ReviewSegment, @@ -286,6 +287,16 @@ export default function Events() { updateSummary(); }, [updateSummary]); + // recordings summary + + const { data: recordingsSummary } = useSWR([ + "recordings/summary", + { + timezone: timezone, + cameras: reviewSearchParams["cameras"] ?? null, + }, + ]); + // preview videos const previewTimes = useMemo(() => { const startDate = new Date(selectedTimeRange.after * 1000); @@ -475,6 +486,7 @@ export default function Events() { reviewItems={reviewItems} currentReviewItems={currentItems} reviewSummary={reviewSummary} + recordingsSummary={recordingsSummary} relevantPreviews={allPreviews} timeRange={selectedTimeRange} filter={reviewFilter} diff --git a/web/src/types/review.ts b/web/src/types/review.ts index dbbd471fd..8d567bffc 100644 --- a/web/src/types/review.ts +++ b/web/src/types/review.ts @@ -50,6 +50,10 @@ export type ReviewSummary = { [day: string]: ReviewSummaryDay; }; +export type RecordingsSummary = { + [day: string]: boolean; +}; + export type MotionData = { start_time: number; motion?: number; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index cb2994322..e8e864e32 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -12,6 +12,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { MotionData, + RecordingsSummary, REVIEW_PADDING, ReviewFilter, ReviewSegment, @@ -59,6 +60,7 @@ type EventViewProps = { reviewItems?: SegmentedReviewData; currentReviewItems: ReviewSegment[] | null; reviewSummary?: ReviewSummary; + recordingsSummary?: RecordingsSummary; relevantPreviews?: Preview[]; timeRange: TimeRange; filter?: ReviewFilter; @@ -77,6 +79,7 @@ export default function EventView({ reviewItems, currentReviewItems, reviewSummary, + recordingsSummary, relevantPreviews, timeRange, filter, @@ -359,6 +362,7 @@ export default function EventView({ } currentSeverity={severityToggle} reviewSummary={reviewSummary} + recordingsSummary={recordingsSummary} filter={filter} motionOnly={motionOnly} filterList={reviewFilterList} diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 1ba4d1bd7..c5e528736 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -15,6 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { MotionData, + RecordingsSummary, REVIEW_PADDING, ReviewFilter, ReviewSegment, @@ -46,6 +47,7 @@ import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import { useResizeObserver } from "@/hooks/resize-observer"; import { cn } from "@/lib/utils"; import { useFullscreen } from "@/hooks/use-fullscreen"; +import { useTimezone } from "@/hooks/use-date-utils"; import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; type RecordingViewProps = { @@ -74,6 +76,18 @@ export function RecordingView({ const navigate = useNavigate(); const contentRef = useRef(null); + // recordings summary + + const timezone = useTimezone(config); + + const { data: recordingsSummary } = useSWR([ + "recordings/summary", + { + timezone: timezone, + cameras: allCameras ?? null, + }, + ]); + // controller state const [mainCamera, setMainCamera] = useState(startCamera); @@ -430,6 +444,7 @@ export function RecordingView({ ("recordings/storage"); const { data: stats } = useSWR("stats"); + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + + const timezone = useTimezone(config); const totalStorage = useMemo(() => { if (!cameraStorage || !stats) { @@ -44,7 +53,23 @@ export default function StorageMetrics({ return totalStorage; }, [cameraStorage, stats, setLastUpdated]); - if (!cameraStorage || !stats || !totalStorage) { + // recordings summary + + const { data: recordingsSummary } = useSWR([ + "recordings/summary", + { + timezone: timezone, + }, + ]); + + const earliestDate = useMemo(() => { + const keys = Object.keys(recordingsSummary || {}); + return keys.length + ? new Date(keys[keys.length - 1]).getTime() / 1000 + : null; + }, [recordingsSummary]); + + if (!cameraStorage || !stats || !totalStorage || !config) { return; } @@ -81,6 +106,16 @@ export default function StorageMetrics({ used={totalStorage.used} total={totalStorage.total} /> + {earliestDate && ( +
+ Earliest recording available:{" "} + {formatUnixTimestampToDateTime(earliestDate, { + timezone: timezone, + strftime_fmt: + config.ui.time_format == "24hour" ? "%d %b %Y" : "%B %d, %Y", + })} +
+ )}
/tmp/cache