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 && (