Compare commits

...

5 Commits

Author SHA1 Message Date
Nicolas Mowen
78fc472026
Improve Intel Stats (#23190)
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
* Implement per intel-gpu stats collection

* Improve device naming

* Improve GPU vendor handling

* Cleanup
2026-05-13 15:12:48 -06:00
Aaron Daubman
c8cfb9400a
Fix multi-GPU OpenVINO detection for enrichments (#23188)
On multi-GPU systems, OpenVINO enumerates devices as "GPU.0", "GPU.1",
etc. rather than a single "GPU". The exact string match in
is_openvino_gpu_npu_available() fails to recognize these suffixed device
names, causing enrichments (face recognition, semantic search) to
silently fall back to CPU-only inference via ONNXModelRunner instead of
using OpenVINOModelRunner on GPU.

Switch from exact match to prefix match so both single-GPU ("GPU") and
multi-GPU ("GPU.0", "GPU.1") device names are correctly detected, along
with any future suffixed variants for NPU and other accelerators.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-13 11:22:55 -06:00
Josh Hawkins
ca75f06456
Miscellaneous fixes (#23186)
* improve scroll handling for non-modal DropdownMenu in classification and face selection dialogs

* clean up

* fix incorrect key capitalization

* fix profile array overrides not replacing base arrays

don't use lodash merge(), it does positional merging and an empty source array doesn't override the destination, and shorter arrays leak destination elements through.

backend is unaffected, so the saved config and actual backend functionality was right

* only show audio debug tab when audio is enabled in config

* move apple_compatibility out of advanced

* remove retry_interval from UI

99% of users should never be changing this

* hide switch in optionalfieldwidget if editing a profile

* add override badges for cameras and profiles

collect shared functions into the config util and separate hooks

* Use new models endpoint info to determine modalities

* clarify language

* fix linter

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-05-13 11:04:11 -05:00
Josh Hawkins
bd1fc1cc72
API access improvements (#23183)
* restrict viewer access to logs, labels, and go2rtc stream list

* filter stats data for non admins

* track creator on vlm watch jobs and scope view/cancel to admin or creator

* add shortcut for admins in /stats
2026-05-13 10:40:29 -05:00
YDKK
e20fc521b1
fix: fix ReviewTimeline ZoomIn/Out tooltip text (#23184) 2026-05-13 10:28:20 -05:00
31 changed files with 1130 additions and 455 deletions

View File

@ -96,11 +96,46 @@ def version():
@router.get("/stats", dependencies=[Depends(allow_any_authenticated())])
def stats(request: Request):
return JSONResponse(content=request.app.stats_emitter.get_latest_stats())
def stats(
request: Request,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
stats_data = request.app.stats_emitter.get_latest_stats()
# Admins see the full snapshot
if request.headers.get("remote-role") == "admin":
return JSONResponse(content=stats_data)
allowed_set = set(allowed_cameras)
# Shallow-copy so we don't mutate the cached stats history entry.
filtered = {**stats_data}
cameras = stats_data.get("cameras")
if cameras is not None:
filtered["cameras"] = {
name: data for name, data in cameras.items() if name in allowed_set
}
bandwidth = stats_data.get("bandwidth_usages")
if bandwidth is not None:
filtered["bandwidth_usages"] = {
name: data for name, data in bandwidth.items() if name in allowed_set
}
# cmdline can leak camera URLs/paths; strip but keep cpu/mem so
# client-side problem heuristics still work.
cpu_usages = stats_data.get("cpu_usages")
if cpu_usages is not None:
filtered["cpu_usages"] = {
pid: {k: v for k, v in usage.items() if k != "cmdline"}
for pid, usage in cpu_usages.items()
}
return JSONResponse(content=filtered)
@router.get("/stats/history", dependencies=[Depends(allow_any_authenticated())])
@router.get("/stats/history", dependencies=[Depends(require_role(["admin"]))])
def stats_history(request: Request, keys: str = None):
if keys:
keys = keys.split(",")
@ -835,7 +870,7 @@ def nvinfo():
@router.get(
"/logs/{service}",
tags=[Tags.logs],
dependencies=[Depends(allow_any_authenticated())],
dependencies=[Depends(require_role(["admin"]))],
)
async def logs(
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
@ -1040,12 +1075,27 @@ def get_media_sync_status(job_id: str):
@router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
def get_labels(camera: str = ""):
def get_labels(
camera: str = "",
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
try:
if camera:
if camera not in allowed_cameras:
return JSONResponse(
content={
"success": False,
"message": f"Access denied to camera '{camera}'",
},
status_code=403,
)
events = Event.select(Event.label).where(Event.camera == camera).distinct()
else:
events = Event.select(Event.label).distinct()
events = (
Event.select(Event.label)
.where(Event.camera << allowed_cameras)
.distinct()
)
except Exception as e:
logger.error(e)
return JSONResponse(
@ -1058,9 +1108,16 @@ def get_labels(camera: str = ""):
@router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())])
def get_sub_labels(split_joined: Optional[int] = None):
def get_sub_labels(
split_joined: Optional[int] = None,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
try:
events = Event.select(Event.sub_label).distinct()
events = (
Event.select(Event.sub_label)
.where(Event.camera << allowed_cameras)
.distinct()
)
except Exception:
return JSONResponse(
content=({"success": False, "message": "Failed to get sub_labels"}),

View File

@ -19,7 +19,9 @@ from zeep.exceptions import Fault, TransportError
from zeep.transports import AsyncTransport
from frigate.api.auth import (
_get_stream_owner_cameras,
allow_any_authenticated,
get_current_user,
require_go2rtc_stream_access,
require_role,
)
@ -31,6 +33,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateTopic,
)
from frigate.config.env import substitute_frigate_vars
from frigate.models import User
from frigate.util.builtin import clean_camera_user_pass
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file
@ -66,7 +69,7 @@ def _is_valid_host(host: str) -> bool:
@router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())])
def go2rtc_streams():
async def go2rtc_streams(request: Request):
r = requests.get("http://127.0.0.1:1984/api/streams")
if not r.ok:
logger.error("Failed to fetch streams from go2rtc")
@ -75,6 +78,24 @@ def go2rtc_streams():
status_code=500,
)
stream_data = r.json()
# Roles with an explicit camera list see only streams owned by an allowed
# camera. Admin and full-access roles (no list / empty list) see all streams.
current_user = await get_current_user(request)
if not isinstance(current_user, JSONResponse):
role = current_user["role"]
roles_dict = request.app.frigate_config.auth.roles
if role != "admin" and roles_dict.get(role):
all_camera_names = set(request.app.frigate_config.cameras.keys())
allowed_cameras = set(
User.get_allowed_cameras(role, roles_dict, all_camera_names)
)
stream_data = {
name: data
for name, data in stream_data.items()
if _get_stream_owner_cameras(request, name) & allowed_cameras
}
for data in stream_data.values():
for producer in data.get("producers") or []:
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
@ -966,7 +987,6 @@ async def onvif_probe(
probe = ffprobe_stream(
request.app.frigate_config.ffmpeg, test_uri, detailed=False
)
print(probe)
ok = probe is not None and getattr(probe, "returncode", 1) == 0
tested_candidates.append(
{

View File

@ -10,7 +10,7 @@ from functools import reduce
from typing import Any, Dict, List, Optional
import cv2
from fastapi import APIRouter, Body, Depends, Request
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
@ -1641,6 +1641,7 @@ async def start_vlm_monitor(
dispatcher=request.app.dispatcher,
labels=body.labels,
zones=body.zones,
username=request.headers.get("remote-user", ""),
)
except RuntimeError as e:
logger.error("Failed to start VLM watch job: %s", e, exc_info=True)
@ -1661,10 +1662,22 @@ async def start_vlm_monitor(
summary="Get current VLM watch job",
description="Returns the current (or most recently completed) VLM watch job.",
)
async def get_vlm_monitor() -> JSONResponse:
async def get_vlm_monitor(request: Request) -> JSONResponse:
job = get_vlm_watch_job()
if job is None:
return JSONResponse(content={"active": False}, status_code=200)
role = request.headers.get("remote-role", "viewer")
username = request.headers.get("remote-user", "")
# Admin and the job's creator always see the job. Other users only see it
# if they have access to the camera being watched; otherwise hide it.
if role != "admin" and username != job.username:
try:
await require_camera_access(job.camera, request=request)
except HTTPException:
return JSONResponse(content={"active": False}, status_code=200)
return JSONResponse(content={"active": True, **job.to_dict()}, status_code=200)
@ -1674,7 +1687,27 @@ async def get_vlm_monitor() -> JSONResponse:
summary="Cancel the current VLM watch job",
description="Cancels the running watch job if one exists.",
)
async def cancel_vlm_monitor() -> JSONResponse:
async def cancel_vlm_monitor(request: Request) -> JSONResponse:
job = get_vlm_watch_job()
if job is None:
return JSONResponse(
content={"success": False, "message": "No active watch job to cancel."},
status_code=404,
)
role = request.headers.get("remote-role", "viewer")
username = request.headers.get("remote-user", "")
# Admin can cancel any job; other users can only cancel jobs they started.
if role != "admin" and username != job.username:
return JSONResponse(
content={
"success": False,
"message": "Not authorized to cancel this watch job.",
},
status_code=403,
)
cancelled = stop_vlm_watch_job()
if not cancelled:
return JSONResponse(

View File

@ -79,7 +79,11 @@ def is_openvino_gpu_npu_available() -> bool:
available_devices = get_openvino_available_devices()
# Check for GPU, NPU, or other acceleration devices (excluding CPU)
acceleration_devices = ["GPU", "MYRIAD", "NPU", "GNA", "HDDL"]
return any(device in available_devices for device in acceleration_devices)
return any(
avail_dev == accel_dev or avail_dev.startswith(accel_dev + ".")
for avail_dev in available_devices
for accel_dev in acceleration_devices
)
class BaseModelRunner(ABC):

View File

@ -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,

View File

@ -45,6 +45,7 @@ class VLMWatchJob(Job):
last_reasoning: str = ""
notification_message: str = ""
iteration_count: int = 0
username: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@ -374,6 +375,7 @@ def start_vlm_watch_job(
dispatcher: Any,
labels: list[str] | None = None,
zones: list[str] | None = None,
username: str = "",
) -> str:
"""Start a new VLM watch job. Returns the job ID.
@ -397,6 +399,7 @@ def start_vlm_watch_job(
max_duration_minutes=max_duration_minutes,
labels=labels or [],
zones=zones or [],
username=username,
)
cancel_ev = threading.Event()
_current_job = job

View File

@ -0,0 +1,109 @@
"""Resolve human-readable names for Intel GPUs via OpenVINO."""
import logging
import re
from typing import Optional
logger = logging.getLogger(__name__)
class IntelGpuNameResolver:
"""Build a pdev -> normalized device name map by enumerating OpenVINO GPUs.
The lookup is performed once on first access and cached for the process
lifetime. OpenVINO exposes DEVICE_PCI_INFO (domain/bus/device/function) and
FULL_DEVICE_NAME for each GPU it can see, which is enough to associate the
name with the pdev string used by DRM fdinfo.
"""
_names: Optional[dict[str, str]] = None
def get_names(self) -> dict[str, str]:
if self._names is not None:
return self._names
names: dict[str, str] = {}
try:
from openvino import Core
except ImportError:
logger.debug("OpenVINO unavailable; cannot resolve Intel GPU names")
self._names = names
return names
try:
core = Core()
devices = core.available_devices
except Exception as exc:
logger.debug(f"OpenVINO Core initialization failed: {exc}")
self._names = names
return names
cpu_name: Optional[str] = None
if "CPU" in devices:
try:
cpu_name = self._strip_trademarks(
core.get_property("CPU", "FULL_DEVICE_NAME")
)
except Exception as exc:
logger.debug(f"Failed to read CPU FULL_DEVICE_NAME: {exc}")
for device in devices:
if not device.startswith("GPU"):
continue
try:
pci = core.get_property(device, "DEVICE_PCI_INFO")
raw_name = core.get_property(device, "FULL_DEVICE_NAME")
device_type = core.get_property(device, "DEVICE_TYPE")
except Exception as exc:
logger.debug(f"Failed to read properties for {device}: {exc}")
continue
pdev = self._format_pdev(pci)
if not pdev:
continue
names[pdev] = self._resolve_name(raw_name, device_type, cpu_name)
self._names = names
return names
@staticmethod
def _format_pdev(pci) -> Optional[str]:
try:
return f"{pci.domain:04x}:{pci.bus:02x}:{pci.device:02x}.{pci.function:x}"
except AttributeError:
return None
@classmethod
def _resolve_name(cls, raw_name: str, device_type, cpu_name: Optional[str]) -> str:
"""Build a display name for a GPU.
Modern integrated Intel GPUs are reported by OpenVINO with a generic
FULL_DEVICE_NAME like "Intel(R) Graphics (iGPU)" that gives no model
information. Since the iGPU is part of the CPU on these platforms, fall
back to the CPU name (which OpenVINO does report specifically) and
suffix it with "iGPU" so it's clear what the entry is.
"""
is_integrated = "INTEGRATED" in str(device_type).upper()
if is_integrated and cpu_name:
short_cpu = re.sub(r"^Intel\s+", "", cpu_name)
return f"{short_cpu} iGPU"
return cls._normalize_name(raw_name)
@classmethod
def _normalize_name(cls, name: str) -> str:
cleaned = cls._strip_trademarks(name)
cleaned = re.sub(r"\s*\((?:i|d)GPU\)\s*$", "", cleaned, flags=re.IGNORECASE)
return " ".join(cleaned.split())
@staticmethod
def _strip_trademarks(name: str) -> str:
cleaned = re.sub(r"\(R\)|\(TM\)", "", name)
return " ".join(cleaned.split())
intel_gpu_name_resolver = IntelGpuNameResolver()

View File

@ -230,6 +230,7 @@ async def set_gpu_stats(
hwaccel_args.append(args)
stats: dict[str, dict] = {}
intel_gpu_collected = False
for args in hwaccel_args:
if args in hwaccel_errors:
@ -242,6 +243,7 @@ async def set_gpu_stats(
if nvidia_usage:
for i in range(len(nvidia_usage)):
stats[nvidia_usage[i]["name"]] = {
"vendor": "nvidia",
"gpu": str(round(float(nvidia_usage[i]["gpu"]), 2)) + "%",
"mem": str(round(float(nvidia_usage[i]["mem"]), 2)) + "%",
"enc": str(round(float(nvidia_usage[i]["enc"]), 2)) + "%",
@ -250,31 +252,34 @@ async def set_gpu_stats(
}
else:
stats["nvidia-gpu"] = {"gpu": "", "mem": ""}
stats["nvidia-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
hwaccel_errors.append(args)
elif "nvmpi" in args or "jetson" in args:
# nvidia Jetson
jetson_usage = get_jetson_stats()
if jetson_usage:
stats["jetson-gpu"] = jetson_usage
stats["jetson-gpu"] = {"vendor": "nvidia", **jetson_usage}
else:
stats["jetson-gpu"] = {"gpu": "", "mem": ""}
stats["jetson-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
hwaccel_errors.append(args)
elif "qsv" in args or ("vaapi" in args and not is_vaapi_amd_driver()):
if not config.telemetry.stats.intel_gpu_stats:
continue
if "intel-gpu" not in stats:
if not intel_gpu_collected:
# intel GPU (QSV or VAAPI both use the same physical GPU)
intel_gpu_collected = True
intel_usage = get_intel_gpu_stats(
config.telemetry.stats.intel_gpu_device
)
if intel_usage is not None:
stats["intel-gpu"] = intel_usage or {"gpu": "", "mem": ""}
if intel_usage:
for entry in intel_usage.values():
name = entry.pop("name")
stats[name] = entry
else:
stats["intel-gpu"] = {"gpu": "", "mem": ""}
stats["intel-gpu"] = {"vendor": "intel", "gpu": "", "mem": ""}
hwaccel_errors.append(args)
elif "vaapi" in args:
if not config.telemetry.stats.amd_gpu_stats:
@ -284,18 +289,18 @@ async def set_gpu_stats(
amd_usage = get_amd_gpu_stats()
if amd_usage:
stats["amd-vaapi"] = amd_usage
stats["amd-vaapi"] = {"vendor": "amd", **amd_usage}
else:
stats["amd-vaapi"] = {"gpu": "", "mem": ""}
stats["amd-vaapi"] = {"vendor": "amd", "gpu": "", "mem": ""}
hwaccel_errors.append(args)
elif "preset-rk" in args:
rga_usage = get_rockchip_gpu_stats()
if rga_usage:
stats["rockchip"] = rga_usage
stats["rockchip"] = {"vendor": "rockchip", **rga_usage}
elif "v4l2m2m" in args or "rpi" in args:
# RPi v4l2m2m is currently not able to get usage stats
stats["rpi-v4l2m2m"] = {"gpu": "", "mem": ""}
stats["rpi-v4l2m2m"] = {"vendor": "rpi", "gpu": "", "mem": ""}
if stats:
all_stats["gpu_usages"] = stats

View File

@ -17,12 +17,14 @@ class TestGpuStats(unittest.TestCase):
amd_stats = get_amd_gpu_stats()
assert amd_stats == {"gpu": "4.17%", "mem": "60.37%"}
@patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services.time.monotonic")
@patch("frigate.util.services._read_intel_drm_fdinfo")
def test_intel_gpu_stats_fdinfo(self, read_fdinfo, monotonic, sleep):
def test_intel_gpu_stats_fdinfo(self, read_fdinfo, monotonic, sleep, get_names):
# 1 second of wall clock between snapshots
monotonic.side_effect = [0.0, 1.0]
get_names.return_value = {"0000:00:02.0": "Intel Graphics"}
# Two i915 clients on the same iGPU. Engine values are cumulative ns.
# Deltas over the 1s window:
@ -79,11 +81,15 @@ class TestGpuStats(unittest.TestCase):
sleep.assert_called_once()
assert intel_stats == {
"gpu": "90.0%",
"mem": "-%",
"compute": "30.0%",
"dec": "60.0%",
"clients": {"100": "80.0%", "200": "10.0%"},
"0000:00:02.0": {
"name": "Intel Graphics",
"vendor": "intel",
"gpu": "90.0%",
"mem": "-%",
"compute": "30.0%",
"dec": "60.0%",
"clients": {"100": "80.0%", "200": "10.0%"},
},
}
@patch("frigate.util.services._read_intel_drm_fdinfo")

View File

@ -393,8 +393,10 @@ def _read_intel_drm_fdinfo(target_pdev: Optional[str]) -> dict:
return snapshot
def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, Any]]:
"""Get stats by reading DRM fdinfo files.
def get_intel_gpu_stats(
intel_gpu_device: Optional[str],
) -> Optional[dict[str, dict[str, Any]]]:
"""Get stats by reading DRM fdinfo files, bucketed per-pdev.
Each DRM client FD exposes monotonic per-engine busy counters via
/proc/<pid>/fdinfo/<fd> (i915 since kernel 5.19, Xe since first release).
@ -402,7 +404,14 @@ def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, A
utilization. Render/3D and Compute are pooled into "compute"; Video and
VideoEnhance into "dec". Overall "gpu" is the sum of those pools (clamped
to 100%).
The return value is keyed by the GPU's drm-pdev string so multiple Intel
GPUs in the same system are reported separately. Each entry carries a
"name" populated from OpenVINO (falling back to the pdev) so callers can
surface a real device name in the UI.
"""
from frigate.stats.intel_gpu_info import intel_gpu_name_resolver
target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device)
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
@ -417,19 +426,21 @@ def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, A
if not snapshot_b or elapsed_ns <= 0:
return None
engine_pct: dict[str, float] = {
"render": 0.0,
"video": 0.0,
"video-enhance": 0.0,
"compute": 0.0,
}
pid_pct: dict[str, float] = {}
def _new_engine_pct() -> dict[str, float]:
return {"render": 0.0, "video": 0.0, "video-enhance": 0.0, "compute": 0.0}
per_pdev_engine_pct: dict[str, dict[str, float]] = {}
per_pdev_pid_pct: dict[str, dict[str, float]] = {}
for key, data_b in snapshot_b.items():
data_a = snapshot_a.get(key)
if not data_a or data_a["driver"] != data_b["driver"]:
continue
pdev = key[0]
engine_pct = per_pdev_engine_pct.setdefault(pdev, _new_engine_pct())
pid_pct = per_pdev_pid_pct.setdefault(pdev, {})
client_total = 0.0
for engine, (busy_b, total_b) in data_b["engines"].items():
if engine not in engine_pct:
@ -452,25 +463,37 @@ def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, A
pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total
for engine in engine_pct:
engine_pct[engine] = min(100.0, engine_pct[engine])
if not per_pdev_engine_pct:
return None
compute_pct = min(100.0, engine_pct["render"] + engine_pct["compute"])
dec_pct = min(100.0, engine_pct["video"] + engine_pct["video-enhance"])
overall_pct = min(100.0, compute_pct + dec_pct)
names = intel_gpu_name_resolver.get_names()
results: dict[str, dict[str, Any]] = {}
results: dict[str, Any] = {
"gpu": f"{round(overall_pct, 2)}%",
"mem": "-%",
"compute": f"{round(compute_pct, 2)}%",
"dec": f"{round(dec_pct, 2)}%",
}
for pdev, engine_pct in per_pdev_engine_pct.items():
for engine in engine_pct:
engine_pct[engine] = min(100.0, engine_pct[engine])
if pid_pct:
results["clients"] = {
pid: f"{round(min(100.0, pct), 2)}%" for pid, pct in pid_pct.items()
compute_pct = min(100.0, engine_pct["render"] + engine_pct["compute"])
dec_pct = min(100.0, engine_pct["video"] + engine_pct["video-enhance"])
overall_pct = min(100.0, compute_pct + dec_pct)
entry: dict[str, Any] = {
"name": names.get(pdev) or f"Intel GPU {pdev}",
"vendor": "intel",
"gpu": f"{round(overall_pct, 2)}%",
"mem": "-%",
"compute": f"{round(compute_pct, 2)}%",
"dec": f"{round(dec_pct, 2)}%",
}
pid_pct = per_pdev_pid_pct.get(pdev)
if pid_pct:
entry["clients"] = {
pid: f"{round(min(100.0, pct), 2)}%" for pid, pct in pid_pct.items()
}
results[pdev] = entry
return results

View File

@ -125,5 +125,5 @@
"baby": "Baby",
"baby_stroller": "Baby Stroller",
"rickshaw": "Rickshaw",
"Rodent": "Rodent"
}
"rodent": "Rodent"
}

View File

@ -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 camera overrides the global config, but no field values differ.",
"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",

View File

@ -156,7 +156,7 @@ export function MessageBubble({
<div
className={cn(
!isComplete &&
"[&>p:last-child]:inline after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-['']",
"after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-[''] [&>p:last-child]:inline",
)}
>
<ReactMarkdown

View File

@ -41,19 +41,12 @@ const ffmpeg: SectionConfigOverrides = {
"input_args",
"hwaccel_args",
"output_args",
"path",
"retry_interval",
"apple_compatibility",
"gpu",
],
hiddenFields: [],
advancedFields: [
"path",
"global_args",
"retry_interval",
"apple_compatibility",
"path",
"gpu",
],
hiddenFields: ["retry_interval"],
advancedFields: ["path", "global_args", "gpu"],
overrideFields: [
"inputs",
"path",
@ -61,7 +54,6 @@ const ffmpeg: SectionConfigOverrides = {
"input_args",
"hwaccel_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
@ -125,19 +117,10 @@ const ffmpeg: SectionConfigOverrides = {
"global_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
advancedFields: [
"global_args",
"input_args",
"output_args",
"path",
"retry_interval",
"apple_compatibility",
"gpu",
],
advancedFields: ["global_args", "input_args", "output_args", "path", "gpu"],
uiSchema: {
path: {
"ui:options": { size: "md" },

View File

@ -27,21 +27,17 @@ 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";
import get from "lodash/get";
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import merge from "lodash/merge";
import {
Collapsible,
CollapsibleContent,
@ -73,6 +69,7 @@ import {
buildConfigDataForPath,
flattenOverrides,
getBaseCameraSectionValue,
mergeProfileOverrides,
resolveHiddenFieldEntries,
sanitizeSectionData as sharedSanitizeSectionData,
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
@ -353,7 +350,10 @@ export function ConfigSection({
`profiles.${profileName}.${sectionPath}`,
);
if (profileOverrides && typeof profileOverrides === "object") {
return merge(cloneDeep(baseValue ?? {}), cloneDeep(profileOverrides));
return mergeProfileOverrides(
(baseValue as object) ?? {},
profileOverrides as object,
);
}
return baseValue;
}
@ -1044,6 +1044,7 @@ export function ConfigSection({
hiddenFields: effectiveHiddenFields,
restartRequired: sectionConfig.restartRequired,
requiresRestart,
isProfile: !!profileName,
}}
/>
@ -1253,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} />
)}
@ -1319,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} />
)}

View File

@ -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)

View File

@ -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}
/>
);
}

View File

@ -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>
);
}

View File

@ -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}
/>
);
}

