diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json
index 48e46f5e5..67dc057ef 100644
--- a/web/public/locales/en/common.json
+++ b/web/public/locales/en/common.json
@@ -117,6 +117,7 @@
"button": {
"apply": "Apply",
"reset": "Reset",
+ "undo": "Undo",
"done": "Done",
"enabled": "Enabled",
"enable": "Enable",
diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
index 554e66cf1..4cf54d2b9 100644
--- a/web/public/locales/en/views/settings.json
+++ b/web/public/locales/en/views/settings.json
@@ -1290,5 +1290,8 @@
"resetSuccess": "Reset to global defaults",
"resetError": "Failed to reset settings"
},
- "unsavedChanges": "You have unsaved changes"
+ "unsavedChanges": "You have unsaved changes",
+ "confirmReset": "Confirm Reset",
+ "resetToDefaultDescription": "This will reset all settings in this section to their default values. This action cannot be undone.",
+ "resetToGlobalDescription": "This will reset the settings in this section to the global defaults. This action cannot be undone."
}
diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx
index 118f9a2a2..23575082f 100644
--- a/web/src/components/config-form/sections/BaseSection.tsx
+++ b/web/src/components/config-form/sections/BaseSection.tsx
@@ -36,6 +36,16 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
import { applySchemaDefaults } from "@/lib/config-schema";
import { isJsonObject } from "@/lib/utils";
import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm";
@@ -92,6 +102,14 @@ export interface BaseSectionProps {
hasChanges: boolean;
isOverridden: boolean;
}) => void;
+ /** Pending form data keyed by "sectionKey" or "cameraName::sectionKey" */
+ pendingDataBySection?: Record;
+ /** Callback to update pending data for a section */
+ onPendingDataChange?: (
+ sectionKey: string,
+ cameraName: string | undefined,
+ data: ConfigSectionData | null,
+ ) => void;
}
export interface CreateSectionOptions {
@@ -140,6 +158,8 @@ export function ConfigSection({
defaultCollapsed = false,
showTitle,
onStatusChange,
+ pendingDataBySection,
+ onPendingDataChange,
}: ConfigSectionProps) {
const { t, i18n } = useTranslation([
level === "camera" ? "config/cameras" : "config/global",
@@ -148,12 +168,40 @@ export function ConfigSection({
"common",
]);
const [isOpen, setIsOpen] = useState(!defaultCollapsed);
- const [pendingData, setPendingData] = useState(
- null,
+
+ // Create a key for this section's pending data
+ const pendingDataKey = useMemo(
+ () =>
+ level === "camera" && cameraName
+ ? `${cameraName}::${sectionPath}`
+ : sectionPath,
+ [level, cameraName, sectionPath],
+ );
+
+ // Use pending data from parent if available, otherwise use local state
+ const [localPendingData, setLocalPendingData] =
+ useState(null);
+
+ const pendingData =
+ pendingDataBySection !== undefined
+ ? (pendingDataBySection[pendingDataKey] as ConfigSectionData | null)
+ : localPendingData;
+
+ const setPendingData = useCallback(
+ (data: ConfigSectionData | null) => {
+ if (onPendingDataChange) {
+ onPendingDataChange(sectionPath, cameraName, data);
+ } else {
+ setLocalPendingData(data);
+ }
+ },
+ [onPendingDataChange, sectionPath, cameraName],
);
const [isSaving, setIsSaving] = useState(false);
const [formKey, setFormKey] = useState(0);
+ const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
const isResettingRef = useRef(false);
+ const isInitializingRef = useRef(true);
const updateTopic =
level === "camera" && cameraName
@@ -226,9 +274,15 @@ export function ConfigSection({
// Clear pendingData whenever formData changes (e.g., from server refresh)
// This prevents RJSF's initial onChange call from being treated as a user edit
+ // Only clear if pendingData is managed locally (not by parent)
useEffect(() => {
- setPendingData(null);
- }, [formData]);
+ if (!pendingData) {
+ isInitializingRef.current = true;
+ }
+ if (onPendingDataChange === undefined) {
+ setPendingData(null);
+ }
+ }, [formData, pendingData, setPendingData, onPendingDataChange]);
useEffect(() => {
if (isResettingRef.current) {
@@ -323,20 +377,36 @@ export function ConfigSection({
return;
}
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
- if (isEqual(formData, sanitizedData)) {
+ const rawData = sanitizeSectionData(rawFormData as ConfigSectionData);
+ const overrides = buildOverrides(sanitizedData, rawData, schemaDefaults);
+ if (isInitializingRef.current && !pendingData) {
+ isInitializingRef.current = false;
+ if (overrides === undefined) {
+ setPendingData(null);
+ return;
+ }
+ }
+ if (overrides === undefined) {
setPendingData(null);
return;
}
setPendingData(sanitizedData);
},
- [formData, sanitizeSectionData],
+ [
+ pendingData,
+ rawFormData,
+ sanitizeSectionData,
+ buildOverrides,
+ schemaDefaults,
+ setPendingData,
+ ],
);
const handleReset = useCallback(() => {
isResettingRef.current = true;
setPendingData(null);
setFormKey((prev) => prev + 1);
- }, []);
+ }, [setPendingData]);
// Handle save button click
const handleSave = useCallback(async () => {
@@ -433,6 +503,7 @@ export function ConfigSection({
buildOverrides,
schemaDefaults,
updateTopic,
+ setPendingData,
]);
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
@@ -496,6 +567,7 @@ export function ConfigSection({
t,
refreshConfig,
updateTopic,
+ setPendingData,
]);
const sectionValidation = useMemo(
@@ -597,7 +669,7 @@ export function ConfigSection({
{hasChanges && (
-
+
{t("unsavedChanges", {
ns: "views/settings",
defaultValue: "You have unsaved changes",
@@ -606,6 +678,26 @@ export function ConfigSection({
)}
+ {((level === "camera" && isOverridden) || level === "global") &&
+ !hasChanges && (
+
+ )}
{hasChanges && (
)}
+
+
+
+
+
+ {t("confirmReset", { ns: "views/settings" })}
+
+
+ {level === "global"
+ ? t("resetToDefaultDescription", { ns: "views/settings" })
+ : t("resetToGlobalDescription", { ns: "views/settings" })}
+
+
+
+
+ {t("cancel", { ns: "common" })}
+
+ {
+ await handleResetToGlobal();
+ setIsResetDialogOpen(false);
+ }}
+ >
+ {level === "global"
+ ? t("button.resetToDefault", { ns: "common" })
+ : t("button.resetToGlobal", { ns: "common" })}
+
+
+
+
);
@@ -664,28 +787,6 @@ export function ConfigSection({
)}
- {((level === "camera" && isOverridden) || level === "global") && (
-
- )}
@@ -724,52 +825,9 @@ export function ConfigSection({
)}
- {((level === "camera" && isOverridden) || level === "global") && (
-
- )}
)}
- {/* Reset button when title is hidden but we're at camera level with override */}
- {!shouldShowTitle &&
- ((level === "camera" && isOverridden) || level === "global") && (
-
-
-
- )}
-
{sectionContent}
);
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx
index 684ee7e0c..ef75e31c5 100644
--- a/web/src/pages/Settings.tsx
+++ b/web/src/pages/Settings.tsx
@@ -28,6 +28,7 @@ import useOptimisticState from "@/hooks/use-optimistic-state";
import { isMobile } from "react-device-detect";
import { FaVideo } from "react-icons/fa";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
+import type { ConfigSectionData } from "@/types/configForm";
import useSWR from "swr";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
@@ -539,6 +540,11 @@ export default function Settings() {
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
+ // Store pending form data keyed by "sectionKey" or "cameraName::sectionKey"
+ const [pendingDataBySection, setPendingDataBySection] = useState<
+ Record
+ >({});
+
const navigate = useNavigate();
const cameras = useMemo(() => {
@@ -666,6 +672,30 @@ export default function Settings() {
[],
);
+ const handlePendingDataChange = useCallback(
+ (
+ sectionKey: string,
+ cameraName: string | undefined,
+ data: ConfigSectionData | null,
+ ) => {
+ const pendingDataKey = cameraName
+ ? `${cameraName}::${sectionKey}`
+ : sectionKey;
+
+ setPendingDataBySection((prev) => {
+ if (data === null) {
+ const { [pendingDataKey]: _, ...rest } = prev;
+ return rest;
+ }
+ return {
+ ...prev,
+ [pendingDataKey]: data,
+ };
+ });
+ },
+ [],
+ );
+
// Initialize override status for all camera sections
useEffect(() => {
if (!selectedCamera || !cameraOverrides) return;
@@ -701,8 +731,7 @@ export default function Settings() {
const status = sectionStatusByKey[key];
const showOverrideDot =
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
- // const showUnsavedDot = status?.hasChanges;
- const showUnsavedDot = false; // Disable unsaved changes indicator for now
+ const showUnsavedDot = status?.hasChanges;
return (
@@ -824,6 +853,8 @@ export default function Settings() {
setUnsavedChanges={setUnsavedChanges}
selectedZoneMask={filterZoneMask}
onSectionStatusChange={handleSectionStatusChange}
+ pendingDataBySection={pendingDataBySection}
+ onPendingDataChange={handlePendingDataChange}
/>
);
})()}
@@ -983,6 +1014,8 @@ export default function Settings() {
setUnsavedChanges={setUnsavedChanges}
selectedZoneMask={filterZoneMask}
onSectionStatusChange={handleSectionStatusChange}
+ pendingDataBySection={pendingDataBySection}
+ onPendingDataChange={handlePendingDataChange}
/>
);
})()}
diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx
index 97184e634..40be4f3cb 100644
--- a/web/src/views/settings/SingleSectionPage.tsx
+++ b/web/src/views/settings/SingleSectionPage.tsx
@@ -1,10 +1,10 @@
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { useTranslation } from "react-i18next";
-import Heading from "@/components/ui/heading";
import type { SectionConfig } from "@/components/config-form/sections";
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";
export type SettingsPageProps = {
selectedCamera?: string;
@@ -15,6 +15,12 @@ export type SettingsPageProps = {
level: "global" | "camera",
status: SectionStatus,
) => void;
+ pendingDataBySection?: Record
;
+ onPendingDataChange?: (
+ sectionKey: string,
+ cameraName: string | undefined,
+ data: ConfigSectionData | null,
+ ) => void;
};
export type SectionStatus = {
@@ -41,7 +47,8 @@ export function SingleSectionPage({
showOverrideIndicator = true,
selectedCamera,
setUnsavedChanges,
- onSectionStatusChange,
+ pendingDataBySection,
+ onPendingDataChange,
}: SingleSectionPageProps) {
const sectionNamespace =
level === "camera" ? "config/cameras" : "config/global";
@@ -55,10 +62,6 @@ export function SingleSectionPage({
isOverridden: false,
});
- useEffect(() => {
- onSectionStatusChange?.(sectionKey, level, sectionStatus);
- }, [onSectionStatusChange, sectionKey, level, sectionStatus]);
-
if (level === "camera" && !selectedCamera) {
return (
@@ -68,32 +71,44 @@ export function SingleSectionPage({
}
return (
-
-
-
- {t(`${sectionKey}.label`, { ns: sectionNamespace })}
-
-
- {i18n.exists(`${sectionKey}.description`, {
- ns: sectionNamespace,
- }) && (
-
- {t(`${sectionKey}.description`, { ns: sectionNamespace })}
-
- )}
-
- {level === "camera" &&
- showOverrideIndicator &&
- sectionStatus.isOverridden && (
-
- {t("overridden", { ns: "common", defaultValue: "Overridden" })}
+
+
+
+
+ {t(`${sectionKey}.label`, { ns: sectionNamespace })}
+
+ {i18n.exists(`${sectionKey}.description`, {
+ ns: sectionNamespace,
+ }) && (
+
+ {t(`${sectionKey}.description`, { ns: sectionNamespace })}
+
+ )}
+
+
+
+ {level === "camera" &&
+ showOverrideIndicator &&
+ sectionStatus.isOverridden && (
+
+ {t("overridden", {
+ ns: "common",
+ defaultValue: "Overridden",
+ })}
+
+ )}
+ {sectionStatus.hasChanges && (
+
+ {t("modified", { ns: "common", defaultValue: "Modified" })}
)}
- {sectionStatus.hasChanges && (
-
- {t("modified", { ns: "common", defaultValue: "Modified" })}
-
- )}
+
setUnsavedChanges?.(false)}
showTitle={false}
sectionConfig={sectionConfig}
+ pendingDataBySection={pendingDataBySection}
+ onPendingDataChange={onPendingDataChange}
requiresRestart={requiresRestart}
onStatusChange={setSectionStatus}
/>