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