View File

@ -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]);
};
}

View File

@ -6,6 +6,7 @@ import { getWidget } from "@rjsf/utils";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { getNonNullSchema } from "../fields/nullableUtils";
import type { ConfigFormContext } from "@/types/configForm";
export function OptionalFieldWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options, registry } =
@ -13,6 +14,8 @@ export function OptionalFieldWidget(props: WidgetProps) {
const innerWidgetName = (options.innerWidget as string) || undefined;
const isEnabled = value !== undefined && value !== null;
const formContext = registry?.formContext as ConfigFormContext | undefined;
const isProfile = !!formContext?.isProfile;
// Extract the non-null branch from anyOf [Type, null]
const innerSchema = getNonNullSchema(schema) ?? schema;
@ -42,10 +45,17 @@ export function OptionalFieldWidget(props: WidgetProps) {
const innerProps: WidgetProps = {
...props,
schema: innerSchema,
disabled: disabled || readonly || !isEnabled,
disabled: disabled || readonly || (!isProfile && !isEnabled),
value: isEnabled ? value : getDefaultValue(),
};
// don't show the switch if we're editing in a profile
// to disable in a profile, users should edit the config manually, eg:
// skip_motion_threshold: None
if (isProfile) {
return <InnerWidget {...innerProps} />;
}
return (
<div className="flex items-center gap-3">
<Switch

View File

@ -102,6 +102,19 @@ export default function ClassificationSelectionDialog({
// control
const [newClass, setNewClass] = useState(false);
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
// scroll containers, so attach a non-passive listener that scrolls manually.
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
if (!el || !isDesktop) return;
const handleWheel = (e: WheelEvent) => {
if (el.scrollHeight <= el.clientHeight) return;
e.preventDefault();
el.scrollTop += e.deltaY;
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => el.removeEventListener("wheel", handleWheel);
}, []);
// components
const Selector = isDesktop ? DropdownMenu : Drawer;
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
@ -114,6 +127,8 @@ export default function ClassificationSelectionDialog({
</DrawerClose>
);
// keep modal false on desktop to prevent dismissable layer pointer events
// issue with dialog auto-close
return (
<div className={className ?? "flex"}>
<TextEntryDialog
@ -122,60 +137,60 @@ export default function ClassificationSelectionDialog({
title={t("createCategory.new")}
onSave={(newCat) => onCategorizeImage(newCat)}
/>
<Tooltip>
<Selector {...(isDesktop ? { modal: false } : {})}>
<SelectorTrigger asChild>
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
</SelectorTrigger>
<SelectorContent
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isMobile && (
<DrawerHeader className="sr-only">
<DrawerTitle>Details</DrawerTitle>
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
)}
<DropdownMenuLabel>
{dialogLabel ?? t("categorizeImageAs")}
</DropdownMenuLabel>
<div
className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto",
isMobile && "gap-2 pb-4",
)}
<Selector {...(isDesktop ? { modal: false } : {})}>
<Tooltip>
<TooltipTrigger asChild={isChildButton}>
<SelectorTrigger asChild>{children}</SelectorTrigger>
</TooltipTrigger>
<TooltipContent>
{tooltipLabel ?? t("categorizeImage")}
</TooltipContent>
</Tooltip>
<SelectorContent
ref={scrollContainerRef}
className={cn(
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isMobile && (
<DrawerHeader className="sr-only">
<DrawerTitle>Details</DrawerTitle>
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
)}
<DropdownMenuLabel>
{dialogLabel ?? t("categorizeImageAs")}
</DropdownMenuLabel>
<div className={cn("flex flex-col", isMobile && "gap-2 pb-4")}>
{filteredClasses
.sort((a, b) => {
if (a === "none") return 1;
if (b === "none") return -1;
return a.localeCompare(b);
})
.map((category) => (
<SelectorItem
key={category}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onCategorizeImage(category)}
>
{category === "none"
? t("details.none")
: category.replaceAll("_", " ")}
</SelectorItem>
))}
<Separator />
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewClass(true)}
>
{filteredClasses
.sort((a, b) => {
if (a === "none") return 1;
if (b === "none") return -1;
return a.localeCompare(b);
})
.map((category) => (
<SelectorItem
key={category}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onCategorizeImage(category)}
>
{category === "none"
? t("details.none")
: category.replaceAll("_", " ")}
</SelectorItem>
))}
<Separator />
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewClass(true)}
>
{t("createCategory.new")}
</SelectorItem>
</div>
</SelectorContent>
</Selector>
<TooltipContent>{tooltipLabel ?? t("categorizeImage")}</TooltipContent>
</Tooltip>
{t("createCategory.new")}
</SelectorItem>
</div>
</SelectorContent>
</Selector>
</div>
);
}

