fix save button race conditions, add reset spinner, and fix enrichments profile leak

- Disable both Save and SaveAll buttons while either operation is in progress so users cannot trigger concurrent saves
- Show activity indicator on Reset to Default/Global button during the API call
- Enrichments panes (semantic search, genai, face recognition) now always show base config fields regardless of profile selection in the header dropdown
This commit is contained in:
Josh Hawkins 2026-03-26 10:35:03 -05:00
parent 996967dece
commit 5f61079f81
3 changed files with 43 additions and 7 deletions

View File

@ -152,6 +152,10 @@ export interface BaseSectionProps {
profileBorderColor?: string; profileBorderColor?: string;
/** Callback to delete the current profile's overrides for this section */ /** Callback to delete the current profile's overrides for this section */
onDeleteProfileSection?: () => void; onDeleteProfileSection?: () => void;
/** Whether a SaveAll operation is in progress (disables individual Save) */
isSavingAll?: boolean;
/** Callback when this section's saving state changes */
onSavingChange?: (isSaving: boolean) => void;
} }
export interface CreateSectionOptions { export interface CreateSectionOptions {
@ -186,6 +190,8 @@ export function ConfigSection({
profileFriendlyName, profileFriendlyName,
profileBorderColor, profileBorderColor,
onDeleteProfileSection, onDeleteProfileSection,
isSavingAll = false,
onSavingChange,
}: 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;
@ -246,6 +252,7 @@ export function ConfigSection({
[onPendingDataChange, effectiveSectionPath, cameraName], [onPendingDataChange, effectiveSectionPath, cameraName],
); );
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isResettingToDefault, setIsResettingToDefault] = useState(false);
const [hasValidationErrors, setHasValidationErrors] = useState(false); const [hasValidationErrors, setHasValidationErrors] = useState(false);
const [extraHasChanges, setExtraHasChanges] = useState(false); const [extraHasChanges, setExtraHasChanges] = useState(false);
const [formKey, setFormKey] = useState(0); const [formKey, setFormKey] = useState(0);
@ -577,6 +584,7 @@ export function ConfigSection({
if (!pendingData) return; if (!pendingData) return;
setIsSaving(true); setIsSaving(true);
onSavingChange?.(true);
try { try {
const basePath = const basePath =
effectiveLevel === "camera" && cameraName effectiveLevel === "camera" && cameraName
@ -699,6 +707,7 @@ export function ConfigSection({
} }
} finally { } finally {
setIsSaving(false); setIsSaving(false);
onSavingChange?.(false);
} }
}, [ }, [
sectionPath, sectionPath,
@ -718,12 +727,14 @@ export function ConfigSection({
setPendingData, setPendingData,
requiresRestartForOverrides, requiresRestartForOverrides,
skipSave, skipSave,
onSavingChange,
]); ]);
// Handle reset to global/defaults - removes camera-level override or resets global to defaults // Handle reset to global/defaults - removes camera-level override or resets global to defaults
const handleResetToGlobal = useCallback(async () => { const handleResetToGlobal = useCallback(async () => {
if (effectiveLevel === "camera" && !cameraName) return; if (effectiveLevel === "camera" && !cameraName) return;
setIsResettingToDefault(true);
try { try {
const basePath = const basePath =
effectiveLevel === "camera" && cameraName effectiveLevel === "camera" && cameraName
@ -758,6 +769,8 @@ export function ConfigSection({
defaultValue: "Failed to reset settings", defaultValue: "Failed to reset settings",
}), }),
); );
} finally {
setIsResettingToDefault(false);
} }
}, [ }, [
effectiveSectionPath, effectiveSectionPath,
@ -945,9 +958,12 @@ export function ConfigSection({
<Button <Button
onClick={() => setIsResetDialogOpen(true)} onClick={() => setIsResetDialogOpen(true)}
variant="outline" variant="outline"
disabled={isSaving || disabled} disabled={isSaving || isResettingToDefault || disabled}
className="flex flex-1 gap-2" className="flex flex-1 gap-2"
> >
{isResettingToDefault && (
<ActivityIndicator className="h-4 w-4" />
)}
{effectiveLevel === "global" {effectiveLevel === "global"
? t("button.resetToDefault", { ? t("button.resetToDefault", {
ns: "common", ns: "common",
@ -990,7 +1006,11 @@ export function ConfigSection({
onClick={handleSave} onClick={handleSave}
variant="select" variant="select"
disabled={ disabled={
!hasChanges || hasValidationErrors || isSaving || disabled !hasChanges ||
hasValidationErrors ||
isSaving ||
isSavingAll ||
disabled
} }
className="flex min-w-36 flex-1 gap-2" className="flex min-w-36 flex-1 gap-2"
> >

View File

@ -724,6 +724,7 @@ export default function Settings() {
// Save All state // Save All state
const [isSavingAll, setIsSavingAll] = useState(false); const [isSavingAll, setIsSavingAll] = useState(false);
const [isAnySectionSaving, setIsAnySectionSaving] = useState(false);
const [restartDialogOpen, setRestartDialogOpen] = useState(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const { send: sendRestart } = useRestart(); const { send: sendRestart } = useRestart();
const { data: fullSchema } = useSWR<RJSFSchema>("config/schema.json"); const { data: fullSchema } = useSWR<RJSFSchema>("config/schema.json");
@ -1299,6 +1300,10 @@ export default function Settings() {
[], [],
); );
const handleSectionSavingChange = useCallback((saving: boolean) => {
setIsAnySectionSaving(saving);
}, []);
// The active profile being edited for the selected camera // The active profile being edited for the selected camera
const activeEditingProfile = selectedCamera const activeEditingProfile = selectedCamera
? (editingProfile[selectedCamera] ?? null) ? (editingProfile[selectedCamera] ?? null)
@ -1520,7 +1525,7 @@ export default function Settings() {
onClick={handleSaveAll} onClick={handleSaveAll}
variant="select" variant="select"
size="sm" size="sm"
disabled={isSavingAll || hasPendingValidationErrors} disabled={isSavingAll || isAnySectionSaving || hasPendingValidationErrors}
className="flex w-full items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2"
> >
{isSavingAll ? ( {isSavingAll ? (
@ -1606,6 +1611,8 @@ export default function Settings() {
} }
profilesUIEnabled={profilesUIEnabled} profilesUIEnabled={profilesUIEnabled}
setProfilesUIEnabled={setProfilesUIEnabled} setProfilesUIEnabled={setProfilesUIEnabled}
isSavingAll={isSavingAll}
onSectionSavingChange={handleSectionSavingChange}
/> />
); );
})()} })()}
@ -1686,7 +1693,7 @@ export default function Settings() {
variant="select" variant="select"
size="sm" size="sm"
onClick={handleSaveAll} onClick={handleSaveAll}
disabled={isSavingAll || hasPendingValidationErrors} disabled={isSavingAll || isAnySectionSaving || hasPendingValidationErrors}
className="flex items-center justify-center gap-2" className="flex items-center justify-center gap-2"
> >
{isSavingAll ? ( {isSavingAll ? (

View File

@ -39,6 +39,10 @@ export type SettingsPageProps = {
onDeleteProfileSection?: (profileName: string) => void; onDeleteProfileSection?: (profileName: string) => void;
profilesUIEnabled?: boolean; profilesUIEnabled?: boolean;
setProfilesUIEnabled?: React.Dispatch<React.SetStateAction<boolean>>; setProfilesUIEnabled?: React.Dispatch<React.SetStateAction<boolean>>;
/** Whether a SaveAll operation is in progress */
isSavingAll?: boolean;
/** Callback when a section's saving state changes */
onSectionSavingChange?: (isSaving: boolean) => void;
}; };
export type SectionStatus = { export type SectionStatus = {
@ -73,6 +77,8 @@ export function SingleSectionPage({
onPendingDataChange, onPendingDataChange,
profileState, profileState,
onDeleteProfileSection, onDeleteProfileSection,
isSavingAll,
onSectionSavingChange,
}: SingleSectionPageProps) { }: SingleSectionPageProps) {
const sectionNamespace = const sectionNamespace =
level === "camera" ? "config/cameras" : "config/global"; level === "camera" ? "config/cameras" : "config/global";
@ -95,9 +101,10 @@ export function SingleSectionPage({
? getLocaleDocUrl(resolvedSectionConfig.sectionDocs) ? getLocaleDocUrl(resolvedSectionConfig.sectionDocs)
: undefined; : undefined;
const currentEditingProfile = selectedCamera const currentEditingProfile =
? (profileState?.editingProfile[selectedCamera] ?? null) level === "camera" && selectedCamera
: null; ? (profileState?.editingProfile[selectedCamera] ?? null)
: null;
const profileColor = useMemo( const profileColor = useMemo(
() => () =>
@ -273,6 +280,8 @@ export function SingleSectionPage({
onDeleteProfileSection={ onDeleteProfileSection={
currentEditingProfile ? handleDeleteProfileSection : undefined currentEditingProfile ? handleDeleteProfileSection : undefined
} }
isSavingAll={isSavingAll}
onSavingChange={onSectionSavingChange}
/> />
</div> </div>
); );