mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-05 11:31:13 +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": {
|
"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 section is marked as overridden, but no field values differ from the global config.",
|
||||||
"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",
|
||||||
|
|||||||
@ -27,14 +27,11 @@ 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";
|
||||||
@ -1257,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} />
|
||||||
)}
|
)}
|
||||||
@ -1323,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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 {
|
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]);
|
||||||
|
}
|
||||||
|
|||||||
@ -763,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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
currentEditingProfile,
|
||||||
: "border-selected",
|
)}
|
||||||
)}
|
profileBorderColor={profileColor?.border}
|
||||||
>
|
/>
|
||||||
{sectionStatus.overrideSource === "profile"
|
) : (
|
||||||
? t("button.overriddenBaseConfig", {
|
<GlobalOverridesBadge
|
||||||
ns: "views/settings",
|
sectionPath={sectionKey}
|
||||||
defaultValue: "Overridden (Base Config)",
|
cameraName={selectedCamera}
|
||||||
})
|
/>
|
||||||
: 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.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,
|
||||||
)}
|
)}
|
||||||
>
|
profileBorderColor={profileColor?.border}
|
||||||
{sectionStatus.overrideSource === "profile"
|
/>
|
||||||
? t("button.overriddenBaseConfig", {
|
) : (
|
||||||
ns: "views/settings",
|
<GlobalOverridesBadge
|
||||||
defaultValue: "Overridden (Base Config)",
|
sectionPath={sectionKey}
|
||||||
})
|
cameraName={selectedCamera}
|
||||||
: t("button.overriddenGlobal", {
|
/>
|
||||||
ns: "views/settings",
|
))}
|
||||||
defaultValue: "Overridden (Global)",
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{sectionStatus.hasChanges && (
|
{sectionStatus.hasChanges && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user