refactor to shared utils and add save all button

This commit is contained in:
Josh Hawkins 2026-02-08 13:52:39 -06:00
parent df52529927
commit f0bd84bf63
5 changed files with 720 additions and 190 deletions

View File

@ -156,7 +156,9 @@
"modified": "Modified",
"overridden": "Overridden",
"resetToGlobal": "Reset to Global",
"resetToDefault": "Reset to Default"
"resetToDefault": "Reset to Default",
"saveAll": "Save All",
"savingAll": "Saving All…"
},
"menu": {
"system": "System",

View File

@ -1311,7 +1311,12 @@
"error": "Failed to save settings",
"validationError": "Validation failed: {{message}}",
"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",
"confirmReset": "Confirm Reset",

View File

@ -17,10 +17,7 @@ import {
sanitizeOverridesForSection,
} from "./section-special-cases";
import { getSectionValidation } from "../section-validations";
import {
useConfigOverride,
normalizeConfigValue,
} from "@/hooks/use-config-override";
import { useConfigOverride } from "@/hooks/use-config-override";
import { useSectionSchema } from "@/hooks/use-config-schema";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Badge } from "@/components/ui/badge";
@ -28,7 +25,6 @@ import { Button } from "@/components/ui/button";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import Heading from "@/components/ui/heading";
import get from "lodash/get";
import unset from "lodash/unset";
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import {
@ -47,9 +43,15 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { applySchemaDefaults } from "@/lib/config-schema";
import { cn, isJsonObject } from "@/lib/utils";
import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm";
import { cn } from "@/lib/utils";
import { ConfigSectionData, JsonValue } from "@/types/configForm";
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 { useRestart } from "@/api/ws";
@ -128,28 +130,6 @@ export interface CreateSectionOptions {
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 function ConfigSection({
@ -276,22 +256,8 @@ export function ConfigSection({
}, [config, rawSectionValue]);
const sanitizeSectionData = useCallback(
(data: ConfigSectionData) => {
const normalized = normalizeConfigValue(data) as ConfigSectionData;
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;
},
(data: ConfigSectionData) =>
sharedSanitizeSectionData(data, sectionConfig.hiddenFields),
[sectionConfig.hiddenFields],
);
@ -354,94 +320,6 @@ export function ConfigSection({
}
}, [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
const hasChanges = useMemo(() => {
if (!pendingData) return false;
@ -510,7 +388,6 @@ export function ConfigSection({
pendingData,
compareBaseData,
sanitizeSectionData,
buildOverrides,
effectiveSchemaDefaults,
setPendingData,
setPendingOverrides,
@ -539,7 +416,6 @@ export function ConfigSection({
}, [
currentFormData,
sanitizeSectionData,
buildOverrides,
compareBaseData,
effectiveSchemaDefaults,
]);
@ -550,23 +426,12 @@ export function ConfigSection({
const uiOverrides = dirtyOverrides ?? effectiveOverrides;
const requiresRestartForOverrides = useCallback(
(overrides: unknown) => {
if (sectionConfig.restartRequired === undefined) {
return 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,
);
},
(overrides: unknown) =>
sharedRequiresRestartForOverrides(
overrides,
sectionConfig.restartRequired,
requiresRestart,
),
[requiresRestart, sectionConfig.restartRequired],
);
@ -703,7 +568,6 @@ export function ConfigSection({
onSave,
rawFormData,
sanitizeSectionData,
buildOverrides,
effectiveSchemaDefaults,
updateTopic,
setPendingData,

View File

@ -80,6 +80,14 @@ import {
MobilePageTitle,
} from "@/components/mobile/MobilePage";
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 = [
"profileSettings",
@ -557,7 +565,6 @@ export default function Settings() {
? ALLOWED_VIEWS_FOR_VIEWER
: allSettingsViews;
// TODO: confirm leave page
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
@ -606,15 +613,206 @@ export default function Settings() {
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(
(save: boolean) => {
if (unsavedChanges && save) {
// TODO
handleSaveAll();
}
setConfirmationDialogOpen(false);
setUnsavedChanges(false);
},
[unsavedChanges],
[unsavedChanges, handleSaveAll],
);
useEffect(() => {
@ -834,6 +1032,42 @@ export default function Settings() {
);
})}
</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>
)}
<MobilePage
@ -847,23 +1081,25 @@ export default function Settings() {
className="top-0 mb-0"
onClose={() => navigate(-1)}
actions={
CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) ? (
<div className="flex items-center gap-2">
{pageToggle == "masksAndZones" && (
<ZoneMaskFilterButton
selectedZoneMask={filterZoneMask}
updateZoneMaskFilter={setFilterZoneMask}
<div className="flex items-center gap-2">
{CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) && (
<>
{pageToggle == "masksAndZones" && (
<ZoneMaskFilterButton
selectedZoneMask={filterZoneMask}
updateZoneMaskFilter={setFilterZoneMask}
/>
)}
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
cameraEnabledStates={cameraEnabledStates}
currentPage={page}
/>
)}
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
cameraEnabledStates={cameraEnabledStates}
currentPage={page}
/>
</div>
) : undefined
</>
)}
</div>
}
>
<MobilePageTitle>{t("menu." + page)}</MobilePageTitle>
@ -912,6 +1148,11 @@ export default function Settings() {
</AlertDialogContent>
</AlertDialog>
)}
<RestartDialog
isOpen={restartDialogOpen}
onClose={() => setRestartDialogOpen(false)}
onRestart={() => sendRestart("restart")}
/>
</>
);
}
@ -923,23 +1164,37 @@ export default function Settings() {
<Heading as="h3" className="mb-0">
{t("menu.settings", { ns: "common" })}
</Heading>
{CAMERA_SELECT_BUTTON_PAGES.includes(page) && (
<div className="flex items-center gap-2">
{pageToggle == "masksAndZones" && (
<ZoneMaskFilterButton
selectedZoneMask={filterZoneMask}
updateZoneMaskFilter={setFilterZoneMask}
<div className="flex items-center gap-2">
{hasPendingChanges && (
<Button size="sm" onClick={handleSaveAll} disabled={isSavingAll}>
{isSavingAll ? (
<>
<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}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
cameraEnabledStates={cameraEnabledStates}
currentPage={page}
/>
</div>
)}
</>
)}
</div>
</div>
<SidebarProvider>
<Sidebar variant="inset" className="relative mb-8 pl-0 pt-0">
@ -1074,6 +1329,11 @@ export default function Settings() {
</AlertDialog>
)}
</SidebarProvider>
<RestartDialog
isOpen={restartDialogOpen}
onClose={() => setRestartDialogOpen(false)}
onRestart={() => sendRestart("restart")}
/>
</div>
);
}

View 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,
};
}