From b956d942a14937560bd32f8f8cf44f7e01c9547f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:36:40 -0500 Subject: [PATCH 01/13] fix mobile export crash by removing stale iOS non-modal drawer workaround --- web/src/components/overlay/MobileReviewSettingsDrawer.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 77cb8e3f4..4a54bb142 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -23,7 +23,7 @@ import { GeneralFilterContent } from "../filter/ReviewFilterGroup"; import { toast } from "sonner"; import axios, { AxiosError } from "axios"; import SaveExportOverlay from "./SaveExportOverlay"; -import { isIOS, isMobile } from "react-device-detect"; +import { isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -505,7 +505,6 @@ export default function MobileReviewSettingsDrawer({ setShowPreview={setShowExportPreview} /> { if (!open) { From eb68a9978d9fe334c706cffff2ab0980ea8e3c25 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 6 Apr 2026 11:02:28 -0600 Subject: [PATCH 02/13] Remove titlecase to avoid Gemma4 handling plain labels as proper nouns --- frigate/data_processing/post/review_descriptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/data_processing/post/review_descriptions.py b/frigate/data_processing/post/review_descriptions.py index 4bb2deac2..aebf1d6a5 100644 --- a/frigate/data_processing/post/review_descriptions.py +++ b/frigate/data_processing/post/review_descriptions.py @@ -556,7 +556,7 @@ def run_analysis( if "-verified" in label: continue elif label in labelmap_objects: - object_type = titlecase(label.replace("_", " ")) + object_type = label.replace("_", " ") if label in attribute_labels: unified_objects.append(f"{object_type} (delivery/service)") From bc7f4eacc893310f3519fe4fd4621fb5a5efd02d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 6 Apr 2026 11:55:00 -0600 Subject: [PATCH 03/13] Improve titling: --- frigate/genai/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index d80e53b2e..bc281aaef 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -190,6 +190,7 @@ Each line represents a detection state, not necessarily unique individuals. The if any("←" in obj for obj in review_data["unified_objects"]): metadata.potential_threat_level = 0 + metadata.title = metadata.title[0].upper() + metadata.title[1:] metadata.time = review_data["start"] return metadata except Exception as e: From 26c70922836e4a8a712e1f17cfb6945155f87f83 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 6 Apr 2026 12:23:30 -0600 Subject: [PATCH 04/13] Make directions more clear --- frigate/genai/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index bc281aaef..d95dd2cae 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -106,8 +106,8 @@ When forming your description: ## Response Field Guidelines Respond with a JSON object matching the provided schema. Field-specific guidance: -- `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. Always use subject names from "Objects in Scene" — do not replace named subjects with generic terms like "a person" or "the individual". Your description should align with and support the threat level you assign. -- `title`: Characterize **what took place and where** — interpret the overall purpose or outcome, do not simply compress the scene description into fewer words. Include the relevant location (zone, area, or entry point). Always include subject names from "Objects in Scene" — do not replace named subjects with generic terms. No editorial qualifiers like "routine" or "suspicious." +- `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign. +- `title`: Characterize **what took place and where** — interpret the overall purpose or outcome, do not simply compress the scene description into fewer words. Include the relevant location (zone, area, or entry point). For named subjects, always use their name. For unnamed objects, refer to them naturally with articles. No editorial qualifiers like "routine" or "suspicious." - `potential_threat_level`: Must be consistent with your scene description and the activity patterns above. {get_concern_prompt()} From 9e0e4162f987fb8cf5d4406bf33ec6295246d5f5 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 6 Apr 2026 12:27:17 -0600 Subject: [PATCH 05/13] Properly capitalize delivery services --- frigate/const.py | 16 ++++++++++++++++ .../data_processing/post/review_descriptions.py | 10 ++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/frigate/const.py b/frigate/const.py index 6b1e227d5..51e06e4ad 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -44,6 +44,22 @@ DEFAULT_ATTRIBUTE_LABEL_MAP = { ], "motorcycle": ["license_plate"], } +ATTRIBUTE_LABEL_DISPLAY_MAP = { + "amazon": "Amazon", + "an_post": "An Post", + "canada_post": "Canada Post", + "dhl": "DHL", + "dpd": "DPD", + "fedex": "FedEx", + "gls": "GLS", + "nzpost": "NZ Post", + "postnl": "PostNL", + "postnord": "PostNord", + "purolator": "Purolator", + "royal_mail": "Royal Mail", + "ups": "UPS", + "usps": "USPS", +} LABEL_CONSOLIDATION_MAP = { "car": 0.8, "face": 0.5, diff --git a/frigate/data_processing/post/review_descriptions.py b/frigate/data_processing/post/review_descriptions.py index aebf1d6a5..536b57f3c 100644 --- a/frigate/data_processing/post/review_descriptions.py +++ b/frigate/data_processing/post/review_descriptions.py @@ -19,7 +19,12 @@ from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig from frigate.config.camera import CameraConfig from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum -from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION +from frigate.const import ( + ATTRIBUTE_LABEL_DISPLAY_MAP, + CACHE_DIR, + CLIPS_DIR, + UPDATE_REVIEW_DESCRIPTION, +) from frigate.data_processing.types import PostProcessDataEnum from frigate.genai import GenAIClient from frigate.genai.manager import GenAIClientManager @@ -559,7 +564,8 @@ def run_analysis( object_type = label.replace("_", " ") if label in attribute_labels: - unified_objects.append(f"{object_type} (delivery/service)") + display_name = ATTRIBUTE_LABEL_DISPLAY_MAP.get(label, object_type) + unified_objects.append(f"{display_name} (delivery/service)") else: unified_objects.append(object_type) From 7c0e5a8f17e798760a3eafb6225c19b5754af5ee Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:17:36 -0500 Subject: [PATCH 06/13] update dispatcher config reference on save --- frigate/api/app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frigate/api/app.py b/frigate/api/app.py index af6778451..57d1f0a79 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -694,6 +694,9 @@ def config_set(request: Request, body: AppConfigSetBody): if request.app.stats_emitter is not None: request.app.stats_emitter.config = config + if request.app.dispatcher is not None: + request.app.dispatcher.config = config + if body.update_topic: if body.update_topic.startswith("config/cameras/"): _, _, camera, field = body.update_topic.split("/") From f33b07bc907c512178833de32d18e91f29d7e79c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:18:14 -0500 Subject: [PATCH 07/13] subscribe to review topic so ReviewDescriptionProcessor knows genai is enabled --- frigate/embeddings/maintainer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 9247f4fb4..3f066a860 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -92,6 +92,7 @@ class EmbeddingMaintainer(threading.Thread): CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove, CameraConfigUpdateEnum.object_genai, + CameraConfigUpdateEnum.review, CameraConfigUpdateEnum.review_genai, CameraConfigUpdateEnum.semantic_search, ], From 23aa21ea82c7f9a761f30c6351f48d247afe5e05 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:18:52 -0500 Subject: [PATCH 08/13] auto-send ON genai review WS message when enabled_in_config transitions to true --- .../CameraReviewStatusToggles.tsx | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx b/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx index f09cc5406..c5eb83489 100644 --- a/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx +++ b/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import useSWR from "swr"; import { Trans } from "react-i18next"; import Heading from "@/components/ui/heading"; @@ -37,6 +37,34 @@ export default function CameraReviewStatusToggles({ const { payload: revDescState, send: sendRevDesc } = useReviewDescriptionState(cameraId); + // Sync WS runtime state when genai transitions from disabled to enabled in config + const prevObjGenaiEnabled = useRef( + cameraConfig?.objects?.genai?.enabled_in_config, + ); + const prevRevGenaiEnabled = useRef( + cameraConfig?.review?.genai?.enabled_in_config, + ); + + useEffect(() => { + const wasEnabled = prevObjGenaiEnabled.current; + const isEnabled = cameraConfig?.objects?.genai?.enabled_in_config; + prevObjGenaiEnabled.current = isEnabled; + + if (!wasEnabled && isEnabled) { + sendObjDesc("ON"); + } + }, [cameraConfig?.objects?.genai?.enabled_in_config, sendObjDesc]); + + useEffect(() => { + const wasEnabled = prevRevGenaiEnabled.current; + const isEnabled = cameraConfig?.review?.genai?.enabled_in_config; + prevRevGenaiEnabled.current = isEnabled; + + if (!wasEnabled && isEnabled) { + sendRevDesc("ON"); + } + }, [cameraConfig?.review?.genai?.enabled_in_config, sendRevDesc]); + if (!selectedCamera || !cameraConfig) { return null; } From b83f56d4b0508a363fece09cfb45e00ddbf91ad1 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:36:22 -0500 Subject: [PATCH 09/13] remove unused object level --- .../sectionExtras/CameraReviewStatusToggles.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx b/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx index c5eb83489..74c237c3b 100644 --- a/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx +++ b/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx @@ -37,24 +37,11 @@ export default function CameraReviewStatusToggles({ const { payload: revDescState, send: sendRevDesc } = useReviewDescriptionState(cameraId); - // Sync WS runtime state when genai transitions from disabled to enabled in config - const prevObjGenaiEnabled = useRef( - cameraConfig?.objects?.genai?.enabled_in_config, - ); + // Sync WS runtime state when review genai transitions from disabled to enabled in config const prevRevGenaiEnabled = useRef( cameraConfig?.review?.genai?.enabled_in_config, ); - useEffect(() => { - const wasEnabled = prevObjGenaiEnabled.current; - const isEnabled = cameraConfig?.objects?.genai?.enabled_in_config; - prevObjGenaiEnabled.current = isEnabled; - - if (!wasEnabled && isEnabled) { - sendObjDesc("ON"); - } - }, [cameraConfig?.objects?.genai?.enabled_in_config, sendObjDesc]); - useEffect(() => { const wasEnabled = prevRevGenaiEnabled.current; const isEnabled = cameraConfig?.review?.genai?.enabled_in_config; From c7a10c93bed87fea86c1e8fdcf72608f5854b79f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:03:45 -0500 Subject: [PATCH 10/13] update docs to clarify pre/post capture settings --- docs/docs/configuration/record.md | 70 +++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index d98f51491..043a9d0af 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -123,6 +123,76 @@ record: +## Pre-capture and Post-capture + +The `pre_capture` and `post_capture` settings control how many seconds of video are included before and after an alert or detection. These can be configured independently for alerts and detections, and can be set globally or overridden per camera. + + + + +Navigate to for global defaults, or to override for a specific camera. + +| Field | Description | +| ---------------------------------------------- | ---------------------------------------------------- | +| **Alert retention > Pre-capture seconds** | Seconds of video to include before an alert event | +| **Alert retention > Post-capture seconds** | Seconds of video to include after an alert event | +| **Detection retention > Pre-capture seconds** | Seconds of video to include before a detection event | +| **Detection retention > Post-capture seconds** | Seconds of video to include after a detection event | + + + + +```yaml +record: + enabled: True + alerts: + pre_capture: 5 # seconds before the alert to include + post_capture: 5 # seconds after the alert to include + detections: + pre_capture: 5 # seconds before the detection to include + post_capture: 5 # seconds after the detection to include +``` + + + + +- **Default**: 5 seconds for both pre and post capture. +- **Pre-capture maximum**: 60 seconds. +- These settings apply per review category (alerts and detections), not per object type. + +### How pre/post capture interacts with retention mode + +The `pre_capture` and `post_capture` values define the **time window** around a review item, but only recording segments that also match the configured **retention mode** are actually kept on disk. + +- **`mode: all`** — Retains every segment within the capture window, regardless of whether motion was detected. +- **`mode: motion`** (default) — Only retains segments within the capture window that contain motion. This includes segments with active tracked objects, since object motion implies motion. Segments without any motion are discarded even if they fall within the pre/post capture range. +- **`mode: active_objects`** — Only retains segments within the capture window where tracked objects were actively moving. Segments with general motion but no active objects are discarded. + +This means that with the default `motion` mode, you may see less footage than the configured pre/post capture duration if parts of the capture window had no motion. + +To guarantee the full pre/post capture duration is always retained: + +```yaml +record: + enabled: True + alerts: + pre_capture: 10 + post_capture: 10 + retain: + days: 30 + mode: all # retains all segments within the capture window +``` + +:::note + +Because recording segments are written in 10 second chunks, pre-capture timing depends on segment boundaries. The actual pre-capture footage may be slightly shorter or longer than the exact configured value. + +::: + +### Where to view pre/post capture footage + +Pre and post capture footage is included in the **recording timeline**, visible in the History view. Note that pre/post capture settings only affect which recording segments are **retained on disk** — they do not change the start and end points shown in the UI. The History view will still center on the review item's actual time range, but you can scrub backward and forward through the retained pre/post capture footage on the timeline. The Explore view shows object-specific clips that are trimmed to when the tracked object was actually visible, so pre/post capture time will not be reflected there. + ## Will Frigate delete old recordings if my storage runs out? As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted. From 9962f8508f1eb1317a9e5aba97f6147f8494a66f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:03:56 -0500 Subject: [PATCH 11/13] add ui docs links --- .../components/config-form/section-configs/record.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index 35f3b1ef7..89b1232bf 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -16,6 +16,16 @@ const record: SectionConfigOverrides = { }, }, ], + fieldDocs: { + "alerts.pre_capture": + "/configuration/record#pre-capture-and-post-capture", + "alerts.post_capture": + "/configuration/record#pre-capture-and-post-capture", + "detections.pre_capture": + "/configuration/record#pre-capture-and-post-capture", + "detections.post_capture": + "/configuration/record#pre-capture-and-post-capture", + }, restartRequired: [], fieldOrder: [ "enabled", From 8de6f2b4cfca5389dc500f0afd3b44648f9163ba Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:06:18 -0500 Subject: [PATCH 12/13] improve known_plates field in settings UI --- web/public/locales/en/views/settings.json | 4 + .../config-form/section-configs/lpr.ts | 7 + .../theme/fields/KnownPlatesField.tsx | 277 ++++++++++++++++++ .../config-form/theme/frigateTheme.ts | 2 + 4 files changed, 290 insertions(+) create mode 100644 web/src/components/config-form/theme/fields/KnownPlatesField.tsx diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index a151e9ca9..a1e14452e 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1326,6 +1326,10 @@ "keyPlaceholder": "New key", "remove": "Remove" }, + "knownPlates": { + "namePlaceholder": "e.g., Wife's Car", + "platePlaceholder": "Plate number or regex" + }, "timezone": { "defaultOption": "Use browser timezone" }, diff --git a/web/src/components/config-form/section-configs/lpr.ts b/web/src/components/config-form/section-configs/lpr.ts index 4997d766f..0567c6cf4 100644 --- a/web/src/components/config-form/section-configs/lpr.ts +++ b/web/src/components/config-form/section-configs/lpr.ts @@ -67,6 +67,13 @@ const lpr: SectionConfigOverrides = { format: { "ui:options": { size: "md" }, }, + known_plates: { + "ui:field": "KnownPlatesField", + "ui:options": { + label: false, + suppressDescription: true, + }, + }, replace_rules: { "ui:field": "ReplaceRulesField", "ui:options": { diff --git a/web/src/components/config-form/theme/fields/KnownPlatesField.tsx b/web/src/components/config-form/theme/fields/KnownPlatesField.tsx new file mode 100644 index 000000000..f710dcd11 --- /dev/null +++ b/web/src/components/config-form/theme/fields/KnownPlatesField.tsx @@ -0,0 +1,277 @@ +import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { + LuChevronDown, + LuChevronRight, + LuPlus, + LuTrash2, +} from "react-icons/lu"; +import type { ConfigFormContext } from "@/types/configForm"; +import get from "lodash/get"; +import { isSubtreeModified } from "../utils"; + +type KnownPlatesData = Record; + +export function KnownPlatesField(props: FieldProps) { + const { schema, formData, onChange, idSchema, disabled, readonly } = props; + const formContext = props.registry?.formContext as + | ConfigFormContext + | undefined; + + const { t } = useTranslation(["views/settings", "common"]); + + const data: KnownPlatesData = useMemo(() => { + if (!formData || typeof formData !== "object" || Array.isArray(formData)) { + return {}; + } + return formData as KnownPlatesData; + }, [formData]); + + const entries = useMemo(() => Object.entries(data), [data]); + + const title = (schema as RJSFSchema).title; + const description = (schema as RJSFSchema).description; + + const hasItems = entries.length > 0; + const emptyPath = useMemo(() => [] as FieldPathList, []); + const fieldPath = + (props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ?? + emptyPath; + + const isModified = useMemo(() => { + const baselineRoot = formContext?.baselineFormData; + const baselineValue = baselineRoot + ? get(baselineRoot, fieldPath) + : undefined; + return isSubtreeModified( + data, + baselineValue, + formContext?.overrides, + fieldPath, + formContext?.formData, + ); + }, [fieldPath, formContext, data]); + + const [open, setOpen] = useState(hasItems || isModified); + + useEffect(() => { + if (isModified) { + setOpen(true); + } + }, [isModified]); + + useEffect(() => { + if (hasItems) { + setOpen(true); + } + }, [hasItems]); + + const handleAddEntry = useCallback(() => { + const next = { ...data, "": [""] }; + onChange(next, fieldPath); + }, [data, fieldPath, onChange]); + + const handleRemoveEntry = useCallback( + (key: string) => { + const next = { ...data }; + delete next[key]; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleRenameKey = useCallback( + (oldKey: string, newKey: string) => { + if (oldKey === newKey) return; + // Preserve order by rebuilding the object + const next: KnownPlatesData = {}; + for (const [k, v] of Object.entries(data)) { + if (k === oldKey) { + next[newKey] = v; + } else { + next[k] = v; + } + } + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleAddPlate = useCallback( + (key: string) => { + const next = { ...data, [key]: [...(data[key] || []), ""] }; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleRemovePlate = useCallback( + (key: string, plateIndex: number) => { + const plates = [...(data[key] || [])]; + plates.splice(plateIndex, 1); + const next = { ...data, [key]: plates }; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleUpdatePlate = useCallback( + (key: string, plateIndex: number, value: string) => { + const plates = [...(data[key] || [])]; + plates[plateIndex] = value; + const next = { ...data, [key]: plates }; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const baseId = idSchema?.$id || "known_plates"; + const deleteLabel = t("button.delete", { + ns: "common", + defaultValue: "Delete", + }); + const namePlaceholder = t("configForm.knownPlates.namePlaceholder", { + ns: "views/settings", + }); + const platePlaceholder = t("configForm.knownPlates.platePlaceholder", { + ns: "views/settings", + }); + return ( + + + + +
+
+ + {title} + + {description && ( +

+ {description} +

+ )} +
+ {open ? ( + + ) : ( + + )} +
+
+
+ + + + {entries.map(([key, plates], entryIndex) => { + const entryId = `${baseId}-${entryIndex}`; + + return ( +
+
+ handleRenameKey(key, e.target.value)} + className="flex-1" + /> + +
+ +
+ {plates.map((plate, plateIndex) => ( +
+ + handleUpdatePlate(key, plateIndex, e.target.value) + } + className="flex-1" + /> + {plates.length > 1 && ( + + )} +
+ ))} + +
+
+ ); + })} + +
+ +
+
+
+
+
+ ); +} + +export default KnownPlatesField; diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index b7d619fe0..ae612d9ac 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -49,6 +49,7 @@ import { DetectorHardwareField } from "./fields/DetectorHardwareField"; import { ReplaceRulesField } from "./fields/ReplaceRulesField"; import { CameraInputsField } from "./fields/CameraInputsField"; import { DictAsYamlField } from "./fields/DictAsYamlField"; +import { KnownPlatesField } from "./fields/KnownPlatesField"; export interface FrigateTheme { widgets: RegistryWidgetsType; @@ -105,5 +106,6 @@ export const frigateTheme: FrigateTheme = { ReplaceRulesField: ReplaceRulesField, CameraInputsField: CameraInputsField, DictAsYamlField: DictAsYamlField, + KnownPlatesField: KnownPlatesField, }, }; From 88ab136f8ed56ce4667b076c9cd71ab273e2a318 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:06:54 -0500 Subject: [PATCH 13/13] only show save all when multiple sections are changed or if the section being changed is not currently being viewed --- web/src/pages/Settings.tsx | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 5f119a86c..c74191957 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -818,6 +818,27 @@ export default function Settings() { [], ); + // Show save/undo all buttons only when changes span multiple sections + // or the single changed section is not the one currently being viewed + const showSaveAllButtons = useMemo(() => { + const pendingKeys = Object.keys(pendingDataBySection); + if (pendingKeys.length === 0) return false; + if (pendingKeys.length >= 2) return true; + + // Exactly one pending section — check if it matches the current view + const key = pendingKeys[0]; + const menuKey = pendingKeyToMenuKey(key); + if (menuKey !== pageToggle) return true; + + // For camera-scoped keys, also check if the camera matches + if (key.includes("::")) { + const cameraName = key.slice(0, key.indexOf("::")); + return cameraName !== selectedCamera; + } + + return false; + }, [pendingDataBySection, pendingKeyToMenuKey, pageToggle, selectedCamera]); + const handleSaveAll = useCallback(async () => { if ( !config || @@ -1491,7 +1512,7 @@ export default function Settings() { ); })} - {hasPendingChanges && ( + {showSaveAllButtons && (
@@ -1667,7 +1688,7 @@ export default function Settings() {
- {hasPendingChanges && ( + {showSaveAllButtons && (