mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-14 16:01:13 +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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_launch_arg(args: list[str], flag: str) -> str | None:
|
||||||
|
"""Return the value following `flag` in a positional argv list, or None."""
|
||||||
|
try:
|
||||||
|
idx = args.index(flag)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if idx + 1 >= len(args):
|
||||||
|
return None
|
||||||
|
return args[idx + 1]
|
||||||
|
|
||||||
|
|
||||||
def _to_jpeg(img_bytes: bytes) -> bytes | None:
|
def _to_jpeg(img_bytes: bytes) -> bytes | None:
|
||||||
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
|
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
|
||||||
try:
|
try:
|
||||||
@ -71,26 +82,69 @@ class LlamaCppClient(GenAIClient):
|
|||||||
base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url
|
base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url
|
||||||
|
|
||||||
configured_model = self.genai_config.model
|
configured_model = self.genai_config.model
|
||||||
|
info = self._get_model_info(base_url, configured_model)
|
||||||
|
|
||||||
# Query /v1/models to validate the configured model exists
|
if info is None:
|
||||||
|
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:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(f"{base_url}/v1/models", timeout=10)
|
||||||
f"{base_url}/v1/models",
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
models_data = response.json()
|
models_data = response.json()
|
||||||
|
|
||||||
model_found = False
|
|
||||||
for model in models_data.get("data", []):
|
for model in models_data.get("data", []):
|
||||||
model_ids = {model.get("id")}
|
model_ids = {model.get("id")}
|
||||||
for alias in model.get("aliases", []):
|
for alias in model.get("aliases", []):
|
||||||
model_ids.add(alias)
|
model_ids.add(alias)
|
||||||
if configured_model in model_ids:
|
if configured_model in model_ids:
|
||||||
model_found = True
|
model_entry = model
|
||||||
break
|
break
|
||||||
|
|
||||||
if not model_found:
|
if model_entry is None:
|
||||||
available = []
|
available = []
|
||||||
for m in models_data.get("data", []):
|
for m in models_data.get("data", []):
|
||||||
available.append(m.get("id", "unknown"))
|
available.append(m.get("id", "unknown"))
|
||||||
@ -109,10 +163,35 @@ class LlamaCppClient(GenAIClient):
|
|||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Query /props for context size, modalities, and tool support.
|
if model_entry is not None:
|
||||||
# The standard /props?model=<name> endpoint works with llama-server.
|
architecture = model_entry.get("architecture") or {}
|
||||||
# If it fails, try the llama-swap per-model passthrough endpoint which
|
input_modalities = architecture.get("input_modalities") or []
|
||||||
# returns props for a specific model without requiring it to be loaded.
|
|
||||||
|
if isinstance(input_modalities, list):
|
||||||
|
info["supports_vision"] = "image" in input_modalities
|
||||||
|
info["supports_audio"] = "audio" in input_modalities
|
||||||
|
|
||||||
|
status = model_entry.get("status") or {}
|
||||||
|
launch_args = status.get("args") if isinstance(status, dict) else None
|
||||||
|
if not isinstance(launch_args, list):
|
||||||
|
launch_args = []
|
||||||
|
|
||||||
|
meta = model_entry.get("meta") if isinstance(model_entry, dict) else None
|
||||||
|
n_ctx = meta.get("n_ctx") if isinstance(meta, dict) else None
|
||||||
|
|
||||||
|
if not n_ctx:
|
||||||
|
n_ctx = _parse_launch_arg(launch_args, "--ctx-size")
|
||||||
|
|
||||||
|
if n_ctx:
|
||||||
|
try:
|
||||||
|
info["context_size"] = int(n_ctx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Tool calling on llama-server requires --jinja.
|
||||||
|
if "--jinja" in launch_args:
|
||||||
|
info["supports_tools"] = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
@ -130,44 +209,32 @@ class LlamaCppClient(GenAIClient):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
props = response.json()
|
props = response.json()
|
||||||
|
|
||||||
# Context size from server runtime config
|
if info["context_size"] is None:
|
||||||
default_settings = props.get("default_generation_settings", {})
|
default_settings = props.get("default_generation_settings", {})
|
||||||
n_ctx = default_settings.get("n_ctx")
|
n_ctx = default_settings.get("n_ctx")
|
||||||
if n_ctx:
|
if n_ctx:
|
||||||
self._context_size = int(n_ctx)
|
info["context_size"] = int(n_ctx)
|
||||||
|
|
||||||
# Modalities (vision, audio)
|
if not (info["supports_vision"] or info["supports_audio"]):
|
||||||
modalities = props.get("modalities", {})
|
modalities = props.get("modalities", {})
|
||||||
self._supports_vision = modalities.get("vision", False)
|
info["supports_vision"] = bool(modalities.get("vision", False))
|
||||||
self._supports_audio = modalities.get("audio", False)
|
info["supports_audio"] = bool(modalities.get("audio", False))
|
||||||
|
|
||||||
# Tool support from chat template capabilities
|
if not info["supports_tools"]:
|
||||||
chat_caps = props.get("chat_template_caps", {})
|
chat_caps = props.get("chat_template_caps", {})
|
||||||
self._supports_tools = chat_caps.get("supports_tools", False)
|
info["supports_tools"] = bool(chat_caps.get("supports_tools", False))
|
||||||
|
|
||||||
# Media marker for multimodal embeddings; the server randomizes this
|
|
||||||
# per startup unless LLAMA_MEDIA_MARKER is set, so we must read it
|
|
||||||
# from /props rather than hardcoding "<__media__>".
|
|
||||||
media_marker = props.get("media_marker")
|
media_marker = props.get("media_marker")
|
||||||
if isinstance(media_marker, str) and media_marker:
|
if isinstance(media_marker, str) and media_marker:
|
||||||
self._media_marker = media_marker
|
info["media_marker"] = media_marker
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
|
|
||||||
configured_model,
|
|
||||||
self._context_size or "unknown",
|
|
||||||
self._supports_vision,
|
|
||||||
self._supports_audio,
|
|
||||||
self._supports_tools,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to query llama.cpp /props endpoint: %s. "
|
"Failed to query llama.cpp /props endpoint: %s. "
|
||||||
"Using defaults for context size and capabilities.",
|
"Image embeddings may fail if the server randomized its media marker.",
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
|
|
||||||
return base_url
|
return info
|
||||||
|
|
||||||
def _send(
|
def _send(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@ -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