* tweak language

* show validation errors in json response

* fix export hwaccel args field in UI

* increase annotation offset consts

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

- Disable both Save and SaveAll buttons while either operation is in progress so users cannot trigger concurrent saves
- Show activity indicator on Reset to Default/Global button during the API call
- Enrichments panes (semantic search, genai, face recognition) now always show base config fields regardless of profile selection in the header dropdown

* fix genai additional_concerns validation error with textarea array widget

The additional_concerns field is list[str] in the backend but was using the textarea widget which produces a string value, causing validation errors.
Created a TextareaArrayWidget that converts between array (one item per line) and textarea display, and switched additional_concerns to use it

* populate and sort global audio filters for all audio labels

* add column labels in profiles view

* enforce a minimum value of 2 for min_initialized

* reuse widget and refactor for multiline

* fix

* change record copy preset to transcode audio to aac
This commit is contained in:
Josh Hawkins 2026-03-26 13:47:24 -05:00 committed by GitHub
parent 909b40ba96
commit 4772e6a2ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 264 additions and 69 deletions

View File

@ -613,6 +613,34 @@ def config_set(request: Request, body: AppConfigSetBody):
try: try:
config = FrigateConfig.parse(new_raw_config) config = FrigateConfig.parse(new_raw_config)
except ValidationError as e:
with open(config_file, "w") as f:
f.write(old_raw_config)
f.close()
logger.error(
f"Config Validation Error:\n\n{str(traceback.format_exc())}"
)
error_messages = []
for err in e.errors():
msg = err.get("msg", "")
# Strip pydantic "Value error, " prefix for cleaner display
if msg.startswith("Value error, "):
msg = msg[len("Value error, ") :]
error_messages.append(msg)
message = (
"; ".join(error_messages)
if error_messages
else "Check logs for error message."
)
return JSONResponse(
content=(
{
"success": False,
"message": f"Error saving config: {message}",
}
),
status_code=400,
)
except Exception: except Exception:
with open(config_file, "w") as f: with open(config_file, "w") as f:
f.write(old_raw_config) f.write(old_raw_config)

View File

