mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-04 11:01:14 +03:00
Compare commits
5 Commits
19ec6fa245
...
78fc472026
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78fc472026 | ||
|
|
c8cfb9400a | ||
|
|
ca75f06456 | ||
|
|
bd1fc1cc72 | ||
|
|
e20fc521b1 |
@ -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"}),
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
109
frigate/stats/intel_gpu_info.py
Normal file
109
frigate/stats/intel_gpu_info.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -125,5 +125,5 @@
|
||||
"baby": "Baby",
|
||||
"baby_stroller": "Baby Stroller",
|
||||
"rickshaw": "Rickshaw",
|
||||
"Rodent": "Rodent"
|
||||
}
|
||||
"rodent": "Rodent"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
|
||||
@ -17,10 +17,13 @@ import {
|
||||
} from "@/hooks/use-config-override";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { ProfilesApiResponse } from "@/types/profile";
|
||||
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { formatList } from "@/utils/stringUtil";
|
||||
import { getEffectiveHiddenFields } from "@/utils/configUtil";
|
||||
import {
|
||||
getEffectiveHiddenFields,
|
||||
pathMatchesHiddenPattern,
|
||||
} from "@/utils/configUtil";
|
||||
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
|
||||
|
||||
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
|
||||
detect: "cameraDetect",
|
||||
@ -72,26 +75,6 @@ const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
|
||||
"model",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Match a delta path against a hidden-field pattern. Supports literal prefixes
|
||||
* (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards
|
||||
* matching exactly one path segment (e.g. "filters.*.mask").
|
||||
*/
|
||||
function pathMatchesHiddenPattern(path: string, pattern: string): boolean {
|
||||
if (!pattern) return false;
|
||||
if (!pattern.includes("*")) {
|
||||
return path === pattern || path.startsWith(`${pattern}.`);
|
||||
}
|
||||
const patternSegments = pattern.split(".");
|
||||
const pathSegments = path.split(".");
|
||||
if (pathSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i += 1) {
|
||||
if (patternSegments[i] === "*") continue;
|
||||
if (patternSegments[i] !== pathSegments[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
type CameraEntryProps = {
|
||||
sectionPath: string;
|
||||
entry: CameraOverrideEntry;
|
||||
@ -127,11 +110,8 @@ function groupDeltasBySource(deltas: FieldDelta[]): SourceGroup[] {
|
||||
}
|
||||
|
||||
function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
||||
const { t, i18n } = useTranslation([
|
||||
"config/global",
|
||||
"views/settings",
|
||||
"objects",
|
||||
]);
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const fieldLabel = useOverrideFieldLabel(sectionPath);
|
||||
const friendlyName = useCameraFriendlyName(entry.camera);
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
|
||||
@ -141,49 +121,6 @@ function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
||||
return map;
|
||||
}, [profilesData]);
|
||||
|
||||
const fieldLabel = (fieldPath: string) => {
|
||||
if (!fieldPath) {
|
||||
const sectionKey = `${sectionPath}.label`;
|
||||
return i18n.exists(sectionKey, { ns: "config/global" })
|
||||
? t(sectionKey, { ns: "config/global" })
|
||||
: humanizeKey(sectionPath);
|
||||
}
|
||||
|
||||
const segments = fieldPath.split(".");
|
||||
|
||||
// Most specific: try the full nested path
|
||||
const fullKey = `${sectionPath}.${fieldPath}.label`;
|
||||
if (i18n.exists(fullKey, { ns: "config/global" })) {
|
||||
return t(fullKey, { ns: "config/global" });
|
||||
}
|
||||
|
||||
// Try dropping each intermediate segment in turn — those are typically
|
||||
// user-defined dict keys (object class names, zone names, etc.) that
|
||||
// don't have their own label entries. Prepend the dropped segment as
|
||||
// context to disambiguate (e.g. "Person · Minimum object area").
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join(
|
||||
".",
|
||||
);
|
||||
if (!reduced) continue;
|
||||
const reducedKey = `${sectionPath}.${reduced}.label`;
|
||||
if (i18n.exists(reducedKey, { ns: "config/global" })) {
|
||||
const resolvedLabel = t(reducedKey, { ns: "config/global" });
|
||||
const dropped = segments[i];
|
||||
// Object class names ("person", "car", "fox") have translations in
|
||||
// the `objects` namespace; fall back to humanizing the raw key for
|
||||
// anything that isn't a known label.
|
||||
const droppedLabel = i18n.exists(dropped, { ns: "objects" })
|
||||
? t(dropped, { ns: "objects" })
|
||||
: humanizeKey(dropped);
|
||||
return `${droppedLabel} · ${resolvedLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: humanize the leaf segment
|
||||
return humanizeKey(segments[segments.length - 1]);
|
||||
};
|
||||
|
||||
const formatDeltas = (deltas: FieldDelta[]) => {
|
||||
const visibleLabels = deltas
|
||||
.slice(0, MAX_FIELDS_PER_CAMERA)
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useCameraSectionDeltas } from "@/hooks/use-config-override";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { OverrideDeltaPopover } from "./OverrideDeltaPopover";
|
||||
|
||||
type Props = {
|
||||
sectionPath: string;
|
||||
cameraName: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function GlobalOverridesBadge({
|
||||
sectionPath,
|
||||
cameraName,
|
||||
className,
|
||||
}: Props) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const deltas = useCameraSectionDeltas(config, cameraName, sectionPath);
|
||||
|
||||
return (
|
||||
<OverrideDeltaPopover
|
||||
sectionPath={sectionPath}
|
||||
deltas={deltas}
|
||||
badgeLabel={t("button.overriddenGlobal", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Global)",
|
||||
})}
|
||||
ariaLabel={t("button.overriddenGlobalTooltip", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
heading={t("button.overriddenGlobalHeading", {
|
||||
ns: "views/settings",
|
||||
count: deltas.length,
|
||||
})}
|
||||
noDeltasMessage={t("button.overriddenGlobalNoDeltas", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import { LuChevronDown } from "react-icons/lu";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type { FieldDelta } from "@/hooks/use-config-override";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
|
||||
|
||||
type Props = {
|
||||
sectionPath: string;
|
||||
deltas: FieldDelta[];
|
||||
/** Translated label shown inside the badge */
|
||||
badgeLabel: string;
|
||||
/** Accessible label for the badge trigger */
|
||||
ariaLabel: string;
|
||||
/** Heading rendered at the top of the popover content */
|
||||
heading: string;
|
||||
/** Message shown when there are zero field deltas */
|
||||
noDeltasMessage: string;
|
||||
/** Border color class for the badge (defaults to selected) */
|
||||
borderColorClass?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared popover layout for "this scope overrides these fields" badges
|
||||
* (e.g. profile overrides base config, camera overrides global config).
|
||||
*/
|
||||
export function OverrideDeltaPopover({
|
||||
sectionPath,
|
||||
deltas,
|
||||
badgeLabel,
|
||||
ariaLabel,
|
||||
heading,
|
||||
noDeltasMessage,
|
||||
borderColorClass,
|
||||
className,
|
||||
}: Props) {
|
||||
const fieldLabel = useOverrideFieldLabel(sectionPath);
|
||||
const count = deltas.length;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-pointer border-2 text-center text-xs text-primary-variant",
|
||||
borderColorClass ?? "border-selected",
|
||||
className,
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span>{badgeLabel}</span>
|
||||
<LuChevronDown className="ml-1 size-3" />
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 max-w-[90vw] pr-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="pr-4 text-xs text-primary-variant">
|
||||
{count > 0 ? heading : noDeltasMessage}
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<ul className="scrollbar-container ml-5 flex max-h-[40dvh] list-disc flex-col gap-1 overflow-y-auto pr-4 text-xs">
|
||||
{deltas.map((delta) => (
|
||||
<li key={delta.fieldPath}>{fieldLabel(delta.fieldPath)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useProfileSectionDeltas } from "@/hooks/use-config-override";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { OverrideDeltaPopover } from "./OverrideDeltaPopover";
|
||||
|
||||
type Props = {
|
||||
sectionPath: string;
|
||||
cameraName: string;
|
||||
profileName: string;
|
||||
profileFriendlyName?: string;
|
||||
/** Border color class for profile-themed badge (e.g., "border-amber-500") */
|
||||
profileBorderColor?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ProfileOverridesBadge({
|
||||
sectionPath,
|
||||
cameraName,
|
||||
profileName,
|
||||
profileFriendlyName,
|
||||
profileBorderColor,
|
||||
className,
|
||||
}: Props) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const deltas = useProfileSectionDeltas(
|
||||
config,
|
||||
cameraName,
|
||||
profileName,
|
||||
sectionPath,
|
||||
);
|
||||
|
||||
const displayProfile = profileFriendlyName ?? profileName;
|
||||
|
||||
return (
|
||||
<OverrideDeltaPopover
|
||||
sectionPath={sectionPath}
|
||||
deltas={deltas}
|
||||
badgeLabel={t("button.overriddenBaseConfig", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Base Config)",
|
||||
})}
|
||||
ariaLabel={t("button.overriddenBaseConfigTooltip", {
|
||||
ns: "views/settings",
|
||||
profile: displayProfile,
|
||||
})}
|
||||
heading={t("button.overriddenBaseConfigHeading", {
|
||||
ns: "views/settings",
|
||||
profile: displayProfile,
|
||||
count: deltas.length,
|
||||
})}
|
||||
noDeltasMessage={t("button.overriddenBaseConfigNoDeltas", {
|
||||
ns: "views/settings",
|
||||
profile: displayProfile,
|
||||
})}
|
||||
borderColorClass={profileBorderColor}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
|
||||
|
||||
/**
|
||||
* Resolve a translated label for a config field path within a section, falling
|
||||
* back through reduced paths (dropping each intermediate segment in turn) so
|
||||
* dict-keyed paths like `filters.person.threshold` still surface a meaningful
|
||||
* label. Dropped segments are prepended as context (e.g. "Person · Threshold").
|
||||
*
|
||||
* Shared between override badges that need to render field labels (e.g.
|
||||
* CameraOverridesBadge, ProfileOverridesBadge).
|
||||
*/
|
||||
export function useOverrideFieldLabel(sectionPath: string) {
|
||||
const { t, i18n } = useTranslation([
|
||||
"config/global",
|
||||
"views/settings",
|
||||
"objects",
|
||||
]);
|
||||
|
||||
return (fieldPath: string): string => {
|
||||
if (!fieldPath) {
|
||||
const sectionKey = `${sectionPath}.label`;
|
||||
return i18n.exists(sectionKey, { ns: "config/global" })
|
||||
? t(sectionKey, { ns: "config/global" })
|
||||
: humanizeKey(sectionPath);
|
||||
}
|
||||
|
||||
const segments = fieldPath.split(".");
|
||||
|
||||
const fullKey = `${sectionPath}.${fieldPath}.label`;
|
||||
if (i18n.exists(fullKey, { ns: "config/global" })) {
|
||||
return t(fullKey, { ns: "config/global" });
|
||||
}
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join(
|
||||
".",
|
||||
);
|
||||
if (!reduced) continue;
|
||||
const reducedKey = `${sectionPath}.${reduced}.label`;
|
||||
if (i18n.exists(reducedKey, { ns: "config/global" })) {
|
||||
const resolvedLabel = t(reducedKey, { ns: "config/global" });
|
||||
const dropped = segments[i];
|
||||
const droppedLabel = i18n.exists(dropped, { ns: "objects" })
|
||||
? t(dropped, { ns: "objects" })
|
||||
: humanizeKey(dropped);
|
||||
return `${droppedLabel} · ${resolvedLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
return humanizeKey(segments[segments.length - 1]);
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -44,4 +44,5 @@ export type ConfigFormContext = {
|
||||
requiresRestart?: boolean;
|
||||
t?: (key: string, options?: Record<string, unknown>) => string;
|
||||
renderers?: Record<string, RendererComponent>;
|
||||
isProfile?: boolean;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user