Miscellaneous fixes (#23186)

* improve scroll handling for non-modal DropdownMenu in classification and face selection dialogs

* clean up

* fix incorrect key capitalization

* fix profile array overrides not replacing base arrays

don't use lodash merge(), it does positional merging and an empty source array doesn't override the destination, and shorter arrays leak destination elements through.

backend is unaffected, so the saved config and actual backend functionality was right

* only show audio debug tab when audio is enabled in config

* move apple_compatibility out of advanced

* remove retry_interval from UI

99% of users should never be changing this

* hide switch in optionalfieldwidget if editing a profile

* add override badges for cameras and profiles

collect shared functions into the config util and separate hooks

* Use new models endpoint info to determine modalities

* clarify language

* fix linter

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
Josh Hawkins 2026-05-13 11:04:11 -05:00 committed by GitHub
parent bd1fc1cc72
commit ca75f06456
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 783 additions and 379 deletions

View File

@ -18,6 +18,17 @@ from frigate.genai.utils import parse_tool_calls_from_message
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _parse_launch_arg(args: list[str], flag: str) -> str | None:
"""Return the value following `flag` in a positional argv list, or None."""
try:
idx = args.index(flag)
except ValueError:
return None
if idx + 1 >= len(args):
return None
return args[idx + 1]
def _to_jpeg(img_bytes: bytes) -> bytes | None: def _to_jpeg(img_bytes: bytes) -> bytes | None:
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP.""" """Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
try: try:
@ -71,26 +82,69 @@ class LlamaCppClient(GenAIClient):
base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url
configured_model = self.genai_config.model configured_model = self.genai_config.model
info = self._get_model_info(base_url, configured_model)
# Query /v1/models to validate the configured model exists if info is None:
try: return None
response = requests.get(
f"{base_url}/v1/models", self._context_size = info["context_size"]
timeout=10, self._supports_vision = info["supports_vision"]
self._supports_audio = info["supports_audio"]
self._supports_tools = info["supports_tools"]
self._media_marker = info["media_marker"]
logger.info(
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
configured_model,
self._context_size or "unknown",
self._supports_vision,
self._supports_audio,
self._supports_tools,
) )
return base_url
def _get_model_info(
self, base_url: str, configured_model: str
) -> dict[str, Any] | None:
"""Resolve model metadata from /v1/models with /props fallback.
Returns a dict of capability fields, or None if the server's model
registry was reachable and reported the configured model as missing.
A reachable-but-unparseable /v1/models is treated as soft-pass and
falls through to /props, matching prior behavior.
After ggml-org/llama.cpp#22952, /v1/models exposes per-model
`architecture.input_modalities` (text/image/audio) the primary
source. When proxied through llama-swap, the same entry carries
`status.args` (server launch argv) and, for the loaded model,
`meta.n_ctx`. /props remains the only source for `media_marker`,
which the server randomizes per startup unless LLAMA_MEDIA_MARKER
is set.
"""
info: dict[str, Any] = {
"context_size": None,
"supports_vision": False,
"supports_audio": False,
"supports_tools": False,
"media_marker": "<__media__>",
}
model_entry: dict[str, Any] | None = None
try:
response = requests.get(f"{base_url}/v1/models", timeout=10)
response.raise_for_status() response.raise_for_status()
models_data = response.json() models_data = response.json()
model_found = False
for model in models_data.get("data", []): for model in models_data.get("data", []):
model_ids = {model.get("id")} model_ids = {model.get("id")}
for alias in model.get("aliases", []): for alias in model.get("aliases", []):
model_ids.add(alias) model_ids.add(alias)
if configured_model in model_ids: if configured_model in model_ids:
model_found = True model_entry = model
break break
if not model_found: if model_entry is None:
available = [] available = []
for m in models_data.get("data", []): for m in models_data.get("data", []):
available.append(m.get("id", "unknown")) available.append(m.get("id", "unknown"))
@ -109,10 +163,35 @@ class LlamaCppClient(GenAIClient):
e, e,
) )
# Query /props for context size, modalities, and tool support. if model_entry is not None:
# The standard /props?model=<name> endpoint works with llama-server. architecture = model_entry.get("architecture") or {}
# If it fails, try the llama-swap per-model passthrough endpoint which input_modalities = architecture.get("input_modalities") or []
# returns props for a specific model without requiring it to be loaded.
if isinstance(input_modalities, list):
info["supports_vision"] = "image" in input_modalities
info["supports_audio"] = "audio" in input_modalities
status = model_entry.get("status") or {}
launch_args = status.get("args") if isinstance(status, dict) else None
if not isinstance(launch_args, list):
launch_args = []
meta = model_entry.get("meta") if isinstance(model_entry, dict) else None
n_ctx = meta.get("n_ctx") if isinstance(meta, dict) else None
if not n_ctx:
n_ctx = _parse_launch_arg(launch_args, "--ctx-size")
if n_ctx:
try:
info["context_size"] = int(n_ctx)
except (TypeError, ValueError):
pass
# Tool calling on llama-server requires --jinja.
if "--jinja" in launch_args:
info["supports_tools"] = True
try: try:
try: try:
response = requests.get( response = requests.get(
@ -130,44 +209,32 @@ class LlamaCppClient(GenAIClient):
response.raise_for_status() response.raise_for_status()
props = response.json() props = response.json()
# Context size from server runtime config if info["context_size"] is None:
default_settings = props.get("default_generation_settings", {}) default_settings = props.get("default_generation_settings", {})
n_ctx = default_settings.get("n_ctx") n_ctx = default_settings.get("n_ctx")
if n_ctx: if n_ctx:
self._context_size = int(n_ctx) info["context_size"] = int(n_ctx)
# Modalities (vision, audio) if not (info["supports_vision"] or info["supports_audio"]):
modalities = props.get("modalities", {}) modalities = props.get("modalities", {})
self._supports_vision = modalities.get("vision", False) info["supports_vision"] = bool(modalities.get("vision", False))
self._supports_audio = modalities.get("audio", False) info["supports_audio"] = bool(modalities.get("audio", False))
# Tool support from chat template capabilities if not info["supports_tools"]:
chat_caps = props.get("chat_template_caps", {}) chat_caps = props.get("chat_template_caps", {})
self._supports_tools = chat_caps.get("supports_tools", False) info["supports_tools"] = bool(chat_caps.get("supports_tools", False))
# Media marker for multimodal embeddings; the server randomizes this
# per startup unless LLAMA_MEDIA_MARKER is set, so we must read it
# from /props rather than hardcoding "<__media__>".
media_marker = props.get("media_marker") media_marker = props.get("media_marker")
if isinstance(media_marker, str) and media_marker: if isinstance(media_marker, str) and media_marker:
self._media_marker = media_marker info["media_marker"] = media_marker
logger.info(
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
configured_model,
self._context_size or "unknown",
self._supports_vision,
self._supports_audio,
self._supports_tools,
)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"Failed to query llama.cpp /props endpoint: %s. " "Failed to query llama.cpp /props endpoint: %s. "
"Using defaults for context size and capabilities.", "Image embeddings may fail if the server randomized its media marker.",
e, e,
) )
return base_url return info
def _send( def _send(
self, self,

View File

@ -125,5 +125,5 @@
"baby": "Baby", "baby": "Baby",
"baby_stroller": "Baby Stroller", "baby_stroller": "Baby Stroller",
"rickshaw": "Rickshaw", "rickshaw": "Rickshaw",
"Rodent": "Rodent" "rodent": "Rodent"
} }