@ -71,6 +71,7 @@ class DetectConfig(FrigateBaseModel):
default=None, default=None,
title="Minimum initialization frames", title="Minimum initialization frames",
description="Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.", description="Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.",
ge=2,
) )
max_disappeared: Optional[int] = Field( max_disappeared: Optional[int] = Field(
default=None, default=None,

View File

@ -614,6 +614,21 @@ class FrigateConfig(FrigateBaseModel):
if self.ffmpeg.hwaccel_args == "auto": if self.ffmpeg.hwaccel_args == "auto":
self.ffmpeg.hwaccel_args = auto_detect_hwaccel() self.ffmpeg.hwaccel_args = auto_detect_hwaccel()
# Populate global audio filters for all audio labels
all_audio_labels = {
label
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
if label
}
if self.audio.filters is None:
self.audio.filters = {}
for key in sorted(all_audio_labels - self.audio.filters.keys()):
self.audio.filters[key] = AudioFilterConfig()
self.audio.filters = dict(sorted(self.audio.filters.items()))
# Global config to propagate down to camera level # Global config to propagate down to camera level
global_config = self.model_dump( global_config = self.model_dump(
include={ include={
@ -672,12 +687,6 @@ class FrigateConfig(FrigateBaseModel):
detector_config.model = model detector_config.model = model
self.detectors[key] = detector_config self.detectors[key] = detector_config
all_audio_labels = {
label
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
if label
}
for name, camera in self.cameras.items(): for name, camera in self.cameras.items():
modified_global_config = global_config.copy() modified_global_config = global_config.copy()
@ -755,7 +764,7 @@ class FrigateConfig(FrigateBaseModel):
) )
# Default min_initialized configuration # Default min_initialized configuration
min_initialized = int(camera_config.detect.fps / 2) min_initialized = max(int(camera_config.detect.fps / 2), 2)
if camera_config.detect.min_initialized is None: if camera_config.detect.min_initialized is None:
camera_config.detect.min_initialized = min_initialized camera_config.detect.min_initialized = min_initialized
@ -801,11 +810,13 @@ class FrigateConfig(FrigateBaseModel):
if camera_config.audio.filters is None: if camera_config.audio.filters is None:
camera_config.audio.filters = {} camera_config.audio.filters = {}
audio_keys = all_audio_labels for key in sorted(all_audio_labels - camera_config.audio.filters.keys()):
audio_keys = audio_keys - camera_config.audio.filters.keys()
for key in audio_keys:
camera_config.audio.filters[key] = AudioFilterConfig() camera_config.audio.filters[key] = AudioFilterConfig()
camera_config.audio.filters = dict(
sorted(camera_config.audio.filters.items())
)
# Add default filters # Add default filters
object_keys = camera_config.objects.track object_keys = camera_config.objects.track
if camera_config.objects.filters is None: if camera_config.objects.filters is None:

View File

@ -47,7 +47,7 @@ class ModelTypeEnum(str, Enum):
class ModelConfig(BaseModel): class ModelConfig(BaseModel):
path: Optional[str] = Field( path: Optional[str] = Field(
None, None,
title="Custom Object detection model path", title="Custom object detector model path",
description="Path to a custom detection model file (or plus://<model_id> for Frigate+ models).", description="Path to a custom detection model file (or plus://<model_id> for Frigate+ models).",
) )
labelmap_path: Optional[str] = Field( labelmap_path: Optional[str] = Field(

View File

@ -465,6 +465,16 @@ PRESETS_RECORD_OUTPUT = {
"-c:a", "-c:a",
"aac", "aac",
], ],
# NOTE: This preset originally used "-c:a copy" to pass through audio
# without re-encoding. FFmpeg 7.x introduced a threaded pipeline where
# demuxing, encoding, and muxing run in parallel via a Scheduler. This
# broke audio streamcopy from RTSP sources: packets are demuxed correctly
# but silently dropped before reaching the muxer (0 bytes written). The
# issue is specific to RTSP + streamcopy; file inputs and transcoding both
# work. Transcoding AAC audio is very lightweight (~30KiB per 10s segment)
# and adds negligible CPU overhead, so this is an acceptable workaround.
# The benefits of FFmpeg 7.x — particularly the removal of gamma correction
# hacks required by earlier versions — outweigh this trade-off.
"preset-record-generic-audio-copy": [ "preset-record-generic-audio-copy": [
"-f", "-f",
"segment", "segment",
@ -476,8 +486,10 @@ PRESETS_RECORD_OUTPUT = {
"1", "1",
"-strftime", "-strftime",
"1", "1",
"-c", "-c:v",
"copy", "copy",
"-c:a",
"aac",
], ],
"preset-record-mjpeg": [ "preset-record-mjpeg": [
"-f", "-f",

View File

@ -293,7 +293,7 @@
"label": "Detector specific model configuration", "label": "Detector specific model configuration",
"description": "Detector-specific model configuration options (path, input size, etc.).", "description": "Detector-specific model configuration options (path, input size, etc.).",
"path": { "path": {
"label": "Custom Object detection model path", "label": "Custom object detector model path",
"description": "Path to a custom detection model file (or plus://<model_id> for Frigate+ models)." "description": "Path to a custom detection model file (or plus://<model_id> for Frigate+ models)."
}, },
"labelmap_path": { "labelmap_path": {
@ -466,7 +466,7 @@
"label": "Detection model", "label": "Detection model",
"description": "Settings to configure a custom object detection model and its input shape.", "description": "Settings to configure a custom object detection model and its input shape.",
"path": { "path": {
"label": "Custom Object detection model path", "label": "Custom object detector model path",
"description": "Path to a custom detection model file (or plus://<model_id> for Frigate+ models)." "description": "Path to a custom detection model file (or plus://<model_id> for Frigate+ models)."
}, },
"labelmap_path": { "labelmap_path": {

View File

@ -1521,6 +1521,8 @@
"noOverrides": "No overrides", "noOverrides": "No overrides",
"cameraCount_one": "{{count}} camera", "cameraCount_one": "{{count}} camera",
"cameraCount_other": "{{count}} cameras", "cameraCount_other": "{{count}} cameras",
"columnCamera": "Camera",
"columnOverrides": "Profile Overrides",
"baseConfig": "Base Config", "baseConfig": "Base Config",
"addProfile": "Add Profile", "addProfile": "Add Profile",
"newProfile": "New Profile", "newProfile": "New Profile",

View File

@ -23,7 +23,7 @@ const record: SectionConfigOverrides = {
uiSchema: { uiSchema: {
export: { export: {
hwaccel_args: { hwaccel_args: {
"ui:options": { size: "lg" }, "ui:options": { suppressMultiSchema: true, size: "lg" },
}, },
}, },
}, },

View File

@ -29,9 +29,10 @@ const review: SectionConfigOverrides = {
}, },
genai: { genai: {
additional_concerns: { additional_concerns: {
"ui:widget": "textarea", "ui:widget": "ArrayAsTextWidget",
"ui:options": { "ui:options": {
size: "full", size: "full",
multiline: true,
}, },
}, },
activity_context_prompt: { activity_context_prompt: {

View File

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

View File

@ -1,33 +1,101 @@
// Widget that displays an array as a concatenated text string // Widget that displays an array as editable text.
// Single-line mode (default): space-separated in an Input.
// Multiline mode (options.multiline): one item per line in a Textarea.
import type { WidgetProps } from "@rjsf/utils"; import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useCallback } from "react"; import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
import { useCallback, useEffect, useState } from "react";
function arrayToText(value: unknown, multiline: boolean): string {
const sep = multiline ? "\n" : " ";
if (Array.isArray(value) && value.length > 0) {
return value.join(sep);
}
if (typeof value === "string") {
return value;
}
return "";
}
function textToArray(text: string, multiline: boolean): string[] {
if (text.trim() === "") {
return [];
}
return multiline
? text.split("\n").filter((line) => line.trim() !== "")
: text.trim().split(/\s+/);
}
export function ArrayAsTextWidget(props: WidgetProps) { export function ArrayAsTextWidget(props: WidgetProps) {
const { value, onChange, disabled, readonly, placeholder } = props; const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
// Convert array or string to text const multiline = !!(options.multiline as boolean);
let textValue = "";
if (typeof value === "string" && value.length > 0) {
textValue = value;
} else if (Array.isArray(value) && value.length > 0) {
textValue = value.join(" ");
}
const handleChange = useCallback( // Local state keeps raw text so newlines aren't stripped mid-typing
(event: React.ChangeEvent<HTMLInputElement>) => { const [text, setText] = useState(() => arrayToText(value, multiline));
const newText = event.target.value;
// Convert space-separated string back to array useEffect(() => {
const newArray = newText.trim() ? newText.trim().split(/\s+/) : []; setText(arrayToText(value, multiline));
onChange(newArray); }, [value, multiline]);
const fieldClassName = multiline
? getSizedFieldClassName(options, "md")
: undefined;
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const raw = e.target.value;
setText(raw);
onChange(textToArray(raw, multiline));
}, },
[onChange], [onChange, multiline],
); );
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// Clean up: strip empty entries and sync
const cleaned = textToArray(e.target.value, multiline);
onChange(cleaned);
setText(arrayToText(cleaned, multiline));
onBlur?.(id, e.target.value);
},
[id, onChange, onBlur, multiline],
);
if (multiline) {
return (
<Textarea
id={id}
className={cn("text-md", fieldClassName)}
value={text}
disabled={disabled || readonly}
rows={(options.rows as number) || 3}
onChange={handleInputChange}
onBlur={handleBlur}
onFocus={(e) => onFocus?.(id, e.target.value)}
aria-label={schema.title}
/>
);
}
return ( return (
<Input <Input
value={textValue} value={text}
onChange={handleChange} onChange={handleInputChange}
onBlur={handleBlur}
disabled={disabled} disabled={disabled}
readOnly={readonly} readOnly={readonly}
placeholder={placeholder} placeholder={placeholder}

View File

@ -9,15 +9,16 @@ import { toast } from "sonner";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { LuExternalLink, LuInfo, LuMinus, LuPlus } from "react-icons/lu"; import { LuExternalLink, LuInfo, LuMinus, LuPlus } from "react-icons/lu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
ANNOTATION_OFFSET_MAX,
ANNOTATION_OFFSET_MIN,
ANNOTATION_OFFSET_STEP,
} from "@/lib/const";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
const OFFSET_MIN = -2500;
const OFFSET_MAX = 2500;
const OFFSET_STEP = 50;
type Props = { type Props = {
className?: string; className?: string;
}; };
@ -43,7 +44,10 @@ export default function AnnotationOffsetSlider({ className }: Props) {
(delta: number) => { (delta: number) => {
setAnnotationOffset((prev) => { setAnnotationOffset((prev) => {
const next = prev + delta; const next = prev + delta;
return Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, next)); return Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, next),
);
}); });
}, },
[setAnnotationOffset], [setAnnotationOffset],
@ -114,17 +118,17 @@ export default function AnnotationOffsetSlider({ className }: Props) {
size="icon" size="icon"
className="size-8 shrink-0" className="size-8 shrink-0"
aria-label="-50ms" aria-label="-50ms"
onClick={() => stepOffset(-OFFSET_STEP)} onClick={() => stepOffset(-ANNOTATION_OFFSET_STEP)}
disabled={annotationOffset <= OFFSET_MIN} disabled={annotationOffset <= ANNOTATION_OFFSET_MIN}
> >
<LuMinus className="size-4" /> <LuMinus className="size-4" />
</Button> </Button>
<div className="w-full flex-1 landscape:flex"> <div className="w-full flex-1 landscape:flex">
<Slider <Slider
value={[annotationOffset]} value={[annotationOffset]}
min={OFFSET_MIN} min={ANNOTATION_OFFSET_MIN}
max={OFFSET_MAX} max={ANNOTATION_OFFSET_MAX}
step={OFFSET_STEP} step={ANNOTATION_OFFSET_STEP}
onValueChange={handleChange} onValueChange={handleChange}
/> />
</div> </div>
@ -134,8 +138,8 @@ export default function AnnotationOffsetSlider({ className }: Props) {
size="icon" size="icon"
className="size-8 shrink-0" className="size-8 shrink-0"
aria-label="+50ms" aria-label="+50ms"
onClick={() => stepOffset(OFFSET_STEP)} onClick={() => stepOffset(ANNOTATION_OFFSET_STEP)}
disabled={annotationOffset >= OFFSET_MAX} disabled={annotationOffset >= ANNOTATION_OFFSET_MAX}
> >
<LuPlus className="size-4" /> <LuPlus className="size-4" />
</Button> </Button>

View File

@ -13,10 +13,11 @@ import { Slider } from "@/components/ui/slider";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import {
const OFFSET_MIN = -2500; ANNOTATION_OFFSET_MAX,
const OFFSET_MAX = 2500; ANNOTATION_OFFSET_MIN,
const OFFSET_STEP = 50; ANNOTATION_OFFSET_STEP,
} from "@/lib/const";
type AnnotationSettingsPaneProps = { type AnnotationSettingsPaneProps = {
event: Event; event: Event;
@ -49,7 +50,10 @@ export function AnnotationSettingsPane({
(delta: number) => { (delta: number) => {
setAnnotationOffset((prev) => { setAnnotationOffset((prev) => {
const next = prev + delta; const next = prev + delta;
return Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, next)); return Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, next),
);
}); });
}, },
[setAnnotationOffset], [setAnnotationOffset],
@ -128,16 +132,16 @@ export function AnnotationSettingsPane({
size="icon" size="icon"
className="size-8 shrink-0" className="size-8 shrink-0"
aria-label="-50ms" aria-label="-50ms"
onClick={() => stepOffset(-OFFSET_STEP)} onClick={() => stepOffset(-ANNOTATION_OFFSET_STEP)}
disabled={annotationOffset <= OFFSET_MIN} disabled={annotationOffset <= ANNOTATION_OFFSET_MIN}
> >
<LuMinus className="size-4" /> <LuMinus className="size-4" />
</Button> </Button>
<Slider <Slider
value={[annotationOffset]} value={[annotationOffset]}
min={OFFSET_MIN} min={ANNOTATION_OFFSET_MIN}
max={OFFSET_MAX} max={ANNOTATION_OFFSET_MAX}
step={OFFSET_STEP} step={ANNOTATION_OFFSET_STEP}
onValueChange={handleSliderChange} onValueChange={handleSliderChange}
className="flex-1" className="flex-1"
/> />
@ -147,8 +151,8 @@ export function AnnotationSettingsPane({
size="icon" size="icon"
className="size-8 shrink-0" className="size-8 shrink-0"
aria-label="+50ms" aria-label="+50ms"
onClick={() => stepOffset(OFFSET_STEP)} onClick={() => stepOffset(ANNOTATION_OFFSET_STEP)}
disabled={annotationOffset >= OFFSET_MAX} disabled={annotationOffset >= ANNOTATION_OFFSET_MAX}
> >
<LuPlus className="size-4" /> <LuPlus className="size-4" />
</Button> </Button>

View File

@ -1,6 +1,10 @@
/** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */ /** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */
export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const; export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
export const ANNOTATION_OFFSET_MIN = -10000;
export const ANNOTATION_OFFSET_MAX = 5000;
export const ANNOTATION_OFFSET_STEP = 50;
export const supportedLanguageKeys = [ export const supportedLanguageKeys = [
"en", "en",
"es", "es",

View File

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

View File

@ -542,6 +542,20 @@ export default function ProfilesView({
<CollapsibleContent> <CollapsibleContent>
{cameras.length > 0 ? ( {cameras.length > 0 ? (
<div className="mx-4 mb-3 ml-11 border-l border-border/50 pl-4"> <div className="mx-4 mb-3 ml-11 border-l border-border/50 pl-4">
<div className="flex items-baseline gap-3 border-b border-border/30 pb-1.5">
<span className="min-w-[120px] shrink-0 text-xs font-semibold uppercase text-muted-foreground">
{t("profiles.columnCamera", {
ns: "views/settings",
defaultValue: "Camera",
})}
</span>
<span className="text-xs font-semibold uppercase text-muted-foreground">
{t("profiles.columnOverrides", {
ns: "views/settings",
defaultValue: "Profile Overrides",
})}
</span>
</div>
{cameras.map((camera) => { {cameras.map((camera) => {
const sections = cameraData[camera]; const sections = cameraData[camera];
return ( return (

View File

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