Merge detector and model in settings UI (#23216)

* add embedded mode to BaseSection so parents can host the save action

* add optional action slot to current Frigate+ model summary

* add w-full to action slot flex wrapper for explicit width contract

* i18n

* merged detectors and model settings view

* fix document title

* Embed detector form in merged settings view

* add detection model card with tabs and custom model embed

* add Frigate+ model selector with filter popover to merged page

* Add mismatch banner and gate save on detector and model compatibility

* Wire atomic save, restart toast, and undo on detectors and model page

* Clear child pending data on undo

* route merged detectors and model view in settings

* trim Frigate+ page to account-only and remove old detection model view

* basic e2e

* Fix unsaved-changes guard, custom path leak, and post-failure cache resync

* Rename to Detectors and model, float Modified badge, use ConfigMessageBanner for mismatch

* Hide Plus/Custom tabs when Frigate+ is not enabled

* Detect active Plus model via model.plus.id instead of path prefix

* Sync state back to snapshot when child form un-modifies and remount on undo

* Always require restart on save since model changes also need one

* Wrap Frigate+ model selector in SplitCardRow with label and description

* rename tab

* update docs

* sync top-level model with default detector's resolved model

when the user doesn't define a top-level `model:` block, `FrigateConfig.model` stayed at pydantic field defaults (320×320, /labelmap.txt) while the per-detector model picked up `DEFAULT_MODEL` for openvino on cpu (300×300, coco_91cl_bkgr.txt introduced in #23127), causing `RemoteObjectDetector` to fail with "buffer is too small for requested array" because the SHM was sized from the per-detector model but mapped using the top-level one. After the detector loop, copy the first detector's resolved model up to `self.model` so both sides agree on dimensions and labelmap

* revert to cpu detector by default

use openvino cpu for new configs only

* add defaults
This commit is contained in:
Josh Hawkins
2026-05-17 11:54:21 -06:00
committed by GitHub
parent b712e1fbd9
commit 7413ce08d4
18 changed files with 1107 additions and 719 deletions
@@ -44,7 +44,7 @@ export function ConfigMessageBanner({ messages }: ConfigMessageBannerProps) {
className="flex items-center [&>svg+div]:translate-y-0 [&>svg]:static [&>svg~*]:pl-2"
>
<SeverityIcon severity={msg.severity} />
<AlertDescription>{t(msg.messageKey)}</AlertDescription>
<AlertDescription>{t(msg.messageKey, msg.values)}</AlertDescription>
</Alert>
))}
</div>
@@ -24,6 +24,8 @@ export type ConditionalMessage = {
severity: MessageSeverity;
/** Function returning true when the message should be shown */
condition: (ctx: MessageConditionContext) => boolean;
/** Optional interpolation values passed to t() for {{var}} substitution */
values?: Record<string, unknown>;
};
/** Field-level conditional message, adds field targeting */
@@ -175,6 +175,9 @@ export interface BaseSectionProps {
isSavingAll?: boolean;
/** Callback when this section's saving state changes */
onSavingChange?: (isSaving: boolean) => void;
/** When true, render the form fields only; suppress the internal save/undo bar.
* The parent owns the save action and reads pending data via `onPendingDataChange`. */
embedded?: boolean;
}
export interface CreateSectionOptions {
@@ -211,6 +214,7 @@ export function ConfigSection({
onDeleteProfileSection,
isSavingAll = false,
onSavingChange,
embedded = false,
}: ConfigSectionProps) {
// For replay level, treat as camera-level config access
const effectiveLevel = level === "replay" ? "camera" : level;
@@ -1048,121 +1052,123 @@ export function ConfigSection({
}}
/>
<div
className={cn(
"w-full border-t border-secondary bg-background pt-0",
!noStickyButtons && "sticky bottom-0 z-50",
)}
>
{!embedded && (
<div
className={cn(
"flex flex-col items-center gap-4 pt-2 md:flex-row",
hasChanges ? "justify-between" : "justify-end",
"w-full border-t border-secondary bg-background pt-0",
!noStickyButtons && "sticky bottom-0 z-50",
)}
>
{hasChanges && (
<div className="flex items-center gap-2">
<span className="text-sm text-unsaved">
{t("unsavedChanges", {
ns: "views/settings",
defaultValue: "You have unsaved changes",
})}
</span>
<SaveAllPreviewPopover
items={sectionPreviewItems}
className="h-7 w-7"
align="start"
side="top"
/>
</div>
)}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
{((effectiveLevel === "camera" && isOverridden) ||
effectiveLevel === "global") &&
!hasChanges &&
!skipSave &&
!profileName && (
<Button
onClick={() => setIsResetDialogOpen(true)}
variant="outline"
disabled={isSaving || isResettingToDefault || disabled}
className="flex flex-1 gap-2"
>
{isResettingToDefault && (
<ActivityIndicator className="h-4 w-4" />
)}
{effectiveLevel === "global"
? t("button.resetToDefault", {
ns: "common",
defaultValue: "Reset to Default",
})
: t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
)}
{profileName &&
profileOverridesSection &&
!hasChanges &&
!skipSave &&
onDeleteProfileSection && (
<Button
onClick={() => setIsDeleteProfileDialogOpen(true)}
variant="outline"
disabled={isSaving || disabled}
className="flex flex-1 gap-2"
>
{t("profiles.removeOverride", {
ns: "views/settings",
defaultValue: "Remove Profile Override",
})}
</Button>
)}
<div
className={cn(
"flex flex-col items-center gap-4 pt-2 md:flex-row",
hasChanges ? "justify-between" : "justify-end",
)}
>
{hasChanges && (
<div className="flex items-center gap-2">
<span className="text-sm text-unsaved">
{t("unsavedChanges", {
ns: "views/settings",
defaultValue: "You have unsaved changes",
})}
</span>
<SaveAllPreviewPopover
items={sectionPreviewItems}
className="h-7 w-7"
align="start"
side="top"
/>
</div>
)}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
{((effectiveLevel === "camera" && isOverridden) ||
effectiveLevel === "global") &&
!hasChanges &&
!skipSave &&
!profileName && (
<Button
onClick={() => setIsResetDialogOpen(true)}
variant="outline"
disabled={isSaving || isResettingToDefault || disabled}
className="flex flex-1 gap-2"
>
{isResettingToDefault && (
<ActivityIndicator className="h-4 w-4" />
)}
{effectiveLevel === "global"
? t("button.resetToDefault", {
ns: "common",
defaultValue: "Reset to Default",
})
: t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
)}
{profileName &&
profileOverridesSection &&
!hasChanges &&
!skipSave &&
onDeleteProfileSection && (
<Button
onClick={() => setIsDeleteProfileDialogOpen(true)}
variant="outline"
disabled={isSaving || disabled}
className="flex flex-1 gap-2"
>
{t("profiles.removeOverride", {
ns: "views/settings",
defaultValue: "Remove Profile Override",
})}
</Button>
)}
{hasChanges && (
<Button
onClick={handleReset}
variant="outline"
disabled={isSaving || isSavingAll || disabled}
className="flex min-w-36 flex-1 gap-2"
>
{t("button.undo", { ns: "common", defaultValue: "Undo" })}
</Button>
)}
<Button
onClick={handleReset}
variant="outline"
disabled={isSaving || isSavingAll || disabled}
onClick={handleSave}
variant="select"
disabled={
!hasChanges ||
hasValidationErrors ||
isSaving ||
isSavingAll ||
disabled
}
className="flex min-w-36 flex-1 gap-2"
>
{t("button.undo", { ns: "common", defaultValue: "Undo" })}
{isSaving ? (
<>
<ActivityIndicator className="h-4 w-4" />
{skipSave
? t("button.applying", {
ns: "common",
defaultValue: "Applying...",
})
: t("button.saving", {
ns: "common",
defaultValue: "Saving...",
})}
</>
) : skipSave ? (
t("button.apply", { ns: "common", defaultValue: "Apply" })
) : (
t("button.save", { ns: "common", defaultValue: "Save" })
)}
</Button>
)}
<Button
onClick={handleSave}
variant="select"
disabled={
!hasChanges ||
hasValidationErrors ||
isSaving ||
isSavingAll ||
disabled
}
className="flex min-w-36 flex-1 gap-2"
>
{isSaving ? (
<>
<ActivityIndicator className="h-4 w-4" />
{skipSave
? t("button.applying", {
ns: "common",
defaultValue: "Applying...",
})
: t("button.saving", {
ns: "common",
defaultValue: "Saving...",
})}
</>
) : skipSave ? (
t("button.apply", { ns: "common", defaultValue: "Apply" })
) : (
t("button.save", { ns: "common", defaultValue: "Save" })
)}
</Button>
</div>
</div>
</div>
</div>
)}
<AlertDialog open={isResetDialogOpen} onOpenChange={setIsResetDialogOpen}>
<AlertDialogContent>