From d556ff8df2dcdb1113a2931f88218e5c73f2f43f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 22 May 2026 14:41:07 -0500 Subject: [PATCH] Tweaks (#23292) * add review padding to explore debug replay api calls * add semantic search model size widget disables model_size select with n/a text when an embeddings genai provider is selected * regenerate zone contours and per-zone filter masks on detect resolution change * treat null as a clear sentinel in buildOverrides so nullable field edits don't snap back * extract replay config sheet to new component * add validation and messages for detect settings --- frigate/api/app.py | 7 + frigate/util/config.py | 11 ++ web/public/locales/en/config/validation.json | 3 + web/public/locales/en/views/settings.json | 10 +- .../config-form/LiveFormDataContext.ts | 13 ++ .../config-form/section-configs/detect.ts | 44 +++++++ .../section-configs/semantic_search.ts | 17 +-- .../config-form/section-validations/detect.ts | 36 ++++++ .../config-form/section-validations/index.ts | 5 + .../config-form/sections/BaseSection.tsx | 111 ++++++++-------- .../config-form/theme/frigateTheme.ts | 2 + .../widgets/SemanticSearchModelSizeWidget.tsx | 57 +++++++++ .../components/menu/SearchResultActions.tsx | 5 +- .../overlay/DebugReplayConfigSheet.tsx | 120 ++++++++++++++++++ .../overlay/detail/DetailActionsMenu.tsx | 4 +- web/src/components/timeline/EventMenu.tsx | 5 +- web/src/pages/Replay.tsx | 107 +--------------- web/src/utils/configUtil.ts | 7 +- 18 files changed, 382 insertions(+), 182 deletions(-) create mode 100644 web/src/components/config-form/LiveFormDataContext.ts create mode 100644 web/src/components/config-form/section-validations/detect.ts create mode 100644 web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx create mode 100644 web/src/components/overlay/DebugReplayConfigSheet.tsx diff --git a/frigate/api/app.py b/frigate/api/app.py index 179c7fb90a..35eed2b9ce 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -770,6 +770,13 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo ), cam_cfg.objects, ) + if cam_cfg.zones: + request.app.config_publisher.publish_update( + CameraConfigUpdateTopic( + CameraConfigUpdateEnum.zones, camera + ), + cam_cfg.zones, + ) request.app.config_publisher.publish_update( CameraConfigUpdateTopic( CameraConfigUpdateEnum.refresh, camera diff --git a/frigate/util/config.py b/frigate/util/config.py index 5e5d2a0fc8..431c8bff53 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -816,6 +816,17 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[ **filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}), ) + # Regenerate zone contours and per-zone filter masks at the new + # frame_shape so zone outlines and membership stay relative + for zone in camera_config.zones.values(): + if zone.filters: + for zone_obj_name, zone_filter in zone.filters.items(): + zone.filters[zone_obj_name] = RuntimeFilterConfig( + frame_shape=new_frame_shape, + **zone_filter.model_dump(exclude_unset=True), + ) + zone.generate_contour(new_frame_shape) + else: merged = deep_merge(current.model_dump(), update, override=True) setattr(camera_config, section, current.__class__.model_validate(merged)) diff --git a/web/public/locales/en/config/validation.json b/web/public/locales/en/config/validation.json index 6f3b5f6864..f3d98a65e9 100644 --- a/web/public/locales/en/config/validation.json +++ b/web/public/locales/en/config/validation.json @@ -28,5 +28,8 @@ "detectRequired": "At least one input stream must be assigned the 'detect' role.", "hwaccelDetectOnly": "Only the input stream with the detect role can define hardware acceleration arguments." } + }, + "detect": { + "dimensionMustBeEven": "Must be an even number." } } diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 7bb582120b..11fcd92123 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1543,6 +1543,9 @@ "builtIn": "Built-in Models", "genaiProviders": "GenAI Providers" }, + "semanticSearchModelSize": { + "notApplicable": "Not applicable for GenAI providers" + }, "review": { "title": "Review Settings" }, @@ -1791,7 +1794,9 @@ }, "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." + "disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.", + "resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.", + "aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image." }, "objects": { "genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated." @@ -1820,8 +1825,7 @@ "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.", - "modelSizeIgnoredForProvider": "Model size only applies to the built-in Jina models. This value will be ignored when using a GenAI embedding provider." + "jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended." } } } diff --git a/web/src/components/config-form/LiveFormDataContext.ts b/web/src/components/config-form/LiveFormDataContext.ts new file mode 100644 index 0000000000..10d9a3e82c --- /dev/null +++ b/web/src/components/config-form/LiveFormDataContext.ts @@ -0,0 +1,13 @@ +import { createContext } from "react"; +import type { ConfigSectionData } from "@/types/configForm"; + +// Mirrors the current section's in-flight form data so widgets can react +// to changes that RJSF wouldn't otherwise re-render them for. RJSF's +// Form memoizes SchemaField via deep equality and, in some transitions +// (notably reverting a field to its saved value), can skip re-rendering +// a widget even though the form data it depends on changed. useContext +// re-runs consumers directly on every provider value update, sidestepping +// that. +export const LiveFormDataContext = createContext( + null, +); diff --git a/web/src/components/config-form/section-configs/detect.ts b/web/src/components/config-form/section-configs/detect.ts index 964a802d3f..74d170edc6 100644 --- a/web/src/components/config-form/section-configs/detect.ts +++ b/web/src/components/config-form/section-configs/detect.ts @@ -11,6 +11,50 @@ const detect: SectionConfigOverrides = { condition: (ctx) => ctx.level === "camera" && ctx.formData?.enabled === false, }, + { + key: "detect-resolution-not-multiple-of-four", + messageKey: "configMessages.detect.resolutionShouldBeMultipleOfFour", + severity: "warning", + condition: (ctx) => { + const width = ctx.formData?.width as number | null | undefined; + const height = ctx.formData?.height as number | null | undefined; + const isEvenButNotFour = (v: unknown) => + typeof v === "number" && v % 2 === 0 && v % 4 !== 0; + return isEvenButNotFour(width) || isEvenButNotFour(height); + }, + }, + { + key: "detect-aspect-ratio-mismatch", + messageKey: "configMessages.detect.aspectRatioMismatch", + severity: "warning", + condition: (ctx) => { + const newWidth = ctx.formData?.width as number | null | undefined; + const newHeight = ctx.formData?.height as number | null | undefined; + if (typeof newWidth !== "number" || typeof newHeight !== "number") { + return false; + } + const saved = + ctx.level === "camera" + ? ctx.fullCameraConfig?.detect + : ctx.fullConfig?.detect; + const savedWidth = saved?.width; + const savedHeight = saved?.height; + if ( + typeof savedWidth !== "number" || + typeof savedHeight !== "number" || + savedWidth <= 0 || + savedHeight <= 0 + ) { + return false; + } + if (newWidth === savedWidth && newHeight === savedHeight) { + return false; + } + const newRatio = newWidth / newHeight; + const savedRatio = savedWidth / savedHeight; + return Math.abs(newRatio - savedRatio) > 0.01; + }, + }, ], fieldMessages: [ { diff --git a/web/src/components/config-form/section-configs/semantic_search.ts b/web/src/components/config-form/section-configs/semantic_search.ts index 3f9bbfaec1..dde2b75531 100644 --- a/web/src/components/config-form/section-configs/semantic_search.ts +++ b/web/src/components/config-form/section-configs/semantic_search.ts @@ -29,28 +29,13 @@ const semanticSearch: SectionConfigOverrides = { ctx.formData?.model === "jinav2" && ctx.formData?.model_size === "small", }, - { - key: "model-size-ignored-for-provider", - field: "model_size", - messageKey: "configMessages.semanticSearch.modelSizeIgnoredForProvider", - severity: "info", - position: "after", - condition: (ctx) => { - const model = ctx.formData?.model; - return ( - typeof model === "string" && - model !== "" && - model !== "jinav1" && - model !== "jinav2" - ); - }, - }, ], uiSchema: { model: { "ui:widget": "semanticSearchModel", }, model_size: { + "ui:widget": "semanticSearchModelSize", "ui:options": { size: "xs", enumI18nPrefix: "modelSize" }, }, }, diff --git a/web/src/components/config-form/section-validations/detect.ts b/web/src/components/config-form/section-validations/detect.ts new file mode 100644 index 0000000000..7ecc805b72 --- /dev/null +++ b/web/src/components/config-form/section-validations/detect.ts @@ -0,0 +1,36 @@ +import type { FormValidation } from "@rjsf/utils"; +import type { TFunction } from "i18next"; +import { isJsonObject } from "@/lib/utils"; +import type { JsonObject } from "@/types/configForm"; + +export function validateDetectDimensions( + formData: unknown, + errors: FormValidation, + t: TFunction, +): FormValidation { + if (!isJsonObject(formData as JsonObject)) { + return errors; + } + + const data = formData as JsonObject; + const width = data.width; + const height = data.height; + + const widthErrors = errors.width as + | { addError?: (message: string) => void } + | undefined; + const heightErrors = errors.height as + | { addError?: (message: string) => void } + | undefined; + + const message = t("detect.dimensionMustBeEven", { ns: "config/validation" }); + + if (typeof width === "number" && width % 2 !== 0) { + widthErrors?.addError?.(message); + } + if (typeof height === "number" && height % 2 !== 0) { + heightErrors?.addError?.(message); + } + + return errors; +} diff --git a/web/src/components/config-form/section-validations/index.ts b/web/src/components/config-form/section-validations/index.ts index 31a02a1d10..33c02b1c7a 100644 --- a/web/src/components/config-form/section-validations/index.ts +++ b/web/src/components/config-form/section-validations/index.ts @@ -1,5 +1,6 @@ import type { FormValidation } from "@rjsf/utils"; import type { TFunction } from "i18next"; +import { validateDetectDimensions } from "./detect"; import { validateFfmpegInputRoles } from "./ffmpeg"; import { validateProxyRoleHeader } from "./proxy"; @@ -19,6 +20,10 @@ export function getSectionValidation({ level, t, }: SectionValidationOptions): SectionValidation | undefined { + if (sectionPath === "detect") { + return (formData, errors) => validateDetectDimensions(formData, errors, t); + } + if (sectionPath === "ffmpeg" && level === "camera") { return (formData, errors) => validateFfmpegInputRoles(formData, errors, t); } diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 4fbdf76625..b3261a5cc4 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -87,6 +87,7 @@ import type { import { useConfigMessages } from "@/hooks/use-config-messages"; import { ConfigMessageBanner } from "../ConfigMessageBanner"; import { FieldMessagesContext } from "../FieldMessagesContext"; +import { LiveFormDataContext } from "../LiveFormDataContext"; export interface SectionConfig { /** Field ordering within the section */ @@ -998,59 +999,63 @@ export function ConfigSection({
- handleChange(data), - // For widgets that need access to full camera config (e.g., zone names) - fullCameraConfig: - effectiveLevel === "camera" && cameraName - ? 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 - // When using a consolidated global namespace, keys are nested - // under the section name (e.g., `audio.label`) so provide the - // section prefix to templates so they can attempt `${section}.${field}` lookups. - sectionI18nPrefix: sectionPath, - t, - renderers: wrappedRenderers, - sectionDocs: sectionConfig.sectionDocs, - fieldDocs: sectionConfig.fieldDocs, - hiddenFields: effectiveHiddenFields, - restartRequired: sectionConfig.restartRequired, - requiresRestart, - isProfile: !!profileName, - }} - /> + + handleChange(data), + // For widgets that need access to full camera config (e.g., zone names) + fullCameraConfig: + effectiveLevel === "camera" && cameraName + ? 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 + // When using a consolidated global namespace, keys are nested + // under the section name (e.g., `audio.label`) so provide the + // section prefix to templates so they can attempt `${section}.${field}` lookups. + sectionI18nPrefix: sectionPath, + t, + renderers: wrappedRenderers, + sectionDocs: sectionConfig.sectionDocs, + fieldDocs: sectionConfig.fieldDocs, + hiddenFields: effectiveHiddenFields, + restartRequired: sectionConfig.restartRequired, + requiresRestart, + isProfile: !!profileName, + }} + /> + {!embedded && ( diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index ae612d9ac7..ebc6b19b35 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -31,6 +31,7 @@ import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget"; import { CameraPathWidget } from "./widgets/CameraPathWidget"; import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget"; import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget"; +import { SemanticSearchModelSizeWidget } from "./widgets/SemanticSearchModelSizeWidget"; import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget"; import { FieldTemplate } from "./templates/FieldTemplate"; @@ -86,6 +87,7 @@ export const frigateTheme: FrigateTheme = { timezoneSelect: TimezoneSelectWidget, optionalField: OptionalFieldWidget, semanticSearchModel: SemanticSearchModelWidget, + semanticSearchModelSize: SemanticSearchModelSizeWidget, onvifProfile: OnvifProfileWidget, }, templates: { diff --git a/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx b/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx new file mode 100644 index 0000000000..4ee0019363 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx @@ -0,0 +1,57 @@ +// Disables model_size and shows "N/A" when a GenAI provider is selected. +// Reads model via LiveFormDataContext so it re-runs even when RJSF's +// SchemaField memoization would skip this widget. +import type { WidgetProps } from "@rjsf/utils"; +import { useContext, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + Select, + SelectContent, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { LiveFormDataContext } from "../../LiveFormDataContext"; +import { getSizedFieldClassName } from "../utils"; +import { SelectWidget } from "./SelectWidget"; + +export function SemanticSearchModelSizeWidget(props: WidgetProps) { + const { t } = useTranslation(["views/settings"]); + const liveFormData = useContext(LiveFormDataContext); + const model = liveFormData?.model; + const isProvider = + typeof model === "string" && + model !== "" && + model !== "jinav1" && + model !== "jinav2"; + + // Clear model_size while on a provider (buildOverrides converts to "" + // which the backend treats as "remove"). Restore the schema default + // when returning to a Jina model so the field isn't left empty. + const { value, onChange, schema } = props; + const schemaDefault = schema?.default as string | undefined; + useEffect(() => { + if (isProvider && value !== undefined) { + onChange(undefined); + } else if (!isProvider && value === undefined && schemaDefault) { + onChange(schemaDefault); + } + }, [isProvider, value, onChange, schemaDefault]); + + if (isProvider) { + const fieldClassName = getSizedFieldClassName(props.options ?? {}, "sm"); + return ( + + ); + } + + return ; +} diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index 43a192dc9e..90a70ff5d9 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -1,5 +1,6 @@ import { useState, ReactNode, useCallback } from "react"; import { SearchResult } from "@/types/search"; +import { REVIEW_PADDING } from "@/types/review"; import { FrigateConfig } from "@/types/frigateConfig"; import { baseUrl } from "@/api/baseUrl"; import { toast } from "sonner"; @@ -94,8 +95,8 @@ export default function SearchResultActions({ axios .post("debug_replay/start", { camera: event.camera, - start_time: event.start_time, - end_time: event.end_time, + start_time: (event.start_time ?? 0) - REVIEW_PADDING, + end_time: (event.end_time ?? Date.now() / 1000) + REVIEW_PADDING, }) .then((response) => { if (response.status === 202 || response.status === 200) { diff --git a/web/src/components/overlay/DebugReplayConfigSheet.tsx b/web/src/components/overlay/DebugReplayConfigSheet.tsx new file mode 100644 index 0000000000..ca558e43dd --- /dev/null +++ b/web/src/components/overlay/DebugReplayConfigSheet.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuSettings } from "react-icons/lu"; +import useSWR from "swr"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate"; +import { Button } from "@/components/ui/button"; +import { PlatformAwareSheet } from "@/components/overlay/dialog/PlatformAwareDialog"; +import { useConfigSchema } from "@/hooks/use-config-schema"; +import type { FrigateConfig } from "@/types/frigateConfig"; + +type DebugReplayConfigSheetProps = { + replayCamera: string | undefined; +}; + +export function DebugReplayConfigSheet({ + replayCamera, +}: DebugReplayConfigSheetProps) { + const { t } = useTranslation(["views/replay"]); + const configSchema = useConfigSchema(); + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + const [open, setOpen] = useState(false); + + return ( + + + {t("page.configuration")} + + } + title={t("page.configuration")} + titleClassName="text-lg font-semibold" + contentClassName="scrollbar-container flex flex-col gap-0 overflow-y-auto px-6 pb-6 sm:max-w-xl md:max-w-2xl xl:max-w-3xl" + content={ + <> +

+ {t("page.configurationDesc")} +

+ {configSchema == null ? ( +
+ +
+ ) : ( +
+ + + + {config?.face_recognition?.enabled && ( + + )} + {config?.lpr?.enabled && ( + + )} +
+ )} + + } + open={open} + onOpenChange={setOpen} + /> + ); +} diff --git a/web/src/components/overlay/detail/DetailActionsMenu.tsx b/web/src/components/overlay/detail/DetailActionsMenu.tsx index 789f396772..af83700a6f 100644 --- a/web/src/components/overlay/detail/DetailActionsMenu.tsx +++ b/web/src/components/overlay/detail/DetailActionsMenu.tsx @@ -63,8 +63,8 @@ export default function DetailActionsMenu({ axios .post("debug_replay/start", { camera: search.camera, - start_time: search.start_time, - end_time: search.end_time, + start_time: (search.start_time ?? 0) - REVIEW_PADDING, + end_time: (search.end_time ?? Date.now() / 1000) + REVIEW_PADDING, }) .then((response) => { if (response.status === 202 || response.status === 200) { diff --git a/web/src/components/timeline/EventMenu.tsx b/web/src/components/timeline/EventMenu.tsx index 42efd2c97f..5c2798f2af 100644 --- a/web/src/components/timeline/EventMenu.tsx +++ b/web/src/components/timeline/EventMenu.tsx @@ -12,6 +12,7 @@ import { baseUrl } from "@/api/baseUrl"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Event } from "@/types/event"; +import { REVIEW_PADDING } from "@/types/review"; import { FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useState } from "react"; import { useIsAdmin } from "@/hooks/use-is-admin"; @@ -58,8 +59,8 @@ export default function EventMenu({ axios .post("debug_replay/start", { camera: event.camera, - start_time: event.start_time, - end_time: event.end_time, + start_time: (event.start_time ?? 0) - REVIEW_PADDING, + end_time: (event.end_time ?? Date.now() / 1000) + REVIEW_PADDING, }) .then((response) => { if (response.status === 202 || response.status === 200) { diff --git a/web/src/pages/Replay.tsx b/web/src/pages/Replay.tsx index a775ee4312..f43e52ee22 100644 --- a/web/src/pages/Replay.tsx +++ b/web/src/pages/Replay.tsx @@ -27,7 +27,7 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { PlatformAwareSheet } from "@/components/overlay/dialog/PlatformAwareDialog"; +import { DebugReplayConfigSheet } from "@/components/overlay/DebugReplayConfigSheet"; import { useCameraActivity } from "@/hooks/use-camera-activity"; import { cn } from "@/lib/utils"; import Heading from "@/components/ui/heading"; @@ -40,16 +40,14 @@ import { Progress } from "@/components/ui/progress"; import { ObjectType } from "@/types/ws"; import { useJobStatus } from "@/api/ws"; import WsMessageFeed from "@/components/ws/WsMessageFeed"; -import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate"; -import { LuExternalLink, LuInfo, LuSettings } from "react-icons/lu"; +import { LuExternalLink, LuInfo } from "react-icons/lu"; import { LuSquare } from "react-icons/lu"; import { MdReplay } from "react-icons/md"; import { isDesktop, isMobile } from "react-device-detect"; import Logo from "@/components/Logo"; import { Separator } from "@/components/ui/separator"; import { useDocDomain } from "@/hooks/use-doc-domain"; -import { useConfigSchema } from "@/hooks/use-config-schema"; import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer"; import { IoMdArrowRoundBack } from "react-icons/io"; @@ -125,7 +123,6 @@ export default function Replay() { }); const { payload: replayJob } = useJobStatus("debug_replay"); - const configSchema = useConfigSchema(); const [isInitializing, setIsInitializing] = useState(true); // Refresh status immediately on mount to avoid showing "no session" briefly @@ -139,7 +136,6 @@ export default function Replay() { const [options, setOptions] = useState(DEFAULT_OPTIONS); const [isStopping, setIsStopping] = useState(false); - const [configDialogOpen, setConfigDialogOpen] = useState(false); const searchParams = useMemo(() => { const params = new URLSearchParams(); @@ -327,103 +323,8 @@ export default function Replay() { )}
- - - - {t("page.configuration")} - - - } - title={t("page.configuration")} - titleClassName="text-lg font-semibold" - contentClassName="scrollbar-container flex flex-col gap-0 overflow-y-auto px-6 pb-6 sm:max-w-xl md:max-w-2xl xl:max-w-3xl" - content={ - <> -

- {t("page.configurationDesc")} -

- {configSchema == null ? ( -
- -
- ) : ( -
- - - - {config?.face_recognition?.enabled && ( - - )} - {config?.lpr?.enabled && ( - - )} -
- )} - - } - open={configDialogOpen} - onOpenChange={setConfigDialogOpen} + diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index cb7f6f52b6..3ebe96a63b 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -229,7 +229,12 @@ export function buildOverrides( const result: JsonObject = {}; for (const [key, value] of Object.entries(currentObj)) { - if (value === undefined && baseObj && baseObj[key] !== undefined) { + if ( + (value === undefined || value === null) && + baseObj && + baseObj[key] !== undefined && + baseObj[key] !== null + ) { result[key] = ""; continue; }