mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-27 06:41:53 +03:00
add override badges for cameras and profiles
collect shared functions into the config util and separate hooks
This commit is contained in:
parent
3bc1ae2f77
commit
ed4b2cab78
@ -19,8 +19,14 @@
|
||||
"button": {
|
||||
"overriddenGlobal": "Overridden (Global)",
|
||||
"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 section is marked as overridden, but no field values differ from the global config.",
|
||||
"overriddenBaseConfig": "Overridden (Base Config)",
|
||||
"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": {
|
||||
"label_one": "Overridden in {{count}} camera",
|
||||
"label_other": "Overridden in {{count}} cameras",
|
||||
|
||||
@ -27,14 +27,11 @@ import {
|
||||
import { getSectionValidation } from "../section-validations";
|
||||
import { useConfigOverride } from "@/hooks/use-config-override";
|
||||
import { CameraOverridesBadge } from "./CameraOverridesBadge";
|
||||
import { GlobalOverridesBadge } from "./GlobalOverridesBadge";
|
||||
import { ProfileOverridesBadge } from "./ProfileOverridesBadge";
|
||||
import { useSectionSchema } from "@/hooks/use-config-schema";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import Heading from "@/components/ui/heading";
|
||||
@ -1257,33 +1254,22 @@ export function ConfigSection({
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
(profileOverridesSection || isOverridden) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfig", {
|
||||
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>
|
||||
)}
|
||||
(profileOverridesSection || isOverridden) &&
|
||||
cameraName &&
|
||||
(overrideSource === "profile" && profileName ? (
|
||||
<ProfileOverridesBadge
|
||||
sectionPath={sectionPath}
|
||||
cameraName={cameraName}
|
||||
profileName={profileName}
|
||||
profileFriendlyName={profileFriendlyName}
|
||||
profileBorderColor={profileBorderColor}
|
||||
/>
|
||||
) : (
|
||||
<GlobalOverridesBadge
|
||||
sectionPath={sectionPath}
|
||||
cameraName={cameraName}
|
||||
/>
|
||||
))}
|
||||
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||
)}
|
||||
@ -1323,41 +1309,22 @@ export function ConfigSection({
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
(profileOverridesSection || isOverridden) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-default border-2 text-center text-xs text-primary-variant",
|
||||
overrideSource === "profile" && profileBorderColor
|
||||
? profileBorderColor
|
||||
: "border-selected",
|
||||
)}
|
||||
>
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfig", {
|
||||
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>
|
||||
)}
|
||||
(profileOverridesSection || isOverridden) &&
|
||||
cameraName &&
|
||||
(overrideSource === "profile" && profileName ? (
|
||||
<ProfileOverridesBadge
|
||||
sectionPath={sectionPath}
|
||||
cameraName={cameraName}
|
||||
profileName={profileName}
|
||||
profileFriendlyName={profileFriendlyName}
|
||||
profileBorderColor={profileBorderColor}
|
||||
/>
|
||||
) : (
|
||||
<GlobalOverridesBadge
|
||||
sectionPath={sectionPath}
|
||||
cameraName={cameraName}
|
||||
/>
|
||||
))}
|
||||
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||
)}
|
||||
|
||||
@ -17,10 +17,13 @@ import {
|
||||
} from "@/hooks/use-config-override";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { ProfilesApiResponse } from "@/types/profile";
|
||||
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
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> = {
|
||||
detect: "cameraDetect",
|
||||
@ -72,26 +75,6 @@ const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
|
||||
"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 = {
|
||||
sectionPath: string;
|
||||
entry: CameraOverrideEntry;
|
||||
@ -127,11 +110,8 @@ function groupDeltasBySource(deltas: FieldDelta[]): SourceGroup[] {
|
||||
}
|
||||
|
||||
function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
||||
const { t, i18n } = useTranslation([
|
||||
"config/global",
|
||||
"views/settings",
|
||||
"objects",
|
||||
]);
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const fieldLabel = useOverrideFieldLabel(sectionPath);
|
||||
const friendlyName = useCameraFriendlyName(entry.camera);
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
|
||||
@ -141,49 +121,6 @@ function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
||||
return map;
|
||||
}, [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 visibleLabels = deltas
|
||||
.slice(0, MAX_FIELDS_PER_CAMERA)
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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]);
|
||||
};
|
||||
}
|
||||
@ -12,6 +12,7 @@ import { isJsonObject } from "@/lib/utils";
|
||||
import {
|
||||
getBaseCameraSectionValue,
|
||||
getEffectiveHiddenFields,
|
||||
pathMatchesHiddenPattern,
|
||||
unsetWithWildcard,
|
||||
} from "@/utils/configUtil";
|
||||
import { extractSectionSchema } from "@/hooks/use-config-schema";
|
||||
@ -663,3 +664,138 @@ export function useCamerasOverridingSection(
|
||||
return entries;
|
||||
}, [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]);
|
||||
}
|
||||
|
||||
@ -763,3 +763,26 @@ export function resolveHiddenFieldEntries(
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@ -3,18 +3,14 @@ import { useTranslation } from "react-i18next";
|
||||
import type { SectionConfig } from "@/components/config-form/sections";
|
||||
import { ConfigSectionTemplate } from "@/components/config-form/sections";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
import type { ProfileState } from "@/types/profile";
|
||||
import { getSectionConfig } from "@/utils/configUtil";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
@ -173,46 +169,25 @@ export function SingleSectionPage({
|
||||
)}
|
||||
{level === "camera" &&
|
||||
showOverrideIndicator &&
|
||||
sectionStatus.isOverridden && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-default border-2 text-center text-xs text-primary-variant",
|
||||
sectionStatus.overrideSource === "profile" &&
|
||||
profileColor
|
||||
? 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)
|
||||
: "",
|
||||
})
|
||||
: t("button.overriddenGlobalTooltip", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
sectionStatus.isOverridden &&
|
||||
selectedCamera &&
|
||||
(sectionStatus.overrideSource === "profile" &&
|
||||
currentEditingProfile ? (
|
||||
<ProfileOverridesBadge
|
||||
sectionPath={sectionKey}
|
||||
cameraName={selectedCamera}
|
||||
profileName={currentEditingProfile}
|
||||
profileFriendlyName={profileState?.profileFriendlyNames.get(
|
||||
currentEditingProfile,
|
||||
)}
|
||||
profileBorderColor={profileColor?.border}
|
||||
/>
|
||||
) : (
|
||||
<GlobalOverridesBadge
|
||||
sectionPath={sectionKey}
|
||||
cameraName={selectedCamera}
|
||||
/>
|
||||
))}
|
||||
{sectionStatus.hasChanges && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@ -233,27 +208,25 @@ export function SingleSectionPage({
|
||||
)}
|
||||
{level === "camera" &&
|
||||
showOverrideIndicator &&
|
||||
sectionStatus.isOverridden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-default border-2 text-center text-xs text-primary-variant",
|
||||
sectionStatus.overrideSource === "profile" && profileColor
|
||||
? profileColor.border
|
||||
: "border-selected",
|
||||
sectionStatus.isOverridden &&
|
||||
selectedCamera &&
|
||||
(sectionStatus.overrideSource === "profile" &&
|
||||
currentEditingProfile ? (
|
||||
<ProfileOverridesBadge
|
||||
sectionPath={sectionKey}
|
||||
cameraName={selectedCamera}
|
||||
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 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user