Improve display of recordings data (#16436)

* backend

* frontend

* add earliest recording available to storage metrics page
This commit is contained in:
Josh Hawkins 2025-02-09 17:02:36 -06:00 committed by GitHub
parent bc96db8612
commit d5b60237a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 217 additions and 39 deletions

View File

@ -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"

View File

@ -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"""

View File

@ -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({
<>
<ReviewActivityCalendar
reviewSummary={reviewSummary}
recordingsSummary={recordingsSummary}
selectedDay={day}
onSelect={updateSelectedDay}
/>

View File

@ -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") && (
<CalendarFilterButton
reviewSummary={reviewSummary}
recordingsSummary={recordingsSummary}
day={
filter?.after == undefined
? undefined
@ -225,6 +233,7 @@ export default function ReviewFilterGroup({
filter={filter}
currentSeverity={currentSeverity}
reviewSummary={reviewSummary}
recordingsSummary={recordingsSummary}
allLabels={allLabels}
allZones={allZones}
onUpdateFilter={onUpdateFilter}

View File

@ -7,7 +7,12 @@ import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
import { ExportMode, GeneralFilter } from "@/types/filter";
import ReviewActivityCalendar from "./ReviewActivityCalendar";
import { SelectSeparator } from "../ui/select";
import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review";
import {
RecordingsSummary,
ReviewFilter,
ReviewSeverity,
ReviewSummary,
} from "@/types/review";
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
import { toast } from "sonner";
@ -36,6 +41,7 @@ type MobileReviewSettingsDrawerProps = {
mode: ExportMode;
showExportPreview: boolean;
reviewSummary?: ReviewSummary;
recordingsSummary?: RecordingsSummary;
allLabels: string[];
allZones: string[];
onUpdateFilter: (filter: ReviewFilter) => void;
@ -54,6 +60,7 @@ export default function MobileReviewSettingsDrawer({
mode,
showExportPreview,
reviewSummary,
recordingsSummary,
allLabels,
allZones,
onUpdateFilter,
@ -211,6 +218,7 @@ export default function MobileReviewSettingsDrawer({
<div className="flex w-full flex-row justify-center">
<ReviewActivityCalendar
reviewSummary={reviewSummary}
recordingsSummary={recordingsSummary}
selectedDay={
filter?.after == undefined
? undefined

View File

@ -1,4 +1,4 @@
import { ReviewSummary } from "@/types/review";
import { RecordingsSummary, ReviewSummary } from "@/types/review";
import { Calendar } from "../ui/calendar";
import { useMemo } from "react";
import { FaCircle } from "react-icons/fa";
@ -6,16 +6,19 @@ import { getUTCOffset } from "@/utils/dateUtil";
import { type DayContentProps } from "react-day-picker";
import { LAST_24_HOURS_KEY } from "@/types/filter";
import { usePersistence } from "@/hooks/use-persistence";
import { cn } from "@/lib/utils";
type WeekStartsOnType = 0 | 1 | 2 | 3 | 4 | 5 | 6;
type ReviewActivityCalendarProps = {
reviewSummary?: ReviewSummary;
recordingsSummary?: RecordingsSummary;
selectedDay?: Date;
onSelect: (day?: Date) => 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 (
<Calendar
@ -94,13 +114,30 @@ function ReviewActivityDay({ date, activeModifiers }: DayContentProps) {
}, [activeModifiers]);
return (
<div className="flex flex-col items-center justify-center gap-0.5">
{date.getDate()}
{dayActivity != "none" && (
<FaCircle
className={`size-2 ${dayActivity == "alert" ? "fill-severity_alert" : "fill-severity_detection"}`}
/>
)}
<div className={cn("flex flex-col items-center justify-center gap-0.5")}>
<span
className={cn(
"w-4",
activeModifiers["recordings"]
? "border-b border-primary/60 text-primary"
: "text-primary/40",
activeModifiers.selected && "border-white text-white",
)}
>
{date.getDate()}
</span>
<div className="mt-0.5 flex h-2 flex-row gap-0.5">
{dayActivity != "none" && (
<FaCircle
size={6}
className={cn(
dayActivity == "alert"
? "fill-severity_alert"
: "fill-severity_detection",
)}
/>
)}
</div>
</div>
);
}

View File

@ -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<RecordingsSummary>([
"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}

View File

@ -50,6 +50,10 @@ export type ReviewSummary = {
[day: string]: ReviewSummaryDay;
};
export type RecordingsSummary = {
[day: string]: boolean;
};
export type MotionData = {
start_time: number;
motion?: number;

View File

@ -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}

View File

@ -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<HTMLDivElement | null>(null);
// recordings summary
const timezone = useTimezone(config);
const { data: recordingsSummary } = useSWR<RecordingsSummary>([
"recordings/summary",
{
timezone: timezone,
cameras: allCameras ?? null,
},
]);
// controller state
const [mainCamera, setMainCamera] = useState(startCamera);
@ -430,6 +444,7 @@ export function RecordingView({
<ReviewFilterGroup
filters={["date", "general"]}
reviewSummary={reviewSummary}
recordingsSummary={recordingsSummary}
filter={filter}
motionOnly={false}
filterList={reviewFilterList}
@ -475,6 +490,7 @@ export function RecordingView({
filter={filter}
currentTime={currentTime}
latestTime={timeRange.before}
recordingsSummary={recordingsSummary}
mode={exportMode}
range={exportRange}
showExportPreview={showExportPreview}

View File

@ -9,6 +9,10 @@ import {
} from "@/components/ui/popover";
import useSWR from "swr";
import { LuAlertCircle } from "react-icons/lu";
import { FrigateConfig } from "@/types/frigateConfig";
import { useTimezone } from "@/hooks/use-date-utils";
import { RecordingsSummary } from "@/types/review";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
type CameraStorage = {
[key: string]: {
@ -26,6 +30,11 @@ export default function StorageMetrics({
}: StorageMetricsProps) {
const { data: cameraStorage } = useSWR<CameraStorage>("recordings/storage");
const { data: stats } = useSWR<FrigateStats>("stats");
const { data: config } = useSWR<FrigateConfig>("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<RecordingsSummary>([
"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 && (
<div className="mt-2 text-xs text-primary-variant">
<span className="font-medium">Earliest recording available:</span>{" "}
{formatUnixTimestampToDateTime(earliestDate, {
timezone: timezone,
strftime_fmt:
config.ui.time_format == "24hour" ? "%d %b %Y" : "%B %d, %Y",
})}
</div>
)}
</div>
<div className="flex-col rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">/tmp/cache</div>