fix restart fields

This commit is contained in:
Josh Hawkins 2026-02-13 20:03:25 -06:00
parent f90acd9b71
commit 6cd914951d
25 changed files with 318 additions and 59 deletions

View File

@ -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,

View File

@ -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",

View File

@ -1246,6 +1246,7 @@
"selectPreset": "Select preset",
"manualPlaceholder": "Enter FFmpeg arguments"
},
"restartRequiredField": "Restart required",
"restartRequiredFooter": "Configuration changed - Restart required",
"sections": {
"detect": "Detection",

View File

@ -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;
};

View File

@ -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;

View File

@ -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;

View File

@ -10,7 +10,6 @@ const detectorHiddenFields = [
const detectors: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_detectors",
restartRequired: ["*.type", "*.model", "*.model_path"],
fieldOrder: [],
advancedFields: [],
hiddenFields: detectorHiddenFields,

View File

@ -3,7 +3,6 @@ import type { SectionConfigOverrides } from "./types";
const environmentVars: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced#environment_vars",
restartRequired: [],
fieldOrder: [],
advancedFields: [],
uiSchema: {

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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",

View File

@ -28,6 +28,21 @@ const record: SectionConfigOverrides = {
},
},
},
global: {
restartRequired: [
"enabled",
"expire_interval",
"continuous",
"motion",
"alerts",
"detections",
"preview",
"export",
],
},
camera: {
restartRequired: [],
},
};
export default record;

View File

@ -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;

View File

@ -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;

View File

@ -16,6 +16,12 @@ const timestampStyle: SectionConfigOverrides = {
},
},
},
global: {
restartRequired: ["position", "format", "color", "thickness", "effect"],
},
camera: {
restartRequired: [],
},
};
export default timestampStyle;

View File

@ -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,
}}
/>

View File

@ -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<string | number>) =>
requiresRestartForFieldPath(
path,
restartRequired,
defaultRequiresRestart,
),
[defaultRequiresRestart, restartRequired],
);
const renderRestartIcon = (isRequired: boolean) => {
if (!isRequired) {
return null;
}
return <RestartRequiredIndicator className="ml-2" />;
};
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 (
<div key={key} className="rounded-lg border bg-card">
@ -680,8 +706,9 @@ export function DetectorHardwareField(props: FieldProps) {
</Button>
</CollapsibleTrigger>
<div>
<div className="text-sm font-medium">
<div className="flex items-center text-sm font-medium">
{typeLabel}
{renderRestartIcon(detectorTypeRequiresRestart)}
<span className="ml-2 text-xs text-muted-foreground">
{key}
</span>
@ -707,7 +734,7 @@ export function DetectorHardwareField(props: FieldProps) {
<div className="space-y-4 border-t p-4">
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2">
<Label>
<Label className="flex items-center">
{t("label.ID", {
ns: "common",
defaultValue: "ID",
@ -746,7 +773,7 @@ export function DetectorHardwareField(props: FieldProps) {
)}
</div>
<div className="col-span-3 space-y-2">
<Label>
<Label className="flex items-center">
{t("detectors.type.label", {
ns: configNamespace,
defaultValue: "Type",

View File

@ -16,6 +16,8 @@ import { ConfigFormContext } from "@/types/configForm";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { requiresRestartForFieldPath } from "@/utils/configUtil";
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
import {
buildTranslationPath,
getFilterObjectLabel,
@ -196,6 +198,13 @@ export function FieldTemplate(props: FieldTemplateProps) {
const fieldDocsUrl = fieldDocsPath
? getLocaleDocUrl(fieldDocsPath)
: undefined;
const restartRequired = formContext?.restartRequired;
const defaultRequiresRestart = formContext?.requiresRestart ?? true;
const fieldRequiresRestart = requiresRestartForFieldPath(
normalizedFieldPath,
restartRequired,
defaultRequiresRestart,
);
// Use schema title/description as primary source (from JSON Schema)
const schemaTitle = schema.title;
@ -449,6 +458,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
</Label>
);
};
@ -465,6 +475,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
</Label>
);
};
@ -485,6 +496,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
</Label>
);
};

View File

@ -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) {
<div>
<CardTitle
className={cn(
"text-sm",
"flex items-center text-sm",
hasModifiedDescendants && "text-danger",
)}
>
{inferredLabel}
{objectRequiresRestart && (
<RestartRequiredIndicator className="ml-2" />
)}
</CardTitle>
{inferredDescription && (
<p className="mt-1 text-xs text-muted-foreground">

View File

@ -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 (
<span
className={cn(
"inline-flex items-center text-muted-foreground",
className,
)}
title={restartRequiredLabel}
aria-label={restartRequiredLabel}
>
<LuRefreshCcw className={cn("size-3", iconClassName)} />
</span>
);
}

View File

@ -38,6 +38,8 @@ export type ConfigFormContext = {
sectionI18nPrefix?: string;
sectionDocs?: string;
fieldDocs?: Record<string, string>;
restartRequired?: string[];
requiresRestart?: boolean;
t?: (key: string, options?: Record<string, unknown>) => string;
renderers?: Record<string, RendererComponent>;
};

View File

@ -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<string | number>,
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<string, unknown> = {};
set(
probe,
fieldPath.map((segment) => String(segment)),
true,
);
return requiresRestartForOverrides(
probe,
restartRequired,
defaultRequiresRestart,
);
}

View File

@ -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
/>
<div className="flex size-full flex-col md:flex-row">
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<div className="flex size-full space-y-6">
<div className="scrollbar-container flex-1 overflow-y-auto pb-2">
{viewMode === "settings" ? (
<>
<Heading as="h4" className="mb-2">
<Heading as="h4" className="mb-6">
{t("cameraManagement.title")}
</Heading>
<div className="my-4 flex flex-col gap-4">
<div className="w-full max-w-5xl space-y-6">
<Button
variant="select"
onClick={() => setShowWizard(true)}
className="flex max-w-48 items-center gap-2"
className="mb-2 flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("cameraManagement.addCamera")}
</Button>
{cameras.length > 0 && (
<>
<Separator className="my-2 flex bg-secondary" />
<div className="max-w-7xl space-y-4">
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraManagement.streams.title
</Trans>
</Heading>
<div className="mt-3 text-sm text-muted-foreground">
<SettingsGroupCard
title={
<Trans ns="views/settings">
cameraManagement.streams.title
</Trans>
}
>
<div className="space-y-4">
<div className="max-w-md text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraManagement.streams.desc
</Trans>
</div>
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
{cameras.map((camera) => (
<div
key={camera}
className="flex items-center justify-between smart-capitalize"
className="flex flex-row items-center justify-between"
>
<CameraNameLabel camera={camera} />
<CameraEnableSwitch cameraName={camera} />
@ -107,8 +108,7 @@ export default function CameraManagementView({
))}
</div>
</div>
<Separator className="mb-2 mt-4 flex bg-secondary" />
</>
</SettingsGroupCard>
)}
</div>
</>