mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-30 09:01:14 +03:00
mimic logic in detector section for save all button
also, increase toast duration for restart required toasts
This commit is contained in:
parent
628faee304
commit
959ab72f28
@ -1583,6 +1583,8 @@
|
|||||||
"resetError": "Failed to reset settings",
|
"resetError": "Failed to reset settings",
|
||||||
"saveAllSuccess_one": "Saved {{count}} section successfully.",
|
"saveAllSuccess_one": "Saved {{count}} section successfully.",
|
||||||
"saveAllSuccess_other": "All {{count}} sections saved successfully.",
|
"saveAllSuccess_other": "All {{count}} sections saved successfully.",
|
||||||
|
"saveAllSuccessRestartRequired_one": "Saved {{count}} section successfully. Restart Frigate to apply your changes.",
|
||||||
|
"saveAllSuccessRestartRequired_other": "All {{count}} sections saved successfully. Restart Frigate to apply your changes.",
|
||||||
"saveAllPartial_one": "{{successCount}} of {{totalCount}} section saved. {{failCount}} failed.",
|
"saveAllPartial_one": "{{successCount}} of {{totalCount}} section saved. {{failCount}} failed.",
|
||||||
"saveAllPartial_other": "{{successCount}} of {{totalCount}} sections saved. {{failCount}} failed.",
|
"saveAllPartial_other": "{{successCount}} of {{totalCount}} sections saved. {{failCount}} failed.",
|
||||||
"saveAllFailure": "Failed to save all sections."
|
"saveAllFailure": "Failed to save all sections."
|
||||||
|
|||||||
@ -90,9 +90,12 @@ import { RJSFSchema } from "@rjsf/utils";
|
|||||||
import {
|
import {
|
||||||
buildConfigDataForPath,
|
buildConfigDataForPath,
|
||||||
flattenOverrides,
|
flattenOverrides,
|
||||||
|
getSectionConfig,
|
||||||
parseProfileFromSectionPath,
|
parseProfileFromSectionPath,
|
||||||
prepareSectionSavePayload,
|
prepareSectionSavePayload,
|
||||||
PROFILE_ELIGIBLE_SECTIONS,
|
PROFILE_ELIGIBLE_SECTIONS,
|
||||||
|
resolveHiddenFieldEntries,
|
||||||
|
sanitizeSectionData,
|
||||||
} from "@/utils/configUtil";
|
} from "@/utils/configUtil";
|
||||||
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
|
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
|
||||||
import { getProfileColor } from "@/utils/profileColors";
|
import { getProfileColor } from "@/utils/profileColors";
|
||||||
@ -786,24 +789,22 @@ export default function Settings() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show save/undo all buttons only when changes span multiple sections
|
// Show save/undo all buttons only when at least one pending change lives
|
||||||
// or the single changed section is not the one currently being viewed
|
// outside the currently visible page. Map each pending key to its menu key
|
||||||
|
// (e.g. both `detectors` and `model` collapse to `systemDetectorsAndModel`)
|
||||||
|
// so a composite page with two pending config-sections still counts as one.
|
||||||
const showSaveAllButtons = useMemo(() => {
|
const showSaveAllButtons = useMemo(() => {
|
||||||
const pendingKeys = Object.keys(pendingDataBySection);
|
const pendingKeys = Object.keys(pendingDataBySection);
|
||||||
if (pendingKeys.length === 0) return false;
|
if (pendingKeys.length === 0) return false;
|
||||||
if (pendingKeys.length >= 2) return true;
|
|
||||||
|
|
||||||
// Exactly one pending section — check if it matches the current view
|
for (const key of pendingKeys) {
|
||||||
const key = pendingKeys[0];
|
const menuKey = pendingKeyToMenuKey(key);
|
||||||
const menuKey = pendingKeyToMenuKey(key);
|
if (menuKey !== pageToggle) return true;
|
||||||
if (menuKey !== pageToggle) return true;
|
if (key.includes("::")) {
|
||||||
|
const cameraName = key.slice(0, key.indexOf("::"));
|
||||||
// For camera-scoped keys, also check if the camera matches
|
if (cameraName !== selectedCamera) return true;
|
||||||
if (key.includes("::")) {
|
}
|
||||||
const cameraName = key.slice(0, key.indexOf("::"));
|
|
||||||
return cameraName !== selectedCamera;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}, [pendingDataBySection, pendingKeyToMenuKey, pageToggle, selectedCamera]);
|
}, [pendingDataBySection, pendingKeyToMenuKey, pageToggle, selectedCamera]);
|
||||||
|
|
||||||
@ -821,8 +822,119 @@ export default function Settings() {
|
|||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
let anyNeedsRestart = false;
|
let anyNeedsRestart = false;
|
||||||
const savedKeys: string[] = [];
|
const savedKeys: string[] = [];
|
||||||
|
// Pending entries that have been successfully PUT — cleared in one batch
|
||||||
|
// after `mutate("config")` resolves
|
||||||
|
const keysToClear: string[] = [];
|
||||||
|
|
||||||
const pendingKeys = Object.keys(pendingDataBySection);
|
// `detectors` and `model` are owned by DetectorsAndModelSettingsView,
|
||||||
|
// which saves them atomically (single combined PUT with a pre-clear when
|
||||||
|
// detector keys change or the Plus/Custom tab flips). Doing the same here
|
||||||
|
// keeps Save All consistent with the page's own Save button
|
||||||
|
const hasPendingDetectors = "detectors" in pendingDataBySection;
|
||||||
|
const hasPendingModel = "model" in pendingDataBySection;
|
||||||
|
if (hasPendingDetectors || hasPendingModel) {
|
||||||
|
try {
|
||||||
|
const pendingDetectors = hasPendingDetectors
|
||||||
|
? pendingDataBySection.detectors
|
||||||
|
: undefined;
|
||||||
|
const pendingModel = hasPendingModel
|
||||||
|
? pendingDataBySection.model
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Hidden-field lists come from the section configs themselves so
|
||||||
|
// they stay in sync with what the embedded forms strip on render
|
||||||
|
const detectorHiddenFields = resolveHiddenFieldEntries(
|
||||||
|
getSectionConfig("detectors", "global").hiddenFields,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
const modelHiddenFields = resolveHiddenFieldEntries(
|
||||||
|
getSectionConfig("model", "global").hiddenFields,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
const sanitizedDetectors =
|
||||||
|
pendingDetectors !== undefined
|
||||||
|
? sanitizeSectionData(pendingDetectors, detectorHiddenFields)
|
||||||
|
: undefined;
|
||||||
|
const sanitizedModel =
|
||||||
|
pendingModel !== undefined
|
||||||
|
? sanitizeSectionData(pendingModel, modelHiddenFields)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Pre-clear conditions: detector keys differ from saved config (rename
|
||||||
|
// or add/remove), OR the model save flips between Plus and Custom modes
|
||||||
|
let detectorKeysChanged = false;
|
||||||
|
if (sanitizedDetectors && typeof sanitizedDetectors === "object") {
|
||||||
|
const pendingKeySet = Object.keys(
|
||||||
|
sanitizedDetectors as JsonObject,
|
||||||
|
).sort();
|
||||||
|
const savedKeySet = Object.keys(config.detectors ?? {}).sort();
|
||||||
|
detectorKeysChanged =
|
||||||
|
JSON.stringify(pendingKeySet) !== JSON.stringify(savedKeySet);
|
||||||
|
}
|
||||||
|
let modelTabChanged = false;
|
||||||
|
if (sanitizedModel && typeof sanitizedModel === "object") {
|
||||||
|
const newPath = (sanitizedModel as { path?: string }).path;
|
||||||
|
const oldPath = config.model?.path;
|
||||||
|
const newIsPlus =
|
||||||
|
typeof newPath === "string" && newPath.startsWith("plus://");
|
||||||
|
const oldIsPlus =
|
||||||
|
typeof oldPath === "string" && oldPath.startsWith("plus://");
|
||||||
|
modelTabChanged = newIsPlus !== oldIsPlus;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectorKeysChanged || modelTabChanged) {
|
||||||
|
try {
|
||||||
|
await axios.put("config/set", {
|
||||||
|
requires_restart: 0,
|
||||||
|
config_data: { detectors: null, model: null },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup; the merge-write below will surface any
|
||||||
|
// real error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedConfigData: Record<string, unknown> = {};
|
||||||
|
if (sanitizedDetectors !== undefined) {
|
||||||
|
combinedConfigData.detectors = sanitizedDetectors;
|
||||||
|
}
|
||||||
|
if (sanitizedModel !== undefined) {
|
||||||
|
combinedConfigData.model = sanitizedModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.put("config/set", {
|
||||||
|
requires_restart: 0,
|
||||||
|
config_data: combinedConfigData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasPendingDetectors) {
|
||||||
|
keysToClear.push("detectors");
|
||||||
|
savedKeys.push("detectors");
|
||||||
|
}
|
||||||
|
if (hasPendingModel) {
|
||||||
|
keysToClear.push("model");
|
||||||
|
savedKeys.push("model");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPendingDetectors || hasPendingModel) {
|
||||||
|
successCount++;
|
||||||
|
anyNeedsRestart = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(
|
||||||
|
"Save All – error saving detectors/model atomically",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
if (hasPendingDetectors || hasPendingModel) {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingKeys = Object.keys(pendingDataBySection).filter(
|
||||||
|
(key) => key !== "detectors" && key !== "model",
|
||||||
|
);
|
||||||
|
|
||||||
for (const key of pendingKeys) {
|
for (const key of pendingKeys) {
|
||||||
const pendingData = pendingDataBySection[key];
|
const pendingData = pendingDataBySection[key];
|
||||||
@ -836,11 +948,8 @@ export default function Settings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
// No actual overrides — clear the pending entry
|
// No actual overrides — schedule the pending entry for clearing
|
||||||
setPendingDataBySection((prev) => {
|
keysToClear.push(key);
|
||||||
const { [key]: _, ...rest } = prev;
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
successCount++;
|
successCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -859,11 +968,8 @@ export default function Settings() {
|
|||||||
anyNeedsRestart = true;
|
anyNeedsRestart = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear pending entry on success
|
// Defer clearing the pending entry until after mutate("config") resolves
|
||||||
setPendingDataBySection((prev) => {
|
keysToClear.push(key);
|
||||||
const { [key]: _, ...rest } = prev;
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
savedKeys.push(key);
|
savedKeys.push(key);
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -873,10 +979,22 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh config from server once
|
// Refresh config from server once — must complete before clearing the
|
||||||
|
// pending entries so consumers don't observe a moment where pending is
|
||||||
|
// empty AND config is still stale
|
||||||
await mutate("config");
|
await mutate("config");
|
||||||
mutate("config/raw_paths");
|
mutate("config/raw_paths");
|
||||||
|
|
||||||
|
if (keysToClear.length > 0) {
|
||||||
|
setPendingDataBySection((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const key of keysToClear) {
|
||||||
|
delete next[key];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Clear hasChanges in sidebar for all successfully saved sections
|
// Clear hasChanges in sidebar for all successfully saved sections
|
||||||
if (savedKeys.length > 0) {
|
if (savedKeys.length > 0) {
|
||||||
setSectionStatusByKey((prev) => {
|
setSectionStatusByKey((prev) => {
|
||||||
@ -900,11 +1018,12 @@ export default function Settings() {
|
|||||||
if (failCount === 0) {
|
if (failCount === 0) {
|
||||||
if (anyNeedsRestart) {
|
if (anyNeedsRestart) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("toast.saveAllSuccess", {
|
t("toast.saveAllSuccessRestartRequired", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
count: successCount,
|
count: successCount,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
duration: 10000,
|
||||||
action: (
|
action: (
|
||||||
<a onClick={() => setRestartDialogOpen(true)}>
|
<a onClick={() => setRestartDialogOpen(true)}>
|
||||||
<Button>
|
<Button>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user