View File

@ -23,7 +23,7 @@ import {
import { isDesktop, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import React, { ReactNode, useMemo, useState } from "react";
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import TextEntryDialog from "./dialog/TextEntryDialog";
import { Button } from "../ui/button";
@ -61,6 +61,19 @@ export default function FaceSelectionDialog({
// control
const [newFace, setNewFace] = useState(false);
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
// scroll containers, so attach a non-passive listener that scrolls manually.
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
if (!el || !isDesktop) return;
const handleWheel = (e: WheelEvent) => {
if (el.scrollHeight <= el.clientHeight) return;
e.preventDefault();
el.scrollTop += e.deltaY;
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => el.removeEventListener("wheel", handleWheel);
}, []);
// components
const Selector = isDesktop ? DropdownMenu : Drawer;
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
@ -73,6 +86,8 @@ export default function FaceSelectionDialog({
</DrawerClose>
);
// keep modal false on desktop to prevent dismissable layer pointer events
// issue with dialog auto-close
return (
<div className={className ?? "flex"}>
{newFace && (
@ -83,52 +98,56 @@ export default function FaceSelectionDialog({
onSave={(newName) => onTrainAttempt(newName)}
/>
)}
<Tooltip>
<Selector {...(isDesktop ? { modal: false } : {})}>
<SelectorTrigger asChild>
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
</SelectorTrigger>
<SelectorContent
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isMobile && (
<DrawerHeader className="sr-only">
<DrawerTitle>Details</DrawerTitle>
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
<Selector {...(isDesktop ? { modal: false } : {})}>
<Tooltip>
<TooltipTrigger asChild={isChildButton}>
<SelectorTrigger asChild>{children}</SelectorTrigger>
</TooltipTrigger>
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
</Tooltip>
<SelectorContent
ref={scrollContainerRef}
className={cn(
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isMobile && (
<DrawerHeader className="sr-only">
<DrawerTitle>Details</DrawerTitle>
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
)}
<DropdownMenuLabel>
{dialogLabel ?? t("trainFaceAs")}
</DropdownMenuLabel>
<div
className={cn(
"flex flex-col",
isMobile &&
"max-h-[40dvh] gap-2 overflow-y-auto overflow-x-hidden pb-4",
)}
<DropdownMenuLabel>
{dialogLabel ?? t("trainFaceAs")}
</DropdownMenuLabel>
<div
className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
isMobile && "gap-2 pb-4",
)}
>
{filteredNames.sort().map((faceName) => (
<SelectorItem
key={faceName}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onTrainAttempt(faceName)}
>
{faceName}
</SelectorItem>
))}
<DropdownMenuSeparator />
>
{filteredNames.sort().map((faceName) => (
<SelectorItem
key={faceName}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewFace(true)}
onClick={() => onTrainAttempt(faceName)}
>
{t("createFaceLibrary.new")}
{faceName}
</SelectorItem>
</div>
</SelectorContent>
</Selector>
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
</Tooltip>
))}
<DropdownMenuSeparator />
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewFace(true)}
>
{t("createFaceLibrary.new")}
</SelectorItem>
</div>
</SelectorContent>
</Selector>
</div>
);
}

View File

@ -539,7 +539,7 @@ export function ReviewTimeline({
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("zoomIn")}</TooltipContent>
<TooltipContent>{t("zoomOut")}</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
@ -562,7 +562,7 @@ export function ReviewTimeline({
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("zoomOut")}</TooltipContent>
<TooltipContent>{t("zoomIn")}</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>

View File

@ -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]);
}

View File

@ -44,4 +44,5 @@ export type ConfigFormContext = {
requiresRestart?: boolean;
t?: (key: string, options?: Record<string, unknown>) => string;
renderers?: Record<string, RendererComponent>;
isProfile?: boolean;
};

View File

@ -62,7 +62,10 @@ export type ExtraProcessStats = {
mem?: string;
};
export type GpuVendor = "intel" | "amd" | "nvidia" | "rockchip" | "rpi";
export type GpuStats = {
vendor?: GpuVendor;
gpu: string;
mem: string;
enc?: string;

View File

@ -6,7 +6,6 @@
import get from "lodash/get";
import cloneDeep from "lodash/cloneDeep";
import merge from "lodash/merge";
import unset from "lodash/unset";
import isEqual from "lodash/isEqual";
import mergeWith from "lodash/mergeWith";
@ -92,6 +91,32 @@ export function getBaseCameraSectionValue(
return base !== undefined ? base : get(cam, sectionPath);
}
// mergeWith customizer that replaces arrays wholesale instead of merging them
// positionally by index. Used when the source value is meant to fully replace
// the destination (e.g. profile overrides, section config overrides), so an
// empty source array correctly clears the destination array.
const replaceArraysCustomizer = (objValue: unknown, srcValue: unknown) => {
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
return srcValue !== undefined ? srcValue : objValue;
}
return undefined;
};
// Merge profile overrides on top of base config values. Matches the backend's
// deep_merge(overrides, base_data) semantics: arrays are replaced wholesale by
// the profile's value rather than merged positionally, so an empty array in a
// profile clears the base array instead of leaving stale entries behind.
export function mergeProfileOverrides<T extends object>(
baseValue: T,
profileOverrides: object,
): T {
return mergeWith(
cloneDeep(baseValue),
cloneDeep(profileOverrides),
replaceArraysCustomizer,
) as T;
}
/** Sections that can appear inside a camera profile definition. */
export const PROFILE_ELIGIBLE_SECTIONS = new Set([
"audio",
@ -564,9 +589,9 @@ export function prepareSectionSavePayload(opts: {
baseValue &&
typeof baseValue === "object"
) {
rawSectionValue = merge(
cloneDeep(baseValue),
cloneDeep(profileOverrides),
rawSectionValue = mergeProfileOverrides(
baseValue as object,
profileOverrides as object,
);
} else {
rawSectionValue = baseValue;
@ -675,13 +700,12 @@ const mergeSectionConfig = (
overrides: Partial<SectionConfig> | undefined,
): SectionConfig =>
mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => {
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
return srcValue ?? objValue;
}
const arrayResult = replaceArraysCustomizer(objValue, srcValue);
if (arrayResult !== undefined) return arrayResult;
if (key === "uiSchema") {
if (objValue && srcValue) {
return merge({}, objValue, srcValue);
return mergeWith({}, objValue, srcValue, replaceArraysCustomizer);
}
return srcValue ?? objValue;
}
@ -739,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;
}

View File

@ -33,6 +33,7 @@ import { getTranslatedLabel } from "@/utils/i18n";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { AudioLevelGraph } from "@/components/audio/AudioLevelGraph";
import { useWs } from "@/api/ws";
import { cn } from "@/lib/utils";
type ObjectSettingsViewProps = {
selectedCamera?: string;
@ -200,15 +201,18 @@ export default function ObjectSettingsView({
<Tabs defaultValue="debug" className="w-full">
<TabsList
className={`grid w-full ${cameraConfig.ffmpeg.inputs.some((input) => input.roles.includes("audio")) ? "grid-cols-3" : "grid-cols-2"}`}
className={cn(
"grid w-full",
cameraConfig.audio.enabled_in_config
? "grid-cols-3"
: "grid-cols-2",
)}
>
<TabsTrigger value="debug">{t("debug.debugging")}</TabsTrigger>
<TabsTrigger value="objectlist">
{t("debug.objectList")}
</TabsTrigger>
{cameraConfig.ffmpeg.inputs.some((input) =>
input.roles.includes("audio"),
) && (
{cameraConfig.audio.enabled_in_config && (
<TabsTrigger value="audio">{t("debug.audio.title")}</TabsTrigger>
)}
</TabsList>
@ -325,9 +329,7 @@ export default function ObjectSettingsView({
<TabsContent value="objectlist">
<ObjectList cameraConfig={cameraConfig} objects={memoizedObjects} />
</TabsContent>
{cameraConfig.ffmpeg.inputs.some((input) =>
input.roles.includes("audio"),
) && (
{cameraConfig.audio.enabled_in_config && (
<TabsContent value="audio">
<AudioList
cameraConfig={cameraConfig}

View File

@ -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"

View File

@ -1,5 +1,5 @@
import useSWR from "swr";
import { FrigateStats, GpuInfo } from "@/types/stats";
import { FrigateStats, GpuInfo, GpuStats } from "@/types/stats";
import { startTransition, useEffect, useMemo, useState } from "react";
import { useFrigateStats } from "@/api/ws";
import {
@ -98,13 +98,11 @@ export default function GeneralMetrics({
let nvCount = 0;
statsHistory.length > 0 &&
Object.keys(statsHistory[0]?.gpu_usages ?? {}).forEach((key) => {
if (key == "amd-vaapi" || key == "intel-gpu") {
vaCount += 1;
}
if (key.includes("NVIDIA")) {
Object.values(statsHistory[0]?.gpu_usages ?? {}).forEach((stats) => {
if (stats.vendor === "nvidia") {
nvCount += 1;
} else if (stats.vendor === "intel" || stats.vendor === "amd") {
vaCount += 1;
}
});
@ -288,11 +286,15 @@ export default function GeneralMetrics({
return [];
}
// Intel doesn't expose VRAM usage, so hide the memory section
// entirely when every reporting GPU is Intel.
const firstEntries: GpuStats[] = Object.values(
statsHistory[0]?.gpu_usages ?? {},
);
if (
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {}).length == 1 &&
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {})[0] === "intel-gpu"
firstEntries.length > 0 &&
firstEntries.every((s) => s.vendor === "intel")
) {
// intel gpu stats do not support memory
return undefined;
}
@ -307,6 +309,10 @@ export default function GeneralMetrics({
}
Object.entries(stats.gpu_usages || {}).forEach(([key, stats]) => {
if (stats.vendor === "intel") {
return;
}
if (!(key in series)) {
series[key] = { name: key, data: [] };
}
@ -470,8 +476,9 @@ export default function GeneralMetrics({
return false;
}
const gpuKeys = Object.keys(statsHistory[0]?.gpu_usages ?? {});
const hasIntelGpu = gpuKeys.some((key) => key === "intel-gpu");
const hasIntelGpu = Object.values(statsHistory[0]?.gpu_usages ?? {}).some(
(stats) => stats.vendor === "intel",
);
if (!hasIntelGpu) {
return false;
@ -486,14 +493,15 @@ export default function GeneralMetrics({
continue;
}
Object.entries(stats.gpu_usages || {}).forEach(([key, gpuStats]) => {
if (key === "intel-gpu") {
if (gpuStats.gpu) {
hasDataPoints = true;
const gpuValue = parseFloat(gpuStats.gpu.slice(0, -1));
if (!isNaN(gpuValue) && gpuValue > 0) {
allZero = false;
}
Object.values(stats.gpu_usages || {}).forEach((gpuStats) => {
if (gpuStats.vendor !== "intel") {
return;
}
if (gpuStats.gpu) {
hasDataPoints = true;
const gpuValue = parseFloat(gpuStats.gpu.slice(0, -1));
if (!isNaN(gpuValue) && gpuValue > 0) {
allZero = false;
}
}
});