mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-13 03:47:34 +03:00
fix restart fields
This commit is contained in:
parent
f90acd9b71
commit
6cd914951d
@ -314,7 +314,7 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
environment_vars: EnvVars = Field(
|
environment_vars: EnvVars = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
title="Environment variables",
|
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(
|
logger: LoggerConfig = Field(
|
||||||
default_factory=LoggerConfig,
|
default_factory=LoggerConfig,
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"environment_vars": {
|
"environment_vars": {
|
||||||
"label": "Environment variables",
|
"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": {
|
"logger": {
|
||||||
"label": "Logging",
|
"label": "Logging",
|
||||||
|
|||||||
@ -1246,6 +1246,7 @@
|
|||||||
"selectPreset": "Select preset",
|
"selectPreset": "Select preset",
|
||||||
"manualPlaceholder": "Enter FFmpeg arguments"
|
"manualPlaceholder": "Enter FFmpeg arguments"
|
||||||
},
|
},
|
||||||
|
"restartRequiredField": "Restart required",
|
||||||
"restartRequiredFooter": "Configuration changed - Restart required",
|
"restartRequiredFooter": "Configuration changed - Restart required",
|
||||||
"sections": {
|
"sections": {
|
||||||
"detect": "Detection",
|
"detect": "Detection",
|
||||||
|
|||||||
@ -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";
|
export const CONTROL_COLUMN_CLASS_NAME = "w-full md:max-w-2xl";
|
||||||
|
|
||||||
type SettingsGroupCardProps = {
|
type SettingsGroupCardProps = {
|
||||||
title: string;
|
title: string | ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
export default audio;
|
||||||
|
|||||||
@ -16,14 +16,7 @@ const detect: SectionConfigOverrides = {
|
|||||||
"threshold",
|
"threshold",
|
||||||
"max_frames",
|
"max_frames",
|
||||||
],
|
],
|
||||||
restartRequired: [
|
restartRequired: [],
|
||||||
"width",
|
|
||||||
"height",
|
|
||||||
"fps",
|
|
||||||
"min_initialized",
|
|
||||||
"max_disappeared",
|
|
||||||
"stationary",
|
|
||||||
],
|
|
||||||
fieldGroups: {
|
fieldGroups: {
|
||||||
resolution: ["enabled", "width", "height", "fps"],
|
resolution: ["enabled", "width", "height", "fps"],
|
||||||
tracking: ["min_initialized", "max_disappeared"],
|
tracking: ["min_initialized", "max_disappeared"],
|
||||||
@ -36,6 +29,21 @@ const detect: SectionConfigOverrides = {
|
|||||||
"stationary",
|
"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;
|
export default detect;
|
||||||
|
|||||||
@ -10,7 +10,6 @@ const detectorHiddenFields = [
|
|||||||
const detectors: SectionConfigOverrides = {
|
const detectors: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/object_detectors",
|
sectionDocs: "/configuration/object_detectors",
|
||||||
restartRequired: ["*.type", "*.model", "*.model_path"],
|
|
||||||
fieldOrder: [],
|
fieldOrder: [],
|
||||||
advancedFields: [],
|
advancedFields: [],
|
||||||
hiddenFields: detectorHiddenFields,
|
hiddenFields: detectorHiddenFields,
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const environmentVars: SectionConfigOverrides = {
|
const environmentVars: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/advanced#environment_vars",
|
sectionDocs: "/configuration/advanced#environment_vars",
|
||||||
restartRequired: [],
|
|
||||||
fieldOrder: [],
|
fieldOrder: [],
|
||||||
advancedFields: [],
|
advancedFields: [],
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
|
|||||||
@ -96,6 +96,16 @@ const ffmpeg: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
|
restartRequired: [
|
||||||
|
"path",
|
||||||
|
"global_args",
|
||||||
|
"hwaccel_args",
|
||||||
|
"input_args",
|
||||||
|
"output_args",
|
||||||
|
"retry_interval",
|
||||||
|
"apple_compatibility",
|
||||||
|
"gpu",
|
||||||
|
],
|
||||||
fieldOrder: [
|
fieldOrder: [
|
||||||
"path",
|
"path",
|
||||||
"global_args",
|
"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;
|
export default ffmpeg;
|
||||||
|
|||||||
@ -10,8 +10,12 @@ const live: SectionConfigOverrides = {
|
|||||||
advancedFields: ["height", "quality"],
|
advancedFields: ["height", "quality"],
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
|
restartRequired: ["stream_name", "height", "quality"],
|
||||||
hiddenFields: ["streams"],
|
hiddenFields: ["streams"],
|
||||||
},
|
},
|
||||||
|
camera: {
|
||||||
|
restartRequired: ["height", "quality"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default live;
|
export default live;
|
||||||
|
|||||||
@ -28,6 +28,22 @@ const motion: SectionConfigOverrides = {
|
|||||||
"mqtt_off_delay",
|
"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;
|
export default motion;
|
||||||
|
|||||||
@ -83,6 +83,7 @@ const objects: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
|
restartRequired: ["track", "alert", "detect", "filters", "genai"],
|
||||||
hiddenFields: [
|
hiddenFields: [
|
||||||
"enabled_in_config",
|
"enabled_in_config",
|
||||||
"mask",
|
"mask",
|
||||||
@ -95,6 +96,9 @@ const objects: SectionConfigOverrides = {
|
|||||||
"genai.required_zones",
|
"genai.required_zones",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
camera: {
|
||||||
|
restartRequired: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default objects;
|
export default objects;
|
||||||
|
|||||||
@ -3,7 +3,15 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const onvif: SectionConfigOverrides = {
|
const onvif: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/cameras#setting-up-camera-ptz-controls",
|
sectionDocs: "/configuration/cameras#setting-up-camera-ptz-controls",
|
||||||
restartRequired: [],
|
restartRequired: [
|
||||||
|
"host",
|
||||||
|
"port",
|
||||||
|
"user",
|
||||||
|
"password",
|
||||||
|
"tls_insecure",
|
||||||
|
"ignore_time_mismatch",
|
||||||
|
"autotracking.calibrate_on_startup",
|
||||||
|
],
|
||||||
fieldOrder: [
|
fieldOrder: [
|
||||||
"host",
|
"host",
|
||||||
"port",
|
"port",
|
||||||
|
|||||||
@ -28,6 +28,21 @@ const record: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
global: {
|
||||||
|
restartRequired: [
|
||||||
|
"enabled",
|
||||||
|
"expire_interval",
|
||||||
|
"continuous",
|
||||||
|
"motion",
|
||||||
|
"alerts",
|
||||||
|
"detections",
|
||||||
|
"preview",
|
||||||
|
"export",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
restartRequired: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default record;
|
export default record;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const review: SectionConfigOverrides = {
|
const review: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/review",
|
sectionDocs: "/configuration/review",
|
||||||
|
restartRequired: [],
|
||||||
fieldOrder: ["alerts", "detections", "genai"],
|
fieldOrder: ["alerts", "detections", "genai"],
|
||||||
fieldGroups: {},
|
fieldGroups: {},
|
||||||
hiddenFields: [
|
hiddenFields: [
|
||||||
@ -42,6 +43,12 @@ const review: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
global: {
|
||||||
|
restartRequired: ["alerts", "detections", "genai"],
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
restartRequired: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default review;
|
export default review;
|
||||||
|
|||||||
@ -27,8 +27,19 @@ const snapshots: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
|
restartRequired: [
|
||||||
|
"enabled",
|
||||||
|
"bounding_box",
|
||||||
|
"crop",
|
||||||
|
"quality",
|
||||||
|
"timestamp",
|
||||||
|
"retain",
|
||||||
|
],
|
||||||
hiddenFields: ["enabled_in_config", "required_zones"],
|
hiddenFields: ["enabled_in_config", "required_zones"],
|
||||||
},
|
},
|
||||||
|
camera: {
|
||||||
|
restartRequired: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default snapshots;
|
export default snapshots;
|
||||||
|
|||||||
@ -16,6 +16,12 @@ const timestampStyle: SectionConfigOverrides = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
global: {
|
||||||
|
restartRequired: ["position", "format", "color", "thickness", "effect"],
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
restartRequired: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default timestampStyle;
|
export default timestampStyle;
|
||||||
|
|||||||
@ -514,13 +514,6 @@ export function ConfigSection({
|
|||||||
update_topic: updateTopic,
|
update_topic: updateTopic,
|
||||||
config_data: configData,
|
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) {
|
if (needsRestart) {
|
||||||
statusBar?.addMessage(
|
statusBar?.addMessage(
|
||||||
@ -628,23 +621,11 @@ export function ConfigSection({
|
|||||||
const configData = buildConfigDataForPath(basePath, "");
|
const configData = buildConfigDataForPath(basePath, "");
|
||||||
|
|
||||||
await axios.put("config/set", {
|
await axios.put("config/set", {
|
||||||
requires_restart: requiresRestart ? 0 : 1,
|
requires_restart: requiresRestart ? 1 : 0,
|
||||||
update_topic: updateTopic,
|
update_topic: updateTopic,
|
||||||
config_data: configData,
|
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(
|
toast.success(
|
||||||
t("toast.resetSuccess", {
|
t("toast.resetSuccess", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
@ -815,6 +796,8 @@ export function ConfigSection({
|
|||||||
sectionDocs: sectionConfig.sectionDocs,
|
sectionDocs: sectionConfig.sectionDocs,
|
||||||
fieldDocs: sectionConfig.fieldDocs,
|
fieldDocs: sectionConfig.fieldDocs,
|
||||||
hiddenFields: sectionConfig.hiddenFields,
|
hiddenFields: sectionConfig.hiddenFields,
|
||||||
|
restartRequired: sectionConfig.restartRequired,
|
||||||
|
requiresRestart,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,8 @@ import {
|
|||||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||||
import { cn, isJsonObject, mergeUiSchema } from "@/lib/utils";
|
import { cn, isJsonObject, mergeUiSchema } from "@/lib/utils";
|
||||||
import { ConfigFormContext, JsonObject } from "@/types/configForm";
|
import { ConfigFormContext, JsonObject } from "@/types/configForm";
|
||||||
|
import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||||
|
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@ -158,6 +160,8 @@ export function DetectorHardwareField(props: FieldProps) {
|
|||||||
const { t: fallbackT } = useTranslation(["common", configNamespace]);
|
const { t: fallbackT } = useTranslation(["common", configNamespace]);
|
||||||
const t = formContext?.t ?? fallbackT;
|
const t = formContext?.t ?? fallbackT;
|
||||||
const sectionPrefix = formContext?.sectionI18nPrefix ?? "detectors";
|
const sectionPrefix = formContext?.sectionI18nPrefix ?? "detectors";
|
||||||
|
const restartRequired = formContext?.restartRequired;
|
||||||
|
const defaultRequiresRestart = formContext?.requiresRestart ?? true;
|
||||||
|
|
||||||
const options =
|
const options =
|
||||||
(uiSchema?.["ui:options"] as DetectorHardwareFieldOptions | undefined) ??
|
(uiSchema?.["ui:options"] as DetectorHardwareFieldOptions | undefined) ??
|
||||||
@ -322,6 +326,24 @@ export function DetectorHardwareField(props: FieldProps) {
|
|||||||
[t, sectionPrefix, configNamespace],
|
[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(
|
const isSingleInstanceType = useCallback(
|
||||||
(type: string) => !multiInstanceSet.has(type),
|
(type: string) => !multiInstanceSet.has(type),
|
||||||
[multiInstanceSet],
|
[multiInstanceSet],
|
||||||
@ -646,6 +668,10 @@ export function DetectorHardwareField(props: FieldProps) {
|
|||||||
const typeDescription = type ? getTypeDescription(type) : "";
|
const typeDescription = type ? getTypeDescription(type) : "";
|
||||||
const isOpen = openKeys.has(key);
|
const isOpen = openKeys.has(key);
|
||||||
const renameDraft = renameDrafts[key] ?? key;
|
const renameDraft = renameDrafts[key] ?? key;
|
||||||
|
const detectorPath = [...fieldPathId.path, key];
|
||||||
|
const detectorTypePath = [...detectorPath, "type"];
|
||||||
|
const detectorTypeRequiresRestart =
|
||||||
|
shouldShowRestartForPath(detectorTypePath);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="rounded-lg border bg-card">
|
<div key={key} className="rounded-lg border bg-card">
|
||||||
@ -680,8 +706,9 @@ export function DetectorHardwareField(props: FieldProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">
|
<div className="flex items-center text-sm font-medium">
|
||||||
{typeLabel}
|
{typeLabel}
|
||||||
|
{renderRestartIcon(detectorTypeRequiresRestart)}
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
{key}
|
{key}
|
||||||
</span>
|
</span>
|
||||||
@ -707,7 +734,7 @@ export function DetectorHardwareField(props: FieldProps) {
|
|||||||
<div className="space-y-4 border-t p-4">
|
<div className="space-y-4 border-t p-4">
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>
|
<Label className="flex items-center">
|
||||||
{t("label.ID", {
|
{t("label.ID", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
defaultValue: "ID",
|
defaultValue: "ID",
|
||||||
@ -746,7 +773,7 @@ export function DetectorHardwareField(props: FieldProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3 space-y-2">
|
<div className="col-span-3 space-y-2">
|
||||||
<Label>
|
<Label className="flex items-center">
|
||||||
{t("detectors.type.label", {
|
{t("detectors.type.label", {
|
||||||
ns: configNamespace,
|
ns: configNamespace,
|
||||||
defaultValue: "Type",
|
defaultValue: "Type",
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import { ConfigFormContext } from "@/types/configForm";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { LuExternalLink } from "react-icons/lu";
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
|
import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||||
|
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||||
import {
|
import {
|
||||||
buildTranslationPath,
|
buildTranslationPath,
|
||||||
getFilterObjectLabel,
|
getFilterObjectLabel,
|
||||||
@ -196,6 +198,13 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
const fieldDocsUrl = fieldDocsPath
|
const fieldDocsUrl = fieldDocsPath
|
||||||
? getLocaleDocUrl(fieldDocsPath)
|
? getLocaleDocUrl(fieldDocsPath)
|
||||||
: undefined;
|
: 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)
|
// Use schema title/description as primary source (from JSON Schema)
|
||||||
const schemaTitle = schema.title;
|
const schemaTitle = schema.title;
|
||||||
@ -449,6 +458,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
>
|
>
|
||||||
{finalLabel}
|
{finalLabel}
|
||||||
{required && <span className="ml-1 text-destructive">*</span>}
|
{required && <span className="ml-1 text-destructive">*</span>}
|
||||||
|
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||||
</Label>
|
</Label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -465,6 +475,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
>
|
>
|
||||||
{finalLabel}
|
{finalLabel}
|
||||||
{required && <span className="ml-1 text-destructive">*</span>}
|
{required && <span className="ml-1 text-destructive">*</span>}
|
||||||
|
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||||
</Label>
|
</Label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -485,6 +496,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
>
|
>
|
||||||
{finalLabel}
|
{finalLabel}
|
||||||
{required && <span className="ml-1 text-destructive">*</span>}
|
{required && <span className="ml-1 text-destructive">*</span>}
|
||||||
|
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||||
</Label>
|
</Label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,10 +8,12 @@ import {
|
|||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import { Children, useState, useEffect, useRef } from "react";
|
import { Children, useState, useEffect, useRef } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||||
import { ConfigFormContext } from "@/types/configForm";
|
import { ConfigFormContext } from "@/types/configForm";
|
||||||
import {
|
import {
|
||||||
buildTranslationPath,
|
buildTranslationPath,
|
||||||
@ -44,6 +46,8 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
const baselineFormData = formContext?.baselineFormData;
|
const baselineFormData = formContext?.baselineFormData;
|
||||||
const hiddenFields = formContext?.hiddenFields;
|
const hiddenFields = formContext?.hiddenFields;
|
||||||
const fieldPath = props.fieldPathId.path;
|
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
|
// Strip fields from an object that should be excluded from modification
|
||||||
// detection: fields listed in hiddenFields (stripped from baseline by
|
// detection: fields listed in hiddenFields (stripped from baseline by
|
||||||
@ -164,6 +168,11 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
"views/settings",
|
"views/settings",
|
||||||
"common",
|
"common",
|
||||||
]);
|
]);
|
||||||
|
const objectRequiresRestart = requiresRestartForFieldPath(
|
||||||
|
fieldPath,
|
||||||
|
restartRequired,
|
||||||
|
defaultRequiresRestart,
|
||||||
|
);
|
||||||
|
|
||||||
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
|
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
|
||||||
|
|
||||||
@ -438,11 +447,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle
|
<CardTitle
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm",
|
"flex items-center text-sm",
|
||||||
hasModifiedDescendants && "text-danger",
|
hasModifiedDescendants && "text-danger",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{inferredLabel}
|
{inferredLabel}
|
||||||
|
{objectRequiresRestart && (
|
||||||
|
<RestartRequiredIndicator className="ml-2" />
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{inferredDescription && (
|
{inferredDescription && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
|||||||
32
web/src/components/indicators/RestartRequiredIndicator.tsx
Normal file
32
web/src/components/indicators/RestartRequiredIndicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -38,6 +38,8 @@ export type ConfigFormContext = {
|
|||||||
sectionI18nPrefix?: string;
|
sectionI18nPrefix?: string;
|
||||||
sectionDocs?: string;
|
sectionDocs?: string;
|
||||||
fieldDocs?: Record<string, string>;
|
fieldDocs?: Record<string, string>;
|
||||||
|
restartRequired?: string[];
|
||||||
|
requiresRestart?: boolean;
|
||||||
t?: (key: string, options?: Record<string, unknown>) => string;
|
t?: (key: string, options?: Record<string, unknown>) => string;
|
||||||
renderers?: Record<string, RendererComponent>;
|
renderers?: Record<string, RendererComponent>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -197,6 +197,44 @@ export function buildConfigDataForPath(
|
|||||||
// used; an empty array means "never restart"; otherwise the function checks
|
// used; an empty array means "never restart"; otherwise the function checks
|
||||||
// if any of the listed field paths are present in the overrides object.
|
// 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(
|
export function requiresRestartForOverrides(
|
||||||
overrides: unknown,
|
overrides: unknown,
|
||||||
restartRequired: string[] | undefined,
|
restartRequired: string[] | undefined,
|
||||||
@ -211,8 +249,47 @@ export function requiresRestartForOverrides(
|
|||||||
if (!overrides || typeof overrides !== "object") {
|
if (!overrides || typeof overrides !== "object") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return restartRequired.some(
|
return restartRequired.some((path) => {
|
||||||
(path) => get(overrides as JsonObject, path) !== undefined,
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { SettingsGroupCard } from "@/components/card/SettingsGroupCard";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -13,7 +14,6 @@ import { isDesktop } from "react-device-detect";
|
|||||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Trans } from "react-i18next";
|
import { Trans } from "react-i18next";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { useEnabledState } from "@/api/ws";
|
import { useEnabledState } from "@/api/ws";
|
||||||
|
|
||||||
type CameraManagementViewProps = {
|
type CameraManagementViewProps = {
|
||||||
@ -64,42 +64,43 @@ export default function CameraManagementView({
|
|||||||
position="top-center"
|
position="top-center"
|
||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
<div className="flex size-full flex-col md:flex-row">
|
<div className="flex size-full space-y-6">
|
||||||
<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="scrollbar-container flex-1 overflow-y-auto pb-2">
|
||||||
{viewMode === "settings" ? (
|
{viewMode === "settings" ? (
|
||||||
<>
|
<>
|
||||||
<Heading as="h4" className="mb-2">
|
<Heading as="h4" className="mb-6">
|
||||||
{t("cameraManagement.title")}
|
{t("cameraManagement.title")}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="my-4 flex flex-col gap-4">
|
|
||||||
|
<div className="w-full max-w-5xl space-y-6">
|
||||||
<Button
|
<Button
|
||||||
variant="select"
|
variant="select"
|
||||||
onClick={() => setShowWizard(true)}
|
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" />
|
<LuPlus className="h-4 w-4" />
|
||||||
{t("cameraManagement.addCamera")}
|
{t("cameraManagement.addCamera")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{cameras.length > 0 && (
|
{cameras.length > 0 && (
|
||||||
<>
|
<SettingsGroupCard
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
title={
|
||||||
<div className="max-w-7xl space-y-4">
|
<Trans ns="views/settings">
|
||||||
<Heading as="h4" className="my-2">
|
cameraManagement.streams.title
|
||||||
<Trans ns="views/settings">
|
</Trans>
|
||||||
cameraManagement.streams.title
|
}
|
||||||
</Trans>
|
>
|
||||||
</Heading>
|
<div className="space-y-4">
|
||||||
<div className="mt-3 text-sm text-muted-foreground">
|
<div className="max-w-md text-sm text-muted-foreground">
|
||||||
<Trans ns="views/settings">
|
<Trans ns="views/settings">
|
||||||
cameraManagement.streams.desc
|
cameraManagement.streams.desc
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||||
{cameras.map((camera) => (
|
{cameras.map((camera) => (
|
||||||
<div
|
<div
|
||||||
key={camera}
|
key={camera}
|
||||||
className="flex items-center justify-between smart-capitalize"
|
className="flex flex-row items-center justify-between"
|
||||||
>
|
>
|
||||||
<CameraNameLabel camera={camera} />
|
<CameraNameLabel camera={camera} />
|
||||||
<CameraEnableSwitch cameraName={camera} />
|
<CameraEnableSwitch cameraName={camera} />
|
||||||
@ -107,8 +108,7 @@ export default function CameraManagementView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="mb-2 mt-4 flex bg-secondary" />
|
</SettingsGroupCard>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user