From d0f44de6bcdfb033941c0bafd3485092e21e41a0 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 7 May 2026 08:53:07 -0500 Subject: [PATCH] UI fixes (#23127) * hide camera overrides badge from system sections * show empty card on camera metrics page when no cameras are defined * fix enabled camera state switch after adding via wizard Cameras added mid-session have no WS state until the dispatcher publishes camera_activity (which only happens on a fresh onConnect). Fall back to the config's enabled value so the switch reflects reality immediately after the wizard closes. * guard camera enabled access console would throw errors after adding via camera wizard * fix useOptimisticState dropping debounced setState under StrictMode * use openvino on cpu as default model - faster than tflite on cpu - add to default generated config * use an enum for model_size the frontend will then render this as a select dropdown because of the changes in the json schema * i18n * sync object filter entries with tracked labels in camera config form Filter sub-collapsibles in the camera Objects section are driven by `filters` dict keys, but profile merges and live track-switch edits don't add matching entries, so newly tracked labels (like from a profile override) had no collapsible. Synthesize default filter entries from `track` in the form data so every tracked label renders a collapsible; baseline data also gets the synthesized entries, so save payloads are unchanged. * revalidate raw paths cache after config save so CameraPathWidget shows fresh credentials * fix test * restore masked ffmpeg credentials when persisting camera config * formatting * rebuild ffmpeg commands when enabling recording for the first time Toggling record.enabled from the config UI updated the in-memory config but left ffmpeg running with its original command, so the record output args were never wired in and nothing landed in the cache for the maintainer to move. The record config update now rebuilds ffmpeg_cmds when enabled_in_config transitions, and the camera watchdog restarts ffmpeg on a false to true transition so the record output gets wired in. MQTT toggles, which only flip record.enabled at runtime, are unaffected and continue to work via the maintainer's drop/keep gate. * keep record toggle switch in single camera view disabled until enabled in config * fix override detection for sections unset in the global config Override badges and the blue dot now compare against schema defaults for sections like motion that the API serializes as null when omitted from the global YAML, instead of treating any populated camera config as an override * add support for config-aware patterns in section hiddenFields Section configs can now declare dynamic hidden-field entries as functions of the loaded config; objects.ts uses this to hide auto-populated attribute filters (DHL, face, license_plate, etc.) from the form, save flow, and override popover when those labels aren't user-settable * siimplify object filters handling live updating was getting very messy. users will just need to save once they enable a new object in order to see filters for that object * tweaks * update docs for new detector default * make genai provider required and add special case for UI prevent validation errors from appearing on initial creation of genai provider by setting the first option in the select dropdown as default --- docs/docs/configuration/object_detectors.md | 2 +- docs/docs/guides/getting_started.md | 2 +- frigate/api/app.py | 38 +++++ frigate/config/camera/genai.py | 3 +- frigate/config/camera/updater.py | 3 + frigate/config/classification.py | 23 +-- frigate/config/config.py | 33 ++++- frigate/test/test_config.py | 7 +- frigate/video/ffmpeg.py | 17 +++ web/public/locales/en/views/live.json | 3 +- web/public/locales/en/views/settings.json | 8 +- web/public/locales/en/views/system.json | 3 + .../section-configs/audio_transcription.ts | 6 +- .../section-configs/face_recognition.ts | 5 + .../config-form/section-configs/lpr.ts | 3 + .../config-form/section-configs/objects.ts | 11 ++ .../config-form/sections/BaseSection.tsx | 36 +++-- .../sections/CameraOverridesBadge.tsx | 37 +++-- .../sections/section-special-cases.ts | 70 ++++++++- web/src/hooks/use-config-override.ts | 139 ++++++++++++++---- web/src/hooks/use-config-schema.ts | 2 +- web/src/hooks/use-optimistic-state.ts | 51 +++---- web/src/hooks/use-stats.ts | 2 +- web/src/pages/Settings.tsx | 1 + web/src/types/configForm.ts | 2 + web/src/utils/configUtil.ts | 48 +++++- web/src/views/live/LiveCameraView.tsx | 6 +- .../views/settings/CameraManagementView.tsx | 8 +- web/src/views/system/CameraMetrics.tsx | 19 ++- 29 files changed, 475 insertions(+), 113 deletions(-) diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 99b9e1f35..767f70ab9 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -72,7 +72,7 @@ This does not affect using hardware for accelerating other tasks such as [semant # Officially Supported Detectors -Frigate provides a number of builtin detector types. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras. +Frigate provides a number of builtin detector types. By default, Frigate will use a single OpenVINO detector running on the CPU. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras. ## Edge TPU Detector diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index cd456f201..9619f5a31 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -192,7 +192,7 @@ cameras: ### Step 4: Configure detectors -By default, Frigate will use a single CPU detector. +By default, Frigate will use a single OpenVINO detector running on the CPU. In many cases, the integrated graphics on Intel CPUs provides sufficient performance for typical Frigate setups. If you have an Intel processor, you can follow the configuration below. diff --git a/frigate/api/app.py b/frigate/api/app.py index 0f6ff2b6c..5c56ffade 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -499,6 +499,40 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")): ) +def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None: + """Substitute incoming `*:*` masked credentials with the in-memory ones. + + The /config response masks ffmpeg input credentials, so the settings UI + sends the masked path back when sibling fields (e.g. hwaccel_args) are + edited. Without this we'd write `rtsp://*:*@host` into YAML and lose + the real credentials. Mutates `config_data` in place. + """ + cameras = config_data.get("cameras") + if not isinstance(cameras, dict): + return + + for camera_name, camera_data in cameras.items(): + if not isinstance(camera_data, dict): + continue + inputs = camera_data.get("ffmpeg", {}).get("inputs") + if not isinstance(inputs, list): + continue + existing = config.cameras.get(camera_name) + if existing is None: + continue + existing_paths = [inp.path for inp in existing.ffmpeg.inputs] + for index, input_obj in enumerate(inputs): + if not isinstance(input_obj, dict): + continue + path = input_obj.get("path") + if not isinstance(path, str): + continue + if ("://*:*@" in path or "user=*&password=*" in path) and index < len( + existing_paths + ): + input_obj["path"] = existing_paths[index] + + def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse: """Apply config changes in-memory only, without writing to YAML. @@ -509,6 +543,7 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo try: updates = {} if body.config_data: + _restore_masked_camera_paths(body.config_data, request.app.frigate_config) updates = flatten_config_data(body.config_data) updates = {k: ("" if v is None else v) for k, v in updates.items()} @@ -615,6 +650,9 @@ def config_set(request: Request, body: AppConfigSetBody): if query_string: updates = process_config_query_string(query_string) elif body.config_data: + _restore_masked_camera_paths( + body.config_data, request.app.frigate_config + ) updates = flatten_config_data(body.config_data) # Convert None values to empty strings for deletion (e.g., when deleting masks) updates = {k: ("" if v is None else v) for k, v in updates.items()} diff --git a/frigate/config/camera/genai.py b/frigate/config/camera/genai.py index 721eeb60d..902c94c42 100644 --- a/frigate/config/camera/genai.py +++ b/frigate/config/camera/genai.py @@ -41,8 +41,7 @@ class GenAIConfig(FrigateBaseModel): title="Model", description="The model to use from the provider for generating descriptions or summaries.", ) - provider: GenAIProviderEnum | None = Field( - default=None, + provider: GenAIProviderEnum = Field( title="Provider", description="The GenAI provider to use (for example: ollama, gemini, openai).", ) diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index a07d51b8e..95092da08 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -121,7 +121,10 @@ class CameraConfigUpdateSubscriber: elif update_type == CameraConfigUpdateEnum.objects: config.objects = updated_config elif update_type == CameraConfigUpdateEnum.record: + old_enabled_in_config = config.record.enabled_in_config config.record = updated_config + if old_enabled_in_config != updated_config.enabled_in_config: + config.recreate_ffmpeg_cmds() elif update_type == CameraConfigUpdateEnum.review: config.review = updated_config elif update_type == CameraConfigUpdateEnum.review_genai: diff --git a/frigate/config/classification.py b/frigate/config/classification.py index 05d6edc76..708f854e3 100644 --- a/frigate/config/classification.py +++ b/frigate/config/classification.py @@ -26,6 +26,11 @@ class EnrichmentsDeviceEnum(str, Enum): CPU = "CPU" +class ModelSizeEnum(str, Enum): + small = "small" + large = "large" + + class TriggerType(str, Enum): THUMBNAIL = "thumbnail" DESCRIPTION = "description" @@ -53,13 +58,13 @@ class AudioTranscriptionConfig(FrigateBaseModel): title="Transcription language", description="Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.", ) - device: Optional[EnrichmentsDeviceEnum] = Field( + device: EnrichmentsDeviceEnum = Field( default=EnrichmentsDeviceEnum.CPU, title="Transcription device", description="Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription.", ) - model_size: str = Field( - default="small", + model_size: ModelSizeEnum = Field( + default=ModelSizeEnum.small, title="Model size", description="Model size to use for offline audio event transcription.", ) @@ -189,8 +194,8 @@ class SemanticSearchConfig(FrigateBaseModel): return v return v - model_size: str = Field( - default="small", + model_size: ModelSizeEnum = Field( + default=ModelSizeEnum.small, title="Model size", description="Select model size; 'small' runs on CPU and 'large' typically requires GPU.", ) @@ -253,8 +258,8 @@ class FaceRecognitionConfig(FrigateBaseModel): title="Enable face recognition", description="Enable or disable face recognition for all cameras; can be overridden per-camera.", ) - model_size: str = Field( - default="small", + model_size: ModelSizeEnum = Field( + default=ModelSizeEnum.small, title="Model size", description="Model size to use for face embeddings (small/large); larger may require GPU.", ) @@ -335,8 +340,8 @@ class LicensePlateRecognitionConfig(FrigateBaseModel): title="Enable LPR", description="Enable or disable license plate recognition for all cameras; can be overridden per-camera.", ) - model_size: str = Field( - default="small", + model_size: ModelSizeEnum = Field( + default=ModelSizeEnum.small, title="Model size", description="Model size used for text detection/recognition. Most users should use 'small'.", ) diff --git a/frigate/config/config.py b/frigate/config/config.py index de3438cd0..c92d84c60 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import json import logging import os @@ -80,17 +81,40 @@ logger = logging.getLogger(__name__) yaml = YAML() +DEFAULT_DETECTORS = { + "ov": { + "type": "openvino", + "device": "CPU", + } +} +DEFAULT_MODEL = { + "width": 300, + "height": 300, + "input_tensor": "nhwc", + "input_pixel_format": "bgr", + "path": "/openvino-model/ssdlite_mobilenet_v2.xml", + "labelmap_path": "/openvino-model/coco_91cl_bkgr.txt", +} +DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720} + + +def _render_default_yaml(data: dict) -> str: + buf = io.StringIO() + _yaml_writer = YAML() + _yaml_writer.indent(mapping=2, sequence=4, offset=2) + _yaml_writer.dump(data, buf) + return buf.getvalue() + + DEFAULT_CONFIG = f""" mqtt: enabled: False +{_render_default_yaml({"detectors": DEFAULT_DETECTORS, "model": DEFAULT_MODEL})} cameras: {{}} # No cameras defined, UI wizard should be used version: {CURRENT_CONFIG_VERSION} """ -DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}} -DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720} - # stream info handler stream_info_retriever = StreamInfoRetriever() @@ -679,6 +703,9 @@ class FrigateConfig(FrigateBaseModel): model_config["path"] = "/cpu_model.tflite" elif detector_config.type == "edgetpu": model_config["path"] = "/edgetpu_model.tflite" + elif detector_config.type == "openvino": + for default_key, default_value in DEFAULT_MODEL.items(): + model_config.setdefault(default_key, default_value) model = ModelConfig.model_validate(model_config) model.check_and_load_plus_model(self.plus_api, detector_config.type) diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index e82b688c6..3a8909b30 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -64,9 +64,9 @@ class TestConfig(unittest.TestCase): def test_config_class(self): frigate_config = FrigateConfig(**self.minimal) - assert "cpu" in frigate_config.detectors.keys() - assert frigate_config.detectors["cpu"].type == DetectorTypeEnum.cpu - assert frigate_config.detectors["cpu"].model.width == 320 + assert "ov" in frigate_config.detectors.keys() + assert frigate_config.detectors["ov"].type == DetectorTypeEnum.openvino + assert frigate_config.detectors["ov"].model.width == 300 @patch("frigate.detectors.detector_config.load_labels") def test_detector_custom_model_path(self, mock_labels): @@ -1005,6 +1005,7 @@ class TestConfig(unittest.TestCase): config = { "mqtt": {"host": "mqtt"}, + "detectors": {"cpu": {"type": "cpu"}}, "model": {"path": "plus://test"}, "cameras": { "back": { diff --git a/frigate/video/ffmpeg.py b/frigate/video/ffmpeg.py index 3d8b18105..e77c03b5e 100644 --- a/frigate/video/ffmpeg.py +++ b/frigate/video/ffmpeg.py @@ -174,6 +174,7 @@ class CameraWatchdog(threading.Thread): ) self.requestor = InterProcessRequestor() self.was_enabled = self.config.enabled + self.was_record_enabled_in_config = self.config.record.enabled_in_config self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all) self.latest_valid_segment_time: float = 0 @@ -323,6 +324,22 @@ class CameraWatchdog(threading.Thread): self.was_enabled = enabled continue + record_enabled_in_config = self.config.record.enabled_in_config + if record_enabled_in_config != self.was_record_enabled_in_config: + if record_enabled_in_config and enabled: + self.logger.debug( + f"Record enabled in config for {self.config.name}, restarting ffmpeg" + ) + self.stop_all_ffmpeg() + self.start_all_ffmpeg() + self.latest_valid_segment_time = 0 + self.latest_invalid_segment_time = 0 + self.latest_cache_segment_time = 0 + self.record_enable_time = datetime.now().astimezone(timezone.utc) + last_restart_time = datetime.now().timestamp() + self.was_record_enabled_in_config = record_enabled_in_config + continue + if not enabled: continue diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json index 37e6b15db..3aa892222 100644 --- a/web/public/locales/en/views/live.json +++ b/web/public/locales/en/views/live.json @@ -70,7 +70,8 @@ }, "recording": { "enable": "Enable Recording", - "disable": "Disable Recording" + "disable": "Disable Recording", + "disabledInConfig": "Recording must first be enabled in Settings for this camera." }, "snapshots": { "enable": "Enable Snapshots", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 541fa0ffd..27cdf82f1 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1663,12 +1663,12 @@ "fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit." }, "faceRecognition": { - "globalDisabled": "Face recognition is not enabled at the global level. Enable it in Enrichments for camera-level face recognition to function.", - "personNotTracked": "Face recognition requires the 'person' object to be tracked. Ensure 'person' is in the object tracking list." + "globalDisabled": "The face recognition enrichment must be enabled for face recognition features to function on this camera.", + "personNotTracked": "Face recognition requires the 'person' object to be tracked. Enable 'person' in Objects for this camera." }, "lpr": { - "globalDisabled": "License plate recognition is not enabled at the global level. Enable it in Enrichments for camera-level LPR to function.", - "vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked." + "globalDisabled": "The license plate recognition enrichment must be enabled for LPR features to function on this camera.", + "vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked. Enable 'car' or 'motorcycle' in Objects for this camera." }, "record": { "noRecordRole": "No streams have the record role defined. Recording will not function." diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index 6c3f37f71..b824e0749 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -177,6 +177,9 @@ } }, "framesAndDetections": "Frames / Detections", + "noCameras": { + "title": "No Cameras Found" + }, "label": { "camera": "camera", "detect": "detect", diff --git a/web/src/components/config-form/section-configs/audio_transcription.ts b/web/src/components/config-form/section-configs/audio_transcription.ts index 8e8e70d77..fbc0c1dc0 100644 --- a/web/src/components/config-form/section-configs/audio_transcription.ts +++ b/web/src/components/config-form/section-configs/audio_transcription.ts @@ -10,7 +10,11 @@ const audioTranscription: SectionConfigOverrides = { severity: "warning", condition: (ctx) => { if (ctx.level === "camera" && ctx.fullCameraConfig) { - return ctx.fullCameraConfig.audio.enabled === false; + return ( + !ctx.fullCameraConfig.ffmpeg?.inputs?.some((input) => + input.roles?.includes("audio"), + ) || ctx.fullCameraConfig.audio.enabled === false + ); } return false; }, diff --git a/web/src/components/config-form/section-configs/face_recognition.ts b/web/src/components/config-form/section-configs/face_recognition.ts index 822f6ffe0..47344930a 100644 --- a/web/src/components/config-form/section-configs/face_recognition.ts +++ b/web/src/components/config-form/section-configs/face_recognition.ts @@ -53,6 +53,11 @@ const faceRecognition: SectionConfigOverrides = { "device", ], restartRequired: ["enabled", "model_size", "device"], + uiSchema: { + model_size: { + "ui:options": { size: "xs" }, + }, + }, }, }; diff --git a/web/src/components/config-form/section-configs/lpr.ts b/web/src/components/config-form/section-configs/lpr.ts index c56966142..1237172f0 100644 --- a/web/src/components/config-form/section-configs/lpr.ts +++ b/web/src/components/config-form/section-configs/lpr.ts @@ -83,6 +83,9 @@ const lpr: SectionConfigOverrides = { suppressDescription: true, }, }, + model_size: { + "ui:options": { size: "xs" }, + }, }, }, }; diff --git a/web/src/components/config-form/section-configs/objects.ts b/web/src/components/config-form/section-configs/objects.ts index e30ddf9d9..5a87bdc62 100644 --- a/web/src/components/config-form/section-configs/objects.ts +++ b/web/src/components/config-form/section-configs/objects.ts @@ -1,5 +1,13 @@ +import type { FrigateConfig } from "@/types/frigateConfig"; import type { SectionConfigOverrides } from "./types"; +// Attribute labels (face, license_plate, Frigate+ couriers like DHL/Amazon, +// etc.) are populated into objects.filters by the backend even when the +// model can't actually detect them. They aren't user-settable, so hide any +// `filters.` patterns from forms and override comparisons. +const hideAttributeFilters = (config: FrigateConfig): string[] => + (config.model?.all_attributes ?? []).map((attr) => `filters.${attr}`); + const objects: SectionConfigOverrides = { base: { sectionDocs: "/configuration/object_filters", @@ -26,6 +34,7 @@ const objects: SectionConfigOverrides = { "filters.*.raw_mask", "filters.mask", "filters.raw_mask", + hideAttributeFilters, ], advancedFields: ["genai"], uiSchema: { @@ -99,6 +108,7 @@ const objects: SectionConfigOverrides = { "filters.mask", "filters.raw_mask", "genai.required_zones", + hideAttributeFilters, ], }, camera: { @@ -123,6 +133,7 @@ const objects: SectionConfigOverrides = { "filters.*.raw_mask", "filters.mask", "filters.raw_mask", + hideAttributeFilters, ], advancedFields: [], }, diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index df248d271..6eb764045 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -9,7 +9,7 @@ import { useRef, useContext, } from "react"; -import useSWR from "swr"; +import useSWR, { mutate as swrMutate } from "swr"; import axios from "axios"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; @@ -22,6 +22,7 @@ import { modifySchemaForSection, getEffectiveDefaultsForSection, sanitizeOverridesForSection, + synthesizeMissingObjectFilters, } from "./section-special-cases"; import { getSectionValidation } from "../section-validations"; import { useConfigOverride } from "@/hooks/use-config-override"; @@ -58,7 +59,11 @@ import { } from "@/components/ui/alert-dialog"; import { applySchemaDefaults } from "@/lib/config-schema"; import { cn } from "@/lib/utils"; -import { ConfigSectionData, JsonValue } from "@/types/configForm"; +import { + ConfigSectionData, + HiddenFieldEntry, + JsonValue, +} from "@/types/configForm"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { @@ -68,6 +73,7 @@ import { buildConfigDataForPath, flattenOverrides, getBaseCameraSectionValue, + resolveHiddenFieldEntries, sanitizeSectionData as sharedSanitizeSectionData, requiresRestartForOverrides as sharedRequiresRestartForOverrides, } from "@/utils/configUtil"; @@ -90,7 +96,7 @@ export interface SectionConfig { /** Fields to group together */ fieldGroups?: Record; /** Fields to hide from UI */ - hiddenFields?: string[]; + hiddenFields?: HiddenFieldEntry[]; /** Fields to show in advanced section */ advancedFields?: string[]; /** Fields to compare for override detection */ @@ -357,25 +363,34 @@ export function ConfigSection({ return get(config, sectionPath); }, [config, cameraName, sectionPath, effectiveLevel, profileName]); - const rawFormData = useMemo(() => { + const rawFormData = useMemo(() => { if (!config) return {}; if (rawSectionValue === undefined || rawSectionValue === null) { return {}; } - return rawSectionValue; - }, [config, rawSectionValue]); + return synthesizeMissingObjectFilters( + sectionPath, + rawSectionValue, + modifiedSchema ?? undefined, + ) as ConfigSectionData; + }, [config, rawSectionValue, sectionPath, modifiedSchema]); // When editing a profile, hide fields that require a restart since they // cannot take effect via profile switching alone. const effectiveHiddenFields = useMemo(() => { + const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, config); if (!profileName || !sectionConfig.restartRequired?.length) { - return sectionConfig.hiddenFields; + return base; } - const base = sectionConfig.hiddenFields ?? []; return [...new Set([...base, ...sectionConfig.restartRequired])]; - }, [profileName, sectionConfig.hiddenFields, sectionConfig.restartRequired]); + }, [ + profileName, + sectionConfig.hiddenFields, + sectionConfig.restartRequired, + config, + ]); const sanitizeSectionData = useCallback( (data: ConfigSectionData) => @@ -387,7 +402,7 @@ export function ConfigSection({ const baseData = modifiedSchema ? applySchemaDefaults(modifiedSchema, rawFormData) : rawFormData; - return sanitizeSectionData(baseData); + return sanitizeSectionData(baseData as ConfigSectionData); }, [rawFormData, modifiedSchema, sanitizeSectionData]); const baselineSnapshot = useMemo(() => { @@ -743,6 +758,7 @@ export function ConfigSection({ } await refreshConfig(); + swrMutate("config/raw_paths"); setPendingData(null); onSave?.(); } catch (error) { diff --git a/web/src/components/config-form/sections/CameraOverridesBadge.tsx b/web/src/components/config-form/sections/CameraOverridesBadge.tsx index 4da0f51c2..6ccbb028c 100644 --- a/web/src/components/config-form/sections/CameraOverridesBadge.tsx +++ b/web/src/components/config-form/sections/CameraOverridesBadge.tsx @@ -20,7 +20,7 @@ import type { ProfilesApiResponse } from "@/types/profile"; import { humanizeKey } from "@/components/config-form/theme/utils/i18n"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { formatList } from "@/utils/stringUtil"; -import { getSectionConfig } from "@/utils/configUtil"; +import { getEffectiveHiddenFields } from "@/utils/configUtil"; const CAMERA_PAGE_BY_SECTION: Record = { detect: "cameraDetect", @@ -43,17 +43,33 @@ const CAMERA_PAGE_BY_SECTION: Record = { const MAX_FIELDS_PER_CAMERA = 5; /** - * Enrichment sections where the cross-camera override badge should be - * suppressed because they're effectively global-only (or per-camera - * configuration there isn't a useful affordance to surface here). - * Face recognition and LPR are intentionally omitted so the badge does show - * on those enrichment pages. + * Sections where the cross-camera override badge should be suppressed. + * Includes enrichment sections that aren't meaningfully per-camera + * (face recognition and LPR are intentionally omitted so the badge does show + * there) and every System sub-page (detector hardware, database, networking, + * etc.) which configures Frigate as a whole, not per-camera state. */ const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([ + // Enrichments (face_recognition and lpr remain enabled) "semantic_search", "genai", "classification", "audio_transcription", + // System + "go2rtc_streams", + "database", + "mqtt", + "tls", + "auth", + "networking", + "proxy", + "ui", + "logger", + "environment_vars", + "telemetry", + "birdseye", + "detectors", + "model", ]); /** @@ -231,8 +247,11 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) { const rawEntries = useCamerasOverridingSection(config, sectionPath); const entries = useMemo(() => { - const hiddenFields = - getSectionConfig(sectionPath, "global").hiddenFields ?? []; + const hiddenFields = getEffectiveHiddenFields( + sectionPath, + "global", + config, + ); if (hiddenFields.length === 0) return rawEntries; return rawEntries .map((entry) => ({ @@ -245,7 +264,7 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) { ), })) .filter((entry) => entry.fieldDeltas.length > 0); - }, [rawEntries, sectionPath]); + }, [rawEntries, sectionPath, config]); if (SECTIONS_WITHOUT_OVERRIDE_BADGE.has(sectionPath)) { return null; diff --git a/web/src/components/config-form/sections/section-special-cases.ts b/web/src/components/config-form/sections/section-special-cases.ts index 94771644f..d8121aea8 100644 --- a/web/src/components/config-form/sections/section-special-cases.ts +++ b/web/src/components/config-form/sections/section-special-cases.ts @@ -15,7 +15,7 @@ import { JsonObject, JsonValue } from "@/types/configForm"; * Sections that require special handling at the global level. * Add new section paths here as needed. */ -const SPECIAL_CASE_SECTIONS = ["motion", "detectors"] as const; +const SPECIAL_CASE_SECTIONS = ["motion", "detectors", "genai"] as const; /** * Check if a section requires special case handling. @@ -53,6 +53,29 @@ export function modifySchemaForSection( return schemaWithoutDefault; } + if (sectionPath === "genai") { + const additional = schema.additionalProperties; + if ( + additional && + typeof additional === "object" && + !Array.isArray(additional) + ) { + const props = (additional as RJSFSchema).properties; + if (props && typeof props.provider === "object") { + return { + ...schema, + additionalProperties: { + ...additional, + properties: { + ...props, + provider: { ...(props.provider as object), default: "openai" }, + }, + }, + }; + } + } + } + return schema; } @@ -105,6 +128,51 @@ export function getEffectiveDefaultsForSection( return schemaDefaults; } +/** + * Add default filter entries for any label in `objects.track` that isn't + * already in `objects.filters`, so each tracked label gets a collapsible. + * The backend only auto-populates filters at config init, not after profile + * merges. + */ +export function synthesizeMissingObjectFilters( + sectionPath: string, + data: unknown, + sectionSchema: RJSFSchema | undefined, +): unknown { + if (sectionPath !== "objects") return data; + if (!isJsonObject(data)) return data; + + const trackValue = (data as JsonObject).track; + if (!Array.isArray(trackValue) || trackValue.length === 0) return data; + + const properties = (sectionSchema as { properties?: Record }) + ?.properties; + const filtersSchema = isJsonObject(properties) + ? (properties.filters as { additionalProperties?: unknown } | undefined) + : undefined; + const filterEntrySchema = isJsonObject(filtersSchema?.additionalProperties) + ? (filtersSchema.additionalProperties as RJSFSchema) + : undefined; + + const existingFilters = isJsonObject((data as JsonObject).filters) + ? ((data as JsonObject).filters as JsonObject) + : {}; + + const newFilters: JsonObject = { ...existingFilters }; + let added = false; + for (const label of trackValue) { + if (typeof label !== "string") continue; + if (Object.prototype.hasOwnProperty.call(newFilters, label)) continue; + newFilters[label] = ( + filterEntrySchema ? applySchemaDefaults(filterEntrySchema, {}) : {} + ) as JsonValue; + added = true; + } + + if (!added) return data; + return { ...(data as JsonObject), filters: newFilters }; +} + /** * Sanitize overrides payloads for section-specific quirks. */ diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index 759667215..d4bf624cf 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -1,12 +1,16 @@ // Hook to detect when camera config overrides global defaults import { useMemo } from "react"; +import useSWR from "swr"; import isEqual from "lodash/isEqual"; import get from "lodash/get"; import set from "lodash/set"; +import type { RJSFSchema } from "@rjsf/utils"; import { FrigateConfig } from "@/types/frigateConfig"; import { JsonObject, JsonValue } from "@/types/configForm"; import { isJsonObject } from "@/lib/utils"; import { getBaseCameraSectionValue } from "@/utils/configUtil"; +import { extractSectionSchema } from "@/hooks/use-config-schema"; +import { applySchemaDefaults } from "@/lib/config-schema"; const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"]; @@ -34,6 +38,36 @@ export function normalizeConfigValue(value: unknown): JsonValue { return stripInternalFields(value as JsonValue); } +/** + * Collapse null and empty-object values for override comparisons so + * semantically equivalent shapes match. The schema may default `mask: None` + * while the runtime camera config carries `mask: {}` — both mean "no + * masks", so collapsing them here keeps the equality check honest. We + * keep this off the public `normalizeConfigValue` so save-flow code paths + * (which serialize form data) aren't affected. + */ +function collapseEmpty(value: JsonValue): JsonValue { + if (Array.isArray(value)) { + return value.map(collapseEmpty); + } + if (isJsonObject(value)) { + const cleaned: JsonObject = {}; + for (const [key, val] of Object.entries(value as JsonObject)) { + if (val === null || val === undefined) continue; + const collapsed = collapseEmpty(val as JsonValue); + if ( + isJsonObject(collapsed) && + Object.keys(collapsed as JsonObject).length === 0 + ) { + continue; + } + cleaned[key] = collapsed; + } + return cleaned; + } + return value; +} + export interface OverrideStatus { /** Whether the field is overridden from global */ isOverridden: boolean; @@ -96,6 +130,7 @@ export function useConfigOverride({ sectionPath, compareFields, }: UseConfigOverrideOptions) { + const { data: schema } = useSWR("config/schema.json"); return useMemo(() => { if (!config) { return { @@ -153,15 +188,29 @@ export function useConfigOverride({ sectionPath, ); - const normalizedGlobalValue = normalizeConfigValue(globalValue); + // Use the effective baseline (schema defaults when the global section + // is unset, e.g. motion). Without this, sections omitted from the global + // YAML would always read as "overridden" because the raw global value is + // null while every camera has populated defaults. + const normalizedGlobalValue = getEffectiveGlobalBaseline( + config, + sectionPath, + compareFields, + schema, + ); const normalizedCameraValue = normalizeConfigValue(cameraValue); + // Collapse empty/null values for comparison so semantically equivalent + // shapes (e.g. schema default `mask: null` vs runtime `mask: {}`) match. + const collapsedGlobal = collapseEmpty(normalizedGlobalValue); + const collapsedCamera = collapseEmpty(normalizedCameraValue); + const comparisonGlobal = compareFields - ? pickFields(normalizedGlobalValue, compareFields) - : normalizedGlobalValue; + ? pickFields(collapsedGlobal, compareFields) + : collapsedGlobal; const comparisonCamera = compareFields - ? pickFields(normalizedCameraValue, compareFields) - : normalizedCameraValue; + ? pickFields(collapsedCamera, compareFields) + : collapsedCamera; // Check if the entire section is overridden const isOverridden = compareFields @@ -176,7 +225,10 @@ export function useConfigOverride({ const cameraFieldValue = get(normalizedCameraValue, fieldPath); return { - isOverridden: !isEqual(globalFieldValue, cameraFieldValue), + isOverridden: !isEqual( + collapseEmpty(globalFieldValue as JsonValue), + collapseEmpty(cameraFieldValue as JsonValue), + ), globalValue: globalFieldValue, cameraValue: cameraFieldValue, }; @@ -199,7 +251,7 @@ export function useConfigOverride({ getFieldOverride, resetToGlobal, }; - }, [config, cameraName, sectionPath, compareFields]); + }, [config, cameraName, sectionPath, compareFields, schema]); } /** @@ -252,6 +304,7 @@ export function useAllCameraOverrides( config: FrigateConfig | undefined, cameraName: string | undefined, ) { + const { data: schema } = useSWR("config/schema.json"); return useMemo(() => { if (!config || !cameraName) { return []; @@ -265,17 +318,24 @@ export function useAllCameraOverrides( const overriddenSections: string[] = []; for (const { key, compareFields } of OVERRIDABLE_SECTIONS) { - const globalValue = normalizeConfigValue(get(config, key)); + const globalValue = getEffectiveGlobalBaseline( + config, + key, + compareFields, + schema, + ); const cameraValue = normalizeConfigValue( getBaseCameraSectionValue(config, cameraName, key), ); + const collapsedGlobal = collapseEmpty(globalValue); + const collapsedCamera = collapseEmpty(cameraValue); const comparisonGlobal = compareFields - ? pickFields(globalValue, compareFields) - : globalValue; + ? pickFields(collapsedGlobal, compareFields) + : collapsedGlobal; const comparisonCamera = compareFields - ? pickFields(cameraValue, compareFields) - : cameraValue; + ? pickFields(collapsedCamera, compareFields) + : collapsedCamera; if ( compareFields && compareFields.length === 0 @@ -287,7 +347,7 @@ export function useAllCameraOverrides( } return overriddenSections; - }, [config, cameraName]); + }, [config, cameraName, schema]); } export interface FieldDelta { @@ -386,14 +446,40 @@ function isPathAllowed(path: string, compareFields?: string[]): boolean { } /** - * Some Frigate sections (notably `motion`) are dumped by the backend with - * `exclude_unset=True`, so when the user hasn't explicitly written the section - * in their global YAML the API returns null even though every camera still - * gets defaults applied at runtime. To still detect cross-camera differences - * in those sections we synthesize a baseline by taking the modal (most common) - * value at each leaf path across cameras — cameras whose value diverges from - * the modal are treated as overriding. + * Resolve the effective global baseline used for override comparisons. + * + * - When the global section is explicitly set, return it (normalized). + * - Otherwise prefer the camera-level schema defaults so a camera that + * diverges from the implicit Pydantic default registers as overriding + * even with a single camera in the deployment. (Sections like `motion` + * are dumped with `exclude_unset=True`, so the API returns null whenever + * the user hasn't written the section globally.) + * - Fall back to a modal-across-cameras synthetic baseline when the schema + * hasn't loaded yet or the section isn't in it. */ +function getEffectiveGlobalBaseline( + config: FrigateConfig, + sectionPath: string, + compareFields?: string[], + schema?: RJSFSchema, +): JsonValue { + const rawGlobalValue = get(config, sectionPath); + if (rawGlobalValue != null) { + return normalizeConfigValue(rawGlobalValue); + } + if (schema) { + const sectionSchema = extractSectionSchema(schema, sectionPath, "camera"); + if (sectionSchema) { + const defaults = applySchemaDefaults(sectionSchema, {}); + return normalizeConfigValue(defaults as JsonValue); + } + } + const cameraSectionValues = Object.keys(config.cameras ?? {}).map((name) => + normalizeConfigValue(getBaseCameraSectionValue(config, name, sectionPath)), + ); + return deriveSyntheticGlobalValue(cameraSectionValues, compareFields); +} + function deriveSyntheticGlobalValue( cameraSectionValues: JsonValue[], compareFields?: string[], @@ -461,6 +547,7 @@ export function useCamerasOverridingSection( config: FrigateConfig | undefined, sectionPath: string, ): CameraOverrideEntry[] { + const { data: schema } = useSWR("config/schema.json"); return useMemo(() => { if (!config?.cameras || !sectionPath) { return []; @@ -476,11 +563,9 @@ export function useCamerasOverridingSection( ), ); - const rawGlobalValue = get(config, sectionPath); - const globalValue: JsonValue = - rawGlobalValue == null - ? deriveSyntheticGlobalValue(cameraSectionValues, compareFields) - : normalizeConfigValue(rawGlobalValue); + const globalValue = collapseEmpty( + getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema), + ); const entries: CameraOverrideEntry[] = []; for (let idx = 0; idx < cameraNames.length; idx += 1) { @@ -489,7 +574,7 @@ export function useCamerasOverridingSection( const deltasByPath = new Map(); // 1. Camera-level overrides (uses base_config when a profile is active) - const cameraValue = cameraSectionValues[idx]; + const cameraValue = collapseEmpty(cameraSectionValues[idx]); for (const delta of collectFieldDeltas( globalValue, cameraValue, @@ -536,5 +621,5 @@ export function useCamerasOverridingSection( } return entries; - }, [config, sectionPath]); + }, [config, sectionPath, schema]); } diff --git a/web/src/hooks/use-config-schema.ts b/web/src/hooks/use-config-schema.ts index 969e0fdc5..80d17e586 100644 --- a/web/src/hooks/use-config-schema.ts +++ b/web/src/hooks/use-config-schema.ts @@ -24,7 +24,7 @@ const getSchemaDefinitions = (schema: RJSFSchema): Record => * Extracts and resolves a section schema from the full config schema * Uses caching to avoid repeated expensive resolution */ -function extractSectionSchema( +export function extractSectionSchema( schema: RJSFSchema, sectionPath: string, level: "global" | "camera", diff --git a/web/src/hooks/use-optimistic-state.ts b/web/src/hooks/use-optimistic-state.ts index 0b47dd4ff..bddc242c9 100644 --- a/web/src/hooks/use-optimistic-state.ts +++ b/web/src/hooks/use-optimistic-state.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback } from "react"; type OptimisticStateResult = [T, (newValue: T) => void]; @@ -8,37 +8,32 @@ const useOptimisticState = ( delay: number = 20, ): OptimisticStateResult => { const [optimisticValue, setOptimisticValue] = useState(currentState); - const debounceTimeout = useRef | null>(null); - const handleValueChange = useCallback( - (newValue: T) => { - // Update the optimistic value immediately - setOptimisticValue(newValue); - - // Clear any pending debounce timeout - if (debounceTimeout.current) { - clearTimeout(debounceTimeout.current); - } - - // Set a new debounce timeout - debounceTimeout.current = setTimeout(() => { - // Update the actual value using the provided setter function - setState(newValue); - }, delay); - }, - [delay, setState], - ); - - useEffect(() => { - return () => { - if (debounceTimeout.current) { - clearTimeout(debounceTimeout.current); - } - }; + const handleValueChange = useCallback((newValue: T) => { + // Update the optimistic value immediately + setOptimisticValue(newValue); }, []); + // Push the optimistic value to the real setter after the delay. Scoping + // this to an effect keyed on optimisticValue ensures the cleanup only + // cancels the timer for the value it scheduled — so StrictMode's + // effect-rerun (and future re-running mechanisms) reschedules cleanly + // instead of dropping the pending update on the floor. useEffect(() => { - if (currentState != optimisticValue) { + if (Object.is(optimisticValue, currentState)) { + return; + } + const id = setTimeout(() => setState(optimisticValue), delay); + return () => clearTimeout(id); + }, [optimisticValue, currentState, delay, setState]); + + // External updates to currentState should win over a stale optimistic value. + // The guard matters under StrictMode: this effect's re-run captures the + // *old* currentState in its closure, so without the equality check it + // would clobber an optimistic update that another effect (e.g. a search + // param sync) made earlier in the same commit. + useEffect(() => { + if (!Object.is(currentState, optimisticValue)) { setOptimisticValue(currentState); } // sometimes an external action will cause the currentState to change diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index 1fd966aa6..77dd9fc01 100644 --- a/web/src/hooks/use-stats.ts +++ b/web/src/hooks/use-stats.ts @@ -89,7 +89,7 @@ export default function useStats(stats: FrigateStats | undefined) { } const cameraName = config.cameras?.[name]?.friendly_name ?? name; - if (config.cameras[name].enabled && cam["camera_fps"] == 0) { + if (config.cameras?.[name]?.enabled && cam["camera_fps"] == 0) { problems.push({ text: t("stats.cameraIsOffline", { camera: capitalizeFirstLetter(capitalizeAll(cameraName)), diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index a4b7f2245..42511e7a9 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -885,6 +885,7 @@ export default function Settings() { // Refresh config from server once await mutate("config"); + mutate("config/raw_paths"); // Clear hasChanges in sidebar for all successfully saved sections if (savedKeys.length > 0) { diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts index f228de430..9e6181266 100644 --- a/web/src/types/configForm.ts +++ b/web/src/types/configForm.ts @@ -13,6 +13,8 @@ export type JsonArray = JsonValue[]; export type ConfigSectionData = JsonObject; +export type HiddenFieldEntry = string | ((config: FrigateConfig) => string[]); + export type ConfigFormContext = { level?: "global" | "camera"; cameraName?: string; diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 82e54f784..e8c682a5b 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -587,13 +587,14 @@ export function prepareSectionSavePayload(opts: { // For profile sections, also hide restart-required fields to match // effectiveHiddenFields in BaseSection (prevents spurious deletion markers // for fields that are hidden from the form during profile editing). - let hiddenFieldsForSanitize = sectionConfig.hiddenFields; - if (profileInfo.isProfile && sectionConfig.restartRequired?.length) { - const base = sectionConfig.hiddenFields ?? []; - hiddenFieldsForSanitize = [ - ...new Set([...base, ...sectionConfig.restartRequired]), - ]; - } + const resolvedHidden = resolveHiddenFieldEntries( + sectionConfig.hiddenFields, + config, + ); + const hiddenFieldsForSanitize = + profileInfo.isProfile && sectionConfig.restartRequired?.length + ? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])] + : resolvedHidden; // Sanitize raw form data const rawData = sanitizeSectionData( @@ -702,3 +703,36 @@ export function getSectionConfig( : entry.camera; return mergeSectionConfig(entry.base, overrides); } + +/** + * Resolve the effective hidden-field patterns for a section. Each entry in + * `hiddenFields` is either a literal pattern or a function that produces + * patterns from the loaded config (e.g. `filters.` for each + * `model.all_attributes` entry on the objects section). + */ +export function getEffectiveHiddenFields( + sectionKey: string, + level: "global" | "camera" | "replay", + config: FrigateConfig | undefined, +): string[] { + return resolveHiddenFieldEntries( + getSectionConfig(sectionKey, level).hiddenFields, + config, + ); +} + +export function resolveHiddenFieldEntries( + entries: SectionConfig["hiddenFields"] | undefined, + config: FrigateConfig | undefined, +): string[] { + if (!entries || entries.length === 0) return []; + const result: string[] = []; + for (const entry of entries) { + if (typeof entry === "function") { + if (config) result.push(...entry(config)); + } else { + result.push(entry); + } + } + return result; +} diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index dc09fe4f5..0cf9525ff 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -1072,10 +1072,12 @@ function FrigateCameraFeatures({ title={ recordState == "ON" ? t("recording.disable") - : t("recording.enable") + : camera.record.enabled_in_config + ? t("recording.enable") + : t("recording.disabledInConfig") } onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} + disabled={!cameraEnabled || !camera.record.enabled_in_config} /> ("config"); + + const isChecked = + enabledState === "ON" || enabledState === "OFF" + ? enabledState === "ON" + : (config?.cameras?.[cameraName]?.enabled ?? false); return (
{ sendEnabled(isChecked ? "ON" : "OFF"); }} diff --git a/web/src/views/system/CameraMetrics.tsx b/web/src/views/system/CameraMetrics.tsx index 4e524a743..77d01aa3f 100644 --- a/web/src/views/system/CameraMetrics.tsx +++ b/web/src/views/system/CameraMetrics.tsx @@ -2,6 +2,7 @@ import { useFrigateStats } from "@/api/ws"; import { CameraLineGraph } from "@/components/graph/LineGraph"; import CameraInfoDialog from "@/components/overlay/CameraInfoDialog"; import { ConnectionQualityIndicator } from "@/components/camera/ConnectionQualityIndicator"; +import { EmptyCard } from "@/components/card/EmptyCard"; import { Skeleton } from "@/components/ui/skeleton"; import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateStats } from "@/types/stats"; @@ -13,6 +14,7 @@ import { useMemo, useState, } from "react"; +import { BsFillCameraVideoOffFill } from "react-icons/bs"; import { MdInfo } from "react-icons/md"; import { Tooltip, @@ -173,7 +175,7 @@ export default function CameraMetrics({ } Object.entries(stats.cameras).forEach(([key, camStats]) => { - if (!config?.cameras[key].enabled) { + if (!camStats || !config?.cameras[key]?.enabled) { return; } @@ -228,6 +230,10 @@ export default function CameraMetrics({ } Object.entries(stats.cameras).forEach(([key, camStats]) => { + if (!camStats) { + return; + } + if (!(key in series)) { const camName = getCameraName(key); series[key] = {}; @@ -274,6 +280,17 @@ export default function CameraMetrics({ } }, [showCameraInfoDialog]); + if (config && Object.keys(config.cameras).length === 0) { + return ( +
+ } + title={t("cameras.noCameras.title")} + /> +
+ ); + } + return (