add profileName prop to BaseSection for profile-aware config editing

This commit is contained in:
Josh Hawkins 2026-03-09 15:11:56 -05:00
parent edf7fcb5b4
commit d5dc77daa4

View File

@ -34,6 +34,7 @@ import Heading from "@/components/ui/heading";
import get from "lodash/get"; import get from "lodash/get";
import cloneDeep from "lodash/cloneDeep"; import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import merge from "lodash/merge";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@ -136,6 +137,8 @@ export interface BaseSectionProps {
cameraName: string | undefined, cameraName: string | undefined,
data: ConfigSectionData | null, data: ConfigSectionData | null,
) => void; ) => void;
/** When set, editing this profile's overrides instead of the base config */
profileName?: string;
} }
export interface CreateSectionOptions { export interface CreateSectionOptions {
@ -166,6 +169,7 @@ export function ConfigSection({
onStatusChange, onStatusChange,
pendingDataBySection, pendingDataBySection,
onPendingDataChange, onPendingDataChange,
profileName,
}: ConfigSectionProps) { }: ConfigSectionProps) {
// For replay level, treat as camera-level config access // For replay level, treat as camera-level config access
const effectiveLevel = level === "replay" ? "camera" : level; const effectiveLevel = level === "replay" ? "camera" : level;
@ -181,12 +185,17 @@ export function ConfigSection({
const statusBar = useContext(StatusBarMessagesContext); const statusBar = useContext(StatusBarMessagesContext);
// Create a key for this section's pending data // 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( const pendingDataKey = useMemo(
() => () =>
effectiveLevel === "camera" && cameraName effectiveLevel === "camera" && cameraName
? `${cameraName}::${sectionPath}` ? `${cameraName}::${effectiveSectionPath}`
: sectionPath, : effectiveSectionPath,
[effectiveLevel, cameraName, sectionPath], [effectiveLevel, cameraName, effectiveSectionPath],
); );
// Use pending data from parent if available, otherwise use local state // Use pending data from parent if available, otherwise use local state
@ -213,12 +222,12 @@ export function ConfigSection({
const setPendingData = useCallback( const setPendingData = useCallback(
(data: ConfigSectionData | null) => { (data: ConfigSectionData | null) => {
if (onPendingDataChange) { if (onPendingDataChange) {
onPendingDataChange(sectionPath, cameraName, data); onPendingDataChange(effectiveSectionPath, cameraName, data);
} else { } else {
setLocalPendingData(data); setLocalPendingData(data);
} }
}, },
[onPendingDataChange, sectionPath, cameraName], [onPendingDataChange, effectiveSectionPath, cameraName],
); );
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [hasValidationErrors, setHasValidationErrors] = useState(false); const [hasValidationErrors, setHasValidationErrors] = useState(false);
@ -230,8 +239,10 @@ export function ConfigSection({
const isInitializingRef = useRef(true); const isInitializingRef = useRef(true);
const lastPendingDataKeyRef = useRef<string | null>(null); const lastPendingDataKeyRef = useRef<string | null>(null);
const updateTopic = // Profile definitions don't hot-reload — only PUT /api/profile/set applies them
effectiveLevel === "camera" && cameraName const updateTopic = profileName
? undefined
: effectiveLevel === "camera" && cameraName
? cameraUpdateTopicMap[sectionPath] ? cameraUpdateTopicMap[sectionPath]
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}` ? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
: undefined : undefined
@ -265,15 +276,27 @@ export function ConfigSection({
}); });
// Get current form data // Get current form data
// When editing a profile, show base camera config deep-merged with profile overrides
const rawSectionValue = useMemo(() => { const rawSectionValue = useMemo(() => {
if (!config) return undefined; if (!config) return undefined;
if (effectiveLevel === "camera" && cameraName) { 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); return get(config, sectionPath);
}, [config, cameraName, sectionPath, effectiveLevel]); }, [config, cameraName, sectionPath, effectiveLevel, profileName]);
const rawFormData = useMemo(() => { const rawFormData = useMemo(() => {
if (!config) return {}; if (!config) return {};
@ -499,8 +522,8 @@ export function ConfigSection({
try { try {
const basePath = const basePath =
effectiveLevel === "camera" && cameraName effectiveLevel === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}` ? `cameras.${cameraName}.${effectiveSectionPath}`
: sectionPath; : effectiveSectionPath;
const rawData = sanitizeSectionData(rawFormData); const rawData = sanitizeSectionData(rawFormData);
const overrides = buildOverrides( const overrides = buildOverrides(
pendingData, pendingData,
@ -522,9 +545,11 @@ export function ConfigSection({
return; return;
} }
const needsRestart = skipSave // Profile definition edits never require restart
? false const needsRestart =
: requiresRestartForOverrides(sanitizedOverrides); skipSave || profileName
? false
: requiresRestartForOverrides(sanitizedOverrides);
const configData = buildConfigDataForPath(basePath, sanitizedOverrides); const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
await axios.put("config/set", { await axios.put("config/set", {
@ -619,6 +644,8 @@ export function ConfigSection({
} }
}, [ }, [
sectionPath, sectionPath,
effectiveSectionPath,
profileName,
pendingData, pendingData,
effectiveLevel, effectiveLevel,
cameraName, cameraName,
@ -642,8 +669,8 @@ export function ConfigSection({
try { try {
const basePath = const basePath =
effectiveLevel === "camera" && cameraName effectiveLevel === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}` ? `cameras.${cameraName}.${effectiveSectionPath}`
: sectionPath; : effectiveSectionPath;
const configData = buildConfigDataForPath(basePath, ""); const configData = buildConfigDataForPath(basePath, "");
@ -675,7 +702,7 @@ export function ConfigSection({
); );
} }
}, [ }, [
sectionPath, effectiveSectionPath,
effectiveLevel, effectiveLevel,
cameraName, cameraName,
requiresRestart, requiresRestart,
@ -855,7 +882,8 @@ export function ConfigSection({
{((effectiveLevel === "camera" && isOverridden) || {((effectiveLevel === "camera" && isOverridden) ||
effectiveLevel === "global") && effectiveLevel === "global") &&
!hasChanges && !hasChanges &&
!skipSave && ( !skipSave &&
!profileName && (
<Button <Button
onClick={() => setIsResetDialogOpen(true)} onClick={() => setIsResetDialogOpen(true)}
variant="outline" variant="outline"