Compare commits

..

No commits in common. "4ff7ab96dc8816003d1e3997fa927fda0fff52c3" and "5211590866b69c9415ea9dff3e7542bd1ce90971" have entirely different histories.

39 changed files with 147 additions and 673 deletions

View File

@ -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 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.
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.
## Edge TPU Detector

View File

@ -192,7 +192,7 @@ cameras:
### Step 4: Configure detectors
By default, Frigate will use a single OpenVINO detector running on the CPU.
By default, Frigate will use a single CPU detector.
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.

View File

@ -499,40 +499,6 @@ 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.
@ -543,7 +509,6 @@ 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()}
@ -650,9 +615,6 @@ 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()}

View File

@ -76,7 +76,7 @@ class CameraConfig(FrigateBaseModel):
# Options with global fallback
audio: AudioConfig = Field(
default_factory=AudioConfig,
title="Audio detection",
title="Audio events",
description="Settings for audio-based event detection for this camera.",
)
audio_transcription: CameraAudioTranscriptionConfig = Field(

View File

@ -41,7 +41,8 @@ class GenAIConfig(FrigateBaseModel):
title="Model",
description="The model to use from the provider for generating descriptions or summaries.",
)
provider: GenAIProviderEnum = Field(
provider: GenAIProviderEnum | None = Field(
default=None,
title="Provider",
description="The GenAI provider to use (for example: ollama, gemini, openai).",
)

View File

@ -121,10 +121,7 @@ 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:

View File

@ -26,11 +26,6 @@ class EnrichmentsDeviceEnum(str, Enum):
CPU = "CPU"
class ModelSizeEnum(str, Enum):
small = "small"
large = "large"
class TriggerType(str, Enum):
THUMBNAIL = "thumbnail"
DESCRIPTION = "description"
@ -58,13 +53,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: EnrichmentsDeviceEnum = Field(
device: Optional[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: ModelSizeEnum = Field(
default=ModelSizeEnum.small,
model_size: str = Field(
default="small",
title="Model size",
description="Model size to use for offline audio event transcription.",
)
@ -194,8 +189,8 @@ class SemanticSearchConfig(FrigateBaseModel):
return v
return v
model_size: ModelSizeEnum = Field(
default=ModelSizeEnum.small,
model_size: str = Field(
default="small",
title="Model size",
description="Select model size; 'small' runs on CPU and 'large' typically requires GPU.",
)
@ -258,8 +253,8 @@ class FaceRecognitionConfig(FrigateBaseModel):
title="Enable face recognition",
description="Enable or disable face recognition for all cameras; can be overridden per-camera.",
)
model_size: ModelSizeEnum = Field(
default=ModelSizeEnum.small,
model_size: str = Field(
default="small",
title="Model size",
description="Model size to use for face embeddings (small/large); larger may require GPU.",
)
@ -340,8 +335,8 @@ class LicensePlateRecognitionConfig(FrigateBaseModel):
title="Enable LPR",
description="Enable or disable license plate recognition for all cameras; can be overridden per-camera.",
)
model_size: ModelSizeEnum = Field(
default=ModelSizeEnum.small,
model_size: str = Field(
default="small",
title="Model size",
description="Model size used for text detection/recognition. Most users should use 'small'.",
)

View File

@ -1,6 +1,5 @@
from __future__ import annotations
import io
import json
import logging
import os
@ -81,40 +80,17 @@ 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()
@ -477,7 +453,7 @@ class FrigateConfig(FrigateBaseModel):
cameras: Dict[str, CameraConfig] = Field(title="Cameras", description="Cameras")
audio: AudioConfig = Field(
default_factory=AudioConfig,
title="Audio detection",
title="Audio events",
description="Settings for audio-based event detection for all cameras; can be overridden per-camera.",
)
birdseye: BirdseyeConfig = Field(
@ -703,9 +679,6 @@ 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)

View File

@ -64,9 +64,9 @@ class TestConfig(unittest.TestCase):
def test_config_class(self):
frigate_config = FrigateConfig(**self.minimal)
assert "ov" in frigate_config.detectors.keys()
assert frigate_config.detectors["ov"].type == DetectorTypeEnum.openvino
assert frigate_config.detectors["ov"].model.width == 300
assert "cpu" in frigate_config.detectors.keys()
assert frigate_config.detectors["cpu"].type == DetectorTypeEnum.cpu
assert frigate_config.detectors["cpu"].model.width == 320
@patch("frigate.detectors.detector_config.load_labels")
def test_detector_custom_model_path(self, mock_labels):
@ -1005,7 +1005,6 @@ class TestConfig(unittest.TestCase):
config = {
"mqtt": {"host": "mqtt"},
"detectors": {"cpu": {"type": "cpu"}},
"model": {"path": "plus://test"},
"cameras": {
"back": {

View File

@ -773,9 +773,7 @@ class TrackedObjectProcessor(threading.Thread):
logger.debug(f"Camera {camera} disabled, skipping update")
continue
camera_state = self.camera_states.get(camera)
if camera_state is None:
continue
camera_state = self.camera_states[camera]
camera_state.update(
frame_name, frame_time, current_tracked_objects, motion_boxes, regions

View File

@ -330,12 +330,7 @@ class TrackedObject:
if self.obj_data["position_changes"] != obj_data["position_changes"]:
significant_change = True
# disappearance of a per-frame attribute can be caused by detection
# skipping the object on a frame (stationary objects on non-interval
# frames), so only flag when a new attribute label appears
prev_labels = {a["label"] for a in self.obj_data["attributes"]}
curr_labels = {a["label"] for a in obj_data["attributes"]}
if curr_labels - prev_labels:
if self.obj_data["attributes"] != obj_data["attributes"]:
significant_change = True
# if the state changed between stationary and active

View File

@ -438,32 +438,34 @@ def process_frames(
else:
object_tracker.update_frame_times(frame_name, frame_time)
# group the attribute detections based on what label they apply to
attribute_detections: dict[str, list[TrackedObjectAttribute]] = {}
for label, attribute_labels in attributes_map.items():
attribute_detections[label] = [
TrackedObjectAttribute(d)
for d in consolidated_detections
if d[0] in attribute_labels
]
# build detections
detections = {}
for obj in object_tracker.tracked_objects.values():
detections[obj["id"]] = {**obj, "attributes": []}
# assign each detected attribute to the best matching object.
# iterate consolidated_detections once so attributes that appear under
# multiple parent labels in attributes_map (e.g. license_plate is in
# both "car" and "motorcycle") are not appended more than once
# find the best object for each attribute to be assigned to
all_objects: list[dict[str, Any]] = object_tracker.tracked_objects.values()
detected_attributes = [
TrackedObjectAttribute(d)
for d in consolidated_detections
if d[0] in all_attributes
]
for attribute in detected_attributes:
filtered_objects = filter(
lambda o: attribute.label in attributes_map.get(o["label"], []),
all_objects,
)
selected_object_id = attribute.find_best_object(filtered_objects)
if selected_object_id is not None:
detections[selected_object_id]["attributes"].append(
attribute.get_tracking_data()
for attributes in attribute_detections.values():
for attribute in attributes:
filtered_objects = filter(
lambda o: attribute.label in attributes_map.get(o["label"], []),
all_objects,
)
selected_object_id = attribute.find_best_object(filtered_objects)
if selected_object_id is not None:
detections[selected_object_id]["attributes"].append(
attribute.get_tracking_data()
)
# debug object tracking
if False:

View File

@ -174,7 +174,6 @@ 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
@ -324,22 +323,6 @@ 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

View File

@ -13,7 +13,7 @@
"description": "Enabled"
},
"audio": {
"label": "Audio detection",
"label": "Audio events",
"description": "Settings for audio-based event detection for this camera.",
"enabled": {
"label": "Enable audio detection",

View File

@ -539,7 +539,7 @@
}
},
"audio": {
"label": "Audio detection",
"label": "Audio events",
"description": "Settings for audio-based event detection for all cameras; can be overridden per-camera.",
"enabled": {
"label": "Enable audio detection",

View File

@ -70,8 +70,7 @@
},
"recording": {
"enable": "Enable Recording",
"disable": "Disable Recording",
"disabledInConfig": "Recording must first be enabled in Settings for this camera."
"disable": "Disable Recording"
},
"snapshots": {
"enable": "Enable Snapshots",

View File

@ -49,7 +49,7 @@
"globalMotion": "Motion detection",
"globalObjects": "Objects",
"globalReview": "Review",
"globalAudioEvents": "Audio detection",
"globalAudioEvents": "Audio events",
"globalLivePlayback": "Live playback",
"globalTimestampStyle": "Timestamp style",
"systemDatabase": "Database",
@ -80,7 +80,7 @@
"cameraMotion": "Motion detection",
"cameraObjects": "Objects",
"cameraConfigReview": "Review",
"cameraAudioEvents": "Audio detection",
"cameraAudioEvents": "Audio events",
"cameraAudioTranscription": "Audio transcription",
"cameraNotifications": "Notifications",
"cameraLivePlayback": "Live playback",
@ -1651,8 +1651,7 @@
"review": {
"recordDisabled": "Recording is disabled, review items will not be generated.",
"detectDisabled": "Object detection is disabled. Review items require detected objects to categorize alerts and detections.",
"allNonAlertDetections": "All non-alert activity will be included as detections.",
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images."
"allNonAlertDetections": "All non-alert activity will be included as detections."
},
"audio": {
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
@ -1661,21 +1660,15 @@
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active."
},
"detect": {
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function."
},
"objects": {
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
"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": "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.",
"modelSizeLarge": "The 'large' model requires a GPU or NPU for reasonable performance. Use 'small' on CPU-only systems."
"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."
},
"lpr": {
"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.",
"modelSizeLarge": "The 'large' model is optimized for multi-line license plates. The 'small' model provides better performance over 'large' and should be used unless your region uses multi-line plate formats."
"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."
},
"record": {
"noRecordRole": "No streams have the record role defined. Recording will not function."
@ -1689,9 +1682,6 @@
"detectors": {
"mixedTypes": "All detectors must use the same type. Remove existing detectors to use a different type.",
"mixedTypesSuggestion": "All detectors must use the same type. Remove existing detectors or select {{type}}."
},
"semanticSearch": {
"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

@ -177,9 +177,6 @@
}
},
"framesAndDetections": "Frames / Detections",
"noCameras": {
"title": "No Cameras Found"
},
"label": {
"camera": "camera",
"detect": "detect",

View File

@ -56,14 +56,7 @@ export function WsProvider({ children }: { children: ReactNode }) {
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current);
}
const ws = wsRef.current;
if (ws) {
ws.onopen = null;
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
ws.close();
}
wsRef.current?.close();
resetWsStore();
};
}, [wsUrl]);

View File

@ -10,11 +10,7 @@ const audioTranscription: SectionConfigOverrides = {
severity: "warning",
condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) {
return (
!ctx.fullCameraConfig.ffmpeg?.inputs?.some((input) =>
input.roles?.includes("audio"),
) || ctx.fullCameraConfig.audio.enabled === false
);
return ctx.fullCameraConfig.audio.enabled === false;
}
return false;
},

View File

@ -3,15 +3,6 @@ import type { SectionConfigOverrides } from "./types";
const detect: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/camera_specific",
messages: [
{
key: "detect-disabled",
messageKey: "configMessages.detect.disabled",
severity: "info",
condition: (ctx) =>
ctx.level === "camera" && ctx.formData?.enabled === false,
},
],
fieldMessages: [
{
key: "fps-greater-than-five",

View File

@ -53,21 +53,6 @@ const faceRecognition: SectionConfigOverrides = {
"device",
],
restartRequired: ["enabled", "model_size", "device"],
fieldMessages: [
{
key: "model-size-large",
field: "model_size",
messageKey: "configMessages.faceRecognition.modelSizeLarge",
severity: "info",
position: "after",
condition: (ctx) => ctx.formData?.model_size === "large",
},
],
uiSchema: {
model_size: {
"ui:options": { size: "xs" },
},
},
},
};

View File

@ -65,16 +65,6 @@ const lpr: SectionConfigOverrides = {
"replace_rules",
],
restartRequired: ["model_size", "enhancement", "device"],
fieldMessages: [
{
key: "model-size-large",
field: "model_size",
messageKey: "configMessages.lpr.modelSizeLarge",
severity: "info",
position: "after",
condition: (ctx) => ctx.formData?.model_size === "large",
},
],
uiSchema: {
format: {
"ui:options": { size: "md" },
@ -93,9 +83,6 @@ const lpr: SectionConfigOverrides = {
suppressDescription: true,
},
},
model_size: {
"ui:options": { size: "xs" },
},
},
},
};

View File

@ -1,42 +1,8 @@
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.<attr>` 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",
messages: [
{
key: "detect-disabled",
messageKey: "configMessages.detect.disabled",
severity: "info",
condition: (ctx) =>
ctx.level === "camera" &&
ctx.fullCameraConfig?.detect?.enabled === false,
},
],
fieldMessages: [
{
key: "genai-no-descriptions-provider",
field: "genai.enabled",
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
severity: "warning",
position: "before",
condition: (ctx) => {
const providers = ctx.fullConfig.genai;
if (!providers || Object.keys(providers).length === 0) return true;
return !Object.values(providers).some((agent) =>
agent.roles?.includes("descriptions"),
);
},
},
],
fieldDocs: {
"filters.min_area": "/configuration/object_filters#object-area",
"filters.max_area": "/configuration/object_filters#object-area",
@ -60,7 +26,6 @@ const objects: SectionConfigOverrides = {
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
hideAttributeFilters,
],
advancedFields: ["genai"],
uiSchema: {
@ -134,7 +99,6 @@ const objects: SectionConfigOverrides = {
"filters.mask",
"filters.raw_mask",
"genai.required_zones",
hideAttributeFilters,
],
},
camera: {
@ -159,7 +123,6 @@ const objects: SectionConfigOverrides = {
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
hideAttributeFilters,
],
advancedFields: [],
},

View File

@ -41,38 +41,6 @@ const review: SectionConfigOverrides = {
return !Array.isArray(labels) || labels.length === 0;
},
},
{
key: "genai-no-descriptions-provider",
field: "genai.enabled",
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
severity: "warning",
position: "before",
condition: (ctx) => {
const providers = ctx.fullConfig.genai;
if (!providers || Object.keys(providers).length === 0) return true;
return !Object.values(providers).some((agent) =>
agent.roles?.includes("descriptions"),
);
},
},
{
key: "genai-image-source-recordings-record-disabled",
field: "genai.image_source",
messageKey:
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
severity: "warning",
position: "after",
condition: (ctx) => {
const genai = ctx.formData?.genai as
| Record<string, unknown>
| undefined;
if (genai?.image_source !== "recordings") return false;
if (ctx.level === "camera" && ctx.fullCameraConfig) {
return ctx.fullCameraConfig.record?.enabled === false;
}
return ctx.fullConfig.record?.enabled === false;
},
},
],
fieldDocs: {
"alerts.labels": "/configuration/review/#alerts-and-detections",

View File

@ -18,18 +18,6 @@ const semanticSearch: SectionConfigOverrides = {
advancedFields: ["reindex", "device"],
restartRequired: ["enabled", "model", "model_size", "device"],
hiddenFields: ["reindex"],
fieldMessages: [
{
key: "jinav2-small-model-size",
field: "model_size",
messageKey: "configMessages.semanticSearch.jinav2SmallModelSize",
severity: "warning",
position: "after",
condition: (ctx) =>
ctx.formData?.model === "jinav2" &&
ctx.formData?.model_size === "small",
},
],
uiSchema: {
model: {
"ui:widget": "semanticSearchModel",

View File

@ -9,7 +9,7 @@ import {
useRef,
useContext,
} from "react";
import useSWR, { mutate as swrMutate } from "swr";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@ -22,7 +22,6 @@ import {
modifySchemaForSection,
getEffectiveDefaultsForSection,
sanitizeOverridesForSection,
synthesizeMissingObjectFilters,
} from "./section-special-cases";
import { getSectionValidation } from "../section-validations";
import { useConfigOverride } from "@/hooks/use-config-override";
@ -59,11 +58,7 @@ import {
} from "@/components/ui/alert-dialog";
import { applySchemaDefaults } from "@/lib/config-schema";
import { cn } from "@/lib/utils";
import {
ConfigSectionData,
HiddenFieldEntry,
JsonValue,
} from "@/types/configForm";
import { ConfigSectionData, JsonValue } from "@/types/configForm";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import {
@ -73,7 +68,6 @@ import {
buildConfigDataForPath,
flattenOverrides,
getBaseCameraSectionValue,
resolveHiddenFieldEntries,
sanitizeSectionData as sharedSanitizeSectionData,
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
} from "@/utils/configUtil";
@ -96,7 +90,7 @@ export interface SectionConfig {
/** Fields to group together */
fieldGroups?: Record<string, string[]>;
/** Fields to hide from UI */
hiddenFields?: HiddenFieldEntry[];
hiddenFields?: string[];
/** Fields to show in advanced section */
advancedFields?: string[];
/** Fields to compare for override detection */
@ -363,34 +357,25 @@ export function ConfigSection({
return get(config, sectionPath);
}, [config, cameraName, sectionPath, effectiveLevel, profileName]);
const rawFormData = useMemo<ConfigSectionData>(() => {
const rawFormData = useMemo(() => {
if (!config) return {};
if (rawSectionValue === undefined || rawSectionValue === null) {
return {};
}
return synthesizeMissingObjectFilters(
sectionPath,
rawSectionValue,
modifiedSchema ?? undefined,
) as ConfigSectionData;
}, [config, rawSectionValue, sectionPath, modifiedSchema]);
return rawSectionValue;
}, [config, rawSectionValue]);
// 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 base;
return sectionConfig.hiddenFields;
}
const base = sectionConfig.hiddenFields ?? [];
return [...new Set([...base, ...sectionConfig.restartRequired])];
}, [
profileName,
sectionConfig.hiddenFields,
sectionConfig.restartRequired,
config,
]);
}, [profileName, sectionConfig.hiddenFields, sectionConfig.restartRequired]);
const sanitizeSectionData = useCallback(
(data: ConfigSectionData) =>
@ -402,7 +387,7 @@ export function ConfigSection({
const baseData = modifiedSchema
? applySchemaDefaults(modifiedSchema, rawFormData)
: rawFormData;
return sanitizeSectionData(baseData as ConfigSectionData);
return sanitizeSectionData(baseData);
}, [rawFormData, modifiedSchema, sanitizeSectionData]);
const baselineSnapshot = useMemo(() => {
@ -758,7 +743,6 @@ export function ConfigSection({
}
await refreshConfig();
swrMutate("config/raw_paths");
setPendingData(null);
onSave?.();
} catch (error) {

View File

@ -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 { getEffectiveHiddenFields } from "@/utils/configUtil";
import { getSectionConfig } from "@/utils/configUtil";
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
detect: "cameraDetect",
@ -43,33 +43,17 @@ const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
const MAX_FIELDS_PER_CAMERA = 5;
/**
* 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.
* 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.
*/
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",
]);
/**
@ -247,11 +231,8 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
const rawEntries = useCamerasOverridingSection(config, sectionPath);
const entries = useMemo(() => {
const hiddenFields = getEffectiveHiddenFields(
sectionPath,
"global",
config,
);
const hiddenFields =
getSectionConfig(sectionPath, "global").hiddenFields ?? [];
if (hiddenFields.length === 0) return rawEntries;
return rawEntries
.map((entry) => ({
@ -264,7 +245,7 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
),
}))
.filter((entry) => entry.fieldDeltas.length > 0);
}, [rawEntries, sectionPath, config]);
}, [rawEntries, sectionPath]);
if (SECTIONS_WITHOUT_OVERRIDE_BADGE.has(sectionPath)) {
return null;

View File

@ -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", "genai"] as const;
const SPECIAL_CASE_SECTIONS = ["motion", "detectors"] as const;
/**
* Check if a section requires special case handling.
@ -53,29 +53,6 @@ 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;
}
@ -128,51 +105,6 @@ 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<string, unknown> })
?.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.
*/

View File

@ -1,21 +1,12 @@
// Hook to detect when camera config overrides global defaults
import { useMemo } from "react";
import useSWR from "swr";
import cloneDeep from "lodash/cloneDeep";
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,
getEffectiveHiddenFields,
unsetWithWildcard,
} from "@/utils/configUtil";
import { extractSectionSchema } from "@/hooks/use-config-schema";
import { applySchemaDefaults } from "@/lib/config-schema";
import { getBaseCameraSectionValue } from "@/utils/configUtil";
const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"];
@ -43,51 +34,6 @@ export function normalizeConfigValue(value: unknown): JsonValue {
return stripInternalFields(value as JsonValue);
}
/**
* Remove hidden-field paths from a value before comparison so fields the
* user can't change in the UI (e.g. motion masks, attribute filters) don't
* trigger override badges. Operates on a clone so the input is unchanged.
*/
function stripHiddenPaths(value: JsonValue, hiddenFields: string[]): JsonValue {
if (hiddenFields.length === 0 || !isJsonObject(value)) return value;
const cloned = cloneDeep(value) as JsonObject;
for (const path of hiddenFields) {
if (!path) continue;
unsetWithWildcard(cloned as Record<string, unknown>, path);
}
return cloned;
}
/**
* 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;
@ -150,7 +96,6 @@ export function useConfigOverride({
sectionPath,
compareFields,
}: UseConfigOverrideOptions) {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => {
if (!config) {
return {
@ -208,42 +153,15 @@ export function useConfigOverride({
sectionPath,
);
// 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 normalizedGlobalValue = normalizeConfigValue(globalValue);
const normalizedCameraValue = normalizeConfigValue(cameraValue);
// Collapse empty/null values for comparison so semantically equivalent
// shapes (e.g. schema default `mask: null` vs runtime `mask: {}`) match.
// Also strip hidden-field paths (motion masks, attribute filters, etc.)
// so fields the user can't edit in the UI don't trigger override badges.
const hiddenFields = getEffectiveHiddenFields(
sectionPath,
"camera",
config,
);
const collapsedGlobal = stripHiddenPaths(
collapseEmpty(normalizedGlobalValue),
hiddenFields,
);
const collapsedCamera = stripHiddenPaths(
collapseEmpty(normalizedCameraValue),
hiddenFields,
);
const comparisonGlobal = compareFields
? pickFields(collapsedGlobal, compareFields)
: collapsedGlobal;
? pickFields(normalizedGlobalValue, compareFields)
: normalizedGlobalValue;
const comparisonCamera = compareFields
? pickFields(collapsedCamera, compareFields)
: collapsedCamera;
? pickFields(normalizedCameraValue, compareFields)
: normalizedCameraValue;
// Check if the entire section is overridden
const isOverridden = compareFields
@ -258,10 +176,7 @@ export function useConfigOverride({
const cameraFieldValue = get(normalizedCameraValue, fieldPath);
return {
isOverridden: !isEqual(
collapseEmpty(globalFieldValue as JsonValue),
collapseEmpty(cameraFieldValue as JsonValue),
),
isOverridden: !isEqual(globalFieldValue, cameraFieldValue),
globalValue: globalFieldValue,
cameraValue: cameraFieldValue,
};
@ -284,7 +199,7 @@ export function useConfigOverride({
getFieldOverride,
resetToGlobal,
};
}, [config, cameraName, sectionPath, compareFields, schema]);
}, [config, cameraName, sectionPath, compareFields]);
}
/**
@ -337,7 +252,6 @@ export function useAllCameraOverrides(
config: FrigateConfig | undefined,
cameraName: string | undefined,
) {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => {
if (!config || !cameraName) {
return [];
@ -351,31 +265,17 @@ export function useAllCameraOverrides(
const overriddenSections: string[] = [];
for (const { key, compareFields } of OVERRIDABLE_SECTIONS) {
const globalValue = getEffectiveGlobalBaseline(
config,
key,
compareFields,
schema,
);
const globalValue = normalizeConfigValue(get(config, key));
const cameraValue = normalizeConfigValue(
getBaseCameraSectionValue(config, cameraName, key),
);
const hiddenFields = getEffectiveHiddenFields(key, "camera", config);
const collapsedGlobal = stripHiddenPaths(
collapseEmpty(globalValue),
hiddenFields,
);
const collapsedCamera = stripHiddenPaths(
collapseEmpty(cameraValue),
hiddenFields,
);
const comparisonGlobal = compareFields
? pickFields(collapsedGlobal, compareFields)
: collapsedGlobal;
? pickFields(globalValue, compareFields)
: globalValue;
const comparisonCamera = compareFields
? pickFields(collapsedCamera, compareFields)
: collapsedCamera;
? pickFields(cameraValue, compareFields)
: cameraValue;
if (
compareFields && compareFields.length === 0
@ -387,7 +287,7 @@ export function useAllCameraOverrides(
}
return overriddenSections;
}, [config, cameraName, schema]);
}, [config, cameraName]);
}
export interface FieldDelta {
@ -486,40 +386,14 @@ function isPathAllowed(path: string, compareFields?: string[]): boolean {
}
/**
* 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.
* 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.
*/
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[],
@ -587,7 +461,6 @@ export function useCamerasOverridingSection(
config: FrigateConfig | undefined,
sectionPath: string,
): CameraOverrideEntry[] {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => {
if (!config?.cameras || !sectionPath) {
return [];
@ -603,9 +476,11 @@ export function useCamerasOverridingSection(
),
);
const globalValue = collapseEmpty(
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
);
const rawGlobalValue = get(config, sectionPath);
const globalValue: JsonValue =
rawGlobalValue == null
? deriveSyntheticGlobalValue(cameraSectionValues, compareFields)
: normalizeConfigValue(rawGlobalValue);
const entries: CameraOverrideEntry[] = [];
for (let idx = 0; idx < cameraNames.length; idx += 1) {
@ -614,7 +489,7 @@ export function useCamerasOverridingSection(
const deltasByPath = new Map<string, FieldDelta>();
// 1. Camera-level overrides (uses base_config when a profile is active)
const cameraValue = collapseEmpty(cameraSectionValues[idx]);
const cameraValue = cameraSectionValues[idx];
for (const delta of collectFieldDeltas(
globalValue,
cameraValue,
@ -661,5 +536,5 @@ export function useCamerasOverridingSection(
}
return entries;
}, [config, sectionPath, schema]);
}, [config, sectionPath]);
}

View File

@ -24,7 +24,7 @@ const getSchemaDefinitions = (schema: RJSFSchema): Record<string, RJSFSchema> =>
* Extracts and resolves a section schema from the full config schema
* Uses caching to avoid repeated expensive resolution
*/
export function extractSectionSchema(
function extractSectionSchema(
schema: RJSFSchema,
sectionPath: string,
level: "global" | "camera",

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
type OptimisticStateResult<T> = [T, (newValue: T) => void];
@ -8,32 +8,37 @@ const useOptimisticState = <T>(
delay: number = 20,
): OptimisticStateResult<T> => {
const [optimisticValue, setOptimisticValue] = useState<T>(currentState);
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleValueChange = useCallback((newValue: T) => {
// Update the optimistic value immediately
setOptimisticValue(newValue);
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);
}
};
}, []);
// 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 (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)) {
if (currentState != optimisticValue) {
setOptimisticValue(currentState);
}
// sometimes an external action will cause the currentState to change

View File

@ -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)),

View File

@ -885,7 +885,6 @@ 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) {

View File

@ -13,8 +13,6 @@ export type JsonArray = JsonValue[];
export type ConfigSectionData = JsonObject;
export type HiddenFieldEntry = string | ((config: FrigateConfig) => string[]);
export type ConfigFormContext = {
level?: "global" | "camera";
cameraName?: string;

View File

@ -254,10 +254,7 @@ export function flattenOverrides(
// lodash `unset` treats `*` as a literal key. This helper expands wildcard
// segments so that e.g. `"filters.*.mask"` unsets `filters.<each key>.mask`.
export function unsetWithWildcard(
obj: Record<string, unknown>,
path: string,
): void {
function unsetWithWildcard(obj: Record<string, unknown>, path: string): void {
if (!path.includes("*")) {
unset(obj, path);
return;
@ -590,14 +587,13 @@ 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).
const resolvedHidden = resolveHiddenFieldEntries(
sectionConfig.hiddenFields,
config,
);
const hiddenFieldsForSanitize =
profileInfo.isProfile && sectionConfig.restartRequired?.length
? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])]
: resolvedHidden;
let hiddenFieldsForSanitize = sectionConfig.hiddenFields;
if (profileInfo.isProfile && sectionConfig.restartRequired?.length) {
const base = sectionConfig.hiddenFields ?? [];
hiddenFieldsForSanitize = [
...new Set([...base, ...sectionConfig.restartRequired]),
];
}
// Sanitize raw form data
const rawData = sanitizeSectionData(
@ -706,36 +702,3 @@ 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.<attr>` 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;
}

View File

@ -1072,12 +1072,10 @@ function FrigateCameraFeatures({
title={
recordState == "ON"
? t("recording.disable")
: camera.record.enabled_in_config
? t("recording.enable")
: t("recording.disabledInConfig")
: t("recording.enable")
}
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled || !camera.record.enabled_in_config}
disabled={!cameraEnabled}
/>
<CameraFeatureToggle
className="p-2 md:p-0"

View File

@ -313,18 +313,12 @@ type CameraEnableSwitchProps = {
function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
const { payload: enabledState, send: sendEnabled } =
useEnabledState(cameraName);
const { data: config } = useSWR<FrigateConfig>("config");
const isChecked =
enabledState === "ON" || enabledState === "OFF"
? enabledState === "ON"
: (config?.cameras?.[cameraName]?.enabled ?? false);
return (
<div className="flex flex-row items-center">
<Switch
id={`camera-enabled-${cameraName}`}
checked={isChecked}
checked={enabledState === "ON"}
onCheckedChange={(isChecked) => {
sendEnabled(isChecked ? "ON" : "OFF");
}}

View File

@ -2,7 +2,6 @@ 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";
@ -14,7 +13,6 @@ import {
useMemo,
useState,
} from "react";
import { BsFillCameraVideoOffFill } from "react-icons/bs";
import { MdInfo } from "react-icons/md";
import {
Tooltip,
@ -175,7 +173,7 @@ export default function CameraMetrics({
}
Object.entries(stats.cameras).forEach(([key, camStats]) => {
if (!camStats || !config?.cameras[key]?.enabled) {
if (!config?.cameras[key].enabled) {
return;
}
@ -230,10 +228,6 @@ export default function CameraMetrics({
}
Object.entries(stats.cameras).forEach(([key, camStats]) => {
if (!camStats) {
return;
}
if (!(key in series)) {
const camName = getCameraName(key);
series[key] = {};
@ -280,17 +274,6 @@ export default function CameraMetrics({
}
}, [showCameraInfoDialog]);
if (config && Object.keys(config.cameras).length === 0) {
return (
<div className="flex size-full items-center justify-center">
<EmptyCard
icon={<BsFillCameraVideoOffFill className="size-8" />}
title={t("cameras.noCameras.title")}
/>
</div>
);
}
return (
<div className="scrollbar-container mt-4 flex size-full flex-col gap-3 overflow-y-auto">
<div className="text-sm font-medium text-muted-foreground">