View File

@ -19,8 +19,14 @@
"button": { "button": {
"overriddenGlobal": "Overridden (Global)", "overriddenGlobal": "Overridden (Global)",
"overriddenGlobalTooltip": "This camera overrides global configuration settings in this section", "overriddenGlobalTooltip": "This camera overrides global configuration settings in this section",
"overriddenGlobalHeading_one": "This camera overrides {{count}} field from the global config:",
"overriddenGlobalHeading_other": "This camera overrides {{count}} fields from the global config:",
"overriddenGlobalNoDeltas": "This camera overrides the global config, but no field values differ.",
"overriddenBaseConfig": "Overridden (Base Config)", "overriddenBaseConfig": "Overridden (Base Config)",
"overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section", "overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section",
"overriddenBaseConfigHeading_one": "The {{profile}} profile overrides {{count}} field from the base config:",
"overriddenBaseConfigHeading_other": "The {{profile}} profile overrides {{count}} fields from the base config:",
"overriddenBaseConfigNoDeltas": "The {{profile}} profile overrides this section, but no field values differ from the base config.",
"overriddenInCameras": { "overriddenInCameras": {
"label_one": "Overridden in {{count}} camera", "label_one": "Overridden in {{count}} camera",
"label_other": "Overridden in {{count}} cameras", "label_other": "Overridden in {{count}} cameras",

View File

@ -156,7 +156,7 @@ export function MessageBubble({
<div <div
className={cn( className={cn(
!isComplete && !isComplete &&
"[&>p:last-child]:inline after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-['']", "after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-[''] [&>p:last-child]:inline",
)} )}
> >
<ReactMarkdown <ReactMarkdown

View File

@ -41,19 +41,12 @@ const ffmpeg: SectionConfigOverrides = {
"input_args", "input_args",
"hwaccel_args", "hwaccel_args",
"output_args", "output_args",
"path",
"retry_interval",
"apple_compatibility",
"gpu",
],
hiddenFields: [],
advancedFields: [
"path",
"global_args",
"retry_interval",
"apple_compatibility", "apple_compatibility",
"path",
"gpu", "gpu",
], ],
hiddenFields: ["retry_interval"],
advancedFields: ["path", "global_args", "gpu"],
overrideFields: [ overrideFields: [
"inputs", "inputs",
"path", "path",
@ -61,7 +54,6 @@ const ffmpeg: SectionConfigOverrides = {
"input_args", "input_args",
"hwaccel_args", "hwaccel_args",
"output_args", "output_args",
"retry_interval",
"apple_compatibility", "apple_compatibility",
"gpu", "gpu",
], ],
@ -125,19 +117,10 @@ const ffmpeg: SectionConfigOverrides = {
"global_args", "global_args",
"input_args", "input_args",
"output_args", "output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
advancedFields: [
"global_args",
"input_args",
"output_args",
"path",
"retry_interval",
"apple_compatibility", "apple_compatibility",
"gpu", "gpu",
], ],
advancedFields: ["global_args", "input_args", "output_args", "path", "gpu"],
uiSchema: { uiSchema: {
path: { path: {
"ui:options": { size: "md" }, "ui:options": { size: "md" },

View File

@ -27,21 +27,17 @@ import {
import { getSectionValidation } from "../section-validations"; import { getSectionValidation } from "../section-validations";
import { useConfigOverride } from "@/hooks/use-config-override"; import { useConfigOverride } from "@/hooks/use-config-override";
import { CameraOverridesBadge } from "./CameraOverridesBadge"; import { CameraOverridesBadge } from "./CameraOverridesBadge";
import { GlobalOverridesBadge } from "./GlobalOverridesBadge";
import { ProfileOverridesBadge } from "./ProfileOverridesBadge";
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";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button"; 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 cloneDeep from "lodash/cloneDeep"; import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import merge from "lodash/merge";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@ -73,6 +69,7 @@ import {
buildConfigDataForPath, buildConfigDataForPath,
flattenOverrides, flattenOverrides,
getBaseCameraSectionValue, getBaseCameraSectionValue,
mergeProfileOverrides,
resolveHiddenFieldEntries, resolveHiddenFieldEntries,
sanitizeSectionData as sharedSanitizeSectionData, sanitizeSectionData as sharedSanitizeSectionData,
requiresRestartForOverrides as sharedRequiresRestartForOverrides, requiresRestartForOverrides as sharedRequiresRestartForOverrides,
@ -353,7 +350,10 @@ export function ConfigSection({
`profiles.${profileName}.${sectionPath}`, `profiles.${profileName}.${sectionPath}`,
); );
if (profileOverrides && typeof profileOverrides === "object") { if (profileOverrides && typeof profileOverrides === "object") {
return merge(cloneDeep(baseValue ?? {}), cloneDeep(profileOverrides)); return mergeProfileOverrides(
(baseValue as object) ?? {},
profileOverrides as object,
);
} }
return baseValue; return baseValue;
} }
@ -1044,6 +1044,7 @@ export function ConfigSection({
hiddenFields: effectiveHiddenFields, hiddenFields: effectiveHiddenFields,
restartRequired: sectionConfig.restartRequired, restartRequired: sectionConfig.restartRequired,
requiresRestart, requiresRestart,
isProfile: !!profileName,
}} }}
/> />
@ -1253,33 +1254,22 @@ export function ConfigSection({
<Heading as="h4">{title}</Heading> <Heading as="h4">{title}</Heading>
{showOverrideIndicator && {showOverrideIndicator &&
effectiveLevel === "camera" && effectiveLevel === "camera" &&
(profileOverridesSection || isOverridden) && ( (profileOverridesSection || isOverridden) &&
<Tooltip> cameraName &&
<TooltipTrigger asChild> (overrideSource === "profile" && profileName ? (
<Badge variant="secondary" className="text-xs"> <ProfileOverridesBadge
{overrideSource === "profile" sectionPath={sectionPath}
? t("button.overriddenBaseConfig", { cameraName={cameraName}
ns: "views/settings", profileName={profileName}
defaultValue: "Overridden (Base Config)", profileFriendlyName={profileFriendlyName}
}) profileBorderColor={profileBorderColor}
: t("button.overriddenGlobal", { />
ns: "views/settings", ) : (
defaultValue: "Overridden (Global)", <GlobalOverridesBadge
})} sectionPath={sectionPath}
</Badge> cameraName={cameraName}
</TooltipTrigger> />
<TooltipContent> ))}
{overrideSource === "profile"
? t("button.overriddenBaseConfigTooltip", {
ns: "views/settings",
profile: profileFriendlyName ?? profileName,
})
: t("button.overriddenGlobalTooltip", {
ns: "views/settings",
})}
</TooltipContent>
</Tooltip>
)}
{showOverrideIndicator && effectiveLevel === "global" && ( {showOverrideIndicator && effectiveLevel === "global" && (
<CameraOverridesBadge sectionPath={sectionPath} /> <CameraOverridesBadge sectionPath={sectionPath} />
)} )}
@ -1319,41 +1309,22 @@ export function ConfigSection({
<Heading as="h4">{title}</Heading> <Heading as="h4">{title}</Heading>
{showOverrideIndicator && {showOverrideIndicator &&
effectiveLevel === "camera" && effectiveLevel === "camera" &&
(profileOverridesSection || isOverridden) && ( (profileOverridesSection || isOverridden) &&
<Tooltip> cameraName &&
<TooltipTrigger asChild> (overrideSource === "profile" && profileName ? (
<Badge <ProfileOverridesBadge
variant="secondary" sectionPath={sectionPath}
className={cn( cameraName={cameraName}
"cursor-default border-2 text-center text-xs text-primary-variant", profileName={profileName}
overrideSource === "profile" && profileBorderColor profileFriendlyName={profileFriendlyName}
? profileBorderColor profileBorderColor={profileBorderColor}
: "border-selected", />
)} ) : (
> <GlobalOverridesBadge
{overrideSource === "profile" sectionPath={sectionPath}
? t("button.overriddenBaseConfig", { cameraName={cameraName}
ns: "views/settings", />
defaultValue: "Overridden (Base Config)", ))}
})
: t("button.overriddenGlobal", {
ns: "views/settings",
defaultValue: "Overridden (Global)",
})}
</Badge>
</TooltipTrigger>
<TooltipContent>
{overrideSource === "profile"
? t("button.overriddenBaseConfigTooltip", {
ns: "views/settings",
profile: profileFriendlyName ?? profileName,
})
: t("button.overriddenGlobalTooltip", {
ns: "views/settings",
})}
</TooltipContent>
</Tooltip>
)}
{showOverrideIndicator && effectiveLevel === "global" && ( {showOverrideIndicator && effectiveLevel === "global" && (
<CameraOverridesBadge sectionPath={sectionPath} /> <CameraOverridesBadge sectionPath={sectionPath} />
)} )}

View File

@ -17,10 +17,13 @@ import {
} from "@/hooks/use-config-override"; } from "@/hooks/use-config-override";
import type { FrigateConfig } from "@/types/frigateConfig"; import type { FrigateConfig } from "@/types/frigateConfig";
import type { ProfilesApiResponse } from "@/types/profile"; import type { ProfilesApiResponse } from "@/types/profile";
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { formatList } from "@/utils/stringUtil"; import { formatList } from "@/utils/stringUtil";
import { getEffectiveHiddenFields } from "@/utils/configUtil"; import {
getEffectiveHiddenFields,
pathMatchesHiddenPattern,
} from "@/utils/configUtil";
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
const CAMERA_PAGE_BY_SECTION: Record<string, string> = { const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
detect: "cameraDetect", detect: "cameraDetect",
@ -72,26 +75,6 @@ const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
"model", "model",
]); ]);
/**
* Match a delta path against a hidden-field pattern. Supports literal prefixes
* (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards
* matching exactly one path segment (e.g. "filters.*.mask").
*/
function pathMatchesHiddenPattern(path: string, pattern: string): boolean {
if (!pattern) return false;
if (!pattern.includes("*")) {
return path === pattern || path.startsWith(`${pattern}.`);
}
const patternSegments = pattern.split(".");
const pathSegments = path.split(".");
if (pathSegments.length < patternSegments.length) return false;
for (let i = 0; i < patternSegments.length; i += 1) {
if (patternSegments[i] === "*") continue;
if (patternSegments[i] !== pathSegments[i]) return false;
}
return true;
}
type CameraEntryProps = { type CameraEntryProps = {
sectionPath: string; sectionPath: string;
entry: CameraOverrideEntry; entry: CameraOverrideEntry;
@ -127,11 +110,8 @@ function groupDeltasBySource(deltas: FieldDelta[]): SourceGroup[] {
} }
function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) { function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
const { t, i18n } = useTranslation([ const { t } = useTranslation(["views/settings"]);
"config/global", const fieldLabel = useOverrideFieldLabel(sectionPath);
"views/settings",
"objects",
]);
const friendlyName = useCameraFriendlyName(entry.camera); const friendlyName = useCameraFriendlyName(entry.camera);
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles"); const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
@ -141,49 +121,6 @@ function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
return map; return map;
}, [profilesData]); }, [profilesData]);
const fieldLabel = (fieldPath: string) => {
if (!fieldPath) {
const sectionKey = `${sectionPath}.label`;
return i18n.exists(sectionKey, { ns: "config/global" })
? t(sectionKey, { ns: "config/global" })
: humanizeKey(sectionPath);
}
const segments = fieldPath.split(".");
// Most specific: try the full nested path
const fullKey = `${sectionPath}.${fieldPath}.label`;
if (i18n.exists(fullKey, { ns: "config/global" })) {
return t(fullKey, { ns: "config/global" });
}
// Try dropping each intermediate segment in turn — those are typically
// user-defined dict keys (object class names, zone names, etc.) that
// don't have their own label entries. Prepend the dropped segment as
// context to disambiguate (e.g. "Person · Minimum object area").
for (let i = 0; i < segments.length; i++) {
const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join(
".",
);
if (!reduced) continue;
const reducedKey = `${sectionPath}.${reduced}.label`;
if (i18n.exists(reducedKey, { ns: "config/global" })) {
const resolvedLabel = t(reducedKey, { ns: "config/global" });
const dropped = segments[i];
// Object class names ("person", "car", "fox") have translations in
// the `objects` namespace; fall back to humanizing the raw key for
// anything that isn't a known label.
const droppedLabel = i18n.exists(dropped, { ns: "objects" })
? t(dropped, { ns: "objects" })
: humanizeKey(dropped);
return `${droppedLabel} · ${resolvedLabel}`;
}
}
// Last resort: humanize the leaf segment
return humanizeKey(segments[segments.length - 1]);
};
const formatDeltas = (deltas: FieldDelta[]) => { const formatDeltas = (deltas: FieldDelta[]) => {
const visibleLabels = deltas const visibleLabels = deltas
.slice(0, MAX_FIELDS_PER_CAMERA) .slice(0, MAX_FIELDS_PER_CAMERA)

View File

@ -0,0 +1,44 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import { useCameraSectionDeltas } from "@/hooks/use-config-override";
import type { FrigateConfig } from "@/types/frigateConfig";
import { OverrideDeltaPopover } from "./OverrideDeltaPopover";
type Props = {
sectionPath: string;
cameraName: string;
className?: string;
};
export function GlobalOverridesBadge({
sectionPath,
cameraName,
className,
}: Props) {
const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation(["views/settings"]);
const deltas = useCameraSectionDeltas(config, cameraName, sectionPath);
return (
<OverrideDeltaPopover
sectionPath={sectionPath}
deltas={deltas}
badgeLabel={t("button.overriddenGlobal", {
ns: "views/settings",
defaultValue: "Overridden (Global)",
})}
ariaLabel={t("button.overriddenGlobalTooltip", {
ns: "views/settings",
})}
heading={t("button.overriddenGlobalHeading", {
ns: "views/settings",
count: deltas.length,
})}
noDeltasMessage={t("button.overriddenGlobalNoDeltas", {
ns: "views/settings",
})}
className={className}
/>
);
}

View File

@ -0,0 +1,78 @@
import { LuChevronDown } from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { FieldDelta } from "@/hooks/use-config-override";
import { cn } from "@/lib/utils";
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
type Props = {
sectionPath: string;
deltas: FieldDelta[];
/** Translated label shown inside the badge */
badgeLabel: string;
/** Accessible label for the badge trigger */
ariaLabel: string;
/** Heading rendered at the top of the popover content */
heading: string;
/** Message shown when there are zero field deltas */
noDeltasMessage: string;
/** Border color class for the badge (defaults to selected) */
borderColorClass?: string;
className?: string;
};
/**
* Shared popover layout for "this scope overrides these fields" badges
* (e.g. profile overrides base config, camera overrides global config).
*/
export function OverrideDeltaPopover({
sectionPath,
deltas,
badgeLabel,
ariaLabel,
heading,
noDeltasMessage,
borderColorClass,
className,
}: Props) {
const fieldLabel = useOverrideFieldLabel(sectionPath);
const count = deltas.length;
return (
<Popover>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant="secondary"
className={cn(
"cursor-pointer border-2 text-center text-xs text-primary-variant",
borderColorClass ?? "border-selected",
className,
)}
aria-label={ariaLabel}
>
<span>{badgeLabel}</span>
<LuChevronDown className="ml-1 size-3" />
</Badge>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 max-w-[90vw] pr-0">
<div className="flex flex-col gap-3">
<div className="pr-4 text-xs text-primary-variant">
{count > 0 ? heading : noDeltasMessage}
</div>
{count > 0 && (
<ul className="scrollbar-container ml-5 flex max-h-[40dvh] list-disc flex-col gap-1 overflow-y-auto pr-4 text-xs">
{deltas.map((delta) => (
<li key={delta.fieldPath}>{fieldLabel(delta.fieldPath)}</li>
))}
</ul>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,62 @@
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import { useProfileSectionDeltas } from "@/hooks/use-config-override";
import type { FrigateConfig } from "@/types/frigateConfig";
import { OverrideDeltaPopover } from "./OverrideDeltaPopover";
type Props = {
sectionPath: string;
cameraName: string;
profileName: string;
profileFriendlyName?: string;
/** Border color class for profile-themed badge (e.g., "border-amber-500") */
profileBorderColor?: string;
className?: string;
};
export function ProfileOverridesBadge({
sectionPath,
cameraName,
profileName,
profileFriendlyName,
profileBorderColor,
className,
}: Props) {
const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation(["views/settings"]);
const deltas = useProfileSectionDeltas(
config,
cameraName,
profileName,
sectionPath,
);
const displayProfile = profileFriendlyName ?? profileName;
return (
<OverrideDeltaPopover
sectionPath={sectionPath}
deltas={deltas}
badgeLabel={t("button.overriddenBaseConfig", {
ns: "views/settings",
defaultValue: "Overridden (Base Config)",
})}
ariaLabel={t("button.overriddenBaseConfigTooltip", {
ns: "views/settings",
profile: displayProfile,
})}
heading={t("button.overriddenBaseConfigHeading", {
ns: "views/settings",
profile: displayProfile,
count: deltas.length,
})}
noDeltasMessage={t("button.overriddenBaseConfigNoDeltas", {
ns: "views/settings",
profile: displayProfile,
})}
borderColorClass={profileBorderColor}
className={className}
/>
);
}

View File

@ -0,0 +1,53 @@
import { useTranslation } from "react-i18next";
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
/**
* Resolve a translated label for a config field path within a section, falling
* back through reduced paths (dropping each intermediate segment in turn) so
* dict-keyed paths like `filters.person.threshold` still surface a meaningful
* label. Dropped segments are prepended as context (e.g. "Person · Threshold").
*
* Shared between override badges that need to render field labels (e.g.
* CameraOverridesBadge, ProfileOverridesBadge).
*/
export function useOverrideFieldLabel(sectionPath: string) {
const { t, i18n } = useTranslation([
"config/global",
"views/settings",
"objects",
]);
return (fieldPath: string): string => {
if (!fieldPath) {
const sectionKey = `${sectionPath}.label`;
return i18n.exists(sectionKey, { ns: "config/global" })
? t(sectionKey, { ns: "config/global" })
: humanizeKey(sectionPath);
}
const segments = fieldPath.split(".");
const fullKey = `${sectionPath}.${fieldPath}.label`;
if (i18n.exists(fullKey, { ns: "config/global" })) {
return t(fullKey, { ns: "config/global" });
}
for (let i = 0; i < segments.length; i++) {
const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join(
".",
);
if (!reduced) continue;
const reducedKey = `${sectionPath}.${reduced}.label`;
if (i18n.exists(reducedKey, { ns: "config/global" })) {
const resolvedLabel = t(reducedKey, { ns: "config/global" });
const dropped = segments[i];
const droppedLabel = i18n.exists(dropped, { ns: "objects" })
? t(dropped, { ns: "objects" })
: humanizeKey(dropped);
return `${droppedLabel} · ${resolvedLabel}`;
}
}
return humanizeKey(segments[segments.length - 1]);
};
}

View File

@ -6,6 +6,7 @@ import { getWidget } from "@rjsf/utils";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getNonNullSchema } from "../fields/nullableUtils"; import { getNonNullSchema } from "../fields/nullableUtils";
import type { ConfigFormContext } from "@/types/configForm";
export function OptionalFieldWidget(props: WidgetProps) { export function OptionalFieldWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options, registry } = const { id, value, disabled, readonly, onChange, schema, options, registry } =
@ -13,6 +14,8 @@ export function OptionalFieldWidget(props: WidgetProps) {
const innerWidgetName = (options.innerWidget as string) || undefined; const innerWidgetName = (options.innerWidget as string) || undefined;
const isEnabled = value !== undefined && value !== null; const isEnabled = value !== undefined && value !== null;
const formContext = registry?.formContext as ConfigFormContext | undefined;
const isProfile = !!formContext?.isProfile;
// Extract the non-null branch from anyOf [Type, null] // Extract the non-null branch from anyOf [Type, null]
const innerSchema = getNonNullSchema(schema) ?? schema; const innerSchema = getNonNullSchema(schema) ?? schema;
@ -42,10 +45,17 @@ export function OptionalFieldWidget(props: WidgetProps) {
const innerProps: WidgetProps = { const innerProps: WidgetProps = {
...props, ...props,
schema: innerSchema, schema: innerSchema,
disabled: disabled || readonly || !isEnabled, disabled: disabled || readonly || (!isProfile && !isEnabled),
value: isEnabled ? value : getDefaultValue(), value: isEnabled ? value : getDefaultValue(),
}; };
// don't show the switch if we're editing in a profile
// to disable in a profile, users should edit the config manually, eg:
// skip_motion_threshold: None
if (isProfile) {
return <InnerWidget {...innerProps} />;
}
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch <Switch

View File

@ -102,6 +102,19 @@ export default function ClassificationSelectionDialog({
// control // control
const [newClass, setNewClass] = useState(false); const [newClass, setNewClass] = useState(false);
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
// scroll containers, so attach a non-passive listener that scrolls manually.
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
if (!el || !isDesktop) return;
const handleWheel = (e: WheelEvent) => {
if (el.scrollHeight <= el.clientHeight) return;
e.preventDefault();
el.scrollTop += e.deltaY;
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => el.removeEventListener("wheel", handleWheel);
}, []);
// components // components
const Selector = isDesktop ? DropdownMenu : Drawer; const Selector = isDesktop ? DropdownMenu : Drawer;
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
@ -114,6 +127,8 @@ export default function ClassificationSelectionDialog({
</DrawerClose> </DrawerClose>
); );
// keep modal false on desktop to prevent dismissable layer pointer events
// issue with dialog auto-close
return ( return (
<div className={className ?? "flex"}> <div className={className ?? "flex"}>
<TextEntryDialog <TextEntryDialog
@ -122,14 +137,21 @@ export default function ClassificationSelectionDialog({
title={t("createCategory.new")} title={t("createCategory.new")}
onSave={(newCat) => onCategorizeImage(newCat)} onSave={(newCat) => onCategorizeImage(newCat)}
/> />
<Tooltip>
<Selector {...(isDesktop ? { modal: false } : {})}> <Selector {...(isDesktop ? { modal: false } : {})}>
<SelectorTrigger asChild> <Tooltip>
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger> <TooltipTrigger asChild={isChildButton}>
</SelectorTrigger> <SelectorTrigger asChild>{children}</SelectorTrigger>
</TooltipTrigger>
<TooltipContent>
{tooltipLabel ?? t("categorizeImage")}
</TooltipContent>
</Tooltip>
<SelectorContent <SelectorContent
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")} ref={scrollContainerRef}
className={cn(
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
)}
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
> >
{isMobile && ( {isMobile && (
@ -141,12 +163,7 @@ export default function ClassificationSelectionDialog({
<DropdownMenuLabel> <DropdownMenuLabel>
{dialogLabel ?? t("categorizeImageAs")} {dialogLabel ?? t("categorizeImageAs")}
</DropdownMenuLabel> </DropdownMenuLabel>
<div <div className={cn("flex flex-col", isMobile && "gap-2 pb-4")}>
className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto",
isMobile && "gap-2 pb-4",
)}
>
{filteredClasses {filteredClasses
.sort((a, b) => { .sort((a, b) => {
if (a === "none") return 1; if (a === "none") return 1;
@ -174,8 +191,6 @@ export default function ClassificationSelectionDialog({
</div> </div>
</SelectorContent> </SelectorContent>
</Selector> </Selector>
<TooltipContent>{tooltipLabel ?? t("categorizeImage")}</TooltipContent>
</Tooltip>
</div> </div>
); );
} }

View File

@ -23,7 +23,7 @@ import {
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import React, { ReactNode, useMemo, useState } from "react"; import React, { ReactNode, useCallback, useMemo, useState } from "react";
import TextEntryDialog from "./dialog/TextEntryDialog"; import TextEntryDialog from "./dialog/TextEntryDialog";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
@ -61,6 +61,19 @@ export default function FaceSelectionDialog({
// control // control
const [newFace, setNewFace] = useState(false); const [newFace, setNewFace] = useState(false);
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
// scroll containers, so attach a non-passive listener that scrolls manually.
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
if (!el || !isDesktop) return;
const handleWheel = (e: WheelEvent) => {
if (el.scrollHeight <= el.clientHeight) return;
e.preventDefault();
el.scrollTop += e.deltaY;
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => el.removeEventListener("wheel", handleWheel);
}, []);
// components // components
const Selector = isDesktop ? DropdownMenu : Drawer; const Selector = isDesktop ? DropdownMenu : Drawer;
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
@ -73,6 +86,8 @@ export default function FaceSelectionDialog({
</DrawerClose> </DrawerClose>
); );
// keep modal false on desktop to prevent dismissable layer pointer events
// issue with dialog auto-close
return ( return (
<div className={className ?? "flex"}> <div className={className ?? "flex"}>
{newFace && ( {newFace && (
@ -83,14 +98,19 @@ export default function FaceSelectionDialog({
onSave={(newName) => onTrainAttempt(newName)} onSave={(newName) => onTrainAttempt(newName)}
/> />
)} )}
<Tooltip>
<Selector {...(isDesktop ? { modal: false } : {})}> <Selector {...(isDesktop ? { modal: false } : {})}>
<SelectorTrigger asChild> <Tooltip>
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger> <TooltipTrigger asChild={isChildButton}>
</SelectorTrigger> <SelectorTrigger asChild>{children}</SelectorTrigger>
</TooltipTrigger>
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
</Tooltip>
<SelectorContent <SelectorContent
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")} ref={scrollContainerRef}
className={cn(
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
)}
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
> >
{isMobile && ( {isMobile && (
@ -104,8 +124,9 @@ export default function FaceSelectionDialog({
</DropdownMenuLabel> </DropdownMenuLabel>
<div <div
className={cn( className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden", "flex flex-col",
isMobile && "gap-2 pb-4", isMobile &&
"max-h-[40dvh] gap-2 overflow-y-auto overflow-x-hidden pb-4",
)} )}
> >
{filteredNames.sort().map((faceName) => ( {filteredNames.sort().map((faceName) => (
@ -127,8 +148,6 @@ export default function FaceSelectionDialog({
</div> </div>
</SelectorContent> </SelectorContent>
</Selector> </Selector>
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
</Tooltip>
</div> </div>
); );
} }

View File

@ -12,6 +12,7 @@ import { isJsonObject } from "@/lib/utils";
import { import {
getBaseCameraSectionValue, getBaseCameraSectionValue,
getEffectiveHiddenFields, getEffectiveHiddenFields,
pathMatchesHiddenPattern,
unsetWithWildcard, unsetWithWildcard,
} from "@/utils/configUtil"; } from "@/utils/configUtil";
import { extractSectionSchema } from "@/hooks/use-config-schema"; import { extractSectionSchema } from "@/hooks/use-config-schema";
@ -663,3 +664,138 @@ export function useCamerasOverridingSection(
return entries; return entries;
}, [config, sectionPath, schema]); }, [config, sectionPath, schema]);
} }
/**
* Hook returning the field-level deltas between a single camera's base
* (pre-profile) section value and the effective global baseline. Mirrors
* `useConfigOverride`'s comparison logic but exposes per-field deltas so a
* popover can list the overridden fields.
*
* @example
* ```tsx
* const deltas = useCameraSectionDeltas(config, "front_door", "detect");
* // [{ fieldPath: "fps", globalValue: 5, cameraValue: 10 }]
* ```
*/
export function useCameraSectionDeltas(
config: FrigateConfig | undefined,
cameraName: string | undefined,
sectionPath: string,
): FieldDelta[] {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => {
if (!config?.cameras || !cameraName || !sectionPath) {
return [];
}
const cameraConfig = config.cameras[cameraName];
if (!cameraConfig) return [];
const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath);
const compareFields = sectionMeta?.compareFields;
const globalValue = collapseEmpty(
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
);
const cameraValue = collapseEmpty(
normalizeConfigValue(
getBaseCameraSectionValue(config, cameraName, sectionPath),
),
);
const hiddenFields = getEffectiveHiddenFields(
sectionPath,
"camera",
config,
);
const deltas: FieldDelta[] = [];
for (const delta of collectFieldDeltas(
globalValue,
cameraValue,
compareFields,
)) {
if (
hiddenFields.some((pattern) =>
pathMatchesHiddenPattern(delta.fieldPath, pattern),
)
) {
continue;
}
deltas.push(delta);
}
return deltas;
}, [config, cameraName, sectionPath, schema]);
}
/**
* Hook returning the field-level deltas between a single profile's overrides
* and the camera's base (pre-profile) section value. Honors per-section
* `compareFields` filters and hidden-field patterns so the result matches
* what's actually exposed in the UI.
*
* @example
* ```tsx
* const deltas = useProfileSectionDeltas(config, "front_door", "night", "detect");
* // [{ fieldPath: "fps", globalValue: 5, cameraValue: 10, profileName: "night" }]
* ```
*/
export function useProfileSectionDeltas(
config: FrigateConfig | undefined,
cameraName: string | undefined,
profileName: string | undefined,
sectionPath: string,
): FieldDelta[] {
return useMemo(() => {
if (!config?.cameras || !cameraName || !profileName || !sectionPath) {
return [];
}
const cameraConfig = config.cameras[cameraName];
if (!cameraConfig) return [];
const profileSection = (
cameraConfig.profiles?.[profileName] as
| Record<string, unknown>
| undefined
)?.[sectionPath];
if (profileSection == null) return [];
const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath);
const compareFields = sectionMeta?.compareFields;
const baseValue = collapseEmpty(
normalizeConfigValue(
getBaseCameraSectionValue(config, cameraName, sectionPath),
),
);
const profileValue = collapseEmpty(
normalizeConfigValue(profileSection as JsonValue),
);
const hiddenFields = getEffectiveHiddenFields(
sectionPath,
"camera",
config,
);
const deltas: FieldDelta[] = [];
for (const path of collectDefinedLeafPaths(profileValue)) {
if (!isPathAllowed(path, compareFields)) continue;
if (
hiddenFields.some((pattern) => pathMatchesHiddenPattern(path, pattern))
) {
continue;
}
const baseField = get(baseValue, path);
const profileField = get(profileValue, path);
if (!isEqual(baseField, profileField)) {
deltas.push({
fieldPath: path,
globalValue: baseField,
cameraValue: profileField,
profileName,
});
}
}
return deltas;
}, [config, cameraName, profileName, sectionPath]);
}

View File

@ -44,4 +44,5 @@ export type ConfigFormContext = {
requiresRestart?: boolean; requiresRestart?: boolean;
t?: (key: string, options?: Record<string, unknown>) => string; t?: (key: string, options?: Record<string, unknown>) => string;
renderers?: Record<string, RendererComponent>; renderers?: Record<string, RendererComponent>;
isProfile?: boolean;
}; };

View File

@ -6,7 +6,6 @@
import get from "lodash/get"; import get from "lodash/get";
import cloneDeep from "lodash/cloneDeep"; import cloneDeep from "lodash/cloneDeep";
import merge from "lodash/merge";
import unset from "lodash/unset"; import unset from "lodash/unset";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import mergeWith from "lodash/mergeWith"; import mergeWith from "lodash/mergeWith";
@ -92,6 +91,32 @@ export function getBaseCameraSectionValue(
return base !== undefined ? base : get(cam, sectionPath); return base !== undefined ? base : get(cam, sectionPath);
} }
// mergeWith customizer that replaces arrays wholesale instead of merging them
// positionally by index. Used when the source value is meant to fully replace
// the destination (e.g. profile overrides, section config overrides), so an
// empty source array correctly clears the destination array.
const replaceArraysCustomizer = (objValue: unknown, srcValue: unknown) => {
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
return srcValue !== undefined ? srcValue : objValue;
}
return undefined;
};
// Merge profile overrides on top of base config values. Matches the backend's
// deep_merge(overrides, base_data) semantics: arrays are replaced wholesale by
// the profile's value rather than merged positionally, so an empty array in a
// profile clears the base array instead of leaving stale entries behind.
export function mergeProfileOverrides<T extends object>(
baseValue: T,
profileOverrides: object,
): T {
return mergeWith(
cloneDeep(baseValue),
cloneDeep(profileOverrides),
replaceArraysCustomizer,
) as T;
}
/** Sections that can appear inside a camera profile definition. */ /** Sections that can appear inside a camera profile definition. */
export const PROFILE_ELIGIBLE_SECTIONS = new Set([ export const PROFILE_ELIGIBLE_SECTIONS = new Set([
"audio", "audio",
@ -564,9 +589,9 @@ export function prepareSectionSavePayload(opts: {
baseValue && baseValue &&
typeof baseValue === "object" typeof baseValue === "object"
) { ) {
rawSectionValue = merge( rawSectionValue = mergeProfileOverrides(
cloneDeep(baseValue), baseValue as object,
cloneDeep(profileOverrides), profileOverrides as object,
); );
} else { } else {
rawSectionValue = baseValue; rawSectionValue = baseValue;
@ -675,13 +700,12 @@ const mergeSectionConfig = (
overrides: Partial<SectionConfig> | undefined, overrides: Partial<SectionConfig> | undefined,
): SectionConfig => ): SectionConfig =>
mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => { mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => {
if (Array.isArray(objValue) || Array.isArray(srcValue)) { const arrayResult = replaceArraysCustomizer(objValue, srcValue);
return srcValue ?? objValue; if (arrayResult !== undefined) return arrayResult;
}
if (key === "uiSchema") { if (key === "uiSchema") {
if (objValue && srcValue) { if (objValue && srcValue) {
return merge({}, objValue, srcValue); return mergeWith({}, objValue, srcValue, replaceArraysCustomizer);
} }
return srcValue ?? objValue; return srcValue ?? objValue;
} }
@ -739,3 +763,26 @@ export function resolveHiddenFieldEntries(
} }
return result; return result;
} }
/**
* Match a delta path against a hidden-field pattern. Supports literal prefixes
* (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards
* matching exactly one path segment (e.g. "filters.*.mask").
*/
export function pathMatchesHiddenPattern(
path: string,
pattern: string,
): boolean {
if (!pattern) return false;
if (!pattern.includes("*")) {
return path === pattern || path.startsWith(`${pattern}.`);
}
const patternSegments = pattern.split(".");
const pathSegments = path.split(".");
if (pathSegments.length < patternSegments.length) return false;
for (let i = 0; i < patternSegments.length; i += 1) {
if (patternSegments[i] === "*") continue;
if (patternSegments[i] !== pathSegments[i]) return false;
}
return true;
}

View File

@ -33,6 +33,7 @@ import { getTranslatedLabel } from "@/utils/i18n";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { AudioLevelGraph } from "@/components/audio/AudioLevelGraph"; import { AudioLevelGraph } from "@/components/audio/AudioLevelGraph";
import { useWs } from "@/api/ws"; import { useWs } from "@/api/ws";
import { cn } from "@/lib/utils";
type ObjectSettingsViewProps = { type ObjectSettingsViewProps = {
selectedCamera?: string; selectedCamera?: string;
@ -200,15 +201,18 @@ export default function ObjectSettingsView({
<Tabs defaultValue="debug" className="w-full"> <Tabs defaultValue="debug" className="w-full">
<TabsList <TabsList
className={`grid w-full ${cameraConfig.ffmpeg.inputs.some((input) => input.roles.includes("audio")) ? "grid-cols-3" : "grid-cols-2"}`} className={cn(
"grid w-full",
cameraConfig.audio.enabled_in_config
? "grid-cols-3"
: "grid-cols-2",
)}
> >
<TabsTrigger value="debug">{t("debug.debugging")}</TabsTrigger> <TabsTrigger value="debug">{t("debug.debugging")}</TabsTrigger>
<TabsTrigger value="objectlist"> <TabsTrigger value="objectlist">
{t("debug.objectList")} {t("debug.objectList")}
</TabsTrigger> </TabsTrigger>
{cameraConfig.ffmpeg.inputs.some((input) => {cameraConfig.audio.enabled_in_config && (
input.roles.includes("audio"),
) && (
<TabsTrigger value="audio">{t("debug.audio.title")}</TabsTrigger> <TabsTrigger value="audio">{t("debug.audio.title")}</TabsTrigger>
)} )}
</TabsList> </TabsList>
@ -325,9 +329,7 @@ export default function ObjectSettingsView({
<TabsContent value="objectlist"> <TabsContent value="objectlist">
<ObjectList cameraConfig={cameraConfig} objects={memoizedObjects} /> <ObjectList cameraConfig={cameraConfig} objects={memoizedObjects} />
</TabsContent> </TabsContent>
{cameraConfig.ffmpeg.inputs.some((input) => {cameraConfig.audio.enabled_in_config && (
input.roles.includes("audio"),
) && (
<TabsContent value="audio"> <TabsContent value="audio">
<AudioList <AudioList
cameraConfig={cameraConfig} cameraConfig={cameraConfig}

View File

@ -3,18 +3,14 @@ import { useTranslation } from "react-i18next";
import type { SectionConfig } from "@/components/config-form/sections"; import type { SectionConfig } from "@/components/config-form/sections";
import { ConfigSectionTemplate } from "@/components/config-form/sections"; import { ConfigSectionTemplate } from "@/components/config-form/sections";
import { CameraOverridesBadge } from "@/components/config-form/sections/CameraOverridesBadge"; import { CameraOverridesBadge } from "@/components/config-form/sections/CameraOverridesBadge";
import { GlobalOverridesBadge } from "@/components/config-form/sections/GlobalOverridesBadge";
import { ProfileOverridesBadge } from "@/components/config-form/sections/ProfileOverridesBadge";
import type { PolygonType } from "@/types/canvas"; import type { PolygonType } from "@/types/canvas";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ConfigSectionData } from "@/types/configForm"; import type { ConfigSectionData } from "@/types/configForm";
import type { ProfileState } from "@/types/profile"; import type { ProfileState } from "@/types/profile";
import { getSectionConfig } from "@/utils/configUtil"; import { getSectionConfig } from "@/utils/configUtil";
import { getProfileColor } from "@/utils/profileColors"; import { getProfileColor } from "@/utils/profileColors";
import { cn } from "@/lib/utils";
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";
import { LuExternalLink } from "react-icons/lu"; import { LuExternalLink } from "react-icons/lu";
@ -173,46 +169,25 @@ export function SingleSectionPage({
)} )}
{level === "camera" && {level === "camera" &&
showOverrideIndicator && showOverrideIndicator &&
sectionStatus.isOverridden && ( sectionStatus.isOverridden &&
<Tooltip> selectedCamera &&
<TooltipTrigger asChild> (sectionStatus.overrideSource === "profile" &&
<Badge currentEditingProfile ? (
variant="secondary" <ProfileOverridesBadge
className={cn( sectionPath={sectionKey}
"cursor-default border-2 text-center text-xs text-primary-variant", cameraName={selectedCamera}
sectionStatus.overrideSource === "profile" && profileName={currentEditingProfile}
profileColor profileFriendlyName={profileState?.profileFriendlyNames.get(
? profileColor.border
: "border-selected",
)}
>
{sectionStatus.overrideSource === "profile"
? t("button.overriddenBaseConfig", {
ns: "views/settings",
defaultValue: "Overridden (Base Config)",
})
: t("button.overriddenGlobal", {
ns: "views/settings",
defaultValue: "Overridden (Global)",
})}
</Badge>
</TooltipTrigger>
<TooltipContent>
{sectionStatus.overrideSource === "profile"
? t("button.overriddenBaseConfigTooltip", {
ns: "views/settings",
profile: currentEditingProfile
? (profileState?.profileFriendlyNames.get(
currentEditingProfile, currentEditingProfile,
) ?? currentEditingProfile)
: "",
})
: t("button.overriddenGlobalTooltip", {
ns: "views/settings",
})}
</TooltipContent>
</Tooltip>
)} )}
profileBorderColor={profileColor?.border}
/>
) : (
<GlobalOverridesBadge
sectionPath={sectionKey}
cameraName={selectedCamera}
/>
))}
{sectionStatus.hasChanges && ( {sectionStatus.hasChanges && (
<Badge <Badge
variant="secondary" variant="secondary"
@ -233,27 +208,25 @@ export function SingleSectionPage({
)} )}
{level === "camera" && {level === "camera" &&
showOverrideIndicator && showOverrideIndicator &&
sectionStatus.isOverridden && ( sectionStatus.isOverridden &&
<Badge selectedCamera &&
variant="secondary" (sectionStatus.overrideSource === "profile" &&
className={cn( currentEditingProfile ? (
"cursor-default border-2 text-center text-xs text-primary-variant", <ProfileOverridesBadge
sectionStatus.overrideSource === "profile" && profileColor sectionPath={sectionKey}
? profileColor.border cameraName={selectedCamera}
: "border-selected", profileName={currentEditingProfile}
)} profileFriendlyName={profileState?.profileFriendlyNames.get(
> currentEditingProfile,
{sectionStatus.overrideSource === "profile"
? t("button.overriddenBaseConfig", {
ns: "views/settings",
defaultValue: "Overridden (Base Config)",
})
: t("button.overriddenGlobal", {
ns: "views/settings",
defaultValue: "Overridden (Global)",
})}
</Badge>
)} )}
profileBorderColor={profileColor?.border}
/>
) : (
<GlobalOverridesBadge
sectionPath={sectionKey}
cameraName={selectedCamera}
/>
))}
{sectionStatus.hasChanges && ( {sectionStatus.hasChanges && (
<Badge <Badge
variant="secondary" variant="secondary"