diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 06d9279f7..e075c7e0e 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1433,8 +1433,7 @@ }, "reviewLabels": { "summary": "{{count}} labels selected", - "empty": "No labels available", - "allNonAlertDetections": "All non-alert activity will be included as detections." + "empty": "No labels available" }, "filters": { "objectFieldLabel": "{{field}} for {{label}}" @@ -1606,5 +1605,38 @@ "onvif": { "profileAuto": "Auto", "profileLoading": "Loading profiles..." + }, + "configMessages": { + "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." + }, + "audio": { + "noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function." + }, + "audioTranscription": { + "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." + }, + "faceRecognition": { + "globalDisabled": "Face recognition is not enabled at the global level. Enable it in global settings 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": "License plate recognition is not enabled at the global level. Enable it in global settings 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." + }, + "birdseye": { + "objectsModeDetectDisabled": "Birdseye is set to 'objects' mode, but object detection is disabled for this camera. The camera will not appear in Birdseye." + }, + "snapshots": { + "detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created." + } } } diff --git a/web/src/components/config-form/ConfigFieldMessage.tsx b/web/src/components/config-form/ConfigFieldMessage.tsx new file mode 100644 index 000000000..5c0a5c505 --- /dev/null +++ b/web/src/components/config-form/ConfigFieldMessage.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from "react-i18next"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { LuInfo, LuTriangleAlert, LuCircleAlert } from "react-icons/lu"; +import type { MessageSeverity } from "./section-configs/types"; + +const severityVariantMap: Record< + MessageSeverity, + "info" | "warning" | "destructive" +> = { + info: "info", + warning: "warning", + error: "destructive", +}; + +function SeverityIcon({ severity }: { severity: string }) { + switch (severity) { + case "info": + return ; + case "warning": + return ; + case "error": + return ; + default: + return ; + } +} + +type ConfigFieldMessageProps = { + messageKey: string; + severity: string; +}; + +export function ConfigFieldMessage({ + messageKey, + severity, +}: ConfigFieldMessageProps) { + const { t } = useTranslation("views/settings"); + + return ( + + + {t(messageKey)} + + ); +} diff --git a/web/src/components/config-form/ConfigMessageBanner.tsx b/web/src/components/config-form/ConfigMessageBanner.tsx new file mode 100644 index 000000000..f5b828000 --- /dev/null +++ b/web/src/components/config-form/ConfigMessageBanner.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { LuInfo, LuTriangleAlert, LuCircleAlert } from "react-icons/lu"; +import type { + ConditionalMessage, + MessageSeverity, +} from "./section-configs/types"; + +const severityVariantMap: Record< + MessageSeverity, + "info" | "warning" | "destructive" +> = { + info: "info", + warning: "warning", + error: "destructive", +}; + +function SeverityIcon({ severity }: { severity: MessageSeverity }) { + switch (severity) { + case "info": + return ; + case "warning": + return ; + case "error": + return ; + } +} + +type ConfigMessageBannerProps = { + messages: ConditionalMessage[]; +}; + +export function ConfigMessageBanner({ messages }: ConfigMessageBannerProps) { + const { t } = useTranslation("views/settings"); + + if (messages.length === 0) return null; + + return ( +
+ {messages.map((msg) => ( + + + {t(msg.messageKey)} + + ))} +
+ ); +} diff --git a/web/src/components/config-form/section-configs/audio.ts b/web/src/components/config-form/section-configs/audio.ts index a112fa0db..31f19e93d 100644 --- a/web/src/components/config-form/section-configs/audio.ts +++ b/web/src/components/config-form/section-configs/audio.ts @@ -3,6 +3,21 @@ import type { SectionConfigOverrides } from "./types"; const audio: SectionConfigOverrides = { base: { sectionDocs: "/configuration/audio_detectors", + messages: [ + { + key: "no-audio-role", + messageKey: "configMessages.audio.noAudioRole", + severity: "warning", + condition: (ctx) => { + if (ctx.level === "camera" && ctx.fullCameraConfig) { + return !ctx.fullCameraConfig.ffmpeg?.inputs?.some((input) => + input.roles?.includes("audio"), + ); + } + return false; + }, + }, + ], restartRequired: [], fieldOrder: [ "enabled", 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 169a77954..8e8e70d77 100644 --- a/web/src/components/config-form/section-configs/audio_transcription.ts +++ b/web/src/components/config-form/section-configs/audio_transcription.ts @@ -3,6 +3,19 @@ import type { SectionConfigOverrides } from "./types"; const audioTranscription: SectionConfigOverrides = { base: { sectionDocs: "/configuration/audio_detectors#audio-transcription", + messages: [ + { + key: "audio-detection-disabled", + messageKey: "configMessages.audioTranscription.audioDetectionDisabled", + severity: "warning", + condition: (ctx) => { + if (ctx.level === "camera" && ctx.fullCameraConfig) { + return ctx.fullCameraConfig.audio.enabled === false; + } + return false; + }, + }, + ], restartRequired: [], fieldOrder: ["enabled", "language", "device", "model_size"], hiddenFields: ["enabled_in_config", "live_enabled"], diff --git a/web/src/components/config-form/section-configs/birdseye.ts b/web/src/components/config-form/section-configs/birdseye.ts index 63fae75d9..d621c9203 100644 --- a/web/src/components/config-form/section-configs/birdseye.ts +++ b/web/src/components/config-form/section-configs/birdseye.ts @@ -3,6 +3,20 @@ import type { SectionConfigOverrides } from "./types"; const birdseye: SectionConfigOverrides = { base: { sectionDocs: "/configuration/birdseye", + messages: [ + { + key: "objects-mode-detect-disabled", + messageKey: "configMessages.birdseye.objectsModeDetectDisabled", + severity: "info", + condition: (ctx) => { + if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false; + return ( + ctx.formData?.mode === "objects" && + ctx.fullCameraConfig.detect?.enabled === false + ); + }, + }, + ], restartRequired: [], fieldOrder: ["enabled", "mode", "order"], hiddenFields: [], diff --git a/web/src/components/config-form/section-configs/detect.ts b/web/src/components/config-form/section-configs/detect.ts index 778620f1c..ef14d13fd 100644 --- a/web/src/components/config-form/section-configs/detect.ts +++ b/web/src/components/config-form/section-configs/detect.ts @@ -3,6 +3,21 @@ import type { SectionConfigOverrides } from "./types"; const detect: SectionConfigOverrides = { base: { sectionDocs: "/configuration/camera_specific", + fieldMessages: [ + { + key: "fps-greater-than-five", + field: "fps", + messageKey: "configMessages.detect.fpsGreaterThanFive", + severity: "info", + position: "after", + condition: (ctx) => { + if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false; + const detectFps = ctx.formData?.fps as number | undefined; + const streamFps = ctx.fullCameraConfig.detect?.fps; + return detectFps != null && streamFps != null && detectFps > 5; + }, + }, + ], fieldOrder: [ "enabled", "width", 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 ef9e43506..822f6ffe0 100644 --- a/web/src/components/config-form/section-configs/face_recognition.ts +++ b/web/src/components/config-form/section-configs/face_recognition.ts @@ -3,6 +3,26 @@ import type { SectionConfigOverrides } from "./types"; const faceRecognition: SectionConfigOverrides = { base: { sectionDocs: "/configuration/face_recognition", + messages: [ + { + key: "global-disabled", + messageKey: "configMessages.faceRecognition.globalDisabled", + severity: "warning", + condition: (ctx) => { + if (ctx.level !== "camera") return false; + return ctx.fullConfig.face_recognition?.enabled === false; + }, + }, + { + key: "person-not-tracked", + messageKey: "configMessages.faceRecognition.personNotTracked", + severity: "info", + condition: (ctx) => { + if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false; + return !ctx.fullCameraConfig.objects?.track?.includes("person"); + }, + }, + ], restartRequired: [], fieldOrder: ["enabled", "min_area"], hiddenFields: [], diff --git a/web/src/components/config-form/section-configs/lpr.ts b/web/src/components/config-form/section-configs/lpr.ts index 514dba9be..4997d766f 100644 --- a/web/src/components/config-form/section-configs/lpr.ts +++ b/web/src/components/config-form/section-configs/lpr.ts @@ -3,6 +3,28 @@ import type { SectionConfigOverrides } from "./types"; const lpr: SectionConfigOverrides = { base: { sectionDocs: "/configuration/license_plate_recognition", + messages: [ + { + key: "global-disabled", + messageKey: "configMessages.lpr.globalDisabled", + severity: "warning", + condition: (ctx) => { + if (ctx.level !== "camera") return false; + return ctx.fullConfig.lpr?.enabled === false; + }, + }, + { + key: "vehicle-not-tracked", + messageKey: "configMessages.lpr.vehicleNotTracked", + severity: "info", + condition: (ctx) => { + if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false; + if (ctx.fullCameraConfig.type === "lpr") return false; + const tracked = ctx.fullCameraConfig.objects?.track ?? []; + return !tracked.some((o) => ["car", "motorcycle"].includes(o)); + }, + }, + ], fieldDocs: { enhancement: "/configuration/license_plate_recognition#enhancement", }, diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index 05a21f224..35f3b1ef7 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -3,6 +3,19 @@ import type { SectionConfigOverrides } from "./types"; const record: SectionConfigOverrides = { base: { sectionDocs: "/configuration/record", + messages: [ + { + key: "no-record-role", + messageKey: "configMessages.record.noRecordRole", + severity: "warning", + condition: (ctx) => { + if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false; + return !ctx.fullCameraConfig.ffmpeg?.inputs?.some((i) => + i.roles?.includes("record"), + ); + }, + }, + ], restartRequired: [], fieldOrder: [ "enabled", diff --git a/web/src/components/config-form/section-configs/review.ts b/web/src/components/config-form/section-configs/review.ts index e9e3169d7..6f769179d 100644 --- a/web/src/components/config-form/section-configs/review.ts +++ b/web/src/components/config-form/section-configs/review.ts @@ -3,6 +3,45 @@ import type { SectionConfigOverrides } from "./types"; const review: SectionConfigOverrides = { base: { sectionDocs: "/configuration/review", + messages: [ + { + key: "record-disabled", + messageKey: "configMessages.review.recordDisabled", + severity: "warning", + condition: (ctx) => { + if (ctx.level === "camera" && ctx.fullCameraConfig) { + return ctx.fullCameraConfig.record.enabled === false; + } + return ctx.fullConfig.record?.enabled === false; + }, + }, + { + key: "detect-disabled", + messageKey: "configMessages.review.detectDisabled", + severity: "info", + condition: (ctx) => { + if (ctx.level === "camera" && ctx.fullCameraConfig) { + return ctx.fullCameraConfig.detect?.enabled === false; + } + return false; + }, + }, + ], + fieldMessages: [ + { + key: "detections-all-non-alert", + field: "detections.labels", + messageKey: "configMessages.review.allNonAlertDetections", + severity: "info", + position: "after", + condition: (ctx) => { + const labels = ( + ctx.formData?.detections as Record | undefined + )?.labels; + return !Array.isArray(labels) || labels.length === 0; + }, + }, + ], fieldDocs: { "alerts.labels": "/configuration/review/#alerts-and-detections", "detections.labels": "/configuration/review/#alerts-and-detections", @@ -35,8 +74,6 @@ const review: SectionConfigOverrides = { "ui:widget": "reviewLabels", "ui:options": { suppressMultiSchema: true, - emptySelectionHintKey: - "configForm.reviewLabels.allNonAlertDetections", }, }, required_zones: { diff --git a/web/src/components/config-form/section-configs/snapshots.ts b/web/src/components/config-form/section-configs/snapshots.ts index 126ecd496..7d08cc728 100644 --- a/web/src/components/config-form/section-configs/snapshots.ts +++ b/web/src/components/config-form/section-configs/snapshots.ts @@ -3,6 +3,17 @@ import type { SectionConfigOverrides } from "./types"; const snapshots: SectionConfigOverrides = { base: { sectionDocs: "/configuration/snapshots", + messages: [ + { + key: "detect-disabled", + messageKey: "configMessages.snapshots.detectDisabled", + severity: "info", + condition: (ctx) => { + if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false; + return ctx.fullCameraConfig.detect?.enabled === false; + }, + }, + ], restartRequired: [], fieldOrder: [ "enabled", diff --git a/web/src/components/config-form/section-configs/types.ts b/web/src/components/config-form/section-configs/types.ts index e2b308e08..9efeb2b32 100644 --- a/web/src/components/config-form/section-configs/types.ts +++ b/web/src/components/config-form/section-configs/types.ts @@ -1,5 +1,39 @@ +import type { FrigateConfig, CameraConfig } from "@/types/frigateConfig"; +import type { ConfigSectionData } from "@/types/configForm"; import type { SectionConfig } from "../sections/BaseSection"; +/** Context provided to message condition functions */ +export type MessageConditionContext = { + fullConfig: FrigateConfig; + fullCameraConfig?: CameraConfig; + level: "global" | "camera"; + cameraName?: string; + formData: ConfigSectionData; +}; + +/** Severity levels for conditional messages */ +export type MessageSeverity = "info" | "warning" | "error"; + +/** A conditional message definition */ +export type ConditionalMessage = { + /** Unique key for React list rendering and deduplication */ + key: string; + /** Translation key resolved via t() in the views/settings namespace */ + messageKey: string; + /** Severity level controlling visual styling */ + severity: MessageSeverity; + /** Function returning true when the message should be shown */ + condition: (ctx: MessageConditionContext) => boolean; +}; + +/** Field-level conditional message, adds field targeting */ +export type FieldConditionalMessage = ConditionalMessage & { + /** Dot-separated field path (e.g., "enabled", "alerts.labels") */ + field: string; + /** Whether to render before or after the field (default: "before") */ + position?: "before" | "after"; +}; + export type SectionConfigOverrides = { base?: SectionConfig; global?: Partial; diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 8a35f4dff..96bd3efa9 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -71,6 +71,13 @@ import { } from "@/utils/configUtil"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import { useRestart } from "@/api/ws"; +import type { + ConditionalMessage, + FieldConditionalMessage, + MessageConditionContext, +} from "../section-configs/types"; +import { useConfigMessages } from "@/hooks/use-config-messages"; +import { ConfigMessageBanner } from "../ConfigMessageBanner"; export interface SectionConfig { /** Field ordering within the section */ @@ -100,6 +107,10 @@ export interface SectionConfig { formData: unknown, errors: FormValidation, ) => FormValidation; + /** Conditional messages displayed as banners above the section form */ + messages?: ConditionalMessage[]; + /** Conditional messages displayed inline with specific fields */ + fieldMessages?: FieldConditionalMessage[]; } export interface BaseSectionProps { @@ -536,6 +547,65 @@ export function ConfigSection({ const currentFormData = pendingData || formData; const effectiveBaselineFormData = baselineSnapshot; + // Build context for conditional messages + const messageContext = useMemo(() => { + if (!config || !currentFormData) return undefined; + return { + fullConfig: config, + fullCameraConfig: + effectiveLevel === "camera" && cameraName + ? config.cameras?.[cameraName] + : undefined, + level: effectiveLevel, + cameraName, + formData: currentFormData as ConfigSectionData, + }; + }, [config, currentFormData, effectiveLevel, cameraName]); + + const { activeMessages, activeFieldMessages } = useConfigMessages( + sectionConfig.messages, + sectionConfig.fieldMessages, + messageContext, + ); + + // Merge field-level conditional messages into uiSchema + const effectiveUiSchema = useMemo(() => { + if (activeFieldMessages.length === 0) return sectionConfig.uiSchema; + const merged = { ...(sectionConfig.uiSchema ?? {}) }; + for (const msg of activeFieldMessages) { + const segments = msg.field.split("."); + // Navigate to the nested uiSchema node, shallow-cloning along the way + let node = merged; + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]; + node[seg] = { ...(node[seg] as Record) }; + node = node[seg] as Record; + } + const leafKey = segments[segments.length - 1]; + const existing = node[leafKey] as Record | undefined; + const existingMessages = ((existing?.["ui:messages"] as unknown[]) ?? + []) as Array<{ + key: string; + messageKey: string; + severity: string; + position?: string; + }>; + node[leafKey] = { + ...existing, + "ui:messages": [ + ...existingMessages, + { + key: msg.key, + messageKey: msg.messageKey, + severity: msg.severity, + position: msg.position ?? "before", + }, + ], + }; + } + return merged; + }, [sectionConfig.uiSchema, activeFieldMessages]); + const currentOverrides = useMemo(() => { if (!currentFormData || typeof currentFormData !== "object") { return undefined; @@ -874,6 +944,7 @@ export function ConfigSection({ const sectionContent = (
+ + | undefined; + const beforeMessages = fieldMessageSpecs?.filter( + (m) => (m.position ?? "before") === "before", + ); + const afterMessages = fieldMessageSpecs?.filter( + (m) => m.position === "after", + ); + const beforeMessagesContent = + beforeMessages && beforeMessages.length > 0 ? ( +
+ {beforeMessages.map((m) => ( + + ))} +
+ ) : null; + const afterMessagesContent = + afterMessages && afterMessages.length > 0 ? ( +
+ {afterMessages.map((m) => ( + + ))} +
+ ) : null; const WrapIfAdditionalTemplate = getTemplate( "WrapIfAdditionalTemplate", registry, @@ -600,6 +641,7 @@ export function FieldTemplate(props: FieldTemplateProps) { >
{beforeContent} + {beforeMessagesContent}
{renderStandardLabel()} {renderFieldLayout()} @@ -607,6 +649,7 @@ export function FieldTemplate(props: FieldTemplateProps) { {errors} {help}
+ {afterMessagesContent} {afterContent}
diff --git a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx index a7351c8b7..46fb5345e 100644 --- a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx @@ -45,8 +45,6 @@ export type SwitchesWidgetOptions = { enableSearch?: boolean; /** Allow users to add custom entries not in the predefined list */ allowCustomEntries?: boolean; - /** i18n key for a hint shown when no entities are selected */ - emptySelectionHintKey?: string; }; function normalizeValue(value: unknown): string[] { @@ -131,11 +129,6 @@ export function SwitchesWidget(props: WidgetProps) { [props.options], ); - const emptySelectionHintKey = useMemo( - () => props.options?.emptySelectionHintKey as string | undefined, - [props.options], - ); - const selectedEntities = useMemo(() => normalizeValue(value), [value]); const [isOpen, setIsOpen] = useState(selectedEntities.length > 0); const [searchTerm, setSearchTerm] = useState(""); @@ -215,12 +208,6 @@ export function SwitchesWidget(props: WidgetProps) { - {emptySelectionHintKey && selectedEntities.length === 0 && t && ( -
- {t(emptySelectionHintKey, { ns: namespace })} -
- )} - {allEntities.length === 0 && !allowCustomEntries ? (
{emptyMessage}
diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx index 32005241a..5a3d48b1d 100644 --- a/web/src/components/ui/alert.tsx +++ b/web/src/components/ui/alert.tsx @@ -11,6 +11,9 @@ const alertVariants = cva( default: "bg-background text-foreground", destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + warning: + "border-amber-500/50 bg-amber-50 text-amber-800 dark:bg-amber-950/20 dark:text-amber-400 dark:border-amber-500/30 [&>svg]:text-amber-600 dark:[&>svg]:text-amber-400", + info: "border-blue-500/50 bg-blue-50 text-blue-800 dark:bg-blue-950/20 dark:text-blue-400 dark:border-blue-500/30 [&>svg]:text-blue-600 dark:[&>svg]:text-blue-400", }, }, defaultVariants: { diff --git a/web/src/hooks/use-config-messages.ts b/web/src/hooks/use-config-messages.ts new file mode 100644 index 000000000..6c2b43853 --- /dev/null +++ b/web/src/hooks/use-config-messages.ts @@ -0,0 +1,27 @@ +import { useMemo } from "react"; +import type { + ConditionalMessage, + FieldConditionalMessage, + MessageConditionContext, +} from "@/components/config-form/section-configs/types"; + +export function useConfigMessages( + messages: ConditionalMessage[] | undefined, + fieldMessages: FieldConditionalMessage[] | undefined, + context: MessageConditionContext | undefined, +): { + activeMessages: ConditionalMessage[]; + activeFieldMessages: FieldConditionalMessage[]; +} { + const activeMessages = useMemo(() => { + if (!messages || !context) return []; + return messages.filter((msg) => msg.condition(context)); + }, [messages, context]); + + const activeFieldMessages = useMemo(() => { + if (!fieldMessages || !context) return []; + return fieldMessages.filter((msg) => msg.condition(context)); + }, [fieldMessages, context]); + + return { activeMessages, activeFieldMessages }; +}