mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-28 19:18:22 +03:00
refactor to shared utils and add save all button
This commit is contained in:
parent
df52529927
commit
f0bd84bf63
@ -156,7 +156,9 @@
|
|||||||
"modified": "Modified",
|
"modified": "Modified",
|
||||||
"overridden": "Overridden",
|
"overridden": "Overridden",
|
||||||
"resetToGlobal": "Reset to Global",
|
"resetToGlobal": "Reset to Global",
|
||||||
"resetToDefault": "Reset to Default"
|
"resetToDefault": "Reset to Default",
|
||||||
|
"saveAll": "Save All",
|
||||||
|
"savingAll": "Saving All…"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"system": "System",
|
"system": "System",
|
||||||
|
|||||||
@ -1311,7 +1311,12 @@
|
|||||||
"error": "Failed to save settings",
|
"error": "Failed to save settings",
|
||||||
"validationError": "Validation failed: {{message}}",
|
"validationError": "Validation failed: {{message}}",
|
||||||
"resetSuccess": "Reset to global defaults",
|
"resetSuccess": "Reset to global defaults",
|
||||||
"resetError": "Failed to reset settings"
|
"resetError": "Failed to reset settings",
|
||||||
|
"saveAllSuccess_one": "Saved {{count}} section successfully.",
|
||||||
|
"saveAllSuccess_other": "All {{count}} sections saved successfully.",
|
||||||
|
"saveAllPartial_one": "{{successCount}} of {{totalCount}} section saved. {{failCount}} failed.",
|
||||||
|
"saveAllPartial_other": "{{successCount}} of {{totalCount}} sections saved. {{failCount}} failed.",
|
||||||
|
"saveAllFailure": "Failed to save all sections."
|
||||||
},
|
},
|
||||||
"unsavedChanges": "You have unsaved changes",
|
"unsavedChanges": "You have unsaved changes",
|
||||||
"confirmReset": "Confirm Reset",
|
"confirmReset": "Confirm Reset",
|
||||||
|
|||||||
@ -17,10 +17,7 @@ import {
|
|||||||
sanitizeOverridesForSection,
|
sanitizeOverridesForSection,
|
||||||
} from "./section-special-cases";
|
} from "./section-special-cases";
|
||||||
import { getSectionValidation } from "../section-validations";
|
import { getSectionValidation } from "../section-validations";
|
||||||
import {
|
import { useConfigOverride } from "@/hooks/use-config-override";
|
||||||
useConfigOverride,
|
|
||||||
normalizeConfigValue,
|
|
||||||
} from "@/hooks/use-config-override";
|
|
||||||
import { useSectionSchema } from "@/hooks/use-config-schema";
|
import { useSectionSchema } from "@/hooks/use-config-schema";
|
||||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@ -28,7 +25,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import get from "lodash/get";
|
import get from "lodash/get";
|
||||||
import unset from "lodash/unset";
|
|
||||||
import cloneDeep from "lodash/cloneDeep";
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
import isEqual from "lodash/isEqual";
|
import isEqual from "lodash/isEqual";
|
||||||
import {
|
import {
|
||||||
@ -47,9 +43,15 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||||
import { cn, isJsonObject } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm";
|
import { ConfigSectionData, JsonValue } from "@/types/configForm";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import {
|
||||||
|
cameraUpdateTopicMap,
|
||||||
|
buildOverrides,
|
||||||
|
sanitizeSectionData as sharedSanitizeSectionData,
|
||||||
|
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
|
||||||
|
} from "@/utils/configSaveUtil";
|
||||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||||
import { useRestart } from "@/api/ws";
|
import { useRestart } from "@/api/ws";
|
||||||
|
|
||||||
@ -128,28 +130,6 @@ export interface CreateSectionOptions {
|
|||||||
defaultConfig: SectionConfig;
|
defaultConfig: SectionConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cameraUpdateTopicMap: Record<string, string> = {
|
|
||||||
detect: "detect",
|
|
||||||
record: "record",
|
|
||||||
snapshots: "snapshots",
|
|
||||||
motion: "motion",
|
|
||||||
objects: "objects",
|
|
||||||
review: "review",
|
|
||||||
audio: "audio",
|
|
||||||
notifications: "notifications",
|
|
||||||
live: "live",
|
|
||||||
timestamp_style: "timestamp_style",
|
|
||||||
audio_transcription: "audio_transcription",
|
|
||||||
birdseye: "birdseye",
|
|
||||||
face_recognition: "face_recognition",
|
|
||||||
ffmpeg: "ffmpeg",
|
|
||||||
lpr: "lpr",
|
|
||||||
semantic_search: "semantic_search",
|
|
||||||
mqtt: "mqtt",
|
|
||||||
onvif: "onvif",
|
|
||||||
ui: "ui",
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions;
|
export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions;
|
||||||
|
|
||||||
export function ConfigSection({
|
export function ConfigSection({
|
||||||
@ -276,22 +256,8 @@ export function ConfigSection({
|
|||||||
}, [config, rawSectionValue]);
|
}, [config, rawSectionValue]);
|
||||||
|
|
||||||
const sanitizeSectionData = useCallback(
|
const sanitizeSectionData = useCallback(
|
||||||
(data: ConfigSectionData) => {
|
(data: ConfigSectionData) =>
|
||||||
const normalized = normalizeConfigValue(data) as ConfigSectionData;
|
sharedSanitizeSectionData(data, sectionConfig.hiddenFields),
|
||||||
if (
|
|
||||||
!sectionConfig.hiddenFields ||
|
|
||||||
sectionConfig.hiddenFields.length === 0
|
|
||||||
) {
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleaned = cloneDeep(normalized) as ConfigSectionData;
|
|
||||||
sectionConfig.hiddenFields.forEach((path) => {
|
|
||||||
if (!path) return;
|
|
||||||
unset(cleaned, path);
|
|
||||||
});
|
|
||||||
return cleaned;
|
|
||||||
},
|
|
||||||
[sectionConfig.hiddenFields],
|
[sectionConfig.hiddenFields],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -354,94 +320,6 @@ export function ConfigSection({
|
|||||||
}
|
}
|
||||||
}, [formKey]);
|
}, [formKey]);
|
||||||
|
|
||||||
// Build a minimal overrides payload by comparing `current` against `base`
|
|
||||||
// (existing config) and `defaults` (schema defaults).
|
|
||||||
// - Returns `undefined` for null/empty values or when `current` equals `base`
|
|
||||||
// (or equals `defaults` when `base` is undefined).
|
|
||||||
// - For objects, recurses and returns an object containing only keys that
|
|
||||||
// are overridden; returns `undefined` if no keys are overridden.
|
|
||||||
const buildOverrides = useCallback(
|
|
||||||
(
|
|
||||||
current: unknown,
|
|
||||||
base: unknown,
|
|
||||||
defaults: unknown,
|
|
||||||
): unknown | undefined => {
|
|
||||||
if (current === null || current === undefined || current === "") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(current)) {
|
|
||||||
if (
|
|
||||||
current.length === 0 &&
|
|
||||||
(base === undefined || base === null) &&
|
|
||||||
(defaults === undefined || defaults === null)
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(base === undefined &&
|
|
||||||
defaults !== undefined &&
|
|
||||||
isEqual(current, defaults)) ||
|
|
||||||
isEqual(current, base)
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isJsonObject(current)) {
|
|
||||||
const currentObj = current;
|
|
||||||
const baseObj = isJsonObject(base) ? base : undefined;
|
|
||||||
const defaultsObj = isJsonObject(defaults) ? defaults : undefined;
|
|
||||||
|
|
||||||
const result: JsonObject = {};
|
|
||||||
for (const [key, value] of Object.entries(currentObj)) {
|
|
||||||
if (value === undefined && baseObj && baseObj[key] !== undefined) {
|
|
||||||
result[key] = "";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const overrideValue = buildOverrides(
|
|
||||||
value,
|
|
||||||
baseObj ? baseObj[key] : undefined,
|
|
||||||
defaultsObj ? defaultsObj[key] : undefined,
|
|
||||||
);
|
|
||||||
if (overrideValue !== undefined) {
|
|
||||||
result[key] = overrideValue as JsonValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baseObj) {
|
|
||||||
for (const [key, baseValue] of Object.entries(baseObj)) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(currentObj, key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (baseValue === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result[key] = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(result).length > 0 ? result : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
base === undefined &&
|
|
||||||
defaults !== undefined &&
|
|
||||||
isEqual(current, defaults)
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEqual(current, base)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return current;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track if there are unsaved changes
|
// Track if there are unsaved changes
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
if (!pendingData) return false;
|
if (!pendingData) return false;
|
||||||
@ -510,7 +388,6 @@ export function ConfigSection({
|
|||||||
pendingData,
|
pendingData,
|
||||||
compareBaseData,
|
compareBaseData,
|
||||||
sanitizeSectionData,
|
sanitizeSectionData,
|
||||||
buildOverrides,
|
|
||||||
effectiveSchemaDefaults,
|
effectiveSchemaDefaults,
|
||||||
setPendingData,
|
setPendingData,
|
||||||
setPendingOverrides,
|
setPendingOverrides,
|
||||||
@ -539,7 +416,6 @@ export function ConfigSection({
|
|||||||
}, [
|
}, [
|
||||||
currentFormData,
|
currentFormData,
|
||||||
sanitizeSectionData,
|
sanitizeSectionData,
|
||||||
buildOverrides,
|
|
||||||
compareBaseData,
|
compareBaseData,
|
||||||
effectiveSchemaDefaults,
|
effectiveSchemaDefaults,
|
||||||
]);
|
]);
|
||||||
@ -550,23 +426,12 @@ export function ConfigSection({
|
|||||||
const uiOverrides = dirtyOverrides ?? effectiveOverrides;
|
const uiOverrides = dirtyOverrides ?? effectiveOverrides;
|
||||||
|
|
||||||
const requiresRestartForOverrides = useCallback(
|
const requiresRestartForOverrides = useCallback(
|
||||||
(overrides: unknown) => {
|
(overrides: unknown) =>
|
||||||
if (sectionConfig.restartRequired === undefined) {
|
sharedRequiresRestartForOverrides(
|
||||||
return requiresRestart;
|
overrides,
|
||||||
}
|
sectionConfig.restartRequired,
|
||||||
|
requiresRestart,
|
||||||
if (sectionConfig.restartRequired.length === 0) {
|
),
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!overrides || typeof overrides !== "object") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sectionConfig.restartRequired.some(
|
|
||||||
(path) => get(overrides as JsonObject, path) !== undefined,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[requiresRestart, sectionConfig.restartRequired],
|
[requiresRestart, sectionConfig.restartRequired],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -703,7 +568,6 @@ export function ConfigSection({
|
|||||||
onSave,
|
onSave,
|
||||||
rawFormData,
|
rawFormData,
|
||||||
sanitizeSectionData,
|
sanitizeSectionData,
|
||||||
buildOverrides,
|
|
||||||
effectiveSchemaDefaults,
|
effectiveSchemaDefaults,
|
||||||
updateTopic,
|
updateTopic,
|
||||||
setPendingData,
|
setPendingData,
|
||||||
|
|||||||
@ -80,6 +80,14 @@ import {
|
|||||||
MobilePageTitle,
|
MobilePageTitle,
|
||||||
} from "@/components/mobile/MobilePage";
|
} from "@/components/mobile/MobilePage";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { mutate } from "swr";
|
||||||
|
import { RJSFSchema } from "@rjsf/utils";
|
||||||
|
import { prepareSectionSavePayload } from "@/utils/configSaveUtil";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||||
|
import { useRestart } from "@/api/ws";
|
||||||
|
|
||||||
const allSettingsViews = [
|
const allSettingsViews = [
|
||||||
"profileSettings",
|
"profileSettings",
|
||||||
@ -557,7 +565,6 @@ export default function Settings() {
|
|||||||
? ALLOWED_VIEWS_FOR_VIEWER
|
? ALLOWED_VIEWS_FOR_VIEWER
|
||||||
: allSettingsViews;
|
: allSettingsViews;
|
||||||
|
|
||||||
// TODO: confirm leave page
|
|
||||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||||
|
|
||||||
@ -606,15 +613,206 @@ export default function Settings() {
|
|||||||
|
|
||||||
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
|
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
|
||||||
|
|
||||||
|
// Save All state
|
||||||
|
const [isSavingAll, setIsSavingAll] = useState(false);
|
||||||
|
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||||
|
const { send: sendRestart } = useRestart();
|
||||||
|
const { data: fullSchema } = useSWR<RJSFSchema>("config/schema.json");
|
||||||
|
|
||||||
|
const hasPendingChanges = Object.keys(pendingDataBySection).length > 0;
|
||||||
|
|
||||||
|
// Map a pendingDataKey to SettingsType menu key for clearing section status
|
||||||
|
const pendingKeyToMenuKey = useCallback(
|
||||||
|
(pendingDataKey: string): SettingsType | undefined => {
|
||||||
|
let sectionPath: string;
|
||||||
|
let level: "global" | "camera";
|
||||||
|
|
||||||
|
if (pendingDataKey.includes("::")) {
|
||||||
|
sectionPath = pendingDataKey.slice(pendingDataKey.indexOf("::") + 2);
|
||||||
|
level = "camera";
|
||||||
|
} else {
|
||||||
|
sectionPath = pendingDataKey;
|
||||||
|
level = "global";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === "camera") {
|
||||||
|
return CAMERA_SECTION_MAPPING[sectionPath] as SettingsType | undefined;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(GLOBAL_SECTION_MAPPING[sectionPath] as SettingsType | undefined) ??
|
||||||
|
(ENRICHMENTS_SECTION_MAPPING[sectionPath] as
|
||||||
|
| SettingsType
|
||||||
|
| undefined) ??
|
||||||
|
(SYSTEM_SECTION_MAPPING[sectionPath] as SettingsType | undefined)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSaveAll = useCallback(async () => {
|
||||||
|
if (!config || !fullSchema || !hasPendingChanges) return;
|
||||||
|
|
||||||
|
setIsSavingAll(true);
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
let anyNeedsRestart = false;
|
||||||
|
const savedKeys: string[] = [];
|
||||||
|
|
||||||
|
const pendingKeys = Object.keys(pendingDataBySection);
|
||||||
|
|
||||||
|
for (const key of pendingKeys) {
|
||||||
|
const pendingData = pendingDataBySection[key];
|
||||||
|
try {
|
||||||
|
const payload = prepareSectionSavePayload({
|
||||||
|
pendingDataKey: key,
|
||||||
|
pendingData,
|
||||||
|
config,
|
||||||
|
fullSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
// No actual overrides — clear the pending entry
|
||||||
|
setPendingDataBySection((prev) => {
|
||||||
|
const { [key]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
successCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.put("config/set", {
|
||||||
|
requires_restart: payload.needsRestart ? 1 : 0,
|
||||||
|
update_topic: payload.updateTopic,
|
||||||
|
config_data: { [payload.basePath]: payload.sanitizedOverrides },
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("Save All – saved:", {
|
||||||
|
[payload.basePath]: payload.sanitizedOverrides,
|
||||||
|
update_topic: payload.updateTopic,
|
||||||
|
requires_restart: payload.needsRestart ? 1 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payload.needsRestart) {
|
||||||
|
anyNeedsRestart = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear pending entry on success
|
||||||
|
setPendingDataBySection((prev) => {
|
||||||
|
const { [key]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
savedKeys.push(key);
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("Save All – error saving", key, error);
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh config from server once
|
||||||
|
await mutate("config");
|
||||||
|
|
||||||
|
// Clear hasChanges in sidebar for all successfully saved sections
|
||||||
|
if (savedKeys.length > 0) {
|
||||||
|
setSectionStatusByKey((prev) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
for (const key of savedKeys) {
|
||||||
|
const menuKey = pendingKeyToMenuKey(key);
|
||||||
|
if (menuKey && updated[menuKey]) {
|
||||||
|
updated[menuKey] = {
|
||||||
|
...updated[menuKey],
|
||||||
|
hasChanges: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate toast
|
||||||
|
const totalCount = successCount + failCount;
|
||||||
|
if (failCount === 0) {
|
||||||
|
if (anyNeedsRestart) {
|
||||||
|
toast.success(
|
||||||
|
t("toast.saveAllSuccess", {
|
||||||
|
ns: "views/settings",
|
||||||
|
count: successCount,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
action: (
|
||||||
|
<a onClick={() => setRestartDialogOpen(true)}>
|
||||||
|
<Button>
|
||||||
|
{t("restart.button", { ns: "components/dialog" })}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
t("toast.saveAllSuccess", {
|
||||||
|
ns: "views/settings",
|
||||||
|
count: successCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (successCount > 0) {
|
||||||
|
toast.warning(
|
||||||
|
t("toast.saveAllPartial", {
|
||||||
|
ns: "views/settings",
|
||||||
|
count: totalCount,
|
||||||
|
successCount,
|
||||||
|
totalCount,
|
||||||
|
failCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(t("toast.saveAllFailure", { ns: "views/settings" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingAll(false);
|
||||||
|
}, [
|
||||||
|
config,
|
||||||
|
fullSchema,
|
||||||
|
hasPendingChanges,
|
||||||
|
pendingDataBySection,
|
||||||
|
pendingKeyToMenuKey,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleUndoAll = useCallback(() => {
|
||||||
|
const pendingKeys = Object.keys(pendingDataBySection);
|
||||||
|
if (pendingKeys.length === 0) return;
|
||||||
|
|
||||||
|
setPendingDataBySection({});
|
||||||
|
setUnsavedChanges(false);
|
||||||
|
|
||||||
|
setSectionStatusByKey((prev) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
for (const key of pendingKeys) {
|
||||||
|
const menuKey = pendingKeyToMenuKey(key);
|
||||||
|
if (menuKey && updated[menuKey]) {
|
||||||
|
updated[menuKey] = {
|
||||||
|
...updated[menuKey],
|
||||||
|
hasChanges: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, [pendingDataBySection, pendingKeyToMenuKey]);
|
||||||
|
|
||||||
const handleDialog = useCallback(
|
const handleDialog = useCallback(
|
||||||
(save: boolean) => {
|
(save: boolean) => {
|
||||||
if (unsavedChanges && save) {
|
if (unsavedChanges && save) {
|
||||||
// TODO
|
handleSaveAll();
|
||||||
}
|
}
|
||||||
setConfirmationDialogOpen(false);
|
setConfirmationDialogOpen(false);
|
||||||
setUnsavedChanges(false);
|
setUnsavedChanges(false);
|
||||||
},
|
},
|
||||||
[unsavedChanges],
|
[unsavedChanges, handleSaveAll],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -834,6 +1032,42 @@ export default function Settings() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{hasPendingChanges && (
|
||||||
|
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<span className="text-sm text-danger">
|
||||||
|
{t("unsavedChanges", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "You have unsaved changes",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleUndoAll}
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSavingAll}
|
||||||
|
className="flex w-full items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{t("undo", { ns: "common", defaultValue: "Undo" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
variant="select"
|
||||||
|
disabled={isSavingAll}
|
||||||
|
className="flex w-full items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isSavingAll ? (
|
||||||
|
<>
|
||||||
|
<ActivityIndicator className="h-4 w-4" />
|
||||||
|
{t("button.savingAll", { ns: "common" })}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("button.saveAll", { ns: "common" })
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MobilePage
|
<MobilePage
|
||||||
@ -847,23 +1081,25 @@ export default function Settings() {
|
|||||||
className="top-0 mb-0"
|
className="top-0 mb-0"
|
||||||
onClose={() => navigate(-1)}
|
onClose={() => navigate(-1)}
|
||||||
actions={
|
actions={
|
||||||
CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) ? (
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
{CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) && (
|
||||||
{pageToggle == "masksAndZones" && (
|
<>
|
||||||
<ZoneMaskFilterButton
|
{pageToggle == "masksAndZones" && (
|
||||||
selectedZoneMask={filterZoneMask}
|
<ZoneMaskFilterButton
|
||||||
updateZoneMaskFilter={setFilterZoneMask}
|
selectedZoneMask={filterZoneMask}
|
||||||
|
updateZoneMaskFilter={setFilterZoneMask}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<CameraSelectButton
|
||||||
|
allCameras={cameras}
|
||||||
|
selectedCamera={selectedCamera}
|
||||||
|
setSelectedCamera={setSelectedCamera}
|
||||||
|
cameraEnabledStates={cameraEnabledStates}
|
||||||
|
currentPage={page}
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
<CameraSelectButton
|
)}
|
||||||
allCameras={cameras}
|
</div>
|
||||||
selectedCamera={selectedCamera}
|
|
||||||
setSelectedCamera={setSelectedCamera}
|
|
||||||
cameraEnabledStates={cameraEnabledStates}
|
|
||||||
currentPage={page}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : undefined
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MobilePageTitle>{t("menu." + page)}</MobilePageTitle>
|
<MobilePageTitle>{t("menu." + page)}</MobilePageTitle>
|
||||||
@ -912,6 +1148,11 @@ export default function Settings() {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
|
<RestartDialog
|
||||||
|
isOpen={restartDialogOpen}
|
||||||
|
onClose={() => setRestartDialogOpen(false)}
|
||||||
|
onRestart={() => sendRestart("restart")}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -923,23 +1164,37 @@ export default function Settings() {
|
|||||||
<Heading as="h3" className="mb-0">
|
<Heading as="h3" className="mb-0">
|
||||||
{t("menu.settings", { ns: "common" })}
|
{t("menu.settings", { ns: "common" })}
|
||||||
</Heading>
|
</Heading>
|
||||||
{CAMERA_SELECT_BUTTON_PAGES.includes(page) && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
{hasPendingChanges && (
|
||||||
{pageToggle == "masksAndZones" && (
|
<Button size="sm" onClick={handleSaveAll} disabled={isSavingAll}>
|
||||||
<ZoneMaskFilterButton
|
{isSavingAll ? (
|
||||||
selectedZoneMask={filterZoneMask}
|
<>
|
||||||
updateZoneMaskFilter={setFilterZoneMask}
|
<ActivityIndicator className="mr-2" />
|
||||||
|
{t("button.savingAll", { ns: "common" })}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("button.saveAll", { ns: "common" })
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{CAMERA_SELECT_BUTTON_PAGES.includes(page) && (
|
||||||
|
<>
|
||||||
|
{pageToggle == "masksAndZones" && (
|
||||||
|
<ZoneMaskFilterButton
|
||||||
|
selectedZoneMask={filterZoneMask}
|
||||||
|
updateZoneMaskFilter={setFilterZoneMask}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<CameraSelectButton
|
||||||
|
allCameras={cameras}
|
||||||
|
selectedCamera={selectedCamera}
|
||||||
|
setSelectedCamera={setSelectedCamera}
|
||||||
|
cameraEnabledStates={cameraEnabledStates}
|
||||||
|
currentPage={page}
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
<CameraSelectButton
|
)}
|
||||||
allCameras={cameras}
|
</div>
|
||||||
selectedCamera={selectedCamera}
|
|
||||||
setSelectedCamera={setSelectedCamera}
|
|
||||||
cameraEnabledStates={cameraEnabledStates}
|
|
||||||
currentPage={page}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<Sidebar variant="inset" className="relative mb-8 pl-0 pt-0">
|
<Sidebar variant="inset" className="relative mb-8 pl-0 pt-0">
|
||||||
@ -1074,6 +1329,11 @@ export default function Settings() {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
<RestartDialog
|
||||||
|
isOpen={restartDialogOpen}
|
||||||
|
onClose={() => setRestartDialogOpen(false)}
|
||||||
|
onRestart={() => sendRestart("restart")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
399
web/src/utils/configSaveUtil.ts
Normal file
399
web/src/utils/configSaveUtil.ts
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
// Shared config save utilities.
|
||||||
|
//
|
||||||
|
// Provides the core per-section save logic (buildOverrides, sanitize, restart
|
||||||
|
// detection, update-topic resolution) used by both the individual per-section
|
||||||
|
// Save button in BaseSection and the global "Save All" coordinator in Settings.
|
||||||
|
|
||||||
|
import get from "lodash/get";
|
||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
import unset from "lodash/unset";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
import { isJsonObject } from "@/lib/utils";
|
||||||
|
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||||
|
import { normalizeConfigValue } from "@/hooks/use-config-override";
|
||||||
|
import {
|
||||||
|
modifySchemaForSection,
|
||||||
|
getEffectiveDefaultsForSection,
|
||||||
|
sanitizeOverridesForSection,
|
||||||
|
} from "@/components/config-form/sections/section-special-cases";
|
||||||
|
import { getSectionConfig } from "@/utils/sectionConfigsUtils";
|
||||||
|
import type { RJSFSchema } from "@rjsf/utils";
|
||||||
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import type {
|
||||||
|
ConfigSectionData,
|
||||||
|
JsonObject,
|
||||||
|
JsonValue,
|
||||||
|
} from "@/types/configForm";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// cameraUpdateTopicMap — maps config section paths to MQTT/WS update topics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const cameraUpdateTopicMap: Record<string, string> = {
|
||||||
|
detect: "detect",
|
||||||
|
record: "record",
|
||||||
|
snapshots: "snapshots",
|
||||||
|
motion: "motion",
|
||||||
|
objects: "objects",
|
||||||
|
review: "review",
|
||||||
|
audio: "audio",
|
||||||
|
notifications: "notifications",
|
||||||
|
live: "live",
|
||||||
|
timestamp_style: "timestamp_style",
|
||||||
|
audio_transcription: "audio_transcription",
|
||||||
|
birdseye: "birdseye",
|
||||||
|
face_recognition: "face_recognition",
|
||||||
|
ffmpeg: "ffmpeg",
|
||||||
|
lpr: "lpr",
|
||||||
|
semantic_search: "semantic_search",
|
||||||
|
mqtt: "mqtt",
|
||||||
|
onvif: "onvif",
|
||||||
|
ui: "ui",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildOverrides — pure recursive diff of current vs stored config & defaults
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Recursively compare `current` (pending form data) against `base` (persisted
|
||||||
|
// config) and `defaults` (schema defaults) to produce a minimal overrides
|
||||||
|
// payload.
|
||||||
|
//
|
||||||
|
// - Returns `undefined` when the value matches `base` (or `defaults` when
|
||||||
|
// `base` is absent), indicating no override is needed.
|
||||||
|
// - For objects, recurses per-key; deleted keys (present in `base` but absent
|
||||||
|
// in `current`) are represented as `""`.
|
||||||
|
// - For arrays, returns the full array when it differs.
|
||||||
|
|
||||||
|
export function buildOverrides(
|
||||||
|
current: unknown,
|
||||||
|
base: unknown,
|
||||||
|
defaults: unknown,
|
||||||
|
): unknown | undefined {
|
||||||
|
if (current === null || current === undefined || current === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
if (
|
||||||
|
current.length === 0 &&
|
||||||
|
(base === undefined || base === null) &&
|
||||||
|
(defaults === undefined || defaults === null)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(base === undefined &&
|
||||||
|
defaults !== undefined &&
|
||||||
|
isEqual(current, defaults)) ||
|
||||||
|
isEqual(current, base)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJsonObject(current)) {
|
||||||
|
const currentObj = current;
|
||||||
|
const baseObj = isJsonObject(base) ? base : undefined;
|
||||||
|
const defaultsObj = isJsonObject(defaults) ? defaults : undefined;
|
||||||
|
|
||||||
|
const result: JsonObject = {};
|
||||||
|
for (const [key, value] of Object.entries(currentObj)) {
|
||||||
|
if (value === undefined && baseObj && baseObj[key] !== undefined) {
|
||||||
|
result[key] = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const overrideValue = buildOverrides(
|
||||||
|
value,
|
||||||
|
baseObj ? baseObj[key] : undefined,
|
||||||
|
defaultsObj ? defaultsObj[key] : undefined,
|
||||||
|
);
|
||||||
|
if (overrideValue !== undefined) {
|
||||||
|
result[key] = overrideValue as JsonValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseObj) {
|
||||||
|
for (const [key, baseValue] of Object.entries(baseObj)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(currentObj, key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (baseValue === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[key] = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
base === undefined &&
|
||||||
|
defaults !== undefined &&
|
||||||
|
isEqual(current, defaults)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEqual(current, base)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// sanitizeSectionData — normalize config values and strip hidden fields
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Normalize raw config data (strip internal fields) and remove any paths
|
||||||
|
// listed in `hiddenFields` so they are not included in override computation.
|
||||||
|
|
||||||
|
export function sanitizeSectionData(
|
||||||
|
data: ConfigSectionData,
|
||||||
|
hiddenFields?: string[],
|
||||||
|
): ConfigSectionData {
|
||||||
|
const normalized = normalizeConfigValue(data) as ConfigSectionData;
|
||||||
|
if (!hiddenFields || hiddenFields.length === 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
const cleaned = cloneDeep(normalized) as ConfigSectionData;
|
||||||
|
hiddenFields.forEach((path) => {
|
||||||
|
if (!path) return;
|
||||||
|
unset(cleaned, path);
|
||||||
|
});
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// requiresRestartForOverrides — determine whether a restart is needed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Check whether the given overrides include fields that require a Frigate
|
||||||
|
// restart. When `restartRequired` is `undefined` the caller's default is
|
||||||
|
// used; an empty array means "never restart"; otherwise the function checks
|
||||||
|
// if any of the listed field paths are present in the overrides object.
|
||||||
|
|
||||||
|
export function requiresRestartForOverrides(
|
||||||
|
overrides: unknown,
|
||||||
|
restartRequired: string[] | undefined,
|
||||||
|
defaultRequiresRestart: boolean = true,
|
||||||
|
): boolean {
|
||||||
|
if (restartRequired === undefined) {
|
||||||
|
return defaultRequiresRestart;
|
||||||
|
}
|
||||||
|
if (restartRequired.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!overrides || typeof overrides !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return restartRequired.some(
|
||||||
|
(path) => get(overrides as JsonObject, path) !== undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SectionSavePayload — data produced by prepareSectionSavePayload
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Ready-to-PUT payload for a single config section.
|
||||||
|
|
||||||
|
export interface SectionSavePayload {
|
||||||
|
basePath: string;
|
||||||
|
sanitizedOverrides: Record<string, unknown>;
|
||||||
|
updateTopic: string | undefined;
|
||||||
|
needsRestart: boolean;
|
||||||
|
pendingDataKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// extractSectionSchema — resolve a section schema from the full config schema
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { resolveAndCleanSchema } from "@/lib/config-schema";
|
||||||
|
|
||||||
|
type SchemaWithDefinitions = RJSFSchema & {
|
||||||
|
$defs?: Record<string, RJSFSchema>;
|
||||||
|
definitions?: Record<string, RJSFSchema>;
|
||||||
|
properties?: Record<string, RJSFSchema>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSchemaDefinitions(schema: RJSFSchema): Record<string, RJSFSchema> {
|
||||||
|
return (
|
||||||
|
(schema as SchemaWithDefinitions).$defs ||
|
||||||
|
(schema as SchemaWithDefinitions).definitions ||
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSectionSchema(
|
||||||
|
schema: RJSFSchema,
|
||||||
|
sectionPath: string,
|
||||||
|
level: "global" | "camera",
|
||||||
|
): RJSFSchema | null {
|
||||||
|
const defs = getSchemaDefinitions(schema);
|
||||||
|
const schemaObj = schema as SchemaWithDefinitions;
|
||||||
|
let sectionDef: RJSFSchema | null = null;
|
||||||
|
|
||||||
|
if (level === "camera") {
|
||||||
|
const cameraConfigDef = defs.CameraConfig;
|
||||||
|
if (cameraConfigDef?.properties) {
|
||||||
|
const sectionProp = cameraConfigDef.properties[sectionPath];
|
||||||
|
if (sectionProp && typeof sectionProp === "object") {
|
||||||
|
if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") {
|
||||||
|
const refPath = sectionProp.$ref
|
||||||
|
.replace(/^#\/\$defs\//, "")
|
||||||
|
.replace(/^#\/definitions\//, "");
|
||||||
|
sectionDef = defs[refPath] || null;
|
||||||
|
} else {
|
||||||
|
sectionDef = sectionProp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (schemaObj.properties) {
|
||||||
|
const sectionProp = schemaObj.properties[sectionPath];
|
||||||
|
if (sectionProp && typeof sectionProp === "object") {
|
||||||
|
if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") {
|
||||||
|
const refPath = sectionProp.$ref
|
||||||
|
.replace(/^#\/\$defs\//, "")
|
||||||
|
.replace(/^#\/definitions\//, "");
|
||||||
|
sectionDef = defs[refPath] || null;
|
||||||
|
} else {
|
||||||
|
sectionDef = sectionProp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sectionDef) return null;
|
||||||
|
|
||||||
|
const schemaWithDefs: RJSFSchema = { ...sectionDef, $defs: defs };
|
||||||
|
return resolveAndCleanSchema(schemaWithDefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// prepareSectionSavePayload — build the PUT payload for a single section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Given a pending-data key (e.g. `"detect"` or `"front_door::detect"`), its
|
||||||
|
// dirty form data, the current stored config, and the full JSON Schema,
|
||||||
|
// produce a `SectionSavePayload` that can be sent directly to
|
||||||
|
// `PUT config/set`. Returns `null` when there are no effective overrides.
|
||||||
|
|
||||||
|
export function prepareSectionSavePayload(opts: {
|
||||||
|
pendingDataKey: string;
|
||||||
|
pendingData: unknown;
|
||||||
|
config: FrigateConfig;
|
||||||
|
fullSchema: RJSFSchema;
|
||||||
|
}): SectionSavePayload | null {
|
||||||
|
const { pendingDataKey, pendingData, config, fullSchema } = opts;
|
||||||
|
|
||||||
|
if (!pendingData) return null;
|
||||||
|
|
||||||
|
// Parse pendingDataKey → sectionPath, level, cameraName
|
||||||
|
let sectionPath: string;
|
||||||
|
let level: "global" | "camera";
|
||||||
|
let cameraName: string | undefined;
|
||||||
|
|
||||||
|
if (pendingDataKey.includes("::")) {
|
||||||
|
const idx = pendingDataKey.indexOf("::");
|
||||||
|
cameraName = pendingDataKey.slice(0, idx);
|
||||||
|
sectionPath = pendingDataKey.slice(idx + 2);
|
||||||
|
level = "camera";
|
||||||
|
} else {
|
||||||
|
sectionPath = pendingDataKey;
|
||||||
|
level = "global";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve section config
|
||||||
|
const sectionConfig = getSectionConfig(sectionPath, level);
|
||||||
|
|
||||||
|
// Resolve section schema
|
||||||
|
const sectionSchema = extractSectionSchema(fullSchema, sectionPath, level);
|
||||||
|
if (!sectionSchema) return null;
|
||||||
|
|
||||||
|
const modifiedSchema = modifySchemaForSection(
|
||||||
|
sectionPath,
|
||||||
|
level,
|
||||||
|
sectionSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute rawFormData (the current stored value for this section)
|
||||||
|
let rawSectionValue: unknown;
|
||||||
|
if (level === "camera" && cameraName) {
|
||||||
|
rawSectionValue = get(config.cameras?.[cameraName], sectionPath);
|
||||||
|
} else {
|
||||||
|
rawSectionValue = get(config, sectionPath);
|
||||||
|
}
|
||||||
|
const rawFormData =
|
||||||
|
rawSectionValue === undefined || rawSectionValue === null
|
||||||
|
? {}
|
||||||
|
: rawSectionValue;
|
||||||
|
|
||||||
|
// Sanitize raw form data
|
||||||
|
const rawData = sanitizeSectionData(
|
||||||
|
rawFormData as ConfigSectionData,
|
||||||
|
sectionConfig.hiddenFields,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute schema defaults
|
||||||
|
const schemaDefaults = modifiedSchema
|
||||||
|
? applySchemaDefaults(modifiedSchema, {})
|
||||||
|
: {};
|
||||||
|
const effectiveDefaults = getEffectiveDefaultsForSection(
|
||||||
|
sectionPath,
|
||||||
|
level,
|
||||||
|
modifiedSchema ?? undefined,
|
||||||
|
schemaDefaults,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build overrides
|
||||||
|
const overrides = buildOverrides(pendingData, rawData, effectiveDefaults);
|
||||||
|
const sanitizedOverrides = sanitizeOverridesForSection(
|
||||||
|
sectionPath,
|
||||||
|
level,
|
||||||
|
overrides,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!sanitizedOverrides ||
|
||||||
|
typeof sanitizedOverrides !== "object" ||
|
||||||
|
Object.keys(sanitizedOverrides as Record<string, unknown>).length === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute basePath
|
||||||
|
const basePath =
|
||||||
|
level === "camera" && cameraName
|
||||||
|
? `cameras.${cameraName}.${sectionPath}`
|
||||||
|
: sectionPath;
|
||||||
|
|
||||||
|
// Compute updateTopic
|
||||||
|
let updateTopic: string | undefined;
|
||||||
|
if (level === "camera" && cameraName) {
|
||||||
|
const topic = cameraUpdateTopicMap[sectionPath];
|
||||||
|
updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined;
|
||||||
|
} else {
|
||||||
|
updateTopic = `config/${sectionPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart detection
|
||||||
|
const needsRestart = requiresRestartForOverrides(
|
||||||
|
sanitizedOverrides,
|
||||||
|
sectionConfig.restartRequired,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
basePath,
|
||||||
|
sanitizedOverrides: sanitizedOverrides as Record<string, unknown>,
|
||||||
|
updateTopic,
|
||||||
|
needsRestart,
|
||||||
|
pendingDataKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user