mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-20 02:41: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())])
|
@router.get("/stats", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def stats(request: Request):
|
def stats(
|
||||||
return JSONResponse(content=request.app.stats_emitter.get_latest_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):
|
def stats_history(request: Request, keys: str = None):
|
||||||
if keys:
|
if keys:
|
||||||
keys = keys.split(",")
|
keys = keys.split(",")
|
||||||
@ -835,7 +870,7 @@ def nvinfo():
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/logs/{service}",
|
"/logs/{service}",
|
||||||
tags=[Tags.logs],
|
tags=[Tags.logs],
|
||||||
dependencies=[Depends(allow_any_authenticated())],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
)
|
)
|
||||||
async def logs(
|
async def logs(
|
||||||
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
|
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())])
|
@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:
|
try:
|
||||||
if camera:
|
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()
|
events = Event.select(Event.label).where(Event.camera == camera).distinct()
|
||||||
else:
|
else:
|
||||||
events = Event.select(Event.label).distinct()
|
events = (
|
||||||
|
Event.select(Event.label)
|
||||||
|
.where(Event.camera << allowed_cameras)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -1058,9 +1108,16 @@ def get_labels(camera: str = ""):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())])
|
@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:
|
try:
|
||||||
events = Event.select(Event.sub_label).distinct()
|
events = (
|
||||||
|
Event.select(Event.sub_label)
|
||||||
|
.where(Event.camera << allowed_cameras)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=({"success": False, "message": "Failed to get sub_labels"}),
|
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 zeep.transports import AsyncTransport
|
||||||
|
|
||||||
from frigate.api.auth import (
|
from frigate.api.auth import (
|
||||||
|
_get_stream_owner_cameras,
|
||||||
allow_any_authenticated,
|
allow_any_authenticated,
|
||||||
|
get_current_user,
|
||||||
require_go2rtc_stream_access,
|
require_go2rtc_stream_access,
|
||||||
require_role,
|
require_role,
|
||||||
)
|
)
|
||||||
@ -31,6 +33,7 @@ from frigate.config.camera.updater import (
|
|||||||
CameraConfigUpdateTopic,
|
CameraConfigUpdateTopic,
|
||||||
)
|
)
|
||||||
from frigate.config.env import substitute_frigate_vars
|
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.builtin import clean_camera_user_pass
|
||||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||||
from frigate.util.config import find_config_file
|
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())])
|
@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")
|
r = requests.get("http://127.0.0.1:1984/api/streams")
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
logger.error("Failed to fetch streams from go2rtc")
|
logger.error("Failed to fetch streams from go2rtc")
|
||||||
@ -75,6 +78,24 @@ def go2rtc_streams():
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
stream_data = r.json()
|
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 data in stream_data.values():
|
||||||
for producer in data.get("producers") or []:
|
for producer in data.get("producers") or []:
|
||||||
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
||||||
@ -966,7 +987,6 @@ async def onvif_probe(
|
|||||||
probe = ffprobe_stream(
|
probe = ffprobe_stream(
|
||||||
request.app.frigate_config.ffmpeg, test_uri, detailed=False
|
request.app.frigate_config.ffmpeg, test_uri, detailed=False
|
||||||
)
|
)
|
||||||
print(probe)
|
|
||||||
ok = probe is not None and getattr(probe, "returncode", 1) == 0
|
ok = probe is not None and getattr(probe, "returncode", 1) == 0
|
||||||
tested_candidates.append(
|
tested_candidates.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from functools import reduce
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
from fastapi import APIRouter, Body, Depends, Request
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@ -1641,6 +1641,7 @@ async def start_vlm_monitor(
|
|||||||
dispatcher=request.app.dispatcher,
|
dispatcher=request.app.dispatcher,
|
||||||
labels=body.labels,
|
labels=body.labels,
|
||||||
zones=body.zones,
|
zones=body.zones,
|
||||||
|
username=request.headers.get("remote-user", ""),
|
||||||
)
|
)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
logger.error("Failed to start VLM watch job: %s", e, exc_info=True)
|
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",
|
summary="Get current VLM watch job",
|
||||||
description="Returns the current (or most recently completed) 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()
|
job = get_vlm_watch_job()
|
||||||
if job is None:
|
if job is None:
|
||||||
return JSONResponse(content={"active": False}, status_code=200)
|
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)
|
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",
|
summary="Cancel the current VLM watch job",
|
||||||
description="Cancels the running watch job if one exists.",
|
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()
|
cancelled = stop_vlm_watch_job()
|
||||||
if not cancelled:
|
if not cancelled:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@ -79,7 +79,11 @@ def is_openvino_gpu_npu_available() -> bool:
|
|||||||
available_devices = get_openvino_available_devices()
|
available_devices = get_openvino_available_devices()
|
||||||
# Check for GPU, NPU, or other acceleration devices (excluding CPU)
|
# Check for GPU, NPU, or other acceleration devices (excluding CPU)
|
||||||
acceleration_devices = ["GPU", "MYRIAD", "NPU", "GNA", "HDDL"]
|
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):
|
class BaseModelRunner(ABC):
|
||||||
|
|||||||
@ -18,6 +18,17 @@ from frigate.genai.utils import parse_tool_calls_from_message
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_launch_arg(args: list[str], flag: str) -> str | None:
|
||||||
|
"""Return the value following `flag` in a positional argv list, or None."""
|
||||||
|
try:
|
||||||
|
idx = args.index(flag)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if idx + 1 >= len(args):
|
||||||
|
return None
|
||||||
|
return args[idx + 1]
|
||||||
|
|
||||||
|
|
||||||
def _to_jpeg(img_bytes: bytes) -> bytes | None:
|
def _to_jpeg(img_bytes: bytes) -> bytes | None:
|
||||||
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
|
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
|
||||||
try:
|
try:
|
||||||
@ -71,26 +82,69 @@ class LlamaCppClient(GenAIClient):
|
|||||||
base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url
|
base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url
|
||||||
|
|
||||||
configured_model = self.genai_config.model
|
configured_model = self.genai_config.model
|
||||||
|
info = self._get_model_info(base_url, configured_model)
|
||||||
|
|
||||||
# Query /v1/models to validate the configured model exists
|
if info is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._context_size = info["context_size"]
|
||||||
|
self._supports_vision = info["supports_vision"]
|
||||||
|
self._supports_audio = info["supports_audio"]
|
||||||
|
self._supports_tools = info["supports_tools"]
|
||||||
|
self._media_marker = info["media_marker"]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
|
||||||
|
configured_model,
|
||||||
|
self._context_size or "unknown",
|
||||||
|
self._supports_vision,
|
||||||
|
self._supports_audio,
|
||||||
|
self._supports_tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
return base_url
|
||||||
|
|
||||||
|
def _get_model_info(
|
||||||
|
self, base_url: str, configured_model: str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Resolve model metadata from /v1/models with /props fallback.
|
||||||
|
|
||||||
|
Returns a dict of capability fields, or None if the server's model
|
||||||
|
registry was reachable and reported the configured model as missing.
|
||||||
|
A reachable-but-unparseable /v1/models is treated as soft-pass and
|
||||||
|
falls through to /props, matching prior behavior.
|
||||||
|
|
||||||
|
After ggml-org/llama.cpp#22952, /v1/models exposes per-model
|
||||||
|
`architecture.input_modalities` (text/image/audio) — the primary
|
||||||
|
source. When proxied through llama-swap, the same entry carries
|
||||||
|
`status.args` (server launch argv) and, for the loaded model,
|
||||||
|
`meta.n_ctx`. /props remains the only source for `media_marker`,
|
||||||
|
which the server randomizes per startup unless LLAMA_MEDIA_MARKER
|
||||||
|
is set.
|
||||||
|
"""
|
||||||
|
info: dict[str, Any] = {
|
||||||
|
"context_size": None,
|
||||||
|
"supports_vision": False,
|
||||||
|
"supports_audio": False,
|
||||||
|
"supports_tools": False,
|
||||||
|
"media_marker": "<__media__>",
|
||||||
|
}
|
||||||
|
|
||||||
|
model_entry: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(f"{base_url}/v1/models", timeout=10)
|
||||||
f"{base_url}/v1/models",
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
models_data = response.json()
|
models_data = response.json()
|
||||||
|
|
||||||
model_found = False
|
|
||||||
for model in models_data.get("data", []):
|
for model in models_data.get("data", []):
|
||||||
model_ids = {model.get("id")}
|
model_ids = {model.get("id")}
|
||||||
for alias in model.get("aliases", []):
|
for alias in model.get("aliases", []):
|
||||||
model_ids.add(alias)
|
model_ids.add(alias)
|
||||||
if configured_model in model_ids:
|
if configured_model in model_ids:
|
||||||
model_found = True
|
model_entry = model
|
||||||
break
|
break
|
||||||
|
|
||||||
if not model_found:
|
if model_entry is None:
|
||||||
available = []
|
available = []
|
||||||
for m in models_data.get("data", []):
|
for m in models_data.get("data", []):
|
||||||
available.append(m.get("id", "unknown"))
|
available.append(m.get("id", "unknown"))
|
||||||
@ -109,10 +163,35 @@ class LlamaCppClient(GenAIClient):
|
|||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Query /props for context size, modalities, and tool support.
|
if model_entry is not None:
|
||||||
# The standard /props?model=<name> endpoint works with llama-server.
|
architecture = model_entry.get("architecture") or {}
|
||||||
# If it fails, try the llama-swap per-model passthrough endpoint which
|
input_modalities = architecture.get("input_modalities") or []
|
||||||
# returns props for a specific model without requiring it to be loaded.
|
|
||||||
|
if isinstance(input_modalities, list):
|
||||||
|
info["supports_vision"] = "image" in input_modalities
|
||||||
|
info["supports_audio"] = "audio" in input_modalities
|
||||||
|
|
||||||
|
status = model_entry.get("status") or {}
|
||||||
|
launch_args = status.get("args") if isinstance(status, dict) else None
|
||||||
|
if not isinstance(launch_args, list):
|
||||||
|
launch_args = []
|
||||||
|
|
||||||
|
meta = model_entry.get("meta") if isinstance(model_entry, dict) else None
|
||||||
|
n_ctx = meta.get("n_ctx") if isinstance(meta, dict) else None
|
||||||
|
|
||||||
|
if not n_ctx:
|
||||||
|
n_ctx = _parse_launch_arg(launch_args, "--ctx-size")
|
||||||
|
|
||||||
|
if n_ctx:
|
||||||
|
try:
|
||||||
|
info["context_size"] = int(n_ctx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Tool calling on llama-server requires --jinja.
|
||||||
|
if "--jinja" in launch_args:
|
||||||
|
info["supports_tools"] = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
@ -130,44 +209,32 @@ class LlamaCppClient(GenAIClient):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
props = response.json()
|
props = response.json()
|
||||||
|
|
||||||
# Context size from server runtime config
|
if info["context_size"] is None:
|
||||||
default_settings = props.get("default_generation_settings", {})
|
default_settings = props.get("default_generation_settings", {})
|
||||||
n_ctx = default_settings.get("n_ctx")
|
n_ctx = default_settings.get("n_ctx")
|
||||||
if n_ctx:
|
if n_ctx:
|
||||||
self._context_size = int(n_ctx)
|
info["context_size"] = int(n_ctx)
|
||||||
|
|
||||||
# Modalities (vision, audio)
|
if not (info["supports_vision"] or info["supports_audio"]):
|
||||||
modalities = props.get("modalities", {})
|
modalities = props.get("modalities", {})
|
||||||
self._supports_vision = modalities.get("vision", False)
|
info["supports_vision"] = bool(modalities.get("vision", False))
|
||||||
self._supports_audio = modalities.get("audio", False)
|
info["supports_audio"] = bool(modalities.get("audio", False))
|
||||||
|
|
||||||
# Tool support from chat template capabilities
|
if not info["supports_tools"]:
|
||||||
chat_caps = props.get("chat_template_caps", {})
|
chat_caps = props.get("chat_template_caps", {})
|
||||||
self._supports_tools = chat_caps.get("supports_tools", False)
|
info["supports_tools"] = bool(chat_caps.get("supports_tools", False))
|
||||||
|
|
||||||
# Media marker for multimodal embeddings; the server randomizes this
|
|
||||||
# per startup unless LLAMA_MEDIA_MARKER is set, so we must read it
|
|
||||||
# from /props rather than hardcoding "<__media__>".
|
|
||||||
media_marker = props.get("media_marker")
|
media_marker = props.get("media_marker")
|
||||||
if isinstance(media_marker, str) and media_marker:
|
if isinstance(media_marker, str) and media_marker:
|
||||||
self._media_marker = media_marker
|
info["media_marker"] = media_marker
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
|
|
||||||
configured_model,
|
|
||||||
self._context_size or "unknown",
|
|
||||||
self._supports_vision,
|
|
||||||
self._supports_audio,
|
|
||||||
self._supports_tools,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to query llama.cpp /props endpoint: %s. "
|
"Failed to query llama.cpp /props endpoint: %s. "
|
||||||
"Using defaults for context size and capabilities.",
|
"Image embeddings may fail if the server randomized its media marker.",
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
|
|
||||||
return base_url
|
return info
|
||||||
|
|
||||||
def _send(
|
def _send(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@ -45,6 +45,7 @@ class VLMWatchJob(Job):
|
|||||||
last_reasoning: str = ""
|
last_reasoning: str = ""
|
||||||
notification_message: str = ""
|
notification_message: str = ""
|
||||||
iteration_count: int = 0
|
iteration_count: int = 0
|
||||||
|
username: str = ""
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
@ -374,6 +375,7 @@ def start_vlm_watch_job(
|
|||||||
dispatcher: Any,
|
dispatcher: Any,
|
||||||
labels: list[str] | None = None,
|
labels: list[str] | None = None,
|
||||||
zones: list[str] | None = None,
|
zones: list[str] | None = None,
|
||||||
|
username: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Start a new VLM watch job. Returns the job ID.
|
"""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,
|
max_duration_minutes=max_duration_minutes,
|
||||||
labels=labels or [],
|
labels=labels or [],
|
||||||
zones=zones or [],
|
zones=zones or [],
|
||||||
|
username=username,
|
||||||
)
|
)
|
||||||
cancel_ev = threading.Event()
|
cancel_ev = threading.Event()
|
||||||
_current_job = job
|
_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)
|
hwaccel_args.append(args)
|
||||||
|
|
||||||
stats: dict[str, dict] = {}
|
stats: dict[str, dict] = {}
|
||||||
|
intel_gpu_collected = False
|
||||||
|
|
||||||
for args in hwaccel_args:
|
for args in hwaccel_args:
|
||||||
if args in hwaccel_errors:
|
if args in hwaccel_errors:
|
||||||
@ -242,6 +243,7 @@ async def set_gpu_stats(
|
|||||||
if nvidia_usage:
|
if nvidia_usage:
|
||||||
for i in range(len(nvidia_usage)):
|
for i in range(len(nvidia_usage)):
|
||||||
stats[nvidia_usage[i]["name"]] = {
|
stats[nvidia_usage[i]["name"]] = {
|
||||||
|
"vendor": "nvidia",
|
||||||
"gpu": str(round(float(nvidia_usage[i]["gpu"]), 2)) + "%",
|
"gpu": str(round(float(nvidia_usage[i]["gpu"]), 2)) + "%",
|
||||||
"mem": str(round(float(nvidia_usage[i]["mem"]), 2)) + "%",
|
"mem": str(round(float(nvidia_usage[i]["mem"]), 2)) + "%",
|
||||||
"enc": str(round(float(nvidia_usage[i]["enc"]), 2)) + "%",
|
"enc": str(round(float(nvidia_usage[i]["enc"]), 2)) + "%",
|
||||||
@ -250,31 +252,34 @@ async def set_gpu_stats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
stats["nvidia-gpu"] = {"gpu": "", "mem": ""}
|
stats["nvidia-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
|
||||||
hwaccel_errors.append(args)
|
hwaccel_errors.append(args)
|
||||||
elif "nvmpi" in args or "jetson" in args:
|
elif "nvmpi" in args or "jetson" in args:
|
||||||
# nvidia Jetson
|
# nvidia Jetson
|
||||||
jetson_usage = get_jetson_stats()
|
jetson_usage = get_jetson_stats()
|
||||||
|
|
||||||
if jetson_usage:
|
if jetson_usage:
|
||||||
stats["jetson-gpu"] = jetson_usage
|
stats["jetson-gpu"] = {"vendor": "nvidia", **jetson_usage}
|
||||||
else:
|
else:
|
||||||
stats["jetson-gpu"] = {"gpu": "", "mem": ""}
|
stats["jetson-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
|
||||||
hwaccel_errors.append(args)
|
hwaccel_errors.append(args)
|
||||||
elif "qsv" in args or ("vaapi" in args and not is_vaapi_amd_driver()):
|
elif "qsv" in args or ("vaapi" in args and not is_vaapi_amd_driver()):
|
||||||
if not config.telemetry.stats.intel_gpu_stats:
|
if not config.telemetry.stats.intel_gpu_stats:
|
||||||
continue
|
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 (QSV or VAAPI both use the same physical GPU)
|
||||||
|
intel_gpu_collected = True
|
||||||
intel_usage = get_intel_gpu_stats(
|
intel_usage = get_intel_gpu_stats(
|
||||||
config.telemetry.stats.intel_gpu_device
|
config.telemetry.stats.intel_gpu_device
|
||||||
)
|
)
|
||||||
|
|
||||||
if intel_usage is not None:
|
if intel_usage:
|
||||||
stats["intel-gpu"] = intel_usage or {"gpu": "", "mem": ""}
|
for entry in intel_usage.values():
|
||||||
|
name = entry.pop("name")
|
||||||
|
stats[name] = entry
|
||||||
else:
|
else:
|
||||||
stats["intel-gpu"] = {"gpu": "", "mem": ""}
|
stats["intel-gpu"] = {"vendor": "intel", "gpu": "", "mem": ""}
|
||||||
hwaccel_errors.append(args)
|
hwaccel_errors.append(args)
|
||||||
elif "vaapi" in args:
|
elif "vaapi" in args:
|
||||||
if not config.telemetry.stats.amd_gpu_stats:
|
if not config.telemetry.stats.amd_gpu_stats:
|
||||||
@ -284,18 +289,18 @@ async def set_gpu_stats(
|
|||||||
amd_usage = get_amd_gpu_stats()
|
amd_usage = get_amd_gpu_stats()
|
||||||
|
|
||||||
if amd_usage:
|
if amd_usage:
|
||||||
stats["amd-vaapi"] = amd_usage
|
stats["amd-vaapi"] = {"vendor": "amd", **amd_usage}
|
||||||
else:
|
else:
|
||||||
stats["amd-vaapi"] = {"gpu": "", "mem": ""}
|
stats["amd-vaapi"] = {"vendor": "amd", "gpu": "", "mem": ""}
|
||||||
hwaccel_errors.append(args)
|
hwaccel_errors.append(args)
|
||||||
elif "preset-rk" in args:
|
elif "preset-rk" in args:
|
||||||
rga_usage = get_rockchip_gpu_stats()
|
rga_usage = get_rockchip_gpu_stats()
|
||||||
|
|
||||||
if rga_usage:
|
if rga_usage:
|
||||||
stats["rockchip"] = rga_usage
|
stats["rockchip"] = {"vendor": "rockchip", **rga_usage}
|
||||||
elif "v4l2m2m" in args or "rpi" in args:
|
elif "v4l2m2m" in args or "rpi" in args:
|
||||||
# RPi v4l2m2m is currently not able to get usage stats
|
# RPi v4l2m2m is currently not able to get usage stats
|
||||||
stats["rpi-v4l2m2m"] = {"gpu": "", "mem": ""}
|
stats["rpi-v4l2m2m"] = {"vendor": "rpi", "gpu": "", "mem": ""}
|
||||||
|
|
||||||
if stats:
|
if stats:
|
||||||
all_stats["gpu_usages"] = stats
|
all_stats["gpu_usages"] = stats
|
||||||
|
|||||||
@ -17,12 +17,14 @@ class TestGpuStats(unittest.TestCase):
|
|||||||
amd_stats = get_amd_gpu_stats()
|
amd_stats = get_amd_gpu_stats()
|
||||||
assert amd_stats == {"gpu": "4.17%", "mem": "60.37%"}
|
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.sleep")
|
||||||
@patch("frigate.util.services.time.monotonic")
|
@patch("frigate.util.services.time.monotonic")
|
||||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
@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
|
# 1 second of wall clock between snapshots
|
||||||
monotonic.side_effect = [0.0, 1.0]
|
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.
|
# Two i915 clients on the same iGPU. Engine values are cumulative ns.
|
||||||
# Deltas over the 1s window:
|
# Deltas over the 1s window:
|
||||||
@ -79,11 +81,15 @@ class TestGpuStats(unittest.TestCase):
|
|||||||
|
|
||||||
sleep.assert_called_once()
|
sleep.assert_called_once()
|
||||||
assert intel_stats == {
|
assert intel_stats == {
|
||||||
"gpu": "90.0%",
|
"0000:00:02.0": {
|
||||||
"mem": "-%",
|
"name": "Intel Graphics",
|
||||||
"compute": "30.0%",
|
"vendor": "intel",
|
||||||
"dec": "60.0%",
|
"gpu": "90.0%",
|
||||||
"clients": {"100": "80.0%", "200": "10.0%"},
|
"mem": "-%",
|
||||||
|
"compute": "30.0%",
|
||||||
|
"dec": "60.0%",
|
||||||
|
"clients": {"100": "80.0%", "200": "10.0%"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||||
|
|||||||
@ -393,8 +393,10 @@ def _read_intel_drm_fdinfo(target_pdev: Optional[str]) -> dict:
|
|||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, Any]]:
|
def get_intel_gpu_stats(
|
||||||
"""Get stats by reading DRM fdinfo files.
|
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
|
Each DRM client FD exposes monotonic per-engine busy counters via
|
||||||
/proc/<pid>/fdinfo/<fd> (i915 since kernel 5.19, Xe since first release).
|
/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
|
utilization. Render/3D and Compute are pooled into "compute"; Video and
|
||||||
VideoEnhance into "dec". Overall "gpu" is the sum of those pools (clamped
|
VideoEnhance into "dec". Overall "gpu" is the sum of those pools (clamped
|
||||||
to 100%).
|
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)
|
target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device)
|
||||||
|
|
||||||
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
|
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:
|
if not snapshot_b or elapsed_ns <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
engine_pct: dict[str, float] = {
|
def _new_engine_pct() -> dict[str, float]:
|
||||||
"render": 0.0,
|
return {"render": 0.0, "video": 0.0, "video-enhance": 0.0, "compute": 0.0}
|
||||||
"video": 0.0,
|
|
||||||
"video-enhance": 0.0,
|
per_pdev_engine_pct: dict[str, dict[str, float]] = {}
|
||||||
"compute": 0.0,
|
per_pdev_pid_pct: dict[str, dict[str, float]] = {}
|
||||||
}
|
|
||||||
pid_pct: dict[str, float] = {}
|
|
||||||
|
|
||||||
for key, data_b in snapshot_b.items():
|
for key, data_b in snapshot_b.items():
|
||||||
data_a = snapshot_a.get(key)
|
data_a = snapshot_a.get(key)
|
||||||
if not data_a or data_a["driver"] != data_b["driver"]:
|
if not data_a or data_a["driver"] != data_b["driver"]:
|
||||||
continue
|
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
|
client_total = 0.0
|
||||||
for engine, (busy_b, total_b) in data_b["engines"].items():
|
for engine, (busy_b, total_b) in data_b["engines"].items():
|
||||||
if engine not in engine_pct:
|
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
|
pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total
|
||||||
|
|
||||||
for engine in engine_pct:
|
if not per_pdev_engine_pct:
|
||||||
engine_pct[engine] = min(100.0, engine_pct[engine])
|
return None
|
||||||
|
|
||||||
compute_pct = min(100.0, engine_pct["render"] + engine_pct["compute"])
|
names = intel_gpu_name_resolver.get_names()
|
||||||
dec_pct = min(100.0, engine_pct["video"] + engine_pct["video-enhance"])
|
results: dict[str, dict[str, Any]] = {}
|
||||||
overall_pct = min(100.0, compute_pct + dec_pct)
|
|
||||||
|
|
||||||
results: dict[str, Any] = {
|
for pdev, engine_pct in per_pdev_engine_pct.items():
|
||||||
"gpu": f"{round(overall_pct, 2)}%",
|
for engine in engine_pct:
|
||||||
"mem": "-%",
|
engine_pct[engine] = min(100.0, engine_pct[engine])
|
||||||
"compute": f"{round(compute_pct, 2)}%",
|
|
||||||
"dec": f"{round(dec_pct, 2)}%",
|
|
||||||
}
|
|
||||||
|
|
||||||
if pid_pct:
|
compute_pct = min(100.0, engine_pct["render"] + engine_pct["compute"])
|
||||||
results["clients"] = {
|
dec_pct = min(100.0, engine_pct["video"] + engine_pct["video-enhance"])
|
||||||
pid: f"{round(min(100.0, pct), 2)}%" for pid, pct in pid_pct.items()
|
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
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -125,5 +125,5 @@
|
|||||||
"baby": "Baby",
|
"baby": "Baby",
|
||||||
"baby_stroller": "Baby Stroller",
|
"baby_stroller": "Baby Stroller",
|
||||||
"rickshaw": "Rickshaw",
|
"rickshaw": "Rickshaw",
|
||||||
"Rodent": "Rodent"
|
"rodent": "Rodent"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,8 +19,14 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"overriddenGlobal": "Overridden (Global)",
|
"overriddenGlobal": "Overridden (Global)",
|
||||||
"overriddenGlobalTooltip": "This camera overrides global configuration settings in this section",
|
"overriddenGlobalTooltip": "This camera overrides global configuration settings in this section",
|
||||||
|
"overriddenGlobalHeading_one": "This camera overrides {{count}} field from the global config:",
|
||||||
|
"overriddenGlobalHeading_other": "This camera overrides {{count}} fields from the global config:",
|
||||||
|
"overriddenGlobalNoDeltas": "This camera overrides the global config, but no field values differ.",
|
||||||
"overriddenBaseConfig": "Overridden (Base Config)",
|
"overriddenBaseConfig": "Overridden (Base Config)",
|
||||||
"overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section",
|
"overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section",
|
||||||
|
"overriddenBaseConfigHeading_one": "The {{profile}} profile overrides {{count}} field from the base config:",
|
||||||
|
"overriddenBaseConfigHeading_other": "The {{profile}} profile overrides {{count}} fields from the base config:",
|
||||||
|
"overriddenBaseConfigNoDeltas": "The {{profile}} profile overrides this section, but no field values differ from the base config.",
|
||||||
"overriddenInCameras": {
|
"overriddenInCameras": {
|
||||||
"label_one": "Overridden in {{count}} camera",
|
"label_one": "Overridden in {{count}} camera",
|
||||||
"label_other": "Overridden in {{count}} cameras",
|
"label_other": "Overridden in {{count}} cameras",
|
||||||
|
|||||||
@ -156,7 +156,7 @@ export function MessageBubble({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
!isComplete &&
|
!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
|
<ReactMarkdown
|
||||||
|
|||||||
@ -41,19 +41,12 @@ const ffmpeg: SectionConfigOverrides = {
|
|||||||
"input_args",
|
"input_args",
|
||||||
"hwaccel_args",
|
"hwaccel_args",
|
||||||
"output_args",
|
"output_args",
|
||||||
"path",
|
|
||||||
"retry_interval",
|
|
||||||
"apple_compatibility",
|
|
||||||
"gpu",
|
|
||||||
],
|
|
||||||
hiddenFields: [],
|
|
||||||
advancedFields: [
|
|
||||||
"path",
|
|
||||||
"global_args",
|
|
||||||
"retry_interval",
|
|
||||||
"apple_compatibility",
|
"apple_compatibility",
|
||||||
|
"path",
|
||||||
"gpu",
|
"gpu",
|
||||||
],
|
],
|
||||||
|
hiddenFields: ["retry_interval"],
|
||||||
|
advancedFields: ["path", "global_args", "gpu"],
|
||||||
overrideFields: [
|
overrideFields: [
|
||||||
"inputs",
|
"inputs",
|
||||||
"path",
|
"path",
|
||||||
@ -61,7 +54,6 @@ const ffmpeg: SectionConfigOverrides = {
|
|||||||
"input_args",
|
"input_args",
|
||||||
"hwaccel_args",
|
"hwaccel_args",
|
||||||
"output_args",
|
"output_args",
|
||||||
"retry_interval",
|
|
||||||
"apple_compatibility",
|
"apple_compatibility",
|
||||||
"gpu",
|
"gpu",
|
||||||
],
|
],
|
||||||
@ -125,19 +117,10 @@ const ffmpeg: SectionConfigOverrides = {
|
|||||||
"global_args",
|
"global_args",
|
||||||
"input_args",
|
"input_args",
|
||||||
"output_args",
|
"output_args",
|
||||||
"retry_interval",
|
|
||||||
"apple_compatibility",
|
|
||||||
"gpu",
|
|
||||||
],
|
|
||||||
advancedFields: [
|
|
||||||
"global_args",
|
|
||||||
"input_args",
|
|
||||||
"output_args",
|
|
||||||
"path",
|
|
||||||
"retry_interval",
|
|
||||||
"apple_compatibility",
|
"apple_compatibility",
|
||||||
"gpu",
|
"gpu",
|
||||||
],
|
],
|
||||||
|
advancedFields: ["global_args", "input_args", "output_args", "path", "gpu"],
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
path: {
|
path: {
|
||||||
"ui:options": { size: "md" },
|
"ui:options": { size: "md" },
|
||||||
|
|||||||
@ -27,21 +27,17 @@ import {
|
|||||||
import { getSectionValidation } from "../section-validations";
|
import { getSectionValidation } from "../section-validations";
|
||||||
import { useConfigOverride } from "@/hooks/use-config-override";
|
import { useConfigOverride } from "@/hooks/use-config-override";
|
||||||
import { CameraOverridesBadge } from "./CameraOverridesBadge";
|
import { CameraOverridesBadge } from "./CameraOverridesBadge";
|
||||||
|
import { GlobalOverridesBadge } from "./GlobalOverridesBadge";
|
||||||
|
import { ProfileOverridesBadge } from "./ProfileOverridesBadge";
|
||||||
import { useSectionSchema } from "@/hooks/use-config-schema";
|
import { useSectionSchema } from "@/hooks/use-config-schema";
|
||||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import get from "lodash/get";
|
import get from "lodash/get";
|
||||||
import cloneDeep from "lodash/cloneDeep";
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
import isEqual from "lodash/isEqual";
|
import isEqual from "lodash/isEqual";
|
||||||
import merge from "lodash/merge";
|
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@ -73,6 +69,7 @@ import {
|
|||||||
buildConfigDataForPath,
|
buildConfigDataForPath,
|
||||||
flattenOverrides,
|
flattenOverrides,
|
||||||
getBaseCameraSectionValue,
|
getBaseCameraSectionValue,
|
||||||
|
mergeProfileOverrides,
|
||||||
resolveHiddenFieldEntries,
|
resolveHiddenFieldEntries,
|
||||||
sanitizeSectionData as sharedSanitizeSectionData,
|
sanitizeSectionData as sharedSanitizeSectionData,
|
||||||
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
|
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
|
||||||
@ -353,7 +350,10 @@ export function ConfigSection({
|
|||||||
`profiles.${profileName}.${sectionPath}`,
|
`profiles.${profileName}.${sectionPath}`,
|
||||||
);
|
);
|
||||||
if (profileOverrides && typeof profileOverrides === "object") {
|
if (profileOverrides && typeof profileOverrides === "object") {
|
||||||
return merge(cloneDeep(baseValue ?? {}), cloneDeep(profileOverrides));
|
return mergeProfileOverrides(
|
||||||
|
(baseValue as object) ?? {},
|
||||||
|
profileOverrides as object,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return baseValue;
|
return baseValue;
|
||||||
}
|
}
|
||||||
@ -1044,6 +1044,7 @@ export function ConfigSection({
|
|||||||
hiddenFields: effectiveHiddenFields,
|
hiddenFields: effectiveHiddenFields,
|
||||||
restartRequired: sectionConfig.restartRequired,
|
restartRequired: sectionConfig.restartRequired,
|
||||||
requiresRestart,
|
requiresRestart,
|
||||||
|
isProfile: !!profileName,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -1253,33 +1254,22 @@ export function ConfigSection({
|
|||||||
<Heading as="h4">{title}</Heading>
|
<Heading as="h4">{title}</Heading>
|
||||||
{showOverrideIndicator &&
|
{showOverrideIndicator &&
|
||||||
effectiveLevel === "camera" &&
|
effectiveLevel === "camera" &&
|
||||||
(profileOverridesSection || isOverridden) && (
|
(profileOverridesSection || isOverridden) &&
|
||||||
<Tooltip>
|
cameraName &&
|
||||||
<TooltipTrigger asChild>
|
(overrideSource === "profile" && profileName ? (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<ProfileOverridesBadge
|
||||||
{overrideSource === "profile"
|
sectionPath={sectionPath}
|
||||||
? t("button.overriddenBaseConfig", {
|
cameraName={cameraName}
|
||||||
ns: "views/settings",
|
profileName={profileName}
|
||||||
defaultValue: "Overridden (Base Config)",
|
profileFriendlyName={profileFriendlyName}
|
||||||
})
|
profileBorderColor={profileBorderColor}
|
||||||
: t("button.overriddenGlobal", {
|
/>
|
||||||
ns: "views/settings",
|
) : (
|
||||||
defaultValue: "Overridden (Global)",
|
<GlobalOverridesBadge
|
||||||
})}
|
sectionPath={sectionPath}
|
||||||
</Badge>
|
cameraName={cameraName}
|
||||||
</TooltipTrigger>
|
/>
|
||||||
<TooltipContent>
|
))}
|
||||||
{overrideSource === "profile"
|
|
||||||
? t("button.overriddenBaseConfigTooltip", {
|
|
||||||
ns: "views/settings",
|
|
||||||
profile: profileFriendlyName ?? profileName,
|
|
||||||
})
|
|
||||||
: t("button.overriddenGlobalTooltip", {
|
|
||||||
ns: "views/settings",
|
|
||||||
})}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{showOverrideIndicator && effectiveLevel === "global" && (
|
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||||
<CameraOverridesBadge sectionPath={sectionPath} />
|
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||||
)}
|
)}
|
||||||
@ -1319,41 +1309,22 @@ export function ConfigSection({
|
|||||||
<Heading as="h4">{title}</Heading>
|
<Heading as="h4">{title}</Heading>
|
||||||
{showOverrideIndicator &&
|
{showOverrideIndicator &&
|
||||||
effectiveLevel === "camera" &&
|
effectiveLevel === "camera" &&
|
||||||
(profileOverridesSection || isOverridden) && (
|
(profileOverridesSection || isOverridden) &&
|
||||||
<Tooltip>
|
cameraName &&
|
||||||
<TooltipTrigger asChild>
|
(overrideSource === "profile" && profileName ? (
|
||||||
<Badge
|
<ProfileOverridesBadge
|
||||||
variant="secondary"
|
sectionPath={sectionPath}
|
||||||
className={cn(
|
cameraName={cameraName}
|
||||||
"cursor-default border-2 text-center text-xs text-primary-variant",
|
profileName={profileName}
|
||||||
overrideSource === "profile" && profileBorderColor
|
profileFriendlyName={profileFriendlyName}
|
||||||
? profileBorderColor
|
profileBorderColor={profileBorderColor}
|
||||||
: "border-selected",
|
/>
|
||||||
)}
|
) : (
|
||||||
>
|
<GlobalOverridesBadge
|
||||||
{overrideSource === "profile"
|
sectionPath={sectionPath}
|
||||||
? t("button.overriddenBaseConfig", {
|
cameraName={cameraName}
|
||||||
ns: "views/settings",
|
/>
|
||||||
defaultValue: "Overridden (Base Config)",
|
))}
|
||||||
})
|
|
||||||
: t("button.overriddenGlobal", {
|
|
||||||
ns: "views/settings",
|
|
||||||
defaultValue: "Overridden (Global)",
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{overrideSource === "profile"
|
|
||||||
? t("button.overriddenBaseConfigTooltip", {
|
|
||||||
ns: "views/settings",
|
|
||||||
profile: profileFriendlyName ?? profileName,
|
|
||||||
})
|
|
||||||
: t("button.overriddenGlobalTooltip", {
|
|
||||||
ns: "views/settings",
|
|
||||||
})}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{showOverrideIndicator && effectiveLevel === "global" && (
|
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||||
<CameraOverridesBadge sectionPath={sectionPath} />
|
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -17,10 +17,13 @@ import {
|
|||||||
} from "@/hooks/use-config-override";
|
} from "@/hooks/use-config-override";
|
||||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import type { ProfilesApiResponse } from "@/types/profile";
|
import type { ProfilesApiResponse } from "@/types/profile";
|
||||||
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
|
|
||||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||||
import { formatList } from "@/utils/stringUtil";
|
import { formatList } from "@/utils/stringUtil";
|
||||||
import { getEffectiveHiddenFields } from "@/utils/configUtil";
|
import {
|
||||||
|
getEffectiveHiddenFields,
|
||||||
|
pathMatchesHiddenPattern,
|
||||||
|
} from "@/utils/configUtil";
|
||||||
|
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
|
||||||
|
|
||||||
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
|
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
|
||||||
detect: "cameraDetect",
|
detect: "cameraDetect",
|
||||||
@ -72,26 +75,6 @@ const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
|
|||||||
"model",
|
"model",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Match a delta path against a hidden-field pattern. Supports literal prefixes
|
|
||||||
* (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards
|
|
||||||
* matching exactly one path segment (e.g. "filters.*.mask").
|
|
||||||
*/
|
|
||||||
function pathMatchesHiddenPattern(path: string, pattern: string): boolean {
|
|
||||||
if (!pattern) return false;
|
|
||||||
if (!pattern.includes("*")) {
|
|
||||||
return path === pattern || path.startsWith(`${pattern}.`);
|
|
||||||
}
|
|
||||||
const patternSegments = pattern.split(".");
|
|
||||||
const pathSegments = path.split(".");
|
|
||||||
if (pathSegments.length < patternSegments.length) return false;
|
|
||||||
for (let i = 0; i < patternSegments.length; i += 1) {
|
|
||||||
if (patternSegments[i] === "*") continue;
|
|
||||||
if (patternSegments[i] !== pathSegments[i]) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CameraEntryProps = {
|
type CameraEntryProps = {
|
||||||
sectionPath: string;
|
sectionPath: string;
|
||||||
entry: CameraOverrideEntry;
|
entry: CameraOverrideEntry;
|
||||||
@ -127,11 +110,8 @@ function groupDeltasBySource(deltas: FieldDelta[]): SourceGroup[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
||||||
const { t, i18n } = useTranslation([
|
const { t } = useTranslation(["views/settings"]);
|
||||||
"config/global",
|
const fieldLabel = useOverrideFieldLabel(sectionPath);
|
||||||
"views/settings",
|
|
||||||
"objects",
|
|
||||||
]);
|
|
||||||
const friendlyName = useCameraFriendlyName(entry.camera);
|
const friendlyName = useCameraFriendlyName(entry.camera);
|
||||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||||
|
|
||||||
@ -141,49 +121,6 @@ function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
|||||||
return map;
|
return map;
|
||||||
}, [profilesData]);
|
}, [profilesData]);
|
||||||
|
|
||||||
const fieldLabel = (fieldPath: string) => {
|
|
||||||
if (!fieldPath) {
|
|
||||||
const sectionKey = `${sectionPath}.label`;
|
|
||||||
return i18n.exists(sectionKey, { ns: "config/global" })
|
|
||||||
? t(sectionKey, { ns: "config/global" })
|
|
||||||
: humanizeKey(sectionPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments = fieldPath.split(".");
|
|
||||||
|
|
||||||
// Most specific: try the full nested path
|
|
||||||
const fullKey = `${sectionPath}.${fieldPath}.label`;
|
|
||||||
if (i18n.exists(fullKey, { ns: "config/global" })) {
|
|
||||||
return t(fullKey, { ns: "config/global" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try dropping each intermediate segment in turn — those are typically
|
|
||||||
// user-defined dict keys (object class names, zone names, etc.) that
|
|
||||||
// don't have their own label entries. Prepend the dropped segment as
|
|
||||||
// context to disambiguate (e.g. "Person · Minimum object area").
|
|
||||||
for (let i = 0; i < segments.length; i++) {
|
|
||||||
const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join(
|
|
||||||
".",
|
|
||||||
);
|
|
||||||
if (!reduced) continue;
|
|
||||||
const reducedKey = `${sectionPath}.${reduced}.label`;
|
|
||||||
if (i18n.exists(reducedKey, { ns: "config/global" })) {
|
|
||||||
const resolvedLabel = t(reducedKey, { ns: "config/global" });
|
|
||||||
const dropped = segments[i];
|
|
||||||
// Object class names ("person", "car", "fox") have translations in
|
|
||||||
// the `objects` namespace; fall back to humanizing the raw key for
|
|
||||||
// anything that isn't a known label.
|
|
||||||
const droppedLabel = i18n.exists(dropped, { ns: "objects" })
|
|
||||||
? t(dropped, { ns: "objects" })
|
|
||||||
: humanizeKey(dropped);
|
|
||||||
return `${droppedLabel} · ${resolvedLabel}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort: humanize the leaf segment
|
|
||||||
return humanizeKey(segments[segments.length - 1]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDeltas = (deltas: FieldDelta[]) => {
|
const formatDeltas = (deltas: FieldDelta[]) => {
|
||||||
const visibleLabels = deltas
|
const visibleLabels = deltas
|
||||||
.slice(0, MAX_FIELDS_PER_CAMERA)
|
.slice(0, MAX_FIELDS_PER_CAMERA)
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
import useSWR from "swr";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useCameraSectionDeltas } from "@/hooks/use-config-override";
|
||||||
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { OverrideDeltaPopover } from "./OverrideDeltaPopover";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
sectionPath: string;
|
||||||
|
cameraName: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GlobalOverridesBadge({
|
||||||
|
sectionPath,
|
||||||
|
cameraName,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const deltas = useCameraSectionDeltas(config, cameraName, sectionPath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OverrideDeltaPopover
|
||||||
|
sectionPath={sectionPath}
|
||||||
|
deltas={deltas}
|
||||||
|
badgeLabel={t("button.overriddenGlobal", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "Overridden (Global)",
|
||||||
|
})}
|
||||||
|
ariaLabel={t("button.overriddenGlobalTooltip", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
heading={t("button.overriddenGlobalHeading", {
|
||||||
|
ns: "views/settings",
|
||||||
|
count: deltas.length,
|
||||||
|
})}
|
||||||
|
noDeltasMessage={t("button.overriddenGlobalNoDeltas", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
import { LuChevronDown } from "react-icons/lu";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import type { FieldDelta } from "@/hooks/use-config-override";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
sectionPath: string;
|
||||||
|
deltas: FieldDelta[];
|
||||||
|
/** Translated label shown inside the badge */
|
||||||
|
badgeLabel: string;
|
||||||
|
/** Accessible label for the badge trigger */
|
||||||
|
ariaLabel: string;
|
||||||
|
/** Heading rendered at the top of the popover content */
|
||||||
|
heading: string;
|
||||||
|
/** Message shown when there are zero field deltas */
|
||||||
|
noDeltasMessage: string;
|
||||||
|
/** Border color class for the badge (defaults to selected) */
|
||||||
|
borderColorClass?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared popover layout for "this scope overrides these fields" badges
|
||||||
|
* (e.g. profile overrides base config, camera overrides global config).
|
||||||
|
*/
|
||||||
|
export function OverrideDeltaPopover({
|
||||||
|
sectionPath,
|
||||||
|
deltas,
|
||||||
|
badgeLabel,
|
||||||
|
ariaLabel,
|
||||||
|
heading,
|
||||||
|
noDeltasMessage,
|
||||||
|
borderColorClass,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const fieldLabel = useOverrideFieldLabel(sectionPath);
|
||||||
|
const count = deltas.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer border-2 text-center text-xs text-primary-variant",
|
||||||
|
borderColorClass ?? "border-selected",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<span>{badgeLabel}</span>
|
||||||
|
<LuChevronDown className="ml-1 size-3" />
|
||||||
|
</Badge>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-80 max-w-[90vw] pr-0">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="pr-4 text-xs text-primary-variant">
|
||||||
|
{count > 0 ? heading : noDeltasMessage}
|
||||||
|
</div>
|
||||||
|
{count > 0 && (
|
||||||
|
<ul className="scrollbar-container ml-5 flex max-h-[40dvh] list-disc flex-col gap-1 overflow-y-auto pr-4 text-xs">
|
||||||
|
{deltas.map((delta) => (
|
||||||
|
<li key={delta.fieldPath}>{fieldLabel(delta.fieldPath)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import useSWR from "swr";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useProfileSectionDeltas } from "@/hooks/use-config-override";
|
||||||
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { OverrideDeltaPopover } from "./OverrideDeltaPopover";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
sectionPath: string;
|
||||||
|
cameraName: string;
|
||||||
|
profileName: string;
|
||||||
|
profileFriendlyName?: string;
|
||||||
|
/** Border color class for profile-themed badge (e.g., "border-amber-500") */
|
||||||
|
profileBorderColor?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileOverridesBadge({
|
||||||
|
sectionPath,
|
||||||
|
cameraName,
|
||||||
|
profileName,
|
||||||
|
profileFriendlyName,
|
||||||
|
profileBorderColor,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const deltas = useProfileSectionDeltas(
|
||||||
|
config,
|
||||||
|
cameraName,
|
||||||
|
profileName,
|
||||||
|
sectionPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayProfile = profileFriendlyName ?? profileName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OverrideDeltaPopover
|
||||||
|
sectionPath={sectionPath}
|
||||||
|
deltas={deltas}
|
||||||
|
badgeLabel={t("button.overriddenBaseConfig", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "Overridden (Base Config)",
|
||||||
|
})}
|
||||||
|
ariaLabel={t("button.overriddenBaseConfigTooltip", {
|
||||||
|
ns: "views/settings",
|
||||||
|
profile: displayProfile,
|
||||||
|
})}
|
||||||
|
heading={t("button.overriddenBaseConfigHeading", {
|
||||||
|
ns: "views/settings",
|
||||||
|
profile: displayProfile,
|
||||||
|
count: deltas.length,
|
||||||
|
})}
|
||||||
|
noDeltasMessage={t("button.overriddenBaseConfigNoDeltas", {
|
||||||
|
ns: "views/settings",
|
||||||
|
profile: displayProfile,
|
||||||
|
})}
|
||||||
|
borderColorClass={profileBorderColor}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a translated label for a config field path within a section, falling
|
||||||
|
* back through reduced paths (dropping each intermediate segment in turn) so
|
||||||
|
* dict-keyed paths like `filters.person.threshold` still surface a meaningful
|
||||||
|
* label. Dropped segments are prepended as context (e.g. "Person · Threshold").
|
||||||
|
*
|
||||||
|
* Shared between override badges that need to render field labels (e.g.
|
||||||
|
* CameraOverridesBadge, ProfileOverridesBadge).
|
||||||
|
*/
|
||||||
|
export function useOverrideFieldLabel(sectionPath: string) {
|
||||||
|
const { t, i18n } = useTranslation([
|
||||||
|
"config/global",
|
||||||
|
"views/settings",
|
||||||
|
"objects",
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (fieldPath: string): string => {
|
||||||
|
if (!fieldPath) {
|
||||||
|
const sectionKey = `${sectionPath}.label`;
|
||||||
|
return i18n.exists(sectionKey, { ns: "config/global" })
|
||||||
|
? t(sectionKey, { ns: "config/global" })
|
||||||
|
: humanizeKey(sectionPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = fieldPath.split(".");
|
||||||
|
|
||||||
|
const fullKey = `${sectionPath}.${fieldPath}.label`;
|
||||||
|
if (i18n.exists(fullKey, { ns: "config/global" })) {
|
||||||
|
return t(fullKey, { ns: "config/global" });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join(
|
||||||
|
".",
|
||||||
|
);
|
||||||
|
if (!reduced) continue;
|
||||||
|
const reducedKey = `${sectionPath}.${reduced}.label`;
|
||||||
|
if (i18n.exists(reducedKey, { ns: "config/global" })) {
|
||||||
|
const resolvedLabel = t(reducedKey, { ns: "config/global" });
|
||||||
|
const dropped = segments[i];
|
||||||
|
const droppedLabel = i18n.exists(dropped, { ns: "objects" })
|
||||||
|
? t(dropped, { ns: "objects" })
|
||||||
|
: humanizeKey(dropped);
|
||||||
|
return `${droppedLabel} · ${resolvedLabel}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return humanizeKey(segments[segments.length - 1]);
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { getWidget } from "@rjsf/utils";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getNonNullSchema } from "../fields/nullableUtils";
|
import { getNonNullSchema } from "../fields/nullableUtils";
|
||||||
|
import type { ConfigFormContext } from "@/types/configForm";
|
||||||
|
|
||||||
export function OptionalFieldWidget(props: WidgetProps) {
|
export function OptionalFieldWidget(props: WidgetProps) {
|
||||||
const { id, value, disabled, readonly, onChange, schema, options, registry } =
|
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 innerWidgetName = (options.innerWidget as string) || undefined;
|
||||||
const isEnabled = value !== undefined && value !== null;
|
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]
|
// Extract the non-null branch from anyOf [Type, null]
|
||||||
const innerSchema = getNonNullSchema(schema) ?? schema;
|
const innerSchema = getNonNullSchema(schema) ?? schema;
|
||||||
@ -42,10 +45,17 @@ export function OptionalFieldWidget(props: WidgetProps) {
|
|||||||
const innerProps: WidgetProps = {
|
const innerProps: WidgetProps = {
|
||||||
...props,
|
...props,
|
||||||
schema: innerSchema,
|
schema: innerSchema,
|
||||||
disabled: disabled || readonly || !isEnabled,
|
disabled: disabled || readonly || (!isProfile && !isEnabled),
|
||||||
value: isEnabled ? value : getDefaultValue(),
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@ -102,6 +102,19 @@ export default function ClassificationSelectionDialog({
|
|||||||
// control
|
// control
|
||||||
const [newClass, setNewClass] = useState(false);
|
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
|
// components
|
||||||
const Selector = isDesktop ? DropdownMenu : Drawer;
|
const Selector = isDesktop ? DropdownMenu : Drawer;
|
||||||
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||||
@ -114,6 +127,8 @@ export default function ClassificationSelectionDialog({
|
|||||||
</DrawerClose>
|
</DrawerClose>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// keep modal false on desktop to prevent dismissable layer pointer events
|
||||||
|
// issue with dialog auto-close
|
||||||
return (
|
return (
|
||||||
<div className={className ?? "flex"}>
|
<div className={className ?? "flex"}>
|
||||||
<TextEntryDialog
|
<TextEntryDialog
|
||||||
@ -122,60 +137,60 @@ export default function ClassificationSelectionDialog({
|
|||||||
title={t("createCategory.new")}
|
title={t("createCategory.new")}
|
||||||
onSave={(newCat) => onCategorizeImage(newCat)}
|
onSave={(newCat) => onCategorizeImage(newCat)}
|
||||||
/>
|
/>
|
||||||
|
<Selector {...(isDesktop ? { modal: false } : {})}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Selector {...(isDesktop ? { modal: false } : {})}>
|
<TooltipTrigger asChild={isChildButton}>
|
||||||
<SelectorTrigger asChild>
|
<SelectorTrigger asChild>{children}</SelectorTrigger>
|
||||||
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</SelectorTrigger>
|
<TooltipContent>
|
||||||
<SelectorContent
|
{tooltipLabel ?? t("categorizeImage")}
|
||||||
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
|
</TooltipContent>
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
</Tooltip>
|
||||||
>
|
<SelectorContent
|
||||||
{isMobile && (
|
ref={scrollContainerRef}
|
||||||
<DrawerHeader className="sr-only">
|
className={cn(
|
||||||
<DrawerTitle>Details</DrawerTitle>
|
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
|
||||||
<DrawerDescription>Details</DrawerDescription>
|
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
|
||||||
</DrawerHeader>
|
)}
|
||||||
)}
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
<DropdownMenuLabel>
|
>
|
||||||
{dialogLabel ?? t("categorizeImageAs")}
|
{isMobile && (
|
||||||
</DropdownMenuLabel>
|
<DrawerHeader className="sr-only">
|
||||||
<div
|
<DrawerTitle>Details</DrawerTitle>
|
||||||
className={cn(
|
<DrawerDescription>Details</DrawerDescription>
|
||||||
"flex max-h-[40dvh] flex-col overflow-y-auto",
|
</DrawerHeader>
|
||||||
isMobile && "gap-2 pb-4",
|
)}
|
||||||
)}
|
<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
|
{t("createCategory.new")}
|
||||||
.sort((a, b) => {
|
</SelectorItem>
|
||||||
if (a === "none") return 1;
|
</div>
|
||||||
if (b === "none") return -1;
|
</SelectorContent>
|
||||||
return a.localeCompare(b);
|
</Selector>
|
||||||
})
|
|
||||||
.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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ import {
|
|||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
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 TextEntryDialog from "./dialog/TextEntryDialog";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
@ -61,6 +61,19 @@ export default function FaceSelectionDialog({
|
|||||||
// control
|
// control
|
||||||
const [newFace, setNewFace] = useState(false);
|
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
|
// components
|
||||||
const Selector = isDesktop ? DropdownMenu : Drawer;
|
const Selector = isDesktop ? DropdownMenu : Drawer;
|
||||||
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||||
@ -73,6 +86,8 @@ export default function FaceSelectionDialog({
|
|||||||
</DrawerClose>
|
</DrawerClose>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// keep modal false on desktop to prevent dismissable layer pointer events
|
||||||
|
// issue with dialog auto-close
|
||||||
return (
|
return (
|
||||||
<div className={className ?? "flex"}>
|
<div className={className ?? "flex"}>
|
||||||
{newFace && (
|
{newFace && (
|
||||||
@ -83,52 +98,56 @@ export default function FaceSelectionDialog({
|
|||||||
onSave={(newName) => onTrainAttempt(newName)}
|
onSave={(newName) => onTrainAttempt(newName)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Selector {...(isDesktop ? { modal: false } : {})}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Selector {...(isDesktop ? { modal: false } : {})}>
|
<TooltipTrigger asChild={isChildButton}>
|
||||||
<SelectorTrigger asChild>
|
<SelectorTrigger asChild>{children}</SelectorTrigger>
|
||||||
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</SelectorTrigger>
|
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
|
||||||
<SelectorContent
|
</Tooltip>
|
||||||
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
|
<SelectorContent
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
ref={scrollContainerRef}
|
||||||
>
|
className={cn(
|
||||||
{isMobile && (
|
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
|
||||||
<DrawerHeader className="sr-only">
|
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
|
||||||
<DrawerTitle>Details</DrawerTitle>
|
)}
|
||||||
<DrawerDescription>Details</DrawerDescription>
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
</DrawerHeader>
|
>
|
||||||
|
{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")}
|
{filteredNames.sort().map((faceName) => (
|
||||||
</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 />
|
|
||||||
<SelectorItem
|
<SelectorItem
|
||||||
|
key={faceName}
|
||||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||||
onClick={() => setNewFace(true)}
|
onClick={() => onTrainAttempt(faceName)}
|
||||||
>
|
>
|
||||||
{t("createFaceLibrary.new")}
|
{faceName}
|
||||||
</SelectorItem>
|
</SelectorItem>
|
||||||
</div>
|
))}
|
||||||
</SelectorContent>
|
<DropdownMenuSeparator />
|
||||||
</Selector>
|
<SelectorItem
|
||||||
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
|
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||||
</Tooltip>
|
onClick={() => setNewFace(true)}
|
||||||
|
>
|
||||||
|
{t("createFaceLibrary.new")}
|
||||||
|
</SelectorItem>
|
||||||
|
</div>
|
||||||
|
</SelectorContent>
|
||||||
|
</Selector>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -539,7 +539,7 @@ export function ReviewTimeline({
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent>{t("zoomIn")}</TooltipContent>
|
<TooltipContent>{t("zoomOut")}</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -562,7 +562,7 @@ export function ReviewTimeline({
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent>{t("zoomOut")}</TooltipContent>
|
<TooltipContent>{t("zoomIn")}</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { isJsonObject } from "@/lib/utils";
|
|||||||
import {
|
import {
|
||||||
getBaseCameraSectionValue,
|
getBaseCameraSectionValue,
|
||||||
getEffectiveHiddenFields,
|
getEffectiveHiddenFields,
|
||||||
|
pathMatchesHiddenPattern,
|
||||||
unsetWithWildcard,
|
unsetWithWildcard,
|
||||||
} from "@/utils/configUtil";
|
} from "@/utils/configUtil";
|
||||||
import { extractSectionSchema } from "@/hooks/use-config-schema";
|
import { extractSectionSchema } from "@/hooks/use-config-schema";
|
||||||
@ -663,3 +664,138 @@ export function useCamerasOverridingSection(
|
|||||||
return entries;
|
return entries;
|
||||||
}, [config, sectionPath, schema]);
|
}, [config, sectionPath, schema]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook returning the field-level deltas between a single camera's base
|
||||||
|
* (pre-profile) section value and the effective global baseline. Mirrors
|
||||||
|
* `useConfigOverride`'s comparison logic but exposes per-field deltas so a
|
||||||
|
* popover can list the overridden fields.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const deltas = useCameraSectionDeltas(config, "front_door", "detect");
|
||||||
|
* // [{ fieldPath: "fps", globalValue: 5, cameraValue: 10 }]
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useCameraSectionDeltas(
|
||||||
|
config: FrigateConfig | undefined,
|
||||||
|
cameraName: string | undefined,
|
||||||
|
sectionPath: string,
|
||||||
|
): FieldDelta[] {
|
||||||
|
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!config?.cameras || !cameraName || !sectionPath) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const cameraConfig = config.cameras[cameraName];
|
||||||
|
if (!cameraConfig) return [];
|
||||||
|
|
||||||
|
const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath);
|
||||||
|
const compareFields = sectionMeta?.compareFields;
|
||||||
|
|
||||||
|
const globalValue = collapseEmpty(
|
||||||
|
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
|
||||||
|
);
|
||||||
|
const cameraValue = collapseEmpty(
|
||||||
|
normalizeConfigValue(
|
||||||
|
getBaseCameraSectionValue(config, cameraName, sectionPath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hiddenFields = getEffectiveHiddenFields(
|
||||||
|
sectionPath,
|
||||||
|
"camera",
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deltas: FieldDelta[] = [];
|
||||||
|
for (const delta of collectFieldDeltas(
|
||||||
|
globalValue,
|
||||||
|
cameraValue,
|
||||||
|
compareFields,
|
||||||
|
)) {
|
||||||
|
if (
|
||||||
|
hiddenFields.some((pattern) =>
|
||||||
|
pathMatchesHiddenPattern(delta.fieldPath, pattern),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
deltas.push(delta);
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
|
}, [config, cameraName, sectionPath, schema]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook returning the field-level deltas between a single profile's overrides
|
||||||
|
* and the camera's base (pre-profile) section value. Honors per-section
|
||||||
|
* `compareFields` filters and hidden-field patterns so the result matches
|
||||||
|
* what's actually exposed in the UI.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const deltas = useProfileSectionDeltas(config, "front_door", "night", "detect");
|
||||||
|
* // [{ fieldPath: "fps", globalValue: 5, cameraValue: 10, profileName: "night" }]
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useProfileSectionDeltas(
|
||||||
|
config: FrigateConfig | undefined,
|
||||||
|
cameraName: string | undefined,
|
||||||
|
profileName: string | undefined,
|
||||||
|
sectionPath: string,
|
||||||
|
): FieldDelta[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!config?.cameras || !cameraName || !profileName || !sectionPath) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const cameraConfig = config.cameras[cameraName];
|
||||||
|
if (!cameraConfig) return [];
|
||||||
|
|
||||||
|
const profileSection = (
|
||||||
|
cameraConfig.profiles?.[profileName] as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined
|
||||||
|
)?.[sectionPath];
|
||||||
|
if (profileSection == null) return [];
|
||||||
|
|
||||||
|
const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath);
|
||||||
|
const compareFields = sectionMeta?.compareFields;
|
||||||
|
|
||||||
|
const baseValue = collapseEmpty(
|
||||||
|
normalizeConfigValue(
|
||||||
|
getBaseCameraSectionValue(config, cameraName, sectionPath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const profileValue = collapseEmpty(
|
||||||
|
normalizeConfigValue(profileSection as JsonValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hiddenFields = getEffectiveHiddenFields(
|
||||||
|
sectionPath,
|
||||||
|
"camera",
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deltas: FieldDelta[] = [];
|
||||||
|
for (const path of collectDefinedLeafPaths(profileValue)) {
|
||||||
|
if (!isPathAllowed(path, compareFields)) continue;
|
||||||
|
if (
|
||||||
|
hiddenFields.some((pattern) => pathMatchesHiddenPattern(path, pattern))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const baseField = get(baseValue, path);
|
||||||
|
const profileField = get(profileValue, path);
|
||||||
|
if (!isEqual(baseField, profileField)) {
|
||||||
|
deltas.push({
|
||||||
|
fieldPath: path,
|
||||||
|
globalValue: baseField,
|
||||||
|
cameraValue: profileField,
|
||||||
|
profileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
|
}, [config, cameraName, profileName, sectionPath]);
|
||||||
|
}
|
||||||
|
|||||||
@ -44,4 +44,5 @@ export type ConfigFormContext = {
|
|||||||
requiresRestart?: boolean;
|
requiresRestart?: boolean;
|
||||||
t?: (key: string, options?: Record<string, unknown>) => string;
|
t?: (key: string, options?: Record<string, unknown>) => string;
|
||||||
renderers?: Record<string, RendererComponent>;
|
renderers?: Record<string, RendererComponent>;
|
||||||
|
isProfile?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -62,7 +62,10 @@ export type ExtraProcessStats = {
|
|||||||
mem?: string;
|
mem?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GpuVendor = "intel" | "amd" | "nvidia" | "rockchip" | "rpi";
|
||||||
|
|
||||||
export type GpuStats = {
|
export type GpuStats = {
|
||||||
|
vendor?: GpuVendor;
|
||||||
gpu: string;
|
gpu: string;
|
||||||
mem: string;
|
mem: string;
|
||||||
enc?: string;
|
enc?: string;
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import get from "lodash/get";
|
import get from "lodash/get";
|
||||||
import cloneDeep from "lodash/cloneDeep";
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
import merge from "lodash/merge";
|
|
||||||
import unset from "lodash/unset";
|
import unset from "lodash/unset";
|
||||||
import isEqual from "lodash/isEqual";
|
import isEqual from "lodash/isEqual";
|
||||||
import mergeWith from "lodash/mergeWith";
|
import mergeWith from "lodash/mergeWith";
|
||||||
@ -92,6 +91,32 @@ export function getBaseCameraSectionValue(
|
|||||||
return base !== undefined ? base : get(cam, sectionPath);
|
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. */
|
/** Sections that can appear inside a camera profile definition. */
|
||||||
export const PROFILE_ELIGIBLE_SECTIONS = new Set([
|
export const PROFILE_ELIGIBLE_SECTIONS = new Set([
|
||||||
"audio",
|
"audio",
|
||||||
@ -564,9 +589,9 @@ export function prepareSectionSavePayload(opts: {
|
|||||||
baseValue &&
|
baseValue &&
|
||||||
typeof baseValue === "object"
|
typeof baseValue === "object"
|
||||||
) {
|
) {
|
||||||
rawSectionValue = merge(
|
rawSectionValue = mergeProfileOverrides(
|
||||||
cloneDeep(baseValue),
|
baseValue as object,
|
||||||
cloneDeep(profileOverrides),
|
profileOverrides as object,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
rawSectionValue = baseValue;
|
rawSectionValue = baseValue;
|
||||||
@ -675,13 +700,12 @@ const mergeSectionConfig = (
|
|||||||
overrides: Partial<SectionConfig> | undefined,
|
overrides: Partial<SectionConfig> | undefined,
|
||||||
): SectionConfig =>
|
): SectionConfig =>
|
||||||
mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => {
|
mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => {
|
||||||
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
|
const arrayResult = replaceArraysCustomizer(objValue, srcValue);
|
||||||
return srcValue ?? objValue;
|
if (arrayResult !== undefined) return arrayResult;
|
||||||
}
|
|
||||||
|
|
||||||
if (key === "uiSchema") {
|
if (key === "uiSchema") {
|
||||||
if (objValue && srcValue) {
|
if (objValue && srcValue) {
|
||||||
return merge({}, objValue, srcValue);
|
return mergeWith({}, objValue, srcValue, replaceArraysCustomizer);
|
||||||
}
|
}
|
||||||
return srcValue ?? objValue;
|
return srcValue ?? objValue;
|
||||||
}
|
}
|
||||||
@ -739,3 +763,26 @@ export function resolveHiddenFieldEntries(
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a delta path against a hidden-field pattern. Supports literal prefixes
|
||||||
|
* (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards
|
||||||
|
* matching exactly one path segment (e.g. "filters.*.mask").
|
||||||
|
*/
|
||||||
|
export function pathMatchesHiddenPattern(
|
||||||
|
path: string,
|
||||||
|
pattern: string,
|
||||||
|
): boolean {
|
||||||
|
if (!pattern) return false;
|
||||||
|
if (!pattern.includes("*")) {
|
||||||
|
return path === pattern || path.startsWith(`${pattern}.`);
|
||||||
|
}
|
||||||
|
const patternSegments = pattern.split(".");
|
||||||
|
const pathSegments = path.split(".");
|
||||||
|
if (pathSegments.length < patternSegments.length) return false;
|
||||||
|
for (let i = 0; i < patternSegments.length; i += 1) {
|
||||||
|
if (patternSegments[i] === "*") continue;
|
||||||
|
if (patternSegments[i] !== pathSegments[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import { getTranslatedLabel } from "@/utils/i18n";
|
|||||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||||
import { AudioLevelGraph } from "@/components/audio/AudioLevelGraph";
|
import { AudioLevelGraph } from "@/components/audio/AudioLevelGraph";
|
||||||
import { useWs } from "@/api/ws";
|
import { useWs } from "@/api/ws";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ObjectSettingsViewProps = {
|
type ObjectSettingsViewProps = {
|
||||||
selectedCamera?: string;
|
selectedCamera?: string;
|
||||||
@ -200,15 +201,18 @@ export default function ObjectSettingsView({
|
|||||||
|
|
||||||
<Tabs defaultValue="debug" className="w-full">
|
<Tabs defaultValue="debug" className="w-full">
|
||||||
<TabsList
|
<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="debug">{t("debug.debugging")}</TabsTrigger>
|
||||||
<TabsTrigger value="objectlist">
|
<TabsTrigger value="objectlist">
|
||||||
{t("debug.objectList")}
|
{t("debug.objectList")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{cameraConfig.ffmpeg.inputs.some((input) =>
|
{cameraConfig.audio.enabled_in_config && (
|
||||||
input.roles.includes("audio"),
|
|
||||||
) && (
|
|
||||||
<TabsTrigger value="audio">{t("debug.audio.title")}</TabsTrigger>
|
<TabsTrigger value="audio">{t("debug.audio.title")}</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@ -325,9 +329,7 @@ export default function ObjectSettingsView({
|
|||||||
<TabsContent value="objectlist">
|
<TabsContent value="objectlist">
|
||||||
<ObjectList cameraConfig={cameraConfig} objects={memoizedObjects} />
|
<ObjectList cameraConfig={cameraConfig} objects={memoizedObjects} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{cameraConfig.ffmpeg.inputs.some((input) =>
|
{cameraConfig.audio.enabled_in_config && (
|
||||||
input.roles.includes("audio"),
|
|
||||||
) && (
|
|
||||||
<TabsContent value="audio">
|
<TabsContent value="audio">
|
||||||
<AudioList
|
<AudioList
|
||||||
cameraConfig={cameraConfig}
|
cameraConfig={cameraConfig}
|
||||||
|
|||||||
@ -3,18 +3,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { SectionConfig } from "@/components/config-form/sections";
|
import type { SectionConfig } from "@/components/config-form/sections";
|
||||||
import { ConfigSectionTemplate } from "@/components/config-form/sections";
|
import { ConfigSectionTemplate } from "@/components/config-form/sections";
|
||||||
import { CameraOverridesBadge } from "@/components/config-form/sections/CameraOverridesBadge";
|
import { CameraOverridesBadge } from "@/components/config-form/sections/CameraOverridesBadge";
|
||||||
|
import { GlobalOverridesBadge } from "@/components/config-form/sections/GlobalOverridesBadge";
|
||||||
|
import { ProfileOverridesBadge } from "@/components/config-form/sections/ProfileOverridesBadge";
|
||||||
import type { PolygonType } from "@/types/canvas";
|
import type { PolygonType } from "@/types/canvas";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import type { ConfigSectionData } from "@/types/configForm";
|
import type { ConfigSectionData } from "@/types/configForm";
|
||||||
import type { ProfileState } from "@/types/profile";
|
import type { ProfileState } from "@/types/profile";
|
||||||
import { getSectionConfig } from "@/utils/configUtil";
|
import { getSectionConfig } from "@/utils/configUtil";
|
||||||
import { getProfileColor } from "@/utils/profileColors";
|
import { getProfileColor } from "@/utils/profileColors";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { LuExternalLink } from "react-icons/lu";
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
@ -173,46 +169,25 @@ export function SingleSectionPage({
|
|||||||
)}
|
)}
|
||||||
{level === "camera" &&
|
{level === "camera" &&
|
||||||
showOverrideIndicator &&
|
showOverrideIndicator &&
|
||||||
sectionStatus.isOverridden && (
|
sectionStatus.isOverridden &&
|
||||||
<Tooltip>
|
selectedCamera &&
|
||||||
<TooltipTrigger asChild>
|
(sectionStatus.overrideSource === "profile" &&
|
||||||
<Badge
|
currentEditingProfile ? (
|
||||||
variant="secondary"
|
<ProfileOverridesBadge
|
||||||
className={cn(
|
sectionPath={sectionKey}
|
||||||
"cursor-default border-2 text-center text-xs text-primary-variant",
|
cameraName={selectedCamera}
|
||||||
sectionStatus.overrideSource === "profile" &&
|
profileName={currentEditingProfile}
|
||||||
profileColor
|
profileFriendlyName={profileState?.profileFriendlyNames.get(
|
||||||
? profileColor.border
|
currentEditingProfile,
|
||||||
: "border-selected",
|
)}
|
||||||
)}
|
profileBorderColor={profileColor?.border}
|
||||||
>
|
/>
|
||||||
{sectionStatus.overrideSource === "profile"
|
) : (
|
||||||
? t("button.overriddenBaseConfig", {
|
<GlobalOverridesBadge
|
||||||
ns: "views/settings",
|
sectionPath={sectionKey}
|
||||||
defaultValue: "Overridden (Base Config)",
|
cameraName={selectedCamera}
|
||||||
})
|
/>
|
||||||
: t("button.overriddenGlobal", {
|
))}
|
||||||
ns: "views/settings",
|
|
||||||
defaultValue: "Overridden (Global)",
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{sectionStatus.overrideSource === "profile"
|
|
||||||
? t("button.overriddenBaseConfigTooltip", {
|
|
||||||
ns: "views/settings",
|
|
||||||
profile: currentEditingProfile
|
|
||||||
? (profileState?.profileFriendlyNames.get(
|
|
||||||
currentEditingProfile,
|
|
||||||
) ?? currentEditingProfile)
|
|
||||||
: "",
|
|
||||||
})
|
|
||||||
: t("button.overriddenGlobalTooltip", {
|
|
||||||
ns: "views/settings",
|
|
||||||
})}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{sectionStatus.hasChanges && (
|
{sectionStatus.hasChanges && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@ -233,27 +208,25 @@ export function SingleSectionPage({
|
|||||||
)}
|
)}
|
||||||
{level === "camera" &&
|
{level === "camera" &&
|
||||||
showOverrideIndicator &&
|
showOverrideIndicator &&
|
||||||
sectionStatus.isOverridden && (
|
sectionStatus.isOverridden &&
|
||||||
<Badge
|
selectedCamera &&
|
||||||
variant="secondary"
|
(sectionStatus.overrideSource === "profile" &&
|
||||||
className={cn(
|
currentEditingProfile ? (
|
||||||
"cursor-default border-2 text-center text-xs text-primary-variant",
|
<ProfileOverridesBadge
|
||||||
sectionStatus.overrideSource === "profile" && profileColor
|
sectionPath={sectionKey}
|
||||||
? profileColor.border
|
cameraName={selectedCamera}
|
||||||
: "border-selected",
|
profileName={currentEditingProfile}
|
||||||
|
profileFriendlyName={profileState?.profileFriendlyNames.get(
|
||||||
|
currentEditingProfile,
|
||||||
)}
|
)}
|
||||||
>
|
profileBorderColor={profileColor?.border}
|
||||||
{sectionStatus.overrideSource === "profile"
|
/>
|
||||||
? t("button.overriddenBaseConfig", {
|
) : (
|
||||||
ns: "views/settings",
|
<GlobalOverridesBadge
|
||||||
defaultValue: "Overridden (Base Config)",
|
sectionPath={sectionKey}
|
||||||
})
|
cameraName={selectedCamera}
|
||||||
: t("button.overriddenGlobal", {
|
/>
|
||||||
ns: "views/settings",
|
))}
|
||||||
defaultValue: "Overridden (Global)",
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{sectionStatus.hasChanges && (
|
{sectionStatus.hasChanges && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import useSWR from "swr";
|
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 { startTransition, useEffect, useMemo, useState } from "react";
|
||||||
import { useFrigateStats } from "@/api/ws";
|
import { useFrigateStats } from "@/api/ws";
|
||||||
import {
|
import {
|
||||||
@ -98,13 +98,11 @@ export default function GeneralMetrics({
|
|||||||
let nvCount = 0;
|
let nvCount = 0;
|
||||||
|
|
||||||
statsHistory.length > 0 &&
|
statsHistory.length > 0 &&
|
||||||
Object.keys(statsHistory[0]?.gpu_usages ?? {}).forEach((key) => {
|
Object.values(statsHistory[0]?.gpu_usages ?? {}).forEach((stats) => {
|
||||||
if (key == "amd-vaapi" || key == "intel-gpu") {
|
if (stats.vendor === "nvidia") {
|
||||||
vaCount += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.includes("NVIDIA")) {
|
|
||||||
nvCount += 1;
|
nvCount += 1;
|
||||||
|
} else if (stats.vendor === "intel" || stats.vendor === "amd") {
|
||||||
|
vaCount += 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -288,11 +286,15 @@ export default function GeneralMetrics({
|
|||||||
return [];
|
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 (
|
if (
|
||||||
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {}).length == 1 &&
|
firstEntries.length > 0 &&
|
||||||
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {})[0] === "intel-gpu"
|
firstEntries.every((s) => s.vendor === "intel")
|
||||||
) {
|
) {
|
||||||
// intel gpu stats do not support memory
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,6 +309,10 @@ export default function GeneralMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(stats.gpu_usages || {}).forEach(([key, stats]) => {
|
Object.entries(stats.gpu_usages || {}).forEach(([key, stats]) => {
|
||||||
|
if (stats.vendor === "intel") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!(key in series)) {
|
if (!(key in series)) {
|
||||||
series[key] = { name: key, data: [] };
|
series[key] = { name: key, data: [] };
|
||||||
}
|
}
|
||||||
@ -470,8 +476,9 @@ export default function GeneralMetrics({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gpuKeys = Object.keys(statsHistory[0]?.gpu_usages ?? {});
|
const hasIntelGpu = Object.values(statsHistory[0]?.gpu_usages ?? {}).some(
|
||||||
const hasIntelGpu = gpuKeys.some((key) => key === "intel-gpu");
|
(stats) => stats.vendor === "intel",
|
||||||
|
);
|
||||||
|
|
||||||
if (!hasIntelGpu) {
|
if (!hasIntelGpu) {
|
||||||
return false;
|
return false;
|
||||||
@ -486,14 +493,15 @@ export default function GeneralMetrics({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(stats.gpu_usages || {}).forEach(([key, gpuStats]) => {
|
Object.values(stats.gpu_usages || {}).forEach((gpuStats) => {
|
||||||
if (key === "intel-gpu") {
|
if (gpuStats.vendor !== "intel") {
|
||||||
if (gpuStats.gpu) {
|
return;
|
||||||
hasDataPoints = true;
|
}
|
||||||
const gpuValue = parseFloat(gpuStats.gpu.slice(0, -1));
|
if (gpuStats.gpu) {
|
||||||
if (!isNaN(gpuValue) && gpuValue > 0) {
|
hasDataPoints = true;
|
||||||
allZero = false;
|
const gpuValue = parseFloat(gpuStats.gpu.slice(0, -1));
|
||||||
}
|
if (!isNaN(gpuValue) && gpuValue > 0) {
|
||||||
|
allZero = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user