mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-05 04:57:42 +03:00
Settings UI improvements (#23109)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* use badge with popover to show which cameras override each global config section * don't use shorthand * use label i18n
This commit is contained in:
parent
ef9d7e07b7
commit
f448b259a2
@ -20,7 +20,18 @@
|
|||||||
"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",
|
||||||
"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",
|
||||||
|
"overriddenInCameras": {
|
||||||
|
"label_one": "Overridden in {{count}} camera",
|
||||||
|
"label_other": "Overridden in {{count}} cameras",
|
||||||
|
"tooltip_one": "{{count}} camera overrides values in this section. Click to see details.",
|
||||||
|
"tooltip_other": "{{count}} cameras override values in this section. Click to see details.",
|
||||||
|
"heading_one": "This global section has fields that are overridden in {{count}} camera.",
|
||||||
|
"heading_other": "This global section has fields that are overridden in {{count}} cameras.",
|
||||||
|
"othersField_one": "{{count}} other",
|
||||||
|
"othersField_other": "{{count}} others",
|
||||||
|
"profilePrefix": "{{profile}} profile: {{fields}}"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import {
|
|||||||
} from "./section-special-cases";
|
} from "./section-special-cases";
|
||||||
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 { 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";
|
||||||
@ -1263,6 +1264,9 @@ export function ConfigSection({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||||
|
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||||
|
)}
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{t("button.modified", {
|
{t("button.modified", {
|
||||||
@ -1334,6 +1338,9 @@ export function ConfigSection({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||||
|
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||||
|
)}
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
303
web/src/components/config-form/sections/CameraOverridesBadge.tsx
Normal file
303
web/src/components/config-form/sections/CameraOverridesBadge.tsx
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
import useSWR from "swr";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { LuChevronDown } from "react-icons/lu";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
CameraOverrideEntry,
|
||||||
|
FieldDelta,
|
||||||
|
useCamerasOverridingSection,
|
||||||
|
} 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 { getSectionConfig } from "@/utils/configUtil";
|
||||||
|
|
||||||
|
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
|
||||||
|
detect: "cameraDetect",
|
||||||
|
ffmpeg: "cameraFfmpeg",
|
||||||
|
record: "cameraRecording",
|
||||||
|
snapshots: "cameraSnapshots",
|
||||||
|
motion: "cameraMotion",
|
||||||
|
objects: "cameraObjects",
|
||||||
|
review: "cameraReview",
|
||||||
|
audio: "cameraAudioEvents",
|
||||||
|
audio_transcription: "cameraAudioTranscription",
|
||||||
|
notifications: "cameraNotifications",
|
||||||
|
live: "cameraLivePlayback",
|
||||||
|
birdseye: "cameraBirdseye",
|
||||||
|
face_recognition: "cameraFaceRecognition",
|
||||||
|
lpr: "cameraLpr",
|
||||||
|
timestamp_style: "cameraTimestampStyle",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_FIELDS_PER_CAMERA = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrichment sections where the cross-camera override badge should be
|
||||||
|
* suppressed because they're effectively global-only (or per-camera
|
||||||
|
* configuration there isn't a useful affordance to surface here).
|
||||||
|
* Face recognition and LPR are intentionally omitted so the badge does show
|
||||||
|
* on those enrichment pages.
|
||||||
|
*/
|
||||||
|
const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
|
||||||
|
"semantic_search",
|
||||||
|
"genai",
|
||||||
|
"classification",
|
||||||
|
"audio_transcription",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
cameraPage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SourceGroup = {
|
||||||
|
/** undefined → camera-level; string → profile name */
|
||||||
|
profileName: string | undefined;
|
||||||
|
deltas: FieldDelta[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupDeltasBySource(deltas: FieldDelta[]): SourceGroup[] {
|
||||||
|
const cameraDeltas: FieldDelta[] = [];
|
||||||
|
const byProfile = new Map<string, FieldDelta[]>();
|
||||||
|
for (const delta of deltas) {
|
||||||
|
if (delta.profileName) {
|
||||||
|
const arr = byProfile.get(delta.profileName) ?? [];
|
||||||
|
arr.push(delta);
|
||||||
|
byProfile.set(delta.profileName, arr);
|
||||||
|
} else {
|
||||||
|
cameraDeltas.push(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const groups: SourceGroup[] = [];
|
||||||
|
if (cameraDeltas.length > 0) {
|
||||||
|
groups.push({ profileName: undefined, deltas: cameraDeltas });
|
||||||
|
}
|
||||||
|
for (const [profileName, group] of byProfile) {
|
||||||
|
groups.push({ profileName, deltas: group });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
||||||
|
const { t, i18n } = useTranslation([
|
||||||
|
"config/global",
|
||||||
|
"views/settings",
|
||||||
|
"objects",
|
||||||
|
]);
|
||||||
|
const friendlyName = useCameraFriendlyName(entry.camera);
|
||||||
|
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||||
|
|
||||||
|
const profileFriendlyNames = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
profilesData?.profiles?.forEach((p) => map.set(p.name, p.friendly_name));
|
||||||
|
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)
|
||||||
|
.map((delta) => fieldLabel(delta.fieldPath));
|
||||||
|
const hiddenCount = deltas.length - visibleLabels.length;
|
||||||
|
const labelsForList =
|
||||||
|
hiddenCount > 0
|
||||||
|
? [
|
||||||
|
...visibleLabels,
|
||||||
|
t("button.overriddenInCameras.othersField", {
|
||||||
|
ns: "views/settings",
|
||||||
|
count: hiddenCount,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: visibleLabels;
|
||||||
|
return formatList(labelsForList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups = groupDeltasBySource(entry.fieldDeltas);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5 text-xs">
|
||||||
|
{cameraPage ? (
|
||||||
|
<Link
|
||||||
|
to={`/settings?page=${cameraPage}&camera=${encodeURIComponent(entry.camera)}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{friendlyName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{friendlyName}</span>
|
||||||
|
)}
|
||||||
|
{groups.map((group) => (
|
||||||
|
<span
|
||||||
|
key={group.profileName ?? "__camera__"}
|
||||||
|
className="ml-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{group.profileName
|
||||||
|
? t("button.overriddenInCameras.profilePrefix", {
|
||||||
|
ns: "views/settings",
|
||||||
|
profile:
|
||||||
|
profileFriendlyNames.get(group.profileName) ??
|
||||||
|
group.profileName,
|
||||||
|
fields: formatDeltas(group.deltas),
|
||||||
|
})
|
||||||
|
: formatDeltas(group.deltas)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
sectionPath: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CameraOverridesBadge({ sectionPath, className }: Props) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const rawEntries = useCamerasOverridingSection(config, sectionPath);
|
||||||
|
|
||||||
|
const entries = useMemo(() => {
|
||||||
|
const hiddenFields =
|
||||||
|
getSectionConfig(sectionPath, "global").hiddenFields ?? [];
|
||||||
|
if (hiddenFields.length === 0) return rawEntries;
|
||||||
|
return rawEntries
|
||||||
|
.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
fieldDeltas: entry.fieldDeltas.filter(
|
||||||
|
(delta) =>
|
||||||
|
!hiddenFields.some((pattern) =>
|
||||||
|
pathMatchesHiddenPattern(delta.fieldPath, pattern),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.fieldDeltas.length > 0);
|
||||||
|
}, [rawEntries, sectionPath]);
|
||||||
|
|
||||||
|
if (SECTIONS_WITHOUT_OVERRIDE_BADGE.has(sectionPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameraPage = CAMERA_PAGE_BY_SECTION[sectionPath];
|
||||||
|
const count = entries.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={`cursor-pointer border-2 border-selected text-xs text-primary-variant ${className ?? ""}`}
|
||||||
|
aria-label={t("button.overriddenInCameras.tooltip", {
|
||||||
|
ns: "views/settings",
|
||||||
|
count: count,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{t("button.overriddenInCameras.label", {
|
||||||
|
ns: "views/settings",
|
||||||
|
count: count,
|
||||||
|
})}
|
||||||
|
</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">
|
||||||
|
{t("button.overriddenInCameras.heading", {
|
||||||
|
ns: "views/settings",
|
||||||
|
count: count,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="scrollbar-container flex max-h-[40dvh] flex-col gap-2 overflow-y-auto pr-4">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<CameraEntry
|
||||||
|
key={entry.camera}
|
||||||
|
sectionPath={sectionPath}
|
||||||
|
entry={entry}
|
||||||
|
cameraPage={cameraPage}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -202,6 +202,49 @@ export function useConfigOverride({
|
|||||||
}, [config, cameraName, sectionPath, compareFields]);
|
}, [config, cameraName, sectionPath, compareFields]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sections that can be overridden per-camera, with optional compareFields
|
||||||
|
* filters that scope the override comparison to a subset of fields.
|
||||||
|
*/
|
||||||
|
export const OVERRIDABLE_SECTIONS: ReadonlyArray<{
|
||||||
|
key: string;
|
||||||
|
compareFields?: string[];
|
||||||
|
}> = [
|
||||||
|
{ key: "detect" },
|
||||||
|
{ key: "record" },
|
||||||
|
{ key: "snapshots" },
|
||||||
|
{ key: "motion" },
|
||||||
|
{ key: "objects" },
|
||||||
|
{ key: "review" },
|
||||||
|
{ key: "audio" },
|
||||||
|
{ key: "notifications" },
|
||||||
|
{ key: "live" },
|
||||||
|
{ key: "timestamp_style" },
|
||||||
|
{
|
||||||
|
key: "audio_transcription",
|
||||||
|
compareFields: ["enabled", "live_enabled"],
|
||||||
|
},
|
||||||
|
{ key: "birdseye", compareFields: ["enabled", "mode"] },
|
||||||
|
{ key: "face_recognition", compareFields: ["enabled", "min_area"] },
|
||||||
|
{
|
||||||
|
key: "ffmpeg",
|
||||||
|
compareFields: [
|
||||||
|
"path",
|
||||||
|
"global_args",
|
||||||
|
"hwaccel_args",
|
||||||
|
"input_args",
|
||||||
|
"output_args",
|
||||||
|
"retry_interval",
|
||||||
|
"apple_compatibility",
|
||||||
|
"gpu",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lpr",
|
||||||
|
compareFields: ["enabled", "min_area", "enhancement"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get all overridden fields for a camera
|
* Hook to get all overridden fields for a camera
|
||||||
*/
|
*/
|
||||||
@ -221,47 +264,7 @@ export function useAllCameraOverrides(
|
|||||||
|
|
||||||
const overriddenSections: string[] = [];
|
const overriddenSections: string[] = [];
|
||||||
|
|
||||||
// Check each section that can be overridden
|
for (const { key, compareFields } of OVERRIDABLE_SECTIONS) {
|
||||||
const sectionsToCheck: Array<{
|
|
||||||
key: string;
|
|
||||||
compareFields?: string[];
|
|
||||||
}> = [
|
|
||||||
{ key: "detect" },
|
|
||||||
{ key: "record" },
|
|
||||||
{ key: "snapshots" },
|
|
||||||
{ key: "motion" },
|
|
||||||
{ key: "objects" },
|
|
||||||
{ key: "review" },
|
|
||||||
{ key: "audio" },
|
|
||||||
{ key: "notifications" },
|
|
||||||
{ key: "live" },
|
|
||||||
{ key: "timestamp_style" },
|
|
||||||
{
|
|
||||||
key: "audio_transcription",
|
|
||||||
compareFields: ["enabled", "live_enabled"],
|
|
||||||
},
|
|
||||||
{ key: "birdseye", compareFields: ["enabled", "mode"] },
|
|
||||||
{ key: "face_recognition", compareFields: ["enabled", "min_area"] },
|
|
||||||
{
|
|
||||||
key: "ffmpeg",
|
|
||||||
compareFields: [
|
|
||||||
"path",
|
|
||||||
"global_args",
|
|
||||||
"hwaccel_args",
|
|
||||||
"input_args",
|
|
||||||
"output_args",
|
|
||||||
"retry_interval",
|
|
||||||
"apple_compatibility",
|
|
||||||
"gpu",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "lpr",
|
|
||||||
compareFields: ["enabled", "min_area", "enhancement"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { key, compareFields } of sectionsToCheck) {
|
|
||||||
const globalValue = normalizeConfigValue(get(config, key));
|
const globalValue = normalizeConfigValue(get(config, key));
|
||||||
const cameraValue = normalizeConfigValue(
|
const cameraValue = normalizeConfigValue(
|
||||||
getBaseCameraSectionValue(config, cameraName, key),
|
getBaseCameraSectionValue(config, cameraName, key),
|
||||||
@ -286,3 +289,252 @@ export function useAllCameraOverrides(
|
|||||||
return overriddenSections;
|
return overriddenSections;
|
||||||
}, [config, cameraName]);
|
}, [config, cameraName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FieldDelta {
|
||||||
|
/** Path relative to the section (e.g. "genai.enabled") */
|
||||||
|
fieldPath: string;
|
||||||
|
globalValue: unknown;
|
||||||
|
cameraValue: unknown;
|
||||||
|
/** Profile name when the override originates from a profile; undefined for camera-level overrides */
|
||||||
|
profileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CameraOverrideEntry {
|
||||||
|
camera: string;
|
||||||
|
fieldDeltas: FieldDelta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect leaf-level field differences between a global section value
|
||||||
|
* and a camera section value. When compareFields is provided, only those
|
||||||
|
* paths are compared; otherwise the objects are walked recursively.
|
||||||
|
*/
|
||||||
|
function collectFieldDeltas(
|
||||||
|
globalValue: JsonValue,
|
||||||
|
cameraValue: JsonValue,
|
||||||
|
compareFields?: string[],
|
||||||
|
pathPrefix = "",
|
||||||
|
): FieldDelta[] {
|
||||||
|
if (compareFields) {
|
||||||
|
if (compareFields.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const deltas: FieldDelta[] = [];
|
||||||
|
for (const path of compareFields) {
|
||||||
|
const g = get(globalValue, path);
|
||||||
|
const c = get(cameraValue, path);
|
||||||
|
if (!isEqual(g, c)) {
|
||||||
|
deltas.push({ fieldPath: path, globalValue: g, cameraValue: c });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJsonObject(globalValue) && isJsonObject(cameraValue)) {
|
||||||
|
const deltas: FieldDelta[] = [];
|
||||||
|
const keys = new Set([
|
||||||
|
...Object.keys(globalValue),
|
||||||
|
...Object.keys(cameraValue),
|
||||||
|
]);
|
||||||
|
for (const key of keys) {
|
||||||
|
const g = (globalValue as JsonObject)[key];
|
||||||
|
const c = (cameraValue as JsonObject)[key];
|
||||||
|
if (isEqual(g, c)) continue;
|
||||||
|
const childPath = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||||||
|
if (isJsonObject(g) && isJsonObject(c)) {
|
||||||
|
deltas.push(...collectFieldDeltas(g, c, undefined, childPath));
|
||||||
|
} else {
|
||||||
|
deltas.push({ fieldPath: childPath, globalValue: g, cameraValue: c });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEqual(globalValue, cameraValue)) {
|
||||||
|
return [{ fieldPath: pathPrefix, globalValue, cameraValue }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a partial config object and return the dot-paths of every leaf value
|
||||||
|
* (primitive or array) actually defined on it. Used to limit profile-vs-global
|
||||||
|
* diffs to keys the profile actually sets, avoiding false "undefined" deltas
|
||||||
|
* for fields the profile leaves unspecified.
|
||||||
|
*/
|
||||||
|
function collectDefinedLeafPaths(value: JsonValue, prefix = ""): string[] {
|
||||||
|
if (!isJsonObject(value)) {
|
||||||
|
return prefix ? [prefix] : [];
|
||||||
|
}
|
||||||
|
const paths: string[] = [];
|
||||||
|
for (const [key, val] of Object.entries(value as JsonObject)) {
|
||||||
|
const childPath = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (isJsonObject(val)) {
|
||||||
|
paths.push(...collectDefinedLeafPaths(val as JsonValue, childPath));
|
||||||
|
} else {
|
||||||
|
paths.push(childPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathAllowed(path: string, compareFields?: string[]): boolean {
|
||||||
|
if (!compareFields) return true;
|
||||||
|
return compareFields.some(
|
||||||
|
(allowed) => path === allowed || path.startsWith(`${allowed}.`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some Frigate sections (notably `motion`) are dumped by the backend with
|
||||||
|
* `exclude_unset=True`, so when the user hasn't explicitly written the section
|
||||||
|
* in their global YAML the API returns null even though every camera still
|
||||||
|
* gets defaults applied at runtime. To still detect cross-camera differences
|
||||||
|
* in those sections we synthesize a baseline by taking the modal (most common)
|
||||||
|
* value at each leaf path across cameras — cameras whose value diverges from
|
||||||
|
* the modal are treated as overriding.
|
||||||
|
*/
|
||||||
|
function deriveSyntheticGlobalValue(
|
||||||
|
cameraSectionValues: JsonValue[],
|
||||||
|
compareFields?: string[],
|
||||||
|
): JsonObject {
|
||||||
|
const cameras = cameraSectionValues.filter(isJsonObject) as JsonObject[];
|
||||||
|
if (cameras.length === 0) return {};
|
||||||
|
|
||||||
|
const allPaths = new Set<string>();
|
||||||
|
for (const cam of cameras) {
|
||||||
|
for (const path of collectDefinedLeafPaths(cam as JsonValue)) {
|
||||||
|
if (!isPathAllowed(path, compareFields)) continue;
|
||||||
|
allPaths.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseline: JsonObject = {};
|
||||||
|
for (const path of allPaths) {
|
||||||
|
const counts = new Map<string, { value: unknown; count: number }>();
|
||||||
|
for (const cam of cameras) {
|
||||||
|
const v = get(cam, path);
|
||||||
|
const key = JSON.stringify(v ?? null);
|
||||||
|
const existing = counts.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
} else {
|
||||||
|
counts.set(key, { value: v, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let modal: { value: unknown; count: number } | undefined;
|
||||||
|
for (const entry of counts.values()) {
|
||||||
|
if (!modal || entry.count > modal.count) modal = entry;
|
||||||
|
}
|
||||||
|
if (modal) {
|
||||||
|
set(baseline, path, modal.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paths that are intentionally hidden from the cross-camera override summary
|
||||||
|
* because they're inherently per-camera (mask polygons, zone definitions) and
|
||||||
|
* would otherwise dominate the popover with noise. Excludes any path where
|
||||||
|
* `mask` appears as a path segment, so nested keys under a mask dict (e.g.
|
||||||
|
* `mask.global_object_mask_1.coordinates`) are also filtered.
|
||||||
|
*/
|
||||||
|
function isCrossCameraIgnoredPath(path: string): boolean {
|
||||||
|
if (!path) return false;
|
||||||
|
return path.split(".").includes("mask");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to find every camera that overrides a given global section. Returns
|
||||||
|
* one entry per overriding camera with the specific field-level deltas.
|
||||||
|
* Considers both the camera's own (pre-profile) section value and any of its
|
||||||
|
* defined profiles, so a field overridden only inside a profile still surfaces.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const entries = useCamerasOverridingSection(config, "review");
|
||||||
|
* // [{ camera: "front_door", fieldDeltas: [{ fieldPath: "genai.enabled", ... }] }]
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useCamerasOverridingSection(
|
||||||
|
config: FrigateConfig | undefined,
|
||||||
|
sectionPath: string,
|
||||||
|
): CameraOverrideEntry[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!config?.cameras || !sectionPath) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath);
|
||||||
|
const compareFields = sectionMeta?.compareFields;
|
||||||
|
|
||||||
|
const cameraNames = Object.keys(config.cameras);
|
||||||
|
const cameraSectionValues = cameraNames.map((name) =>
|
||||||
|
normalizeConfigValue(
|
||||||
|
getBaseCameraSectionValue(config, name, sectionPath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawGlobalValue = get(config, sectionPath);
|
||||||
|
const globalValue: JsonValue =
|
||||||
|
rawGlobalValue == null
|
||||||
|
? deriveSyntheticGlobalValue(cameraSectionValues, compareFields)
|
||||||
|
: normalizeConfigValue(rawGlobalValue);
|
||||||
|
|
||||||
|
const entries: CameraOverrideEntry[] = [];
|
||||||
|
for (let idx = 0; idx < cameraNames.length; idx += 1) {
|
||||||
|
const cameraName = cameraNames[idx];
|
||||||
|
const cameraConfig = config.cameras[cameraName];
|
||||||
|
const deltasByPath = new Map<string, FieldDelta>();
|
||||||
|
|
||||||
|
// 1. Camera-level overrides (uses base_config when a profile is active)
|
||||||
|
const cameraValue = cameraSectionValues[idx];
|
||||||
|
for (const delta of collectFieldDeltas(
|
||||||
|
globalValue,
|
||||||
|
cameraValue,
|
||||||
|
compareFields,
|
||||||
|
)) {
|
||||||
|
if (isCrossCameraIgnoredPath(delta.fieldPath)) continue;
|
||||||
|
deltasByPath.set(delta.fieldPath, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Profile-level overrides — diff only the paths each profile actually
|
||||||
|
// defines, so unspecified-in-profile fields don't register as deltas.
|
||||||
|
const profiles = cameraConfig?.profiles ?? {};
|
||||||
|
for (const profileName of Object.keys(profiles)) {
|
||||||
|
const profileSection = (
|
||||||
|
profiles[profileName] as Record<string, unknown> | undefined
|
||||||
|
)?.[sectionPath];
|
||||||
|
if (profileSection === undefined) continue;
|
||||||
|
const normalizedProfile = normalizeConfigValue(
|
||||||
|
profileSection as JsonValue,
|
||||||
|
);
|
||||||
|
for (const path of collectDefinedLeafPaths(normalizedProfile)) {
|
||||||
|
if (deltasByPath.has(path)) continue;
|
||||||
|
if (isCrossCameraIgnoredPath(path)) continue;
|
||||||
|
if (!isPathAllowed(path, compareFields)) continue;
|
||||||
|
const g = get(globalValue, path);
|
||||||
|
const p = get(normalizedProfile, path);
|
||||||
|
if (!isEqual(g, p)) {
|
||||||
|
deltasByPath.set(path, {
|
||||||
|
fieldPath: path,
|
||||||
|
globalValue: g,
|
||||||
|
cameraValue: p,
|
||||||
|
profileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltasByPath.size > 0) {
|
||||||
|
entries.push({
|
||||||
|
camera: cameraName,
|
||||||
|
fieldDeltas: Array.from(deltasByPath.values()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}, [config, sectionPath]);
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
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 type { PolygonType } from "@/types/canvas";
|
import type { PolygonType } from "@/types/canvas";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
@ -167,6 +168,9 @@ export function SingleSectionPage({
|
|||||||
</div>
|
</div>
|
||||||
{/* Desktop: badge inline next to title */}
|
{/* Desktop: badge inline next to title */}
|
||||||
<div className="hidden shrink-0 sm:flex sm:flex-wrap sm:items-center sm:gap-2">
|
<div className="hidden shrink-0 sm:flex sm:flex-wrap sm:items-center sm:gap-2">
|
||||||
|
{level === "global" && showOverrideIndicator && (
|
||||||
|
<CameraOverridesBadge sectionPath={sectionKey} />
|
||||||
|
)}
|
||||||
{level === "camera" &&
|
{level === "camera" &&
|
||||||
showOverrideIndicator &&
|
showOverrideIndicator &&
|
||||||
sectionStatus.isOverridden && (
|
sectionStatus.isOverridden && (
|
||||||
@ -224,6 +228,9 @@ export function SingleSectionPage({
|
|||||||
</div>
|
</div>
|
||||||
{/* Mobile: badge below title/description */}
|
{/* Mobile: badge below title/description */}
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:hidden">
|
<div className="flex flex-wrap items-center gap-2 sm:hidden">
|
||||||
|
{level === "global" && showOverrideIndicator && (
|
||||||
|
<CameraOverridesBadge sectionPath={sectionKey} />
|
||||||
|
)}
|
||||||
{level === "camera" &&
|
{level === "camera" &&
|
||||||
showOverrideIndicator &&
|
showOverrideIndicator &&
|
||||||
sectionStatus.isOverridden && (
|
sectionStatus.isOverridden && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user