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 = {