From 6cd914951db69f0bdfbd1e0868f898e65fbd50fc Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:03:25 -0600 Subject: [PATCH] fix restart fields --- frigate/config/config.py | 2 +- web/public/locales/en/config/global.json | 2 +- web/public/locales/en/views/settings.json | 1 + web/src/components/card/SettingsGroupCard.tsx | 2 +- .../config-form/section-configs/audio.ts | 13 +++ .../config-form/section-configs/detect.ts | 24 ++++-- .../config-form/section-configs/detectors.ts | 1 - .../section-configs/environment_vars.ts | 1 - .../config-form/section-configs/ffmpeg.ts | 23 ++++++ .../config-form/section-configs/live.ts | 4 + .../config-form/section-configs/motion.ts | 16 ++++ .../config-form/section-configs/objects.ts | 4 + .../config-form/section-configs/onvif.ts | 10 ++- .../config-form/section-configs/record.ts | 15 ++++ .../config-form/section-configs/review.ts | 7 ++ .../config-form/section-configs/snapshots.ts | 11 +++ .../section-configs/timestamp_style.ts | 6 ++ .../config-form/sections/BaseSection.tsx | 23 +----- .../theme/fields/DetectorHardwareField.tsx | 33 +++++++- .../theme/templates/FieldTemplate.tsx | 12 +++ .../theme/templates/ObjectFieldTemplate.tsx | 14 +++- .../indicators/RestartRequiredIndicator.tsx | 32 ++++++++ web/src/types/configForm.ts | 2 + web/src/utils/configUtil.ts | 81 ++++++++++++++++++- .../views/settings/CameraManagementView.tsx | 38 ++++----- 25 files changed, 318 insertions(+), 59 deletions(-) create mode 100644 web/src/components/indicators/RestartRequiredIndicator.tsx diff --git a/frigate/config/config.py b/frigate/config/config.py index 8b4a9d7d6..6465ef2a5 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -314,7 +314,7 @@ class FrigateConfig(FrigateBaseModel): environment_vars: EnvVars = Field( default_factory=dict, title="Environment variables", - description="Key/value pairs of environment variables to set for the Frigate process.", + description="Key/value pairs of environment variables to set for the Frigate process in Home Assistant OS. Non-HAOS users must use Docker environment variable configuration instead.", ) logger: LoggerConfig = Field( default_factory=LoggerConfig, diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index f4c6db617..e00949adf 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -9,7 +9,7 @@ }, "environment_vars": { "label": "Environment variables", - "description": "Key/value pairs of environment variables to set for the Frigate process." + "description": "Key/value pairs of environment variables to set for the Frigate process in Home Assistant OS. Non-HAOS users must use Docker environment variable configuration instead." }, "logger": { "label": "Logging", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 433f64c6f..f2709bf4f 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1246,6 +1246,7 @@ "selectPreset": "Select preset", "manualPlaceholder": "Enter FFmpeg arguments" }, + "restartRequiredField": "Restart required", "restartRequiredFooter": "Configuration changed - Restart required", "sections": { "detect": "Detection", diff --git a/web/src/components/card/SettingsGroupCard.tsx b/web/src/components/card/SettingsGroupCard.tsx index 3bc53fb36..0377e0797 100644 --- a/web/src/components/card/SettingsGroupCard.tsx +++ b/web/src/components/card/SettingsGroupCard.tsx @@ -7,7 +7,7 @@ export const DESCRIPTION_CLASS_NAME = "text-sm text-muted-foreground"; export const CONTROL_COLUMN_CLASS_NAME = "w-full md:max-w-2xl"; type SettingsGroupCardProps = { - title: string; + title: string | ReactNode; children: ReactNode; }; diff --git a/web/src/components/config-form/section-configs/audio.ts b/web/src/components/config-form/section-configs/audio.ts index b2a33f82c..81ddb9b0a 100644 --- a/web/src/components/config-form/section-configs/audio.ts +++ b/web/src/components/config-form/section-configs/audio.ts @@ -24,6 +24,19 @@ const audio: SectionConfigOverrides = { }, }, }, + global: { + restartRequired: [ + "enabled", + "listen", + "filters", + "min_volume", + "max_not_heard", + "num_threads", + ], + }, + camera: { + restartRequired: ["num_threads"], + }, }; export default audio; diff --git a/web/src/components/config-form/section-configs/detect.ts b/web/src/components/config-form/section-configs/detect.ts index 88cdb236f..2c3da7b06 100644 --- a/web/src/components/config-form/section-configs/detect.ts +++ b/web/src/components/config-form/section-configs/detect.ts @@ -16,14 +16,7 @@ const detect: SectionConfigOverrides = { "threshold", "max_frames", ], - restartRequired: [ - "width", - "height", - "fps", - "min_initialized", - "max_disappeared", - "stationary", - ], + restartRequired: [], fieldGroups: { resolution: ["enabled", "width", "height", "fps"], tracking: ["min_initialized", "max_disappeared"], @@ -36,6 +29,21 @@ const detect: SectionConfigOverrides = { "stationary", ], }, + global: { + restartRequired: [ + "enabled", + "width", + "height", + "fps", + "min_initialized", + "max_disappeared", + "annotation_offset", + "stationary", + ], + }, + camera: { + restartRequired: ["width", "height", "min_initialized", "max_disappeared"], + }, }; export default detect; diff --git a/web/src/components/config-form/section-configs/detectors.ts b/web/src/components/config-form/section-configs/detectors.ts index 68fcb3ae4..3ca2dd81d 100644 --- a/web/src/components/config-form/section-configs/detectors.ts +++ b/web/src/components/config-form/section-configs/detectors.ts @@ -10,7 +10,6 @@ const detectorHiddenFields = [ const detectors: SectionConfigOverrides = { base: { sectionDocs: "/configuration/object_detectors", - restartRequired: ["*.type", "*.model", "*.model_path"], fieldOrder: [], advancedFields: [], hiddenFields: detectorHiddenFields, diff --git a/web/src/components/config-form/section-configs/environment_vars.ts b/web/src/components/config-form/section-configs/environment_vars.ts index a74098698..2100d3e35 100644 --- a/web/src/components/config-form/section-configs/environment_vars.ts +++ b/web/src/components/config-form/section-configs/environment_vars.ts @@ -3,7 +3,6 @@ import type { SectionConfigOverrides } from "./types"; const environmentVars: SectionConfigOverrides = { base: { sectionDocs: "/configuration/advanced#environment_vars", - restartRequired: [], fieldOrder: [], advancedFields: [], uiSchema: { diff --git a/web/src/components/config-form/section-configs/ffmpeg.ts b/web/src/components/config-form/section-configs/ffmpeg.ts index 94a7d7843..b3980af30 100644 --- a/web/src/components/config-form/section-configs/ffmpeg.ts +++ b/web/src/components/config-form/section-configs/ffmpeg.ts @@ -96,6 +96,16 @@ const ffmpeg: SectionConfigOverrides = { }, }, global: { + restartRequired: [ + "path", + "global_args", + "hwaccel_args", + "input_args", + "output_args", + "retry_interval", + "apple_compatibility", + "gpu", + ], fieldOrder: [ "path", "global_args", @@ -127,6 +137,19 @@ const ffmpeg: SectionConfigOverrides = { }, }, }, + camera: { + restartRequired: [ + "inputs", + "path", + "global_args", + "hwaccel_args", + "input_args", + "output_args", + "retry_interval", + "apple_compatibility", + "gpu", + ], + }, }; export default ffmpeg; diff --git a/web/src/components/config-form/section-configs/live.ts b/web/src/components/config-form/section-configs/live.ts index 56ec386da..c0d80627c 100644 --- a/web/src/components/config-form/section-configs/live.ts +++ b/web/src/components/config-form/section-configs/live.ts @@ -10,8 +10,12 @@ const live: SectionConfigOverrides = { advancedFields: ["height", "quality"], }, global: { + restartRequired: ["stream_name", "height", "quality"], hiddenFields: ["streams"], }, + camera: { + restartRequired: ["height", "quality"], + }, }; export default live; diff --git a/web/src/components/config-form/section-configs/motion.ts b/web/src/components/config-form/section-configs/motion.ts index 6a276b7cc..0acdc0d99 100644 --- a/web/src/components/config-form/section-configs/motion.ts +++ b/web/src/components/config-form/section-configs/motion.ts @@ -28,6 +28,22 @@ const motion: SectionConfigOverrides = { "mqtt_off_delay", ], }, + global: { + restartRequired: [ + "enabled", + "threshold", + "lightning_threshold", + "improve_contrast", + "contour_area", + "delta_alpha", + "frame_alpha", + "frame_height", + "mqtt_off_delay", + ], + }, + camera: { + restartRequired: ["frame_height"], + }, }; export default motion; diff --git a/web/src/components/config-form/section-configs/objects.ts b/web/src/components/config-form/section-configs/objects.ts index ccf117b3d..1dfb31053 100644 --- a/web/src/components/config-form/section-configs/objects.ts +++ b/web/src/components/config-form/section-configs/objects.ts @@ -83,6 +83,7 @@ const objects: SectionConfigOverrides = { }, }, global: { + restartRequired: ["track", "alert", "detect", "filters", "genai"], hiddenFields: [ "enabled_in_config", "mask", @@ -95,6 +96,9 @@ const objects: SectionConfigOverrides = { "genai.required_zones", ], }, + camera: { + restartRequired: [], + }, }; export default objects; diff --git a/web/src/components/config-form/section-configs/onvif.ts b/web/src/components/config-form/section-configs/onvif.ts index 23c10ae2e..b8be693d6 100644 --- a/web/src/components/config-form/section-configs/onvif.ts +++ b/web/src/components/config-form/section-configs/onvif.ts @@ -3,7 +3,15 @@ import type { SectionConfigOverrides } from "./types"; const onvif: SectionConfigOverrides = { base: { sectionDocs: "/configuration/cameras#setting-up-camera-ptz-controls", - restartRequired: [], + restartRequired: [ + "host", + "port", + "user", + "password", + "tls_insecure", + "ignore_time_mismatch", + "autotracking.calibrate_on_startup", + ], fieldOrder: [ "host", "port", diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index 5932936b0..c47d67ad0 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -28,6 +28,21 @@ const record: SectionConfigOverrides = { }, }, }, + global: { + restartRequired: [ + "enabled", + "expire_interval", + "continuous", + "motion", + "alerts", + "detections", + "preview", + "export", + ], + }, + camera: { + restartRequired: [], + }, }; export default record; diff --git a/web/src/components/config-form/section-configs/review.ts b/web/src/components/config-form/section-configs/review.ts index 13abac4ef..bb3ec4ca4 100644 --- a/web/src/components/config-form/section-configs/review.ts +++ b/web/src/components/config-form/section-configs/review.ts @@ -3,6 +3,7 @@ import type { SectionConfigOverrides } from "./types"; const review: SectionConfigOverrides = { base: { sectionDocs: "/configuration/review", + restartRequired: [], fieldOrder: ["alerts", "detections", "genai"], fieldGroups: {}, hiddenFields: [ @@ -42,6 +43,12 @@ const review: SectionConfigOverrides = { }, }, }, + global: { + restartRequired: ["alerts", "detections", "genai"], + }, + camera: { + restartRequired: [], + }, }; export default review; diff --git a/web/src/components/config-form/section-configs/snapshots.ts b/web/src/components/config-form/section-configs/snapshots.ts index a46ef5aea..b098d84a5 100644 --- a/web/src/components/config-form/section-configs/snapshots.ts +++ b/web/src/components/config-form/section-configs/snapshots.ts @@ -27,8 +27,19 @@ const snapshots: SectionConfigOverrides = { }, }, global: { + restartRequired: [ + "enabled", + "bounding_box", + "crop", + "quality", + "timestamp", + "retain", + ], hiddenFields: ["enabled_in_config", "required_zones"], }, + camera: { + restartRequired: [], + }, }; export default snapshots; diff --git a/web/src/components/config-form/section-configs/timestamp_style.ts b/web/src/components/config-form/section-configs/timestamp_style.ts index fbcf7dc8b..2f51b2416 100644 --- a/web/src/components/config-form/section-configs/timestamp_style.ts +++ b/web/src/components/config-form/section-configs/timestamp_style.ts @@ -16,6 +16,12 @@ const timestampStyle: SectionConfigOverrides = { }, }, }, + global: { + restartRequired: ["position", "format", "color", "thickness", "effect"], + }, + camera: { + restartRequired: [], + }, }; export default timestampStyle; diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 8ad69f401..64c239367 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -514,13 +514,6 @@ export function ConfigSection({ update_topic: updateTopic, config_data: configData, }); - // log save to console for debugging - // eslint-disable-next-line no-console - console.log("Saved config data:", { - [basePath]: sanitizedOverrides, - update_topic: updateTopic, - requires_restart: needsRestart ? 1 : 0, - }); if (needsRestart) { statusBar?.addMessage( @@ -628,23 +621,11 @@ export function ConfigSection({ const configData = buildConfigDataForPath(basePath, ""); await axios.put("config/set", { - requires_restart: requiresRestart ? 0 : 1, + requires_restart: requiresRestart ? 1 : 0, update_topic: updateTopic, config_data: configData, }); - // log reset to console for debugging - // eslint-disable-next-line no-console - console.log( - level === "global" - ? "Reset to defaults for path:" - : "Reset to global config for path:", - basePath, - { - update_topic: updateTopic, - requires_restart: requiresRestart ? 0 : 1, - }, - ); toast.success( t("toast.resetSuccess", { ns: "views/settings", @@ -815,6 +796,8 @@ export function ConfigSection({ sectionDocs: sectionConfig.sectionDocs, fieldDocs: sectionConfig.fieldDocs, hiddenFields: sectionConfig.hiddenFields, + restartRequired: sectionConfig.restartRequired, + requiresRestart, }} /> diff --git a/web/src/components/config-form/theme/fields/DetectorHardwareField.tsx b/web/src/components/config-form/theme/fields/DetectorHardwareField.tsx index dca280926..a848da84c 100644 --- a/web/src/components/config-form/theme/fields/DetectorHardwareField.tsx +++ b/web/src/components/config-form/theme/fields/DetectorHardwareField.tsx @@ -18,6 +18,8 @@ import { import { applySchemaDefaults } from "@/lib/config-schema"; import { cn, isJsonObject, mergeUiSchema } from "@/lib/utils"; import { ConfigFormContext, JsonObject } from "@/types/configForm"; +import { requiresRestartForFieldPath } from "@/utils/configUtil"; +import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -158,6 +160,8 @@ export function DetectorHardwareField(props: FieldProps) { const { t: fallbackT } = useTranslation(["common", configNamespace]); const t = formContext?.t ?? fallbackT; const sectionPrefix = formContext?.sectionI18nPrefix ?? "detectors"; + const restartRequired = formContext?.restartRequired; + const defaultRequiresRestart = formContext?.requiresRestart ?? true; const options = (uiSchema?.["ui:options"] as DetectorHardwareFieldOptions | undefined) ?? @@ -322,6 +326,24 @@ export function DetectorHardwareField(props: FieldProps) { [t, sectionPrefix, configNamespace], ); + const shouldShowRestartForPath = useCallback( + (path: Array) => + requiresRestartForFieldPath( + path, + restartRequired, + defaultRequiresRestart, + ), + [defaultRequiresRestart, restartRequired], + ); + + const renderRestartIcon = (isRequired: boolean) => { + if (!isRequired) { + return null; + } + + return ; + }; + const isSingleInstanceType = useCallback( (type: string) => !multiInstanceSet.has(type), [multiInstanceSet], @@ -646,6 +668,10 @@ export function DetectorHardwareField(props: FieldProps) { const typeDescription = type ? getTypeDescription(type) : ""; const isOpen = openKeys.has(key); const renameDraft = renameDrafts[key] ?? key; + const detectorPath = [...fieldPathId.path, key]; + const detectorTypePath = [...detectorPath, "type"]; + const detectorTypeRequiresRestart = + shouldShowRestartForPath(detectorTypePath); return (
@@ -680,8 +706,9 @@ export function DetectorHardwareField(props: FieldProps) {
-
+
{typeLabel} + {renderRestartIcon(detectorTypeRequiresRestart)} {key} @@ -707,7 +734,7 @@ export function DetectorHardwareField(props: FieldProps) {
-
- ); }; @@ -485,6 +496,7 @@ export function FieldTemplate(props: FieldTemplateProps) { > {finalLabel} {required && *} + {fieldRequiresRestart && } ); }; diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index 71d700746..808557d46 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -8,10 +8,12 @@ import { } from "@/components/ui/collapsible"; import { Children, useState, useEffect, useRef } from "react"; import type { ReactNode } from "react"; +import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { getTranslatedLabel } from "@/utils/i18n"; +import { requiresRestartForFieldPath } from "@/utils/configUtil"; import { ConfigFormContext } from "@/types/configForm"; import { buildTranslationPath, @@ -44,6 +46,8 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { const baselineFormData = formContext?.baselineFormData; const hiddenFields = formContext?.hiddenFields; const fieldPath = props.fieldPathId.path; + const restartRequired = formContext?.restartRequired; + const defaultRequiresRestart = formContext?.requiresRestart ?? true; // Strip fields from an object that should be excluded from modification // detection: fields listed in hiddenFields (stripped from baseline by @@ -164,6 +168,11 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { "views/settings", "common", ]); + const objectRequiresRestart = requiresRestartForFieldPath( + fieldPath, + restartRequired, + defaultRequiresRestart, + ); const domain = getDomainFromNamespace(formContext?.i18nNamespace); @@ -438,11 +447,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
{inferredLabel} + {objectRequiresRestart && ( + + )} {inferredDescription && (

diff --git a/web/src/components/indicators/RestartRequiredIndicator.tsx b/web/src/components/indicators/RestartRequiredIndicator.tsx new file mode 100644 index 000000000..3268599ea --- /dev/null +++ b/web/src/components/indicators/RestartRequiredIndicator.tsx @@ -0,0 +1,32 @@ +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; +import { LuRefreshCcw } from "react-icons/lu"; + +type RestartRequiredIndicatorProps = { + className?: string; + iconClassName?: string; +}; + +export default function RestartRequiredIndicator({ + className, + iconClassName, +}: RestartRequiredIndicatorProps) { + const { t } = useTranslation(["views/settings"]); + const restartRequiredLabel = t("configForm.restartRequiredField", { + ns: "views/settings", + defaultValue: "Restart required", + }); + + return ( + + + + ); +} diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts index b1d4217e8..0782d677f 100644 --- a/web/src/types/configForm.ts +++ b/web/src/types/configForm.ts @@ -38,6 +38,8 @@ export type ConfigFormContext = { sectionI18nPrefix?: string; sectionDocs?: string; fieldDocs?: Record; + restartRequired?: string[]; + requiresRestart?: boolean; t?: (key: string, options?: Record) => string; renderers?: Record; }; diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index fc47dd1c0..c4c5afad1 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -197,6 +197,44 @@ export function buildConfigDataForPath( // used; an empty array means "never restart"; otherwise the function checks // if any of the listed field paths are present in the overrides object. +function hasMatchAtPath(value: unknown, pathSegments: string[]): boolean { + if (pathSegments.length === 0) { + return value !== undefined; + } + + if (value === undefined || value === null) { + return false; + } + + const [segment, ...rest] = pathSegments; + + if (segment === "*") { + if (Array.isArray(value)) { + return value.some((item) => hasMatchAtPath(item, rest)); + } + + if (isJsonObject(value)) { + return Object.values(value).some((item) => hasMatchAtPath(item, rest)); + } + + return false; + } + + if (Array.isArray(value)) { + const index = Number(segment); + if (!Number.isInteger(index)) { + return false; + } + return hasMatchAtPath(value[index], rest); + } + + if (isJsonObject(value)) { + return hasMatchAtPath(value[segment], rest); + } + + return false; +} + export function requiresRestartForOverrides( overrides: unknown, restartRequired: string[] | undefined, @@ -211,8 +249,47 @@ export function requiresRestartForOverrides( if (!overrides || typeof overrides !== "object") { return false; } - return restartRequired.some( - (path) => get(overrides as JsonObject, path) !== undefined, + return restartRequired.some((path) => { + if (!path) { + return false; + } + + if (!path.includes("*")) { + return get(overrides as JsonObject, path) !== undefined; + } + + return hasMatchAtPath(overrides, path.split(".")); + }); +} + +export function requiresRestartForFieldPath( + fieldPath: Array, + restartRequired: string[] | undefined, + defaultRequiresRestart: boolean = true, +): boolean { + if (restartRequired === undefined) { + return defaultRequiresRestart; + } + + if (restartRequired.length === 0) { + return false; + } + + if (fieldPath.length === 0) { + return false; + } + + const probe: Record = {}; + set( + probe, + fieldPath.map((segment) => String(segment)), + true, + ); + + return requiresRestartForOverrides( + probe, + restartRequired, + defaultRequiresRestart, ); } diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 77231de45..82312d9ff 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -1,5 +1,6 @@ import Heading from "@/components/ui/heading"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { SettingsGroupCard } from "@/components/card/SettingsGroupCard"; import { Toaster } from "@/components/ui/sonner"; import { Button } from "@/components/ui/button"; import useSWR from "swr"; @@ -13,7 +14,6 @@ import { isDesktop } from "react-device-detect"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { Switch } from "@/components/ui/switch"; import { Trans } from "react-i18next"; -import { Separator } from "@/components/ui/separator"; import { useEnabledState } from "@/api/ws"; type CameraManagementViewProps = { @@ -64,42 +64,43 @@ export default function CameraManagementView({ position="top-center" closeButton /> -

-
+
+
{viewMode === "settings" ? ( <> - + {t("cameraManagement.title")} -
+ +
+ {cameras.length > 0 && ( - <> - -
- - - cameraManagement.streams.title - - -
+ + cameraManagement.streams.title + + } + > +
+
cameraManagement.streams.desc
-
{cameras.map((camera) => (
@@ -107,8 +108,7 @@ export default function CameraManagementView({ ))}
- - + )}