mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-26 13:47:03 +02:00
Improve display of recordings data (#16436)
* backend * frontend * add earliest recording available to storage metrics page
This commit is contained in:
parent
bc96db8612
commit
d5b60237a2
@ -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"
|
||||
|
@ -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"""
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -50,6 +50,10 @@ export type ReviewSummary = {
|
||||
[day: string]: ReviewSummaryDay;
|
||||
};
|
||||
|
||||
export type RecordingsSummary = {
|
||||
[day: string]: boolean;
|
||||
};
|
||||
|
||||
export type MotionData = {
|
||||
start_time: number;
|
||||
motion?: number;
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user