diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md
index a565398774..94d46dba07 100644
--- a/docs/docs/configuration/authentication.md
+++ b/docs/docs/configuration/authentication.md
@@ -262,6 +262,19 @@ In this example:
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
+:::note
+
+If a user isn't getting the role you expect, enable debug logging to see exactly what headers Frigate is receiving from your proxy:
+
+```yaml
+logger:
+ default: info
+ logs:
+ frigate.api.auth: debug
+```
+
+:::
+
#### Port Considerations
**Authenticated Port (8971)**
diff --git a/frigate/comms/ws.py b/frigate/comms/ws.py
index ac047b053f..ccb5d42890 100644
--- a/frigate/comms/ws.py
+++ b/frigate/comms/ws.py
@@ -23,7 +23,6 @@ from frigate.const import (
EXPIRE_AUDIO_ACTIVITY,
INSERT_MANY_RECORDINGS,
INSERT_PREVIEW,
- NOTIFICATION_TEST,
REQUEST_REGION_GRID,
UPDATE_AUDIO_ACTIVITY,
UPDATE_AUDIO_TRANSCRIPTION_STATE,
@@ -57,7 +56,6 @@ _WS_BLOCKED_TOPICS = frozenset(
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_BIRDSEYE_LAYOUT,
UPDATE_AUDIO_TRANSCRIPTION_STATE,
- NOTIFICATION_TEST,
}
)
diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py
index 83c8a2e55a..ec1fccb45e 100644
--- a/frigate/data_processing/real_time/face.py
+++ b/frigate/data_processing/real_time/face.py
@@ -288,6 +288,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
]
+ if face_frame.size == 0:
+ logger.debug(f"Empty face crop for {id}")
+ return
+
res = self.recognizer.classify(face_frame)
if not res:
diff --git a/frigate/test/test_ws_auth.py b/frigate/test/test_ws_auth.py
index a9fc6e1320..4fc6701136 100644
--- a/frigate/test/test_ws_auth.py
+++ b/frigate/test/test_ws_auth.py
@@ -115,6 +115,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
)
)
+ def test_viewer_blocked_from_notification_test(self):
+ self.assertFalse(
+ _check_ws_authorization(
+ "notification_test", "viewer", self.DEFAULT_SEPARATOR
+ )
+ )
+
# --- Admin access ---
def test_admin_can_send_restart(self):
@@ -134,6 +141,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
_check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR)
)
+ def test_admin_can_send_notification_test(self):
+ self.assertTrue(
+ _check_ws_authorization(
+ "notification_test", "admin", self.DEFAULT_SEPARATOR
+ )
+ )
+
# --- Comma-separated roles ---
def test_comma_separated_admin_viewer_grants_admin(self):
diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py
index 5832d8cdb8..999ec5c04b 100644
--- a/frigate/track/object_processing.py
+++ b/frigate/track/object_processing.py
@@ -684,22 +684,21 @@ class TrackedObjectProcessor(threading.Thread):
# check for config updates
updated_topics = self.camera_config_subscriber.check_for_updates()
- if "enabled" in updated_topics:
- for camera in updated_topics["enabled"]:
- if self.camera_states[camera].prev_enabled is None:
- self.camera_states[camera].prev_enabled = self.config.cameras[
- camera
- ].enabled
- elif "add" in updated_topics:
- for camera in updated_topics["add"]:
- self.config.cameras[camera] = (
- self.camera_config_subscriber.camera_configs[camera]
- )
- self.create_camera_state(camera)
- elif "remove" in updated_topics:
+ # a single drain can carry several topics at once, so add and
+ # remove are handled independently rather than as exclusive branches
+ for camera in updated_topics.get("add", []):
+ self.config.cameras[camera] = (
+ self.camera_config_subscriber.camera_configs[camera]
+ )
+ self.create_camera_state(camera)
+
+ if "remove" in updated_topics:
for camera in updated_topics["remove"]:
- removed_camera_state = self.camera_states[camera]
- removed_camera_state.shutdown()
+ camera_state = self.camera_states.get(camera)
+ if camera_state is None:
+ continue
+
+ camera_state.shutdown()
self.camera_states.pop(camera)
self.camera_activity.pop(camera, None)
self.last_motion_detected.pop(camera, None)
diff --git a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx
index ca5b30d29f..58a30434b4 100644
--- a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx
+++ b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx
@@ -227,16 +227,18 @@ export function GenAIModelWidget(props: WidgetProps) {
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
- "justify-between font-normal",
+ "min-w-0 justify-between font-normal",
!currentLabel && "text-muted-foreground",
fieldClassName,
)}
>
- {currentLabel ??
- t("configForm.genaiModel.placeholder", {
- ns: "views/settings",
- defaultValue: "Select or enter a model…",
- })}
+
+ {currentLabel ??
+ t("configForm.genaiModel.placeholder", {
+ ns: "views/settings",
+ defaultValue: "Select or enter a model…",
+ })}
+
@@ -263,12 +265,14 @@ export function GenAIModelWidget(props: WidgetProps) {
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
-
- {t("configForm.genaiModel.useCustom", {
- ns: "views/settings",
- value: trimmedSearch,
- defaultValue: 'Use "{{value}}"',
- })}
+
+
+ {t("configForm.genaiModel.useCustom", {
+ ns: "views/settings",
+ value: trimmedSearch,
+ defaultValue: 'Use "{{value}}"',
+ })}
+
)}
@@ -287,11 +291,11 @@ export function GenAIModelWidget(props: WidgetProps) {
>
- {model}
+ {model}
))}
diff --git a/web/src/views/motion-search/MotionSearchView.tsx b/web/src/views/motion-search/MotionSearchView.tsx
index e5cb2f4784..d2b06b6e70 100644
--- a/web/src/views/motion-search/MotionSearchView.tsx
+++ b/web/src/views/motion-search/MotionSearchView.tsx
@@ -770,6 +770,34 @@ export default function MotionSearchView({
};
}, [cancelMotionSearchJobViaBeacon]);
+ const handleBack = useCallback(() => {
+ if (onBack) {
+ onBack();
+ } else {
+ navigate(-1);
+ }
+ }, [navigate, onBack]);
+
+ // Dismissing the entry dialog (escape / click outside) before a search has
+ // run leaves nothing behind it, so cancel the flow instead of revealing an
+ // empty page.
+ const handleSearchDialogOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ if (
+ !nextOpen &&
+ !isSearching &&
+ !hasSearched &&
+ searchResults.length === 0
+ ) {
+ handleBack();
+ return;
+ }
+
+ setIsSearchDialogOpen(nextOpen);
+ },
+ [handleBack, hasSearched, isSearching, searchResults.length],
+ );
+
const handleNewSearch = useCallback(() => {
if (jobId && jobCamera) {
void cancelMotionSearchJob(jobId, jobCamera);
@@ -1238,7 +1266,7 @@ export default function MotionSearchView({
(onBack ? onBack() : navigate(-1))}
+ onClick={handleBack}
>
{isDesktop && (