From c35cee2d2f35df4f7fb34ef8c9e5090322736af5 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:45:50 -0500 Subject: [PATCH] Review labels widget (#22664) * add review labels widget * register widget and add to review section * i18n * add border to switches widget * padding tweaks * don't show audio labels if audio is not enabled * add docs links * ability to add custom labels to review * add hint for empty selection in review labels and SwitchesWidget * language consistency --- frigate/config/camera/review.py | 2 +- web/public/locales/en/config/cameras.json | 2 +- web/public/locales/en/config/global.json | 2 +- web/public/locales/en/views/settings.json | 8 +- .../config-form/section-configs/review.ts | 20 ++++- .../config-form/theme/frigateTheme.ts | 2 + .../widgets/ReviewLabelSwitchesWidget.tsx | 84 +++++++++++++++++++ .../theme/widgets/SwitchesWidget.tsx | 75 +++++++++++++++-- 8 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 web/src/components/config-form/theme/widgets/ReviewLabelSwitchesWidget.tsx diff --git a/frigate/config/camera/review.py b/frigate/config/camera/review.py index ff07fb368..fbe24c98c 100644 --- a/frigate/config/camera/review.py +++ b/frigate/config/camera/review.py @@ -188,7 +188,7 @@ class ReviewConfig(FrigateBaseModel): detections: DetectionsConfig = Field( default_factory=DetectionsConfig, title="Detections config", - description="Settings for creating detection events (non-alert) and how long to keep them.", + description="Settings for which tracked objects generate detections (non-alert) and how detections are retained.", ) genai: GenAIReviewConfig = Field( default_factory=GenAIReviewConfig, diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json index 470af687e..1b524c347 100644 --- a/web/public/locales/en/config/cameras.json +++ b/web/public/locales/en/config/cameras.json @@ -529,7 +529,7 @@ }, "detections": { "label": "Detections config", - "description": "Settings for creating detection events (non-alert) and how long to keep them.", + "description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.", "enabled": { "label": "Enable detections", "description": "Enable or disable detection events for this camera." diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index e5235f9cb..318d3a8c9 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -1044,7 +1044,7 @@ }, "detections": { "label": "Detections config", - "description": "Settings for creating detection events (non-alert) and how long to keep them.", + "description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.", "enabled": { "label": "Enable detections", "description": "Enable or disable detection events for all cameras; can be overridden per-camera." diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index d9abb427d..06d9279f7 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1431,6 +1431,11 @@ "summary": "{{count}} object types selected", "empty": "No object labels available" }, + "reviewLabels": { + "summary": "{{count}} labels selected", + "empty": "No labels available", + "allNonAlertDetections": "All non-alert activity will be included as detections." + }, "filters": { "objectFieldLabel": "{{field}} for {{label}}" }, @@ -1474,7 +1479,8 @@ "timestamp_style": { "title": "Timestamp Settings" }, - "searchPlaceholder": "Search..." + "searchPlaceholder": "Search...", + "addCustomLabel": "Add custom label..." }, "globalConfig": { "title": "Global Configuration", diff --git a/web/src/components/config-form/section-configs/review.ts b/web/src/components/config-form/section-configs/review.ts index d1909e926..e9e3169d7 100644 --- a/web/src/components/config-form/section-configs/review.ts +++ b/web/src/components/config-form/section-configs/review.ts @@ -3,14 +3,16 @@ import type { SectionConfigOverrides } from "./types"; const review: SectionConfigOverrides = { base: { sectionDocs: "/configuration/review", + fieldDocs: { + "alerts.labels": "/configuration/review/#alerts-and-detections", + "detections.labels": "/configuration/review/#alerts-and-detections", + }, restartRequired: [], fieldOrder: ["alerts", "detections", "genai"], fieldGroups: {}, hiddenFields: [ "enabled_in_config", - "alerts.labels", "alerts.enabled_in_config", - "detections.labels", "detections.enabled_in_config", "genai.enabled_in_config", ], @@ -18,11 +20,25 @@ const review: SectionConfigOverrides = { uiSchema: { alerts: { "ui:before": { render: "CameraReviewStatusToggles" }, + labels: { + "ui:widget": "reviewLabels", + "ui:options": { + suppressMultiSchema: true, + }, + }, required_zones: { "ui:widget": "hidden", }, }, detections: { + labels: { + "ui:widget": "reviewLabels", + "ui:options": { + suppressMultiSchema: true, + emptySelectionHintKey: + "configForm.reviewLabels.allNonAlertDetections", + }, + }, required_zones: { "ui:widget": "hidden", }, diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 5497e35b7..f1c241c1d 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -20,6 +20,7 @@ import { TextareaWidget } from "./widgets/TextareaWidget"; import { SwitchesWidget } from "./widgets/SwitchesWidget"; import { ObjectLabelSwitchesWidget } from "./widgets/ObjectLabelSwitchesWidget"; import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget"; +import { ReviewLabelSwitchesWidget } from "./widgets/ReviewLabelSwitchesWidget"; import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget"; import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget"; import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget"; @@ -76,6 +77,7 @@ export const frigateTheme: FrigateTheme = { switches: SwitchesWidget, objectLabels: ObjectLabelSwitchesWidget, audioLabels: AudioLabelSwitchesWidget, + reviewLabels: ReviewLabelSwitchesWidget, zoneNames: ZoneSwitchesWidget, timezoneSelect: TimezoneSelectWidget, optionalField: OptionalFieldWidget, diff --git a/web/src/components/config-form/theme/widgets/ReviewLabelSwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/ReviewLabelSwitchesWidget.tsx new file mode 100644 index 000000000..857450534 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/ReviewLabelSwitchesWidget.tsx @@ -0,0 +1,84 @@ +// Review Label Switches Widget - For selecting review alert/detection labels via switches. +// Combines object labels (from objects.track) and audio labels (from audio.listen) +// since review labels can include both types. +import type { WidgetProps } from "@rjsf/utils"; +import { SwitchesWidget } from "./SwitchesWidget"; +import type { FormContext } from "./SwitchesWidget"; +import { getTranslatedLabel } from "@/utils/i18n"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import type { JsonObject } from "@/types/configForm"; + +function getReviewLabels(context: FormContext): string[] { + const labels = new Set(); + const fullConfig = context.fullConfig as FrigateConfig | undefined; + const fullCameraConfig = context.fullCameraConfig; + + // Object labels from tracked objects (camera-level, falling back to global) + const trackLabels = + fullCameraConfig?.objects?.track ?? fullConfig?.objects?.track; + if (Array.isArray(trackLabels)) { + trackLabels.forEach((label: string) => labels.add(label)); + } + + // Audio labels from listen config, only if audio detection is enabled + const audioEnabled = + fullCameraConfig?.audio?.enabled_in_config ?? + fullConfig?.audio?.enabled_in_config; + if (audioEnabled) { + const audioLabels = + fullCameraConfig?.audio?.listen ?? fullConfig?.audio?.listen; + if (Array.isArray(audioLabels)) { + audioLabels.forEach((label: string) => labels.add(label)); + } + } + + // Include any labels already in the review form data (alerts + detections) + // so that previously saved labels remain visible even if tracking config changed + if (context.formData && typeof context.formData === "object") { + const formData = context.formData as JsonObject; + for (const section of ["alerts", "detections"] as const) { + const sectionData = formData[section]; + if (sectionData && typeof sectionData === "object") { + const sectionLabels = (sectionData as JsonObject).labels; + if (Array.isArray(sectionLabels)) { + sectionLabels.forEach((label) => { + if (typeof label === "string") { + labels.add(label); + } + }); + } + } + } + } + + return [...labels].sort(); +} + +function getReviewLabelDisplayName( + label: string, + context?: FormContext, +): string { + const fullCameraConfig = context?.fullCameraConfig; + const fullConfig = context?.fullConfig as FrigateConfig | undefined; + const audioLabels = + fullCameraConfig?.audio?.listen ?? fullConfig?.audio?.listen; + const isAudio = Array.isArray(audioLabels) && audioLabels.includes(label); + return getTranslatedLabel(label, isAudio ? "audio" : "object"); +} + +export function ReviewLabelSwitchesWidget(props: WidgetProps) { + return ( + + ); +} diff --git a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx index 272629a1a..a7351c8b7 100644 --- a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx @@ -1,6 +1,6 @@ // Generic Switches Widget - Reusable component for selecting from any list of entities import { WidgetProps } from "@rjsf/utils"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -43,6 +43,10 @@ export type SwitchesWidgetOptions = { listClassName?: string; /** Enable search input to filter the list */ 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[] { @@ -122,20 +126,51 @@ export function SwitchesWidget(props: WidgetProps) { [props.options], ); + const allowCustomEntries = useMemo( + () => props.options?.allowCustomEntries as boolean | undefined, + [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(""); + const [customEntries, setCustomEntries] = useState([]); + const [customInput, setCustomInput] = useState(""); + + const allEntities = useMemo(() => { + if (customEntries.length === 0) { + return availableEntities; + } + const merged = new Set([...availableEntities, ...customEntries]); + return [...merged].sort(); + }, [availableEntities, customEntries]); const filteredEntities = useMemo(() => { if (!enableSearch || !searchTerm.trim()) { - return availableEntities; + return allEntities; } const term = searchTerm.toLowerCase(); - return availableEntities.filter((entity) => { + return allEntities.filter((entity) => { const displayLabel = getDisplayLabel(entity, context); return displayLabel.toLowerCase().includes(term); }); - }, [availableEntities, searchTerm, enableSearch, getDisplayLabel, context]); + }, [allEntities, searchTerm, enableSearch, getDisplayLabel, context]); + + const addCustomEntry = useCallback(() => { + const trimmed = customInput.trim().toLowerCase(); + if (!trimmed || allEntities.includes(trimmed)) { + setCustomInput(""); + return; + } + setCustomEntries((prev) => [...prev, trimmed]); + onChange([...selectedEntities, trimmed]); + setCustomInput(""); + }, [customInput, allEntities, selectedEntities, onChange]); const toggleEntity = (entity: string, enabled: boolean) => { if (enabled) { @@ -163,7 +198,7 @@ export function SwitchesWidget(props: WidgetProps) { return ( -
+
+ {allowCustomEntries && !disabled && !readonly && ( +
+ setCustomInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addCustomEntry(); + } + }} + onBlur={addCustomEntry} + /> +
+ )} )}