Compare commits

...

11 Commits

Author SHA1 Message Date
Josh Hawkins
3bc1ae2f77 hide switch in optionalfieldwidget if editing a profile 2026-05-12 16:43:14 -05:00
Josh Hawkins
389ee73ad0 remove retry_interval from UI
99% of users should never be changing this
2026-05-12 16:24:13 -05:00
Josh Hawkins
9d023a8c97 move apple_compatibility out of advanced 2026-05-12 16:22:27 -05:00
Josh Hawkins
689f580d1a only show audio debug tab when audio is enabled in config 2026-05-12 15:51:18 -05:00
Josh Hawkins
c6b355bd7c fix profile array overrides not replacing base arrays
don't use lodash merge(), it does positional merging and an empty source array doesn't override the destination, and shorter arrays leak destination elements through.

backend is unaffected, so the saved config and actual backend functionality was right
2026-05-12 15:40:13 -05:00
Josh Hawkins
ba9a30e5c9 fix incorrect key capitalization 2026-05-12 15:10:47 -05:00
Josh Hawkins
01d861aad6 clean up 2026-05-12 12:21:09 -05:00
Josh Hawkins
077f4a601a improve scroll handling for non-modal DropdownMenu in classification and face selection dialogs 2026-05-12 11:48:35 -05:00
Josh Hawkins
4e90d254ed
Miscellaneous fixes (#23177)
* add optional onClick to EmptyCard

* show EmptyCard in face rec when face library is empty

* add loading indicator

* add description to camera management pane

* Cleanup when use snapshot but can't load snapshot

* Migrate files

* fix birdseye color distortion when configured aspect ratio is unsupported

* Skip processing end for object descriptions

* don't crash if stats is null

* fix genai roles in migration

* frigate+ pane updates

- allow users to select a plus model from the select even when one was not previously loaded
- always show model summary card
- add model filter popover
- add restart button totast

* fix frigate+ pane layout and buttons to match other settings panes

* match button layout in go2rtc settings view

* make audio maintainer respond to dynamic config updates

* check correct zone name in publish state

* fix nested translation extraction for Optional dict and list fields

* mypy

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-05-12 10:20:39 -06:00
Nicolas Mowen
c67170aa20
Implement cross-camera safety for indexed media folders (#23164)
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
* Implement cross-camera safety for indexed media folders

* Cleanup

* Improve robustness
2026-05-11 14:52:18 -05:00
Puma7
e9432d55e8
log(masks): include camera name in invalid-coordinates error (#23156)
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
get_relative_coordinates() previously logged
"Not applying mask due to invalid coordinates. X,Y is outside ..."
without naming the camera, so on a multi-camera setup the user had
to guess which one to fix.

Add an optional camera_name kwarg with default "" (no behavior
change for existing callers). The global object-mask path in
FrigateConfig.validate_config passes camera_name=camera_config.name
since it already has it in scope, so legacy configs with absolute
pixel coordinates now get an actionable log line:

  Not applying mask due to invalid coordinates for camera back.
  9000,9000 is outside of the detection resolution 800x400.
  Use the editor in the UI to correct the mask.

Existing wording is preserved verbatim except for the inserted
" for camera <name>" segment. Runtime behavior is unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-10 15:21:44 -05:00
47 changed files with 1467 additions and 506 deletions

View File

@ -26,6 +26,7 @@ from frigate.api.defs.request.app_body import (
AppPutRoleBody,
)
from frigate.api.defs.tags import Tags
from frigate.api.media_auth import check_camera_access, deny_response_for_media_uri
from frigate.config import AuthConfig, NetworkingConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User
@ -633,6 +634,9 @@ def auth(request: Request):
logger.debug("X-Proxy-Secret header does not match configured secret value")
return fail_response
original_url = request.headers.get("x-original-url")
frigate_config = request.app.frigate_config
# if auth is disabled, just apply the proxy header map and return success
if not auth_config.enabled:
# pass the user header value from the upstream proxy if a mapping is specified
@ -649,6 +653,11 @@ def auth(request: Request):
role = resolve_role(request.headers, proxy_config, config_roles_set)
success_response.headers["remote-role"] = role
deny_status = deny_response_for_media_uri(original_url, role, frigate_config)
if deny_status is not None:
return Response("", status_code=deny_status)
return success_response
# now apply authentication
@ -743,6 +752,11 @@ def auth(request: Request):
success_response.headers["remote-user"] = user
success_response.headers["remote-role"] = role
deny_status = deny_response_for_media_uri(original_url, role, frigate_config)
if deny_status is not None:
return Response("", status_code=deny_status)
return success_response
except Exception as e:
logger.error(f"Error parsing jwt: {e}")
@ -1069,19 +1083,19 @@ async def require_camera_access(
raise HTTPException(status_code=current_user.status_code, detail=detail)
role = current_user["role"]
all_camera_names = set(request.app.frigate_config.cameras.keys())
roles_dict = request.app.frigate_config.auth.roles
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
frigate_config = request.app.frigate_config
# Admin or full access bypasses
if role == "admin" or not roles_dict.get(role):
if check_camera_access(role, camera_name, frigate_config):
return
if camera_name not in allowed_cameras:
raise HTTPException(
status_code=403,
detail=f"Access denied to camera '{camera_name}'. Allowed: {allowed_cameras}",
)
all_camera_names = set(frigate_config.cameras.keys())
allowed_cameras = User.get_allowed_cameras(
role, frigate_config.auth.roles, all_camera_names
)
raise HTTPException(
status_code=403,
detail=f"Access denied to camera '{camera_name}'. Allowed: {allowed_cameras}",
)
def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:

291
frigate/api/media_auth.py Normal file
View File

@ -0,0 +1,291 @@
"""URI-aware authorization for nginx-served static media.
The `/auth` endpoint (used as nginx `auth_request` target) calls into this
module to classify the requested URI from the `X-Original-URL` header and, for
camera-scoped resources, decide whether the current role may access them.
Without this, `auth_request` only verifies the JWT every authenticated user
could read clips, recordings, and exports for *any* camera, bypassing the
per-camera authorization the regular API enforces via `require_camera_access`.
"""
from __future__ import annotations
import logging
import os
from enum import Enum
from typing import Optional
from urllib.parse import unquote, urlparse
from peewee import DoesNotExist
from frigate.config import FrigateConfig
from frigate.const import EXPORT_DIR
from frigate.models import Export, User
logger = logging.getLogger(__name__)
class MediaAuthResolution(str, Enum):
"""Classification of an `X-Original-URL` path for media-auth purposes."""
CAMERA = "camera"
ADMIN_ONLY = "admin_only"
LISTING_MULTI_CAMERA = "listing_multi_camera"
LISTING_NEUTRAL = "listing_neutral"
# Under a recognized media root (/clips, /recordings, /exports) but
# unclassifiable (unknown subtree, no matching DB row, DB error).
# Restricted users are denied; admins/full-access roles are allowed
# (nginx will likely return 404 if the file genuinely doesn't exist).
UNRESOLVED_MEDIA = "unresolved_media"
# Not a media URI at all (e.g. /api/events, /login).
UNKNOWN = "unknown"
def extract_path(original_url: Optional[str]) -> Optional[str]:
"""Return the decoded path component of nginx's `X-Original-URL` header.
nginx forwards the *raw* request URI (with `..` segments intact) via
`$request_uri`. nginx normalizes the path before serving the file, so a
request like `/recordings/.../allowed_cam/../forbidden_cam/file.mp4`
would (1) parse as the allowed camera in our auth check, (2) be served
as the forbidden camera by nginx. To close the bypass we reject any URI
whose path contains `.` or `..` segments outright.
"""
if not original_url:
return None
parsed = urlparse(original_url)
raw_path = parsed.path or original_url
decoded = unquote(raw_path)
if not decoded:
return None
if not decoded.startswith("/"):
decoded = "/" + decoded
segments = decoded.split("/")
if ".." in segments or "." in segments:
return None
return decoded
def resolve_media_uri(
uri: str, frigate_config: Optional[FrigateConfig] = None
) -> tuple[MediaAuthResolution, Optional[str]]:
"""Classify a URI and return the owning camera if applicable.
`frigate_config` is used to disambiguate clip/review filenames whose
camera name contains hyphens by matching against the longest configured
camera-name prefix.
"""
if not uri:
return MediaAuthResolution.UNKNOWN, None
parts = [p for p in uri.split("/") if p]
if not parts:
return MediaAuthResolution.UNKNOWN, None
root = parts[0]
if root == "recordings":
return _resolve_recording(parts)
if root == "clips":
return _resolve_clip(parts, frigate_config)
if root == "exports":
return _resolve_export(parts)
return MediaAuthResolution.UNKNOWN, None
def _resolve_recording(
parts: list[str],
) -> tuple[MediaAuthResolution, Optional[str]]:
# /recordings → neutral
# /recordings/{date} → neutral
# /recordings/{date}/{hour} → multi-camera listing
# /recordings/{date}/{hour}/{cam}/... → camera
if len(parts) <= 2:
return MediaAuthResolution.LISTING_NEUTRAL, None
if len(parts) == 3:
return MediaAuthResolution.LISTING_MULTI_CAMERA, None
return MediaAuthResolution.CAMERA, parts[3]
def _resolve_clip(
parts: list[str], frigate_config: Optional[FrigateConfig]
) -> tuple[MediaAuthResolution, Optional[str]]:
# /clips → multi-camera listing
# /clips/thumbs/{cam}/... → camera
# /clips/previews/{cam}/... → camera
# /clips/review/thumb-{cam}-{review_id}.webp → camera (parsed)
# /clips/faces/... → admin-only
# /clips/genai-requests/... → admin-only
# /clips/preview_restart_cache/... → admin-only
# /clips/{model}/train|dataset/... → admin-only
# /clips/{cam}-{event_id}[-clean].{ext} → camera (parsed)
# other /clips/{subdir}/... → unresolved (deny restricted)
if len(parts) == 1:
return MediaAuthResolution.LISTING_MULTI_CAMERA, None
second = parts[1]
if second in ("thumbs", "previews"):
if len(parts) == 2:
return MediaAuthResolution.LISTING_MULTI_CAMERA, None
return MediaAuthResolution.CAMERA, parts[2]
if second == "review":
if len(parts) == 2:
return MediaAuthResolution.LISTING_MULTI_CAMERA, None
camera = _camera_from_thumb_filename(parts[2], frigate_config)
if camera:
return MediaAuthResolution.CAMERA, camera
return MediaAuthResolution.UNRESOLVED_MEDIA, None
if second in ("faces", "genai-requests", "preview_restart_cache"):
return MediaAuthResolution.ADMIN_ONLY, None
if len(parts) >= 3 and parts[2] in ("train", "dataset"):
return MediaAuthResolution.ADMIN_ONLY, None
if len(parts) == 2:
camera = _camera_from_clip_filename(second, frigate_config)
if camera:
return MediaAuthResolution.CAMERA, camera
return MediaAuthResolution.UNRESOLVED_MEDIA, None
return MediaAuthResolution.UNRESOLVED_MEDIA, None
def _longest_prefix_camera(
stem: str, frigate_config: Optional[FrigateConfig]
) -> Optional[str]:
if frigate_config is None:
return None
for cam in sorted(frigate_config.cameras.keys(), key=len, reverse=True):
if stem.startswith(cam + "-"):
return cam
return None
def _camera_from_clip_filename(
filename: str, frigate_config: Optional[FrigateConfig]
) -> Optional[str]:
"""Match a flat clip filename `{camera}-{event_id}[-clean].{ext}` against
configured camera names. Longest-prefix wins so camera names containing
hyphens (e.g. `front-door`) resolve correctly.
"""
dot = filename.rfind(".")
stem = filename[:dot] if dot > 0 else filename
return _longest_prefix_camera(stem, frigate_config)
def _camera_from_thumb_filename(
filename: str, frigate_config: Optional[FrigateConfig]
) -> Optional[str]:
"""Match a review thumbnail filename `thumb-{camera}-{review_id}.webp`."""
if not filename.startswith("thumb-"):
return None
dot = filename.rfind(".")
stem = filename[len("thumb-") : dot] if dot > 0 else filename[len("thumb-") :]
return _longest_prefix_camera(stem, frigate_config)
def _resolve_export(
parts: list[str],
) -> tuple[MediaAuthResolution, Optional[str]]:
# /exports → multi-camera listing
# /exports/{filename}.mp4 → camera (DB lookup by exact path)
if len(parts) == 1:
return MediaAuthResolution.LISTING_MULTI_CAMERA, None
if len(parts) != 2:
return MediaAuthResolution.UNRESOLVED_MEDIA, None
filename = parts[1]
full_path = os.path.join(EXPORT_DIR, filename)
try:
export = Export.get(Export.video_path == full_path)
return MediaAuthResolution.CAMERA, export.camera
except DoesNotExist:
return MediaAuthResolution.UNRESOLVED_MEDIA, None
except Exception as e:
logger.warning("Export DB lookup failed for %s: %s", filename, e)
return MediaAuthResolution.UNRESOLVED_MEDIA, None
def check_camera_access(role: str, camera: str, frigate_config: FrigateConfig) -> bool:
"""Return True iff `role` may access `camera`.
Mirrors the gating logic in `require_camera_access`: admin and any role
without a non-empty allow-list bypass the check.
"""
if role == "admin":
return True
roles_dict = frigate_config.auth.roles
if not roles_dict.get(role):
return True
all_camera_names = set(frigate_config.cameras.keys())
allowed = User.get_allowed_cameras(role, roles_dict, all_camera_names)
return camera in allowed
def is_role_restricted(role: str, frigate_config: FrigateConfig) -> bool:
"""True if `role` has a non-empty allow-list (i.e. not full-access)."""
if role == "admin":
return False
return bool(frigate_config.auth.roles.get(role))
def deny_response_for_media_uri(
original_url: Optional[str], role: Optional[str], frigate_config: FrigateConfig
) -> Optional[int]:
"""Decide whether the current role should be blocked from `original_url`.
Returns an HTTP status code (403) when access should be denied, or `None`
when the request is allowed.
"""
if not original_url:
return None
path = extract_path(original_url)
# `extract_path` returns None for URIs containing `.` or `..` segments.
# For media-root URIs that's a traversal attempt — deny outright. For
# non-media URIs, pass through (nginx / the backend handle them).
if path is None:
raw = urlparse(original_url).path or original_url
decoded = unquote(raw)
first = decoded.lstrip("/").split("/", 1)[0] if decoded else ""
if first in ("clips", "recordings", "exports"):
return 403
return None
resolution, camera = resolve_media_uri(path, frigate_config)
if resolution == MediaAuthResolution.UNKNOWN:
return None
if not role or role == "admin":
return None
if not is_role_restricted(role, frigate_config):
return None
if resolution == MediaAuthResolution.LISTING_NEUTRAL:
return None
if resolution in (
MediaAuthResolution.LISTING_MULTI_CAMERA,
MediaAuthResolution.ADMIN_ONLY,
MediaAuthResolution.UNRESOLVED_MEDIA,
):
return 403
if resolution == MediaAuthResolution.CAMERA:
if camera and check_camera_access(role, camera, frigate_config):
return None
return 403
return 403

View File

@ -428,18 +428,11 @@ class FrigateApp:
self.camera_maintainer.start()
def start_audio_processor(self) -> None:
audio_cameras = [
c
for c in self.config.cameras.values()
if c.enabled and c.audio.enabled_in_config
]
if audio_cameras:
self.audio_process = AudioProcessor(
self.config, audio_cameras, self.camera_metrics, self.stop_event
)
self.audio_process.start()
self.processes["audio_detector"] = self.audio_process.pid or 0
self.audio_process = AudioProcessor(
self.config, self.camera_metrics, self.stop_event
)
self.audio_process.start()
self.processes["audio_detector"] = self.audio_process.pid or 0
def start_timeline_processor(self) -> None:
self.timeline_processor = TimelineProcessor(

View File

@ -862,7 +862,9 @@ class FrigateConfig(FrigateBaseModel):
if mask_config:
coords = mask_config.coordinates
relative_coords = get_relative_coordinates(
coords, camera_config.frame_shape
coords,
camera_config.frame_shape,
camera_name=camera_config.name,
)
# Create a new ObjectMaskConfig with raw_coordinates set
processed_global_masks[mask_id] = ObjectMaskConfig(

View File

@ -269,7 +269,9 @@ class ObjectDescriptionProcessor(PostProcessorApi):
if event.has_snapshot and camera_config.objects.genai.use_snapshot:
snapshot_image = self._read_and_crop_snapshot(event)
if not snapshot_image:
self.cleanup_event(event_id)
return
num_thumbnails = len(self.tracked_events.get(event_id, []))

View File

@ -60,7 +60,11 @@ from frigate.data_processing.real_time.license_plate import (
)
from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum
from frigate.events.types import (
EventStateEnum,
EventTypeEnum,
RegenerateDescriptionEnum,
)
from frigate.genai import GenAIClientManager
from frigate.models import Event, Recordings, ReviewSegment, Trigger
from frigate.types import TrackedObjectUpdateTypesEnum
@ -435,7 +439,7 @@ class EmbeddingMaintainer(threading.Thread):
if update is None:
return
source_type, _, camera, frame_name, data = update
source_type, event_type, camera, frame_name, data = update
logger.debug(
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
@ -485,6 +489,12 @@ class EmbeddingMaintainer(threading.Thread):
for processor in self.post_processors:
if isinstance(processor, ObjectDescriptionProcessor):
# skip end events — _process_finalized handles them via event_end_subscriber.
# processing them here can re-create tracked_events entries after cleanup
# when the event_subscriber queue is backlogged behind event_end_subscriber.
if event_type == EventStateEnum.end:
continue
processor.process_data(
{
"camera": camera,

View File

@ -84,7 +84,6 @@ class AudioProcessor(FrigateProcess):
def __init__(
self,
config: FrigateConfig,
cameras: list[CameraConfig],
camera_metrics: DictProxy,
stop_event: MpEvent,
):
@ -93,12 +92,11 @@ class AudioProcessor(FrigateProcess):
)
self.camera_metrics = camera_metrics
self.cameras = cameras
self.config = config
def run(self) -> None:
self.pre_run_setup(self.config.logger)
audio_threads: list[AudioEventMaintainer] = []
audio_threads: dict[str, AudioEventMaintainer] = {}
threading.current_thread().name = "process:audio_manager"
@ -112,32 +110,56 @@ class AudioProcessor(FrigateProcess):
else:
self.transcription_model_runner = None
if len(self.cameras) == 0:
return
config_subscriber = CameraConfigUpdateSubscriber(
self.config,
self.config.cameras,
[
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.audio,
CameraConfigUpdateEnum.ffmpeg,
],
)
for camera in self.cameras:
audio_thread = AudioEventMaintainer(
def spawn_if_needed(camera: CameraConfig) -> None:
name = camera.name
if name is None or name in audio_threads:
return
if not camera.enabled or not camera.audio.enabled:
return
# ffmpeg update may not have arrived yet; wait for next poll
if not any("audio" in i.roles for i in camera.ffmpeg.inputs):
return
thread = AudioEventMaintainer(
camera,
self.config,
self.camera_metrics,
self.transcription_model_runner,
self.stop_event, # type: ignore[arg-type]
)
audio_threads.append(audio_thread)
audio_thread.start()
audio_threads[name] = thread
thread.start()
self.logger.info(f"Audio maintainer started for {name}")
for camera in self.config.cameras.values():
spawn_if_needed(camera)
self.logger.info(f"Audio processor started (pid: {self.pid})")
while not self.stop_event.wait():
pass
# poll for newly added cameras or cameras flipped to audio.enabled at runtime
while not self.stop_event.wait(timeout=1.0):
config_subscriber.check_for_updates()
for camera in self.config.cameras.values():
spawn_if_needed(camera)
for thread in audio_threads:
config_subscriber.stop()
for thread in audio_threads.values():
thread.join(1)
if thread.is_alive():
self.logger.info(f"Waiting for thread {thread.name:s} to exit")
thread.join(10)
for thread in audio_threads:
for thread in audio_threads.values():
if thread.is_alive():
self.logger.warning(f"Thread {thread.name} is still alive")

View File

@ -62,8 +62,10 @@ def get_canvas_shape(width: int, height: int) -> tuple[int, int]:
if round(a_w / a_h, 2) != round(width / height, 2):
canvas_width = int(width // 4 * 4)
canvas_height = int((canvas_width / a_w * a_h) // 4 * 4)
logger.warning(
f"The birdseye resolution is a non-standard aspect ratio, forcing birdseye resolution to {canvas_width} x {canvas_height}"
logger.error(
f"Birdseye resolution {width}x{height} is not a supported aspect ratio "
f"and may cause visual distortion; falling back to {canvas_width}x{canvas_height}. "
f"Set width and height to a supported aspect ratio (16:9, 20:10, 16:6, 32:9, 12:9, 22:15, 9:16, 9:12, 16:3, or 1:1)"
)
return (canvas_width, canvas_height)
@ -796,15 +798,18 @@ class Birdseye:
websocket_server: Any,
) -> None:
self.config = config
canvas_width, canvas_height = get_canvas_shape(
config.birdseye.width, config.birdseye.height
)
self.input: queue.Queue[bytes] = queue.Queue(maxsize=10)
self.converter = FFMpegConverter(
config.ffmpeg,
self.input,
stop_event,
config.birdseye.width,
config.birdseye.height,
config.birdseye.width,
config.birdseye.height,
canvas_width,
canvas_height,
canvas_width,
canvas_height,
config.birdseye.quality,
config.birdseye.restream,
)

View File

@ -0,0 +1,381 @@
"""Unit tests for `frigate.api.media_auth`.
Covers URI classification, the role-vs-camera decision matrix, and the export
DB-lookup path. These are pure functions/DB lookups no HTTP stack involved.
"""
import datetime
import logging
import os
import unittest
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.api.media_auth import (
MediaAuthResolution,
deny_response_for_media_uri,
extract_path,
resolve_media_uri,
)
from frigate.config import FrigateConfig
from frigate.models import Event, Export, Recordings, ReviewSegment
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
_CONFIG = {
"mqtt": {"host": "mqtt"},
"auth": {"roles": {"limited_user": ["front_door"]}},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"back_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
# Camera name with a hyphen — exercises longest-prefix match.
"back-yard": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
class TestExtractPath(unittest.TestCase):
def test_full_url(self):
self.assertEqual(
extract_path("http://host:8971/clips/front_door-1.jpg"),
"/clips/front_door-1.jpg",
)
def test_strips_query_string(self):
self.assertEqual(
extract_path("http://h/recordings/2026-05-11/14/front_door/00.00.mp4?t=1"),
"/recordings/2026-05-11/14/front_door/00.00.mp4",
)
def test_path_only(self):
self.assertEqual(extract_path("/exports/x.mp4"), "/exports/x.mp4")
def test_percent_decoded(self):
self.assertEqual(
extract_path("http://h/clips/front%20door-1.jpg"),
"/clips/front door-1.jpg",
)
def test_empty(self):
self.assertIsNone(extract_path(None))
self.assertIsNone(extract_path(""))
class TestResolveMediaUri(unittest.TestCase):
def setUp(self):
self.config = FrigateConfig(**_CONFIG)
def _assert(self, uri, resolution, camera=None):
got_resolution, got_camera = resolve_media_uri(uri, self.config)
self.assertEqual(got_resolution, resolution, uri)
self.assertEqual(got_camera, camera, uri)
def test_unknown_paths(self):
self._assert("/api/events", MediaAuthResolution.UNKNOWN)
self._assert("/", MediaAuthResolution.UNKNOWN)
self._assert("", MediaAuthResolution.UNKNOWN)
def test_recordings(self):
self._assert("/recordings/", MediaAuthResolution.LISTING_NEUTRAL)
self._assert("/recordings/2026-05-11/", MediaAuthResolution.LISTING_NEUTRAL)
self._assert(
"/recordings/2026-05-11/14/", MediaAuthResolution.LISTING_MULTI_CAMERA
)
self._assert(
"/recordings/2026-05-11/14/front_door/",
MediaAuthResolution.CAMERA,
camera="front_door",
)
self._assert(
"/recordings/2026-05-11/14/back_door/00.00.mp4",
MediaAuthResolution.CAMERA,
camera="back_door",
)
def test_clip_flat_filename_resolves_camera(self):
self._assert(
"/clips/front_door-1234.jpg",
MediaAuthResolution.CAMERA,
camera="front_door",
)
self._assert(
"/clips/back_door-1234-clean.webp",
MediaAuthResolution.CAMERA,
camera="back_door",
)
def test_clip_filename_with_hyphenated_camera_name(self):
# Camera name "back-yard" itself contains a hyphen; longest-prefix
# match must pick `back-yard`, not the bogus `back` prefix.
self._assert(
"/clips/back-yard-1234.jpg",
MediaAuthResolution.CAMERA,
camera="back-yard",
)
def test_clip_filename_no_matching_camera(self):
# Looks like a media path but couldn't classify — fail closed for
# restricted users (UNRESOLVED_MEDIA), not pass-through.
self._assert(
"/clips/nonexistent-1234.jpg", MediaAuthResolution.UNRESOLVED_MEDIA
)
def test_clip_thumbs(self):
self._assert("/clips/thumbs/", MediaAuthResolution.LISTING_MULTI_CAMERA)
self._assert(
"/clips/thumbs/front_door/",
MediaAuthResolution.CAMERA,
camera="front_door",
)
self._assert(
"/clips/thumbs/back_door/abc.webp",
MediaAuthResolution.CAMERA,
camera="back_door",
)
def test_clip_previews(self):
self._assert("/clips/previews/", MediaAuthResolution.LISTING_MULTI_CAMERA)
self._assert(
"/clips/previews/front_door/",
MediaAuthResolution.CAMERA,
camera="front_door",
)
self._assert(
"/clips/previews/back_door/segment.mp4",
MediaAuthResolution.CAMERA,
camera="back_door",
)
def test_clip_review_thumbs(self):
# Format: /clips/review/thumb-{camera}-{review_id}.webp (frigate/review/maintainer.py).
self._assert(
"/clips/review/thumb-front_door-abc123.webp",
MediaAuthResolution.CAMERA,
camera="front_door",
)
# Hyphenated camera name — longest-prefix match.
self._assert(
"/clips/review/thumb-back-yard-abc123.webp",
MediaAuthResolution.CAMERA,
camera="back-yard",
)
# Unknown camera prefix → unresolved, not allowed for restricted users.
self._assert(
"/clips/review/thumb-unknown-cam-abc123.webp",
MediaAuthResolution.UNRESOLVED_MEDIA,
)
def test_clip_admin_only_subtrees(self):
self._assert("/clips/faces/train/foo.webp", MediaAuthResolution.ADMIN_ONLY)
self._assert("/clips/faces/", MediaAuthResolution.ADMIN_ONLY)
self._assert("/clips/genai-requests/x/0.webp", MediaAuthResolution.ADMIN_ONLY)
self._assert(
"/clips/preview_restart_cache/x.mp4", MediaAuthResolution.ADMIN_ONLY
)
self._assert("/clips/some_model/train/x.jpg", MediaAuthResolution.ADMIN_ONLY)
self._assert("/clips/some_model/dataset/x.jpg", MediaAuthResolution.ADMIN_ONLY)
def test_clip_unknown_subtree_is_unresolved(self):
# Unknown /clips/{x}/{y}/... subtree falls through as unresolved (not
# admin-only) so restricted users get 403 without admins being denied
# access to legitimate but unrecognized resources.
self._assert("/clips/random_dir/foo.jpg", MediaAuthResolution.UNRESOLVED_MEDIA)
def test_clip_top_level_listing(self):
self._assert("/clips/", MediaAuthResolution.LISTING_MULTI_CAMERA)
def test_exports_listing(self):
self._assert("/exports/", MediaAuthResolution.LISTING_MULTI_CAMERA)
class TestExportResolution(unittest.TestCase):
"""Export resolution requires a DB lookup."""
def setUp(self):
migrate_db = SqliteExtDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:]
Router(migrate_db).run()
migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB)
self.db.bind([Event, ReviewSegment, Recordings, Export])
self.config = FrigateConfig(**_CONFIG)
def tearDown(self):
if not self.db.is_closed():
self.db.close()
for f in TEST_DB_CLEANUPS:
try:
os.remove(f)
except OSError:
pass
def _insert_export(self, export_id, camera, filename):
Export.insert(
id=export_id,
camera=camera,
name=f"export-{export_id}",
date=datetime.datetime.now(),
video_path=f"/media/frigate/exports/{filename}",
thumb_path=f"/media/frigate/exports/{filename}.jpg",
in_progress=False,
).execute()
def test_export_resolves_camera(self):
self._insert_export(
"exp1", "back_door", "back_door_20260511_140000-20260511_150000_abc123.mp4"
)
resolution, camera = resolve_media_uri(
"/exports/back_door_20260511_140000-20260511_150000_abc123.mp4",
self.config,
)
self.assertEqual(resolution, MediaAuthResolution.CAMERA)
self.assertEqual(camera, "back_door")
def test_unknown_export_is_unresolved(self):
# No matching row → UNRESOLVED_MEDIA (fail closed for restricted users),
# not UNKNOWN (which would pass-through).
resolution, camera = resolve_media_uri(
"/exports/does_not_exist.mp4", self.config
)
self.assertEqual(resolution, MediaAuthResolution.UNRESOLVED_MEDIA)
self.assertIsNone(camera)
def test_export_anchored_match_not_endswith(self):
# Anchored exact-path equality must NOT match by filename suffix.
# A request like /exports/clip.mp4 must not authorize against a row at
# /media/frigate/exports/back_door_clip.mp4 just because the suffix matches.
self._insert_export("exp_bd", "back_door", "back_door_clip.mp4")
self._insert_export("exp_fd", "front_door", "front_door_clip.mp4")
resolution, _ = resolve_media_uri("/exports/clip.mp4", self.config)
self.assertEqual(resolution, MediaAuthResolution.UNRESOLVED_MEDIA)
class TestDenyResponseForMediaUri(unittest.TestCase):
"""End-to-end decision check used by /auth."""
def setUp(self):
self.config = FrigateConfig(**_CONFIG)
def _deny(self, url, role):
return deny_response_for_media_uri(url, role, self.config)
def test_admin_always_allowed(self):
self.assertIsNone(self._deny("/clips/back_door-1.jpg", "admin"))
self.assertIsNone(self._deny("/clips/", "admin"))
self.assertIsNone(self._deny("/clips/faces/x.webp", "admin"))
self.assertIsNone(
self._deny("/recordings/2026-05-11/14/back_door/00.00.mp4", "admin")
)
def test_unrestricted_role_allowed(self):
# "viewer" role has no entry in roles_dict → full access (matches the
# behavior of require_camera_access).
self.assertIsNone(self._deny("/clips/back_door-1.jpg", "viewer"))
self.assertIsNone(self._deny("/clips/", "viewer"))
def test_restricted_role_allowed_camera(self):
self.assertIsNone(self._deny("/clips/front_door-1.jpg", "limited_user"))
self.assertIsNone(
self._deny("/recordings/2026-05-11/14/front_door/00.00.mp4", "limited_user")
)
self.assertIsNone(
self._deny("/clips/thumbs/front_door/abc.webp", "limited_user")
)
def test_restricted_role_blocked_other_camera(self):
self.assertEqual(self._deny("/clips/back_door-1.jpg", "limited_user"), 403)
self.assertEqual(
self._deny("/recordings/2026-05-11/14/back_door/00.00.mp4", "limited_user"),
403,
)
self.assertEqual(
self._deny("/clips/thumbs/back_door/abc.webp", "limited_user"), 403
)
def test_restricted_role_blocked_admin_only(self):
self.assertEqual(self._deny("/clips/faces/train/foo.webp", "limited_user"), 403)
def test_restricted_role_blocked_multi_camera_listing(self):
self.assertEqual(self._deny("/clips/", "limited_user"), 403)
self.assertEqual(self._deny("/exports/", "limited_user"), 403)
self.assertEqual(self._deny("/recordings/2026-05-11/14/", "limited_user"), 403)
def test_restricted_role_allowed_neutral_listing(self):
self.assertIsNone(self._deny("/recordings/", "limited_user"))
self.assertIsNone(self._deny("/recordings/2026-05-11/", "limited_user"))
def test_non_media_uri_passes_through(self):
self.assertIsNone(self._deny("/api/events", "limited_user"))
self.assertIsNone(self._deny("http://h/login", "limited_user"))
def test_missing_header(self):
self.assertIsNone(self._deny(None, "limited_user"))
self.assertIsNone(self._deny("", "limited_user"))
def test_traversal_in_media_uri_denied_for_all_roles(self):
# Bypass attempt: parts[3] looks like an allowed camera, but the
# normalized path nginx would serve points at a forbidden camera.
# Both restricted and admin should be denied — the URI is malformed
# and we refuse to make an auth decision against it.
traversal_uris = [
"/recordings/2026-05-11/14/front_door/../back_door/00.00.mp4",
"/clips/front_door-1.jpg/../back_door-1.jpg",
"/exports/../recordings/2026-05-11/14/back_door/00.00.mp4",
"/clips/./back_door-1.jpg",
]
for uri in traversal_uris:
self.assertEqual(self._deny(uri, "limited_user"), 403, uri)
self.assertEqual(self._deny(uri, "admin"), 403, uri)
self.assertEqual(self._deny(uri, "viewer"), 403, uri)
def test_traversal_outside_media_passes_through(self):
# `..` in non-media URIs is not our problem; the backend handles it.
self.assertIsNone(self._deny("/api/foo/../bar", "limited_user"))
def test_percent_encoded_traversal_denied(self):
# nginx may decode percent-encoded `%2E%2E` to `..` before serving;
# we must apply the same denial after percent-decoding.
self.assertEqual(
self._deny(
"/recordings/2026-05-11/14/front_door/%2E%2E/back_door/00.mp4",
"limited_user",
),
403,
)
def test_unresolved_media_fails_closed_for_restricted(self):
# Restricted user requesting a media URI we can't classify (no DB row,
# unknown clip prefix, unknown clip subtree) must be denied.
self.assertEqual(self._deny("/clips/nonexistent-1.jpg", "limited_user"), 403)
self.assertEqual(self._deny("/clips/random_dir/foo.jpg", "limited_user"), 403)
self.assertEqual(
self._deny("/clips/review/thumb-unknown_cam-1.webp", "limited_user"),
403,
)
def test_unresolved_media_allowed_for_admin(self):
# Admin and full-access roles are *not* denied on UNRESOLVED_MEDIA —
# nginx returns 404 if the file doesn't exist on disk anyway, and we
# don't want a stale DB to lock out admins.
self.assertIsNone(self._deny("/clips/nonexistent-1.jpg", "admin"))
self.assertIsNone(self._deny("/clips/nonexistent-1.jpg", "viewer"))
if __name__ == "__main__":
unittest.main()

View File

@ -492,7 +492,7 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
genai = new_config.get("genai")
if genai and genai.get("provider"):
genai["roles"] = ["embeddings", "vision", "tools"]
genai["roles"] = ["embeddings", "descriptions", "chat"]
new_config["genai"] = {"default": genai}
# Remove deprecated sync_recordings from global record config
@ -608,11 +608,14 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
def get_relative_coordinates(
mask: Optional[Union[str, list]], frame_shape: tuple[int, int]
mask: Optional[Union[str, list]],
frame_shape: tuple[int, int],
camera_name: str = "",
) -> Union[str, list]:
# masks and zones are saved as relative coordinates
# we know if any points are > 1 then it is using the
# old native resolution coordinates
where = f" for camera {camera_name}" if camera_name else ""
if mask:
if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")):
relative_masks = []
@ -627,7 +630,7 @@ def get_relative_coordinates(
if x > frame_shape[1] or y > frame_shape[0]:
logger.error(
f"Not applying mask due to invalid coordinates. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask."
f"Not applying mask due to invalid coordinates{where}. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask."
)
continue
@ -650,7 +653,7 @@ def get_relative_coordinates(
if x > frame_shape[1] or y > frame_shape[0]:
logger.error(
f"Not applying mask due to invalid coordinates. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask."
f"Not applying mask due to invalid coordinates{where}. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask."
)
return []

View File

@ -150,29 +150,51 @@ def extract_translations_from_schema(
# Handle anyOf cases
elif "anyOf" in field_schema:
for item in field_schema["anyOf"]:
nested = None
if item.get("type") == "null":
continue
if "properties" in item:
nested = extract_translations_from_schema(item, defs=defs)
elif "$ref" in item:
ref_path = item["$ref"]
if ref_path.startswith("#/$defs/"):
ref_name = ref_path.split("/")[-1]
if ref_name in defs:
nested = extract_translations_from_schema(
defs[ref_name], defs=defs
)
elif (
"additionalProperties" in item
and isinstance(item["additionalProperties"], dict)
and "$ref" in item["additionalProperties"]
):
ref_path = item["additionalProperties"]["$ref"]
if ref_path.startswith("#/$defs/"):
ref_name = ref_path.split("/")[-1]
if ref_name in defs:
nested = extract_translations_from_schema(
defs[ref_name], defs=defs
)
elif (
"items" in item
and isinstance(item["items"], dict)
and ("$ref" in item["items"])
):
ref_path = item["items"]["$ref"]
if ref_path.startswith("#/$defs/"):
ref_name = ref_path.split("/")[-1]
if ref_name in defs:
nested = extract_translations_from_schema(
defs[ref_name], defs=defs
)
if nested:
nested_without_root = {
k: v
for k, v in nested.items()
if k not in ("label", "description")
}
field_translations.update(nested_without_root)
elif "$ref" in item:
ref_path = item["$ref"]
if ref_path.startswith("#/$defs/"):
ref_name = ref_path.split("/")[-1]
if ref_name in defs:
ref_schema = defs[ref_name]
nested = extract_translations_from_schema(
ref_schema, defs=defs
)
nested_without_root = {
k: v
for k, v in nested.items()
if k not in ("label", "description")
}
field_translations.update(nested_without_root)
if field_translations:
translations[field_name] = field_translations

View File

@ -33,7 +33,11 @@
},
"filters": {
"label": "Audio filters",
"description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives."
"description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.",
"threshold": {
"label": "Minimum audio confidence",
"description": "Minimum confidence threshold for the audio event to be counted."
}
},
"enabled_in_config": {
"label": "Original audio state",

View File

@ -559,7 +559,11 @@
},
"filters": {
"label": "Audio filters",
"description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives."
"description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.",
"threshold": {
"label": "Minimum audio confidence",
"description": "Minimum confidence threshold for the audio event to be counted."
}
},
"enabled_in_config": {
"label": "Original audio state",

View File

@ -125,5 +125,5 @@
"baby": "Baby",
"baby_stroller": "Baby Stroller",
"rickshaw": "Rickshaw",
"Rodent": "Rodent"
}
"rodent": "Rodent"
}

View File

@ -32,7 +32,11 @@
"title": "Recent Recognitions",
"titleShort": "Recent",
"aria": "Select recent recognitions",
"empty": "There are no recent face recognition attempts"
"empty": "There are no recent face recognition attempts",
"emptyNoLibrary": {
"title": "Upload a face",
"description": "You must add at least one face to the library for face recognition to function."
}
},
"deleteFaceLibrary": {
"title": "Delete Name",

View File

@ -446,6 +446,7 @@
},
"cameraManagement": {
"title": "Manage Cameras",
"description": "Add, edit, and delete cameras, control which cameras are enabled, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.",
"addCamera": "Add New Camera",
"deleteCamera": "Delete Camera",
"deleteCameraDialog": {
@ -1127,8 +1128,16 @@
"cameras": "Cameras",
"loading": "Loading model information…",
"error": "Failed to load model information",
"noModelLoaded": "No Frigate+ model is currently loaded.",
"availableModels": "Available Models",
"loadingAvailableModels": "Loading available models…",
"selectModel": "Select a model",
"noModelsAvailable": "No models available",
"filter": {
"ariaLabel": "Filter models by type",
"baseModels": "Base Models",
"fineTunedModels": "Fine-tuned Models"
},
"modelSelect": "Your available models on Frigate+ can be selected here. Note that only models compatible with your current detector configuration can be selected."
},
"unsavedChanges": "Unsaved Frigate+ settings changes",
@ -1744,4 +1753,4 @@
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
}
}
}
}

View File

@ -12,6 +12,7 @@ type EmptyCardProps = {
description?: string;
buttonText?: string;
link?: string;
onClick?: () => void;
};
export function EmptyCard({
className,
@ -21,6 +22,7 @@ export function EmptyCard({
description,
buttonText,
link,
onClick,
}: EmptyCardProps) {
let TitleComponent;
@ -39,11 +41,16 @@ export function EmptyCard({
{description}
</div>
)}
{buttonText?.length && (
<Button size="sm" variant="select">
<Link to={link ?? "#"}>{buttonText}</Link>
</Button>
)}
{buttonText?.length &&
(onClick ? (
<Button size="sm" variant="select" onClick={onClick}>
{buttonText}
</Button>
) : (
<Button size="sm" variant="select">
<Link to={link ?? "#"}>{buttonText}</Link>
</Button>
))}
</div>
);
}

View File

@ -41,19 +41,12 @@ const ffmpeg: SectionConfigOverrides = {
"input_args",
"hwaccel_args",
"output_args",
"path",
"retry_interval",
"apple_compatibility",
"gpu",
],
hiddenFields: [],
advancedFields: [
"path",
"global_args",
"retry_interval",
"apple_compatibility",
"path",
"gpu",
],
hiddenFields: ["retry_interval"],
advancedFields: ["path", "global_args", "gpu"],
overrideFields: [
"inputs",
"path",
@ -61,7 +54,6 @@ const ffmpeg: SectionConfigOverrides = {
"input_args",
"hwaccel_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
@ -125,19 +117,10 @@ const ffmpeg: SectionConfigOverrides = {
"global_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
advancedFields: [
"global_args",
"input_args",
"output_args",
"path",
"retry_interval",
"apple_compatibility",
"gpu",
],
advancedFields: ["global_args", "input_args", "output_args", "path", "gpu"],
uiSchema: {
path: {
"ui:options": { size: "md" },

View File

@ -41,7 +41,6 @@ import Heading from "@/components/ui/heading";
import get from "lodash/get";
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import merge from "lodash/merge";
import {
Collapsible,
CollapsibleContent,
@ -73,6 +72,7 @@ import {
buildConfigDataForPath,
flattenOverrides,
getBaseCameraSectionValue,
mergeProfileOverrides,
resolveHiddenFieldEntries,
sanitizeSectionData as sharedSanitizeSectionData,
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
@ -353,7 +353,10 @@ export function ConfigSection({
`profiles.${profileName}.${sectionPath}`,
);
if (profileOverrides && typeof profileOverrides === "object") {
return merge(cloneDeep(baseValue ?? {}), cloneDeep(profileOverrides));
return mergeProfileOverrides(
(baseValue as object) ?? {},
profileOverrides as object,
);
}
return baseValue;
}
@ -1044,6 +1047,7 @@ export function ConfigSection({
hiddenFields: effectiveHiddenFields,
restartRequired: sectionConfig.restartRequired,
requiresRestart,
isProfile: !!profileName,
}}
/>

View File

@ -6,6 +6,7 @@ import { getWidget } from "@rjsf/utils";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { getNonNullSchema } from "../fields/nullableUtils";
import type { ConfigFormContext } from "@/types/configForm";
export function OptionalFieldWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options, registry } =
@ -13,6 +14,8 @@ export function OptionalFieldWidget(props: WidgetProps) {
const innerWidgetName = (options.innerWidget as string) || undefined;
const isEnabled = value !== undefined && value !== null;
const formContext = registry?.formContext as ConfigFormContext | undefined;
const isProfile = !!formContext?.isProfile;
// Extract the non-null branch from anyOf [Type, null]
const innerSchema = getNonNullSchema(schema) ?? schema;
@ -42,10 +45,17 @@ export function OptionalFieldWidget(props: WidgetProps) {
const innerProps: WidgetProps = {
...props,
schema: innerSchema,
disabled: disabled || readonly || !isEnabled,
disabled: disabled || readonly || (!isProfile && !isEnabled),
value: isEnabled ? value : getDefaultValue(),
};
// don't show the switch if we're editing in a profile
// to disable in a profile, users should edit the config manually, eg:
// skip_motion_threshold: None
if (isProfile) {
return <InnerWidget {...innerProps} />;
}
return (
<div className="flex items-center gap-3">
<Switch

View File

@ -102,6 +102,19 @@ export default function ClassificationSelectionDialog({
// control
const [newClass, setNewClass] = useState(false);
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
// scroll containers, so attach a non-passive listener that scrolls manually.
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
if (!el || !isDesktop) return;
const handleWheel = (e: WheelEvent) => {
if (el.scrollHeight <= el.clientHeight) return;
e.preventDefault();
el.scrollTop += e.deltaY;
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => el.removeEventListener("wheel", handleWheel);
}, []);
// components
const Selector = isDesktop ? DropdownMenu : Drawer;
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
@ -114,6 +127,8 @@ export default function ClassificationSelectionDialog({
</DrawerClose>
);
// keep modal false on desktop to prevent dismissable layer pointer events
// issue with dialog auto-close
return (
<div className={className ?? "flex"}>
<TextEntryDialog
@ -122,60 +137,60 @@ export default function ClassificationSelectionDialog({
title={t("createCategory.new")}
onSave={(newCat) => onCategorizeImage(newCat)}
/>
<Tooltip>
<Selector {...(isDesktop ? { modal: false } : {})}>
<SelectorTrigger asChild>
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
</SelectorTrigger>
<SelectorContent
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isMobile && (
<DrawerHeader className="sr-only">
<DrawerTitle>Details</DrawerTitle>
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
)}
<DropdownMenuLabel>
{dialogLabel ?? t("categorizeImageAs")}
</DropdownMenuLabel>
<div
className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto",
isMobile && "gap-2 pb-4",
)}
<Selector {...(isDesktop ? { modal: false } : {})}>
<Tooltip>
<TooltipTrigger asChild={isChildButton}>
<SelectorTrigger asChild>{children}</SelectorTrigger>
</TooltipTrigger>
<TooltipContent>
{tooltipLabel ?? t("categorizeImage")}
</TooltipContent>
</Tooltip>
<SelectorContent
ref={scrollContainerRef}
className={cn(
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isMobile && (
<DrawerHeader className="sr-only">
<DrawerTitle>Details</DrawerTitle>
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
)}
<DropdownMenuLabel>
{dialogLabel ?? t("categorizeImageAs")}
</DropdownMenuLabel>
<div className={cn("flex flex-col", isMobile && "gap-2 pb-4")}>
{filteredClasses
.sort((a, b) => {
if (a === "none") return 1;
if (b === "none") return -1;
return a.localeCompare(b);
})
.map((category) => (
<SelectorItem
key={category}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onCategorizeImage(category)}
>
{category === "none"
? t("details.none")
: category.replaceAll("_", " ")}
</SelectorItem>
))}
<Separator />
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewClass(true)}
>
{filteredClasses
.sort((a, b) => {
if (a === "none") return 1;
if (b === "none") return -1;
return a.localeCompare(b);
})
.map((category) => (
<SelectorItem
key={category}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onCategorizeImage(category)}
>
{category === "none"
? t("details.none")
: category.replaceAll("_", " ")}
</SelectorItem>
))}
<Separator />
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewClass(true)}
>
{t("createCategory.new")}
</SelectorItem>
</div>
</SelectorContent>
</Selector>
<TooltipContent>{tooltipLabel ?? t("categorizeImage")}</TooltipContent>
</Tooltip>
{t("createCategory.new")}
</SelectorItem>
</div>
</SelectorContent>
</Selector>
</div>
);
}

View File

@ -23,7 +23,7 @@ import {
import { isDesktop, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import React, { ReactNode, useMemo, useState } from "react";
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import TextEntryDialog from "./dialog/TextEntryDialog";
import { Button } from "../ui/button";
@ -61,6 +61,19 @@ export default function FaceSelectionDialog({
// control
const [newFace, setNewFace] = useState(false);
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
// scroll containers, so attach a non-passive listener that scrolls manually.
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
if (!el || !isDesktop) return;
const handleWheel = (e: WheelEvent) => {
if (el.scrollHeight <= el.clientHeight) return;
e.preventDefault();
el.scrollTop += e.deltaY;
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => el.removeEventListener("wheel", handleWheel);
}, []);
// components
const Selector = isDesktop ? DropdownMenu : Drawer;
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
@ -73,6 +86,8 @@ export default function FaceSelectionDialog({
</DrawerClose>
);
// keep modal false on desktop to prevent dismissable layer pointer events
// issue with dialog auto-close
return (
<div className={className ?? "flex"}>
{newFace && (
@ -83,52 +98,56 @@ export default function FaceSelectionDialog({
onSave={(newName) => onTrainAttempt(newName)}
/>
)}
<Tooltip>
<Selector {...(isDesktop ? { modal: false } : {})}>
<SelectorTrigger asChild>
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
</SelectorTrigger>
<SelectorContent
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isMobile && (
<DrawerHeader className="sr-only">
<DrawerTitle>Details</DrawerTitle>
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
<Selector {...(isDesktop ? { modal: false } : {})}>
<Tooltip>
<TooltipTrigger asChild={isChildButton}>
<SelectorTrigger asChild>{children}</SelectorTrigger>
</TooltipTrigger>
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
</Tooltip>
<SelectorContent
ref={scrollContainerRef}
className={cn(
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isMobile && (
<DrawerHeader className="sr-only">
<DrawerTitle>Details</DrawerTitle>
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
)}
<DropdownMenuLabel>
{dialogLabel ?? t("trainFaceAs")}
</DropdownMenuLabel>
<div
className={cn(
"flex flex-col",
isMobile &&
"max-h-[40dvh] gap-2 overflow-y-auto overflow-x-hidden pb-4",
)}
<DropdownMenuLabel>
{dialogLabel ?? t("trainFaceAs")}
</DropdownMenuLabel>
<div
className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
isMobile && "gap-2 pb-4",
)}
>
{filteredNames.sort().map((faceName) => (
<SelectorItem
key={faceName}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onTrainAttempt(faceName)}
>
{faceName}
</SelectorItem>
))}
<DropdownMenuSeparator />
>
{filteredNames.sort().map((faceName) => (
<SelectorItem
key={faceName}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewFace(true)}
onClick={() => onTrainAttempt(faceName)}
>
{t("createFaceLibrary.new")}
{faceName}
</SelectorItem>
</div>
</SelectorContent>
</Selector>
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
</Tooltip>
))}
<DropdownMenuSeparator />
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewFace(true)}
>
{t("createFaceLibrary.new")}
</SelectorItem>
</div>
</SelectorContent>
</Selector>
</div>
);
}

View File

@ -528,7 +528,7 @@ export default function ZoneEditPane({
);
updateConfig();
// Only publish WS state for base config when zone has a name
if (!editingProfile && zoneName) {
if (!editingProfile && polygon?.name) {
sendZoneState(enabled ? "ON" : "OFF");
}
} else {

View File

@ -1,5 +1,6 @@
import AddFaceIcon from "@/components/icons/AddFaceIcon";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { EmptyCard } from "@/components/card/EmptyCard";
import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog";
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog";
@ -473,7 +474,9 @@ export default function FaceLibrary() {
attemptImages={trainImages}
faceNames={faces}
selectedFaces={selectedFaces}
isLoading={faceData === undefined}
onClickFaces={onClickFaces}
onAddFace={() => setAddFace(true)}
onRefresh={refreshFaces}
/>
) : (
@ -691,7 +694,9 @@ type TrainingGridProps = {
attemptImages: string[];
faceNames: string[];
selectedFaces: string[];
isLoading: boolean;
onClickFaces: (images: string[], ctrl: boolean) => void;
onAddFace: () => void;
onRefresh: (
data?:
| FaceLibraryData
@ -708,7 +713,9 @@ function TrainingGrid({
attemptImages,
faceNames,
selectedFaces,
isLoading,
onClickFaces,
onAddFace,
onRefresh,
}: TrainingGridProps) {
const { t } = useTranslation(["views/faceLibrary"]);
@ -762,6 +769,25 @@ function TrainingGrid({
]);
if (attemptImages.length == 0) {
if (isLoading) {
return (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 items-center text-center" />
);
}
if (faceNames.length == 0) {
return (
<EmptyCard
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 items-center text-center"
icon={<AddFaceIcon className="size-16" />}
title={t("train.emptyNoLibrary.title")}
description={t("train.emptyNoLibrary.description")}
buttonText={t("button.addFace")}
onClick={onAddFace}
/>
);
}
return (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderCheck className="size-16" />

View File

@ -44,4 +44,5 @@ export type ConfigFormContext = {
requiresRestart?: boolean;
t?: (key: string, options?: Record<string, unknown>) => string;
renderers?: Record<string, RendererComponent>;
isProfile?: boolean;
};

View File

@ -6,7 +6,6 @@
import get from "lodash/get";
import cloneDeep from "lodash/cloneDeep";
import merge from "lodash/merge";
import unset from "lodash/unset";
import isEqual from "lodash/isEqual";
import mergeWith from "lodash/mergeWith";
@ -92,6 +91,32 @@ export function getBaseCameraSectionValue(
return base !== undefined ? base : get(cam, sectionPath);
}
// mergeWith customizer that replaces arrays wholesale instead of merging them
// positionally by index. Used when the source value is meant to fully replace
// the destination (e.g. profile overrides, section config overrides), so an
// empty source array correctly clears the destination array.
const replaceArraysCustomizer = (objValue: unknown, srcValue: unknown) => {
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
return srcValue !== undefined ? srcValue : objValue;
}
return undefined;
};
// Merge profile overrides on top of base config values. Matches the backend's
// deep_merge(overrides, base_data) semantics: arrays are replaced wholesale by
// the profile's value rather than merged positionally, so an empty array in a
// profile clears the base array instead of leaving stale entries behind.
export function mergeProfileOverrides<T extends object>(
baseValue: T,
profileOverrides: object,
): T {
return mergeWith(
cloneDeep(baseValue),
cloneDeep(profileOverrides),
replaceArraysCustomizer,
) as T;
}
/** Sections that can appear inside a camera profile definition. */
export const PROFILE_ELIGIBLE_SECTIONS = new Set([
"audio",
@ -564,9 +589,9 @@ export function prepareSectionSavePayload(opts: {
baseValue &&
typeof baseValue === "object"
) {
rawSectionValue = merge(
cloneDeep(baseValue),
cloneDeep(profileOverrides),
rawSectionValue = mergeProfileOverrides(
baseValue as object,
profileOverrides as object,
);
} else {
rawSectionValue = baseValue;
@ -675,13 +700,12 @@ const mergeSectionConfig = (
overrides: Partial<SectionConfig> | undefined,
): SectionConfig =>
mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => {
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
return srcValue ?? objValue;
}
const arrayResult = replaceArraysCustomizer(objValue, srcValue);
if (arrayResult !== undefined) return arrayResult;
if (key === "uiSchema") {
if (objValue && srcValue) {
return merge({}, objValue, srcValue);
return mergeWith({}, objValue, srcValue, replaceArraysCustomizer);
}
return srcValue ?? objValue;
}

View File

@ -122,9 +122,12 @@ export default function CameraManagementView({
<div className="scrollbar-container flex-1 overflow-y-auto pb-2">
{viewMode === "settings" ? (
<>
<Heading as="h4" className="mb-6">
<Heading as="h4" className="mb-2">
{t("cameraManagement.title")}
</Heading>
<p className="mb-6 max-w-5xl text-sm text-muted-foreground">
{t("cameraManagement.description")}
</p>
<div className="w-full max-w-5xl space-y-6">
<div className="flex gap-2">

View File

@ -1,5 +1,5 @@
import Heading from "@/components/ui/heading";
import { useCallback, useContext, useEffect, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { toast } from "sonner";
@ -10,7 +10,7 @@ import { CheckCircle2, XCircle } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { LuExternalLink, LuFilter } from "react-icons/lu";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import {
Select,
@ -19,6 +19,14 @@ import {
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import {
@ -26,6 +34,8 @@ import {
SplitCardRow,
} from "@/components/card/SettingsGroupCard";
import FrigatePlusCurrentModelSummary from "@/views/settings/components/FrigatePlusCurrentModelSummary";
import { useRestart } from "@/api/ws";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
type FrigatePlusModel = {
id: string;
@ -58,6 +68,8 @@ export default function FrigatePlusSettingsView({
useSWR<FrigateConfig>("config");
const [changedValue, setChangedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const { send: sendRestart } = useRestart();
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
@ -76,7 +88,7 @@ export default function FrigatePlusSettingsView({
},
);
const { data: availableModels = {} } = useSWR<
const { data: availableModels = {}, isLoading: isLoadingModels } = useSWR<
Record<string, FrigatePlusModel>
>("/plus/models", {
fallbackData: {},
@ -92,6 +104,19 @@ export default function FrigatePlusSettingsView({
},
});
const [showBaseModels, setShowBaseModels] = useState(true);
const [showFineTunedModels, setShowFineTunedModels] = useState(true);
const filteredModelEntries = useMemo(
() =>
Object.entries(availableModels || {}).filter(([, model]) =>
model.isBaseModel ? showBaseModels : showFineTunedModels,
),
[availableModels, showBaseModels, showFineTunedModels],
);
const isFilterActive = !showBaseModels || !showFineTunedModels;
useEffect(() => {
if (config) {
if (frigatePlusSettings?.model.id == undefined) {
@ -128,47 +153,60 @@ export default function FrigatePlusSettingsView({
const saveToConfig = useCallback(async () => {
setIsLoading(true);
axios
.put(`config/set?model.path=plus://${frigatePlusSettings.model.id}`, {
try {
// Clear the existing model section so only the new path remains
await axios.put("config/set", {
requires_restart: 0,
})
.then((res) => {
if (res.status === 200) {
toast.success(t("frigatePlus.toast.success"), {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
toast.error(
t("frigatePlus.toast.error", { errorMessage: res.statusText }),
{
position: "top-center",
},
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
config_data: { model: null },
});
const res = await axios.put("config/set", {
requires_restart: 0,
config_data: {
model: { path: `plus://${frigatePlusSettings.model.id}` },
},
});
if (res.status === 200) {
toast.success(t("frigatePlus.toast.success"), {
position: "top-center",
action: (
<a onClick={() => setRestartDialogOpen(true)}>
<Button>
{t("restart.button", { ns: "components/dialog" })}
</Button>
</a>
),
});
setChangedValue(false);
updateConfig();
} else {
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
t("frigatePlus.toast.error", { errorMessage: res.statusText }),
{
position: "top-center",
},
);
})
.finally(() => {
addMessage(
"plus_restart",
t("frigatePlus.restart_required"),
undefined,
"plus_restart",
);
setIsLoading(false);
}
} catch (error) {
const err = error as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
err.response?.data?.message ||
err.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
position: "top-center",
});
} finally {
addMessage(
"plus_restart",
t("frigatePlus.restart_required"),
undefined,
"plus_restart",
);
setIsLoading(false);
}
}, [updateConfig, addMessage, frigatePlusSettings, t]);
const onCancel = useCallback(() => {
@ -201,274 +239,340 @@ export default function FrigatePlusSettingsView({
}
return (
<div className="flex size-full flex-col md:flex-row">
<div className="flex size-full flex-col md:pr-2">
<Toaster position="top-center" closeButton={true} />
<div className="mt-2 flex h-full w-full flex-col">
<div className="scrollbar-container flex-1 overflow-y-auto">
<div className="w-full max-w-5xl space-y-6">
<div className="flex flex-col gap-0">
<Heading as="h4" className="mb-2">
{t("frigatePlus.title")}
</Heading>
<div className="w-full max-w-5xl space-y-6 pt-2">
<div className="flex flex-col gap-0">
<Heading as="h4" className="mb-2">
{t("frigatePlus.title")}
</Heading>
<p className="text-sm text-muted-foreground">
{t("frigatePlus.description")}
</p>
</div>
<p className="text-sm text-muted-foreground">
{t("frigatePlus.description")}
</p>
</div>
<div className="space-y-6">
<SettingsGroupCard title={t("frigatePlus.cardTitles.api")}>
<SplitCardRow
label={t("frigatePlus.apiKey.title")}
description={
<>
<p>{t("frigatePlus.apiKey.desc")}</p>
{!config?.model.plus && (
<div className="mt-2 flex items-center text-primary-variant">
<Link
to="https://frigate.video/plus"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("frigatePlus.apiKey.plusLink")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
)}
</>
}
content={
<div className="flex items-center gap-2">
{config?.plus?.enabled ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<span className="text-sm">
{config?.plus?.enabled
? t("frigatePlus.apiKey.validated")
: t("frigatePlus.apiKey.notValidated")}
</span>
</div>
}
/>
</SettingsGroupCard>
{config?.model.plus && (
<FrigatePlusCurrentModelSummary plusModel={config.model.plus} />
)}
{config?.model.plus && (
<SettingsGroupCard
title={t("frigatePlus.cardTitles.otherModels")}
>
<SplitCardRow
label={t("frigatePlus.modelInfo.availableModels")}
description={
<Trans ns="views/settings">
frigatePlus.modelInfo.modelSelect
</Trans>
}
content={
<Select
value={frigatePlusSettings.model.id}
onValueChange={(value) =>
handleFrigatePlusConfigChange({
model: { id: value as string },
})
}
<div className="space-y-6">
<SettingsGroupCard title={t("frigatePlus.cardTitles.api")}>
<SplitCardRow
label={t("frigatePlus.apiKey.title")}
description={
<>
<p>{t("frigatePlus.apiKey.desc")}</p>
{!config?.model.plus && (
<div className="mt-2 flex items-center text-primary-variant">
<Link
to="https://frigate.video/plus"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("frigatePlus.apiKey.plusLink")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
)}
</>
}
content={
<div className="flex items-center gap-2">
{config?.plus?.enabled ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<span className="text-sm">
{config?.plus?.enabled
? t("frigatePlus.apiKey.validated")
: t("frigatePlus.apiKey.notValidated")}
</span>
</div>
}
/>
</SettingsGroupCard>
{config?.plus?.enabled && (
<FrigatePlusCurrentModelSummary plusModel={config.model.plus} />
)}
{config?.plus?.enabled && (
<SettingsGroupCard title={t("frigatePlus.cardTitles.otherModels")}>
<SplitCardRow
label={t("frigatePlus.modelInfo.availableModels")}
description={
<Trans ns="views/settings">
frigatePlus.modelInfo.modelSelect
</Trans>
}
content={
<div className="flex w-full items-center gap-2">
<Select
value={frigatePlusSettings.model.id}
onValueChange={(value) =>
handleFrigatePlusConfigChange({
model: { id: value as string },
})
}
>
<SelectTrigger className="w-full">
{frigatePlusSettings.model.id &&
availableModels?.[frigatePlusSettings.model.id] ? (
<SelectTrigger className="w-full">
{new Date(
availableModels?.[frigatePlusSettings.model.id]
? new Date(
availableModels[
frigatePlusSettings.model.id
].trainDate,
).toLocaleString() +
" " +
availableModels[frigatePlusSettings.model.id]
.baseModel +
" (" +
(availableModels[frigatePlusSettings.model.id]
.isBaseModel
? t(
"frigatePlus.modelInfo.plusModelType.baseModel",
" " +
availableModels[frigatePlusSettings.model.id]
.baseModel +
" (" +
(availableModels[frigatePlusSettings.model.id]
.isBaseModel
? t(
"frigatePlus.modelInfo.plusModelType.baseModel",
)
: t(
"frigatePlus.modelInfo.plusModelType.userModel",
)) +
") " +
availableModels[frigatePlusSettings.model.id].name +
" (" +
availableModels[frigatePlusSettings.model.id]
.width +
"x" +
availableModels[frigatePlusSettings.model.id]
.height +
")"
: isLoadingModels
? t("frigatePlus.modelInfo.loadingAvailableModels")
: t("frigatePlus.modelInfo.selectModel")}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{filteredModelEntries.length === 0 ? (
<div className="px-4 py-3 text-center text-sm text-muted-foreground">
{t("frigatePlus.modelInfo.noModelsAvailable")}
</div>
) : (
filteredModelEntries.map(([id, model]) => (
<SelectItem
key={id}
className="cursor-pointer"
value={id}
disabled={
!model.supportedDetectors.includes(
Object.values(config.detectors)[0].type,
)
: t(
"frigatePlus.modelInfo.plusModelType.userModel",
)) +
") " +
availableModels[frigatePlusSettings.model.id]
.name +
" (" +
availableModels[frigatePlusSettings.model.id]
.width +
"x" +
availableModels[frigatePlusSettings.model.id]
.height +
")"}
</SelectTrigger>
) : (
<SelectTrigger className="w-full">
{t("frigatePlus.modelInfo.loadingAvailableModels")}
</SelectTrigger>
)}
<SelectContent>
<SelectGroup>
{Object.entries(availableModels || {}).map(
([id, model]) => (
<SelectItem
key={id}
className="cursor-pointer"
value={id}
disabled={
!model.supportedDetectors.includes(
Object.values(config.detectors)[0].type,
)
}
>
{new Date(model.trainDate).toLocaleString()}{" "}
<div>
{model.baseModel} {" ("}
{model.isBaseModel
? t(
"frigatePlus.modelInfo.plusModelType.baseModel",
)
: t(
"frigatePlus.modelInfo.plusModelType.userModel",
)}
{")"}
</div>
<div>
{model.name} (
{model.width + "x" + model.height})
</div>
<div>
{t(
"frigatePlus.modelInfo.supportedDetectors",
)}
: {model.supportedDetectors.join(", ")}
</div>
<div className="text-xs text-muted-foreground">
{id}
</div>
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
}
/>
</SettingsGroupCard>
)}
<SettingsGroupCard
title={t("frigatePlus.cardTitles.configuration")}
>
<SplitCardRow
label={t("frigatePlus.snapshotConfig.title")}
description={
<>
<p>
<Trans ns="views/settings">
frigatePlus.snapshotConfig.desc
</Trans>
</p>
<div className="mt-2 flex items-center text-primary-variant">
<Link
to={getLocaleDocUrl("plus/faq")}
target="_blank"
rel="noopener noreferrer"
className="inline"
}
>
{new Date(model.trainDate).toLocaleString()}{" "}
<div>
{model.baseModel} {" ("}
{model.isBaseModel
? t(
"frigatePlus.modelInfo.plusModelType.baseModel",
)
: t(
"frigatePlus.modelInfo.plusModelType.userModel",
)}
{")"}
</div>
<div>
{model.name} (
{model.width + "x" + model.height})
</div>
<div>
{t(
"frigatePlus.modelInfo.supportedDetectors",
)}
: {model.supportedDetectors.join(", ")}
</div>
<div className="text-xs text-muted-foreground">
{id}
</div>
</SelectItem>
))
)}
</SelectGroup>
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="focus:outline-none"
aria-label={t(
"frigatePlus.modelInfo.filter.ariaLabel",
)}
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</>
}
content={
<div className="space-y-3">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-secondary">
<th className="px-4 py-2 text-left">
{t("frigatePlus.snapshotConfig.table.camera")}
</th>
<th className="px-4 py-2 text-center">
{t(
"frigatePlus.snapshotConfig.table.snapshots",
)}
</th>
</tr>
</thead>
<tbody>
{Object.entries(config.cameras).map(
([name, camera]) => (
<tr
key={name}
className="border-b border-secondary"
>
<td className="px-4 py-2">
<CameraNameLabel camera={name} />
</td>
<td className="px-4 py-2 text-center">
{camera.snapshots.enabled ? (
<CheckCircle2 className="mx-auto size-5 text-green-500" />
) : (
<XCircle className="mx-auto size-5 text-danger" />
)}
</td>
</tr>
),
<LuFilter
className={cn(
"size-4",
isFilterActive
? "text-selected"
: "text-secondary-foreground",
)}
</tbody>
</table>
</div>
</div>
}
/>
</SettingsGroupCard>
</div>
</div>
</div>
/>
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56">
<div className="space-y-3">
<div className="text-sm text-primary-variant">
{t("frigatePlus.modelInfo.filter.ariaLabel")}
</div>
<div className="flex items-center justify-between">
<Label
htmlFor="filterBaseModels"
className="cursor-pointer text-primary"
>
{t("frigatePlus.modelInfo.filter.baseModels")}
</Label>
<Switch
id="filterBaseModels"
checked={showBaseModels}
onCheckedChange={setShowBaseModels}
/>
</div>
<div className="flex items-center justify-between">
<Label
htmlFor="filterFineTunedModels"
className="cursor-pointer text-primary"
>
{t(
"frigatePlus.modelInfo.filter.fineTunedModels",
)}
</Label>
<Switch
id="filterFineTunedModels"
checked={showFineTunedModels}
onCheckedChange={setShowFineTunedModels}
/>
</div>
</div>
</PopoverContent>
</Popover>
</div>
}
/>
</SettingsGroupCard>
)}
<div className="sticky bottom-0 z-50 w-full border-t border-secondary bg-background pb-5 pt-0 md:pr-2">
<div className="flex flex-col items-center gap-4 pt-2 md:flex-row md:justify-end">
<div className="flex w-full items-center gap-2 md:w-auto">
<Button
className="flex min-w-36 flex-1 gap-2"
variant="outline"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
>
{t("button.reset", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={!changedValue || isLoading}
className="flex min-w-36 flex-1 gap-2"
aria-label={t("button.save", { ns: "common" })}
onClick={saveToConfig}
>
{isLoading ? (
<>
<ActivityIndicator className="h-4 w-4" />
{t("button.saving", { ns: "common" })}
</>
) : (
t("button.save", { ns: "common" })
)}
</Button>
<SettingsGroupCard title={t("frigatePlus.cardTitles.configuration")}>
<SplitCardRow
label={t("frigatePlus.snapshotConfig.title")}
description={
<>
<p>
<Trans ns="views/settings">
frigatePlus.snapshotConfig.desc
</Trans>
</p>
<div className="mt-2 flex items-center text-primary-variant">
<Link
to={getLocaleDocUrl("plus/faq")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</>
}
content={
<div className="space-y-3">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-secondary">
<th className="px-4 py-2 text-left">
{t("frigatePlus.snapshotConfig.table.camera")}
</th>
<th className="px-4 py-2 text-center">
{t("frigatePlus.snapshotConfig.table.snapshots")}
</th>
</tr>
</thead>
<tbody>
{Object.entries(config.cameras).map(
([name, camera]) => (
<tr
key={name}
className="border-b border-secondary"
>
<td className="px-4 py-2">
<CameraNameLabel camera={name} />
</td>
<td className="px-4 py-2 text-center">
{camera.snapshots.enabled ? (
<CheckCircle2 className="mx-auto size-5 text-green-500" />
) : (
<XCircle className="mx-auto size-5 text-danger" />
)}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
</div>
}
/>
</SettingsGroupCard>
</div>
</div>
<div className="sticky bottom-0 z-50 mt-6 w-full border-t border-secondary bg-background pt-0">
<div
className={cn(
"flex flex-col items-center gap-4 pt-2 md:flex-row",
changedValue ? "justify-between" : "justify-end",
)}
>
{changedValue && (
<div className="flex items-center gap-2">
<span className="text-sm text-unsaved">
{t("unsavedChanges")}
</span>
</div>
)}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
{changedValue && (
<Button
onClick={onCancel}
variant="outline"
disabled={isLoading}
className="flex min-w-36 flex-1 gap-2"
>
{t("button.undo", { ns: "common" })}
</Button>
)}
<Button
onClick={saveToConfig}
variant="select"
disabled={!changedValue || isLoading}
className="flex min-w-36 flex-1 gap-2"
>
{isLoading ? (
<>
<ActivityIndicator className="h-4 w-4" />
{t("button.saving", { ns: "common" })}
</>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</div>
<RestartDialog
isOpen={restartDialogOpen}
onClose={() => setRestartDialogOpen(false)}
onRestart={() => sendRestart("restart")}
/>
</div>
);
}

View File

@ -385,7 +385,7 @@ export default function Go2RtcStreamsSettingsView({
</span>
</div>
)}
<div className="flex w-full items-center gap-2 md:w-auto">
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
{hasChanges && (
<Button
onClick={onReset}

View File

@ -33,6 +33,7 @@ import { getTranslatedLabel } from "@/utils/i18n";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { AudioLevelGraph } from "@/components/audio/AudioLevelGraph";
import { useWs } from "@/api/ws";
import { cn } from "@/lib/utils";
type ObjectSettingsViewProps = {
selectedCamera?: string;
@ -200,15 +201,18 @@ export default function ObjectSettingsView({
<Tabs defaultValue="debug" className="w-full">
<TabsList
className={`grid w-full ${cameraConfig.ffmpeg.inputs.some((input) => input.roles.includes("audio")) ? "grid-cols-3" : "grid-cols-2"}`}
className={cn(
"grid w-full",
cameraConfig.audio.enabled_in_config
? "grid-cols-3"
: "grid-cols-2",
)}
>
<TabsTrigger value="debug">{t("debug.debugging")}</TabsTrigger>
<TabsTrigger value="objectlist">
{t("debug.objectList")}
</TabsTrigger>
{cameraConfig.ffmpeg.inputs.some((input) =>
input.roles.includes("audio"),
) && (
{cameraConfig.audio.enabled_in_config && (
<TabsTrigger value="audio">{t("debug.audio.title")}</TabsTrigger>
)}
</TabsList>
@ -325,9 +329,7 @@ export default function ObjectSettingsView({
<TabsContent value="objectlist">
<ObjectList cameraConfig={cameraConfig} objects={memoizedObjects} />
</TabsContent>
{cameraConfig.ffmpeg.inputs.some((input) =>
input.roles.includes("audio"),
) && (
{cameraConfig.audio.enabled_in_config && (
<TabsContent value="audio">
<AudioList
cameraConfig={cameraConfig}

View File

@ -16,14 +16,11 @@ export default function FrigatePlusCurrentModelSummary({
return (
<SettingsGroupCard title={t("frigatePlus.cardTitles.currentModel")}>
{plusModel === undefined && (
{!plusModel && (
<p className="text-muted-foreground">
{t("frigatePlus.modelInfo.loading")}
{t("frigatePlus.modelInfo.noModelLoaded")}
</p>
)}
{plusModel === null && (
<p className="text-danger">{t("frigatePlus.modelInfo.error")}</p>
)}
{plusModel && (
<div className="space-y-6">
<SplitCardRow

View File

@ -450,7 +450,7 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] };
}
if (stats.temp !== undefined) {
if (stats?.temp !== undefined) {
hasValidGpu = true;
series[key].data.push({ x: statsIdx + 1, y: stats.temp });
}
@ -562,7 +562,7 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] };
}
if (stats.temp !== undefined) {
if (stats?.temp !== undefined) {
hasValidNpu = true;
series[key].data.push({ x: statsIdx + 1, y: stats.temp });
}