Miscellaneous Fixes (#20951)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled

* ensure viewer roles are available in create user dialog

* admin-only endpoint to return unmaksed camera paths and go2rtc streams

* remove camera edit dropdown

pushing camera editing from the UI to 0.18

* clean up camera edit form

* rename component for clarity

CameraSettingsView is now CameraReviewSettingsView

* Catch case where user requsts clip for time that has no recordings

* ensure emergency cleanup also sets has_clip on overlapping events

improves https://github.com/blakeblackshear/frigate/discussions/20945

* use debug log instead of info

* update docs to recommend tmpfs

* improve display of in-progress events in explore tracking details

* improve seeking logic in tracking details

mimic the logic of DynamicVideoController

* only use ffprobe for duration to avoid blocking

fixes https://github.com/blakeblackshear/frigate/discussions/20737#discussioncomment-14999869

* Revert "only use ffprobe for duration to avoid blocking"

This reverts commit 8b15078005.

* update readme to link to object detector docs

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
Josh Hawkins 2025-11-18 16:33:42 -06:00 committed by GitHub
parent fbf4388b37
commit 213a1fbd00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1114 additions and 905 deletions

View File

@ -12,7 +12,7 @@
A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras. A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras.
Use of a GPU or AI accelerator such as a [Google Coral](https://coral.ai/products/) or [Hailo](https://hailo.ai/) is highly recommended. AI accelerators will outperform even the best CPUs with very little overhead. Use of a GPU or AI accelerator is highly recommended. AI accelerators will outperform even the best CPUs with very little overhead. See Frigate's supported [object detectors](https://docs.frigate.video/configuration/object_detectors/).
- Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration) - Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration)
- Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary - Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary

View File

@ -56,7 +56,7 @@ services:
volumes: volumes:
- /path/to/your/config:/config - /path/to/your/config:/config
- /path/to/your/storage:/media/frigate - /path/to/your/storage:/media/frigate
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear - type: tmpfs # Recommended: 1GB of memory
target: /tmp/cache target: /tmp/cache
tmpfs: tmpfs:
size: 1000000000 size: 1000000000
@ -310,7 +310,7 @@ services:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /path/to/your/config:/config - /path/to/your/config:/config
- /path/to/your/storage:/media/frigate - /path/to/your/storage:/media/frigate
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear - type: tmpfs # Recommended: 1GB of memory
target: /tmp/cache target: /tmp/cache
tmpfs: tmpfs:
size: 1000000000 size: 1000000000

View File

@ -179,6 +179,36 @@ def config(request: Request):
return JSONResponse(content=config) return JSONResponse(content=config)
@router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))])
def config_raw_paths(request: Request):
"""Admin-only endpoint that returns camera paths and go2rtc streams without credential masking."""
config_obj: FrigateConfig = request.app.frigate_config
raw_paths = {"cameras": {}, "go2rtc": {"streams": {}}}
# Extract raw camera ffmpeg input paths
for camera_name, camera in config_obj.cameras.items():
raw_paths["cameras"][camera_name] = {
"ffmpeg": {
"inputs": [
{"path": input.path, "roles": input.roles}
for input in camera.ffmpeg.inputs
]
}
}
# Extract raw go2rtc stream URLs
go2rtc_config = config_obj.go2rtc.model_dump(
mode="json", warnings="none", exclude_none=True
)
for stream_name, stream in go2rtc_config.get("streams", {}).items():
if stream is None:
continue
raw_paths["go2rtc"]["streams"][stream_name] = stream
return JSONResponse(content=raw_paths)
@router.get("/config/raw") @router.get("/config/raw")
def config_raw(): def config_raw():
config_file = find_config_file() config_file = find_config_file()

View File

@ -762,6 +762,15 @@ async def recording_clip(
.order_by(Recordings.start_time.asc()) .order_by(Recordings.start_time.asc())
) )
if recordings.count() == 0:
return JSONResponse(
content={
"success": False,
"message": "No recordings found for the specified time range",
},
status_code=400,
)
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt") file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
file_path = os.path.join(CACHE_DIR, file_name) file_path = os.path.join(CACHE_DIR, file_name)
with open(file_path, "w") as file: with open(file_path, "w") as file:

View File

