add section description from schema and clarify global vs camera level descriptions

This commit is contained in:
Josh Hawkins 2026-01-31 08:50:24 -06:00
parent 8b7156438e
commit 8f681d5689
16 changed files with 218 additions and 76 deletions

View File

@ -76,7 +76,7 @@ class CameraConfig(FrigateBaseModel):
audio: AudioConfig = Field(
default_factory=AudioConfig,
title="Audio events",
description="Settings for audio-based event detection; can be overridden per-camera.",
description="Settings for audio-based event detection for this camera.",
)
audio_transcription: CameraAudioTranscriptionConfig = Field(
default_factory=CameraAudioTranscriptionConfig,
@ -96,7 +96,7 @@ class CameraConfig(FrigateBaseModel):
face_recognition: CameraFaceRecognitionConfig = Field(
default_factory=CameraFaceRecognitionConfig,
title="Face recognition",
description="Settings for face detection and recognition; can be overridden per-camera.",
description="Settings for face detection and recognition for this camera.",
)
ffmpeg: CameraFfmpegConfig = Field(
title="FFmpeg",
@ -115,7 +115,7 @@ class CameraConfig(FrigateBaseModel):
motion: MotionConfig = Field(
None,
title="Motion detection",
description="Default motion detection settings; can be overridden per-camera.",
description="Default motion detection settings for this camera.",
)
objects: ObjectConfig = Field(
default_factory=ObjectConfig,
@ -125,12 +125,12 @@ class CameraConfig(FrigateBaseModel):
record: RecordConfig = Field(
default_factory=RecordConfig,
title="Recording",
description="Recording and retention settings; can be overridden per-camera.",
description="Recording and retention settings for this camera.",
)
review: ReviewConfig = Field(
default_factory=ReviewConfig,
title="Review",
description="Settings that control alerts, detections, and GenAI review summaries used by the UI and storage; can be overridden per-camera.",
description="Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.",
)
semantic_search: CameraSemanticSearchConfig = Field(
default_factory=CameraSemanticSearchConfig,
@ -140,7 +140,7 @@ class CameraConfig(FrigateBaseModel):
snapshots: SnapshotsConfig = Field(
default_factory=SnapshotsConfig,
title="Snapshots",
description="Settings for saved JPEG snapshots of tracked objects; can be overridden per-camera.",
description="Settings for saved JPEG snapshots of tracked objects for this camera.",
)
timestamp_style: TimestampStyleConfig = Field(
default_factory=TimestampStyleConfig,
@ -162,7 +162,7 @@ class CameraConfig(FrigateBaseModel):
notifications: NotificationConfig = Field(
default_factory=NotificationConfig,
title="Notifications",
description="Settings to enable and control notifications; can be overridden per-camera.",
description="Settings to enable and control notifications for this camera.",
)
onvif: OnvifConfig = Field(
default_factory=OnvifConfig,

View File

@ -49,7 +49,7 @@ class FfmpegConfig(FrigateBaseModel):
path: str = Field(
default="default",
title="FFmpeg path",
description='Path to the FFmpeg binary to use globally or a version alias ("5.0" or "7.0").',
description='Path to the FFmpeg binary to use or a version alias ("5.0" or "7.0").',
)
global_args: Union[str, list[str]] = Field(
default=FFMPEG_GLOBAL_ARGS_DEFAULT,

View File

@ -11,7 +11,7 @@ class MotionConfig(FrigateBaseModel):
enabled: bool = Field(
default=True,
title="Enable motion detection",
description="Enable or disable motion detection globally; per-camera settings can override this.",
description="Enable or disable motion detection; can be overridden per-camera.",
)
threshold: int = Field(
default=30,

View File

@ -11,7 +11,7 @@ class NotificationConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
title="Enable notifications",
description="Enable or disable notifications globally.",
description="Enable or disable notifications; can be overridden per-camera.",
)
email: Optional[str] = Field(
default=None,

View File

@ -132,7 +132,7 @@ class ObjectConfig(FrigateBaseModel):
track: list[str] = Field(
default=DEFAULT_TRACKED_OBJECTS,
title="Objects to track",
description="List of object labels to track globally; camera configs can override this.",
description="List of object labels to track; can be overridden per-camera.",
)
filters: dict[str, FilterConfig] = Field(
default_factory=dict,

View File

@ -98,7 +98,7 @@ class RecordConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
title="Enable recording",
description="Enable or disable recording globally; individual cameras can override this.",
description="Enable or disable recording; can be overridden per-camera.",
)
expire_interval: int = Field(
default=60,

View File

@ -30,7 +30,7 @@ class SnapshotsConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
title="Snapshots enabled",
description="Enable or disable saving snapshots globally.",
description="Enable or disable saving snapshots; can be overridden per-camera.",
)
clean_copy: bool = Field(
default=True,

View File

@ -46,7 +46,7 @@ class AudioTranscriptionConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
title="Enable audio transcription",
description="Enable or disable automatic audio transcription globally.",
description="Enable or disable automatic audio transcription; can be overridden per-camera.",
)
language: str = Field(
default="en",
@ -240,7 +240,7 @@ class FaceRecognitionConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
title="Enable face recognition",
description="Enable or disable face recognition globally.",
description="Enable or disable face recognition; can be overridden per-camera.",
)
model_size: str = Field(
default="small",
@ -322,12 +322,12 @@ class LicensePlateRecognitionConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
title="Enable LPR",
description="Enable or disable LPR globally; camera-level settings can override.",
description="Enable or disable license plate recognition; can be overridden per-camera.",
)
model_size: str = Field(
default="small",
title="Model size",
description="Model size used for text detection/recognition; small runs on CPU, large on GPU.",
description="Model size used for text detection/recognition. Most users should use 'small'.",
)
detection_threshold: float = Field(
default=0.7,

View File

@ -15,10 +15,10 @@
},
"audio": {
"label": "Audio events",
"description": "Settings for audio-based event detection; can be overridden per-camera.",
"description": "Settings for audio-based event detection for this camera.",
"enabled": {
"label": "Enable audio detection",
"description": "Enable or disable audio event detection; can be overridden per-camera."
"description": "Enable or disable audio event detection for this camera."
},
"max_not_heard": {
"label": "End timeout",
@ -138,7 +138,7 @@
},
"face_recognition": {
"label": "Face recognition",
"description": "Settings for face detection and recognition; can be overridden per-camera.",
"description": "Settings for face detection and recognition for this camera.",
"enabled": {
"label": "Enable face recognition",
"description": "Enable or disable face recognition."
@ -153,7 +153,7 @@
"description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
"path": {
"label": "FFmpeg path",
"description": "Path to the FFmpeg binary to use globally or a version alias (\"5.0\" or \"7.0\")."
"description": "Path to the FFmpeg binary to use for this camera or a version alias (\"5.0\" or \"7.0\")."
},
"global_args": {
"label": "FFmpeg global args",
@ -254,10 +254,10 @@
},
"motion": {
"label": "Motion detection",
"description": "Default motion detection settings; can be overridden per-camera.",
"description": "Default motion detection settings for this camera.",
"enabled": {
"label": "Enable motion detection",
"description": "Enable or disable motion detection globally; per-camera settings can override this."
"description": "Enable or disable motion detection for this camera."
},
"threshold": {
"label": "Motion threshold",
@ -308,7 +308,7 @@
"description": "Object tracking defaults including which labels to track and per-object filters.",
"track": {
"label": "Objects to track",
"description": "List of object labels to track globally; camera configs can override this."
"description": "List of object labels to track for this camera."
},
"filters": {
"label": "Object filters",
@ -400,10 +400,10 @@
},
"record": {
"label": "Recording",
"description": "Recording and retention settings; can be overridden per-camera.",
"description": "Recording and retention settings for this camera.",
"enabled": {
"label": "Enable recording",
"description": "Enable or disable recording globally; individual cameras can override this."
"description": "Enable or disable recording for this camera."
},
"expire_interval": {
"label": "Record cleanup interval",
@ -496,7 +496,7 @@
},
"review": {
"label": "Review",
"description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage; can be overridden per-camera.",
"description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.",
"alerts": {
"label": "Alerts config",
"description": "Settings for which tracked objects generate alerts and how alerts are retained.",
@ -620,10 +620,10 @@
},
"snapshots": {
"label": "Snapshots",
"description": "Settings for saved JPEG snapshots of tracked objects; can be overridden per-camera.",
"description": "Settings for saved JPEG snapshots of tracked objects for this camera.",
"enabled": {
"label": "Snapshots enabled",
"description": "Enable or disable saving snapshots globally."
"description": "Enable or disable saving snapshots for this camera."
},
"clean_copy": {
"label": "Save clean copy",
@ -744,10 +744,10 @@
},
"notifications": {
"label": "Notifications",
"description": "Settings to enable and control notifications; can be overridden per-camera.",
"description": "Settings to enable and control notifications for this camera.",
"enabled": {
"label": "Enable notifications",
"description": "Enable or disable notifications globally."
"description": "Enable or disable notifications for this camera."
},
"email": {
"label": "Notification email",

View File

@ -130,7 +130,12 @@ export function createConfigSection({
defaultCollapsed = false,
showTitle,
}: BaseSectionProps) {
const { t } = useTranslation([i18nNamespace, "views/settings", "common"]);
const { t, i18n } = useTranslation([
i18nNamespace,
"config/cameras",
"views/settings",
"common",
]);
const [isOpen, setIsOpen] = useState(!defaultCollapsed);
const [pendingData, setPendingData] = useState<ConfigSectionData | null>(
null,
@ -455,13 +460,31 @@ export function createConfigSection({
return null;
}
// Get section title from config namespace
const title = t("label", {
ns: i18nNamespace,
defaultValue:
sectionPath.charAt(0).toUpperCase() +
sectionPath.slice(1).replace(/_/g, " "),
});
// Get section title from config namespace. For camera-level sections we
// prefer the `config/cameras` namespace where keys are nested under the
// section name (e.g., `audio.label`). Fall back to provided i18nNamespace.
const defaultTitle =
sectionPath.charAt(0).toUpperCase() +
sectionPath.slice(1).replace(/_/g, " ");
const title =
level === "camera"
? t(`${sectionPath}.label`, {
ns: "config/cameras",
defaultValue: defaultTitle,
})
: t("label", {
ns: i18nNamespace,
defaultValue: defaultTitle,
});
const sectionDescription =
level === "camera"
? i18n.exists(`${sectionPath}.description`, { ns: "config/cameras" })
? t(`${sectionPath}.description`, { ns: "config/cameras" })
: undefined
: i18n.exists("description", { ns: i18nNamespace })
? t("description", { ns: i18nNamespace })
: undefined;
const sectionContent = (
<div className="space-y-6">
@ -491,6 +514,9 @@ export function createConfigSection({
? config?.cameras?.[cameraName]
: undefined,
fullConfig: config,
// When rendering camera-level sections, provide the section path so
// field templates can look up keys under the `config/cameras` namespace
sectionI18nPrefix: level === "camera" ? sectionPath : undefined,
t,
}}
/>
@ -597,21 +623,30 @@ export function createConfigSection({
return (
<div className="space-y-3">
{shouldShowTitle && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Heading as="h4">{title}</Heading>
{showOverrideIndicator && level === "camera" && isOverridden && (
<Badge variant="secondary" className="text-xs">
{t("overridden", {
ns: "common",
defaultValue: "Overridden",
})}
</Badge>
)}
{hasChanges && (
<Badge variant="outline" className="text-xs">
{t("modified", { ns: "common", defaultValue: "Modified" })}
</Badge>
<div className="flex items-start justify-between">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<Heading as="h4">{title}</Heading>
{showOverrideIndicator &&
level === "camera" &&
isOverridden && (
<Badge variant="secondary" className="text-xs">
{t("overridden", {
ns: "common",
defaultValue: "Overridden",
})}
</Badge>
)}
{hasChanges && (
<Badge variant="outline" className="text-xs">
{t("modified", { ns: "common", defaultValue: "Modified" })}
</Badge>
)}
</div>
{sectionDescription && (
<p className="text-sm text-muted-foreground">
{sectionDescription}
</p>
)}
</div>
{level === "camera" && isOverridden && (

View File

@ -1,16 +1,40 @@
// Description Field Template
import type { DescriptionFieldProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next";
import { ConfigFormContext } from "@/types/configForm";
export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
const { description, id } = props;
const formContext = (
props as { registry?: { formContext?: ConfigFormContext } }
).registry?.formContext;
if (!description) {
const isCameraLevel = formContext?.level === "camera";
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
const i18nNamespace = formContext?.i18nNamespace;
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
const { t, i18n } = useTranslation([
effectiveNamespace || i18nNamespace || "common",
i18nNamespace || "common",
]);
let resolvedDescription = description;
if (isCameraLevel && sectionI18nPrefix && effectiveNamespace) {
const descriptionKey = `${sectionI18nPrefix}.description`;
if (i18n.exists(descriptionKey, { ns: effectiveNamespace })) {
resolvedDescription = t(descriptionKey, { ns: effectiveNamespace });
}
}
if (!resolvedDescription) {
return null;
}
return (
<p id={id} className="text-sm text-muted-foreground">
{description}
{resolvedDescription}
</p>
);
}

View File

@ -81,7 +81,13 @@ export function FieldTemplate(props: FieldTemplateProps) {
// Get i18n namespace from form context (passed through registry)
const formContext = registry?.formContext as ConfigFormContext | undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const sectionI18nPrefix = formContext?.sectionI18nPrefix as
| string
| undefined;
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
const { t, i18n } = useTranslation([
effectiveNamespace || i18nNamespace || "common",
i18nNamespace || "common",
"views/settings",
]);
@ -126,10 +132,21 @@ export function FieldTemplate(props: FieldTemplateProps) {
// Try to get translated label, falling back to schema title, then RJSF label
let finalLabel = label;
if (i18nNamespace && translationPath) {
if (effectiveNamespace && translationPath) {
// Prefer camera-scoped translations when a section prefix is provided
const prefixedTranslationKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.label`
: undefined;
const translationKey = `${translationPath}.label`;
if (i18n.exists(translationKey, { ns: i18nNamespace })) {
finalLabel = t(translationKey, { ns: i18nNamespace });
if (
prefixedTranslationKey &&
i18n.exists(prefixedTranslationKey, { ns: effectiveNamespace })
) {
finalLabel = t(prefixedTranslationKey, { ns: effectiveNamespace });
} else if (i18n.exists(translationKey, { ns: effectiveNamespace })) {
finalLabel = t(translationKey, { ns: effectiveNamespace });
} else if (schemaTitle) {
finalLabel = schemaTitle;
} else if (translatedFilterObjectLabel) {
@ -145,11 +162,25 @@ export function FieldTemplate(props: FieldTemplateProps) {
let fieldLabel = schemaTitle;
if (!fieldLabel) {
const fieldTranslationKey = `${fieldName}.label`;
const prefixedFieldTranslationKey =
sectionI18nPrefix &&
!fieldTranslationKey.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${fieldTranslationKey}`
: undefined;
if (
i18nNamespace &&
i18n.exists(fieldTranslationKey, { ns: i18nNamespace })
prefixedFieldTranslationKey &&
effectiveNamespace &&
i18n.exists(prefixedFieldTranslationKey, { ns: effectiveNamespace })
) {
fieldLabel = t(fieldTranslationKey, { ns: i18nNamespace });
fieldLabel = t(prefixedFieldTranslationKey, {
ns: effectiveNamespace,
});
} else if (
effectiveNamespace &&
i18n.exists(fieldTranslationKey, { ns: effectiveNamespace })
) {
fieldLabel = t(fieldTranslationKey, { ns: effectiveNamespace });
} else {
fieldLabel = humanizeKey(fieldName);
}
@ -177,11 +208,25 @@ export function FieldTemplate(props: FieldTemplateProps) {
let fieldLabel = schemaTitle;
if (!fieldLabel) {
const fieldTranslationKey = `${fieldName}.label`;
const prefixedFieldTranslationKey =
sectionI18nPrefix &&
!fieldTranslationKey.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${fieldTranslationKey}`
: undefined;
if (
i18nNamespace &&
i18n.exists(fieldTranslationKey, { ns: i18nNamespace })
prefixedFieldTranslationKey &&
effectiveNamespace &&
i18n.exists(prefixedFieldTranslationKey, { ns: effectiveNamespace })
) {
fieldLabel = t(fieldTranslationKey, { ns: i18nNamespace });
fieldLabel = t(prefixedFieldTranslationKey, {
ns: effectiveNamespace,
});
} else if (
effectiveNamespace &&
i18n.exists(fieldTranslationKey, { ns: effectiveNamespace })
) {
fieldLabel = t(fieldTranslationKey, { ns: effectiveNamespace });
} else {
fieldLabel = humanizeKey(fieldName);
}
@ -198,10 +243,19 @@ export function FieldTemplate(props: FieldTemplateProps) {
// Try to get translated description, falling back to schema description
let finalDescription = description || "";
if (i18nNamespace && translationPath) {
if (effectiveNamespace && translationPath) {
const prefixedDescriptionKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.description`
: undefined;
const descriptionKey = `${translationPath}.description`;
if (i18n.exists(descriptionKey, { ns: i18nNamespace })) {
finalDescription = t(descriptionKey, { ns: i18nNamespace });
if (
prefixedDescriptionKey &&
i18n.exists(prefixedDescriptionKey, { ns: effectiveNamespace })
) {
finalDescription = t(prefixedDescriptionKey, { ns: effectiveNamespace });
} else if (i18n.exists(descriptionKey, { ns: effectiveNamespace })) {
finalDescription = t(descriptionKey, { ns: effectiveNamespace });
} else if (schemaDescription) {
finalDescription = schemaDescription;
}

View File

@ -68,7 +68,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const [isOpen, setIsOpen] = useState(true);
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel
? "config/cameras"
: formContext?.i18nNamespace;
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
const { t, i18n } = useTranslation([
effectiveNamespace || formContext?.i18nNamespace || "common",
formContext?.i18nNamespace || "common",
"config/groups",
"common",
@ -127,12 +134,18 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
}
// Try i18n translation, fall back to schema or original values
const i18nNs = formContext?.i18nNamespace;
const i18nNs = effectiveNamespace;
let inferredLabel: string | undefined;
if (i18nNs && translationPath) {
const prefixedLabelKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.label`
: undefined;
const labelKey = `${translationPath}.label`;
if (i18n.exists(labelKey, { ns: i18nNs })) {
if (prefixedLabelKey && i18n.exists(prefixedLabelKey, { ns: i18nNs })) {
inferredLabel = t(prefixedLabelKey, { ns: i18nNs });
} else if (i18n.exists(labelKey, { ns: i18nNs })) {
inferredLabel = t(labelKey, { ns: i18nNs });
}
}
@ -146,8 +159,17 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
let inferredDescription: string | undefined;
if (i18nNs && translationPath) {
const prefixedDescriptionKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.description`
: undefined;
const descriptionKey = `${translationPath}.description`;
if (i18n.exists(descriptionKey, { ns: i18nNs })) {
if (
prefixedDescriptionKey &&
i18n.exists(prefixedDescriptionKey, { ns: i18nNs })
) {
inferredDescription = t(prefixedDescriptionKey, { ns: i18nNs });
} else if (i18n.exists(descriptionKey, { ns: i18nNs })) {
inferredDescription = t(descriptionKey, { ns: i18nNs });
}
}

View File

@ -20,5 +20,6 @@ export type ConfigFormContext = {
fullCameraConfig?: CameraConfig;
fullConfig?: FrigateConfig;
i18nNamespace?: string;
sectionI18nPrefix?: string;
t?: (key: string, options?: Record<string, unknown>) => string;
};

View File

@ -53,6 +53,7 @@ i18n
"views/exports",
"views/explore",
// Config section translations
"config/cameras",
"config/detect",
"config/record",
"config/snapshots",

View File

@ -187,6 +187,7 @@ const CameraConfigContent = memo(function CameraConfigContent({
"config/objects",
"config/review",
"config/audio",
"config/cameras",
"config/audio_transcription",
"config/birdseye",
"config/camera_mqtt",
@ -322,11 +323,15 @@ const CameraConfigContent = memo(function CameraConfigContent({
<ul className="space-y-1">
{sections.map((section) => {
const isOverridden = overriddenSections.includes(section.key);
const sectionLabel = t("label", {
ns: section.i18nNamespace,
defaultValue:
section.key.charAt(0).toUpperCase() +
section.key.slice(1).replace(/_/g, " "),
const defaultSectionLabel =
section.key.charAt(0).toUpperCase() +
section.key.slice(1).replace(/_/g, " ");
const sectionLabel = t(`${section.key}.label`, {
ns: "config/cameras",
defaultValue: t("label", {
ns: section.i18nNamespace,
defaultValue: defaultSectionLabel,
}),
});
return (