From d5dc77daa456e947b5a4539f68ea9a474a785310 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:11:56 -0500 Subject: [PATCH] add profileName prop to BaseSection for profile-aware config editing --- .../config-form/sections/BaseSection.tsx | 64 +++++++++++++------ 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 047edd449..d2f392944 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -34,6 +34,7 @@ import Heading from "@/components/ui/heading"; import get from "lodash/get"; import cloneDeep from "lodash/cloneDeep"; import isEqual from "lodash/isEqual"; +import merge from "lodash/merge"; import { Collapsible, CollapsibleContent, @@ -136,6 +137,8 @@ export interface BaseSectionProps { cameraName: string | undefined, data: ConfigSectionData | null, ) => void; + /** When set, editing this profile's overrides instead of the base config */ + profileName?: string; } export interface CreateSectionOptions { @@ -166,6 +169,7 @@ export function ConfigSection({ onStatusChange, pendingDataBySection, onPendingDataChange, + profileName, }: ConfigSectionProps) { // For replay level, treat as camera-level config access const effectiveLevel = level === "replay" ? "camera" : level; @@ -181,12 +185,17 @@ export function ConfigSection({ const statusBar = useContext(StatusBarMessagesContext); // Create a key for this section's pending data + // When editing a profile, use "cameraName::profiles.profileName.sectionPath" + const effectiveSectionPath = profileName + ? `profiles.${profileName}.${sectionPath}` + : sectionPath; + const pendingDataKey = useMemo( () => effectiveLevel === "camera" && cameraName - ? `${cameraName}::${sectionPath}` - : sectionPath, - [effectiveLevel, cameraName, sectionPath], + ? `${cameraName}::${effectiveSectionPath}` + : effectiveSectionPath, + [effectiveLevel, cameraName, effectiveSectionPath], ); // Use pending data from parent if available, otherwise use local state @@ -213,12 +222,12 @@ export function ConfigSection({ const setPendingData = useCallback( (data: ConfigSectionData | null) => { if (onPendingDataChange) { - onPendingDataChange(sectionPath, cameraName, data); + onPendingDataChange(effectiveSectionPath, cameraName, data); } else { setLocalPendingData(data); } }, - [onPendingDataChange, sectionPath, cameraName], + [onPendingDataChange, effectiveSectionPath, cameraName], ); const [isSaving, setIsSaving] = useState(false); const [hasValidationErrors, setHasValidationErrors] = useState(false); @@ -230,8 +239,10 @@ export function ConfigSection({ const isInitializingRef = useRef(true); const lastPendingDataKeyRef = useRef(null); - const updateTopic = - effectiveLevel === "camera" && cameraName + // Profile definitions don't hot-reload — only PUT /api/profile/set applies them + const updateTopic = profileName + ? undefined + : effectiveLevel === "camera" && cameraName ? cameraUpdateTopicMap[sectionPath] ? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}` : undefined @@ -265,15 +276,27 @@ export function ConfigSection({ }); // Get current form data + // When editing a profile, show base camera config deep-merged with profile overrides const rawSectionValue = useMemo(() => { if (!config) return undefined; if (effectiveLevel === "camera" && cameraName) { - return get(config.cameras?.[cameraName], sectionPath); + const baseValue = get(config.cameras?.[cameraName], sectionPath); + if (profileName) { + const profileOverrides = get( + config.cameras?.[cameraName], + `profiles.${profileName}.${sectionPath}`, + ); + if (profileOverrides && typeof profileOverrides === "object") { + return merge(cloneDeep(baseValue ?? {}), cloneDeep(profileOverrides)); + } + return baseValue; + } + return baseValue; } return get(config, sectionPath); - }, [config, cameraName, sectionPath, effectiveLevel]); + }, [config, cameraName, sectionPath, effectiveLevel, profileName]); const rawFormData = useMemo(() => { if (!config) return {}; @@ -499,8 +522,8 @@ export function ConfigSection({ try { const basePath = effectiveLevel === "camera" && cameraName - ? `cameras.${cameraName}.${sectionPath}` - : sectionPath; + ? `cameras.${cameraName}.${effectiveSectionPath}` + : effectiveSectionPath; const rawData = sanitizeSectionData(rawFormData); const overrides = buildOverrides( pendingData, @@ -522,9 +545,11 @@ export function ConfigSection({ return; } - const needsRestart = skipSave - ? false - : requiresRestartForOverrides(sanitizedOverrides); + // Profile definition edits never require restart + const needsRestart = + skipSave || profileName + ? false + : requiresRestartForOverrides(sanitizedOverrides); const configData = buildConfigDataForPath(basePath, sanitizedOverrides); await axios.put("config/set", { @@ -619,6 +644,8 @@ export function ConfigSection({ } }, [ sectionPath, + effectiveSectionPath, + profileName, pendingData, effectiveLevel, cameraName, @@ -642,8 +669,8 @@ export function ConfigSection({ try { const basePath = effectiveLevel === "camera" && cameraName - ? `cameras.${cameraName}.${sectionPath}` - : sectionPath; + ? `cameras.${cameraName}.${effectiveSectionPath}` + : effectiveSectionPath; const configData = buildConfigDataForPath(basePath, ""); @@ -675,7 +702,7 @@ export function ConfigSection({ ); } }, [ - sectionPath, + effectiveSectionPath, effectiveLevel, cameraName, requiresRestart, @@ -855,7 +882,8 @@ export function ConfigSection({ {((effectiveLevel === "camera" && isOverridden) || effectiveLevel === "global") && !hasChanges && - !skipSave && ( + !skipSave && + !profileName && (