Compare commits

...

3 Commits

Author SHA1 Message Date
Chris
6559c973bf
Merge 793906bb68 into 213a1fbd00 2025-11-18 19:45:20 -05:00
Josh Hawkins
213a1fbd00
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>
2025-11-18 15:33:42 -07:00
Chris Suich
793906bb68 feat: use persisted state for selected camera on settings page
Leverage the usePersistence() hook to store the selected camera so that
navigating away from the settings page and back will remember the
selected camera.
2025-11-15 11:26:59 -05:00
12 changed files with 1130 additions and 906 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.
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)
- 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:
- /path/to/your/config:/config
- /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
tmpfs:
size: 1000000000
@ -310,7 +310,7 @@ services:
- /etc/localtime:/etc/localtime:ro
- /path/to/your/config:/config
- /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
tmpfs:
size: 1000000000

View File

@ -179,6 +179,36 @@ def config(request: Request):
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")
def config_raw():
config_file = find_config_file()

View File

@ -762,6 +762,15 @@ async def recording_clip(
.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_path = os.path.join(CACHE_DIR, file_name)
with open(file_path, "w") as file:

View File

@ -113,6 +113,7 @@ class StorageMaintainer(threading.Thread):
recordings: Recordings = (
Recordings.select(
Recordings.id,
Recordings.camera,
Recordings.start_time,
Recordings.end_time,
Recordings.segment_size,
@ -137,7 +138,7 @@ class StorageMaintainer(threading.Thread):
)
event_start = 0
deleted_recordings = set()
deleted_recordings = []
for recording in recordings:
# check if 1 hour of storage has been reclaimed
if deleted_segments_size > hourly_bandwidth:
@ -172,7 +173,7 @@ class StorageMaintainer(threading.Thread):
if not keep:
try:
clear_and_unlink(Path(recording.path), missing_ok=False)
deleted_recordings.add(recording.id)
deleted_recordings.append(recording)
deleted_segments_size += recording.segment_size
except FileNotFoundError:
# this file was not found so we must assume no space was cleaned up
@ -186,6 +187,9 @@ class StorageMaintainer(threading.Thread):
recordings = (
Recordings.select(
Recordings.id,
Recordings.camera,
Recordings.start_time,
Recordings.end_time,
Recordings.path,
Recordings.segment_size,
)
@ -201,7 +205,7 @@ class StorageMaintainer(threading.Thread):
try:
clear_and_unlink(Path(recording.path), missing_ok=False)
deleted_segments_size += recording.segment_size
deleted_recordings.add(recording.id)
deleted_recordings.append(recording)
except FileNotFoundError:
# this file was not found so we must assume no space was cleaned up
pass
@ -211,7 +215,50 @@ class StorageMaintainer(threading.Thread):
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
# delete up to 100,000 at a time
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):
Recordings.delete().where(
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 { z } from "zod";
import ActivityIndicator from "../indicators/activity-indicator";
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import useSWR from "swr";
import {
Dialog,
DialogContent,
@ -35,6 +36,7 @@ import { LuCheck, LuX } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { isDesktop, isMobile } from "react-device-detect";
import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import {
MobilePage,
MobilePageContent,
@ -54,9 +56,15 @@ export default function CreateUserDialog({
onCreate,
onCancel,
}: CreateUserOverlayProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation(["views/settings"]);
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
.object({
user: z
@ -69,7 +77,7 @@ export default function CreateUserDialog({
confirmPassword: z
.string()
.min(1, t("users.dialog.createUser.confirmPassword")),
role: z.enum(["admin", "viewer"]),
role: z.string().min(1),
})
.refine((data) => data.password === data.confirmPassword, {
message: t("users.dialog.form.password.notMatch"),
@ -246,24 +254,22 @@ export default function CreateUserDialog({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
value="admin"
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
<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" />
<span>{t("role.viewer", { ns: "common" })}</span>
</div>
</SelectItem>
{roles.map((r) => (
<SelectItem
value={r}
key={r}
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
{r === "admin" ? (
<Shield className="h-4 w-4 text-primary" />
) : (
<User className="h-4 w-4 text-muted-foreground" />
)}
<span>{t(`role.${r}`, { ns: "common" }) || r}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<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 { baseUrl } from "@/api/baseUrl";
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 {
DropdownMenu,
DropdownMenuTrigger,
@ -75,6 +79,139 @@ export function TrackingDetails({
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) => {
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
return resolveZoneName(config, zone);
@ -148,17 +285,14 @@ export function TrackingDetails({
return;
}
// For video mode: convert to video-relative time and seek player
const eventStartRecord =
(event.start_time ?? 0) + annotationOffset / 1000;
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const relativeTime = targetTimeRecord - videoStartTime;
// For video mode: convert to video-relative time (accounting for motion-only gaps)
const relativeTime = timestampToVideoTime(targetTimeRecord);
if (videoRef.current) {
videoRef.current.currentTime = relativeTime;
}
},
[event.start_time, annotationOffset, displaySource],
[annotationOffset, displaySource, timestampToVideoTime],
);
const formattedStart = config
@ -177,21 +311,22 @@ export function TrackingDetails({
})
: "";
const formattedEnd = config
? formatUnixTimestampToDateTime(event.end_time ?? 0, {
timezone: config.ui.timezone,
date_format:
config.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour", {
ns: "common",
})
: t("time.formattedTimestamp.12hour", {
ns: "common",
}),
time_style: "medium",
date_style: "medium",
})
: "";
const formattedEnd =
config && event.end_time != null
? formatUnixTimestampToDateTime(event.end_time, {
timezone: config.ui.timezone,
date_format:
config.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour", {
ns: "common",
})
: t("time.formattedTimestamp.12hour", {
ns: "common",
}),
time_style: "medium",
date_style: "medium",
})
: "";
useEffect(() => {
if (!eventSequence || eventSequence.length === 0) return;
@ -210,24 +345,14 @@ export function TrackingDetails({
}
// seekToTimestamp is a record stream timestamp
// event.start_time is detect stream time, convert to record
// The video clip starts at (eventStartRecord - REVIEW_PADDING)
// Convert to video position (accounting for motion-only recording gaps)
if (!videoRef.current) return;
const eventStartRecord = event.start_time + annotationOffset / 1000;
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const relativeTime = seekToTimestamp - videoStartTime;
const relativeTime = timestampToVideoTime(seekToTimestamp);
if (relativeTime >= 0) {
videoRef.current.currentTime = relativeTime;
}
setSeekToTimestamp(null);
}, [
seekToTimestamp,
event.start_time,
annotationOffset,
apiHost,
event.camera,
displaySource,
]);
}, [seekToTimestamp, displaySource, timestampToVideoTime]);
const isWithinEventRange = useMemo(() => {
if (effectiveTime === undefined || event.start_time === undefined) {
@ -334,14 +459,13 @@ export function TrackingDetails({
const handleTimeUpdate = useCallback(
(time: number) => {
// event.start_time is detect stream time, convert to record
const eventStartRecord = event.start_time + annotationOffset / 1000;
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const absoluteTime = time + videoStartTime;
// Convert video player time back to timeline timestamp
// accounting for motion-only recording gaps
const absoluteTime = videoTimeToTimestamp(time);
setCurrentTime(absoluteTime);
},
[event.start_time, annotationOffset],
[videoTimeToTimestamp],
);
const [src, setSrc] = useState(
@ -525,9 +649,16 @@ export function TrackingDetails({
</div>
<div className="flex items-center gap-2">
<span className="capitalize">{label}</span>
<span className="md:text-md text-xs text-secondary-foreground">
{formattedStart ?? ""} - {formattedEnd ?? ""}
</span>
<div className="md:text-md flex items-center text-xs text-secondary-foreground">
{formattedStart ?? ""}
{event.end_time != null ? (
<> - {formattedEnd}</>
) : (
<div className="inline-block">
<ActivityIndicator className="ml-3 size-4" />
</div>
)}
</div>
{event.data?.recognized_license_plate && (
<>
<span className="text-secondary-foreground">·</span>

View File

@ -18,7 +18,7 @@ import { z } from "zod";
import axios from "axios";
import { toast, Toaster } from "sonner";
import { useTranslation } from "react-i18next";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { LuTrash2, LuPlus } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FrigateConfig } from "@/types/frigateConfig";
@ -42,7 +42,15 @@ export default function CameraEditForm({
onCancel,
}: CameraEditFormProps) {
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 formSchema = useMemo(
@ -145,14 +153,23 @@ export default function CameraEditForm({
if (cameraName && config?.cameras[cameraName]) {
const camera = config.cameras[cameraName];
defaultValues.enabled = camera.enabled ?? true;
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length
? camera.ffmpeg.inputs.map((input) => ({
// 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[],
}))
: defaultValues.ffmpeg.inputs;
: camera.ffmpeg?.inputs?.length
? camera.ffmpeg.inputs.map((input) => ({
path: input.path,
roles: input.roles as Role[],
}))
: defaultValues.ffmpeg.inputs;
const go2rtcStreams = config.go2rtc?.streams || {};
const go2rtcStreams =
rawPaths?.go2rtc?.streams || config.go2rtc?.streams || {};
const cameraStreams: Record<string, string[]> = {};
// get candidate stream names for this camera. could be the camera's own name,
@ -196,6 +213,60 @@ export default function CameraEditForm({
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({
control: form.control,
name: "ffmpeg.inputs",
@ -268,6 +339,8 @@ export default function CameraEditForm({
}),
{ position: "top-center" },
);
mutateConfig();
mutateRawPaths();
if (onSave) onSave();
});
} else {
@ -277,6 +350,8 @@ export default function CameraEditForm({
}),
{ position: "top-center" },
);
mutateConfig();
mutateRawPaths();
if (onSave) onSave();
}
} else {

View File

@ -26,7 +26,7 @@ import useSWR from "swr";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
import { PolygonType } from "@/types/canvas";
import CameraSettingsView from "@/views/settings/CameraSettingsView";
import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView";
import CameraManagementView from "@/views/settings/CameraManagementView";
import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
@ -37,6 +37,7 @@ import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView";
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { usePersistence } from "@/hooks/use-persistence";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws";
import { useIsAdmin } from "@/hooks/use-is-admin";
@ -93,7 +94,7 @@ const settingsGroups = [
label: "cameras",
items: [
{ key: "cameraManagement", component: CameraManagementView },
{ key: "cameraReview", component: CameraSettingsView },
{ key: "cameraReview", component: CameraReviewSettingsView },
{ key: "masksAndZones", component: MasksAndZonesView },
{ key: "motionTuner", component: MotionTunerView },
],
@ -207,7 +208,21 @@ export default function Settings() {
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState<string>("");
const [persistedCamera, setPersistedCamera] = usePersistence(
"selectedCamera",
"",
);
const [selectedCamera, setSelectedCamera] = useState(persistedCamera);
useEffect(() => {
if (persistedCamera) {
setSelectedCamera(persistedCamera);
}
}, [persistedCamera]);
useEffect(() => {
if (selectedCamera) {
setPersistedCamera(selectedCamera);
}
}, [selectedCamera, setPersistedCamera]);
const { payload: allCameraStates } = useInitialCameraState(
cameras.length > 0 ? cameras[0].name : "",

View File

@ -5,17 +5,9 @@ import { Button } from "@/components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import CameraEditForm from "@/components/settings/CameraEditForm";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import { LuPlus } from "react-icons/lu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
@ -90,31 +82,6 @@ export default function CameraManagementView({
</Button>
{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" />
<div className="max-w-7xl space-y-4">
<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)}
/>
</>
);
}