Add Camera Wizard (#20461)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* fetch more from ffprobe

* add detailed param to ffprobe endpoint

* add dots variant to step indicator

* add classname

* tweak colors for dark mode to match figma

* add step 1 form

* add helper function for ffmpeg snapshot

* add go2rtc stream add and ffprobe snapshot endpoints

* add camera image and stream details on successful test

* step 1 tweaks

* step 2 and i18n

* types

* step 1 and 2 tweaks

* add wizard to camera settings view

* add data unit i18n keys

* restream tweak

* fix type

* implement rough idea for step 3

* add api endpoint to delete stream from go2rtc

* add main wizard dialog component

* extract logic for friendly_name and use in wizard

* add i18n and popover for brand url

* add camera name to top

* consolidate validation logic

* prevent dialog from closing when clicking outside

* center camera name on mobile

* add help/docs link popovers

* keep spaces in friendly name

* add stream details to overlay like stats in liveplayer

* add validation results pane to step 3

* ensure test is invalidated if stream is changed

* only display validation results and enable save button if all streams have been tested

* tweaks

* normalize camera name to lower case and improve hash generation

* move wizard to subfolder

* tweaks

* match look of camera edit form to wizard

* move wizard and edit form to its own component

* move enabled/disabled switch to management section

* clean up

* fixes

* fix mobile
This commit is contained in:
Josh Hawkins 2025-10-13 11:52:08 -05:00 committed by GitHub
parent 423693d14d
commit 9d85136f8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 3571 additions and 429 deletions

View File

@ -43,6 +43,7 @@ from frigate.util.builtin import (
update_yaml_file_bulk, update_yaml_file_bulk,
) )
from frigate.util.config import find_config_file from frigate.util.config import find_config_file
from frigate.util.image import run_ffmpeg_snapshot
from frigate.util.services import ( from frigate.util.services import (
ffprobe_stream, ffprobe_stream,
get_nvidia_driver_info, get_nvidia_driver_info,
@ -107,6 +108,80 @@ def go2rtc_camera_stream(request: Request, camera_name: str):
return JSONResponse(content=stream_data) return JSONResponse(content=stream_data)
@router.put(
"/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))]
)
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
"""Add or update a go2rtc stream configuration."""
try:
params = {"name": stream_name}
if src:
params["src"] = src
r = requests.put(
"http://127.0.0.1:1984/api/streams",
params=params,
timeout=10,
)
if not r.ok:
logger.error(f"Failed to add go2rtc stream {stream_name}: {r.text}")
return JSONResponse(
content=(
{"success": False, "message": f"Failed to add stream: {r.text}"}
),
status_code=r.status_code,
)
return JSONResponse(
content={"success": True, "message": "Stream added successfully"}
)
except requests.RequestException as e:
logger.error(f"Error communicating with go2rtc: {e}")
return JSONResponse(
content=(
{
"success": False,
"message": "Error communicating with go2rtc",
}
),
status_code=500,
)
@router.delete(
"/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))]
)
def go2rtc_delete_stream(stream_name: str):
"""Delete a go2rtc stream."""
try:
r = requests.delete(
"http://127.0.0.1:1984/api/streams",
params={"src": stream_name},
timeout=10,
)
if not r.ok:
logger.error(f"Failed to delete go2rtc stream {stream_name}: {r.text}")
return JSONResponse(
content=(
{"success": False, "message": f"Failed to delete stream: {r.text}"}
),
status_code=r.status_code,
)
return JSONResponse(
content={"success": True, "message": "Stream deleted successfully"}
)
except requests.RequestException as e:
logger.error(f"Error communicating with go2rtc: {e}")
return JSONResponse(
content=(
{
"success": False,
"message": "Error communicating with go2rtc",
}
),
status_code=500,
)
@router.get("/version", response_class=PlainTextResponse) @router.get("/version", response_class=PlainTextResponse)
def version(): def version():
return VERSION return VERSION
@ -453,7 +528,7 @@ def config_set(request: Request, body: AppConfigSetBody):
@router.get("/ffprobe") @router.get("/ffprobe")
def ffprobe(request: Request, paths: str = ""): def ffprobe(request: Request, paths: str = "", detailed: bool = False):
path_param = paths path_param = paths
if not path_param: if not path_param:
@ -492,9 +567,11 @@ def ffprobe(request: Request, paths: str = ""):
output = [] output = []
for path in paths: for path in paths:
ffprobe = ffprobe_stream(request.app.frigate_config.ffmpeg, path.strip()) ffprobe = ffprobe_stream(
output.append( request.app.frigate_config.ffmpeg, path.strip(), detailed=detailed
{ )
result = {
"return_code": ffprobe.returncode, "return_code": ffprobe.returncode,
"stderr": ( "stderr": (
ffprobe.stderr.decode("unicode_escape").strip() ffprobe.stderr.decode("unicode_escape").strip()
@ -507,11 +584,115 @@ def ffprobe(request: Request, paths: str = ""):
else "" else ""
), ),
} }
# Add detailed metadata if requested and probe was successful
if detailed and ffprobe.returncode == 0 and result["stdout"]:
try:
probe_data = result["stdout"]
metadata = {}
# Extract video stream information
video_stream = None
audio_stream = None
for stream in probe_data.get("streams", []):
if stream.get("codec_type") == "video":
video_stream = stream
elif stream.get("codec_type") == "audio":
audio_stream = stream
# Video metadata
if video_stream:
metadata["video"] = {
"codec": video_stream.get("codec_name"),
"width": video_stream.get("width"),
"height": video_stream.get("height"),
"fps": _extract_fps(video_stream.get("r_frame_rate")),
"pixel_format": video_stream.get("pix_fmt"),
"profile": video_stream.get("profile"),
"level": video_stream.get("level"),
}
# Calculate resolution string
if video_stream.get("width") and video_stream.get("height"):
metadata["video"]["resolution"] = (
f"{video_stream['width']}x{video_stream['height']}"
) )
# Audio metadata
if audio_stream:
metadata["audio"] = {
"codec": audio_stream.get("codec_name"),
"channels": audio_stream.get("channels"),
"sample_rate": audio_stream.get("sample_rate"),
"channel_layout": audio_stream.get("channel_layout"),
}
# Container/format metadata
if probe_data.get("format"):
format_info = probe_data["format"]
metadata["container"] = {
"format": format_info.get("format_name"),
"duration": format_info.get("duration"),
"size": format_info.get("size"),
}
result["metadata"] = metadata
except Exception as e:
logger.warning(f"Failed to extract detailed metadata: {e}")
# Continue without metadata if parsing fails
output.append(result)
return JSONResponse(content=output) return JSONResponse(content=output)
@router.get("/ffprobe/snapshot", dependencies=[Depends(require_role(["admin"]))])
def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10):
"""Get a snapshot from a stream URL using ffmpeg."""
if not url:
return JSONResponse(
content={"success": False, "message": "URL parameter is required"},
status_code=400,
)
config: FrigateConfig = request.app.frigate_config
image_data, error = run_ffmpeg_snapshot(
config.ffmpeg, url, "mjpeg", timeout=timeout
)
if image_data:
return Response(
image_data,
media_type="image/jpeg",
headers={"Cache-Control": "no-store"},
)
elif error == "timeout":
return JSONResponse(
content={"success": False, "message": "Timeout capturing snapshot"},
status_code=408,
)
else:
logger.error(f"ffmpeg failed: {error}")
return JSONResponse(
content={"success": False, "message": "Failed to capture snapshot"},
status_code=500,
)
def _extract_fps(r_frame_rate: str) -> float | None:
"""Extract FPS from ffprobe r_frame_rate string (e.g., '30/1' -> 30.0)"""
if not r_frame_rate:
return None
try:
num, den = r_frame_rate.split("/")
return round(float(num) / float(den), 2)
except (ValueError, ZeroDivisionError):
return None
@router.get("/vainfo") @router.get("/vainfo")
def vainfo(): def vainfo():
vainfo = vainfo_hwaccel() vainfo = vainfo_hwaccel()

View File

@ -943,6 +943,58 @@ def add_mask(mask: str, mask_img: np.ndarray):
cv2.fillPoly(mask_img, pts=[contour], color=(0)) cv2.fillPoly(mask_img, pts=[contour], color=(0))
def run_ffmpeg_snapshot(
ffmpeg,
input_path: str,
codec: str,
seek_time: Optional[float] = None,
height: Optional[int] = None,
timeout: Optional[int] = None,
) -> tuple[Optional[bytes], str]:
"""Run ffmpeg to extract a snapshot/image from a video source."""
ffmpeg_cmd = [
ffmpeg.ffmpeg_path,
"-hide_banner",
"-loglevel",
"warning",
]
if seek_time is not None:
ffmpeg_cmd.extend(["-ss", f"00:00:{seek_time}"])
ffmpeg_cmd.extend(
[
"-i",
input_path,
"-frames:v",
"1",
"-c:v",
codec,
"-f",
"image2pipe",
"-",
]
)
if height is not None:
ffmpeg_cmd.insert(-3, "-vf")
ffmpeg_cmd.insert(-3, f"scale=-1:{height}")
try:
process = sp.run(
ffmpeg_cmd,
capture_output=True,
timeout=timeout,
)
if process.returncode == 0 and process.stdout:
return process.stdout, ""
else:
return None, process.stderr.decode() if process.stderr else "ffmpeg failed"
except sp.TimeoutExpired:
return None, "timeout"
def get_image_from_recording( def get_image_from_recording(
ffmpeg, # Ffmpeg Config ffmpeg, # Ffmpeg Config
file_path: str, file_path: str,
@ -952,37 +1004,11 @@ def get_image_from_recording(
) -> Optional[Any]: ) -> Optional[Any]:
"""retrieve a frame from given time in recording file.""" """retrieve a frame from given time in recording file."""
ffmpeg_cmd = [ image_data, _ = run_ffmpeg_snapshot(
ffmpeg.ffmpeg_path, ffmpeg, file_path, codec, seek_time=relative_frame_time, height=height
"-hide_banner",
"-loglevel",
"warning",
"-ss",
f"00:00:{relative_frame_time}",
"-i",
file_path,
"-frames:v",
"1",
"-c:v",
codec,
"-f",
"image2pipe",
"-",
]
if height is not None:
ffmpeg_cmd.insert(-3, "-vf")
ffmpeg_cmd.insert(-3, f"scale=-1:{height}")
process = sp.run(
ffmpeg_cmd,
capture_output=True,
) )
if process.returncode == 0: return image_data
return process.stdout
else:
return None
def get_histogram(image, x_min, y_min, x_max, y_max): def get_histogram(image, x_min, y_min, x_max, y_max):

View File

@ -515,9 +515,20 @@ def get_jetson_stats() -> Optional[dict[int, dict]]:
return results return results
def ffprobe_stream(ffmpeg, path: str) -> sp.CompletedProcess: def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
"""Run ffprobe on stream.""" """Run ffprobe on stream."""
clean_path = escape_special_characters(path) clean_path = escape_special_characters(path)
# Base entries that are always included
stream_entries = "codec_long_name,width,height,bit_rate,duration,display_aspect_ratio,avg_frame_rate"
# Additional detailed entries
if detailed:
stream_entries += ",codec_name,profile,level,pix_fmt,channels,sample_rate,channel_layout,r_frame_rate"
format_entries = "format_name,size,bit_rate,duration"
else:
format_entries = None
ffprobe_cmd = [ ffprobe_cmd = [
ffmpeg.ffprobe_path, ffmpeg.ffprobe_path,
"-timeout", "-timeout",
@ -525,11 +536,15 @@ def ffprobe_stream(ffmpeg, path: str) -> sp.CompletedProcess:
"-print_format", "-print_format",
"json", "json",
"-show_entries", "-show_entries",
"stream=codec_long_name,width,height,bit_rate,duration,display_aspect_ratio,avg_frame_rate", f"stream={stream_entries}",
"-loglevel",
"quiet",
clean_path,
] ]
# Add format entries for detailed mode
if detailed and format_entries:
ffprobe_cmd.extend(["-show_entries", f"format={format_entries}"])
ffprobe_cmd.extend(["-loglevel", "quiet", clean_path])
return sp.run(ffprobe_cmd, capture_output=True) return sp.run(ffprobe_cmd, capture_output=True)

View File

@ -82,6 +82,14 @@
"length": { "length": {
"feet": "feet", "feet": "feet",
"meters": "meters" "meters": "meters"
},
"data": {
"kbps": "kB/s",
"mbps": "MB/s",
"gbps": "GB/s",
"kbph": "kB/hour",
"mbph": "MB/hour",
"gbph": "GB/hour"
} }
}, },
"label": { "label": {

View File

@ -2,7 +2,8 @@
"documentTitle": { "documentTitle": {
"default": "Settings - Frigate", "default": "Settings - Frigate",
"authentication": "Authentication Settings - Frigate", "authentication": "Authentication Settings - Frigate",
"camera": "Camera Settings - Frigate", "cameraManagement": "Manage Cameras - Frigate",
"cameraReview": "Camera Review Settings - Frigate",
"enrichments": "Enrichments Settings - Frigate", "enrichments": "Enrichments Settings - Frigate",
"masksAndZones": "Mask and Zone Editor - Frigate", "masksAndZones": "Mask and Zone Editor - Frigate",
"motionTuner": "Motion Tuner - Frigate", "motionTuner": "Motion Tuner - Frigate",
@ -14,7 +15,8 @@
"menu": { "menu": {
"ui": "UI", "ui": "UI",
"enrichments": "Enrichments", "enrichments": "Enrichments",
"cameras": "Camera Settings", "cameraManagement": "Management",
"cameraReview": "Review",
"masksAndZones": "Masks / Zones", "masksAndZones": "Masks / Zones",
"motionTuner": "Motion Tuner", "motionTuner": "Motion Tuner",
"triggers": "Triggers", "triggers": "Triggers",
@ -143,12 +145,176 @@
"error": "Failed to save config changes: {{errorMessage}}" "error": "Failed to save config changes: {{errorMessage}}"
} }
}, },
"camera": { "cameraWizard": {
"title": "Camera Settings", "title": "Add Camera",
"description": "Follow the steps below to add a new camera to your Frigate installation.",
"steps": {
"nameAndConnection": "Name & Connection",
"streamConfiguration": "Stream Configuration",
"validationAndTesting": "Validation & Testing"
},
"save": {
"success": "Successfully saved new camera {{cameraName}}.",
"failure": "Error saving {{cameraName}}."
},
"testResultLabels": {
"resolution": "Resolution",
"video": "Video",
"audio": "Audio",
"fps": "FPS"
},
"commonErrors": {
"noUrl": "Please provide a valid stream URL",
"testFailed": "Stream test failed: {{error}}"
},
"step1": {
"description": "Enter your camera details and test the connection.",
"cameraName": "Camera Name",
"cameraNamePlaceholder": "e.g., front_door or Back Yard Overview",
"host": "Host/IP Address",
"port": "Port",
"username": "Username",
"usernamePlaceholder": "Optional",
"password": "Password",
"passwordPlaceholder": "Optional",
"selectTransport": "Select transport protocol",
"cameraBrand": "Camera Brand",
"selectBrand": "Select camera brand for URL template",
"customUrl": "Custom Stream URL",
"brandInformation": "Brand information",
"brandUrlFormat": "For cameras with the RTSP URL format as: {{exampleUrl}}",
"customUrlPlaceholder": "rtsp://username:password@host:port/path",
"testConnection": "Test Connection",
"testSuccess": "Connection test successful!",
"testFailed": "Connection test failed. Please check your input and try again.",
"streamDetails": "Stream Details",
"warnings": {
"noSnapshot": "Unable to fetch a snapshot from the configured stream."
},
"errors": {
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
"nameRequired": "Camera name is required",
"nameLength": "Camera name must be 64 characters or less",
"invalidCharacters": "Camera name contains invalid characters",
"nameExists": "Camera name already exists"
}
},
"step2": {
"description": "Configure stream roles and add additional streams for your camera.",
"streamsTitle": "Camera Streams",
"addStream": "Add Stream",
"addAnotherStream": "Add Another Stream",
"streamTitle": "Stream {{number}}",
"streamUrl": "Stream URL",
"streamUrlPlaceholder": "rtsp://username:password@host:port/path",
"url": "URL",
"resolution": "Resolution",
"selectResolution": "Select resolution",
"quality": "Quality",
"selectQuality": "Select quality",
"roles": "Roles",
"roleLabels": {
"detect": "Object Detection",
"record": "Recording",
"audio": "Audio"
},
"testStream": "Test Connection",
"testSuccess": "Stream test successful!",
"testFailed": "Stream test failed",
"testFailedTitle": "Test Failed",
"connected": "Connected",
"notConnected": "Not Connected",
"featuresTitle": "Features",
"go2rtc": "Reduce connections to camera",
"detectRoleWarning": "At least one stream must have the \"detect\" role to proceed.",
"rolesPopover": {
"title": "Stream Roles",
"detect": "Main feed for object detection.",
"record": "Saves segments of the video feed based on configuration settings.",
"audio": "Feed for audio based detection."
},
"featuresPopover": {
"title": "Stream Features",
"description": "Use go2rtc restreaming to reduce connections to your camera."
}
},
"step3": {
"description": "Final validation and analysis before saving your new camera. Connect each stream before saving.",
"validationTitle": "Stream Validation",
"connectAllStreams": "Connect All Streams",
"reconnectionSuccess": "Reconnection successful.",
"reconnectionPartial": "Some streams failed to reconnect.",
"streamUnavailable": "Stream preview unavailable",
"reload": "Reload",
"connecting": "Connecting...",
"streamTitle": "Stream {{number}}",
"valid": "Valid",
"failed": "Failed",
"notTested": "Not tested",
"connectStream": "Connect",
"connectingStream": "Connecting",
"disconnectStream": "Disconnect",
"estimatedBandwidth": "Estimated Bandwidth",
"roles": "Roles",
"none": "None",
"error": "Error",
"streamValidated": "Stream {{number}} validated successfully",
"streamValidationFailed": "Stream {{number}} validation failed",
"saveAndApply": "Save New Camera",
"saveError": "Invalid configuration. Please check your settings.",
"issues": {
"title": "Stream Validation",
"videoCodecGood": "Video codec is {{codec}}.",
"audioCodecGood": "Audio codec is {{codec}}.",
"noAudioWarning": "No audio detected for this stream, recordings will not have audio.",
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
"audioCodecRequired": "An audio stream is required to support audio detection.",
"restreamingWarning": "Reducing connections to the camera for the record stream may increase CPU usage slightly."
}
}
},
"cameraManagement": {
"title": "Manage Cameras",
"addCamera": "Add New Camera",
"editCamera": "Edit Camera:",
"selectCamera": "Select a Camera",
"backToSettings": "Back to Camera Settings",
"streams": { "streams": {
"title": "Streams", "title": "Enable / Disable Cameras",
"desc": "Temporarily disable a camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>" "desc": "Temporarily disable a camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>"
}, },
"cameraConfig": {
"add": "Add Camera",
"edit": "Edit Camera",
"description": "Configure camera settings including stream inputs and roles.",
"name": "Camera Name",
"nameRequired": "Camera name is required",
"nameLength": "Camera name must be less than 64 characters.",
"namePlaceholder": "e.g., front_door or Back Yard Overview",
"enabled": "Enabled",
"ffmpeg": {
"inputs": "Input Streams",
"path": "Stream Path",
"pathRequired": "Stream path is required",
"pathPlaceholder": "rtsp://...",
"roles": "Roles",
"rolesRequired": "At least one role is required",
"rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream",
"addInput": "Add Input Stream",
"removeInput": "Remove Input Stream",
"inputsRequired": "At least one input stream is required"
},
"go2rtcStreams": "go2rtc Streams",
"streamUrls": "Stream URLs",
"addUrl": "Add URL",
"addGo2rtcStream": "Add go2rtc Stream",
"toast": {
"success": "Camera {{cameraName}} saved successfully"
}
}
},
"cameraReview": {
"title": "Camera Review Settings",
"object_descriptions": { "object_descriptions": {
"title": "Generative AI Object Descriptions", "title": "Generative AI Object Descriptions",
"desc": "Temporarily enable/disable Generative AI object descriptions for this camera. When disabled, AI generated descriptions will not be requested for tracked objects on this camera." "desc": "Temporarily enable/disable Generative AI object descriptions for this camera. When disabled, AI generated descriptions will not be requested for tracked objects on this camera."
@ -183,35 +349,6 @@
"toast": { "toast": {
"success": "Review Classification configuration has been saved. Restart Frigate to apply changes." "success": "Review Classification configuration has been saved. Restart Frigate to apply changes."
} }
},
"addCamera": "Add New Camera",
"editCamera": "Edit Camera:",
"selectCamera": "Select a Camera",
"backToSettings": "Back to Camera Settings",
"cameraConfig": {
"add": "Add Camera",
"edit": "Edit Camera",
"description": "Configure camera settings including stream inputs and roles.",
"name": "Camera Name",
"nameRequired": "Camera name is required",
"nameLength": "Camera name must be less than 24 characters.",
"namePlaceholder": "e.g., front_door",
"enabled": "Enabled",
"ffmpeg": {
"inputs": "Input Streams",
"path": "Stream Path",
"pathRequired": "Stream path is required",
"pathPlaceholder": "rtsp://...",
"roles": "Roles",
"rolesRequired": "At least one role is required",
"rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream",
"addInput": "Add Input Stream",
"removeInput": "Remove Input Stream",
"inputsRequired": "At least one input stream is required"
},
"toast": {
"success": "Camera {{cameraName}} saved successfully"
}
} }
}, },
"masksAndZones": { "masksAndZones": {

View File

@ -4,17 +4,43 @@ import { useTranslation } from "react-i18next";
type StepIndicatorProps = { type StepIndicatorProps = {
steps: string[]; steps: string[];
currentStep: number; currentStep: number;
translationNameSpace: string; variant?: "default" | "dots";
translationNameSpace?: string;
className?: string;
}; };
export default function StepIndicator({ export default function StepIndicator({
steps, steps,
currentStep, currentStep,
variant = "default",
translationNameSpace, translationNameSpace,
className,
}: StepIndicatorProps) { }: StepIndicatorProps) {
const { t } = useTranslation(translationNameSpace); const { t } = useTranslation(translationNameSpace);
if (variant == "dots") {
return ( return (
<div className="flex flex-row justify-evenly"> <div className={cn("flex flex-row justify-center gap-2", className)}>
{steps.map((_, idx) => (
<div
key={idx}
className={cn(
"size-3 rounded-full border border-primary/10 transition-colors",
currentStep === idx
? "bg-selected"
: currentStep > idx
? "bg-muted-foreground"
: "bg-muted",
)}
/>
))}
</div>
);
}
// Default variant (original behavior)
return (
<div className={cn("flex flex-row justify-evenly", className)}>
{steps.map((name, idx) => ( {steps.map((name, idx) => (
<div key={idx} className="flex flex-col items-center gap-2"> <div key={idx} className="flex flex-col items-center gap-2">
<div <div

View File

@ -9,6 +9,7 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Card, CardContent } from "@/components/ui/card";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@ -22,20 +23,9 @@ 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";
import useSWR from "swr"; import useSWR from "swr";
import { processCameraName } from "@/utils/cameraUtil";
type ConfigSetBody = { import { Label } from "@/components/ui/label";
requires_restart: number; import { ConfigSetBody } from "@/types/cameraWizard";
// TODO: type this better
// eslint-disable-next-line @typescript-eslint/no-explicit-any
config_data: any;
update_topic?: string;
};
const generateFixedHash = (name: string): string => {
const encoded = encodeURIComponent(name);
const base64 = btoa(encoded);
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
return `cam_${cleanHash.toLowerCase()}`;
};
const RoleEnum = z.enum(["audio", "detect", "record"]); const RoleEnum = z.enum(["audio", "detect", "record"]);
type Role = z.infer<typeof RoleEnum>; type Role = z.infer<typeof RoleEnum>;
@ -60,22 +50,26 @@ export default function CameraEditForm({
z.object({ z.object({
cameraName: z cameraName: z
.string() .string()
.min(1, { message: t("camera.cameraConfig.nameRequired") }), .min(1, { message: t("cameraManagement.cameraConfig.nameRequired") }),
enabled: z.boolean(), enabled: z.boolean(),
ffmpeg: z.object({ ffmpeg: z.object({
inputs: z inputs: z
.array( .array(
z.object({ z.object({
path: z.string().min(1, { path: z.string().min(1, {
message: t("camera.cameraConfig.ffmpeg.pathRequired"), message: t(
"cameraManagement.cameraConfig.ffmpeg.pathRequired",
),
}), }),
roles: z.array(RoleEnum).min(1, { roles: z.array(RoleEnum).min(1, {
message: t("camera.cameraConfig.ffmpeg.rolesRequired"), message: t(
"cameraManagement.cameraConfig.ffmpeg.rolesRequired",
),
}), }),
}), }),
) )
.min(1, { .min(1, {
message: t("camera.cameraConfig.ffmpeg.inputsRequired"), message: t("cameraManagement.cameraConfig.ffmpeg.inputsRequired"),
}) })
.refine( .refine(
(inputs) => { (inputs) => {
@ -93,11 +87,12 @@ export default function CameraEditForm({
); );
}, },
{ {
message: t("camera.cameraConfig.ffmpeg.rolesUnique"), message: t("cameraManagement.cameraConfig.ffmpeg.rolesUnique"),
path: ["inputs"], path: ["inputs"],
}, },
), ),
}), }),
go2rtcStreams: z.record(z.string(), z.array(z.string())).optional(),
}), }),
[t], [t],
); );
@ -110,6 +105,7 @@ export default function CameraEditForm({
friendly_name: undefined, friendly_name: undefined,
name: cameraName || "", name: cameraName || "",
roles: new Set<Role>(), roles: new Set<Role>(),
go2rtcStreams: {},
}; };
} }
@ -120,10 +116,14 @@ export default function CameraEditForm({
input.roles.forEach((role) => roles.add(role as Role)); input.roles.forEach((role) => roles.add(role as Role));
}); });
// Load existing go2rtc streams
const go2rtcStreams = config.go2rtc?.streams || {};
return { return {
friendly_name: camera?.friendly_name || cameraName, friendly_name: camera?.friendly_name || cameraName,
name: cameraName, name: cameraName,
roles, roles,
go2rtcStreams,
}; };
}, [cameraName, config]); }, [cameraName, config]);
@ -138,6 +138,7 @@ export default function CameraEditForm({
}, },
], ],
}, },
go2rtcStreams: {},
}; };
// Load existing camera config if editing // Load existing camera config if editing
@ -150,6 +151,41 @@ export default function CameraEditForm({
roles: input.roles as Role[], roles: input.roles as Role[],
})) }))
: defaultValues.ffmpeg.inputs; : defaultValues.ffmpeg.inputs;
// Load go2rtc streams for this camera
const go2rtcStreams = config.go2rtc?.streams || {};
const cameraStreams: Record<string, string[]> = {};
// Find streams that match this camera's name pattern
Object.entries(go2rtcStreams).forEach(([streamName, urls]) => {
if (streamName.startsWith(cameraName) || streamName === cameraName) {
cameraStreams[streamName] = Array.isArray(urls) ? urls : [urls];
}
});
// Also deduce go2rtc streams from restream URLs in camera inputs
camera.ffmpeg?.inputs?.forEach((input, index) => {
const restreamMatch = input.path.match(
/^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
);
if (restreamMatch) {
const streamName = restreamMatch[1];
// Find the corresponding go2rtc stream
const go2rtcStream = Object.entries(go2rtcStreams).find(
([name]) =>
name === streamName ||
name === `${cameraName}_${index + 1}` ||
name === cameraName,
);
if (go2rtcStream) {
cameraStreams[go2rtcStream[0]] = Array.isArray(go2rtcStream[1])
? go2rtcStream[1]
: [go2rtcStream[1]];
}
}
});
defaultValues.go2rtcStreams = cameraStreams;
} }
const form = useForm<FormValues>({ const form = useForm<FormValues>({
@ -166,21 +202,20 @@ export default function CameraEditForm({
// Watch ffmpeg.inputs to track used roles // Watch ffmpeg.inputs to track used roles
const watchedInputs = form.watch("ffmpeg.inputs"); const watchedInputs = form.watch("ffmpeg.inputs");
// Watch go2rtc streams
const watchedGo2rtcStreams = form.watch("go2rtcStreams") || {};
const saveCameraConfig = (values: FormValues) => { const saveCameraConfig = (values: FormValues) => {
setIsLoading(true); setIsLoading(true);
let finalCameraName = values.cameraName; const { finalCameraName, friendlyName } = processCameraName(
let friendly_name: string | undefined = undefined; values.cameraName,
const isValidName = /^[a-zA-Z0-9_-]+$/.test(values.cameraName); );
if (!isValidName) {
finalCameraName = generateFixedHash(finalCameraName);
friendly_name = values.cameraName;
}
const configData: ConfigSetBody["config_data"] = { const configData: ConfigSetBody["config_data"] = {
cameras: { cameras: {
[finalCameraName]: { [finalCameraName]: {
enabled: values.enabled, enabled: values.enabled,
...(friendly_name && { friendly_name }), ...(friendlyName && { friendly_name: friendlyName }),
ffmpeg: { ffmpeg: {
inputs: values.ffmpeg.inputs.map((input) => ({ inputs: values.ffmpeg.inputs.map((input) => ({
path: input.path, path: input.path,
@ -191,6 +226,13 @@ export default function CameraEditForm({
}, },
}; };
// Add go2rtc streams if provided
if (values.go2rtcStreams && Object.keys(values.go2rtcStreams).length > 0) {
configData.go2rtc = {
streams: values.go2rtcStreams,
};
}
const requestBody: ConfigSetBody = { const requestBody: ConfigSetBody = {
requires_restart: 1, requires_restart: 1,
config_data: configData, config_data: configData,
@ -205,13 +247,36 @@ export default function CameraEditForm({
.put("config/set", requestBody) .put("config/set", requestBody)
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
// Update running go2rtc instance if streams were configured
if (
values.go2rtcStreams &&
Object.keys(values.go2rtcStreams).length > 0
) {
const updatePromises = Object.entries(values.go2rtcStreams).map(
([streamName, urls]) =>
axios.put(
`go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
),
);
Promise.allSettled(updatePromises).then(() => {
toast.success( toast.success(
t("camera.cameraConfig.toast.success", { t("cameraManagement.cameraConfig.toast.success", {
cameraName: values.cameraName, cameraName: values.cameraName,
}), }),
{ position: "top-center" }, { position: "top-center" },
); );
if (onSave) onSave(); if (onSave) onSave();
});
} else {
toast.success(
t("cameraManagement.cameraConfig.toast.success", {
cameraName: values.cameraName,
}),
{ position: "top-center" },
);
if (onSave) onSave();
}
} else { } else {
throw new Error(res.statusText); throw new Error(res.statusText);
} }
@ -238,11 +303,11 @@ export default function CameraEditForm({
values.cameraName !== cameraInfo?.friendly_name values.cameraName !== cameraInfo?.friendly_name
) { ) {
// If camera name changed, delete old camera config // If camera name changed, delete old camera config
const deleteRequestBody: ConfigSetBody = { const deleteRequestBody = {
requires_restart: 1, requires_restart: 1,
config_data: { config_data: {
cameras: { cameras: {
[cameraName]: "", [cameraName]: null,
}, },
}, },
update_topic: `config/cameras/${cameraName}/remove`, update_topic: `config/cameras/${cameraName}/remove`,
@ -289,15 +354,15 @@ export default function CameraEditForm({
}; };
return ( return (
<> <div className="scrollbar-container max-w-4xl overflow-y-auto md:mb-24">
<Toaster position="top-center" closeButton /> <Toaster position="top-center" closeButton />
<Heading as="h3" className="my-2"> <Heading as="h3" className="my-2">
{cameraName {cameraName
? t("camera.cameraConfig.edit") ? t("cameraManagement.cameraConfig.edit")
: t("camera.cameraConfig.add")} : t("cameraManagement.cameraConfig.add")}
</Heading> </Heading>
<div className="my-3 text-sm text-muted-foreground"> <div className="my-3 text-sm text-muted-foreground">
{t("camera.cameraConfig.description")} {t("cameraManagement.cameraConfig.description")}
</div> </div>
<Separator className="my-3 bg-secondary" /> <Separator className="my-3 bg-secondary" />
@ -308,10 +373,12 @@ export default function CameraEditForm({
name="cameraName" name="cameraName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("camera.cameraConfig.name")}</FormLabel> <FormLabel>{t("cameraManagement.cameraConfig.name")}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder={t("camera.cameraConfig.namePlaceholder")} placeholder={t(
"cameraManagement.cameraConfig.namePlaceholder",
)}
{...field} {...field}
disabled={!!cameraName} // Prevent editing name for existing cameras disabled={!!cameraName} // Prevent editing name for existing cameras
/> />
@ -332,31 +399,51 @@ export default function CameraEditForm({
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
/> />
</FormControl> </FormControl>
<FormLabel>{t("camera.cameraConfig.enabled")}</FormLabel> <FormLabel>
{t("cameraManagement.cameraConfig.enabled")}
</FormLabel>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div> <div className="space-y-4">
<FormLabel>{t("camera.cameraConfig.ffmpeg.inputs")}</FormLabel> <Label className="text-sm font-medium">
{t("cameraManagement.cameraConfig.ffmpeg.inputs")}
</Label>
{fields.map((field, index) => ( {fields.map((field, index) => (
<div <Card key={field.id} className="bg-secondary text-primary">
key={field.id} <CardContent className="space-y-4 p-4">
className="mt-2 space-y-4 rounded-md border p-4" <div className="flex items-center justify-between">
<h4 className="font-medium">
{t("cameraWizard.step2.streamTitle", {
number: index + 1,
})}
</h4>
<Button
variant="ghost"
size="sm"
onClick={() => remove(index)}
disabled={fields.length === 1}
className="text-secondary-foreground hover:text-secondary-foreground"
> >
<LuTrash2 className="size-5" />
</Button>
</div>
<FormField <FormField
control={form.control} control={form.control}
name={`ffmpeg.inputs.${index}.path`} name={`ffmpeg.inputs.${index}.path`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel className="text-sm font-medium">
{t("camera.cameraConfig.ffmpeg.path")} {t("cameraManagement.cameraConfig.ffmpeg.path")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
className="h-8"
placeholder={t( placeholder={t(
"camera.cameraConfig.ffmpeg.pathPlaceholder", "cameraManagement.cameraConfig.ffmpeg.pathPlaceholder",
)} )}
{...field} {...field}
/> />
@ -366,73 +453,197 @@ export default function CameraEditForm({
)} )}
/> />
<FormField <div className="space-y-2">
control={form.control} <Label className="text-sm font-medium">
name={`ffmpeg.inputs.${index}.roles`} {t("cameraManagement.cameraConfig.ffmpeg.roles")}
render={({ field }) => ( </Label>
<FormItem> <div className="rounded-lg bg-background p-3">
<FormLabel>
{t("camera.cameraConfig.ffmpeg.roles")}
</FormLabel>
<FormControl>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(["audio", "detect", "record"] as const).map( {(["detect", "record", "audio"] as const).map(
(role) => ( (role) => {
<label const isUsedElsewhere =
getUsedRolesExcludingIndex(index).has(role);
const isChecked =
watchedInputs[index]?.roles?.includes(role) ||
false;
return (
<div
key={role} key={role}
className="flex items-center space-x-2" className="flex w-full items-center justify-between"
> >
<input <span className="text-sm capitalize">
type="checkbox" {role}
checked={field.value.includes(role)} </span>
onChange={(e) => { <Switch
const updatedRoles = e.target.checked checked={isChecked}
? [...field.value, role] onCheckedChange={(checked) => {
: field.value.filter((r) => r !== role); const currentRoles =
field.onChange(updatedRoles); watchedInputs[index]?.roles || [];
const updatedRoles = checked
? [...currentRoles, role]
: currentRoles.filter((r) => r !== role);
form.setValue(
`ffmpeg.inputs.${index}.roles`,
updatedRoles,
);
}} }}
disabled={ disabled={!isChecked && isUsedElsewhere}
!field.value.includes(role) &&
getUsedRolesExcludingIndex(index).has(role)
}
/> />
<span>{role}</span> </div>
</label> );
), },
)} )}
</div> </div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="destructive"
size="sm"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<LuTrash2 className="mr-2 h-4 w-4" />
{t("camera.cameraConfig.ffmpeg.removeInput")}
</Button>
</div> </div>
</div>
</CardContent>
</Card>
))} ))}
<FormMessage> <FormMessage>
{form.formState.errors.ffmpeg?.inputs?.root && {form.formState.errors.ffmpeg?.inputs?.root &&
form.formState.errors.ffmpeg.inputs.root.message} form.formState.errors.ffmpeg.inputs.root.message}
</FormMessage> </FormMessage>
<Button <Button
variant="outline" type="button"
size="sm"
className="mt-2"
onClick={() => append({ path: "", roles: getAvailableRoles() })} onClick={() => append({ path: "", roles: getAvailableRoles() })}
variant="outline"
className=""
> >
<LuPlus className="mr-2 h-4 w-4" /> <LuPlus className="mr-2 size-4" />
{t("camera.cameraConfig.ffmpeg.addInput")} {t("cameraManagement.cameraConfig.ffmpeg.addInput")}
</Button> </Button>
</div> </div>
{/* go2rtc Streams Section */}
{Object.keys(watchedGo2rtcStreams).length > 0 && (
<div className="space-y-4">
<Label className="text-sm font-medium">
{t("cameraManagement.cameraConfig.go2rtcStreams")}
</Label>
{Object.entries(watchedGo2rtcStreams).map(
([streamName, urls]) => (
<Card key={streamName} className="bg-secondary text-primary">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">{streamName}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => {
const updatedStreams = { ...watchedGo2rtcStreams };
delete updatedStreams[streamName];
form.setValue("go2rtcStreams", updatedStreams);
}}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-5" />
</Button>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("cameraManagement.cameraConfig.streamUrls")}
</Label>
{(Array.isArray(urls) ? urls : [urls]).map(
(url, urlIndex) => (
<div
key={urlIndex}
className="flex items-center gap-2"
>
<Input
className="h-8 flex-1"
value={url}
onChange={(e) => {
const updatedStreams = {
...watchedGo2rtcStreams,
};
const currentUrls = Array.isArray(
updatedStreams[streamName],
)
? updatedStreams[streamName]
: [updatedStreams[streamName]];
currentUrls[urlIndex] = e.target.value;
updatedStreams[streamName] = currentUrls;
form.setValue(
"go2rtcStreams",
updatedStreams,
);
}}
placeholder="rtsp://username:password@host:port/path"
/>
{(Array.isArray(urls) ? urls : [urls]).length >
1 && (
<Button
variant="ghost"
size="sm"
onClick={() => {
const updatedStreams = {
...watchedGo2rtcStreams,
};
const currentUrls = Array.isArray(
updatedStreams[streamName],
)
? updatedStreams[streamName]
: [updatedStreams[streamName]];
currentUrls.splice(urlIndex, 1);
updatedStreams[streamName] = currentUrls;
form.setValue(
"go2rtcStreams",
updatedStreams,
);
}}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-4" />
</Button>
)}
</div>
),
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const updatedStreams = { ...watchedGo2rtcStreams };
const currentUrls = Array.isArray(
updatedStreams[streamName],
)
? updatedStreams[streamName]
: [updatedStreams[streamName]];
currentUrls.push("");
updatedStreams[streamName] = currentUrls;
form.setValue("go2rtcStreams", updatedStreams);
}}
className="w-fit"
>
<LuPlus className="mr-2 size-4" />
{t("cameraManagement.cameraConfig.addUrl")}
</Button>
</div>
</CardContent>
</Card>
),
)}
<Button
type="button"
onClick={() => {
const streamName = `${cameraName}_stream_${Object.keys(watchedGo2rtcStreams).length + 1}`;
const updatedStreams = {
...watchedGo2rtcStreams,
[streamName]: [""],
};
form.setValue("go2rtcStreams", updatedStreams);
}}
variant="outline"
className=""
>
<LuPlus className="mr-2 size-4" />
{t("cameraManagement.cameraConfig.addGo2rtcStream")}
</Button>
</div>
)}
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]"> <div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
<Button <Button
className="flex flex-1" className="flex flex-1"
@ -461,6 +672,6 @@ export default function CameraEditForm({
</div> </div>
</form> </form>
</Form> </Form>
</> </div>
); );
} }

View File

@ -0,0 +1,398 @@
import StepIndicator from "@/components/indicators/StepIndicator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useTranslation } from "react-i18next";
import { useCallback, useState, useEffect, useReducer } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import axios from "axios";
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
import Step2StreamConfig from "@/components/settings/wizard/Step2StreamConfig";
import Step3Validation from "@/components/settings/wizard/Step3Validation";
import type {
WizardFormData,
CameraConfigData,
ConfigSetBody,
} from "@/types/cameraWizard";
import { processCameraName } from "@/utils/cameraUtil";
type WizardState = {
wizardData: Partial<WizardFormData>;
shouldNavigateNext: boolean;
};
type WizardAction =
| { type: "UPDATE_DATA"; payload: Partial<WizardFormData> }
| { type: "UPDATE_AND_NEXT"; payload: Partial<WizardFormData> }
| { type: "RESET_NAVIGATE" };
const wizardReducer = (
state: WizardState,
action: WizardAction,
): WizardState => {
switch (action.type) {
case "UPDATE_DATA":
return {
...state,
wizardData: { ...state.wizardData, ...action.payload },
};
case "UPDATE_AND_NEXT":
return {
wizardData: { ...state.wizardData, ...action.payload },
shouldNavigateNext: true,
};
case "RESET_NAVIGATE":
return { ...state, shouldNavigateNext: false };
default:
return state;
}
};
const STEPS = [
"cameraWizard.steps.nameAndConnection",
"cameraWizard.steps.streamConfiguration",
"cameraWizard.steps.validationAndTesting",
];
type CameraWizardDialogProps = {
open: boolean;
onClose: () => void;
};
export default function CameraWizardDialog({
open,
onClose,
}: CameraWizardDialogProps) {
const { t } = useTranslation(["views/settings"]);
const { mutate: updateConfig } = useSWR("config");
const [currentStep, setCurrentStep] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [state, dispatch] = useReducer(wizardReducer, {
wizardData: { streams: [] },
shouldNavigateNext: false,
});
// Reset wizard when opened
useEffect(() => {
if (open) {
setCurrentStep(0);
dispatch({ type: "UPDATE_DATA", payload: { streams: [] } });
}
}, [open]);
const handleClose = useCallback(() => {
setCurrentStep(0);
dispatch({ type: "UPDATE_DATA", payload: { streams: [] } });
onClose();
}, [onClose]);
const onUpdate = useCallback((data: Partial<WizardFormData>) => {
dispatch({ type: "UPDATE_DATA", payload: data });
}, []);
const canProceedToNext = useCallback((): boolean => {
switch (currentStep) {
case 0:
// Can proceed if camera name is set and at least one stream exists
return !!(
state.wizardData.cameraName &&
(state.wizardData.streams?.length ?? 0) > 0
);
case 1:
// Can proceed if at least one stream has 'detect' role
return !!(
state.wizardData.streams?.some((stream) =>
stream.roles.includes("detect"),
) ?? false
);
case 2:
// Always can proceed from final step (save will be handled there)
return true;
default:
return false;
}
}, [currentStep, state.wizardData]);
const handleNext = useCallback(
(data?: Partial<WizardFormData>) => {
if (data) {
// Atomic update and navigate
dispatch({ type: "UPDATE_AND_NEXT", payload: data });
} else {
// Just navigate
if (currentStep < STEPS.length - 1 && canProceedToNext()) {
setCurrentStep((s) => s + 1);
}
}
},
[currentStep, canProceedToNext],
);
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
// Handle navigation after atomic update
useEffect(() => {
if (state.shouldNavigateNext) {
if (currentStep < STEPS.length - 1 && canProceedToNext()) {
setCurrentStep((s) => s + 1);
}
dispatch({ type: "RESET_NAVIGATE" });
}
}, [state.shouldNavigateNext, currentStep, canProceedToNext]);
// Handle wizard save
const handleSave = useCallback(
(wizardData: WizardFormData) => {
if (!wizardData.cameraName || !wizardData.streams) {
toast.error("Invalid wizard data");
return;
}
setIsLoading(true);
// Process camera name and friendly name
const { finalCameraName, friendlyName } = processCameraName(
wizardData.cameraName,
);
// Convert wizard data to Frigate config format
const configData: CameraConfigData = {
cameras: {
[finalCameraName]: {
enabled: true,
...(friendlyName && { friendly_name: friendlyName }),
ffmpeg: {
inputs: wizardData.streams.map((stream, index) => {
const isRestreamed =
wizardData.restreamIds?.includes(stream.id) ?? false;
if (isRestreamed) {
const go2rtcStreamName =
wizardData.streams!.length === 1
? finalCameraName
: `${finalCameraName}_${index + 1}`;
return {
path: `rtsp://127.0.0.1:8554/${go2rtcStreamName}`,
input_args: "preset-rtsp-restream",
roles: stream.roles,
};
} else {
return {
path: stream.url,
roles: stream.roles,
};
}
}),
},
},
},
};
// Add live.streams configuration for go2rtc streams
if (wizardData.streams && wizardData.streams.length > 0) {
configData.cameras[finalCameraName].live = {
streams: {},
};
wizardData.streams.forEach((_, index) => {
const go2rtcStreamName =
wizardData.streams!.length === 1
? finalCameraName
: `${finalCameraName}_${index + 1}`;
configData.cameras[finalCameraName].live!.streams[
`Stream ${index + 1}`
] = go2rtcStreamName;
});
}
const requestBody: ConfigSetBody = {
requires_restart: 1,
config_data: configData,
update_topic: `config/cameras/${finalCameraName}/add`,
};
axios
.put("config/set", requestBody)
.then((response) => {
if (response.status === 200) {
// Configure go2rtc streams for all streams
if (wizardData.streams && wizardData.streams.length > 0) {
const go2rtcStreams: Record<string, string[]> = {};
wizardData.streams.forEach((stream, index) => {
// Use camera name with index suffix for multiple streams
const streamName =
wizardData.streams!.length === 1
? finalCameraName
: `${finalCameraName}_${index + 1}`;
go2rtcStreams[streamName] = [stream.url];
});
if (Object.keys(go2rtcStreams).length > 0) {
// Update frigate go2rtc config for persistence
const go2rtcConfigData = {
go2rtc: {
streams: go2rtcStreams,
},
};
const go2rtcRequestBody = {
requires_restart: 0,
config_data: go2rtcConfigData,
};
axios
.put("config/set", go2rtcRequestBody)
.then(() => {
// also update the running go2rtc instance for immediate effect
const updatePromises = Object.entries(go2rtcStreams).map(
([streamName, urls]) =>
axios.put(
`go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
),
);
Promise.allSettled(updatePromises).then(() => {
toast.success(
t("cameraWizard.save.success", {
cameraName: friendlyName || finalCameraName,
}),
{ position: "top-center" },
);
updateConfig();
onClose();
});
})
.catch(() => {
// log the error but don't fail the entire save
toast.warning(
t("cameraWizard.save.failure", {
cameraName: friendlyName || finalCameraName,
}),
{ position: "top-center" },
);
updateConfig();
onClose();
});
} else {
// No valid streams found
toast.success(
t("cameraWizard.save.failure", {
cameraName: friendlyName || finalCameraName,
}),
{ position: "top-center" },
);
updateConfig();
onClose();
}
} else {
toast.success(
t("camera.cameraConfig.toast.success", {
cameraName: wizardData.cameraName,
}),
{ position: "top-center" },
);
updateConfig();
onClose();
}
} else {
throw new Error(response.statusText);
}
})
.catch((error) => {
const apiError = error as {
response?: { data?: { message?: string; detail?: string } };
message?: string;
};
const errorMessage =
apiError.response?.data?.message ||
apiError.response?.data?.detail ||
apiError.message ||
"Unknown error";
toast.error(
t("toast.save.error.title", {
errorMessage,
ns: "common",
}),
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
},
[updateConfig, t, onClose],
);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className="max-h-[90dvh] max-w-4xl overflow-y-auto"
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<StepIndicator
steps={STEPS}
currentStep={currentStep}
variant="dots"
className="mb-4 justify-start"
/>
<DialogHeader>
<DialogTitle>{t("cameraWizard.title")}</DialogTitle>
{currentStep === 0 && (
<DialogDescription>
{t("cameraWizard.description")}
</DialogDescription>
)}
</DialogHeader>
{currentStep > 0 && state.wizardData.cameraName && (
<div className="text-center text-primary-variant md:text-start">
{state.wizardData.cameraName}
</div>
)}
<div className="pb-4">
<div className="size-full">
{currentStep === 0 && (
<Step1NameCamera
wizardData={state.wizardData}
onUpdate={onUpdate}
onNext={handleNext}
onCancel={handleClose}
canProceed={canProceedToNext()}
/>
)}
{currentStep === 1 && (
<Step2StreamConfig
wizardData={state.wizardData}
onUpdate={onUpdate}
onBack={handleBack}
onNext={handleNext}
canProceed={canProceedToNext()}
/>
)}
{currentStep === 2 && (
<Step3Validation
wizardData={state.wizardData}
onUpdate={onUpdate}
onSave={handleSave}
onBack={handleBack}
isLoading={isLoading}
/>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,615 @@
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useState, useCallback, useMemo } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import axios from "axios";
import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import {
WizardFormData,
CameraBrand,
CAMERA_BRANDS,
CAMERA_BRAND_VALUES,
TestResult,
FfprobeStream,
StreamRole,
StreamConfig,
} from "@/types/cameraWizard";
import { FaCircleCheck } from "react-icons/fa6";
import { Card, CardContent, CardTitle } from "../../ui/card";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { LuInfo } from "react-icons/lu";
type Step1NameCameraProps = {
wizardData: Partial<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => void;
onNext: (data?: Partial<WizardFormData>) => void;
onCancel: () => void;
canProceed?: boolean;
};
export default function Step1NameCamera({
wizardData,
onUpdate,
onNext,
onCancel,
}: Step1NameCameraProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [showPassword, setShowPassword] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const existingCameraNames = useMemo(() => {
if (!config?.cameras) {
return [];
}
return Object.keys(config.cameras);
}, [config]);
const step1FormData = z
.object({
cameraName: z
.string()
.min(1, t("cameraWizard.step1.errors.nameRequired"))
.max(64, t("cameraWizard.step1.errors.nameLength"))
.regex(
/^[a-zA-Z0-9\s_-]+$/,
t("cameraWizard.step1.errors.invalidCharacters"),
)
.refine(
(value) => !existingCameraNames.includes(value),
t("cameraWizard.step1.errors.nameExists"),
),
host: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
customUrl: z.string().optional(),
})
.refine(
(data) => {
// If brand is "other", customUrl is required
if (data.brandTemplate === "other") {
return data.customUrl && data.customUrl.trim().length > 0;
}
// If brand is not "other", host is required
return data.host && data.host.trim().length > 0;
},
{
message: t("cameraWizard.step1.errors.brandOrCustomUrlRequired"),
path: ["customUrl"],
},
);
const form = useForm<z.infer<typeof step1FormData>>({
resolver: zodResolver(step1FormData),
defaultValues: {
cameraName: wizardData.cameraName || "",
host: wizardData.host || "",
username: wizardData.username || "",
password: wizardData.password || "",
brandTemplate:
wizardData.brandTemplate &&
CAMERA_BRAND_VALUES.includes(wizardData.brandTemplate as CameraBrand)
? (wizardData.brandTemplate as CameraBrand)
: "dahua",
customUrl: wizardData.customUrl || "",
},
mode: "onChange",
});
const watchedBrand = form.watch("brandTemplate");
const watchedHost = form.watch("host");
const watchedCustomUrl = form.watch("customUrl");
const isTestButtonEnabled =
watchedBrand === "other"
? !!(watchedCustomUrl && watchedCustomUrl.trim())
: !!(watchedHost && watchedHost.trim());
const generateStreamUrl = useCallback(
(data: z.infer<typeof step1FormData>): string => {
if (data.brandTemplate === "other") {
return data.customUrl || "";
}
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
if (!brand || !data.host) return "";
return brand.template
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", data.host);
},
[],
);
const testConnection = useCallback(async () => {
const data = form.getValues();
const streamUrl = generateStreamUrl(data);
if (!streamUrl) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
setIsTesting(true);
setTestResult(null);
// First get probe data for metadata
const probePromise = axios.get("ffprobe", {
params: { paths: streamUrl, detailed: true },
timeout: 10000,
});
// Then get snapshot for preview
const snapshotPromise = axios.get("ffprobe/snapshot", {
params: { url: streamUrl },
responseType: "blob",
timeout: 10000,
});
try {
// First get probe data for metadata
const probeResponse = await probePromise;
let probeData = null;
if (
probeResponse.data &&
probeResponse.data.length > 0 &&
probeResponse.data[0].return_code === 0
) {
probeData = probeResponse.data[0];
}
// Then get snapshot for preview (only if probe succeeded)
let snapshotBlob = null;
if (probeData) {
try {
const snapshotResponse = await snapshotPromise;
snapshotBlob = snapshotResponse.data;
} catch (snapshotError) {
// Snapshot is optional, don't fail if it doesn't work
toast.warning(t("cameraWizard.step1.warnings.noSnapshot"));
}
}
if (probeData) {
const ffprobeData = probeData.stdout;
const streams = ffprobeData.streams || [];
const videoStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
// Extract FPS from rational (e.g., "15/1" -> 15)
const fps = videoStream?.r_frame_rate
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
parseFloat(videoStream.r_frame_rate.split("/")[1])
: undefined;
// Convert snapshot blob to base64 if available
let snapshotBase64 = undefined;
if (snapshotBlob) {
snapshotBase64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(snapshotBlob);
});
}
const testResult: TestResult = {
success: true,
snapshot: snapshotBase64,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
setTestResult(testResult);
toast.success(t("cameraWizard.step1.testSuccess"));
} else {
const error = probeData?.stderr || "Unknown error";
setTestResult({
success: false,
error: error,
});
toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
}
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string; detail?: string } };
message?: string;
};
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
axiosError.message ||
"Connection failed";
setTestResult({
success: false,
error: errorMessage,
});
toast.error(
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
);
} finally {
setIsTesting(false);
}
}, [form, generateStreamUrl, t]);
const onSubmit = (data: z.infer<typeof step1FormData>) => {
onUpdate(data);
};
const handleContinue = useCallback(() => {
const data = form.getValues();
const streamUrl = generateStreamUrl(data);
const streamId = `stream_${Date.now()}`;
const streamConfig: StreamConfig = {
id: streamId,
url: streamUrl,
roles: ["detect" as StreamRole],
resolution: testResult?.resolution,
testResult: testResult || undefined,
userTested: false,
};
const updatedData = {
...data,
streams: [streamConfig],
};
onNext(updatedData);
}, [form, generateStreamUrl, testResult, onNext]);
return (
<div className="space-y-6">
{!testResult?.success && (
<>
<div className="text-sm text-muted-foreground">
{t("cameraWizard.step1.description")}
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="cameraName"
render={({ field }) => (
<FormItem>
<FormLabel>{t("cameraWizard.step1.cameraName")}</FormLabel>
<FormControl>
<Input
className="h-8"
placeholder={t(
"cameraWizard.step1.cameraNamePlaceholder",
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandTemplate"
render={({ field }) => (
<FormItem>
<FormLabel>{t("cameraWizard.step1.cameraBrand")}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="h-8">
<SelectValue
placeholder={t("cameraWizard.step1.selectBrand")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{CAMERA_BRANDS.map((brand) => (
<SelectItem key={brand.value} value={brand.value}>
{brand.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
{field.value &&
(() => {
const selectedBrand = CAMERA_BRANDS.find(
(brand) => brand.value === field.value,
);
return selectedBrand &&
selectedBrand.value != "other" ? (
<FormDescription className="mt-1 pt-0.5 text-xs text-muted-foreground">
<Popover>
<PopoverTrigger>
<div className="flex flex-row items-center gap-0.5 text-xs text-muted-foreground hover:text-primary">
<LuInfo className="mr-1 size-3" />
{t("cameraWizard.step1.brandInformation")}
</div>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
<h4 className="font-medium">
{selectedBrand.label}
</h4>
<p className="text-sm text-muted-foreground">
{t("cameraWizard.step1.brandUrlFormat", {
exampleUrl: selectedBrand.exampleUrl,
})}
</p>
</div>
</PopoverContent>
</Popover>
</FormDescription>
) : null;
})()}
</FormItem>
)}
/>
{watchedBrand !== "other" && (
<>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>{t("cameraWizard.step1.host")}</FormLabel>
<FormControl>
<Input
className="h-8"
placeholder="192.168.1.100"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("cameraWizard.step1.username")}
</FormLabel>
<FormControl>
<Input
className="h-8"
placeholder={t(
"cameraWizard.step1.usernamePlaceholder",
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("cameraWizard.step1.password")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
className="h-8 pr-10"
type={showPassword ? "text" : "password"}
placeholder={t(
"cameraWizard.step1.passwordPlaceholder",
)}
{...field}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<LuEyeOff className="size-4" />
) : (
<LuEye className="size-4" />
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBrand == "other" && (
<FormField
control={form.control}
name="customUrl"
render={({ field }) => (
<FormItem>
<FormLabel>{t("cameraWizard.step1.customUrl")}</FormLabel>
<FormControl>
<Input
className="h-8"
placeholder="rtsp://username:password@host:port/path"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</>
)}
{testResult?.success && (
<div className="p-4">
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
<FaCircleCheck className="size-4" />
{t("cameraWizard.step1.testSuccess")}
</div>
<div className="space-y-3">
{testResult.snapshot ? (
<div className="relative flex justify-center">
<img
src={testResult.snapshot}
alt="Camera snapshot"
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
/>
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
<div className="space-y-1">
<StreamDetails testResult={testResult} />
</div>
</div>
</div>
) : (
<Card className="p-4">
<CardTitle className="mb-2 text-sm">
{t("cameraWizard.step1.streamDetails")}
</CardTitle>
<CardContent className="p-0 text-sm">
<StreamDetails testResult={testResult} />
</CardContent>
</Card>
)}
</div>
</div>
)}
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button
type="button"
onClick={testResult?.success ? () => setTestResult(null) : onCancel}
className="sm:flex-1"
>
{testResult?.success
? t("button.back", { ns: "common" })
: t("button.cancel", { ns: "common" })}
</Button>
{testResult?.success ? (
<Button
type="button"
onClick={handleContinue}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("button.continue", { ns: "common" })}
</Button>
) : (
<Button
type="button"
onClick={testConnection}
disabled={isTesting || !isTestButtonEnabled}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step1.testConnection")}
</Button>
)}
</div>
</div>
);
}
function StreamDetails({ testResult }: { testResult: TestResult }) {
const { t } = useTranslation(["views/settings"]);
return (
<>
{testResult.resolution && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.resolution")}:
</span>{" "}
<span className="text-white">{testResult.resolution}</span>
</div>
)}
{testResult.fps && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.fps")}:
</span>{" "}
<span className="text-white">{testResult.fps}</span>
</div>
)}
{testResult.videoCodec && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.video")}:
</span>{" "}
<span className="text-white">{testResult.videoCodec}</span>
</div>
)}
{testResult.audioCodec && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.audio")}:
</span>{" "}
<span className="text-white">{testResult.audioCodec}</span>
</div>
)}
</>
);
}

View File

@ -0,0 +1,487 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useTranslation } from "react-i18next";
import { useState, useCallback, useMemo } from "react";
import { LuPlus, LuTrash2, LuX } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import axios from "axios";
import { toast } from "sonner";
import {
WizardFormData,
StreamConfig,
StreamRole,
TestResult,
FfprobeStream,
} from "@/types/cameraWizard";
import { Label } from "../../ui/label";
import { FaCircleCheck } from "react-icons/fa6";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { LuInfo, LuExternalLink } from "react-icons/lu";
import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain";
type Step2StreamConfigProps = {
wizardData: Partial<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => void;
onBack?: () => void;
onNext?: () => void;
canProceed?: boolean;
};
export default function Step2StreamConfig({
wizardData,
onUpdate,
onBack,
onNext,
canProceed,
}: Step2StreamConfigProps) {
const { t } = useTranslation(["views/settings", "components/dialog"]);
const { getLocaleDocUrl } = useDocDomain();
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
const addStream = useCallback(() => {
const newStream: StreamConfig = {
id: `stream_${Date.now()}`,
url: "",
roles: [],
};
onUpdate({
streams: [...streams, newStream],
});
}, [streams, onUpdate]);
const removeStream = useCallback(
(streamId: string) => {
onUpdate({
streams: streams.filter((s) => s.id !== streamId),
});
},
[streams, onUpdate],
);
const updateStream = useCallback(
(streamId: string, updates: Partial<StreamConfig>) => {
onUpdate({
streams: streams.map((s) =>
s.id === streamId ? { ...s, ...updates } : s,
),
});
},
[streams, onUpdate],
);
const getUsedRolesExcludingStream = useCallback(
(excludeStreamId: string) => {
const roles = new Set<StreamRole>();
streams.forEach((stream) => {
if (stream.id !== excludeStreamId) {
stream.roles.forEach((role) => roles.add(role));
}
});
return roles;
},
[streams],
);
const toggleRole = useCallback(
(streamId: string, role: StreamRole) => {
const stream = streams.find((s) => s.id === streamId);
if (!stream) return;
const hasRole = stream.roles.includes(role);
if (hasRole) {
// Allow removing the role
const newRoles = stream.roles.filter((r) => r !== role);
updateStream(streamId, { roles: newRoles });
} else {
// Check if role is already used in another stream
const usedRoles = getUsedRolesExcludingStream(streamId);
if (!usedRoles.has(role)) {
// Allow adding the role
const newRoles = [...stream.roles, role];
updateStream(streamId, { roles: newRoles });
}
}
},
[streams, updateStream, getUsedRolesExcludingStream],
);
const testStream = useCallback(
(stream: StreamConfig) => {
if (!stream.url.trim()) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
setTestingStreams((prev) => new Set(prev).add(stream.id));
axios
.get("ffprobe", {
params: { paths: stream.url, detailed: true },
timeout: 10000,
})
.then((response) => {
if (response.data?.[0]?.return_code === 0) {
const probeData = response.data[0];
const streams = probeData.stdout.streams || [];
const videoStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
const fps = videoStream?.r_frame_rate
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
parseFloat(videoStream.r_frame_rate.split("/")[1])
: undefined;
const testResult: TestResult = {
success: true,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
updateStream(stream.id, { testResult, userTested: true });
toast.success(t("cameraWizard.step2.testSuccess"));
} else {
const error = response.data?.[0]?.stderr || "Unknown error";
updateStream(stream.id, {
testResult: { success: false, error },
userTested: true,
});
toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Connection failed";
updateStream(stream.id, {
testResult: { success: false, error: errorMessage },
userTested: true,
});
toast.error(
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
);
})
.finally(() => {
setTestingStreams((prev) => {
const newSet = new Set(prev);
newSet.delete(stream.id);
return newSet;
});
});
},
[updateStream, t],
);
const setRestream = useCallback(
(streamId: string) => {
const currentIds = wizardData.restreamIds || [];
const isSelected = currentIds.includes(streamId);
const newIds = isSelected
? currentIds.filter((id) => id !== streamId)
: [...currentIds, streamId];
onUpdate({
restreamIds: newIds,
});
},
[wizardData.restreamIds, onUpdate],
);
const hasDetectRole = streams.some((s) => s.roles.includes("detect"));
return (
<div className="space-y-6">
<div className="text-sm text-secondary-foreground">
{t("cameraWizard.step2.description")}
</div>
<div className="space-y-4">
{streams.map((stream, index) => (
<Card key={stream.id} className="bg-secondary text-primary">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">
{t("cameraWizard.step2.streamTitle", { number: index + 1 })}
</h4>
{stream.testResult && stream.testResult.success && (
<div className="mt-1 text-sm text-muted-foreground">
{[
stream.testResult.resolution,
stream.testResult.fps
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
: null,
stream.testResult.videoCodec,
stream.testResult.audioCodec,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
</div>
<div className="flex items-center gap-2">
{stream.testResult?.success && (
<div className="flex items-center gap-2 text-sm">
<FaCircleCheck className="size-4 text-success" />
<span className="text-success">
{t("cameraWizard.step2.connected")}
</span>
</div>
)}
{stream.testResult && !stream.testResult.success && (
<div className="flex items-center gap-2 text-sm">
<LuX className="size-4 text-danger" />
<span className="text-danger">
{t("cameraWizard.step2.notConnected")}
</span>
</div>
)}
{streams.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeStream(stream.id)}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-5" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t("cameraWizard.step2.url")}
</label>
<div className="flex flex-row items-center gap-2">
<Input
value={stream.url}
onChange={(e) =>
updateStream(stream.id, {
url: e.target.value,
testResult: undefined,
})
}
className="h-8 flex-1"
placeholder={t("cameraWizard.step2.streamUrlPlaceholder")}
/>
<Button
type="button"
onClick={() => testStream(stream)}
disabled={
testingStreams.has(stream.id) || !stream.url.trim()
}
variant="outline"
size="sm"
>
{testingStreams.has(stream.id) && (
<ActivityIndicator className="mr-2 size-4" />
)}
{t("cameraWizard.step2.testStream")}
</Button>
</div>
</div>
</div>
{stream.testResult &&
!stream.testResult.success &&
stream.userTested && (
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
<div className="font-medium">
{t("cameraWizard.step2.testFailedTitle")}
</div>
<div className="mt-1 text-xs">
{stream.testResult.error}
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label className="text-sm font-medium">
{t("cameraWizard.step2.roles")}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step2.rolesPopover.title")}
</div>
<div className="space-y-1 text-muted-foreground">
<div>
<strong>detect</strong> -{" "}
{t("cameraWizard.step2.rolesPopover.detect")}
</div>
<div>
<strong>record</strong> -{" "}
{t("cameraWizard.step2.rolesPopover.record")}
</div>
<div>
<strong>audio</strong> -{" "}
{t("cameraWizard.step2.rolesPopover.audio")}
</div>
</div>
<div className="mt-3 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/cameras")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex flex-wrap gap-2">
{(["detect", "record", "audio"] as const).map((role) => {
const isUsedElsewhere = getUsedRolesExcludingStream(
stream.id,
).has(role);
const isChecked = stream.roles.includes(role);
return (
<div
key={role}
className="flex w-full items-center justify-between"
>
<span className="text-sm capitalize">{role}</span>
<Switch
checked={isChecked}
onCheckedChange={() => toggleRole(stream.id, role)}
disabled={!isChecked && isUsedElsewhere}
/>
</div>
);
})}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label className="text-sm font-medium">
{t("cameraWizard.step2.featuresTitle")}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step2.featuresPopover.title")}
</div>
<div className="text-muted-foreground">
{t("cameraWizard.step2.featuresPopover.description")}
</div>
<div className="mt-3 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/restream#reduce-connections-to-camera",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("cameraWizard.step2.go2rtc")}
</span>
<Switch
checked={(wizardData.restreamIds || []).includes(
stream.id,
)}
onCheckedChange={() => setRestream(stream.id)}
/>
</div>
</div>
</div>
</CardContent>
</Card>
))}
<Button
type="button"
onClick={addStream}
variant="outline"
className=""
>
<LuPlus className="mr-2 size-4" />
{t("cameraWizard.step2.addAnotherStream")}
</Button>
</div>
{!hasDetectRole && (
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
{t("cameraWizard.step2.detectRoleWarning")}
</div>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
{onBack && (
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
)}
{onNext && (
<Button
type="button"
onClick={() => onNext?.()}
disabled={!canProceed}
variant="select"
className="sm:flex-1"
>
{t("button.next", { ns: "common" })}
</Button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,690 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "react-i18next";
import { LuRotateCcw } from "react-icons/lu";
import { useState, useCallback, useMemo, useEffect } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import axios from "axios";
import { toast } from "sonner";
import MSEPlayer from "@/components/player/MsePlayer";
import { WizardFormData, StreamConfig, TestResult } from "@/types/cameraWizard";
import { PlayerStatsType } from "@/types/live";
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
import { LuX } from "react-icons/lu";
import { Card, CardContent } from "../../ui/card";
type Step3ValidationProps = {
wizardData: Partial<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => void;
onSave: (config: WizardFormData) => void;
onBack?: () => void;
isLoading?: boolean;
};
export default function Step3Validation({
wizardData,
onUpdate,
onSave,
onBack,
isLoading = false,
}: Step3ValidationProps) {
const { t } = useTranslation(["views/settings"]);
const [isValidating, setIsValidating] = useState(false);
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
const [measuredBandwidth, setMeasuredBandwidth] = useState<
Map<string, number>
>(new Map());
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
const handleBandwidthUpdate = useCallback(
(streamId: string, bandwidth: number) => {
setMeasuredBandwidth((prev) => new Map(prev).set(streamId, bandwidth));
},
[],
);
// Use test results from Step 2, but allow re-validation in Step 3
const validationResults = useMemo(() => {
const results = new Map<string, TestResult>();
streams.forEach((stream) => {
if (stream.testResult) {
results.set(stream.id, stream.testResult);
}
});
return results;
}, [streams]);
const performStreamValidation = useCallback(
async (stream: StreamConfig): Promise<TestResult> => {
try {
const response = await axios.get("ffprobe", {
params: { paths: stream.url, detailed: true },
timeout: 10000,
});
if (response.data?.[0]?.return_code === 0) {
const probeData = response.data[0];
const streamData = probeData.stdout.streams || [];
const videoStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
const fps = videoStream?.r_frame_rate
? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
parseFloat(videoStream.r_frame_rate.split("/")[1])
: undefined;
return {
success: true,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
} else {
const error = response.data?.[0]?.stderr || "Unknown error";
return { success: false, error };
}
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string; detail?: string } };
message?: string;
};
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
axiosError.message ||
"Connection failed";
return { success: false, error: errorMessage };
}
},
[],
);
const validateStream = useCallback(
async (stream: StreamConfig) => {
if (!stream.url.trim()) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
setTestingStreams((prev) => new Set(prev).add(stream.id));
const testResult = await performStreamValidation(stream);
onUpdate({
streams: streams.map((s) =>
s.id === stream.id ? { ...s, testResult } : s,
),
});
if (testResult.success) {
toast.success(
t("cameraWizard.step3.streamValidated", {
number: streams.findIndex((s) => s.id === stream.id) + 1,
}),
);
} else {
toast.error(
t("cameraWizard.step3.streamValidationFailed", {
number: streams.findIndex((s) => s.id === stream.id) + 1,
}),
);
}
setTestingStreams((prev) => {
const newSet = new Set(prev);
newSet.delete(stream.id);
return newSet;
});
},
[streams, onUpdate, t, performStreamValidation],
);
const validateAllStreams = useCallback(async () => {
setIsValidating(true);
const results = new Map<string, TestResult>();
// Only test streams that haven't been tested or failed
const streamsToTest = streams.filter(
(stream) => !stream.testResult || !stream.testResult.success,
);
for (const stream of streamsToTest) {
if (!stream.url.trim()) continue;
const testResult = await performStreamValidation(stream);
results.set(stream.id, testResult);
}
// Update wizard data with new test results
if (results.size > 0) {
const updatedStreams = streams.map((stream) => {
const newResult = results.get(stream.id);
if (newResult) {
return { ...stream, testResult: newResult };
}
return stream;
});
onUpdate({ streams: updatedStreams });
}
setIsValidating(false);
if (results.size > 0) {
const successfulTests = Array.from(results.values()).filter(
(r) => r.success,
).length;
if (successfulTests === results.size) {
toast.success(t("cameraWizard.step3.reconnectionSuccess"));
} else {
toast.warning(t("cameraWizard.step3.reconnectionPartial"));
}
}
}, [streams, onUpdate, t, performStreamValidation]);
const handleSave = useCallback(() => {
if (!wizardData.cameraName || !wizardData.streams?.length) {
toast.error(t("cameraWizard.step3.saveError"));
return;
}
// Convert wizard data to final config format
const configData = {
cameraName: wizardData.cameraName,
host: wizardData.host,
username: wizardData.username,
password: wizardData.password,
brandTemplate: wizardData.brandTemplate,
customUrl: wizardData.customUrl,
streams: wizardData.streams,
restreamIds: wizardData.restreamIds,
};
onSave(configData);
}, [wizardData, onSave, t]);
const canSave = useMemo(() => {
return (
wizardData.cameraName &&
wizardData.streams?.length &&
wizardData.streams.some((s) => s.roles.includes("detect")) &&
wizardData.streams.every((s) => s.testResult) // All streams must be tested
);
}, [wizardData]);
return (
<div className="space-y-6">
<div className="text-sm text-muted-foreground">
{t("cameraWizard.step3.description")}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">
{t("cameraWizard.step3.validationTitle")}
</h3>
<Button
onClick={validateAllStreams}
disabled={isValidating || streams.length === 0}
variant="outline"
>
{isValidating && <ActivityIndicator className="mr-2 size-4" />}
{isValidating
? t("cameraWizard.step3.connecting")
: t("cameraWizard.step3.connectAllStreams")}
</Button>
</div>
<div className="space-y-3">
{streams.map((stream, index) => {
const result = validationResults.get(stream.id);
return (
<Card key={stream.id} className="bg-secondary text-primary">
<CardContent className="space-y-4 p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-end gap-2">
<div className="flex flex-col space-y-1">
<div className="flex flex-row items-center">
<h4 className="mr-2 font-medium">
{t("cameraWizard.step3.streamTitle", {
number: index + 1,
})}
</h4>
{stream.roles.map((role) => (
<Badge
variant="outline"
key={role}
className="mx-1 text-xs"
>
{role}
</Badge>
))}
</div>
{result && result.success && (
<div className="mb-2 text-sm text-muted-foreground">
{[
result.resolution,
result.fps
? `${result.fps} ${t("cameraWizard.testResultLabels.fps")}`
: null,
result.videoCodec,
result.audioCodec,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
</div>
</div>
{result?.success && (
<div className="flex items-center gap-2 text-sm">
<FaCircleCheck className="size-4 text-success" />
<span className="text-success">
{t("cameraWizard.step2.connected")}
</span>
</div>
)}
{result && !result.success && (
<div className="flex items-center gap-2 text-sm">
<LuX className="size-4 text-danger" />
<span className="text-danger">
{t("cameraWizard.step2.notConnected")}
</span>
</div>
)}
</div>
{result?.success && (
<div className="mb-3">
<StreamPreview
stream={stream}
onBandwidthUpdate={handleBandwidthUpdate}
/>
</div>
)}
<div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center">
<span className="text-sm text-muted-foreground">
{stream.url}
</span>
<Button
onClick={() => {
if (result?.success) {
// Disconnect: clear the test result
onUpdate({
streams: streams.map((s) =>
s.id === stream.id
? { ...s, testResult: undefined }
: s,
),
});
} else {
// Test/Connect: perform validation
validateStream(stream);
}
}}
disabled={
testingStreams.has(stream.id) || !stream.url.trim()
}
variant="outline"
size="sm"
>
{testingStreams.has(stream.id) && (
<ActivityIndicator className="mr-2 size-4" />
)}
{result?.success
? t("cameraWizard.step3.disconnectStream")
: testingStreams.has(stream.id)
? t("cameraWizard.step3.connectingStream")
: t("cameraWizard.step3.connectStream")}
</Button>
</div>
{result && (
<div className="space-y-2">
<div className="text-xs">
{t("cameraWizard.step3.issues.title")}
</div>
<div className="rounded-lg bg-background p-3">
<StreamIssues
stream={stream}
measuredBandwidth={measuredBandwidth}
wizardData={wizardData}
/>
</div>
</div>
)}
{result && !result.success && (
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
<div className="font-medium">
{t("cameraWizard.step2.testFailedTitle")}
</div>
<div className="mt-1 text-xs">{result.error}</div>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
{onBack && (
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
)}
<Button
type="button"
onClick={handleSave}
disabled={!canSave || isLoading}
className="sm:flex-1"
variant="select"
>
{isLoading && <ActivityIndicator className="mr-2 size-4" />}
{isLoading
? t("button.saving", { ns: "common" })
: t("cameraWizard.step3.saveAndApply")}
</Button>
</div>
</div>
);
}
type StreamIssuesProps = {
stream: StreamConfig;
measuredBandwidth: Map<string, number>;
wizardData: Partial<WizardFormData>;
};
function StreamIssues({
stream,
measuredBandwidth,
wizardData,
}: StreamIssuesProps) {
const { t } = useTranslation(["views/settings"]);
const issues = useMemo(() => {
const result: Array<{
type: "good" | "warning" | "error";
message: string;
}> = [];
// Video codec check
if (stream.testResult?.videoCodec) {
const videoCodec = stream.testResult.videoCodec.toLowerCase();
if (["h264", "h265", "hevc"].includes(videoCodec)) {
result.push({
type: "good",
message: t("cameraWizard.step3.issues.videoCodecGood", {
codec: stream.testResult.videoCodec,
}),
});
}
}
// Audio codec check
if (stream.roles.includes("record")) {
if (stream.testResult?.audioCodec) {
const audioCodec = stream.testResult.audioCodec.toLowerCase();
if (audioCodec === "aac") {
result.push({
type: "good",
message: t("cameraWizard.step3.issues.audioCodecGood", {
codec: stream.testResult.audioCodec,
}),
});
} else {
result.push({
type: "error",
message: t("cameraWizard.step3.issues.audioCodecRecordError"),
});
}
} else {
result.push({
type: "warning",
message: t("cameraWizard.step3.issues.noAudioWarning"),
});
}
}
// Audio detection check
if (stream.roles.includes("audio")) {
if (!stream.testResult?.audioCodec) {
result.push({
type: "error",
message: t("cameraWizard.step3.issues.audioCodecRequired"),
});
}
}
// Restreaming check
if (stream.roles.includes("record")) {
const restreamIds = wizardData.restreamIds || [];
if (restreamIds.includes(stream.id)) {
result.push({
type: "warning",
message: t("cameraWizard.step3.issues.restreamingWarning"),
});
}
}
return result;
}, [stream, wizardData, t]);
if (issues.length === 0) {
return null;
}
return (
<div className="space-y-2">
<BandwidthDisplay
streamId={stream.id}
measuredBandwidth={measuredBandwidth}
/>
<div className="space-y-1">
{issues.map((issue, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
{issue.type === "good" && (
<FaCircleCheck className="size-4 flex-shrink-0 text-success" />
)}
{issue.type === "warning" && (
<FaTriangleExclamation className="size-4 flex-shrink-0 text-yellow-500" />
)}
{issue.type === "error" && (
<LuX className="size-4 flex-shrink-0 text-danger" />
)}
<span
className={
issue.type === "good"
? "text-success"
: issue.type === "warning"
? "text-yellow-500"
: "text-danger"
}
>
{issue.message}
</span>
</div>
))}
</div>
</div>
);
}
type BandwidthDisplayProps = {
streamId: string;
measuredBandwidth: Map<string, number>;
};
function BandwidthDisplay({
streamId,
measuredBandwidth,
}: BandwidthDisplayProps) {
const { t } = useTranslation(["views/settings"]);
const streamBandwidth = measuredBandwidth.get(streamId);
if (!streamBandwidth) return null;
const perHour = streamBandwidth * 3600; // kB/hour
const perHourDisplay =
perHour >= 1000000
? `${(perHour / 1000000).toFixed(1)} ${t("unit.data.gbph", { ns: "common" })}`
: perHour >= 1000
? `${(perHour / 1000).toFixed(1)} ${t("unit.data.mbph", { ns: "common" })}`
: `${perHour.toFixed(0)} ${t("unit.data.kbph", { ns: "common" })}`;
return (
<div className="mb-2 text-sm">
<span className="font-medium text-muted-foreground">
{t("cameraWizard.step3.estimatedBandwidth")}:
</span>{" "}
<span className="text-secondary-foreground">
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
</span>
<span className="ml-2 text-muted-foreground">({perHourDisplay})</span>
</div>
);
}
type StreamPreviewProps = {
stream: StreamConfig;
onBandwidthUpdate?: (streamId: string, bandwidth: number) => void;
};
// live stream preview using MSEPlayer with temp go2rtc streams
function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
const { t } = useTranslation(["views/settings"]);
const [streamId, setStreamId] = useState(`wizard_${stream.id}_${Date.now()}`);
const [registered, setRegistered] = useState(false);
const [error, setError] = useState(false);
const handleStats = useCallback(
(stats: PlayerStatsType) => {
if (stats.bandwidth > 0) {
onBandwidthUpdate?.(stream.id, stats.bandwidth);
}
},
[stream.id, onBandwidthUpdate],
);
const handleReload = useCallback(async () => {
// Clean up old stream first
if (streamId) {
axios.delete(`go2rtc/streams/${streamId}`).catch(() => {
// do nothing on cleanup errors - go2rtc won't consume the streams
});
}
// Reset state and create new stream ID
setError(false);
setRegistered(false);
setStreamId(`wizard_${stream.id}_${Date.now()}`);
}, [stream.id, streamId]);
useEffect(() => {
// Register stream with go2rtc
axios
.put(`go2rtc/streams/${streamId}`, null, {
params: { src: stream.url },
})
.then(() => {
// Add small delay to allow go2rtc api to run and initialize the stream
setTimeout(() => {
setRegistered(true);
}, 500);
})
.catch(() => {
setError(true);
});
// Cleanup on unmount
return () => {
axios.delete(`go2rtc/streams/${streamId}`).catch(() => {
// do nothing on cleanup errors - go2rtc won't consume the streams
});
};
}, [stream.url, streamId]);
const resolution = stream.testResult?.resolution;
let aspectRatio = "16/9";
if (resolution) {
const [width, height] = resolution.split("x").map(Number);
if (width && height) {
aspectRatio = `${width}/${height}`;
}
}
if (error) {
return (
<div
className="flex max-h-[30dvh] flex-col items-center justify-center gap-2 rounded-lg bg-secondary p-4 md:max-h-[20dvh]"
style={{ aspectRatio }}
>
<span className="text-sm text-danger">
{t("cameraWizard.step3.streamUnavailable")}
</span>
<Button
variant="outline"
size="sm"
onClick={handleReload}
className="flex items-center gap-2"
>
<LuRotateCcw className="size-4" />
{t("cameraWizard.step3.reload")}
</Button>
</div>
);
}
if (!registered) {
return (
<div
className="flex max-h-[30dvh] items-center justify-center rounded-lg bg-secondary md:max-h-[20dvh]"
style={{ aspectRatio }}
>
<ActivityIndicator className="size-4" />
<span className="ml-2 text-sm">
{t("cameraWizard.step3.connecting")}
</span>
</div>
);
}
return (
<div
className="relative max-h-[30dvh] md:max-h-[20dvh]"
style={{ aspectRatio }}
>
<MSEPlayer
camera={streamId}
playbackEnabled={true}
className="max-h-[30dvh] rounded-lg md:max-h-[20dvh]"
getStats={true}
setStats={handleStats}
onError={() => setError(true)}
/>
</div>
);
}

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { import {
Controller, Controller,
ControllerProps, ControllerProps,
@ -8,27 +8,27 @@ import {
FieldValues, FieldValues,
FormProvider, FormProvider,
useFormContext, useFormContext,
} from "react-hook-form" } from "react-hook-form";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
const Form = FormProvider const Form = FormProvider;
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { > = {
name: TName name: TName;
} };
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue {} as FormFieldContextValue,
) );
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ >({
...props ...props
}: ControllerProps<TFieldValues, TName>) => { }: ControllerProps<TFieldValues, TName>) => {
@ -36,21 +36,21 @@ const FormField = <
<FormFieldContext.Provider value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext.Provider> </FormFieldContext.Provider>
) );
} };
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext) const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext) const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext() const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState) const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) { if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>") throw new Error("useFormField should be used within <FormField>");
} }
const { id } = itemContext const { id } = itemContext;
return { return {
id, id,
@ -59,36 +59,36 @@ const useFormField = () => {
formDescriptionId: `${id}-form-item-description`, formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`, formMessageId: `${id}-form-item-message`,
...fieldState, ...fieldState,
} };
} };
type FormItemContextValue = { type FormItemContextValue = {
id: string id: string;
} };
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue {} as FormItemContextValue,
) );
const FormItem = React.forwardRef< const FormItem = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const id = React.useId() const id = React.useId();
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} /> <div ref={ref} className={cn("space-y-1", className)} {...props} />
</FormItemContext.Provider> </FormItemContext.Provider>
) );
}) });
FormItem.displayName = "FormItem" FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef< const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField() const { error, formItemId } = useFormField();
return ( return (
<Label <Label
@ -97,15 +97,16 @@ const FormLabel = React.forwardRef<
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
) );
}) });
FormLabel.displayName = "FormLabel" FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef< const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>, React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot> React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => { >(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return ( return (
<Slot <Slot
@ -119,15 +120,15 @@ const FormControl = React.forwardRef<
aria-invalid={!!error} aria-invalid={!!error}
{...props} {...props}
/> />
) );
}) });
FormControl.displayName = "FormControl" FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef< const FormDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField();
return ( return (
<p <p
@ -136,19 +137,19 @@ const FormDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) );
}) });
FormDescription.displayName = "FormDescription" FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef< const FormMessage = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => { >(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children const body = error ? String(error?.message) : children;
if (!body) { if (!body) {
return null return null;
} }
return ( return (
@ -160,9 +161,9 @@ const FormMessage = React.forwardRef<
> >
{body} {body}
</p> </p>
) );
}) });
FormMessage.displayName = "FormMessage" FormMessage.displayName = "FormMessage";
export { export {
useFormField, useFormField,
@ -173,4 +174,4 @@ export {
FormDescription, FormDescription,
FormMessage, FormMessage,
FormField, FormField,
} };

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
export interface InputProps export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {} extends React.InputHTMLAttributes<HTMLInputElement> {}
@ -11,15 +11,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-background_alt",
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Input.displayName = "Input" Input.displayName = "Input";
export { Input } export { Input };

View File

@ -1,14 +1,14 @@
import * as React from "react" import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react" import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef< const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>, React.ElementRef<typeof SelectPrimitive.Trigger>,
@ -17,8 +17,8 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-background_alt [&>span]:line-clamp-1",
className className,
)} )}
{...props} {...props}
> >
@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)) ));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef< const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@ -38,14 +38,14 @@ const SelectScrollUpButton = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronUp className="h-4 w-4" /> <ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
)) ));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef< const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@ -55,15 +55,15 @@ const SelectScrollDownButton = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
)) ));
SelectScrollDownButton.displayName = SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef< const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>, React.ElementRef<typeof SelectPrimitive.Content>,
@ -76,7 +76,7 @@ const SelectContent = React.forwardRef<
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className,
)} )}
position={position} position={position}
{...props} {...props}
@ -86,7 +86,7 @@ const SelectContent = React.forwardRef<
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)} )}
> >
{children} {children}
@ -94,8 +94,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
)) ));
SelectContent.displayName = SelectPrimitive.Content.displayName SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef< const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>, React.ElementRef<typeof SelectPrimitive.Label>,
@ -106,8 +106,8 @@ const SelectLabel = React.forwardRef<
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props} {...props}
/> />
)) ));
SelectLabel.displayName = SelectPrimitive.Label.displayName SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef< const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>, React.ElementRef<typeof SelectPrimitive.Item>,
@ -116,8 +116,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className className,
)} )}
{...props} {...props}
> >
@ -129,8 +129,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
)) ));
SelectItem.displayName = SelectPrimitive.Item.displayName SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef< const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>, React.ElementRef<typeof SelectPrimitive.Separator>,
@ -141,8 +141,8 @@ const SelectSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export { export {
Select, Select,
@ -155,4 +155,4 @@ export {
SelectSeparator, SelectSeparator,
SelectScrollUpButton, SelectScrollUpButton,
SelectScrollDownButton, SelectScrollDownButton,
} };

View File

@ -27,6 +27,7 @@ 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 CameraSettingsView from "@/views/settings/CameraSettingsView";
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";
import UsersView from "@/views/settings/UsersView"; import UsersView from "@/views/settings/UsersView";
@ -70,7 +71,8 @@ import {
const allSettingsViews = [ const allSettingsViews = [
"ui", "ui",
"enrichments", "enrichments",
"cameras", "cameraManagement",
"cameraReview",
"masksAndZones", "masksAndZones",
"motionTuner", "motionTuner",
"triggers", "triggers",
@ -90,7 +92,8 @@ const settingsGroups = [
{ {
label: "cameras", label: "cameras",
items: [ items: [
{ key: "cameras", component: CameraSettingsView }, { key: "cameraManagement", component: CameraManagementView },
{ key: "cameraReview", component: CameraSettingsView },
{ key: "masksAndZones", component: MasksAndZonesView }, { key: "masksAndZones", component: MasksAndZonesView },
{ key: "motionTuner", component: MotionTunerView }, { key: "motionTuner", component: MotionTunerView },
], ],
@ -119,6 +122,16 @@ const settingsGroups = [
}, },
]; ];
const CAMERA_SELECT_BUTTON_PAGES = [
"debug",
"cameraReview",
"masksAndZones",
"motionTuner",
"triggers",
];
const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
const getCurrentComponent = (page: SettingsType) => { const getCurrentComponent = (page: SettingsType) => {
for (const group of settingsGroups) { for (const group of settingsGroups) {
for (const item of group.items) { for (const item of group.items) {
@ -172,13 +185,8 @@ export default function Settings() {
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const allowedViewsForViewer: SettingsType[] = [
"ui",
"debug",
"notifications",
];
const visibleSettingsViews = !isAdmin const visibleSettingsViews = !isAdmin
? allowedViewsForViewer ? ALLOWED_VIEWS_FOR_VIEWER
: allSettingsViews; : allSettingsViews;
// TODO: confirm leave page // TODO: confirm leave page
@ -242,7 +250,7 @@ export default function Settings() {
setSelectedCamera(firstEnabledCamera.name); setSelectedCamera(firstEnabledCamera.name);
} else if ( } else if (
!cameraEnabledStates[selectedCamera] && !cameraEnabledStates[selectedCamera] &&
pageToggle !== "cameras" pageToggle !== "cameraReview"
) { ) {
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page // Switch to first enabled camera if current one is disabled, unless on "camera settings" page
const firstEnabledCamera = const firstEnabledCamera =
@ -257,7 +265,10 @@ export default function Settings() {
useSearchEffect("page", (page: string) => { useSearchEffect("page", (page: string) => {
if (allSettingsViews.includes(page as SettingsType)) { if (allSettingsViews.includes(page as SettingsType)) {
// Restrict viewer to UI settings // Restrict viewer to UI settings
if (!isAdmin && !allowedViewsForViewer.includes(page as SettingsType)) { if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(page as SettingsType)
) {
setPageToggle("ui"); setPageToggle("ui");
} else { } else {
setPageToggle(page as SettingsType); setPageToggle(page as SettingsType);
@ -321,7 +332,9 @@ export default function Settings() {
onSelect={(key) => { onSelect={(key) => {
if ( if (
!isAdmin && !isAdmin &&
!allowedViewsForViewer.includes(key as SettingsType) !ALLOWED_VIEWS_FOR_VIEWER.includes(
key as SettingsType,
)
) { ) {
setPageToggle("ui"); setPageToggle("ui");
} else { } else {
@ -348,13 +361,7 @@ export default function Settings() {
className="top-0 mb-0" className="top-0 mb-0"
onClose={() => navigate(-1)} onClose={() => navigate(-1)}
actions={ actions={
[ CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) ? (
"debug",
"cameras",
"masksAndZones",
"motionTuner",
"triggers",
].includes(pageToggle) ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{pageToggle == "masksAndZones" && ( {pageToggle == "masksAndZones" && (
<ZoneMaskFilterButton <ZoneMaskFilterButton
@ -426,13 +433,7 @@ export default function Settings() {
<Heading as="h3" className="mb-0"> <Heading as="h3" className="mb-0">
{t("menu.settings", { ns: "common" })} {t("menu.settings", { ns: "common" })}
</Heading> </Heading>
{[ {CAMERA_SELECT_BUTTON_PAGES.includes(page) && (
"debug",
"cameras",
"masksAndZones",
"motionTuner",
"triggers",
].includes(page) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{pageToggle == "masksAndZones" && ( {pageToggle == "masksAndZones" && (
<ZoneMaskFilterButton <ZoneMaskFilterButton
@ -470,7 +471,7 @@ export default function Settings() {
onClick={() => { onClick={() => {
if ( if (
!isAdmin && !isAdmin &&
!allowedViewsForViewer.includes( !ALLOWED_VIEWS_FOR_VIEWER.includes(
filteredItems[0].key as SettingsType, filteredItems[0].key as SettingsType,
) )
) { ) {
@ -512,7 +513,7 @@ export default function Settings() {
onClick={() => { onClick={() => {
if ( if (
!isAdmin && !isAdmin &&
!allowedViewsForViewer.includes( !ALLOWED_VIEWS_FOR_VIEWER.includes(
item.key as SettingsType, item.key as SettingsType,
) )
) { ) {
@ -635,7 +636,7 @@ function CameraSelectButton({
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{allCameras.map((item) => { {allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name]; const isEnabled = cameraEnabledStates[item.name];
const isCameraSettingsPage = currentPage === "cameras"; const isCameraSettingsPage = currentPage === "cameraReview";
return ( return (
<FilterSwitch <FilterSwitch
key={item.name} key={item.name}

View File

@ -0,0 +1,154 @@
// Camera Wizard Types
export const CAMERA_BRANDS = [
{
value: "dahua" as const,
label: "Dahua / Amcrest / EmpireTech",
template:
"rtsp://{username}:{password}@{host}:554/cam/realmonitor?channel=1&subtype=0",
exampleUrl:
"rtsp://admin:password@192.168.1.100:554/cam/realmonitor?channel=1&subtype=0",
},
{
value: "hikvision" as const,
label: "Hikvision / Uniview / Annke",
template: "rtsp://{username}:{password}@{host}:554/Streaming/Channels/101",
exampleUrl:
"rtsp://admin:password@192.168.1.100:554/Streaming/Channels/101",
},
{
value: "ubiquiti" as const,
label: "Ubiquiti",
template: "rtsp://{username}:{password}@{host}:554/live/ch0",
exampleUrl: "rtsp://ubnt:password@192.168.1.100:554/live/ch0",
},
{
value: "reolink" as const,
label: "Reolink",
template: "rtsp://{username}:{password}@{host}:554/h264Preview_01_main",
exampleUrl: "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main",
},
{
value: "axis" as const,
label: "Axis",
template: "rtsp://{username}:{password}@{host}:554/axis-media/media.amp",
exampleUrl: "rtsp://root:password@192.168.1.100:554/axis-media/media.amp",
},
{
value: "tplink" as const,
label: "TP-Link",
template: "rtsp://{username}:{password}@{host}:554/stream1",
exampleUrl: "rtsp://admin:password@192.168.1.100:554/stream1",
},
{
value: "foscam" as const,
label: "Foscam",
template: "rtsp://{username}:{password}@{host}:88/videoMain",
exampleUrl: "rtsp://admin:password@192.168.1.100:88/videoMain",
},
{
value: "other" as const,
label: "Other",
template: "",
exampleUrl: "rtsp://username:password@host:port/path",
},
] as const;
export const CAMERA_BRAND_VALUES = CAMERA_BRANDS.map(
(brand) => brand.value,
) as unknown as [
(typeof CAMERA_BRANDS)[number]["value"],
...(typeof CAMERA_BRANDS)[number]["value"][],
];
export type CameraBrand = (typeof CAMERA_BRANDS)[number]["value"];
export type StreamRole = "detect" | "record" | "audio";
export type StreamConfig = {
id: string;
url: string;
roles: StreamRole[];
resolution?: string;
quality?: string;
testResult?: TestResult;
userTested?: boolean;
};
export type TestResult = {
success: boolean;
snapshot?: string; // base64 image
resolution?: string;
videoCodec?: string;
audioCodec?: string;
fps?: number;
error?: string;
};
export type WizardFormData = {
cameraName?: string;
host?: string;
username?: string;
password?: string;
brandTemplate?: CameraBrand;
customUrl?: string;
streams?: StreamConfig[];
restreamIds?: string[];
};
// API Response Types
export type FfprobeResponse = {
return_code: number;
stderr: string;
stdout: FfprobeData | string;
};
export type FfprobeData = {
streams: FfprobeStream[];
};
export type FfprobeStream = {
index?: number;
codec_name?: string;
codec_long_name?: string;
codec_type?: "video" | "audio";
profile?: string;
width?: number;
height?: number;
pix_fmt?: string;
level?: number;
r_frame_rate?: string;
avg_frame_rate?: string;
sample_rate?: string;
channels?: number;
channel_layout?: string;
};
// Config API Types
export type CameraConfigData = {
cameras: {
[cameraName: string]: {
enabled: boolean;
friendly_name?: string;
ffmpeg: {
inputs: {
path: string;
roles: string[];
}[];
};
live?: {
streams: Record<string, string>;
};
};
};
go2rtc?: {
streams: {
[streamName: string]: string[];
};
};
};
export type ConfigSetBody = {
requires_restart: number;
config_data: CameraConfigData;
update_topic?: string;
};

View File

@ -0,0 +1,60 @@
/**
* Generates a fixed-length hash from a camera name for use as a valid camera identifier.
* Works safely with Unicode input while outputting Latin-only identifiers.
*
* @param name - The original camera name/display name
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
*/
export function generateFixedHash(name: string): string {
// Safely encode Unicode as UTF-8 bytes
const utf8Bytes = new TextEncoder().encode(name);
// Convert to base64 manually
let binary = "";
for (const byte of utf8Bytes) {
binary += String.fromCharCode(byte);
}
const base64 = btoa(binary);
// Strip out non-alphanumeric characters and truncate
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
return `cam_${cleanHash.toLowerCase()}`;
}
/**
* Checks if a string is a valid camera name identifier.
* Valid camera names contain only ASCII letters, numbers, underscores, and hyphens.
*
* @param name - The camera name to validate
* @returns True if the name is valid, false otherwise
*/
export function isValidCameraName(name: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(name);
}
/**
* Processes a user-entered camera name and returns both the final camera name
* and friendly name for Frigate configuration.
*
* @param userInput - The name entered by the user (could be display name)
* @returns Object with finalCameraName and friendlyName
*/
export function processCameraName(userInput: string): {
finalCameraName: string;
friendlyName?: string;
} {
const normalizedInput = userInput.replace(/\s+/g, "_").toLowerCase();
if (isValidCameraName(normalizedInput)) {
return {
finalCameraName: normalizedInput,
friendlyName: userInput.includes(" ") ? userInput : undefined,
};
}
return {
finalCameraName: generateFixedHash(userInput),
friendlyName: userInput,
};
}

View File

@ -0,0 +1,199 @@
import Heading from "@/components/ui/heading";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Toaster } from "sonner";
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/CameraNameLabel";
import { Switch } from "@/components/ui/switch";
import { Trans } from "react-i18next";
import { Separator } from "@/components/ui/separator";
import { useEnabledState } from "@/api/ws";
type CameraManagementViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function CameraManagementView({
setUnsavedChanges,
}: CameraManagementViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
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);
// List of cameras for dropdown
const cameras = useMemo(() => {
if (config) {
return Object.keys(config.cameras).sort();
}
return [];
}, [config]);
useEffect(() => {
document.title = t("documentTitle.cameraManagement");
}, [t]);
// Handle back navigation from add/edit form
const handleBack = useCallback(() => {
setViewMode("settings");
setEditCameraName(undefined);
setUnsavedChanges(false);
updateConfig();
}, [updateConfig, setUnsavedChanges]);
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("cameraManagement.title")}
</Heading>
<div className="my-4 flex flex-col gap-4">
<Button
variant="select"
onClick={() => setShowWizard(true)}
className="flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("cameraManagement.addCamera")}
</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">
<Trans ns="views/settings">
cameraManagement.streams.title
</Trans>
</Heading>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraManagement.streams.desc
</Trans>
</div>
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
{cameras.map((camera) => (
<div
key={camera}
className="flex items-center justify-between smart-capitalize"
>
<CameraNameLabel camera={camera} />
<CameraEnableSwitch cameraName={camera} />
</div>
))}
</div>
</div>
<Separator className="mb-2 mt-4 flex bg-secondary" />
</>
)}
</div>
</>
) : (
<>
<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)}
/>
</>
);
}
type CameraEnableSwitchProps = {
cameraName: string;
};
function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
const { payload: enabledState, send: sendEnabled } =
useEnabledState(cameraName);
return (
<div className="flex flex-row items-center">
<Switch
id={`camera-enabled-${cameraName}`}
checked={enabledState === "ON"}
onCheckedChange={(isChecked) => {
sendEnabled(isChecked ? "ON" : "OFF");
}}
/>
</div>
);
}

View File

@ -34,23 +34,14 @@ import { getTranslatedLabel } from "@/utils/i18n";
import { import {
useAlertsState, useAlertsState,
useDetectionsState, useDetectionsState,
useEnabledState,
useObjectDescriptionState, useObjectDescriptionState,
useReviewDescriptionState, useReviewDescriptionState,
} from "@/api/ws"; } from "@/api/ws";
import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraEditForm from "@/components/settings/CameraEditForm";
import { LuPlus } from "react-icons/lu"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
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 { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type CameraSettingsViewProps = { type CameraSettingsViewProps = {
selectedCamera: string; selectedCamera: string;
@ -87,17 +78,10 @@ export default function CameraSettingsView({
const [editCameraName, setEditCameraName] = useState<string | undefined>( const [editCameraName, setEditCameraName] = useState<string | undefined>(
undefined, undefined,
); // Track camera being edited ); // Track camera being edited
const [showWizard, setShowWizard] = useState(false);
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
// List of cameras for dropdown
const cameras = useMemo(() => {
if (config) {
return Object.keys(config.cameras).sort();
}
return [];
}, [config]);
const selectCameraName = useCameraFriendlyName(selectedCamera); const selectCameraName = useCameraFriendlyName(selectedCamera);
// zones and labels // zones and labels
@ -148,8 +132,6 @@ export default function CameraSettingsView({
const watchedAlertsZones = form.watch("alerts_zones"); const watchedAlertsZones = form.watch("alerts_zones");
const watchedDetectionsZones = form.watch("detections_zones"); const watchedDetectionsZones = form.watch("detections_zones");
const { payload: enabledState, send: sendEnabled } =
useEnabledState(selectedCamera);
const { payload: alertsState, send: sendAlerts } = const { payload: alertsState, send: sendAlerts } =
useAlertsState(selectedCamera); useAlertsState(selectedCamera);
const { payload: detectionsState, send: sendDetections } = const { payload: detectionsState, send: sendDetections } =
@ -202,9 +184,12 @@ export default function CameraSettingsView({
}) })
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success(t("camera.reviewClassification.toast.success"), { toast.success(
t("cameraReview.reviewClassification.toast.success"),
{
position: "top-center", position: "top-center",
}); },
);
updateConfig(); updateConfig();
} else { } else {
toast.error( toast.error(
@ -272,7 +257,7 @@ export default function CameraSettingsView({
if (changedValue) { if (changedValue) {
addMessage( addMessage(
"camera_settings", "camera_settings",
t("camera.reviewClassification.unsavedChanges", { t("cameraReview.reviewClassification.unsavedChanges", {
camera: selectedCamera, camera: selectedCamera,
}), }),
undefined, undefined,
@ -295,7 +280,7 @@ export default function CameraSettingsView({
} }
useEffect(() => { useEffect(() => {
document.title = t("documentTitle.camera"); document.title = t("documentTitle.cameraReview");
}, [t]); }, [t]);
// Handle back navigation from add/edit form // Handle back navigation from add/edit form
@ -317,70 +302,11 @@ export default function CameraSettingsView({
{viewMode === "settings" ? ( {viewMode === "settings" ? (
<> <>
<Heading as="h4" className="mb-2"> <Heading as="h4" className="mb-2">
{t("camera.title")} {t("cameraReview.title")}
</Heading>
<div className="mb-4 flex flex-col gap-4">
<Button
variant="select"
onClick={() => setViewMode("add")}
className="flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("camera.addCamera")}
</Button>
{cameras.length > 0 && (
<div className="flex items-center gap-2">
<Label>{t("camera.editCamera")}</Label>
<Select
onValueChange={(value) => {
setEditCameraName(value);
setViewMode("edit");
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t("camera.selectCamera")} />
</SelectTrigger>
<SelectContent>
{cameras.map((camera) => {
return (
<SelectItem key={camera} value={camera}>
<CameraNameLabel camera={camera} />
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
)}
</div>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">camera.streams.title</Trans>
</Heading> </Heading>
<div className="flex flex-row items-center">
<Switch
id="camera-enabled"
className="mr-3"
checked={enabledState === "ON"}
onCheckedChange={(isChecked) => {
sendEnabled(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="camera-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">camera.streams.desc</Trans>
</div>
<Separator className="mb-2 mt-4 flex bg-secondary" />
<Heading as="h4" className="my-2"> <Heading as="h4" className="my-2">
<Trans ns="views/settings">camera.review.title</Trans> <Trans ns="views/settings">cameraReview.review.title</Trans>
</Heading> </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="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
@ -395,7 +321,9 @@ export default function CameraSettingsView({
/> />
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="alerts-enabled"> <Label htmlFor="alerts-enabled">
<Trans ns="views/settings">camera.review.alerts</Trans> <Trans ns="views/settings">
cameraReview.review.alerts
</Trans>
</Label> </Label>
</div> </div>
</div> </div>
@ -418,7 +346,7 @@ export default function CameraSettingsView({
</div> </div>
</div> </div>
<div className="mt-3 text-sm text-muted-foreground"> <div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">camera.review.desc</Trans> <Trans ns="views/settings">cameraReview.review.desc</Trans>
</div> </div>
</div> </div>
</div> </div>
@ -428,7 +356,7 @@ export default function CameraSettingsView({
<Heading as="h4" className="my-2"> <Heading as="h4" className="my-2">
<Trans ns="views/settings"> <Trans ns="views/settings">
camera.object_descriptions.title cameraReview.object_descriptions.title
</Trans> </Trans>
</Heading> </Heading>
@ -450,7 +378,7 @@ export default function CameraSettingsView({
</div> </div>
<div className="mt-3 text-sm text-muted-foreground"> <div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings"> <Trans ns="views/settings">
camera.object_descriptions.desc cameraReview.object_descriptions.desc
</Trans> </Trans>
</div> </div>
</div> </div>
@ -463,7 +391,7 @@ export default function CameraSettingsView({
<Heading as="h4" className="my-2"> <Heading as="h4" className="my-2">
<Trans ns="views/settings"> <Trans ns="views/settings">
camera.review_descriptions.title cameraReview.review_descriptions.title
</Trans> </Trans>
</Heading> </Heading>
@ -485,7 +413,7 @@ export default function CameraSettingsView({
</div> </div>
<div className="mt-3 text-sm text-muted-foreground"> <div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings"> <Trans ns="views/settings">
camera.review_descriptions.desc cameraReview.review_descriptions.desc
</Trans> </Trans>
</div> </div>
</div> </div>
@ -496,7 +424,7 @@ export default function CameraSettingsView({
<Heading as="h4" className="my-2"> <Heading as="h4" className="my-2">
<Trans ns="views/settings"> <Trans ns="views/settings">
camera.reviewClassification.title cameraReview.reviewClassification.title
</Trans> </Trans>
</Heading> </Heading>
@ -504,7 +432,7 @@ export default function CameraSettingsView({
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant"> <div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p> <p>
<Trans ns="views/settings"> <Trans ns="views/settings">
camera.reviewClassification.desc cameraReview.reviewClassification.desc
</Trans> </Trans>
</p> </p>
<div className="flex items-center text-primary"> <div className="flex items-center text-primary">
@ -550,7 +478,7 @@ export default function CameraSettingsView({
</FormLabel> </FormLabel>
<FormDescription> <FormDescription>
<Trans ns="views/settings"> <Trans ns="views/settings">
camera.reviewClassification.selectAlertsZones cameraReview.reviewClassification.selectAlertsZones
</Trans> </Trans>
</FormDescription> </FormDescription>
</div> </div>
@ -599,7 +527,7 @@ export default function CameraSettingsView({
) : ( ) : (
<div className="font-normal text-destructive"> <div className="font-normal text-destructive">
<Trans ns="views/settings"> <Trans ns="views/settings">
camera.reviewClassification.noDefinedZones cameraReview.reviewClassification.noDefinedZones
</Trans> </Trans>
</div> </div>
)} )}
@ -607,7 +535,7 @@ export default function CameraSettingsView({
<div className="text-sm"> <div className="text-sm">
{watchedAlertsZones && watchedAlertsZones.length > 0 {watchedAlertsZones && watchedAlertsZones.length > 0
? t( ? t(
"camera.reviewClassification.zoneObjectAlertsTips", "cameraReview.reviewClassification.zoneObjectAlertsTips",
{ {
alertsLabels, alertsLabels,
zone: watchedAlertsZones zone: watchedAlertsZones
@ -622,7 +550,7 @@ export default function CameraSettingsView({
}, },
) )
: t( : t(
"camera.reviewClassification.objectAlertsTips", "cameraReview.reviewClassification.objectAlertsTips",
{ {
alertsLabels, alertsLabels,
cameraName: selectCameraName, cameraName: selectCameraName,
@ -650,7 +578,7 @@ export default function CameraSettingsView({
{selectDetections && ( {selectDetections && (
<FormDescription> <FormDescription>
<Trans ns="views/settings"> <Trans ns="views/settings">
camera.reviewClassification.selectDetectionsZones cameraReview.reviewClassification.selectDetectionsZones
</Trans> </Trans>
</FormDescription> </FormDescription>
)} )}
@ -713,7 +641,7 @@ export default function CameraSettingsView({
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
<Trans ns="views/settings"> <Trans ns="views/settings">
camera.reviewClassification.limitDetections cameraReview.reviewClassification.limitDetections
</Trans> </Trans>
</label> </label>
</div> </div>
@ -726,7 +654,7 @@ export default function CameraSettingsView({
watchedDetectionsZones.length > 0 ? ( watchedDetectionsZones.length > 0 ? (
!selectDetections ? ( !selectDetections ? (
<Trans <Trans
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.text" i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{ values={{
detectionsLabels, detectionsLabels,
zone: watchedDetectionsZones zone: watchedDetectionsZones
@ -743,7 +671,7 @@ export default function CameraSettingsView({
/> />
) : ( ) : (
<Trans <Trans
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.notSelectDetections" i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{ values={{
detectionsLabels, detectionsLabels,
zone: watchedDetectionsZones zone: watchedDetectionsZones
@ -761,7 +689,7 @@ export default function CameraSettingsView({
) )
) : ( ) : (
<Trans <Trans
i18nKey="camera.reviewClassification.objectDetectionsTips" i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
values={{ values={{
detectionsLabels, detectionsLabels,
cameraName: selectCameraName, cameraName: selectCameraName,
@ -835,6 +763,11 @@ export default function CameraSettingsView({
)} )}
</div> </div>
</div> </div>
<CameraWizardDialog
open={showWizard}
onClose={() => setShowWizard(false)}
/>
</> </>
); );
} }