From edf7fcb5b44793ed1c0c5930a128f1c19655607b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:06:11 -0500 Subject: [PATCH] add profile state management and save preview support --- .../overlay/detail/SaveAllPreviewPopover.tsx | 13 ++ web/src/pages/Settings.tsx | 126 +++++++++++++++++- web/src/views/settings/SingleSectionPage.tsx | 2 + 3 files changed, 134 insertions(+), 7 deletions(-) diff --git a/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx b/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx index 399051145..a77593531 100644 --- a/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx +++ b/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx @@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"; export type SaveAllPreviewItem = { scope: "global" | "camera"; cameraName?: string; + profileName?: string; fieldPath: string; value: unknown; }; @@ -114,6 +115,18 @@ export default function SaveAllPreviewPopover({ })} {scopeLabel} + {item.profileName && ( + <> + + {t("saveAllPreview.profile.label", { + ns: "views/settings", + })} + + + {item.profileName} + + + )} {t("saveAllPreview.field.label", { ns: "views/settings", diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 2f069da78..00b9fdf68 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -87,8 +87,10 @@ import { mutate } from "swr"; import { RJSFSchema } from "@rjsf/utils"; import { buildConfigDataForPath, + parseProfileFromSectionPath, prepareSectionSavePayload, } from "@/utils/configUtil"; +import type { ProfileState } from "@/types/profile"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import SaveAllPreviewPopover, { @@ -621,6 +623,22 @@ export default function Settings() { Record >({}); + // Profile editing state + const [editingProfile, setEditingProfile] = useState< + Record + >({}); + const [newProfiles, setNewProfiles] = useState([]); + + const allProfileNames = useMemo(() => { + if (!config) return []; + const names = new Set(); + Object.values(config.cameras).forEach((cam) => { + Object.keys(cam.profiles ?? {}).forEach((p) => names.add(p)); + }); + newProfiles.forEach((p) => names.add(p)); + return [...names].sort(); + }, [config, newProfiles]); + const navigate = useNavigate(); const cameras = useMemo(() => { @@ -692,11 +710,20 @@ export default function Settings() { const { scope, cameraName, sectionPath } = parsePendingDataKey(pendingDataKey); + const { isProfile, profileName, actualSection } = + parseProfileFromSectionPath(sectionPath); const flattened = flattenOverrides(payload.sanitizedOverrides); + const displaySection = isProfile ? actualSection : sectionPath; flattened.forEach(({ path, value }) => { - const fieldPath = path ? `${sectionPath}.${path}` : sectionPath; - items.push({ scope, cameraName, fieldPath, value }); + const fieldPath = path ? `${displaySection}.${path}` : displaySection; + items.push({ + scope, + cameraName, + profileName: isProfile ? profileName : undefined, + fieldPath, + value, + }); }); }, ); @@ -726,15 +753,20 @@ export default function Settings() { level = "global"; } + // For profile keys like "profiles.armed.detect", extract the actual section + const { actualSection } = parseProfileFromSectionPath(sectionPath); + if (level === "camera") { - return CAMERA_SECTION_MAPPING[sectionPath] as SettingsType | undefined; + return CAMERA_SECTION_MAPPING[actualSection] as + | SettingsType + | undefined; } return ( - (GLOBAL_SECTION_MAPPING[sectionPath] as SettingsType | undefined) ?? - (ENRICHMENTS_SECTION_MAPPING[sectionPath] as + (GLOBAL_SECTION_MAPPING[actualSection] as SettingsType | undefined) ?? + (ENRICHMENTS_SECTION_MAPPING[actualSection] as | SettingsType | undefined) ?? - (SYSTEM_SECTION_MAPPING[sectionPath] as SettingsType | undefined) + (SYSTEM_SECTION_MAPPING[actualSection] as SettingsType | undefined) ); }, [], @@ -884,6 +916,16 @@ export default function Settings() { setPendingDataBySection({}); setUnsavedChanges(false); + setEditingProfile({}); + + // Clear new profiles that don't exist in saved config + if (config) { + const savedNames = new Set(); + Object.values(config.cameras).forEach((cam) => { + Object.keys(cam.profiles ?? {}).forEach((p) => savedNames.add(p)); + }); + setNewProfiles((prev) => prev.filter((p) => savedNames.has(p))); + } setSectionStatusByKey((prev) => { const updated = { ...prev }; @@ -899,7 +941,7 @@ export default function Settings() { } return updated; }); - }, [pendingDataBySection, pendingKeyToMenuKey]); + }, [pendingDataBySection, pendingKeyToMenuKey, config]); const handleDialog = useCallback( (save: boolean) => { @@ -970,6 +1012,75 @@ export default function Settings() { } }, [t, contentMobileOpen]); + // Profile state handlers + const handleSelectProfile = useCallback( + (camera: string, section: string, profile: string | null) => { + const key = `${camera}::${section}`; + setEditingProfile((prev) => { + if (profile === null) { + const { [key]: _, ...rest } = prev; + return rest; + } + return { ...prev, [key]: profile }; + }); + }, + [], + ); + + const handleAddProfile = useCallback((name: string) => { + setNewProfiles((prev) => (prev.includes(name) ? prev : [...prev, name])); + }, []); + + const handleDeleteProfileSection = useCallback( + async (camera: string, section: string, profile: string) => { + try { + await axios.put("config/set", { + config_data: { + cameras: { + [camera]: { + profiles: { + [profile]: { + [section]: "", + }, + }, + }, + }, + }, + }); + await mutate("config"); + // Switch back to base config + handleSelectProfile(camera, section, null); + toast.success( + t("toast.save.success", { + ns: "common", + }), + ); + } catch { + toast.error(t("toast.save.error.title", { ns: "common" })); + } + }, + [handleSelectProfile, t], + ); + + const profileState: ProfileState = useMemo( + () => ({ + editingProfile, + newProfiles, + allProfileNames, + onSelectProfile: handleSelectProfile, + onAddProfile: handleAddProfile, + onDeleteProfileSection: handleDeleteProfileSection, + }), + [ + editingProfile, + newProfiles, + allProfileNames, + handleSelectProfile, + handleAddProfile, + handleDeleteProfileSection, + ], + ); + const handleSectionStatusChange = useCallback( (sectionKey: string, level: "global" | "camera", status: SectionStatus) => { // Map section keys to menu keys based on level @@ -1244,6 +1355,7 @@ export default function Settings() { onSectionStatusChange={handleSectionStatusChange} pendingDataBySection={pendingDataBySection} onPendingDataChange={handlePendingDataChange} + profileState={profileState} /> ); })()} diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index c1e7752f7..c1027aa18 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -5,6 +5,7 @@ import { ConfigSectionTemplate } from "@/components/config-form/sections"; import type { PolygonType } from "@/types/canvas"; import { Badge } from "@/components/ui/badge"; import type { ConfigSectionData } from "@/types/configForm"; +import type { ProfileState } from "@/types/profile"; import { getSectionConfig } from "@/utils/configUtil"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { Link } from "react-router-dom"; @@ -26,6 +27,7 @@ export type SettingsPageProps = { cameraName: string | undefined, data: ConfigSectionData | null, ) => void; + profileState?: ProfileState; }; export type SectionStatus = {