add profile state management and save preview support

This commit is contained in:
Josh Hawkins 2026-03-09 15:06:11 -05:00
parent 72b4a4ddad
commit edf7fcb5b4
3 changed files with 134 additions and 7 deletions

View File

@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
export type SaveAllPreviewItem = { export type SaveAllPreviewItem = {
scope: "global" | "camera"; scope: "global" | "camera";
cameraName?: string; cameraName?: string;
profileName?: string;
fieldPath: string; fieldPath: string;
value: unknown; value: unknown;
}; };
@ -114,6 +115,18 @@ export default function SaveAllPreviewPopover({
})} })}
</span> </span>
<span className="truncate">{scopeLabel}</span> <span className="truncate">{scopeLabel}</span>
{item.profileName && (
<>
<span className="text-muted-foreground">
{t("saveAllPreview.profile.label", {
ns: "views/settings",
})}
</span>
<span className="truncate font-medium">
{item.profileName}
</span>
</>
)}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("saveAllPreview.field.label", { {t("saveAllPreview.field.label", {
ns: "views/settings", ns: "views/settings",

View File

@ -87,8 +87,10 @@ import { mutate } from "swr";
import { RJSFSchema } from "@rjsf/utils"; import { RJSFSchema } from "@rjsf/utils";
import { import {
buildConfigDataForPath, buildConfigDataForPath,
parseProfileFromSectionPath,
prepareSectionSavePayload, prepareSectionSavePayload,
} from "@/utils/configUtil"; } from "@/utils/configUtil";
import type { ProfileState } from "@/types/profile";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import SaveAllPreviewPopover, { import SaveAllPreviewPopover, {
@ -621,6 +623,22 @@ export default function Settings() {
Record<string, unknown> Record<string, unknown>
>({}); >({});
// Profile editing state
const [editingProfile, setEditingProfile] = useState<
Record<string, string | null>
>({});
const [newProfiles, setNewProfiles] = useState<string[]>([]);
const allProfileNames = useMemo(() => {
if (!config) return [];
const names = new Set<string>();
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 navigate = useNavigate();
const cameras = useMemo(() => { const cameras = useMemo(() => {
@ -692,11 +710,20 @@ export default function Settings() {
const { scope, cameraName, sectionPath } = const { scope, cameraName, sectionPath } =
parsePendingDataKey(pendingDataKey); parsePendingDataKey(pendingDataKey);
const { isProfile, profileName, actualSection } =
parseProfileFromSectionPath(sectionPath);
const flattened = flattenOverrides(payload.sanitizedOverrides); const flattened = flattenOverrides(payload.sanitizedOverrides);
const displaySection = isProfile ? actualSection : sectionPath;
flattened.forEach(({ path, value }) => { flattened.forEach(({ path, value }) => {
const fieldPath = path ? `${sectionPath}.${path}` : sectionPath; const fieldPath = path ? `${displaySection}.${path}` : displaySection;
items.push({ scope, cameraName, fieldPath, value }); items.push({
scope,
cameraName,
profileName: isProfile ? profileName : undefined,
fieldPath,
value,
});
}); });
}, },
); );
@ -726,15 +753,20 @@ export default function Settings() {
level = "global"; level = "global";
} }
// For profile keys like "profiles.armed.detect", extract the actual section
const { actualSection } = parseProfileFromSectionPath(sectionPath);
if (level === "camera") { if (level === "camera") {
return CAMERA_SECTION_MAPPING[sectionPath] as SettingsType | undefined; return CAMERA_SECTION_MAPPING[actualSection] as
| SettingsType
| undefined;
} }
return ( return (
(GLOBAL_SECTION_MAPPING[sectionPath] as SettingsType | undefined) ?? (GLOBAL_SECTION_MAPPING[actualSection] as SettingsType | undefined) ??
(ENRICHMENTS_SECTION_MAPPING[sectionPath] as (ENRICHMENTS_SECTION_MAPPING[actualSection] as
| SettingsType | SettingsType
| undefined) ?? | undefined) ??
(SYSTEM_SECTION_MAPPING[sectionPath] as SettingsType | undefined) (SYSTEM_SECTION_MAPPING[actualSection] as SettingsType | undefined)
); );
}, },
[], [],
@ -884,6 +916,16 @@ export default function Settings() {
setPendingDataBySection({}); setPendingDataBySection({});
setUnsavedChanges(false); setUnsavedChanges(false);
setEditingProfile({});
// Clear new profiles that don't exist in saved config
if (config) {
const savedNames = new Set<string>();
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) => { setSectionStatusByKey((prev) => {
const updated = { ...prev }; const updated = { ...prev };
@ -899,7 +941,7 @@ export default function Settings() {
} }
return updated; return updated;
}); });
}, [pendingDataBySection, pendingKeyToMenuKey]); }, [pendingDataBySection, pendingKeyToMenuKey, config]);
const handleDialog = useCallback( const handleDialog = useCallback(
(save: boolean) => { (save: boolean) => {
@ -970,6 +1012,75 @@ export default function Settings() {
} }
}, [t, contentMobileOpen]); }, [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( const handleSectionStatusChange = useCallback(
(sectionKey: string, level: "global" | "camera", status: SectionStatus) => { (sectionKey: string, level: "global" | "camera", status: SectionStatus) => {
// Map section keys to menu keys based on level // Map section keys to menu keys based on level
@ -1244,6 +1355,7 @@ export default function Settings() {
onSectionStatusChange={handleSectionStatusChange} onSectionStatusChange={handleSectionStatusChange}
pendingDataBySection={pendingDataBySection} pendingDataBySection={pendingDataBySection}
onPendingDataChange={handlePendingDataChange} onPendingDataChange={handlePendingDataChange}
profileState={profileState}
/> />
); );
})()} })()}

View File

@ -5,6 +5,7 @@ import { ConfigSectionTemplate } from "@/components/config-form/sections";
import type { PolygonType } from "@/types/canvas"; import type { PolygonType } from "@/types/canvas";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import type { ConfigSectionData } from "@/types/configForm"; import type { ConfigSectionData } from "@/types/configForm";
import type { ProfileState } from "@/types/profile";
import { getSectionConfig } from "@/utils/configUtil"; import { getSectionConfig } from "@/utils/configUtil";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@ -26,6 +27,7 @@ export type SettingsPageProps = {
cameraName: string | undefined, cameraName: string | undefined,
data: ConfigSectionData | null, data: ConfigSectionData | null,
) => void; ) => void;
profileState?: ProfileState;
}; };
export type SectionStatus = { export type SectionStatus = {