@ -113,6 +113,7 @@ class StorageMaintainer(threading.Thread):
recordings: Recordings = ( recordings: Recordings = (
Recordings.select( Recordings.select(
Recordings.id, Recordings.id,
Recordings.camera,
Recordings.start_time, Recordings.start_time,
Recordings.end_time, Recordings.end_time,
Recordings.segment_size, Recordings.segment_size,
@ -137,7 +138,7 @@ class StorageMaintainer(threading.Thread):
) )
event_start = 0 event_start = 0
deleted_recordings = set() deleted_recordings = []
for recording in recordings: for recording in recordings:
# check if 1 hour of storage has been reclaimed # check if 1 hour of storage has been reclaimed
if deleted_segments_size > hourly_bandwidth: if deleted_segments_size > hourly_bandwidth:
@ -172,7 +173,7 @@ class StorageMaintainer(threading.Thread):
if not keep: if not keep:
try: try:
clear_and_unlink(Path(recording.path), missing_ok=False) clear_and_unlink(Path(recording.path), missing_ok=False)
deleted_recordings.add(recording.id) deleted_recordings.append(recording)
deleted_segments_size += recording.segment_size deleted_segments_size += recording.segment_size
except FileNotFoundError: except FileNotFoundError:
# this file was not found so we must assume no space was cleaned up # this file was not found so we must assume no space was cleaned up
@ -186,6 +187,9 @@ class StorageMaintainer(threading.Thread):
recordings = ( recordings = (
Recordings.select( Recordings.select(
Recordings.id, Recordings.id,
Recordings.camera,
Recordings.start_time,
Recordings.end_time,
Recordings.path, Recordings.path,
Recordings.segment_size, Recordings.segment_size,
) )
@ -201,7 +205,7 @@ class StorageMaintainer(threading.Thread):
try: try:
clear_and_unlink(Path(recording.path), missing_ok=False) clear_and_unlink(Path(recording.path), missing_ok=False)
deleted_segments_size += recording.segment_size deleted_segments_size += recording.segment_size
deleted_recordings.add(recording.id) deleted_recordings.append(recording)
except FileNotFoundError: except FileNotFoundError:
# this file was not found so we must assume no space was cleaned up # this file was not found so we must assume no space was cleaned up
pass pass
@ -211,7 +215,50 @@ class StorageMaintainer(threading.Thread):
logger.debug(f"Expiring {len(deleted_recordings)} recordings") logger.debug(f"Expiring {len(deleted_recordings)} recordings")
# delete up to 100,000 at a time # delete up to 100,000 at a time
max_deletes = 100000 max_deletes = 100000
deleted_recordings_list = list(deleted_recordings)
# Update has_clip for events that overlap with deleted recordings
if deleted_recordings:
# Group deleted recordings by camera
camera_recordings = {}
for recording in deleted_recordings:
if recording.camera not in camera_recordings:
camera_recordings[recording.camera] = {
"min_start": recording.start_time,
"max_end": recording.end_time,
}
else:
camera_recordings[recording.camera]["min_start"] = min(
camera_recordings[recording.camera]["min_start"],
recording.start_time,
)
camera_recordings[recording.camera]["max_end"] = max(
camera_recordings[recording.camera]["max_end"],
recording.end_time,
)
# Find all events that overlap with deleted recordings time range per camera
events_to_update = []
for camera, time_range in camera_recordings.items():
overlapping_events = Event.select(Event.id).where(
Event.camera == camera,
Event.has_clip == True,
Event.start_time < time_range["max_end"],
Event.end_time > time_range["min_start"],
)
for event in overlapping_events:
events_to_update.append(event.id)
# Update has_clip to False for overlapping events
if events_to_update:
for i in range(0, len(events_to_update), max_deletes):
batch = events_to_update[i : i + max_deletes]
Event.update(has_clip=False).where(Event.id << batch).execute()
logger.debug(
f"Updated has_clip to False for {len(events_to_update)} events"
)
deleted_recordings_list = [r.id for r in deleted_recordings]
for i in range(0, len(deleted_recordings_list), max_deletes): for i in range(0, len(deleted_recordings_list), max_deletes):
Recordings.delete().where( Recordings.delete().where(
Recordings.id << deleted_recordings_list[i : i + max_deletes] Recordings.id << deleted_recordings_list[i : i + max_deletes]

View File

@ -13,7 +13,8 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { useEffect, useState } from "react"; import { useEffect, useState, useMemo } from "react";
import useSWR from "swr";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -35,6 +36,7 @@ import { LuCheck, LuX } from "react-icons/lu";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { import {
MobilePage, MobilePage,
MobilePageContent, MobilePageContent,
@ -54,9 +56,15 @@ export default function CreateUserDialog({
onCreate, onCreate,
onCancel, onCancel,
}: CreateUserOverlayProps) { }: CreateUserOverlayProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const roles = useMemo(() => {
const existingRoles = config ? Object.keys(config.auth?.roles || {}) : [];
return Array.from(new Set(["admin", "viewer", ...(existingRoles || [])]));
}, [config]);
const formSchema = z const formSchema = z
.object({ .object({
user: z user: z
@ -69,7 +77,7 @@ export default function CreateUserDialog({
confirmPassword: z confirmPassword: z
.string() .string()
.min(1, t("users.dialog.createUser.confirmPassword")), .min(1, t("users.dialog.createUser.confirmPassword")),
role: z.enum(["admin", "viewer"]), role: z.string().min(1),
}) })
.refine((data) => data.password === data.confirmPassword, { .refine((data) => data.password === data.confirmPassword, {
message: t("users.dialog.form.password.notMatch"), message: t("users.dialog.form.password.notMatch"),
@ -246,24 +254,22 @@ export default function CreateUserDialog({
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{roles.map((r) => (
<SelectItem <SelectItem
value="admin" value={r}
key={r}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{r === "admin" ? (
<Shield className="h-4 w-4 text-primary" /> <Shield className="h-4 w-4 text-primary" />
<span>{t("role.admin", { ns: "common" })}</span> ) : (
</div>
</SelectItem>
<SelectItem
value="viewer"
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" /> <User className="h-4 w-4 text-muted-foreground" />
<span>{t("role.viewer", { ns: "common" })}</span> )}
<span>{t(`role.${r}`, { ns: "common" }) || r}</span>
</div> </div>
</SelectItem> </SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription className="text-xs text-muted-foreground"> <FormDescription className="text-xs text-muted-foreground">

View File

@ -12,7 +12,11 @@ import { cn } from "@/lib/utils";
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { REVIEW_PADDING } from "@/types/review"; import { REVIEW_PADDING } from "@/types/review";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import {
ASPECT_VERTICAL_LAYOUT,
ASPECT_WIDE_LAYOUT,
Recording,
} from "@/types/record";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@ -75,6 +79,139 @@ export function TrackingDetails({
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
// Fetch recording segments for the event's time range to handle motion-only gaps
const eventStartRecord = useMemo(
() => (event.start_time ?? 0) + annotationOffset / 1000,
[event.start_time, annotationOffset],
);
const eventEndRecord = useMemo(
() => (event.end_time ?? Date.now() / 1000) + annotationOffset / 1000,
[event.end_time, annotationOffset],
);
const { data: recordings } = useSWR<Recording[]>(
event.camera
? [
`${event.camera}/recordings`,
{
after: eventStartRecord - REVIEW_PADDING,
before: eventEndRecord + REVIEW_PADDING,
},
]
: null,
);
// Convert a timeline timestamp to actual video player time, accounting for
// motion-only recording gaps. Uses the same algorithm as DynamicVideoController.
const timestampToVideoTime = useCallback(
(timestamp: number): number => {
if (!recordings || recordings.length === 0) {
// Fallback to simple calculation if no recordings data
return timestamp - (eventStartRecord - REVIEW_PADDING);
}
const videoStartTime = eventStartRecord - REVIEW_PADDING;
// If timestamp is before video start, return 0
if (timestamp < videoStartTime) return 0;
// Check if timestamp is before the first recording or after the last
if (
timestamp < recordings[0].start_time ||
timestamp > recordings[recordings.length - 1].end_time
) {
// No recording available at this timestamp
return 0;
}
// Calculate the inpoint offset - the HLS video may start partway through the first segment
let inpointOffset = 0;
if (
videoStartTime > recordings[0].start_time &&
videoStartTime < recordings[0].end_time
) {
inpointOffset = videoStartTime - recordings[0].start_time;
}
let seekSeconds = 0;
for (const segment of recordings) {
// Skip segments that end before our timestamp
if (segment.end_time <= timestamp) {
// Add this segment's duration, but subtract inpoint offset from first segment
if (segment === recordings[0]) {
seekSeconds += segment.duration - inpointOffset;
} else {
seekSeconds += segment.duration;
}
} else if (segment.start_time <= timestamp) {
// The timestamp is within this segment
if (segment === recordings[0]) {
// For the first segment, account for the inpoint offset
seekSeconds +=
timestamp - Math.max(segment.start_time, videoStartTime);
} else {
seekSeconds += timestamp - segment.start_time;
}
break;
}
}
return seekSeconds;
},
[recordings, eventStartRecord],
);
// Convert video player time back to timeline timestamp, accounting for
// motion-only recording gaps. Reverse of timestampToVideoTime.
const videoTimeToTimestamp = useCallback(
(playerTime: number): number => {
if (!recordings || recordings.length === 0) {
// Fallback to simple calculation if no recordings data
const videoStartTime = eventStartRecord - REVIEW_PADDING;
return playerTime + videoStartTime;
}
const videoStartTime = eventStartRecord - REVIEW_PADDING;
// Calculate the inpoint offset - the video may start partway through the first segment
let inpointOffset = 0;
if (
videoStartTime > recordings[0].start_time &&
videoStartTime < recordings[0].end_time
) {
inpointOffset = videoStartTime - recordings[0].start_time;
}
let timestamp = 0;
let totalTime = 0;
for (const segment of recordings) {
const segmentDuration =
segment === recordings[0]
? segment.duration - inpointOffset
: segment.duration;
if (totalTime + segmentDuration > playerTime) {
// The player time is within this segment
if (segment === recordings[0]) {
// For the first segment, add the inpoint offset
timestamp =
Math.max(segment.start_time, videoStartTime) +
(playerTime - totalTime);
} else {
timestamp = segment.start_time + (playerTime - totalTime);
}
break;
} else {
totalTime += segmentDuration;
}
}
return timestamp;
},
[recordings, eventStartRecord],
);
eventSequence?.map((event) => { eventSequence?.map((event) => {
event.data.zones_friendly_names = event.data?.zones?.map((zone) => { event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
return resolveZoneName(config, zone); return resolveZoneName(config, zone);
@ -148,17 +285,14 @@ export function TrackingDetails({
return; return;
} }
// For video mode: convert to video-relative time and seek player // For video mode: convert to video-relative time (accounting for motion-only gaps)
const eventStartRecord = const relativeTime = timestampToVideoTime(targetTimeRecord);
(event.start_time ?? 0) + annotationOffset / 1000;
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const relativeTime = targetTimeRecord - videoStartTime;
if (videoRef.current) { if (videoRef.current) {
videoRef.current.currentTime = relativeTime; videoRef.current.currentTime = relativeTime;
} }
}, },
[event.start_time, annotationOffset, displaySource], [annotationOffset, displaySource, timestampToVideoTime],
); );
const formattedStart = config const formattedStart = config
@ -177,8 +311,9 @@ export function TrackingDetails({
}) })
: ""; : "";
const formattedEnd = config const formattedEnd =
? formatUnixTimestampToDateTime(event.end_time ?? 0, { config && event.end_time != null
? formatUnixTimestampToDateTime(event.end_time, {
timezone: config.ui.timezone, timezone: config.ui.timezone,
date_format: date_format:
config.ui.time_format == "24hour" config.ui.time_format == "24hour"
@ -210,24 +345,14 @@ export function TrackingDetails({
} }
// seekToTimestamp is a record stream timestamp // seekToTimestamp is a record stream timestamp
// event.start_time is detect stream time, convert to record // Convert to video position (accounting for motion-only recording gaps)
// The video clip starts at (eventStartRecord - REVIEW_PADDING)
if (!videoRef.current) return; if (!videoRef.current) return;
const eventStartRecord = event.start_time + annotationOffset / 1000; const relativeTime = timestampToVideoTime(seekToTimestamp);
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const relativeTime = seekToTimestamp - videoStartTime;
if (relativeTime >= 0) { if (relativeTime >= 0) {
videoRef.current.currentTime = relativeTime; videoRef.current.currentTime = relativeTime;
} }
setSeekToTimestamp(null); setSeekToTimestamp(null);
}, [ }, [seekToTimestamp, displaySource, timestampToVideoTime]);
seekToTimestamp,
event.start_time,
annotationOffset,
apiHost,
event.camera,
displaySource,
]);
const isWithinEventRange = useMemo(() => { const isWithinEventRange = useMemo(() => {
if (effectiveTime === undefined || event.start_time === undefined) { if (effectiveTime === undefined || event.start_time === undefined) {
@ -334,14 +459,13 @@ export function TrackingDetails({
const handleTimeUpdate = useCallback( const handleTimeUpdate = useCallback(
(time: number) => { (time: number) => {
// event.start_time is detect stream time, convert to record // Convert video player time back to timeline timestamp
const eventStartRecord = event.start_time + annotationOffset / 1000; // accounting for motion-only recording gaps
const videoStartTime = eventStartRecord - REVIEW_PADDING; const absoluteTime = videoTimeToTimestamp(time);
const absoluteTime = time + videoStartTime;
setCurrentTime(absoluteTime); setCurrentTime(absoluteTime);
}, },
[event.start_time, annotationOffset], [videoTimeToTimestamp],
); );
const [src, setSrc] = useState( const [src, setSrc] = useState(
@ -525,9 +649,16 @@ export function TrackingDetails({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="capitalize">{label}</span> <span className="capitalize">{label}</span>
<span className="md:text-md text-xs text-secondary-foreground"> <div className="md:text-md flex items-center text-xs text-secondary-foreground">
{formattedStart ?? ""} - {formattedEnd ?? ""} {formattedStart ?? ""}
</span> {event.end_time != null ? (
<> - {formattedEnd}</>
) : (
<div className="inline-block">
<ActivityIndicator className="ml-3 size-4" />
</div>
)}
</div>
{event.data?.recognized_license_plate && ( {event.data?.recognized_license_plate && (
<> <>
<span className="text-secondary-foreground">·</span> <span className="text-secondary-foreground">·</span>

View File

@ -18,7 +18,7 @@ import { z } from "zod";
import axios from "axios"; import axios from "axios";
import { toast, Toaster } from "sonner"; import { toast, Toaster } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect } from "react";
import { LuTrash2, LuPlus } from "react-icons/lu"; import { LuTrash2, LuPlus } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
@ -42,7 +42,15 @@ export default function CameraEditForm({
onCancel, onCancel,
}: CameraEditFormProps) { }: CameraEditFormProps) {
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config, mutate: mutateConfig } =
useSWR<FrigateConfig>("config");
const { data: rawPaths, mutate: mutateRawPaths } = useSWR<{
cameras: Record<
string,
{ ffmpeg: { inputs: { path: string; roles: string[] }[] } }
>;
go2rtc: { streams: Record<string, string | string[]> };
}>(cameraName ? "config/raw_paths" : null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const formSchema = useMemo( const formSchema = useMemo(
@ -145,14 +153,23 @@ export default function CameraEditForm({
if (cameraName && config?.cameras[cameraName]) { if (cameraName && config?.cameras[cameraName]) {
const camera = config.cameras[cameraName]; const camera = config.cameras[cameraName];
defaultValues.enabled = camera.enabled ?? true; defaultValues.enabled = camera.enabled ?? true;
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length
// Use raw paths from the admin endpoint if available, otherwise fall back to masked paths
const rawCameraData = rawPaths?.cameras?.[cameraName];
defaultValues.ffmpeg.inputs = rawCameraData?.ffmpeg?.inputs?.length
? rawCameraData.ffmpeg.inputs.map((input) => ({
path: input.path,
roles: input.roles as Role[],
}))
: camera.ffmpeg?.inputs?.length
? camera.ffmpeg.inputs.map((input) => ({ ? camera.ffmpeg.inputs.map((input) => ({
path: input.path, path: input.path,
roles: input.roles as Role[], roles: input.roles as Role[],
})) }))
: defaultValues.ffmpeg.inputs; : defaultValues.ffmpeg.inputs;
const go2rtcStreams = config.go2rtc?.streams || {}; const go2rtcStreams =
rawPaths?.go2rtc?.streams || config.go2rtc?.streams || {};
const cameraStreams: Record<string, string[]> = {}; const cameraStreams: Record<string, string[]> = {};
// get candidate stream names for this camera. could be the camera's own name, // get candidate stream names for this camera. could be the camera's own name,
@ -196,6 +213,60 @@ export default function CameraEditForm({
mode: "onChange", mode: "onChange",
}); });
// Update form values when rawPaths loads
useEffect(() => {
if (
cameraName &&
config?.cameras[cameraName] &&
rawPaths?.cameras?.[cameraName]
) {
const camera = config.cameras[cameraName];
const rawCameraData = rawPaths.cameras[cameraName];
// Update ffmpeg inputs with raw paths
if (rawCameraData.ffmpeg?.inputs?.length) {
form.setValue(
"ffmpeg.inputs",
rawCameraData.ffmpeg.inputs.map((input) => ({
path: input.path,
roles: input.roles as Role[],
})),
);
}
// Update go2rtc streams with raw URLs
if (rawPaths.go2rtc?.streams) {
const validNames = new Set<string>();
validNames.add(cameraName);
camera.ffmpeg?.inputs?.forEach((input) => {
const restreamMatch = input.path.match(
/^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/,
);
if (restreamMatch) {
validNames.add(restreamMatch[1]);
}
});
const liveStreams = camera?.live?.streams;
if (liveStreams) {
Object.keys(liveStreams).forEach((key) => validNames.add(key));
}
const cameraStreams: Record<string, string[]> = {};
Object.entries(rawPaths.go2rtc.streams).forEach(([name, urls]) => {
if (validNames.has(name)) {
cameraStreams[name] = Array.isArray(urls) ? urls : [urls];
}
});
if (Object.keys(cameraStreams).length > 0) {
form.setValue("go2rtcStreams", cameraStreams);
}
}
}
}, [cameraName, config, rawPaths, form]);
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control: form.control, control: form.control,
name: "ffmpeg.inputs", name: "ffmpeg.inputs",
@ -268,6 +339,8 @@ export default function CameraEditForm({
}), }),
{ position: "top-center" }, { position: "top-center" },
); );
mutateConfig();
mutateRawPaths();
if (onSave) onSave(); if (onSave) onSave();
}); });
} else { } else {
@ -277,6 +350,8 @@ export default function CameraEditForm({
}), }),
{ position: "top-center" }, { position: "top-center" },
); );
mutateConfig();
mutateRawPaths();
if (onSave) onSave(); if (onSave) onSave();
} }
} else { } else {

View File

@ -26,7 +26,7 @@ import useSWR from "swr";
import FilterSwitch from "@/components/filter/FilterSwitch"; import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
import { PolygonType } from "@/types/canvas"; import { PolygonType } from "@/types/canvas";
import CameraSettingsView from "@/views/settings/CameraSettingsView"; import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView";
import CameraManagementView from "@/views/settings/CameraManagementView"; import CameraManagementView from "@/views/settings/CameraManagementView";
import MotionTunerView from "@/views/settings/MotionTunerView"; import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
@ -93,7 +93,7 @@ const settingsGroups = [
label: "cameras", label: "cameras",
items: [ items: [
{ key: "cameraManagement", component: CameraManagementView }, { key: "cameraManagement", component: CameraManagementView },
{ key: "cameraReview", component: CameraSettingsView }, { key: "cameraReview", component: CameraReviewSettingsView },
{ key: "masksAndZones", component: MasksAndZonesView }, { key: "masksAndZones", component: MasksAndZonesView },
{ key: "motionTuner", component: MotionTunerView }, { key: "motionTuner", component: MotionTunerView },
], ],

View File

@ -5,17 +5,9 @@ import { Button } from "@/components/ui/button";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraEditForm from "@/components/settings/CameraEditForm";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import { LuPlus } from "react-icons/lu"; import { LuPlus } from "react-icons/lu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
@ -90,31 +82,6 @@ export default function CameraManagementView({
</Button> </Button>
{cameras.length > 0 && ( {cameras.length > 0 && (
<> <>
<div className="my-4 flex flex-col gap-2">
<Label>{t("cameraManagement.editCamera")}</Label>
<Select
onValueChange={(value) => {
setEditCameraName(value);
setViewMode("edit");
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue
placeholder={t("cameraManagement.selectCamera")}
/>
</SelectTrigger>
<SelectContent>
{cameras.map((camera) => {
return (
<SelectItem key={camera} value={camera}>
<CameraNameLabel camera={camera} />
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<Separator className="my-2 flex bg-secondary" /> <Separator className="my-2 flex bg-secondary" />
<div className="max-w-7xl space-y-4"> <div className="max-w-7xl space-y-4">
<Heading as="h4" className="my-2"> <Heading as="h4" className="my-2">

View File

@ -0,0 +1,738 @@
import Heading from "@/components/ui/heading";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Toaster, toast } from "sonner";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { Checkbox } from "@/components/ui/checkbox";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import axios from "axios";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { MdCircle } from "react-icons/md";
import { cn } from "@/lib/utils";
import { Trans, useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n";
import {
useAlertsState,
useDetectionsState,
useObjectDescriptionState,
useReviewDescriptionState,
} from "@/api/ws";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { formatList } from "@/utils/stringUtil";
type CameraReviewSettingsViewProps = {
selectedCamera: string;
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
type CameraReviewSettingsValueType = {
alerts_zones: string[];
detections_zones: string[];
};
export default function CameraReviewSettingsView({
selectedCamera,
setUnsavedChanges,
}: CameraReviewSettingsViewProps) {
const { t } = useTranslation(["views/settings"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
}
}, [config, selectedCamera]);
const [changedValue, setChangedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectDetections, setSelectDetections] = useState(false);
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
const selectCameraName = useCameraFriendlyName(selectedCamera);
// zones and labels
const getZoneName = useCallback(
(zoneId: string, cameraId?: string) =>
resolveZoneName(config, zoneId, cameraId),
[config],
);
const zones = useMemo(() => {
if (cameraConfig) {
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
camera: cameraConfig.name,
name,
friendly_name: cameraConfig.zones[name].friendly_name,
objects: zoneData.objects,
color: zoneData.color,
}));
}
}, [cameraConfig]);
const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels
? formatList(
cameraConfig.review.alerts.labels.map((label) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
: "";
}, [cameraConfig]);
const detectionsLabels = useMemo(() => {
return cameraConfig?.review.detections.labels
? formatList(
cameraConfig.review.detections.labels.map((label) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
: "";
}, [cameraConfig]);
// form
const formSchema = z.object({
alerts_zones: z.array(z.string()),
detections_zones: z.array(z.string()),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
alerts_zones: cameraConfig?.review.alerts.required_zones || [],
detections_zones: cameraConfig?.review.detections.required_zones || [],
},
});
const watchedAlertsZones = form.watch("alerts_zones");
const watchedDetectionsZones = form.watch("detections_zones");
const { payload: alertsState, send: sendAlerts } =
useAlertsState(selectedCamera);
const { payload: detectionsState, send: sendDetections } =
useDetectionsState(selectedCamera);
const { payload: objDescState, send: sendObjDesc } =
useObjectDescriptionState(selectedCamera);
const { payload: revDescState, send: sendRevDesc } =
useReviewDescriptionState(selectedCamera);
const handleCheckedChange = useCallback(
(isChecked: boolean) => {
if (!isChecked) {
form.reset({
alerts_zones: watchedAlertsZones,
detections_zones: [],
});
}
setChangedValue(true);
setSelectDetections(isChecked as boolean);
},
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[watchedAlertsZones],
);
const saveToConfig = useCallback(
async (
{ alerts_zones, detections_zones }: CameraReviewSettingsValueType, // values submitted via the form
) => {
const createQuery = (zones: string[], type: "alerts" | "detections") =>
zones.length
? zones
.map(
(zone) =>
`&cameras.${selectedCamera}.review.${type}.required_zones=${zone}`,
)
.join("")
: cameraConfig?.review[type]?.required_zones &&
cameraConfig?.review[type]?.required_zones.length > 0
? `&cameras.${selectedCamera}.review.${type}.required_zones`
: "";
const alertQueries = createQuery(alerts_zones, "alerts");
const detectionQueries = createQuery(detections_zones, "detections");
axios
.put(`config/set?${alertQueries}${detectionQueries}`, {
requires_restart: 0,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("cameraReview.reviewClassification.toast.success"),
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", {
errorMessage,
ns: "common",
}),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
});
},
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
);
const onCancel = useCallback(() => {
if (!cameraConfig) {
return;
}
setChangedValue(false);
setUnsavedChanges(false);
removeMessage(
"camera_settings",
`review_classification_settings_${selectedCamera}`,
);
form.reset({
alerts_zones: cameraConfig?.review.alerts.required_zones ?? [],
detections_zones: cameraConfig?.review.detections.required_zones || [],
});
setSelectDetections(
!!cameraConfig?.review.detections.required_zones?.length,
);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [removeMessage, selectedCamera, setUnsavedChanges, cameraConfig]);
useEffect(() => {
onCancel();
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCamera]);
useEffect(() => {
if (changedValue) {
addMessage(
"camera_settings",
t("cameraReview.reviewClassification.unsavedChanges", {
camera: selectedCamera,
}),
undefined,
`review_classification_settings_${selectedCamera}`,
);
} else {
removeMessage(
"camera_settings",
`review_classification_settings_${selectedCamera}`,
);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [changedValue, selectedCamera]);
function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true);
saveToConfig(values as CameraReviewSettingsValueType);
}
useEffect(() => {
document.title = t("documentTitle.cameraReview");
}, [t]);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;
}
return (
<>
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<Heading as="h4" className="mb-2">
{t("cameraReview.title")}
</Heading>
<Heading as="h4" className="my-2">
<Trans ns="views/settings">cameraReview.review.title</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={alertsState == "ON"}
onCheckedChange={(isChecked) => {
sendAlerts(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="alerts-enabled">
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
</Label>
</div>
</div>
<div className="flex flex-col">
<div className="flex flex-row items-center">
<Switch
id="detections-enabled"
className="mr-3"
checked={detectionsState == "ON"}
onCheckedChange={(isChecked) => {
sendDetections(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="detections-enabled">
<Trans ns="views/settings">camera.review.detections</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">cameraReview.review.desc</Trans>
</div>
</div>
</div>
{cameraConfig?.objects?.genai?.enabled_in_config && (
<>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.object_descriptions.title
</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={objDescState == "ON"}
onCheckedChange={(isChecked) => {
sendObjDesc(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="genai-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.object_descriptions.desc
</Trans>
</div>
</div>
</>
)}
{cameraConfig?.review?.genai?.enabled_in_config && (
<>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.review_descriptions.title
</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={revDescState == "ON"}
onCheckedChange={(isChecked) => {
sendRevDesc(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="genai-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.review_descriptions.desc
</Trans>
</div>
</div>
</>
)}
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.reviewClassification.title
</Trans>
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
<Trans ns="views/settings">
cameraReview.reviewClassification.desc
</Trans>
</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/review")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mt-2 space-y-6"
>
<div
className={cn(
"w-full max-w-5xl space-y-0",
zones &&
zones?.length > 0 &&
"grid items-start gap-5 md:grid-cols-2",
)}
>
<FormField
control={form.control}
name="alerts_zones"
render={() => (
<FormItem>
{zones && zones?.length > 0 ? (
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
<Trans ns="views/settings">
camera.review.alerts
</Trans>
<MdCircle className="ml-3 size-2 text-severity_alert" />
</FormLabel>
<FormDescription>
<Trans ns="views/settings">
cameraReview.reviewClassification.selectAlertsZones
</Trans>
</FormDescription>
</div>
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
{zones?.map((zone) => (
<FormField
key={zone.name}
control={form.control}
name="alerts_zones"
render={({ field }) => (
<FormItem
key={zone.name}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<FormControl>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={field.value?.includes(
zone.name,
)}
onCheckedChange={(checked) => {
setChangedValue(true);
return checked
? field.onChange([
...field.value,
zone.name,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== zone.name,
),
);
}}
/>
</FormControl>
<FormLabel
className={cn(
"font-normal",
!zone.friendly_name &&
"smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</FormLabel>
</FormItem>
)}
/>
))}
</div>
</>
) : (
<div className="font-normal text-destructive">
<Trans ns="views/settings">
cameraReview.reviewClassification.noDefinedZones
</Trans>
</div>
)}
<FormMessage />
<div className="text-sm">
{watchedAlertsZones && watchedAlertsZones.length > 0
? t(
"cameraReview.reviewClassification.zoneObjectAlertsTips",
{
alertsLabels,
zone: formatList(
watchedAlertsZones.map((zone) =>
getZoneName(zone),
),
),
cameraName: selectCameraName,
},
)
: t(
"cameraReview.reviewClassification.objectAlertsTips",
{
alertsLabels,
cameraName: selectCameraName,
},
)}
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="detections_zones"
render={() => (
<FormItem>
{zones && zones?.length > 0 && (
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
<Trans ns="views/settings">
camera.review.detections
</Trans>
<MdCircle className="ml-3 size-2 text-severity_detection" />
</FormLabel>
{selectDetections && (
<FormDescription>
<Trans ns="views/settings">
cameraReview.reviewClassification.selectDetectionsZones
</Trans>
</FormDescription>
)}
</div>
{selectDetections && (
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
{zones?.map((zone) => (
<FormField
key={zone.name}
control={form.control}
name="detections_zones"
render={({ field }) => (
<FormItem
key={zone.name}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<FormControl>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={field.value?.includes(
zone.name,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...field.value,
zone.name,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== zone.name,
),
);
}}
/>
</FormControl>
<FormLabel
className={cn(
"font-normal",
!zone.friendly_name &&
"smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</FormLabel>
</FormItem>
)}
/>
))}
</div>
)}
<FormMessage />
<div className="mb-0 flex flex-row items-center gap-2">
<Checkbox
id="select-detections"
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={selectDetections}
onCheckedChange={handleCheckedChange}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="select-detections"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans ns="views/settings">
cameraReview.reviewClassification.limitDetections
</Trans>
</label>
</div>
</div>
</>
)}
<div className="text-sm">
{watchedDetectionsZones &&
watchedDetectionsZones.length > 0 ? (
!selectDetections ? (
<Trans
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{
detectionsLabels,
zone: formatList(
watchedDetectionsZones.map((zone) =>
getZoneName(zone),
),
),
cameraName: selectCameraName,
}}
ns="views/settings"
/>
) : (
<Trans
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{
detectionsLabels,
zone: formatList(
watchedDetectionsZones.map((zone) =>
getZoneName(zone),
),
),
cameraName: selectCameraName,
}}
ns="views/settings"
/>
)
) : (
<Trans
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
values={{
detectionsLabels,
cameraName: selectCameraName,
}}
ns="views/settings"
/>
)}
</div>
</FormItem>
)}
/>
</div>
<Separator className="my-2 flex bg-secondary" />
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
type="button"
>
<Trans>button.reset</Trans>
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>
<Trans>button.saving</Trans>
</span>
</div>
) : (
<Trans>button.save</Trans>
)}
</Button>
</div>
</form>
</Form>
</div>
</div>
</>
);
}

View File

@ -1,794 +0,0 @@
import Heading from "@/components/ui/heading";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Toaster, toast } from "sonner";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { Checkbox } from "@/components/ui/checkbox";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import axios from "axios";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { MdCircle } from "react-icons/md";
import { cn } from "@/lib/utils";
import { Trans, useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n";
import {
useAlertsState,
useDetectionsState,
useObjectDescriptionState,
useReviewDescriptionState,
} from "@/api/ws";
import CameraEditForm from "@/components/settings/CameraEditForm";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { formatList } from "@/utils/stringUtil";
type CameraSettingsViewProps = {
selectedCamera: string;
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
type CameraReviewSettingsValueType = {
alerts_zones: string[];
detections_zones: string[];
};
export default function CameraSettingsView({
selectedCamera,
setUnsavedChanges,
}: CameraSettingsViewProps) {
const { t } = useTranslation(["views/settings"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
}
}, [config, selectedCamera]);
const [changedValue, setChangedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectDetections, setSelectDetections] = useState(false);
const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">(
"settings",
); // Control view state
const [editCameraName, setEditCameraName] = useState<string | undefined>(
undefined,
); // Track camera being edited
const [showWizard, setShowWizard] = useState(false);
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
const selectCameraName = useCameraFriendlyName(selectedCamera);
// zones and labels
const getZoneName = useCallback(
(zoneId: string, cameraId?: string) =>
resolveZoneName(config, zoneId, cameraId),
[config],
);
const zones = useMemo(() => {
if (cameraConfig) {
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
camera: cameraConfig.name,
name,
friendly_name: cameraConfig.zones[name].friendly_name,
objects: zoneData.objects,
color: zoneData.color,
}));
}
}, [cameraConfig]);
const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels
? formatList(
cameraConfig.review.alerts.labels.map((label) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
: "";
}, [cameraConfig]);
const detectionsLabels = useMemo(() => {
return cameraConfig?.review.detections.labels
? formatList(
cameraConfig.review.detections.labels.map((label) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
: "";
}, [cameraConfig]);
// form
const formSchema = z.object({
alerts_zones: z.array(z.string()),
detections_zones: z.array(z.string()),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
alerts_zones: cameraConfig?.review.alerts.required_zones || [],
detections_zones: cameraConfig?.review.detections.required_zones || [],
},
});
const watchedAlertsZones = form.watch("alerts_zones");
const watchedDetectionsZones = form.watch("detections_zones");
const { payload: alertsState, send: sendAlerts } =
useAlertsState(selectedCamera);
const { payload: detectionsState, send: sendDetections } =
useDetectionsState(selectedCamera);
const { payload: objDescState, send: sendObjDesc } =
useObjectDescriptionState(selectedCamera);
const { payload: revDescState, send: sendRevDesc } =
useReviewDescriptionState(selectedCamera);
const handleCheckedChange = useCallback(
(isChecked: boolean) => {
if (!isChecked) {
form.reset({
alerts_zones: watchedAlertsZones,
detections_zones: [],
});
}
setChangedValue(true);
setSelectDetections(isChecked as boolean);
},
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[watchedAlertsZones],
);
const saveToConfig = useCallback(
async (
{ alerts_zones, detections_zones }: CameraReviewSettingsValueType, // values submitted via the form
) => {
const createQuery = (zones: string[], type: "alerts" | "detections") =>
zones.length
? zones
.map(
(zone) =>
`&cameras.${selectedCamera}.review.${type}.required_zones=${zone}`,
)
.join("")
: cameraConfig?.review[type]?.required_zones &&
cameraConfig?.review[type]?.required_zones.length > 0
? `&cameras.${selectedCamera}.review.${type}.required_zones`
: "";
const alertQueries = createQuery(alerts_zones, "alerts");
const detectionQueries = createQuery(detections_zones, "detections");
axios
.put(`config/set?${alertQueries}${detectionQueries}`, {
requires_restart: 0,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("cameraReview.reviewClassification.toast.success"),
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", {
errorMessage,
ns: "common",
}),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
});
},
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
);
const onCancel = useCallback(() => {
if (!cameraConfig) {
return;
}
setChangedValue(false);
setUnsavedChanges(false);
removeMessage(
"camera_settings",
`review_classification_settings_${selectedCamera}`,
);
form.reset({
alerts_zones: cameraConfig?.review.alerts.required_zones ?? [],
detections_zones: cameraConfig?.review.detections.required_zones || [],
});
setSelectDetections(
!!cameraConfig?.review.detections.required_zones?.length,
);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [removeMessage, selectedCamera, setUnsavedChanges, cameraConfig]);
useEffect(() => {
onCancel();
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCamera]);
useEffect(() => {
if (changedValue) {
addMessage(
"camera_settings",
t("cameraReview.reviewClassification.unsavedChanges", {
camera: selectedCamera,
}),
undefined,
`review_classification_settings_${selectedCamera}`,
);
} else {
removeMessage(
"camera_settings",
`review_classification_settings_${selectedCamera}`,
);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [changedValue, selectedCamera]);
function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true);
saveToConfig(values as CameraReviewSettingsValueType);
}
useEffect(() => {
document.title = t("documentTitle.cameraReview");
}, [t]);
// Handle back navigation from add/edit form
const handleBack = useCallback(() => {
setViewMode("settings");
setEditCameraName(undefined);
updateConfig();
}, [updateConfig]);
if (!cameraConfig && !selectedCamera && viewMode === "settings") {
return <ActivityIndicator />;
}
return (
<>
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
{viewMode === "settings" ? (
<>
<Heading as="h4" className="mb-2">
{t("cameraReview.title")}
</Heading>
<Heading as="h4" className="my-2">
<Trans ns="views/settings">cameraReview.review.title</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={alertsState == "ON"}
onCheckedChange={(isChecked) => {
sendAlerts(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="alerts-enabled">
<Trans ns="views/settings">
cameraReview.review.alerts
</Trans>
</Label>
</div>
</div>
<div className="flex flex-col">
<div className="flex flex-row items-center">
<Switch
id="detections-enabled"
className="mr-3"
checked={detectionsState == "ON"}
onCheckedChange={(isChecked) => {
sendDetections(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="detections-enabled">
<Trans ns="views/settings">
camera.review.detections
</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">cameraReview.review.desc</Trans>
</div>
</div>
</div>
{cameraConfig?.objects?.genai?.enabled_in_config && (
<>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.object_descriptions.title
</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={objDescState == "ON"}
onCheckedChange={(isChecked) => {
sendObjDesc(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="genai-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.object_descriptions.desc
</Trans>
</div>
</div>
</>
)}
{cameraConfig?.review?.genai?.enabled_in_config && (
<>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.review_descriptions.title
</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={revDescState == "ON"}
onCheckedChange={(isChecked) => {
sendRevDesc(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="genai-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.review_descriptions.desc
</Trans>
</div>
</div>
</>
)}
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.reviewClassification.title
</Trans>
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
<Trans ns="views/settings">
cameraReview.reviewClassification.desc
</Trans>
</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/review")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mt-2 space-y-6"
>
<div
className={cn(
"w-full max-w-5xl space-y-0",
zones &&
zones?.length > 0 &&
"grid items-start gap-5 md:grid-cols-2",
)}
>
<FormField
control={form.control}
name="alerts_zones"
render={() => (
<FormItem>
{zones && zones?.length > 0 ? (
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
<Trans ns="views/settings">
camera.review.alerts
</Trans>
<MdCircle className="ml-3 size-2 text-severity_alert" />
</FormLabel>
<FormDescription>
<Trans ns="views/settings">
cameraReview.reviewClassification.selectAlertsZones
</Trans>
</FormDescription>
</div>
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
{zones?.map((zone) => (
<FormField
key={zone.name}
control={form.control}
name="alerts_zones"
render={({ field }) => (
<FormItem
key={zone.name}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<FormControl>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={field.value?.includes(
zone.name,
)}
onCheckedChange={(checked) => {
setChangedValue(true);
return checked
? field.onChange([
...field.value,
zone.name,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== zone.name,
),
);
}}
/>
</FormControl>
<FormLabel
className={cn(
"font-normal",
!zone.friendly_name &&
"smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</FormLabel>
</FormItem>
)}
/>
))}
</div>
</>
) : (
<div className="font-normal text-destructive">
<Trans ns="views/settings">
cameraReview.reviewClassification.noDefinedZones
</Trans>
</div>
)}
<FormMessage />
<div className="text-sm">
{watchedAlertsZones && watchedAlertsZones.length > 0
? t(
"cameraReview.reviewClassification.zoneObjectAlertsTips",
{
alertsLabels,
zone: formatList(
watchedAlertsZones.map((zone) =>
getZoneName(zone),
),
),
cameraName: selectCameraName,
},
)
: t(
"cameraReview.reviewClassification.objectAlertsTips",
{
alertsLabels,
cameraName: selectCameraName,
},
)}
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="detections_zones"
render={() => (
<FormItem>
{zones && zones?.length > 0 && (
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
<Trans ns="views/settings">
camera.review.detections
</Trans>
<MdCircle className="ml-3 size-2 text-severity_detection" />
</FormLabel>
{selectDetections && (
<FormDescription>
<Trans ns="views/settings">
cameraReview.reviewClassification.selectDetectionsZones
</Trans>
</FormDescription>
)}
</div>
{selectDetections && (
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
{zones?.map((zone) => (
<FormField
key={zone.name}
control={form.control}
name="detections_zones"
render={({ field }) => (
<FormItem
key={zone.name}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<FormControl>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={field.value?.includes(
zone.name,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...field.value,
zone.name,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== zone.name,
),
);
}}
/>
</FormControl>
<FormLabel
className={cn(
"font-normal",
!zone.friendly_name &&
"smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</FormLabel>
</FormItem>
)}
/>
))}
</div>
)}
<FormMessage />
<div className="mb-0 flex flex-row items-center gap-2">
<Checkbox
id="select-detections"
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={selectDetections}
onCheckedChange={handleCheckedChange}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="select-detections"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans ns="views/settings">
cameraReview.reviewClassification.limitDetections
</Trans>
</label>
</div>
</div>
</>
)}
<div className="text-sm">
{watchedDetectionsZones &&
watchedDetectionsZones.length > 0 ? (
!selectDetections ? (
<Trans
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{
detectionsLabels,
zone: formatList(
watchedDetectionsZones.map((zone) =>
getZoneName(zone),
),
),
cameraName: selectCameraName,
}}
ns="views/settings"
/>
) : (
<Trans
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{
detectionsLabels,
zone: formatList(
watchedDetectionsZones.map((zone) =>
getZoneName(zone),
),
),
cameraName: selectCameraName,
}}
ns="views/settings"
/>
)
) : (
<Trans
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
values={{
detectionsLabels,
cameraName: selectCameraName,
}}
ns="views/settings"
/>
)}
</div>
</FormItem>
)}
/>
</div>
<Separator className="my-2 flex bg-secondary" />
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
type="button"
>
<Trans>button.reset</Trans>
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>
<Trans>button.saving</Trans>
</span>
</div>
) : (
<Trans>button.save</Trans>
)}
</Button>
</div>
</form>
</Form>
</>
) : (
<>
<div className="mb-4 flex items-center gap-2">
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={handleBack}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
</div>
<div className="md:max-w-5xl">
<CameraEditForm
cameraName={viewMode === "edit" ? editCameraName : undefined}
onSave={handleBack}
onCancel={handleBack}
/>
</div>
</>
)}
</div>
</div>
<CameraWizardDialog
open={showWizard}
onClose={() => setShowWizard(false)}
/>
</>
);
}